feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s
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.
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
|
||||
file(GLOB APP_SRC "./echoear_app/*.c")
|
||||
|
||||
set(COMPONENT_SRCS "conv_ai_embedded_kit.c configuration_ap.c network.c platform_client.c" )
|
||||
set(COMPONENT_SRCS "conv_ai_embedded_kit.c configuration_ap.c network.c platform_client.c call_client.c ble_setup.c ble_cloud.c ble_wifi.c device_control.c device_ota.c device_power.c device_screen_wake.c device_reset.c" )
|
||||
|
||||
if (CONFIG_PRINT_TASK_INFO)
|
||||
add_definitions(-DPRINT_TASK_INFO)
|
||||
@@ -12,7 +12,7 @@ endif()
|
||||
set(COMPONENT_ADD_INCLUDEDIRS .)
|
||||
set(COMPONENT_EMBED_TXTFILES assets/wifi_configuration_ap.html)
|
||||
|
||||
set(COMPONENT_REQUIRES esp_http_client esp_http_server esp_wifi esp-tls mbedtls nvs_flash json espressif__gmf_ai_audio espressif__esp_board_manager espressif__esp_lcd_touch)
|
||||
set(COMPONENT_REQUIRES av_processor app_update mqtt esp_http_client esp_http_server esp_wifi esp-tls mbedtls nvs_flash json bt protocomm esp_timer espressif__gmf_ai_audio espressif__esp_board_manager espressif__esp_lcd_touch)
|
||||
|
||||
register_component()
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
menu "Example Configuration"
|
||||
|
||||
choice AI_AGENT_MODE
|
||||
prompt "Volcano RTC/WS mode"
|
||||
default VOLC_WS_MODE
|
||||
|
||||
config VOLC_WS_MODE
|
||||
bool "enable volcano ws mode"
|
||||
|
||||
endchoice
|
||||
default y
|
||||
config VOLC_RTC_MODE
|
||||
bool "enable volcano RTC mode"
|
||||
default y
|
||||
|
||||
menu "Develop Configuration"
|
||||
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
#include "ble_cloud.h"
|
||||
#include "call_client.h"
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/time.h>
|
||||
#include "cJSON.h"
|
||||
#include "esp_crt_bundle.h"
|
||||
#include "esp_random.h"
|
||||
#include "esp_timer.h"
|
||||
#include "mbedtls/base64.h"
|
||||
#include "mbedtls/md.h"
|
||||
#include "mbedtls/sha256.h"
|
||||
#include "mbedtls/platform_util.h"
|
||||
#include "nvs.h"
|
||||
#include "nvs_flash.h"
|
||||
|
||||
static char factory_id[18], model[33], origin[160];
|
||||
static uint8_t device_key[32];
|
||||
static uint32_t key_version;
|
||||
static bool configured;
|
||||
static char voice_boot[37], time_challenge[37];
|
||||
const char *ble_cloud_origin(void) { return origin; }
|
||||
const char *ble_cloud_boot_id(void) { if (!voice_boot[0]) ble_random_uuid(voice_boot); return voice_boot; }
|
||||
const char *ble_cloud_challenge_id(void) { return time_challenge; }
|
||||
typedef struct { char data[4097]; size_t len; } response_t;
|
||||
|
||||
static esp_err_t response_event(esp_http_client_event_t *event)
|
||||
{
|
||||
response_t *r = event->user_data;
|
||||
if (event->event_id == HTTP_EVENT_ON_DATA && event->data_len > 0) {
|
||||
if (r->len + event->data_len >= sizeof(r->data)) return ESP_ERR_INVALID_SIZE;
|
||||
memcpy(r->data + r->len, event->data, event->data_len);
|
||||
r->len += event->data_len;
|
||||
r->data[r->len] = 0;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
static const char *string(cJSON *obj, const char *key)
|
||||
{
|
||||
cJSON *v = cJSON_GetObjectItemCaseSensitive(obj, key);
|
||||
return cJSON_IsString(v) ? v->valuestring : "";
|
||||
}
|
||||
static esp_err_t getstr(nvs_handle_t nvs, const char *key, char *out, size_t len)
|
||||
{ return nvs_get_str(nvs, key, out, &len); }
|
||||
esp_err_t ble_cloud_init(void)
|
||||
{
|
||||
if (configured) return ESP_OK;
|
||||
esp_err_t err = nvs_flash_init_partition("ble_factory");
|
||||
if (err != ESP_OK) return err; // Never erase factory credentials on an init error.
|
||||
nvs_handle_t nvs;
|
||||
err = nvs_open_from_partition("ble_factory", "identity", NVS_READONLY, &nvs);
|
||||
if (err != ESP_OK) return err;
|
||||
size_t len = sizeof(device_key);
|
||||
err = getstr(nvs,"factory_id",factory_id,sizeof(factory_id));
|
||||
if (err == ESP_OK) err = getstr(nvs,"model",model,sizeof(model));
|
||||
if (err == ESP_OK) err = getstr(nvs,"origin",origin,sizeof(origin));
|
||||
if (err == ESP_OK) err = nvs_get_blob(nvs,"device_key",device_key,&len);
|
||||
if (err == ESP_OK && len != 32) err = ESP_ERR_INVALID_SIZE;
|
||||
if (err == ESP_OK) err = nvs_get_u32(nvs,"key_version",&key_version);
|
||||
nvs_close(nvs);
|
||||
if (err != ESP_OK || strlen(factory_id) != 17 || strncmp(factory_id,"KP01-",5) ||
|
||||
strspn(factory_id+5,"0123456789ABCDEF") != 12 || !model[0] || !key_version ||
|
||||
strncmp(origin,"https://",8) || strchr(origin+8,'/') || strchr(origin,'@') ||
|
||||
strpbrk(origin,"\r\n?#")) { mbedtls_platform_zeroize(device_key,32); return ESP_ERR_INVALID_ARG; }
|
||||
// Move this project's old server address without rewriting factory keys.
|
||||
if (!strcmp(origin,"https://121.41.46.238"))
|
||||
snprintf(origin,sizeof(origin),"https://82.157.201.114");
|
||||
configured = true;
|
||||
return ESP_OK;
|
||||
}
|
||||
bool ble_cloud_configured(void) { return configured; }
|
||||
const char *ble_factory_id(void) { return factory_id; }
|
||||
const char *ble_factory_model(void) { return model; }
|
||||
esp_err_t ble_factory_security(uint8_t salt[16], uint8_t verifier[384])
|
||||
{
|
||||
nvs_handle_t nvs;
|
||||
esp_err_t err = nvs_open_from_partition("ble_factory","identity",NVS_READONLY,&nvs);
|
||||
if (err != ESP_OK) return err;
|
||||
size_t len = 16;
|
||||
err = nvs_get_blob(nvs,"salt",salt,&len);
|
||||
if (err == ESP_OK && len != 16) err = ESP_ERR_INVALID_SIZE;
|
||||
len = 384;
|
||||
if (err == ESP_OK) err = nvs_get_blob(nvs,"verifier",verifier,&len);
|
||||
if (err == ESP_OK && len != 384) err = ESP_ERR_INVALID_SIZE;
|
||||
nvs_close(nvs);
|
||||
return err;
|
||||
}
|
||||
void ble_random_uuid(char out[37])
|
||||
{
|
||||
uint8_t b[16]; esp_fill_random(b,16); b[6]=(b[6]&15)|64; b[8]=(b[8]&63)|128;
|
||||
snprintf(out,37,"%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",b[0],b[1],b[2],b[3],b[4],b[5],b[6],b[7],b[8],b[9],b[10],b[11],b[12],b[13],b[14],b[15]);
|
||||
}
|
||||
static void hex(const uint8_t *in, size_t n, char *out)
|
||||
{ for(size_t i=0;i<n;i++) snprintf(out+i*2,3,"%02x",in[i]); }
|
||||
static void mac(const char *input, char out[65])
|
||||
{
|
||||
uint8_t h[32];
|
||||
mbedtls_md_hmac(mbedtls_md_info_from_type(MBEDTLS_MD_SHA256),device_key,32,(const uint8_t*)input,strlen(input),h);
|
||||
hex(h,32,out); mbedtls_platform_zeroize(h,32);
|
||||
}
|
||||
static void nonce(char out[23])
|
||||
{
|
||||
uint8_t b[16], encoded[25]; size_t len;
|
||||
esp_fill_random(b,16); mbedtls_base64_encode(encoded,sizeof(encoded),&len,b,16);
|
||||
for(size_t i=0;i<22;i++) out[i]=encoded[i]=='+'?'-':encoded[i]=='/'?'_':encoded[i];
|
||||
out[22]=0;
|
||||
}
|
||||
esp_err_t ble_cloud_bootstrap(const char *client_nonce, char **response, char password[65])
|
||||
{
|
||||
if(!configured||!client_nonce||strlen(client_nonce)!=22||strspn(client_nonce,"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_")!=22)return ESP_ERR_INVALID_ARG;
|
||||
char server_nonce[23],transcript[256],proof[65];nonce(server_nonce);
|
||||
snprintf(transcript,sizeof(transcript),"BLE-PROOF1\n%s\n%s\n%lu\n%s\n%s",factory_id,model,(unsigned long)key_version,client_nonce,server_nonce);
|
||||
mac(transcript,proof);
|
||||
snprintf(transcript,sizeof(transcript),"BLE-PASS1\n%s\n%s\n%lu\n%s\n%s",factory_id,model,(unsigned long)key_version,client_nonce,server_nonce);
|
||||
mac(transcript,password);
|
||||
cJSON *out=cJSON_CreateObject();if(!out)return ESP_ERR_NO_MEM;
|
||||
cJSON_AddStringToObject(out,"device_id",factory_id);cJSON_AddStringToObject(out,"model",model);
|
||||
cJSON_AddNumberToObject(out,"key_version",key_version);
|
||||
cJSON_AddStringToObject(out,"client_nonce",client_nonce);cJSON_AddStringToObject(out,"server_nonce",server_nonce);cJSON_AddStringToObject(out,"proof",proof);
|
||||
*response=cJSON_PrintUnformatted(out);cJSON_Delete(out);
|
||||
mbedtls_platform_zeroize(transcript,sizeof(transcript));
|
||||
return *response?ESP_OK:ESP_ERR_NO_MEM;
|
||||
}
|
||||
esp_err_t ble_cloud_headers(esp_http_client_handle_t client,const char *method,const char *path,const char *body,const char *request_id)
|
||||
{
|
||||
if (!configured) return ESP_ERR_INVALID_STATE;
|
||||
char timestamp[24], version[16], random[23], digest[65], signature[65], auth[86], canonical[768];
|
||||
struct timeval tv; gettimeofday(&tv,NULL);
|
||||
snprintf(timestamp,sizeof(timestamp),"%lld",(long long)tv.tv_sec*1000+tv.tv_usec/1000);
|
||||
snprintf(version,sizeof(version),"%lu",(unsigned long)key_version);
|
||||
nonce(random); uint8_t hash[32]; mbedtls_sha256((const uint8_t*)body,strlen(body),hash,0); hex(hash,32,digest);
|
||||
int n=snprintf(canonical,sizeof(canonical),"DEVREQ1\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s",method,path,factory_id,version,timestamp,random,request_id,digest);
|
||||
if(n<0 || n>=sizeof(canonical)) return ESP_ERR_INVALID_SIZE;
|
||||
mac(canonical,signature); snprintf(auth,sizeof(auth),"Device-HMAC-SHA256 %s",signature);
|
||||
esp_err_t err=esp_http_client_set_header(client,"X-Device-ID",factory_id);
|
||||
if(err==ESP_OK) err=esp_http_client_set_header(client,"X-Key-Version",version);
|
||||
if(err==ESP_OK) err=esp_http_client_set_header(client,"X-Timestamp-Ms",timestamp);
|
||||
if(err==ESP_OK) err=esp_http_client_set_header(client,"X-Nonce",random);
|
||||
if(err==ESP_OK) err=esp_http_client_set_header(client,"X-Request-ID",request_id);
|
||||
if(err==ESP_OK) err=esp_http_client_set_header(client,"Authorization",auth);
|
||||
return err;
|
||||
}
|
||||
static esp_err_t post(const char *path,const char *body,const char *request_id,response_t *out,int *status,bool signed_request)
|
||||
{
|
||||
char url[256];
|
||||
// TIME1 is the sole HTTP endpoint: its response is bound to a fresh nonce
|
||||
// and authenticated with the factory key. Every credential-bearing call is HTTPS.
|
||||
if(!signed_request && !strcmp(path,"/api/v1/device/time-challenges"))snprintf(url,sizeof(url),"http://%s%s",origin+8,path);
|
||||
else snprintf(url,sizeof(url),"%s%s",origin,path);
|
||||
esp_http_client_config_t cfg={.url=url,.timeout_ms=!strcmp(path,"/api/v1/device/ble-bind")?45000:15000,.crt_bundle_attach=esp_crt_bundle_attach,
|
||||
.event_handler=response_event,.user_data=out,.disable_auto_redirect=true,.buffer_size=1024,.buffer_size_tx=1536};
|
||||
esp_http_client_handle_t client=esp_http_client_init(&cfg);
|
||||
if(!client) return ESP_ERR_NO_MEM;
|
||||
esp_http_client_set_method(client,HTTP_METHOD_POST);
|
||||
esp_http_client_set_header(client,"Content-Type","application/json");
|
||||
esp_http_client_set_post_field(client,body,strlen(body));
|
||||
esp_err_t err=signed_request?ble_cloud_headers(client,"POST",path,body,request_id):ESP_OK;
|
||||
if(err==ESP_OK) err=esp_http_client_perform(client);
|
||||
*status=esp_http_client_get_status_code(client); esp_http_client_cleanup(client);
|
||||
return err;
|
||||
}
|
||||
esp_err_t ble_cloud_sync_time(void)
|
||||
{
|
||||
const char *boot=ble_cloud_boot_id(); char random[23], body[256]; nonce(random);
|
||||
snprintf(body,sizeof(body),"{\"device_id\":\"%s\",\"key_version\":%lu,\"boot_id\":\"%s\",\"client_nonce\":\"%s\"}",factory_id,(unsigned long)key_version,boot,random);
|
||||
response_t *r=calloc(1,sizeof(*r)); if(!r) return ESP_ERR_NO_MEM;
|
||||
int status=0; int64_t start=esp_timer_get_time();
|
||||
esp_err_t err=post("/api/v1/device/time-challenges",body,NULL,r,&status,false);
|
||||
if(err!=ESP_OK || status!=200){free(r);return ESP_FAIL;}
|
||||
cJSON *root=cJSON_Parse(r->data);
|
||||
const char *payload=string(root,"payload_b64"), *signature=string(root,"mac_hex");
|
||||
char expected[65], canonical[3600];
|
||||
if(strlen(payload)>3400 || strlen(signature)!=64){cJSON_Delete(root);free(r);return ESP_FAIL;}
|
||||
snprintf(canonical,sizeof(canonical),"TIME1\n%s",payload); mac(canonical,expected);
|
||||
unsigned mismatch=0; for(int i=0;i<64;i++) mismatch|=expected[i]^signature[i];
|
||||
uint8_t decoded[2600]; char encoded[3500]; size_t len=strlen(payload),decoded_len=0;
|
||||
memcpy(encoded,payload,len);
|
||||
for(size_t i=0;i<len;i++){if(encoded[i]=='-')encoded[i]='+';else if(encoded[i]=='_')encoded[i]='/';}
|
||||
while(len%4) encoded[len++]='=';
|
||||
if(mismatch || mbedtls_base64_decode(decoded,sizeof(decoded)-1,&decoded_len,(uint8_t*)encoded,len)!=0){cJSON_Delete(root);free(r);return ESP_FAIL;}
|
||||
decoded[decoded_len]=0; cJSON *p=cJSON_Parse((char*)decoded); cJSON *time=cJSON_GetObjectItemCaseSensitive(p,"server_time_ms");
|
||||
bool valid=!strcmp(string(p,"device_id"),factory_id)&&!strcmp(string(p,"boot_id"),boot)&&!strcmp(string(p,"client_nonce"),random)&&cJSON_IsNumber(time)&&time->valuedouble>1700000000000.0&&esp_timer_get_time()-start<15000000;
|
||||
if(valid){int64_t ms=(int64_t)time->valuedouble;struct timeval tv={.tv_sec=ms/1000,.tv_usec=(ms%1000)*1000};settimeofday(&tv,NULL);snprintf(time_challenge,sizeof(time_challenge),"%s",string(p,"challenge_id"));}
|
||||
cJSON_Delete(p);cJSON_Delete(root);free(r);return valid?ESP_OK:ESP_FAIL;
|
||||
}
|
||||
esp_err_t ble_cloud_bind(const char *token,const char *request_id)
|
||||
{
|
||||
if(!configured) return ESP_ERR_INVALID_STATE;
|
||||
if(ble_cloud_sync_time()!=ESP_OK) return ESP_FAIL;
|
||||
char body[80];snprintf(body,sizeof(body),"{\"bind_token\":\"%s\"}",token);
|
||||
response_t *r=calloc(1,sizeof(*r));if(!r)return ESP_ERR_NO_MEM;
|
||||
int status=0;esp_err_t err=post("/api/v1/device/ble-bind",body,request_id,r,&status,true);
|
||||
if(err==ESP_OK){
|
||||
cJSON *root=cJSON_Parse(r->data),*data=cJSON_GetObjectItemCaseSensitive(root,"data");
|
||||
if(status==200&&cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(root,"ok"))&&!strcmp(string(data,"status"),"success")) {
|
||||
err=call_client_save_bind_voice(data);
|
||||
// Incomplete responses/storage failures retain the same pending
|
||||
// token for retry; they do not mean the account binding was denied.
|
||||
if(err!=ESP_OK)err=ESP_FAIL;
|
||||
}
|
||||
else if(status>=400&&status<500&&status!=408&&status!=429) err=ESP_ERR_INVALID_RESPONSE;
|
||||
else err=ESP_FAIL;
|
||||
cJSON *secret=cJSON_GetObjectItemCaseSensitive(data,"device_secret");
|
||||
if(cJSON_IsString(secret))mbedtls_platform_zeroize(secret->valuestring,strlen(secret->valuestring));
|
||||
cJSON_Delete(root);
|
||||
}
|
||||
mbedtls_platform_zeroize(r,sizeof(*r));free(r);return err;
|
||||
}
|
||||
esp_err_t ble_cloud_check_bound(const char *request_id)
|
||||
{
|
||||
if(!configured)return ESP_ERR_INVALID_STATE;
|
||||
if(ble_cloud_sync_time()!=ESP_OK)return ESP_FAIL;
|
||||
response_t *r=calloc(1,sizeof(*r));if(!r)return ESP_ERR_NO_MEM;
|
||||
int status=0;esp_err_t err=post("/api/v1/device/ble-state","{}",request_id,r,&status,true);
|
||||
if(err==ESP_OK){
|
||||
cJSON *root=cJSON_Parse(r->data),*data=cJSON_GetObjectItemCaseSensitive(root,"data"),*bound=cJSON_GetObjectItemCaseSensitive(data,"bound");
|
||||
if(status==200&&cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(root,"ok"))&&cJSON_IsBool(bound))err=cJSON_IsTrue(bound)?ESP_OK:ESP_ERR_INVALID_RESPONSE;
|
||||
else if(status>=400&&status<500&&status!=408&&status!=429)err=ESP_ERR_INVALID_RESPONSE;
|
||||
else err=ESP_FAIL;
|
||||
cJSON_Delete(root);
|
||||
}
|
||||
mbedtls_platform_zeroize(r,sizeof(*r));free(r);return err;
|
||||
}
|
||||
|
||||
char *ble_cloud_decode(const char *domain,const char *encoded,const char *signature)
|
||||
{
|
||||
if(!configured || !domain || !encoded || !signature || strlen(signature)!=64 || strlen(encoded)>32768 ||
|
||||
strspn(encoded,"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_")!=strlen(encoded))return NULL;
|
||||
size_t n=strlen(encoded),d=strlen(domain),out_len=0;
|
||||
char *canonical=malloc(n+d+2),*padded=calloc(1,n+5),*out=calloc(1,n+1);
|
||||
if(!canonical||!padded||!out){free(canonical);free(padded);free(out);return NULL;}
|
||||
snprintf(canonical,n+d+2,"%s\n%s",domain,encoded);
|
||||
char expected[65];mac(canonical,expected);unsigned mismatch=0;
|
||||
for(int i=0;i<64;i++)mismatch|=expected[i]^signature[i];
|
||||
memcpy(padded,encoded,n);for(size_t i=0;i<n;i++){if(padded[i]=='-')padded[i]='+';else if(padded[i]=='_')padded[i]='/';}
|
||||
size_t len=n;while(len%4)padded[len++]='=';
|
||||
bool ok=!mismatch&&mbedtls_base64_decode((uint8_t*)out,n,&out_len,(uint8_t*)padded,len)==0;
|
||||
free(canonical);free(padded);if(!ok){free(out);return NULL;}out[out_len]=0;return out;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
#include "esp_err.h"
|
||||
#include "esp_http_client.h"
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
// Factory credentials occupy a separate partition; existing Wi-Fi/voice NVS stays intact.
|
||||
esp_err_t ble_cloud_init(void);
|
||||
const char *ble_factory_id(void);
|
||||
const char *ble_factory_model(void);
|
||||
const char *ble_cloud_origin(void);
|
||||
const char *ble_cloud_boot_id(void);
|
||||
const char *ble_cloud_challenge_id(void);
|
||||
esp_err_t ble_cloud_sync_time(void);
|
||||
// Authenticates original base64url bytes before decoding, without exporting the key.
|
||||
char *ble_cloud_decode(const char *domain, const char *encoded, const char *signature);
|
||||
esp_err_t ble_factory_security(uint8_t salt[16], uint8_t verifier[384]);
|
||||
// Fresh BLE-link bootstrap. Password is returned only to the local SRP worker.
|
||||
esp_err_t ble_cloud_bootstrap(const char *client_nonce, char **response, char password[65]);
|
||||
void ble_random_uuid(char out[37]);
|
||||
bool ble_cloud_configured(void);
|
||||
esp_err_t ble_cloud_headers(esp_http_client_handle_t client, const char *method,
|
||||
const char *path, const char *body, const char *request_id);
|
||||
// ESP_OK = committed, ESP_ERR_INVALID_RESPONSE = definite refusal; other errors are uncertain.
|
||||
esp_err_t ble_cloud_bind(const char *token, const char *request_id);
|
||||
// Wi-Fi-only provisioning verifies an existing binding without changing it.
|
||||
esp_err_t ble_cloud_check_bound(const char *request_id);
|
||||
@@ -0,0 +1,25 @@
|
||||
#ifndef VOICELINK_BLE_NAME_H
|
||||
#define VOICELINK_BLE_NAME_H
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
// Legacy scan response: 31 bytes minus AD length/type = 29 name bytes.
|
||||
// "vl-" (3) + model (<=17 UTF-8 bytes) + "-" (1) + ID (8).
|
||||
#define VOICELINK_BLE_NAME_CAPACITY 30
|
||||
static inline void voicelink_ble_name(char out[VOICELINK_BLE_NAME_CAPACITY],
|
||||
const char *model, const char *factory_id)
|
||||
{
|
||||
size_t model_len = strlen(model);
|
||||
if (model_len > 17) {
|
||||
model_len = 17;
|
||||
// Do not truncate inside a UTF-8 code point.
|
||||
while (model_len && ((unsigned char)model[model_len] & 0xc0) == 0x80)
|
||||
model_len--;
|
||||
}
|
||||
size_t id_len = strlen(factory_id);
|
||||
const char *suffix = id_len >= 8 ? factory_id + id_len - 8 : factory_id;
|
||||
snprintf(out, VOICELINK_BLE_NAME_CAPACITY, "vl-%.*s-%s",
|
||||
(int)model_len, model, suffix);
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,388 @@
|
||||
#include "ble_setup.h"
|
||||
#include "ble_cloud.h"
|
||||
#include "ble_name.h"
|
||||
#include "ble_wifi.h"
|
||||
#include "device_reset.h"
|
||||
#include <stdatomic.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include "cJSON.h"
|
||||
#include "esp_event.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_netif.h"
|
||||
#include "esp_system.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/event_groups.h"
|
||||
#include "freertos/queue.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "freertos/task.h"
|
||||
#include "host/ble_hs.h"
|
||||
#include "host/util/util.h"
|
||||
#include "nimble/nimble_port.h"
|
||||
#include "nimble/nimble_port_freertos.h"
|
||||
#include "services/gap/ble_svc_gap.h"
|
||||
#include "services/gatt/ble_svc_gatt.h"
|
||||
#include "mbedtls/platform_util.h"
|
||||
#include "nvs.h"
|
||||
#include "volc_hal.h"
|
||||
|
||||
#define MAX_FRAME 2050
|
||||
#define NO_CONN BLE_HS_CONN_HANDLE_NONE
|
||||
static const char *TAG="ble_setup";
|
||||
// BLE UUID byte arrays are little endian.
|
||||
#define BLE_UUID(last) BLE_UUID128_INIT(0x00,0x61,0x5e,0x4f,0x82,0x7d,0x31,0x9c,0x2d,0x4a,0x4b,0x6f,last,0x20,0x7a,0x8e)
|
||||
static const ble_uuid128_t service_uuid=BLE_UUID(0),rx_uuid=BLE_UUID(1),tx_uuid=BLE_UUID(2);
|
||||
static uint16_t tx_handle;
|
||||
static uint8_t own_address_type;
|
||||
static atomic_uint connection=NO_CONN, epoch;
|
||||
static atomic_bool subscribed, enabled;
|
||||
static atomic_bool window_closed;
|
||||
static int64_t window_end, fragment_start, fragment_last;
|
||||
static uint8_t rx_buffer[MAX_FRAME];
|
||||
static size_t rx_len;
|
||||
static struct ble_npl_callout watchdog;
|
||||
typedef struct { unsigned generation; uint16_t conn; uint8_t kind; size_t len; int64_t started; uint8_t data[MAX_FRAME]; } record_t;
|
||||
typedef struct { unsigned generation; int status; } ack_t;
|
||||
static QueueHandle_t records,acks;
|
||||
static SemaphoreHandle_t state_mutex;
|
||||
static TaskHandle_t binding_task;
|
||||
typedef struct { uint32_t version; char ssid[33],password[64],token[44],press[37]; } job_t;
|
||||
static job_t job;
|
||||
typedef enum { IDLE,RECEIVED,WIFI_CONNECTING,BINDING,SUCCESS,FAILED } state_t;
|
||||
static state_t state;
|
||||
static const char *state_names[]={"idle","received","wifi_connecting","binding","success","failed"};
|
||||
static const char *failure_message="";
|
||||
static int64_t confirm_until;
|
||||
static bool recover_saved_wifi, timeout_handled;
|
||||
|
||||
static void show(const char *text)
|
||||
{
|
||||
volc_hal_context_t *ctx=volc_get_global_hal_context();
|
||||
if(ctx&&ctx->display_handle) volc_hal_display_set_content(ctx->display_handle,VOLC_DISPLAY_OBJ_SUBTITLE,VOLC_DISPLAY_TEXT,(void*)text);
|
||||
}
|
||||
static void set_state(state_t value,const char *message)
|
||||
{
|
||||
xSemaphoreTake(state_mutex,portMAX_DELAY);state=value;failure_message=message?message:"";xSemaphoreGive(state_mutex);
|
||||
const char *display[]={"Bluetooth setup","Tap to confirm WiFi","Connecting WiFi","Checking account","WiFi setup saved","Setup failed; retry"};
|
||||
const char *reset_display[]={"Bluetooth setup","Tap to ERASE settings and WiFi","Connecting WiFi","Authorizing factory reset","Settings erased; restarting","Reset failed; retry"};
|
||||
show(job.version==3?reset_display[value]:display[value]); ESP_LOGI(TAG,"state=%s",state_names[value]);
|
||||
}
|
||||
static esp_err_t pending_save(bool keep)
|
||||
{
|
||||
nvs_handle_t nvs;esp_err_t err=nvs_open("ble_setup",NVS_READWRITE,&nvs);if(err!=ESP_OK)return err;
|
||||
err=keep?nvs_set_blob(nvs,"pending",&job,sizeof(job)):nvs_erase_key(nvs,"pending");
|
||||
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 complete_save(void)
|
||||
{
|
||||
nvs_handle_t nvs;esp_err_t err=nvs_open("ble_setup",NVS_READWRITE,&nvs);if(err!=ESP_OK)return err;
|
||||
err=nvs_set_u8(nvs,"completed",1);
|
||||
if(err==ESP_OK)err=nvs_set_u8(nvs,"protocol",2);
|
||||
if(err==ESP_OK)err=nvs_erase_key(nvs,"pending");
|
||||
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 save_wifi(void)
|
||||
{
|
||||
nvs_handle_t nvs;esp_err_t err=nvs_open("wifi",NVS_READWRITE,&nvs);if(err!=ESP_OK)return err;
|
||||
err=nvs_set_str(nvs,"ssid",job.ssid);
|
||||
if(err==ESP_OK)err=nvs_set_str(nvs,"password",job.password);
|
||||
if(err==ESP_OK)err=nvs_set_u8(nvs,"setup_requested",0);
|
||||
if(err==ESP_OK)err=nvs_commit(nvs);
|
||||
nvs_close(nvs);return err;
|
||||
}
|
||||
static void binding_worker(void *arg)
|
||||
{
|
||||
bool resume=arg!=NULL;
|
||||
for(;;){
|
||||
if(!resume)ulTaskNotifyTake(pdTRUE,portMAX_DELAY);
|
||||
resume=false;
|
||||
// The confirmation event and all retry inputs reach flash before HTTP.
|
||||
if(pending_save(true)!=ESP_OK){set_state(FAILED,"设备保存失败,请重试");continue;}
|
||||
set_state(WIFI_CONNECTING,NULL);
|
||||
esp_err_t wifi_err=ble_wifi_connect(job.ssid,job.password);
|
||||
if(wifi_err!=ESP_OK){
|
||||
ESP_LOGE(TAG,"Wi-Fi setup failed: %s",esp_err_to_name(wifi_err));
|
||||
const char *message=wifi_err==ESP_ERR_NO_MEM?"设备内存不足,未能启动 Wi-Fi,请重试":
|
||||
wifi_err==ESP_ERR_TIMEOUT?"Wi-Fi 连接失败,请检查 2.4 GHz 网络和密码":"设备 Wi-Fi 初始化失败,请重试";
|
||||
if(pending_save(false)==ESP_OK)set_state(FAILED,message);
|
||||
else set_state(BINDING,"设备保存失败,请重新启动恢复任务");
|
||||
continue;
|
||||
}
|
||||
set_state(BINDING,NULL);
|
||||
for(;;){
|
||||
if(!ble_wifi_connected()&&ble_wifi_connect(job.ssid,job.password)!=ESP_OK){
|
||||
vTaskDelay(pdMS_TO_TICKS(10000));continue;
|
||||
}
|
||||
bool resetting=job.version==3;
|
||||
esp_err_t err=resetting?device_reset_from_setup(job.press):job.token[0]?ble_cloud_bind(job.token,job.press):ble_cloud_check_bound(job.press);
|
||||
if(err==ESP_OK){
|
||||
// Retain the pending record until both commits complete; a power
|
||||
// cut here replays the same token, never creates another binding.
|
||||
if(!resetting&&(save_wifi()!=ESP_OK||complete_save()!=ESP_OK)){vTaskDelay(pdMS_TO_TICKS(5000));continue;}
|
||||
set_state(SUCCESS,NULL);vTaskDelay(pdMS_TO_TICKS(15000));esp_restart();
|
||||
}
|
||||
if(err==ESP_ERR_INVALID_RESPONSE){
|
||||
if(pending_save(false)==ESP_OK)set_state(FAILED,resetting?"重置未完成,请先在原账号解绑;通话或升级结束后再试":job.token[0]?"平台拒绝绑定,请查询账号归属或重新申请":"设备已解绑,请重新搜索并绑定账号");
|
||||
break;
|
||||
}
|
||||
// Transport/5xx/429 uncertainty is not a failed binding. The same
|
||||
// token and persisted press ID are retried with a fresh signed nonce.
|
||||
vTaskDelay(pdMS_TO_TICKS(10000));
|
||||
}
|
||||
}
|
||||
}
|
||||
bool ble_setup_confirm(void)
|
||||
{
|
||||
if(!atomic_load(&enabled))return false;
|
||||
xSemaphoreTake(state_mutex,portMAX_DELAY);
|
||||
if(state==RECEIVED&&esp_timer_get_time()>=confirm_until){
|
||||
state=FAILED;failure_message="确认超时,请重新发送配网信息";
|
||||
}
|
||||
if(esp_timer_get_time()>=window_end&&(state==IDLE||state==FAILED)){
|
||||
xSemaphoreGive(state_mutex);return false;
|
||||
}
|
||||
bool accept=state==RECEIVED&&esp_timer_get_time()<confirm_until;
|
||||
if(accept){ble_random_uuid(job.press);state=WIFI_CONNECTING;}
|
||||
xSemaphoreGive(state_mutex);
|
||||
if(accept)xTaskNotifyGive(binding_task);
|
||||
return true;
|
||||
}
|
||||
static void recover_wifi_after_timeout(void)
|
||||
{
|
||||
if(!atomic_load(&enabled)||timeout_handled||esp_timer_get_time()<window_end)return;
|
||||
xSemaphoreTake(state_mutex,portMAX_DELAY);
|
||||
if(state==RECEIVED&&esp_timer_get_time()>=confirm_until){
|
||||
state=FAILED;failure_message="确认超时,请重新发送配网信息";
|
||||
}
|
||||
bool idle=state==IDLE||state==FAILED;
|
||||
xSemaphoreGive(state_mutex);
|
||||
if(!idle)return; // A confirmed/persisted job continues even after BLE closes.
|
||||
timeout_handled=true;
|
||||
if(!recover_saved_wifi)return; // First-time setup has no known-good Wi-Fi.
|
||||
nvs_handle_t nvs;size_t pending_len=0;
|
||||
if(nvs_open("ble_setup",NVS_READONLY,&nvs)!=ESP_OK)return;
|
||||
esp_err_t err=nvs_get_blob(nvs,"pending",NULL,&pending_len);nvs_close(nvs);
|
||||
if(err!=ESP_ERR_NVS_NOT_FOUND)return; // Preserve confirmed work or uncertain storage.
|
||||
err=nvs_open("wifi",NVS_READWRITE,&nvs);
|
||||
if(err==ESP_OK){
|
||||
err=nvs_set_u8(nvs,"setup_requested",0);
|
||||
if(err==ESP_OK)err=nvs_commit(nvs);
|
||||
nvs_close(nvs);
|
||||
}
|
||||
if(err==ESP_OK){
|
||||
ESP_LOGI(TAG,"Setup window expired; restoring saved Wi-Fi");
|
||||
show("Setup timed out; restoring WiFi");esp_restart();
|
||||
}else{
|
||||
ESP_LOGE(TAG,"Cannot restore saved Wi-Fi: %s",esp_err_to_name(err));
|
||||
show("WiFi restore failed; restart");
|
||||
}
|
||||
}
|
||||
static bool valid_password(const char *p)
|
||||
{
|
||||
size_t len=strlen(p);if(len<8||len>63)return false;
|
||||
for(size_t i=0;i<len;i++)if((uint8_t)p[i]<32||(uint8_t)p[i]>126)return false;
|
||||
return true;
|
||||
}
|
||||
static const char *json_string(cJSON *root,const char *key)
|
||||
{cJSON *v=cJSON_GetObjectItemCaseSensitive(root,key);return cJSON_IsString(v)?v->valuestring:NULL;}
|
||||
static esp_err_t endpoint(uint32_t session,const uint8_t *input,ssize_t len,uint8_t **output,ssize_t *outlen,void *arg)
|
||||
{
|
||||
if(len<=0||len>2048||memchr(input,0,len))return ESP_ERR_INVALID_ARG;
|
||||
// Require a single JSON object; cJSON alone otherwise accepts trailing data.
|
||||
char *text=calloc(1,len+1);if(!text)return ESP_ERR_NO_MEM;memcpy(text,input,len);
|
||||
if(strstr(text,"\\u0000")){mbedtls_platform_zeroize(text,len);free(text);return ESP_ERR_INVALID_ARG;}
|
||||
const char *end=NULL;cJSON *root=cJSON_ParseWithOpts(text,&end,true);const char *cmd=json_string(root,"cmd");
|
||||
if(!cJSON_IsObject(root)||!cmd){cJSON_Delete(root);mbedtls_platform_zeroize(text,len);free(text);return ESP_ERR_INVALID_ARG;}
|
||||
bool valid=true;unsigned count=0;cJSON *item;
|
||||
cJSON_ArrayForEach(item,root){
|
||||
count++;if(!item->string||!cJSON_IsString(item))valid=false;
|
||||
for(cJSON *prev=root->child;prev!=item;prev=prev->next)if(prev->string&&item->string&&!strcmp(prev->string,item->string))valid=false;
|
||||
if(item->string&&strcmp(item->string,"cmd")&&strcmp(item->string,"wifi_ssid")&&strcmp(item->string,"wifi_password")&&strcmp(item->string,"bind_token"))valid=false;
|
||||
}
|
||||
cJSON *result=cJSON_CreateObject();
|
||||
if(!valid||!result)goto invalid;
|
||||
if(!strcmp(cmd,"get_info")&&count==1){cJSON_AddStringToObject(result,"device_id",ble_factory_id());cJSON_AddStringToObject(result,"model",ble_factory_model());cJSON_AddNumberToObject(result,"protocol_version",2);cJSON_AddBoolToObject(result,"factory_reset_supported",true);}
|
||||
else if((!strcmp(cmd,"get_status")&&count==1)||((!strcmp(cmd,"configure")||!strcmp(cmd,"factory_reset"))&&count==4)){
|
||||
const char *error=NULL;xSemaphoreTake(state_mutex,portMAX_DELAY);
|
||||
if(state==RECEIVED&&esp_timer_get_time()>=confirm_until){state=FAILED;failure_message="确认超时,请重新发送配网信息";}
|
||||
if(!strcmp(cmd,"configure")||!strcmp(cmd,"factory_reset")){
|
||||
bool resetting=!strcmp(cmd,"factory_reset");
|
||||
const char *ssid=json_string(root,"wifi_ssid"),*password=json_string(root,"wifi_password"),*token=json_string(root,"bind_token");
|
||||
if(!ssid||!ssid[0]||strlen(ssid)>32||!password||!valid_password(password)||!token||(token[0]&&(strlen(token)!=43||strspn(token,"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_")!=43)))error="配网字段格式不正确";
|
||||
else if(resetting&&token[0])error="恢复出厂不能携带绑定凭据";
|
||||
else if(job.version==(resetting?3:2)&&state!=FAILED&&!strcmp(job.token,token)){
|
||||
if(strcmp(job.ssid,ssid)||strcmp(job.password,password))error="同一绑定凭据不能修改配网内容";
|
||||
}else if(state==RECEIVED||state==WIFI_CONNECTING||state==BINDING||state==SUCCESS)error="设备已有配网任务,请先查询结果";
|
||||
else if(esp_timer_get_time()>=window_end)error="蓝牙配网窗口已关闭,请重新长按开启";
|
||||
else{
|
||||
mbedtls_platform_zeroize(&job,sizeof(job));job.version=resetting?3:2;
|
||||
strcpy(job.ssid,ssid);strcpy(job.password,password);strcpy(job.token,token);
|
||||
state=RECEIVED;failure_message="";confirm_until=esp_timer_get_time()+60000000;
|
||||
show(resetting?"Tap to ERASE settings and WiFi":"Tap screen to confirm WiFi");
|
||||
}
|
||||
}
|
||||
cJSON_AddStringToObject(result,"status",error?"failed":state_names[state]);
|
||||
cJSON_AddStringToObject(result,"operation",job.version==3?"factory_reset":"configure");
|
||||
if(error||state==FAILED)cJSON_AddStringToObject(result,"message",error?error:failure_message);
|
||||
xSemaphoreGive(state_mutex);
|
||||
}else goto invalid;
|
||||
*output=(uint8_t*)cJSON_PrintUnformatted(result);*outlen=*output?strlen((char*)*output):0;
|
||||
cJSON_Delete(root);cJSON_Delete(result);mbedtls_platform_zeroize(text,len);free(text);
|
||||
return *output?ESP_OK:ESP_ERR_NO_MEM;
|
||||
invalid:
|
||||
cJSON_Delete(root);cJSON_Delete(result);mbedtls_platform_zeroize(text,len);free(text);return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
static void terminate_link(void)
|
||||
{unsigned handle=atomic_load(&connection);if(handle!=NO_CONN)ble_gap_terminate(handle,BLE_ERR_REM_USER_CONN_TERM);}
|
||||
static int gatt_access(uint16_t conn,uint16_t attr,struct ble_gatt_access_ctxt *ctx,void *arg)
|
||||
{
|
||||
if(ctx->op!=BLE_GATT_ACCESS_OP_WRITE_CHR||!atomic_load(&subscribed))return BLE_ATT_ERR_WRITE_NOT_PERMITTED;
|
||||
uint16_t len=OS_MBUF_PKTLEN(ctx->om);
|
||||
if(!len||len>20||rx_len+len>MAX_FRAME){terminate_link();return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;}
|
||||
if(!rx_len)fragment_start=esp_timer_get_time();
|
||||
fragment_last=esp_timer_get_time();
|
||||
if(os_mbuf_copydata(ctx->om,0,len,rx_buffer+rx_len)){terminate_link();return BLE_ATT_ERR_UNLIKELY;}
|
||||
rx_len+=len;if(rx_len<2)return 0;
|
||||
size_t expected=2+rx_buffer[0]+((size_t)rx_buffer[1]<<8);
|
||||
if(expected<3||expected>MAX_FRAME||rx_len>expected){terminate_link();return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN;}
|
||||
if(rx_len==expected){
|
||||
record_t *record=calloc(1,sizeof(*record));if(!record)return BLE_ATT_ERR_INSUFFICIENT_RES;
|
||||
record->generation=atomic_load(&epoch);record->conn=conn;record->kind=2;record->len=rx_len;record->started=fragment_start;memcpy(record->data,rx_buffer,rx_len);
|
||||
rx_len=0;fragment_start=fragment_last=0;
|
||||
if(xQueueSend(records,&record,0)!=pdTRUE){free(record);terminate_link();return BLE_ATT_ERR_INSUFFICIENT_RES;}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
static const struct ble_gatt_svc_def services[]={
|
||||
{.type=BLE_GATT_SVC_TYPE_PRIMARY,.uuid=&service_uuid.u,.characteristics=(struct ble_gatt_chr_def[]){
|
||||
{.uuid=&rx_uuid.u,.access_cb=gatt_access,.flags=BLE_GATT_CHR_F_WRITE},
|
||||
{.uuid=&tx_uuid.u,.access_cb=gatt_access,.flags=BLE_GATT_CHR_F_INDICATE,.val_handle=&tx_handle}, {0}}},{0}};
|
||||
static int gap_event(struct ble_gap_event *event,void *arg);
|
||||
static void advertise(void)
|
||||
{
|
||||
if(esp_timer_get_time()>=window_end)return;
|
||||
struct ble_hs_adv_fields adv={.flags=BLE_HS_ADV_F_DISC_GEN|BLE_HS_ADV_F_BREDR_UNSUP,.uuids128=(ble_uuid128_t*)&service_uuid,.num_uuids128=1,.uuids128_is_complete=1};
|
||||
char name[VOICELINK_BLE_NAME_CAPACITY];voicelink_ble_name(name,ble_factory_model(),ble_factory_id());
|
||||
struct ble_hs_adv_fields scan={.name=(uint8_t*)name,.name_len=strlen(name),.name_is_complete=1};
|
||||
int rc=ble_gap_adv_set_fields(&adv);
|
||||
if(!rc)rc=ble_gap_adv_rsp_set_fields(&scan);
|
||||
if(rc){ESP_LOGE(TAG,"Cannot configure BLE advertising: %d",rc);return;}
|
||||
struct ble_gap_adv_params params={.conn_mode=BLE_GAP_CONN_MODE_UND,.disc_mode=BLE_GAP_DISC_MODE_GEN};
|
||||
rc=ble_gap_adv_start(own_address_type,NULL,5000,¶ms,gap_event,NULL);
|
||||
if(rc)ESP_LOGE(TAG,"Cannot start BLE advertising: %d",rc);
|
||||
else ESP_LOGI(TAG,"BLE advertising started");
|
||||
}
|
||||
static void queue_link(uint8_t kind,uint16_t conn,unsigned gen)
|
||||
{
|
||||
record_t *r=calloc(1,sizeof(*r));if(!r){terminate_link();return;}
|
||||
r->kind=kind;r->conn=conn;r->generation=gen;
|
||||
if(xQueueSend(records,&r,0)!=pdTRUE){free(r);terminate_link();}
|
||||
}
|
||||
static int gap_event(struct ble_gap_event *event,void *arg)
|
||||
{
|
||||
switch(event->type){
|
||||
case BLE_GAP_EVENT_CONNECT:
|
||||
if(event->connect.status){advertise();break;}
|
||||
atomic_store(&connection,event->connect.conn_handle);atomic_store(&subscribed,false);rx_len=0;fragment_start=0;
|
||||
queue_link(0,event->connect.conn_handle,atomic_fetch_add(&epoch,1)+1);break;
|
||||
case BLE_GAP_EVENT_DISCONNECT:
|
||||
queue_link(1,event->disconnect.conn.conn_handle,atomic_load(&epoch));
|
||||
atomic_store(&connection,NO_CONN);atomic_store(&subscribed,false);rx_len=0;fragment_start=0;advertise();break;
|
||||
case BLE_GAP_EVENT_SUBSCRIBE:if(event->subscribe.attr_handle==tx_handle)atomic_store(&subscribed,event->subscribe.cur_indicate);break;
|
||||
case BLE_GAP_EVENT_NOTIFY_TX:
|
||||
if(event->notify_tx.indication&&event->notify_tx.status!=0){ack_t ack={.generation=atomic_load(&epoch),.status=event->notify_tx.status};xQueueSend(acks,&ack,0);}break;
|
||||
case BLE_GAP_EVENT_ADV_COMPLETE:advertise();break;
|
||||
default:break;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
static void watchdog_tick(struct ble_npl_event *event)
|
||||
{
|
||||
int64_t now=esp_timer_get_time();
|
||||
if(fragment_start&&(now-fragment_start>45000000||now-fragment_last>10000000))terminate_link();
|
||||
if(now>=window_end&&!atomic_exchange(&window_closed,true)){
|
||||
if(ble_gap_adv_active())ble_gap_adv_stop();
|
||||
terminate_link();
|
||||
}
|
||||
ble_npl_callout_reset(&watchdog,ble_npl_time_ms_to_ticks32(1000));
|
||||
}
|
||||
static void on_sync(void)
|
||||
{
|
||||
int rc=ble_hs_util_ensure_addr(0);
|
||||
if(!rc)rc=ble_hs_id_infer_auto(0,&own_address_type);
|
||||
if(rc){ESP_LOGE(TAG,"Cannot initialize BLE address: %d",rc);return;}
|
||||
ble_npl_callout_init(&watchdog,nimble_port_get_dflt_eventq(),watchdog_tick,NULL);
|
||||
ble_npl_callout_reset(&watchdog,ble_npl_time_ms_to_ticks32(1000));advertise();
|
||||
}
|
||||
static void host_task(void *arg){nimble_port_run();nimble_port_freertos_deinit();}
|
||||
static bool send_frame(record_t *r,const uint8_t *data,size_t len)
|
||||
{
|
||||
uint8_t frame[MAX_FRAME];if(!len||len>2048)return false;
|
||||
frame[0]=len&255;frame[1]=len>>8;memcpy(frame+2,data,len);
|
||||
ack_t ack;while(xQueueReceive(acks,&ack,0)==pdTRUE){}
|
||||
int64_t start=r->started;
|
||||
for(size_t at=0;at<len+2;at+=20){
|
||||
if(r->generation!=atomic_load(&epoch)||r->conn!=atomic_load(&connection)||!atomic_load(&subscribed)||esp_timer_get_time()-start>45000000)return false;
|
||||
size_t size=len+2-at;if(size>20)size=20;
|
||||
struct os_mbuf *mbuf=ble_hs_mbuf_from_flat(frame+at,size);if(!mbuf)return false;
|
||||
if(ble_gatts_indicate_custom(r->conn,tx_handle,mbuf))return false;
|
||||
if(xQueueReceive(acks,&ack,pdMS_TO_TICKS(10000))!=pdTRUE||ack.generation!=r->generation||ack.status!=BLE_HS_EDONE)return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
static void protocol_worker(void *arg)
|
||||
{
|
||||
for(;;){
|
||||
recover_wifi_after_timeout();
|
||||
record_t *r=NULL;if(xQueueReceive(records,&r,pdMS_TO_TICKS(1000))!=pdTRUE)continue;
|
||||
if(r->kind==2&&r->generation==atomic_load(&epoch)&&r->conn==atomic_load(&connection)){
|
||||
uint8_t *out=NULL;ssize_t len=0;
|
||||
esp_err_t err=endpoint(r->generation,r->data+2,r->len-2,&out,&len,NULL);
|
||||
if(err!=ESP_OK||!send_frame(r,out,len))terminate_link();
|
||||
if(out){mbedtls_platform_zeroize(out,len);free(out);}
|
||||
}
|
||||
mbedtls_platform_zeroize(r,sizeof(*r));free(r);
|
||||
}
|
||||
}
|
||||
bool ble_setup_start_if_needed(void)
|
||||
{
|
||||
if(ble_cloud_init()!=ESP_OK)return false;
|
||||
nvs_handle_t nvs;uint8_t requested=0;char ssid[33]={0};size_t len=sizeof(ssid);
|
||||
if(nvs_open("wifi",NVS_READONLY,&nvs)==ESP_OK){nvs_get_u8(nvs,"setup_requested",&requested);nvs_get_str(nvs,"ssid",ssid,&len);nvs_close(nvs);}
|
||||
bool pending=false;uint8_t completed=0,protocol=0;
|
||||
if(nvs_open("ble_setup",NVS_READONLY,&nvs)==ESP_OK){len=sizeof(job);pending=nvs_get_blob(nvs,"pending",&job,&len)==ESP_OK&&len==sizeof(job)&&(job.version==1||job.version==2||job.version==3);nvs_get_u8(nvs,"completed",&completed);nvs_close(nvs);}
|
||||
if(nvs_open("ble_setup",NVS_READONLY,&nvs)==ESP_OK){nvs_get_u8(nvs,"protocol",&protocol);nvs_close(nvs);}
|
||||
if(!pending)mbedtls_platform_zeroize(&job,sizeof(job));
|
||||
if(completed&&protocol==2&&!requested&&ssid[0]&&!pending)return false;
|
||||
recover_saved_wifi=completed&&protocol==2&&ssid[0]&&!pending;
|
||||
// Refuse malformed persisted records instead of sending unbounded strings.
|
||||
if(pending&&(!memchr(job.ssid,0,sizeof(job.ssid))||!memchr(job.password,0,sizeof(job.password))||job.token[43]||job.press[36]||(job.version==3&&job.token[0])))return false;
|
||||
state_mutex=xSemaphoreCreateMutex();records=xQueueCreate(4,sizeof(record_t*));acks=xQueueCreate(4,sizeof(ack_t));
|
||||
if(!state_mutex||!records||!acks)return false;
|
||||
// Reserve the small internal Wi-Fi buffers before BLE allocations.
|
||||
// This does not connect or send data; a physical tap is still required.
|
||||
esp_err_t wifi_err=ble_wifi_prepare();
|
||||
if(wifi_err!=ESP_OK)ESP_LOGW(TAG,"Wi-Fi prepare deferred: %s",esp_err_to_name(wifi_err));
|
||||
if(pending)state=BINDING;
|
||||
if(xTaskCreateWithCaps(binding_worker,"ble_bind",16384,pending?(void*)1:NULL,5,&binding_task,MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT)!=pdPASS)return true;
|
||||
if(xTaskCreateWithCaps(protocol_worker,"ble_protocol",12288,NULL,5,NULL,MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT)!=pdPASS)return true;
|
||||
window_end=esp_timer_get_time()+300000000;
|
||||
ESP_LOGI(TAG,"Before BLE init: internal_free=%u largest=%u",(unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL),(unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL));
|
||||
if(nimble_port_init()!=ESP_OK){show("Bluetooth init failed; restart");return true;}
|
||||
ble_svc_gap_init();ble_svc_gatt_init();
|
||||
if(ble_gatts_count_cfg(services)||ble_gatts_add_svcs(services)){
|
||||
show("Bluetooth service failed; restart");return true;
|
||||
}
|
||||
char name[VOICELINK_BLE_NAME_CAPACITY];voicelink_ble_name(name,ble_factory_model(),ble_factory_id());ble_svc_gap_device_name_set(name);
|
||||
ble_hs_cfg.sync_cb=on_sync;nimble_port_freertos_init(host_task);atomic_store(&enabled,true);
|
||||
if(!pending)set_state(IDLE,NULL);
|
||||
ESP_LOGI(TAG,"BLE initialized; waiting for host sync: %s model=%s",name,ble_factory_model());return true;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
#include <stdbool.h>
|
||||
// Returns true when BLE owns startup (including pending binding recovery).
|
||||
bool ble_setup_start_if_needed(void);
|
||||
// Called only for a physical short tap. Returns true if the tap is consumed.
|
||||
bool ble_setup_confirm(void);
|
||||
@@ -0,0 +1,168 @@
|
||||
#include "ble_wifi.h"
|
||||
#include <string.h>
|
||||
#include "esp_event.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_netif.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "esp_wifi_default.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/event_groups.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#define CONNECTED BIT0
|
||||
#define DISCONNECTED BIT1
|
||||
#define STOPPED BIT2
|
||||
static const char *TAG = "ble_wifi";
|
||||
static esp_netif_t *station;
|
||||
static EventGroupHandle_t events;
|
||||
static esp_event_handler_instance_t wifi_handler, ip_handler;
|
||||
static bool driver_ready, prepared, started, stopping;
|
||||
|
||||
static void wifi_event(void *arg, esp_event_base_t base, int32_t id, void *data)
|
||||
{
|
||||
(void)arg;
|
||||
if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {
|
||||
xEventGroupSetBits(events, CONNECTED);
|
||||
ESP_LOGI(TAG, "Wi-Fi obtained IP");
|
||||
} else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) {
|
||||
xEventGroupClearBits(events, CONNECTED);
|
||||
xEventGroupSetBits(events, DISCONNECTED);
|
||||
const wifi_event_sta_disconnected_t *event = data;
|
||||
// Never log the SSID, password, binding token, or raw event payload.
|
||||
ESP_LOGW(TAG, "Wi-Fi disconnected; reason=%u", event ? event->reason : 0);
|
||||
} else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_STOP) {
|
||||
xEventGroupClearBits(events, CONNECTED);
|
||||
xEventGroupSetBits(events, STOPPED);
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t ble_wifi_prepare(void)
|
||||
{
|
||||
if (prepared) return ESP_OK;
|
||||
// Retain each successfully initialized resource across retries. In
|
||||
// particular an esp_wifi_init failure must not recreate WIFI_STA or leak
|
||||
// another event group on the next screen confirmation.
|
||||
if (!events) {
|
||||
events = xEventGroupCreate();
|
||||
if (!events) return ESP_ERR_NO_MEM;
|
||||
}
|
||||
if (!station) {
|
||||
// Do not adopt/destroy an interface owned by the normal voice flow.
|
||||
if (esp_netif_get_handle_from_ifkey("WIFI_STA")) return ESP_ERR_INVALID_STATE;
|
||||
esp_netif_config_t config = ESP_NETIF_DEFAULT_WIFI_STA();
|
||||
station = esp_netif_new(&config);
|
||||
if (!station) return ESP_ERR_NO_MEM;
|
||||
// The convenience create_default_wifi_sta() aborts on allocation or
|
||||
// duplicate-key failure. Use its checked building blocks instead.
|
||||
esp_err_t err = esp_netif_attach_wifi_station(station);
|
||||
if (err == ESP_OK) err = esp_wifi_set_default_wifi_sta_handlers();
|
||||
if (err != ESP_OK) {
|
||||
esp_netif_destroy_default_wifi(station);
|
||||
station = NULL;
|
||||
return err;
|
||||
}
|
||||
}
|
||||
if (!driver_ready) {
|
||||
wifi_init_config_t config = WIFI_INIT_CONFIG_DEFAULT();
|
||||
// Provisioning sends a few small HTTP requests, not a voice stream.
|
||||
// Static RX/TX buffers require internal RAM even with 8 MB PSRAM.
|
||||
// Keep normal voice-mode sdkconfig performance settings unchanged.
|
||||
config.static_rx_buf_num = 4;
|
||||
config.dynamic_rx_buf_num = 8;
|
||||
config.static_tx_buf_num = 4;
|
||||
config.cache_tx_buf_num = 4;
|
||||
config.ampdu_rx_enable = 0;
|
||||
config.ampdu_tx_enable = 0;
|
||||
config.amsdu_tx_enable = 0;
|
||||
ESP_LOGI(TAG, "Preparing Wi-Fi: internal_free=%u largest=%u rx=%d tx=%d",
|
||||
(unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
|
||||
(unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL),
|
||||
config.static_rx_buf_num, config.static_tx_buf_num);
|
||||
esp_err_t err = esp_wifi_init(&config);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Wi-Fi driver init failed: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
driver_ready = true;
|
||||
}
|
||||
esp_err_t err;
|
||||
if (!wifi_handler) {
|
||||
err = esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID,
|
||||
wifi_event, NULL, &wifi_handler);
|
||||
if (err != ESP_OK) return err;
|
||||
}
|
||||
if (!ip_handler) {
|
||||
err = esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP,
|
||||
wifi_event, NULL, &ip_handler);
|
||||
if (err != ESP_OK) return err;
|
||||
}
|
||||
err = esp_wifi_set_storage(WIFI_STORAGE_RAM);
|
||||
if (err == ESP_OK) err = esp_wifi_set_mode(WIFI_MODE_STA);
|
||||
if (err != ESP_OK) return err;
|
||||
prepared = true;
|
||||
ESP_LOGI(TAG, "Wi-Fi prepared: internal_free=%u largest=%u",
|
||||
(unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
|
||||
(unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL));
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t stop_station(void)
|
||||
{
|
||||
if (!started) return ESP_OK;
|
||||
// Drain the previous attempt's events before accepting a new Wi-Fi job.
|
||||
// Otherwise a delayed DISCONNECTED/GOT_IP can affect the next attempt.
|
||||
if (!stopping) {
|
||||
esp_err_t err = esp_wifi_stop();
|
||||
if (err != ESP_OK) return err;
|
||||
stopping = true;
|
||||
}
|
||||
if (!(xEventGroupWaitBits(events, STOPPED, pdFALSE, pdFALSE,
|
||||
pdMS_TO_TICKS(5000)) & STOPPED)) return ESP_ERR_TIMEOUT;
|
||||
started = false;
|
||||
stopping = false;
|
||||
xEventGroupClearBits(events, CONNECTED | DISCONNECTED | STOPPED);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
bool ble_wifi_connected(void)
|
||||
{
|
||||
return events && (xEventGroupGetBits(events) & CONNECTED);
|
||||
}
|
||||
|
||||
esp_err_t ble_wifi_connect(const char *ssid, const char *password)
|
||||
{
|
||||
if (!ssid || !ssid[0] || strlen(ssid) > 32 || !password || strlen(password) > 63)
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
esp_err_t err = ble_wifi_prepare();
|
||||
if (err != ESP_OK) return err;
|
||||
err = stop_station();
|
||||
if (err != ESP_OK) return err;
|
||||
|
||||
wifi_config_t config = {0};
|
||||
memcpy(config.sta.ssid, ssid, strlen(ssid));
|
||||
memcpy(config.sta.password, password, strlen(password));
|
||||
config.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK;
|
||||
config.sta.scan_method = WIFI_ALL_CHANNEL_SCAN;
|
||||
config.sta.sort_method = WIFI_CONNECT_AP_BY_SIGNAL;
|
||||
err = esp_wifi_set_config(WIFI_IF_STA, &config);
|
||||
if (err != ESP_OK) return err;
|
||||
|
||||
for (int attempt = 0; attempt < 3; ++attempt) {
|
||||
xEventGroupClearBits(events, CONNECTED | DISCONNECTED | STOPPED);
|
||||
err = esp_wifi_start();
|
||||
if (err != ESP_OK) return err;
|
||||
started = true;
|
||||
err = esp_wifi_connect();
|
||||
if (err == ESP_OK) {
|
||||
EventBits_t bits = xEventGroupWaitBits(events, CONNECTED | DISCONNECTED,
|
||||
pdFALSE, pdFALSE, pdMS_TO_TICKS(10000));
|
||||
if (bits & CONNECTED) return ESP_OK;
|
||||
err = ESP_ERR_TIMEOUT;
|
||||
}
|
||||
esp_err_t stop_err = stop_station();
|
||||
if (stop_err != ESP_OK) return stop_err;
|
||||
if (attempt < 2) vTaskDelay(pdMS_TO_TICKS(500));
|
||||
}
|
||||
return err;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
#include <stdbool.h>
|
||||
#include "esp_err.h"
|
||||
|
||||
// Called by startup before the binding worker is created, then by that worker
|
||||
// only. Preparing reserves Wi-Fi memory but does not start/associate the radio.
|
||||
esp_err_t ble_wifi_prepare(void);
|
||||
esp_err_t ble_wifi_connect(const char *ssid, const char *password);
|
||||
bool ble_wifi_connected(void);
|
||||
@@ -0,0 +1,396 @@
|
||||
// Direct chat: fetch parameters, talk to Volcano, report end. Subtitles come from Volcano.
|
||||
#include "call_client.h"
|
||||
#include "firmware_version.h"
|
||||
#include "../components/volc_conv_ai/src/base/volc_device_manager.h"
|
||||
#include "ble_cloud.h"
|
||||
#include "cJSON.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "freertos/task.h"
|
||||
#include "mbedtls/platform_util.h"
|
||||
#include "mbedtls/sha256.h"
|
||||
#include "nvs.h"
|
||||
#include <stdatomic.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/time.h>
|
||||
|
||||
#define AUDIO_PROFILE "echoear-pcm16-16k-v1"
|
||||
#define FIRMWARE DEVICE_FIRMWARE_VERSION
|
||||
#define QUEUE_SIZE 12
|
||||
typedef struct {
|
||||
unsigned version;
|
||||
int64_t binding, config;
|
||||
platform_volc_credential_t credential;
|
||||
char activation[37], attempt[37], result_id[37], rtc_app[129];
|
||||
int pending; // 1=contact possible; 2=successful registration saved; 3=unknown
|
||||
} voice_store_t;
|
||||
typedef struct {
|
||||
unsigned version;
|
||||
char session[37], boot[37], end_id[37], reason[24];
|
||||
bool finished, failed, sdk_stopped;
|
||||
} call_store_t;
|
||||
static voice_store_t voice;
|
||||
static call_store_t call;
|
||||
static atomic_bool audio_allowed;
|
||||
static atomic_uint lease_deadline;
|
||||
static char *params;
|
||||
static char bot[201];
|
||||
static bool boot_loaded, retry_started, engine_ready, sdk_initialized, sdk_stopped;
|
||||
static bool maintenance;
|
||||
static atomic_bool standby_mode;
|
||||
static int64_t renew_due;
|
||||
static StaticSemaphore_t state_mutex_storage;
|
||||
static SemaphoreHandle_t state_mutex;
|
||||
static const char *TAG = "call_client";
|
||||
static char start_error[80];
|
||||
const char *call_client_start_error(void) { return start_error; }
|
||||
static void retry_task(void *unused);
|
||||
static void finish_pending(void);
|
||||
|
||||
static const char *str(const cJSON *j, const char *name) {
|
||||
const cJSON *v = cJSON_GetObjectItemCaseSensitive(j, name);
|
||||
return cJSON_IsString(v) ? v->valuestring : "";
|
||||
}
|
||||
static int64_t num(const cJSON *j, const char *name) {
|
||||
const cJSON *v = cJSON_GetObjectItemCaseSensitive(j, name);
|
||||
return cJSON_IsNumber(v) ? (int64_t)v->valuedouble : -1;
|
||||
}
|
||||
static int64_t wall(void) {
|
||||
struct timeval t;
|
||||
gettimeofday(&t, NULL);
|
||||
return (int64_t)t.tv_sec * 1000 + t.tv_usec / 1000;
|
||||
}
|
||||
static int64_t mono(void) { return esp_timer_get_time() / 1000; }
|
||||
static esp_err_t persist(const char *key, const void *data, size_t len) {
|
||||
nvs_handle_t n;
|
||||
esp_err_t e = nvs_open("voice", NVS_READWRITE, &n);
|
||||
if (e != ESP_OK)
|
||||
return e;
|
||||
e = data ? nvs_set_blob(n, key, data, len) : nvs_erase_key(n, key);
|
||||
if (e == ESP_ERR_NVS_NOT_FOUND)
|
||||
e = ESP_OK;
|
||||
if (e == ESP_OK)
|
||||
e = nvs_commit(n);
|
||||
nvs_close(n);
|
||||
return e;
|
||||
}
|
||||
static void restore(const char *key, void *data, size_t len) {
|
||||
nvs_handle_t n;
|
||||
if (nvs_open("voice", NVS_READONLY, &n) != ESP_OK)
|
||||
return;
|
||||
size_t size = len;
|
||||
if (nvs_get_blob(n, key, data, &size) != ESP_OK || size != len)
|
||||
memset(data, 0, len);
|
||||
nvs_close(n);
|
||||
}
|
||||
// Save the five fields from ble-bind before reporting BLE success. Boot still
|
||||
// obtains the current binding/config metadata for SDK initialization and ACK.
|
||||
esp_err_t call_client_save_bind_voice(const cJSON *data) {
|
||||
voice_store_t saved = {.version=1};
|
||||
const char *instance=str(data,"instance_id"), *product=str(data,"product_key"),
|
||||
*name=str(data,"device_name"), *secret=str(data,"device_secret"), *app=str(data,"RTCAppID");
|
||||
if (!instance[0] || strlen(instance)>=sizeof(saved.credential.instance_id) ||
|
||||
!product[0] || strlen(product)>=sizeof(saved.credential.product_key) ||
|
||||
!name[0] || strlen(name)>=sizeof(saved.credential.device_name) ||
|
||||
strlen(secret)<16 || strlen(secret)>=sizeof(saved.credential.device_secret) ||
|
||||
!app[0] || strlen(app)>=sizeof(saved.rtc_app)) return ESP_ERR_INVALID_RESPONSE;
|
||||
snprintf(saved.credential.instance_id,sizeof(saved.credential.instance_id),"%s",instance);
|
||||
snprintf(saved.credential.product_key,sizeof(saved.credential.product_key),"%s",product);
|
||||
snprintf(saved.credential.device_name,sizeof(saved.credential.device_name),"%s",name);
|
||||
snprintf(saved.credential.device_secret,sizeof(saved.credential.device_secret),"%s",secret);
|
||||
snprintf(saved.rtc_app,sizeof(saved.rtc_app),"%s",app);
|
||||
esp_err_t err=persist("credential",&saved,sizeof(saved));
|
||||
mbedtls_platform_zeroize(&saved,sizeof(saved));
|
||||
return err;
|
||||
}
|
||||
static esp_err_t post(const char *path, cJSON *body, const char *rid, cJSON **out) {
|
||||
char *raw = cJSON_PrintUnformatted(body), *response = NULL;
|
||||
if (!raw)
|
||||
return ESP_ERR_NO_MEM;
|
||||
esp_err_t e = platform_request("POST", path, raw, rid, &response);
|
||||
// An idle keep-alive connection may have been closed by the gateway. Chat
|
||||
// requests are keyed by session_id, so retry once on a transport failure.
|
||||
if (e != ESP_OK && !platform_last_error_code()[0] && strstr(path,"/device/chat/")) {
|
||||
if (response) { mbedtls_platform_zeroize(response,strlen(response)); free(response); response=NULL; }
|
||||
e = platform_request("POST",path,raw,rid,&response);
|
||||
}
|
||||
mbedtls_platform_zeroize(raw,strlen(raw));
|
||||
free(raw);
|
||||
if (e == ESP_OK) {
|
||||
cJSON *root = cJSON_Parse(response), *data = cJSON_GetObjectItemCaseSensitive(root, "data");
|
||||
if (!cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(root, "ok")) || !cJSON_IsObject(data))
|
||||
e = ESP_ERR_INVALID_RESPONSE;
|
||||
else if (out) {
|
||||
*out = cJSON_Duplicate(data, true);
|
||||
cJSON *server_time = cJSON_GetObjectItemCaseSensitive(root,"server_time_ms");
|
||||
if (*out && cJSON_IsNumber(server_time))
|
||||
cJSON_AddNumberToObject(*out,"_server_time_ms",server_time->valuedouble);
|
||||
}
|
||||
cJSON_Delete(root);
|
||||
}
|
||||
if (response) {
|
||||
mbedtls_platform_zeroize(response, strlen(response));
|
||||
free(response);
|
||||
}
|
||||
return e;
|
||||
}
|
||||
const platform_volc_credential_t *call_client_credential(void) { return &voice.credential; }
|
||||
const char *call_client_rtc_app_id(void) { return voice.rtc_app; }
|
||||
|
||||
static bool saved_voice_valid(void) {
|
||||
return voice.version == 1 &&
|
||||
memchr(voice.credential.instance_id, 0, sizeof(voice.credential.instance_id)) &&
|
||||
memchr(voice.credential.product_key, 0, sizeof(voice.credential.product_key)) &&
|
||||
memchr(voice.credential.device_name, 0, sizeof(voice.credential.device_name)) &&
|
||||
memchr(voice.credential.device_secret, 0, sizeof(voice.credential.device_secret)) &&
|
||||
memchr(voice.rtc_app, 0, sizeof(voice.rtc_app)) &&
|
||||
voice.credential.instance_id[0] && voice.credential.product_key[0] &&
|
||||
voice.credential.device_name[0] && strlen(voice.credential.device_secret) >= 16 &&
|
||||
voice.rtc_app[0];
|
||||
}
|
||||
|
||||
esp_err_t call_client_boot(void) {
|
||||
start_error[0] = 0;
|
||||
if (!state_mutex) state_mutex = xSemaphoreCreateMutexStatic(&state_mutex_storage);
|
||||
if (!state_mutex) return ESP_ERR_NO_MEM;
|
||||
atomic_store(&audio_allowed, false);
|
||||
if (!boot_loaded) {
|
||||
restore("credential", &voice, sizeof(voice));
|
||||
restore("direct_call", &call, sizeof(call));
|
||||
if (call.version != 2 || !memchr(call.session,0,sizeof(call.session)) ||
|
||||
!memchr(call.end_id,0,sizeof(call.end_id))) memset(&call,0,sizeof(call));
|
||||
if (call.session[0]) {
|
||||
call.finished = true; call.failed = true; call.sdk_stopped = false;
|
||||
snprintf(call.reason,sizeof(call.reason),"reboot");
|
||||
}
|
||||
boot_loaded = true;
|
||||
}
|
||||
if (ble_cloud_sync_time() != ESP_OK) return ESP_FAIL;
|
||||
if (call.finished) {
|
||||
finish_pending();
|
||||
if (call.session[0]) return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
// A retained credential is not proof of a current household/elder binding.
|
||||
cJSON *j=cJSON_CreateObject(), *data=NULL;
|
||||
esp_err_t e=post("/api/v1/device/ble-state",j,NULL,&data);
|
||||
cJSON_Delete(j);
|
||||
if (e!=ESP_OK) { cJSON_Delete(data); return e; }
|
||||
bool bound=cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(data,"bound"));
|
||||
cJSON_Delete(data); data=NULL;
|
||||
if (!bound) { snprintf(start_error,sizeof(start_error),"DEVICE_UNBOUND"); return ESP_ERR_INVALID_STATE; }
|
||||
// Normal boot uses the five fields saved from ble-bind. Recovery only.
|
||||
if (!saved_voice_valid()) {
|
||||
j=cJSON_CreateObject();
|
||||
e=post("/api/v1/device/voice-config",j,NULL,&data); cJSON_Delete(j);
|
||||
if (e==ESP_OK && !strcmp(str(data,"voice_status"),"ready")) {
|
||||
e=call_client_save_bind_voice(cJSON_GetObjectItemCaseSensitive(data,"voice"));
|
||||
} else if(e==ESP_OK) {
|
||||
snprintf(start_error,sizeof(start_error),"%s",
|
||||
!strcmp(str(data,"voice_status"),"preparing") ? "VOICE_PREPARING" : "VOICE_RECOVERY_REQUIRED");
|
||||
e=ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
cJSON *secret=cJSON_GetObjectItemCaseSensitive(cJSON_GetObjectItemCaseSensitive(data,"voice"),"device_secret");
|
||||
if(cJSON_IsString(secret))mbedtls_platform_zeroize(secret->valuestring,strlen(secret->valuestring));
|
||||
cJSON_Delete(data);
|
||||
if(e!=ESP_OK)return e;
|
||||
}
|
||||
if (!retry_started) {
|
||||
if(xTaskCreate(retry_task,"call_sync",8192,NULL,4,NULL)!=pdPASS)return ESP_ERR_NO_MEM;
|
||||
retry_started=true;
|
||||
}
|
||||
ESP_LOGI(TAG,"Direct chat ready; firmware=%s credentials=platform-issued",FIRMWARE);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void call_client_engine_ready(bool initialized) {
|
||||
sdk_initialized=initialized;
|
||||
engine_ready=initialized; // Local SDK initialization, no platform activation ACK.
|
||||
}
|
||||
bool call_client_audio_allowed(void) {
|
||||
if (!atomic_load(&audio_allowed)) return false;
|
||||
if ((int32_t)(atomic_load(&lease_deadline) - (uint32_t)mono()) <= 0) {
|
||||
atomic_store(&audio_allowed, false);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
bool call_client_is_engine_ready(void) { return engine_ready; }
|
||||
const char *call_client_bot_id(void) { return bot; }
|
||||
const char *call_client_params(void) { return params ? params : "{}"; }
|
||||
// No device transcript queue or event upload: provider callbacks own records.
|
||||
void call_client_observe(const char *type) { (void)type; }
|
||||
int volc_platform_recording_prepare(const char *app, const char *room, const char *user) {
|
||||
if (!app || !room || !user || strlen(app)>128 || strlen(room)>128 || strlen(user)>128 || !state_mutex) return ESP_ERR_INVALID_ARG;
|
||||
char session[37]={0};
|
||||
xSemaphoreTake(state_mutex,portMAX_DELAY);
|
||||
if (call.session[0] && !call.finished && call_client_audio_allowed()) snprintf(session,sizeof(session),"%s",call.session);
|
||||
xSemaphoreGive(state_mutex);
|
||||
if (!session[0]) return ESP_ERR_INVALID_STATE;
|
||||
cJSON *body=cJSON_CreateObject(), *data=NULL;
|
||||
if (!body) return ESP_ERR_NO_MEM;
|
||||
cJSON_AddStringToObject(body,"session_id",session);
|
||||
cJSON_AddStringToObject(body,"device_id",ble_factory_id());
|
||||
cJSON_AddStringToObject(body,"rtc_app_id",app);
|
||||
cJSON_AddStringToObject(body,"room_id",room);
|
||||
cJSON_AddStringToObject(body,"rtc_user_id",user);
|
||||
esp_err_t result=post("/api/v1/device/chat/recording",body,NULL,&data);
|
||||
ESP_LOGI(TAG,"Cloud conversation recording: %s",result==ESP_OK ? str(data,"recording") : "unavailable");
|
||||
cJSON_Delete(data);cJSON_Delete(body);
|
||||
return result;
|
||||
}
|
||||
esp_err_t call_client_turn(const char *speaker, const char *id, const char *text) {
|
||||
(void)speaker; (void)id; (void)text; return ESP_OK;
|
||||
}
|
||||
static esp_err_t fetch_parameters(bool initial) {
|
||||
int64_t requested_at = mono();
|
||||
cJSON *j = cJSON_CreateObject(), *data = NULL;
|
||||
cJSON_AddStringToObject(j, "session_id", call.session);
|
||||
cJSON_AddStringToObject(j, "device_id", ble_factory_id());
|
||||
cJSON_AddStringToObject(j, "boot_id", call.boot);
|
||||
esp_err_t e = post("/api/v1/device/chat/start", j, NULL, &data);
|
||||
cJSON_Delete(j);
|
||||
// A slow reply cannot reopen an expired local authorization.
|
||||
if (!initial && !call_client_audio_allowed()) e = ESP_ERR_TIMEOUT;
|
||||
if (e == ESP_OK) {
|
||||
// Compare server timestamps with each other: boot-time clock skew must
|
||||
// not reject a valid 45-second lease. Subtract the full request duration.
|
||||
int64_t server_now = num(data,"_server_time_ms");
|
||||
int64_t lease = num(data,"lease_expires_at_ms") - (server_now > 0 ? server_now : wall());
|
||||
if (server_now > 0) lease -= mono() - requested_at;
|
||||
cJSON *p = cJSON_GetObjectItemCaseSensitive(data, "params");
|
||||
if (strcmp(str(data,"session_id"),call.session) || !cJSON_IsObject(p) ||
|
||||
lease <= 0 || lease > 45000 || num(data,"expires_at_ms") <= (server_now > 0 ? server_now : wall()) ||
|
||||
!str(data,"bot_id")[0]) e = ESP_ERR_INVALID_RESPONSE;
|
||||
else {
|
||||
if (initial) {
|
||||
char *encoded = cJSON_PrintUnformatted(p);
|
||||
if (!encoded || strlen(encoded) > 16384) {
|
||||
free(encoded); e = ESP_ERR_NO_MEM;
|
||||
} else {
|
||||
free(params); params = encoded;
|
||||
snprintf(bot, sizeof(bot), "%s", str(data,"bot_id"));
|
||||
}
|
||||
}
|
||||
if (e == ESP_OK) {
|
||||
atomic_store(&lease_deadline, (uint32_t)(mono() + lease));
|
||||
atomic_store(&audio_allowed, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
cJSON_Delete(data);
|
||||
renew_due = mono() + 10000;
|
||||
if (e != ESP_OK && initial) atomic_store(&audio_allowed, false);
|
||||
return e;
|
||||
}
|
||||
static void finish_pending(void) {
|
||||
if (!call.session[0] || !call.finished) return;
|
||||
atomic_store(&audio_allowed, false);
|
||||
cJSON *j = cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(j,"session_id",call.session);
|
||||
cJSON_AddStringToObject(j,"device_id",ble_factory_id());
|
||||
cJSON_AddStringToObject(j,"reason",call.reason[0] ? call.reason : "disconnected");
|
||||
cJSON_AddBoolToObject(j,"sdk_stopped",call.sdk_stopped);
|
||||
esp_err_t e = post("/api/v1/device/chat/end",j,call.end_id,NULL);
|
||||
cJSON_Delete(j);
|
||||
if (e == ESP_OK && persist("direct_call",NULL,0) == ESP_OK) memset(&call,0,sizeof(call));
|
||||
if (params) {
|
||||
mbedtls_platform_zeroize(params,strlen(params)); free(params); params=NULL;
|
||||
}
|
||||
ESP_LOGI(TAG,"chat end acknowledged=%d",e == ESP_OK);
|
||||
}
|
||||
esp_err_t call_client_begin(void) {
|
||||
start_error[0] = '\0';
|
||||
if (!state_mutex) return ESP_ERR_INVALID_STATE;
|
||||
if (!engine_ready && sdk_initialized) call_client_engine_ready(true);
|
||||
if (!engine_ready) return ESP_ERR_INVALID_STATE;
|
||||
xSemaphoreTake(state_mutex,portMAX_DELAY);
|
||||
if (call.finished) finish_pending();
|
||||
if (maintenance || atomic_load(&standby_mode) || call.session[0]) { xSemaphoreGive(state_mutex); return ESP_ERR_INVALID_STATE; }
|
||||
memset(&call,0,sizeof(call)); call.version=2;
|
||||
ble_random_uuid(call.session); ble_random_uuid(call.end_id);
|
||||
snprintf(call.boot,sizeof(call.boot),"%s",ble_cloud_boot_id());
|
||||
sdk_stopped=false;
|
||||
esp_err_t e = persist("direct_call",&call,sizeof(call));
|
||||
if (e == ESP_OK) e=fetch_parameters(true);
|
||||
if (e != ESP_OK) {
|
||||
snprintf(start_error,sizeof(start_error),"%s",platform_last_error_code());
|
||||
call.finished=true; call.failed=true;
|
||||
snprintf(call.reason,sizeof(call.reason),"start_failed");
|
||||
persist("direct_call",&call,sizeof(call)); finish_pending();
|
||||
}
|
||||
xSemaphoreGive(state_mutex); return e;
|
||||
}
|
||||
int call_client_poll(void) {
|
||||
if (!call_client_audio_allowed()) return 1;
|
||||
xSemaphoreTake(state_mutex,portMAX_DELAY);
|
||||
if (mono() >= renew_due) {
|
||||
esp_err_t e=fetch_parameters(false);
|
||||
// A known refusal is terminal. Transient transport failures get only
|
||||
// the remaining previously granted time, never a new local lease.
|
||||
if (e != ESP_OK) {
|
||||
const char *code=platform_last_error_code();
|
||||
if (code && (!strcmp(code,"CHAT_ENDED") || !strcmp(code,"DEVICE_BINDING_CHANGED") ||
|
||||
!strcmp(code,"VOICE_CHAT_CONSENT_REQUIRED") || !strcmp(code,"DEVICE_NOT_READY") ||
|
||||
!strcmp(code,"MEMORY_CONSENT_REVOKED") || !strcmp(code,"DEVICE_UNBOUND") ||
|
||||
!strcmp(code,"VOICE_AGENT_NOT_READY"))) atomic_store(&audio_allowed,false);
|
||||
}
|
||||
}
|
||||
int stopped=!call_client_audio_allowed();
|
||||
xSemaphoreGive(state_mutex); return stopped;
|
||||
}
|
||||
void call_client_stop_result(bool started,int result) { sdk_stopped=started && result==0; }
|
||||
void call_client_finish(bool failed) {
|
||||
atomic_store(&audio_allowed,false);
|
||||
if (!state_mutex) return;
|
||||
xSemaphoreTake(state_mutex,portMAX_DELAY);
|
||||
if (call.session[0] && !call.finished) {
|
||||
call.finished=true; call.failed=failed; call.sdk_stopped=sdk_stopped;
|
||||
snprintf(call.reason,sizeof(call.reason),"%s",failed ? "disconnected" : "hangup");
|
||||
persist("direct_call",&call,sizeof(call));
|
||||
}
|
||||
finish_pending();
|
||||
xSemaphoreGive(state_mutex);
|
||||
}
|
||||
static void retry_task(void *unused) {
|
||||
(void)unused;
|
||||
for (;;) {
|
||||
vTaskDelay(pdMS_TO_TICKS(10000));
|
||||
if (xSemaphoreTake(state_mutex,0)==pdTRUE) {
|
||||
if (call.finished) finish_pending();
|
||||
xSemaphoreGive(state_mutex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool call_client_busy(void) {
|
||||
if (!state_mutex || xSemaphoreTake(state_mutex,0)!=pdTRUE) return true;
|
||||
bool busy=maintenance || call.session[0]!=0;
|
||||
xSemaphoreGive(state_mutex);
|
||||
return busy;
|
||||
}
|
||||
bool call_client_standby(void) { return atomic_load(&standby_mode); }
|
||||
esp_err_t call_client_set_standby(bool standby) {
|
||||
if (!state_mutex) { atomic_store(&standby_mode,standby); return ESP_OK; }
|
||||
xSemaphoreTake(state_mutex,portMAX_DELAY);
|
||||
if (call.session[0] || !maintenance) { xSemaphoreGive(state_mutex); return ESP_ERR_INVALID_STATE; }
|
||||
atomic_store(&standby_mode,standby);
|
||||
xSemaphoreGive(state_mutex);
|
||||
return ESP_OK;
|
||||
}
|
||||
bool call_client_enter_maintenance(void) {
|
||||
if (!state_mutex || xSemaphoreTake(state_mutex,0)!=pdTRUE) return false;
|
||||
bool allowed=!maintenance && !call.session[0];
|
||||
if (allowed) maintenance=true;
|
||||
xSemaphoreGive(state_mutex);
|
||||
return allowed;
|
||||
}
|
||||
void call_client_leave_maintenance(void) {
|
||||
if (!state_mutex) return;
|
||||
xSemaphoreTake(state_mutex,portMAX_DELAY);
|
||||
maintenance=false;
|
||||
xSemaphoreGive(state_mutex);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
#include "esp_err.h"
|
||||
#include <stdbool.h>
|
||||
#include "platform_client.h"
|
||||
#include "cJSON.h"
|
||||
esp_err_t call_client_save_bind_voice(const cJSON *data);
|
||||
esp_err_t platform_request(const char *method,const char *path,const char *body,const char *request_id,char **out);
|
||||
esp_err_t call_client_boot(void);
|
||||
const char *call_client_start_error(void);
|
||||
const platform_volc_credential_t *call_client_credential(void);
|
||||
const char *call_client_rtc_app_id(void);
|
||||
void call_client_engine_ready(bool ready);
|
||||
bool call_client_is_engine_ready(void);
|
||||
esp_err_t call_client_begin(void);
|
||||
bool call_client_audio_allowed(void);
|
||||
const char *call_client_bot_id(void);
|
||||
const char *call_client_params(void);
|
||||
int call_client_poll(void);
|
||||
// Compatibility hooks. Direct chat receives transcripts through provider callbacks;
|
||||
// these functions never upload text or perform HTTP in an SDK/audio callback.
|
||||
void call_client_observe(const char *type);
|
||||
esp_err_t call_client_turn(const char *speaker,const char *id,const char *text);
|
||||
void call_client_stop_result(bool started,int result);
|
||||
void call_client_finish(bool failed);
|
||||
bool call_client_busy(void);
|
||||
// Atomically exclude new conversations while a disruptive command executes.
|
||||
bool call_client_enter_maintenance(void);
|
||||
void call_client_leave_maintenance(void);
|
||||
bool call_client_standby(void);
|
||||
// Called before startup, or while the caller owns the maintenance exclusion.
|
||||
esp_err_t call_client_set_standby(bool standby);
|
||||
@@ -8,8 +8,12 @@
|
||||
#include <unistd.h>
|
||||
#include <time.h>
|
||||
#include <inttypes.h>
|
||||
#include <stdatomic.h>
|
||||
#include "esp_board_manager.h"
|
||||
#include "dev_lcd_touch_i2c.h"
|
||||
#include "ble_setup.h"
|
||||
#include "device_reset.h"
|
||||
#include "ble_cloud.h"
|
||||
|
||||
#include "esp_event.h"
|
||||
#include "esp_log.h"
|
||||
@@ -46,13 +50,29 @@
|
||||
#include "esp_gmf_afe.h"
|
||||
#include "mmap_generate_eaf.h"
|
||||
#include "platform_client.h"
|
||||
#include "call_client.h"
|
||||
#include "device_control.h"
|
||||
#include "device_power.h"
|
||||
#include "device_ota.h"
|
||||
#include "freertos/idf_additions.h"
|
||||
#include "hal/usb_serial_jtag_ll.h"
|
||||
#include "lwip/sockets.h"
|
||||
#define STATS_TASK_PRIO 5
|
||||
|
||||
static const char *TAG = "VolcConvAI";
|
||||
static volatile bool is_interrupt = false;
|
||||
static volatile bool conversation_service_ready = false;
|
||||
extern void conv_ai_service_diagnostic_say(void);
|
||||
extern void conv_ai_service_diagnostic_tts(void);
|
||||
extern void volc_rtc_diagnostic_snapshot(void);
|
||||
|
||||
/* RTC_PRIORITY_DIAGNOSTIC_BEGIN: the production scheduling floor has no USB
|
||||
* override. Retired experiment commands acknowledge rejection explicitly. */
|
||||
static void diagnostic_rtc_priority(bool high)
|
||||
{
|
||||
ESP_LOGI(TAG, "RTC_PRIORITY requested_high=%d accepted=0 reason=fixed_floor floor=6", high);
|
||||
}
|
||||
/* RTC_PRIORITY_DIAGNOSTIC_END */
|
||||
|
||||
/* A local USB diagnostic command lets service tests run without repeated taps.
|
||||
* It does not run automatically or accept commands over the network. */
|
||||
@@ -70,6 +90,28 @@ static void usb_diagnostic_task(void *arg)
|
||||
ESP_LOGI(TAG, "USB diagnostic: requesting fixed spoken sentence");
|
||||
conv_ai_service_diagnostic_say();
|
||||
}
|
||||
if (conversation_service_ready && !strcmp(command, "diag-tts")) {
|
||||
ESP_LOGI(TAG, "USB diagnostic: requesting fixed TTS passage with microphone uplink suppressed");
|
||||
conv_ai_service_diagnostic_tts();
|
||||
}
|
||||
if (!strcmp(command, "diag-rtc-high")) diagnostic_rtc_priority(true);
|
||||
if (!strcmp(command, "diag-rtc-normal")) diagnostic_rtc_priority(false);
|
||||
if (conversation_service_ready && !strcmp(command, "diag-stop")) {
|
||||
ESP_LOGI(TAG, "USB diagnostic: ending test conversation");
|
||||
aios_event_pub(VOLC_SERVICE_AI_CONVERSATION_QUIT, NULL, NULL);
|
||||
}
|
||||
if (!strcmp(command, "diag-state")) {
|
||||
unsigned sockets=0;
|
||||
for(int fd=LWIP_SOCKET_OFFSET;fd<FD_SETSIZE;fd++){
|
||||
int type;socklen_t length=sizeof(type);
|
||||
if(getsockopt(fd,SOL_SOCKET,SO_TYPE,&type,&length)==0)sockets++;
|
||||
}
|
||||
ESP_LOGI(TAG,"Network diagnostic: sockets=%u limit=%u",sockets,(unsigned)CONFIG_LWIP_MAX_SOCKETS);
|
||||
ESP_LOGI(TAG,"RTC diagnostic: internal_free=%u largest=%u",
|
||||
(unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL|MALLOC_CAP_8BIT),
|
||||
(unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL|MALLOC_CAP_8BIT));
|
||||
volc_rtc_diagnostic_snapshot();
|
||||
}
|
||||
length = 0;
|
||||
} else if (length < sizeof(command) - 1) {
|
||||
command[length++] = ch;
|
||||
@@ -91,6 +133,8 @@ void session_init(){
|
||||
void Task(void* data){
|
||||
aios_init(VOLC_SERVICE_EVENT_MAX); // AIOS初始化
|
||||
session_init();
|
||||
if(call_client_is_engine_ready())device_ota_boot_ready();
|
||||
else ESP_LOGE(TAG,"Voice engine failed startup; OTA image remains unconfirmed");
|
||||
conversation_service_ready = true;
|
||||
xTaskCreate(usb_diagnostic_task, "usb_diagnostic", 3072, NULL, 3, NULL);
|
||||
aios_run(); // AIOS启动
|
||||
@@ -165,7 +209,7 @@ static void rec_engine_cb(void *event, void *user_data)
|
||||
esp_gmf_afe_evt_t * e = (esp_gmf_afe_evt_t *)event;
|
||||
|
||||
if (ESP_GMF_AFE_EVT_WAKEUP_START == e->type) {
|
||||
if (conversation_service_ready) {
|
||||
if (conversation_service_ready && !call_client_standby()) {
|
||||
ESP_LOGI(TAG, "Wake word detected; requesting conversation");
|
||||
volc_hal_context_t *ctx = volc_get_global_hal_context();
|
||||
if (ctx && ctx->display_handle) {
|
||||
@@ -179,11 +223,34 @@ static void rec_engine_cb(void *event, void *user_data)
|
||||
return;
|
||||
}
|
||||
|
||||
/* SCREEN_CALL_TAP_BEGIN: debounce only touch bounce, never impose the old
|
||||
* five-second start-only guard on a deliberate hangup. */
|
||||
static void screen_call_tap(TickType_t now)
|
||||
{
|
||||
static TickType_t last_tap;
|
||||
static bool tapped;
|
||||
if (tapped && (TickType_t)(now - last_tap) < pdMS_TO_TICKS(300)) return;
|
||||
if (call_client_standby()) {
|
||||
if (device_control_request_screen_wake()) {
|
||||
last_tap = now;
|
||||
tapped = true;
|
||||
ESP_LOGI(TAG, "Screen tapped; requesting wake without starting a conversation");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (aios_event_pub(VOLC_SERVICE_AI_CONVERSATION_TOGGLE, NULL, NULL) == 0) {
|
||||
last_tap = now;
|
||||
tapped = true;
|
||||
ESP_LOGI(TAG, "Screen tapped; toggling conversation");
|
||||
}
|
||||
}
|
||||
/* SCREEN_CALL_TAP_END */
|
||||
|
||||
static void wifi_setup_touch_task(void *arg)
|
||||
{
|
||||
dev_lcd_touch_i2c_handles_t *touch = arg;
|
||||
TickType_t pressed_at = 0;
|
||||
TickType_t last_conversation_request = 0;
|
||||
TickType_t last_setup_tap = 0;
|
||||
uint16_t pressed_x = 0;
|
||||
uint16_t pressed_y = 0;
|
||||
bool holding = false;
|
||||
@@ -199,25 +266,30 @@ static void wifi_setup_touch_task(void *arg)
|
||||
TickType_t held_for = xTaskGetTickCount() - pressed_at;
|
||||
ESP_LOGI(TAG, "Touch released at (%u,%u) after %u ms",
|
||||
pressed_x, pressed_y, (unsigned)(held_for * portTICK_PERIOD_MS));
|
||||
if (held_for >= pdMS_TO_TICKS(50) && held_for < pdMS_TO_TICKS(1500) && ble_setup_confirm()) {
|
||||
holding = false;
|
||||
vTaskDelay(pdMS_TO_TICKS(50));
|
||||
continue;
|
||||
}
|
||||
// A double tap is discoverable and cannot be confused with the
|
||||
// physical power-key long press. Never interrupt a voice call.
|
||||
if (!conversation_service_ready && ble_cloud_configured() &&
|
||||
held_for >= pdMS_TO_TICKS(50) && held_for < pdMS_TO_TICKS(600)) {
|
||||
TickType_t now = xTaskGetTickCount();
|
||||
if (last_setup_tap && now-last_setup_tap < pdMS_TO_TICKS(800)) {
|
||||
nvs_handle_t setup_nvs;
|
||||
if (nvs_open("wifi",NVS_READWRITE,&setup_nvs)==ESP_OK) {
|
||||
esp_err_t err=nvs_set_u8(setup_nvs,"setup_requested",1);
|
||||
if(err==ESP_OK)err=nvs_commit(setup_nvs);
|
||||
nvs_close(setup_nvs);
|
||||
if(err==ESP_OK)esp_restart();
|
||||
}
|
||||
}
|
||||
last_setup_tap=now;
|
||||
}
|
||||
if (held_for >= pdMS_TO_TICKS(50) &&
|
||||
held_for < pdMS_TO_TICKS(1500) && conversation_service_ready) {
|
||||
TickType_t now = xTaskGetTickCount();
|
||||
if (last_conversation_request != 0 &&
|
||||
now - last_conversation_request < pdMS_TO_TICKS(5000)) {
|
||||
ESP_LOGI(TAG, "Ignoring repeated conversation tap");
|
||||
holding = false;
|
||||
vTaskDelay(pdMS_TO_TICKS(50));
|
||||
continue;
|
||||
}
|
||||
last_conversation_request = now;
|
||||
ESP_LOGI(TAG, "Screen tapped; requesting conversation");
|
||||
volc_hal_context_t *ctx = volc_get_global_hal_context();
|
||||
if (ctx && ctx->display_handle) {
|
||||
int index = MMAP_EAF_EMOTION_BLINK_FAST_284_126_EAF;
|
||||
volc_hal_display_set_content(ctx->display_handle, VOLC_DISPLAY_OBJ_MAIN,
|
||||
VOLC_DISPLAY_IMAGE, &index);
|
||||
}
|
||||
aios_event_pub(VOLC_SERVICE_AI_CONVERSATION, NULL, NULL);
|
||||
screen_call_tap(xTaskGetTickCount());
|
||||
}
|
||||
}
|
||||
holding = false;
|
||||
@@ -280,6 +352,16 @@ void hal_level_init()
|
||||
// vTaskDelay(pdMS_TO_TICKS(2000));
|
||||
}
|
||||
|
||||
static void application_network_start(volc_hal_display_t global_display);
|
||||
|
||||
static void application_network_boot_task(void *arg)
|
||||
{
|
||||
application_network_start((volc_hal_display_t)arg);
|
||||
ESP_LOGI(TAG, "Network startup finished; stack headroom: %u bytes",
|
||||
(unsigned)uxTaskGetStackHighWaterMark(NULL));
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
static void application_boot(void)
|
||||
{
|
||||
// xTaskCreate(&sys_monitor_task, "sys_monitor_task", 4096, NULL, 5, NULL);
|
||||
@@ -302,14 +384,33 @@ static void application_boot(void)
|
||||
esp_err_t ret = nvs_flash_init();
|
||||
if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND)
|
||||
{
|
||||
ESP_LOGW(TAG, "Erasing NVS flash to fix corruption");
|
||||
ESP_ERROR_CHECK(nvs_flash_erase());
|
||||
ret = nvs_flash_init();
|
||||
ESP_LOGE(TAG, "NVS requires recovery; preserving Wi-Fi and device credentials");
|
||||
volc_hal_display_set_content(global_display, VOLC_DISPLAY_OBJ_SUBTITLE,
|
||||
VOLC_DISPLAY_TEXT, "Storage recovery required");
|
||||
return;
|
||||
}
|
||||
ESP_ERROR_CHECK(ret);
|
||||
if (device_reset_boot_cleanup()!=ESP_OK) {
|
||||
volc_hal_display_set_content(global_display,VOLC_DISPLAY_OBJ_SUBTITLE,VOLC_DISPLAY_TEXT,"Reset recovery required");
|
||||
return;
|
||||
}
|
||||
device_ota_boot_guard();
|
||||
ESP_ERROR_CHECK(platform_client_init());
|
||||
ESP_ERROR_CHECK(esp_netif_init());
|
||||
wifi_setup_button_init();
|
||||
if (ble_setup_start_if_needed()) return;
|
||||
// TLS/audio startup needs a large stack, but BLE provisioning does not.
|
||||
// Allocate it only for normal operation, after choosing the boot mode.
|
||||
if (xTaskCreate(application_network_boot_task, "network_boot", 24 * 1024,
|
||||
global_display, 5, NULL) != pdPASS) {
|
||||
ESP_LOGE(TAG, "Cannot allocate network startup task");
|
||||
}
|
||||
}
|
||||
|
||||
static void application_network_start(volc_hal_display_t global_display)
|
||||
{
|
||||
esp_err_t ret;
|
||||
if(device_power_boot()!=ESP_OK){ESP_LOGE(TAG,"Standby state restore failed; preserving microphone gate");return;}
|
||||
|
||||
uint8_t station_mac[6] = {0};
|
||||
char setup_status[96] = "等待配网";
|
||||
@@ -324,6 +425,15 @@ static void application_boot(void)
|
||||
bool connected = configure_network();
|
||||
if (connected == false)
|
||||
{
|
||||
if(network_last_error()!=ESP_OK){
|
||||
volc_hal_display_set_content(global_display,VOLC_DISPLAY_OBJ_STATUS,VOLC_DISPLAY_TEXT,"网络初始化失败,请重启");
|
||||
return;
|
||||
}
|
||||
if (ble_cloud_configured()) {
|
||||
volc_hal_display_set_content(global_display,VOLC_DISPLAY_OBJ_STATUS,VOLC_DISPLAY_TEXT,"旧 Wi-Fi 不可用,正在开启蓝牙配网");
|
||||
esp_restart();
|
||||
return;
|
||||
}
|
||||
volc_hal_display_set_content(global_display, VOLC_DISPLAY_OBJ_STATUS,
|
||||
VOLC_DISPLAY_TEXT, setup_status);
|
||||
volc_hal_display_set_content(global_display, VOLC_DISPLAY_OBJ_SUBTITLE,
|
||||
@@ -333,6 +443,42 @@ static void application_boot(void)
|
||||
}
|
||||
initialize_sntp();
|
||||
wait_for_time_sync();
|
||||
if (ble_cloud_configured()) {
|
||||
volc_hal_display_set_content(global_display,VOLC_DISPLAY_OBJ_STATUS,VOLC_DISPLAY_TEXT,"正在同步语音服务");
|
||||
ret=call_client_boot();
|
||||
while(ret!=ESP_OK && !strcmp(call_client_start_error(),"VOICE_PREPARING")) {
|
||||
volc_hal_display_set_content(global_display,VOLC_DISPLAY_OBJ_STATUS,VOLC_DISPLAY_TEXT,"平台正在准备语音服务");
|
||||
vTaskDelay(pdMS_TO_TICKS(5000));
|
||||
ret=call_client_boot();
|
||||
}
|
||||
if(ret!=ESP_OK){
|
||||
const char *message="语音服务未就绪,请稍后重启";
|
||||
if(!strcmp(platform_last_error_code(),"PROVIDER_PROFILE_UNVERIFIED"))message="语音配置待验证";
|
||||
if(!strcmp(platform_last_error_code(),"DEVICE_UNBOUND") || !strcmp(call_client_start_error(),"DEVICE_UNBOUND")) {
|
||||
message="请先在小程序绑定设备";
|
||||
// Platform unbind invalidates cached credentials. Restart once
|
||||
// into BLE setup; keep factory identity and Wi-Fi unchanged.
|
||||
nvs_handle_t setup_nvs;
|
||||
if(nvs_open("wifi",NVS_READWRITE,&setup_nvs)==ESP_OK) {
|
||||
esp_err_t saved=nvs_set_u8(setup_nvs,"setup_requested",1);
|
||||
if(saved==ESP_OK)saved=nvs_commit(setup_nvs);
|
||||
nvs_close(setup_nvs);
|
||||
if(saved==ESP_OK) {
|
||||
ESP_LOGI(TAG,"Device unbound; entering BLE setup");
|
||||
esp_restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
volc_hal_display_set_content(global_display,VOLC_DISPLAY_OBJ_STATUS,VOLC_DISPLAY_TEXT,message);
|
||||
volc_hal_display_set_content(global_display,VOLC_DISPLAY_OBJ_SUBTITLE,VOLC_DISPLAY_TEXT,"双击屏幕重新配网");
|
||||
return;
|
||||
}
|
||||
hal_level_init();
|
||||
if(xTaskCreateWithCaps(&Task,"aios_task",12288,NULL,5,NULL,MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT)!=pdPASS)
|
||||
ESP_LOGE(TAG,"Cannot allocate AI service task");
|
||||
if(device_control_start()!=ESP_OK)ESP_LOGE(TAG,"Device control worker unavailable");
|
||||
volc_hal_display_set_content(global_display,VOLC_DISPLAY_OBJ_STATUS,VOLC_DISPLAY_TEXT,"轻触屏幕开始聊天");return;
|
||||
}
|
||||
if (!platform_identity_is_configured()) {
|
||||
ESP_LOGE(TAG, "Platform identity is missing; erase Wi-Fi NVS or use the setup portal");
|
||||
volc_hal_display_set_content(global_display, VOLC_DISPLAY_OBJ_STATUS, VOLC_DISPLAY_TEXT,
|
||||
@@ -361,14 +507,15 @@ static void application_boot(void)
|
||||
}
|
||||
hal_level_init();
|
||||
ESP_LOGI(TAG, "Startup stack headroom after audio: %u bytes", (unsigned)uxTaskGetStackHighWaterMark(NULL));
|
||||
xTaskCreate(&Task, "aios_task", 8192, NULL, 5, NULL);
|
||||
if(xTaskCreateWithCaps(&Task,"aios_task",12288,NULL,5,NULL,MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT)!=pdPASS)
|
||||
ESP_LOGE(TAG,"Cannot allocate AI service task");
|
||||
int index = MMAP_EAF_HAPPY_EAF;
|
||||
volc_hal_display_set_content(global_display,VOLC_DISPLAY_OBJ_MAIN,VOLC_DISPLAY_IMAGE,&index);
|
||||
volc_hal_display_set_content(global_display,VOLC_DISPLAY_OBJ_STATUS,VOLC_DISPLAY_TEXT,"轻触屏幕或说嗨,乐鑫");
|
||||
}
|
||||
|
||||
/* Network signing/TLS and audio startup exceed the default 4 KiB main stack.
|
||||
* Keep this memory available only during startup and free it on every exit. */
|
||||
/* Board/setup startup keeps internal RAM available for BLE and LCD DMA.
|
||||
* Network signing/TLS gets its separate larger stack only in voice mode. */
|
||||
static void application_boot_task(void *arg)
|
||||
{
|
||||
application_boot();
|
||||
@@ -379,7 +526,7 @@ static void application_boot_task(void *arg)
|
||||
|
||||
void app_main(void)
|
||||
{
|
||||
if (xTaskCreate(application_boot_task, "platform_boot", 24 * 1024,
|
||||
if (xTaskCreate(application_boot_task, "platform_boot", 8 * 1024,
|
||||
NULL, 5, NULL) != pdPASS) {
|
||||
ESP_LOGE(TAG, "Cannot allocate platform startup task");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
#include "device_control.h"
|
||||
#include "device_ota.h"
|
||||
#include "device_power.h"
|
||||
#include "device_screen_wake.h"
|
||||
#include "ble_cloud.h"
|
||||
#include "call_client.h"
|
||||
#include "firmware_version.h"
|
||||
#include "cJSON.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_system.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/idf_additions.h"
|
||||
#include "mqtt_client.h"
|
||||
#include "nvs.h"
|
||||
#include "nvs_flash.h"
|
||||
#include "volc_hal.h"
|
||||
#include "volc_hal_player.h"
|
||||
#include "mbedtls/platform_util.h"
|
||||
#include <stdatomic.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
|
||||
static const char *TAG="device_control";
|
||||
static esp_mqtt_client_handle_t mqtt;
|
||||
static atomic_bool connected;
|
||||
static atomic_bool stopping;
|
||||
static char status_topic[180], ota_topic[180];
|
||||
static int64_t applied_revision;
|
||||
static char device_display_name[97];
|
||||
static bool started;
|
||||
static TaskHandle_t control_worker;
|
||||
|
||||
static const char *text(const cJSON *j,const char *key) {
|
||||
const cJSON *v=cJSON_GetObjectItemCaseSensitive(j,key);
|
||||
return cJSON_IsString(v)?v->valuestring:"";
|
||||
}
|
||||
static void secret_free(char *s) {
|
||||
if(s){mbedtls_platform_zeroize(s,strlen(s));free(s);}
|
||||
}
|
||||
static esp_err_t post(const char *path,cJSON *body,cJSON **data) {
|
||||
char *raw=cJSON_PrintUnformatted(body),*response=NULL;
|
||||
if(!raw)return ESP_ERR_NO_MEM;
|
||||
esp_err_t err=platform_request("POST",path,raw,NULL,&response);
|
||||
secret_free(raw);
|
||||
if(err==ESP_OK) {
|
||||
cJSON *root=response?cJSON_Parse(response):NULL;
|
||||
const cJSON *value=cJSON_GetObjectItemCaseSensitive(root,"data");
|
||||
if(!cJSON_IsObject(value))err=ESP_ERR_INVALID_RESPONSE;
|
||||
else if(data){*data=cJSON_Duplicate(value,true);if(!*data)err=ESP_ERR_NO_MEM;}
|
||||
cJSON_Delete(root);
|
||||
}
|
||||
secret_free(response);
|
||||
return err;
|
||||
}
|
||||
static esp_err_t save_result(const char *result) {
|
||||
nvs_handle_t n;
|
||||
esp_err_t err=nvs_open("control",NVS_READWRITE,&n);
|
||||
if(err!=ESP_OK)return err;
|
||||
err=result?nvs_set_str(n,"result",result):nvs_erase_key(n,"result");
|
||||
if(err==ESP_ERR_NVS_NOT_FOUND)err=ESP_OK;
|
||||
if(err==ESP_OK)err=nvs_commit(n);
|
||||
nvs_close(n);return err;
|
||||
}
|
||||
// Persist terminal receipts before sending them. Commands are delivered at
|
||||
// least once; an offline receipt must not cause another reboot on redelivery.
|
||||
static bool flush_result(void) {
|
||||
nvs_handle_t n;
|
||||
esp_err_t err=nvs_open("control",NVS_READONLY,&n);
|
||||
// Only an absent namespace means no pending receipt. I/O failures must
|
||||
// block fetching another command, or a pending reboot may run again.
|
||||
if(err==ESP_ERR_NVS_NOT_FOUND)return true;
|
||||
if(err!=ESP_OK)return false;
|
||||
size_t len=0;err=nvs_get_str(n,"result",NULL,&len);
|
||||
if(err==ESP_ERR_NVS_NOT_FOUND){nvs_close(n);return true;}
|
||||
if(err!=ESP_OK || len<2 || len>4096){nvs_close(n);return false;}
|
||||
char *raw=malloc(len);
|
||||
if(!raw){nvs_close(n);return false;}
|
||||
err=nvs_get_str(n,"result",raw,&len);nvs_close(n);
|
||||
cJSON *j=err==ESP_OK?cJSON_ParseWithLength(raw,len):NULL;free(raw);
|
||||
if(!j)return false;
|
||||
// A reboot succeeds only after the boot ID changes, never before restart.
|
||||
const char *boot=text(j,"_reboot_from");
|
||||
if(boot[0] && !strcmp(boot,ble_cloud_boot_id())){cJSON_Delete(j);return false;}
|
||||
cJSON_DeleteItemFromObjectCaseSensitive(j,"_reboot_from");
|
||||
err=post("/api/v1/device/control/ack",j,NULL);cJSON_Delete(j);
|
||||
if(err==ESP_OK || !strcmp(platform_last_error_code(),"DEVICE_COMMAND_STATE_CONFLICT"))
|
||||
return save_result(NULL)==ESP_OK;
|
||||
return false;
|
||||
}
|
||||
static esp_err_t ack(const char *id,const char *status,cJSON *result,bool reboot) {
|
||||
cJSON *j=cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(j,"command_id",id);cJSON_AddStringToObject(j,"status",status);
|
||||
cJSON_AddItemToObject(j,"result_summary",result?cJSON_Duplicate(result,true):cJSON_CreateObject());
|
||||
esp_err_t err;
|
||||
if(!strcmp(status,"ACKNOWLEDGED"))err=post("/api/v1/device/control/ack",j,NULL);
|
||||
else {
|
||||
if(reboot)cJSON_AddStringToObject(j,"_reboot_from",ble_cloud_boot_id());
|
||||
char *raw=cJSON_PrintUnformatted(j);err=raw?save_result(raw):ESP_ERR_NO_MEM;free(raw);
|
||||
if(err==ESP_OK && !reboot)flush_result();
|
||||
}
|
||||
cJSON_Delete(j);return err;
|
||||
}
|
||||
static esp_err_t apply_config(const cJSON *config,bool maintenance_held) {
|
||||
cJSON *revision=cJSON_GetObjectItemCaseSensitive(config,"revision");
|
||||
cJSON *desired=cJSON_GetObjectItemCaseSensitive(config,"desired_config");
|
||||
if(!cJSON_IsNumber(revision) || revision->valuedouble<1 || !cJSON_IsObject(desired))return ESP_ERR_INVALID_RESPONSE;
|
||||
const cJSON *category=NULL;
|
||||
cJSON_ArrayForEach(category,desired) {
|
||||
// Do not claim that arbitrary categories were applied by this firmware.
|
||||
if(!strcmp(category->string,"voice_runtime") && cJSON_IsString(category) &&
|
||||
!strcmp(category->valuestring,"volc_hardware_agent"))continue;
|
||||
if(strcmp(category->string,"volume") && strcmp(category->string,"identity") && strcmp(category->string,"power"))return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
const cJSON *power=cJSON_GetObjectItemCaseSensitive(desired,"power");
|
||||
const cJSON *mode=cJSON_GetObjectItemCaseSensitive(power,"mode");
|
||||
if(power && (!cJSON_IsObject(power) || cJSON_GetArraySize(power)!=1 || !cJSON_IsString(mode) || (strcmp(mode->valuestring,"active") && strcmp(mode->valuestring,"standby"))))return ESP_ERR_INVALID_RESPONSE;
|
||||
const cJSON *identity=cJSON_GetObjectItemCaseSensitive(desired,"identity");
|
||||
const cJSON *name=cJSON_GetObjectItemCaseSensitive(identity,"display_name");
|
||||
if(identity) {
|
||||
if(!cJSON_IsObject(identity) || cJSON_GetArraySize(identity)!=1 || !cJSON_IsString(name))return ESP_ERR_INVALID_RESPONSE;
|
||||
size_t len=strlen(name->valuestring);
|
||||
if(len>=sizeof(device_display_name) || (len && (name->valuestring[0]==' ' || name->valuestring[len-1]==' ')))return ESP_ERR_INVALID_RESPONSE;
|
||||
for(size_t i=0;i<len;i++)if((unsigned char)name->valuestring[i]<32 || (unsigned char)name->valuestring[i]==127)return ESP_ERR_INVALID_RESPONSE;
|
||||
}
|
||||
cJSON *volume=cJSON_GetObjectItemCaseSensitive(desired,"volume");
|
||||
cJSON *speaker=cJSON_GetObjectItemCaseSensitive(volume,"speaker");
|
||||
if(volume) {
|
||||
if(!cJSON_IsNumber(speaker) || speaker->valuedouble!=speaker->valueint || speaker->valueint<0 || speaker->valueint>100)return ESP_ERR_INVALID_RESPONSE;
|
||||
if(volc_hal_get_device_volume()!=speaker->valueint){
|
||||
if(volc_hal_set_device_volume(speaker->valueint)!=ESP_OK)return ESP_FAIL;
|
||||
}
|
||||
nvs_handle_t n;
|
||||
if(nvs_open("control",NVS_READWRITE,&n)!=ESP_OK)return ESP_FAIL;
|
||||
int32_t stored=-1;nvs_get_i32(n,"speaker",&stored);
|
||||
esp_err_t e=ESP_OK;
|
||||
if(stored!=speaker->valueint){e=nvs_set_i32(n,"speaker",speaker->valueint);if(e==ESP_OK)e=nvs_commit(n);}
|
||||
nvs_close(n);if(e!=ESP_OK)return e;
|
||||
}
|
||||
if(identity && strcmp(device_display_name,name->valuestring)) {
|
||||
nvs_handle_t n;
|
||||
if(nvs_open("control",NVS_READWRITE,&n)!=ESP_OK)return ESP_FAIL;
|
||||
esp_err_t e=nvs_set_str(n,"display_name",name->valuestring);
|
||||
if(e==ESP_OK)e=nvs_commit(n);
|
||||
char readback[sizeof(device_display_name)];size_t size=sizeof(readback);
|
||||
if(e==ESP_OK)e=nvs_get_str(n,"display_name",readback,&size);
|
||||
nvs_close(n);
|
||||
if(e!=ESP_OK || strcmp(readback,name->valuestring))return ESP_FAIL;
|
||||
memcpy(device_display_name,readback,size);
|
||||
}
|
||||
if(power && device_power_apply(mode->valuestring,maintenance_held)!=ESP_OK)return ESP_FAIL;
|
||||
applied_revision=(int64_t)revision->valuedouble;
|
||||
return ESP_OK;
|
||||
}
|
||||
static cJSON *diagnostics(void) {
|
||||
cJSON *j=cJSON_CreateObject(),*audio=cJSON_AddObjectToObject(j,"audio"),
|
||||
*network=cJSON_AddObjectToObject(j,"network"),*storage=cJSON_AddObjectToObject(j,"storage");
|
||||
int volume=volc_hal_get_device_volume();
|
||||
cJSON_AddBoolToObject(audio,"codec_ready",volume>=0);
|
||||
cJSON_AddNumberToObject(audio,"speaker",volume);
|
||||
cJSON_AddStringToObject(audio,"scope","codec state; microphone and speaker listening test requires user");
|
||||
wifi_ap_record_t ap={0};bool online=esp_wifi_sta_get_ap_info(&ap)==ESP_OK;
|
||||
cJSON_AddBoolToObject(network,"connected",online);
|
||||
if(online)cJSON_AddNumberToObject(network,"rssi",ap.rssi);
|
||||
nvs_stats_t stats={0};esp_err_t e=nvs_get_stats(NULL,&stats);
|
||||
cJSON_AddBoolToObject(storage,"nvs_readable",e==ESP_OK);
|
||||
if(e==ESP_OK)cJSON_AddNumberToObject(storage,"free_entries",stats.free_entries);
|
||||
// Check an actual write/commit/read path in our own namespace only.
|
||||
nvs_handle_t n;bool write_ok=false;uint32_t readback=0,nonce=esp_random();
|
||||
if(nvs_open("control",NVS_READWRITE,&n)==ESP_OK) {
|
||||
write_ok=nvs_set_u32(n,"probe",nonce)==ESP_OK && nvs_commit(n)==ESP_OK &&
|
||||
nvs_get_u32(n,"probe",&readback)==ESP_OK && readback==nonce;
|
||||
nvs_erase_key(n,"probe");nvs_commit(n);nvs_close(n);
|
||||
}
|
||||
cJSON_AddBoolToObject(storage,"write_verified",write_ok);
|
||||
cJSON_AddItemToObject(j,"power",device_power_report());
|
||||
cJSON_AddNumberToObject(j,"internal_heap_free",heap_caps_get_free_size(MALLOC_CAP_INTERNAL|MALLOC_CAP_8BIT));
|
||||
cJSON_AddNumberToObject(j,"uptime_ms",esp_timer_get_time()/1000);
|
||||
return j;
|
||||
}
|
||||
static void command(const cJSON *cmd,const cJSON *config) {
|
||||
const char *id=text(cmd,"id"),*type=text(cmd,"type");
|
||||
if(strlen(id)!=36 || !type[0])return;
|
||||
if(!call_client_enter_maintenance())return;
|
||||
if(ack(id,"ACKNOWLEDGED",NULL,false)!=ESP_OK){call_client_leave_maintenance();return;}
|
||||
cJSON *result=NULL;bool success=false;
|
||||
if(!strcmp(type,"reboot")) {
|
||||
if(ack(id,"SUCCEEDED",NULL,true)==ESP_OK){device_control_prepare_restart();vTaskDelay(pdMS_TO_TICKS(100));esp_restart();}
|
||||
} else if(!strcmp(type,"diagnostics")) { result=diagnostics();success=result!=NULL; }
|
||||
else if(!strcmp(type,"apply_config"))success=apply_config(config,true)==ESP_OK;
|
||||
if(!result) {
|
||||
result=cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(result,"message",success?"Configuration applied":"Command unsupported or hardware operation failed");
|
||||
}
|
||||
ack(id,success?"SUCCEEDED":"FAILED",result,false);cJSON_Delete(result);
|
||||
call_client_leave_maintenance();
|
||||
}
|
||||
void device_control_prepare_restart(void) {
|
||||
atomic_store(&stopping,true);
|
||||
atomic_store(&connected,false);
|
||||
// stop sends MQTT DISCONNECT and closes the transport before returning.
|
||||
// Otherwise a fast reboot can leave stale presence until the next session.
|
||||
if(mqtt && esp_mqtt_client_stop(mqtt)!=ESP_OK)
|
||||
ESP_LOGW(TAG,"MQTT shutdown failed; presence will expire normally");
|
||||
}
|
||||
void device_control_heartbeat(void) {
|
||||
if(!mqtt || atomic_load(&stopping) || !atomic_load(&connected))return;
|
||||
cJSON *j=cJSON_CreateObject();
|
||||
cJSON_AddBoolToObject(j,"online",true);
|
||||
cJSON_AddStringToObject(j,"firmware_version",device_ota_version());
|
||||
cJSON_AddNumberToObject(j,"version_code",device_ota_version_code());
|
||||
cJSON_AddBoolToObject(j,"ota_supported",device_ota_supported());
|
||||
char *raw=cJSON_PrintUnformatted(j);
|
||||
if(raw){esp_mqtt_client_enqueue(mqtt,status_topic,raw,0,1,0,false);free(raw);}
|
||||
cJSON_Delete(j);
|
||||
}
|
||||
static void mqtt_event(void *arg,esp_event_base_t base,int32_t id,void *event_data) {
|
||||
static char incoming[1536];
|
||||
static size_t used,expected;
|
||||
esp_mqtt_event_handle_t e=event_data;
|
||||
if(id==MQTT_EVENT_CONNECTED){esp_mqtt_client_subscribe(e->client,ota_topic,1);}
|
||||
else if(id==MQTT_EVENT_SUBSCRIBED){atomic_store(&connected,true);device_control_heartbeat();}
|
||||
else if(id==MQTT_EVENT_DISCONNECTED)atomic_store(&connected,false);
|
||||
else if(id==MQTT_EVENT_DATA){
|
||||
if(e->current_data_offset==0){
|
||||
used=expected=0;
|
||||
if(e->topic_len!=(int)strlen(ota_topic) || memcmp(e->topic,ota_topic,e->topic_len) ||
|
||||
e->total_data_len<1 || e->total_data_len>=sizeof(incoming))return;
|
||||
expected=e->total_data_len;
|
||||
}
|
||||
if(!expected || e->current_data_offset!=(int)used || e->data_len<0 || used+e->data_len>expected){used=expected=0;return;}
|
||||
memcpy(incoming+used,e->data,e->data_len);used+=e->data_len;
|
||||
if(used==expected){incoming[used]=0;cJSON *j=cJSON_Parse(incoming);
|
||||
device_ota_request(text(j,"task_id"));cJSON_Delete(j);used=expected=0;}
|
||||
}
|
||||
}
|
||||
static esp_err_t start_mqtt(void) {
|
||||
cJSON *body=cJSON_CreateObject(),*j=NULL;
|
||||
esp_err_t err=post("/api/v1/device/mqtt-config",body,&j);cJSON_Delete(body);
|
||||
if(err!=ESP_OK)return err;
|
||||
const char *broker=text(j,"broker_url"),*client=text(j,"client_id"),*user=text(j,"username"),*password=text(j,"password");
|
||||
if(!broker[0] || !client[0] || !password[0] || strlen(text(j,"status_topic"))>=sizeof(status_topic) || strlen(text(j,"ota_topic"))>=sizeof(ota_topic)) {cJSON_Delete(j);return ESP_ERR_INVALID_RESPONSE;}
|
||||
snprintf(status_topic,sizeof(status_topic),"%s",text(j,"status_topic"));
|
||||
snprintf(ota_topic,sizeof(ota_topic),"%s",text(j,"ota_topic"));
|
||||
esp_mqtt_client_config_t cfg={.broker.address.uri=broker,
|
||||
.credentials.client_id=client,.credentials.username=user,.credentials.authentication.password=password,
|
||||
.session.keepalive=60,.session.last_will={.topic=status_topic,.msg="{\"online\":false}",.qos=1,.retain=0},
|
||||
.task.stack_size=4096,.buffer.size=2048,.buffer.out_size=512,.outbox.limit=4096};
|
||||
mqtt=esp_mqtt_client_init(&cfg);
|
||||
if(password[0])mbedtls_platform_zeroize((char*)password,strlen(password));
|
||||
cJSON_Delete(j);
|
||||
if(!mqtt)return ESP_ERR_NO_MEM;
|
||||
esp_mqtt_client_register_event(mqtt,ESP_EVENT_ANY_ID,mqtt_event,NULL);
|
||||
err=esp_mqtt_client_start(mqtt);
|
||||
if(err!=ESP_OK){esp_mqtt_client_destroy(mqtt);mqtt=NULL;}
|
||||
return err;
|
||||
}
|
||||
static void control_task(void *arg) {
|
||||
int64_t mqtt_due=0,heartbeat_due=0,diagnostic_due=0;
|
||||
for(;;) {
|
||||
int64_t now=esp_timer_get_time()/1000;
|
||||
if (device_screen_wake_poll(applied_revision) != ESP_OK)
|
||||
ESP_LOGW(TAG, "Screen wake pending; local hardware/storage operation will retry");
|
||||
if(!mqtt && now>=mqtt_due){start_mqtt();mqtt_due=now+30000;}
|
||||
if(now>=heartbeat_due){device_control_heartbeat();heartbeat_due=now+30000;}
|
||||
device_ota_poll();
|
||||
int speaker=volc_hal_get_device_volume();
|
||||
if(now>=diagnostic_due){
|
||||
ESP_LOGI(TAG,"state mqtt=%d speaker=%d revision=%lld ota=%d internal_free=%u",
|
||||
atomic_load(&connected),speaker,(long long)applied_revision,device_ota_supported(),
|
||||
(unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL|MALLOC_CAP_8BIT));
|
||||
diagnostic_due=now+30000;
|
||||
}
|
||||
wifi_ap_record_t ap={0};
|
||||
if(speaker>=0 && esp_wifi_sta_get_ap_info(&ap)==ESP_OK && flush_result()) {
|
||||
cJSON *j=cJSON_CreateObject(),*data=NULL;
|
||||
cJSON_AddStringToObject(j,"firmware_version",device_ota_version());
|
||||
cJSON_AddNumberToObject(j,"reported_revision",applied_revision);
|
||||
cJSON_AddNumberToObject(j,"speaker",speaker);
|
||||
cJSON_AddStringToObject(j,"display_name",device_display_name);
|
||||
cJSON_AddItemToObject(j,"power",device_power_report());
|
||||
cJSON_AddNumberToObject(j,"rssi",ap.rssi);
|
||||
cJSON_AddBoolToObject(j,"busy",call_client_busy());
|
||||
device_screen_wake_add_report(j);
|
||||
esp_err_t e=post("/api/v1/device/control/sync",j,&data);cJSON_Delete(j);
|
||||
if(e==ESP_OK && device_screen_wake_ack(data)==ESP_OK) {
|
||||
cJSON *config=cJSON_GetObjectItemCaseSensitive(data,"configuration");
|
||||
apply_config(config,false);
|
||||
cJSON *commands=cJSON_GetObjectItemCaseSensitive(data,"commands");
|
||||
const cJSON *item=cJSON_GetArrayItem(commands,0);
|
||||
if(item)command(item,config);
|
||||
} else if(!strcmp(platform_last_error_code(),"CONFIG_REVISION_CONFLICT"))applied_revision=0;
|
||||
cJSON_Delete(data);
|
||||
}
|
||||
ulTaskNotifyTake(pdTRUE,pdMS_TO_TICKS(5000));
|
||||
}
|
||||
}
|
||||
esp_err_t device_control_start(void) {
|
||||
if(started)return ESP_OK;
|
||||
esp_err_t wake_init = device_screen_wake_init();
|
||||
if (wake_init != ESP_OK) return wake_init;
|
||||
nvs_handle_t n;
|
||||
if(nvs_open("control",NVS_READONLY,&n)==ESP_OK){
|
||||
int32_t volume;
|
||||
if(nvs_get_i32(n,"speaker",&volume)==ESP_OK && volume>=0 && volume<=100)volc_hal_set_device_volume(volume);
|
||||
size_t size=sizeof(device_display_name);
|
||||
if(nvs_get_str(n,"display_name",device_display_name,&size)!=ESP_OK)device_display_name[0]=0;
|
||||
nvs_close(n);
|
||||
}
|
||||
if(xTaskCreateWithCaps(control_task,"device_control",12288,NULL,3,&control_worker,MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT)!=pdPASS)return ESP_ERR_NO_MEM;
|
||||
started=true;ESP_LOGI(TAG,"Device control worker started");return ESP_OK;
|
||||
}
|
||||
bool device_control_request_screen_wake(void) {
|
||||
if (!started || !control_worker || atomic_load(&stopping)) return false;
|
||||
device_screen_wake_request();
|
||||
xTaskNotifyGive(control_worker);
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
#include "esp_err.h"
|
||||
#include <stdbool.h>
|
||||
// Starts after network and factory identity initialization. Uses a PSRAM stack.
|
||||
esp_err_t device_control_start(void);
|
||||
void device_control_heartbeat(void);
|
||||
// Call from an application task, never from the MQTT event handler.
|
||||
void device_control_prepare_restart(void);
|
||||
// The touch task queues a wake; the control worker restores power and syncs it.
|
||||
bool device_control_request_screen_wake(void);
|
||||
@@ -0,0 +1,385 @@
|
||||
#include "device_ota.h"
|
||||
#include "device_control.h"
|
||||
#include "ble_cloud.h"
|
||||
#include "call_client.h"
|
||||
#include "firmware_version.h"
|
||||
#include "cJSON.h"
|
||||
#include "esp_app_desc.h"
|
||||
#include "esp_crt_bundle.h"
|
||||
#include "esp_http_client.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_ota_ops.h"
|
||||
#include "esp_partition.h"
|
||||
#include "esp_system.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/queue.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "freertos/idf_additions.h"
|
||||
#include "mbedtls/sha256.h"
|
||||
#include "mbedtls/platform_util.h"
|
||||
#include "nvs.h"
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <sys/time.h>
|
||||
#include <math.h>
|
||||
|
||||
typedef struct {
|
||||
uint32_t format,phase,address,size; // 1=download,2=boot pending,3=success,4=failed,0=reported
|
||||
int64_t code;
|
||||
char id[37],version[81],sha[65],message[100];
|
||||
} ota_state_t;
|
||||
typedef struct {uint32_t format,address;int64_t code;char version[81];} installed_t;
|
||||
static ota_state_t state;
|
||||
static installed_t installed;
|
||||
static esp_timer_handle_t boot_timer;
|
||||
static QueueHandle_t requests;
|
||||
static StaticQueue_t queue_state;
|
||||
static uint8_t queue_storage[37];
|
||||
static bool ready;
|
||||
static bool storage_ready;
|
||||
static char retry_id[37];
|
||||
static unsigned manifest_retries;
|
||||
typedef enum {OTA_FLASH_END,OTA_FLASH_BOOT,OTA_FLASH_ROLLBACK} ota_flash_op_t;
|
||||
static esp_err_t ota_internal_flash_call(ota_flash_op_t op,esp_ota_handle_t handle,const esp_partition_t *target,bool *completed);
|
||||
static const char *TAG="device_ota";
|
||||
static const char *str(const cJSON *j,const char *k){cJSON *v=cJSON_GetObjectItemCaseSensitive(j,k);return cJSON_IsString(v)?v->valuestring:"";}
|
||||
static int64_t number(const cJSON *j,const char *k){
|
||||
cJSON *v=cJSON_GetObjectItemCaseSensitive(j,k);
|
||||
if(!cJSON_IsNumber(v) || !isfinite(v->valuedouble) || v->valuedouble<0 ||
|
||||
v->valuedouble>9007199254740991.0 || floor(v->valuedouble)!=v->valuedouble)return -1;
|
||||
return (int64_t)v->valuedouble;
|
||||
}
|
||||
static void wipe(char *v){if(v){mbedtls_platform_zeroize(v,strlen(v));free(v);}}
|
||||
static esp_err_t post(const char *path,cJSON *j,cJSON **data){
|
||||
char *body=cJSON_PrintUnformatted(j),*raw=NULL;if(!body)return ESP_ERR_NO_MEM;
|
||||
esp_err_t e=platform_request("POST",path,body,NULL,&raw);wipe(body);
|
||||
if(e==ESP_OK){cJSON *root=raw?cJSON_Parse(raw):NULL;const cJSON *v=cJSON_GetObjectItemCaseSensitive(root,"data");
|
||||
if(!cJSON_IsObject(v))e=ESP_ERR_INVALID_RESPONSE;
|
||||
else if(data){*data=cJSON_Duplicate(v,true);if(!*data)e=ESP_ERR_NO_MEM;}
|
||||
cJSON_Delete(root);
|
||||
}wipe(raw);return e;
|
||||
}
|
||||
static esp_err_t persist(const char *key,const void *data,size_t size){
|
||||
nvs_handle_t n;esp_err_t e=nvs_open("control",NVS_READWRITE,&n);if(e!=ESP_OK)return e;
|
||||
e=nvs_set_blob(n,key,data,size);if(e==ESP_OK)e=nvs_commit(n);nvs_close(n);return e;
|
||||
}
|
||||
static esp_err_t load_result(const char *key,void *data,size_t size){
|
||||
nvs_handle_t n;esp_err_t e=nvs_open("control",NVS_READONLY,&n);if(e!=ESP_OK)return e;
|
||||
size_t actual=size;e=nvs_get_blob(n,key,data,&actual);nvs_close(n);
|
||||
return e==ESP_OK && actual!=size?ESP_ERR_INVALID_SIZE:e;
|
||||
}
|
||||
static esp_err_t save(void){return persist("ota",&state,sizeof(state));}
|
||||
static bool installed_key(const esp_partition_t *part,char key[16]){
|
||||
if(!part || part->subtype<ESP_PARTITION_SUBTYPE_APP_OTA_MIN || part->subtype>ESP_PARTITION_SUBTYPE_APP_OTA_MAX)return false;
|
||||
snprintf(key,16,"installed%u",(unsigned)(part->subtype-ESP_PARTITION_SUBTYPE_APP_OTA_MIN));return true;
|
||||
}
|
||||
static bool load_installed(const esp_partition_t *running){
|
||||
char key[16];memset(&installed,0,sizeof(installed));if(!installed_key(running,key))return false;
|
||||
esp_err_t err=load_result(key,&installed,sizeof(installed));
|
||||
// Keep the old single-slot record intact: an older fallback image still
|
||||
// reads it. New images record each slot separately before boot confirmation.
|
||||
bool legacy=err==ESP_ERR_NVS_NOT_FOUND;
|
||||
if(legacy)err=load_result("installed",&installed,sizeof(installed));
|
||||
if(err==ESP_ERR_NVS_NOT_FOUND){memset(&installed,0,sizeof(installed));return true;}
|
||||
if(err!=ESP_OK || installed.format!=1 || installed.code<=0 || installed.code>9007199254740991LL ||
|
||||
!installed.version[0] || !memchr(installed.version,0,sizeof(installed.version))){memset(&installed,0,sizeof(installed));return false;}
|
||||
if(installed.address!=running->address){memset(&installed,0,sizeof(installed));return legacy;}
|
||||
return true;
|
||||
}
|
||||
static void hash_hex(const uint8_t hash[32],char hex[65]){for(int i=0;i<32;i++)sprintf(hex+2*i,"%02x",hash[i]);hex[64]=0;}
|
||||
static bool flash_matches(const esp_partition_t *part){
|
||||
if(!part || state.size<1024 || state.size>part->size)return false;
|
||||
unsigned char *buffer=malloc(4096),hash[32]={0};if(!buffer)return false;
|
||||
mbedtls_sha256_context sha;mbedtls_sha256_init(&sha);bool ok=mbedtls_sha256_starts(&sha,0)==0;
|
||||
for(uint32_t offset=0;ok && offset<state.size;offset+=4096){
|
||||
size_t n=state.size-offset;if(n>4096)n=4096;
|
||||
ok=esp_partition_read(part,offset,buffer,n)==ESP_OK && mbedtls_sha256_update(&sha,buffer,n)==0;
|
||||
}
|
||||
if(ok)ok=mbedtls_sha256_finish(&sha,hash)==0;
|
||||
mbedtls_sha256_free(&sha);free(buffer);char hex[65];hash_hex(hash,hex);return ok && !strcmp(hex,state.sha);
|
||||
}
|
||||
static void boot_timeout(void *arg){ESP_LOGE(TAG,"New firmware health deadline expired; restarting for rollback");esp_restart();}
|
||||
void device_ota_boot_guard(void){
|
||||
ready=false;storage_ready=true;
|
||||
retry_id[0]=0;manifest_retries=0;
|
||||
requests=xQueueCreateStatic(1,37,queue_storage,&queue_state);
|
||||
esp_err_t journal=load_result("ota",&state,sizeof(state));
|
||||
if(journal==ESP_ERR_NVS_NOT_FOUND)memset(&state,0,sizeof(state));
|
||||
else if(journal!=ESP_OK || state.format!=1 || state.phase>4 || !memchr(state.id,0,sizeof(state.id)) ||
|
||||
!memchr(state.version,0,sizeof(state.version)) || !memchr(state.sha,0,sizeof(state.sha)) || !memchr(state.message,0,sizeof(state.message))){memset(&state,0,sizeof(state));storage_ready=false;}
|
||||
const esp_partition_t *running=esp_ota_get_running_partition();
|
||||
if(!load_installed(running))storage_ready=false;
|
||||
esp_ota_img_states_t image;
|
||||
if(running && esp_ota_get_state_partition(running,&image)==ESP_OK && image==ESP_OTA_IMG_PENDING_VERIFY){
|
||||
esp_timer_create_args_t args={.callback=boot_timeout,.name="ota_health"};
|
||||
if(esp_timer_create(&args,&boot_timer)!=ESP_OK || esp_timer_start_once(boot_timer,180000000)!=ESP_OK){
|
||||
ESP_LOGE(TAG,"Cannot arm OTA health deadline; restoring previous image");
|
||||
ota_internal_flash_call(OTA_FLASH_ROLLBACK,0,NULL,NULL);
|
||||
esp_restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
void device_ota_boot_ready(void){
|
||||
// An unreadable record is not equivalent to a factory image with version 0.
|
||||
// Leave OTA disabled; a pending image retains its rollback deadline.
|
||||
if(!storage_ready)return;
|
||||
const esp_partition_t *running=esp_ota_get_running_partition();
|
||||
esp_ota_img_states_t image;
|
||||
esp_err_t image_state=running?esp_ota_get_state_partition(running,&image):ESP_ERR_NOT_FOUND;
|
||||
bool pending=image_state==ESP_OK && image==ESP_OTA_IMG_PENDING_VERIFY;
|
||||
// Establish a valid fallback for the first image flashed over USB. Without
|
||||
// this entry, the first OTA would have no previously confirmed A/B slot.
|
||||
if(running && image_state==ESP_ERR_NOT_FOUND && running->subtype>=ESP_PARTITION_SUBTYPE_APP_OTA_MIN && running->subtype<=ESP_PARTITION_SUBTYPE_APP_OTA_MAX){
|
||||
if(ota_internal_flash_call(OTA_FLASH_BOOT,0,running,NULL)!=ESP_OK || esp_ota_mark_app_valid_cancel_rollback()!=ESP_OK)return;
|
||||
}
|
||||
if(state.phase==2){
|
||||
if(running && running->address==state.address && flash_matches(running)){
|
||||
installed_t value={.format=1,.address=running->address,.code=state.code};
|
||||
snprintf(value.version,sizeof(value.version),"%s",state.version);
|
||||
char key[16];if(!installed_key(running,key) || persist(key,&value,sizeof(value))!=ESP_OK)return;
|
||||
if(pending && esp_ota_mark_app_valid_cancel_rollback()!=ESP_OK)return;
|
||||
installed=value;state.phase=3;
|
||||
ESP_LOGI(TAG,"OTA boot verified: slot=0x%lx version_code=%lld",(unsigned long)running->address,(long long)state.code);
|
||||
}else{state.phase=4;snprintf(state.message,sizeof(state.message),"Boot verification failed or previous firmware restored");
|
||||
if(pending){save();if(ota_internal_flash_call(OTA_FLASH_ROLLBACK,0,NULL,NULL)==ESP_OK)esp_restart();return;}}
|
||||
if(save()!=ESP_OK)return;
|
||||
}else if(state.phase==1){state.phase=4;snprintf(state.message,sizeof(state.message),"Download interrupted before installation");if(save()!=ESP_OK)return;}
|
||||
else if(pending){
|
||||
// Untracked images cannot claim an OTA task; normal health checks still
|
||||
// allow an intentional serial-flashed build to become bootable.
|
||||
if(esp_ota_mark_app_valid_cancel_rollback()!=ESP_OK)return;
|
||||
}
|
||||
if(boot_timer){esp_timer_stop(boot_timer);esp_timer_delete(boot_timer);boot_timer=NULL;}
|
||||
ready=true;
|
||||
if(running)ESP_LOGI(TAG,"OTA ready: slot=0x%lx firmware=%s version_code=%lld",(unsigned long)running->address,device_ota_version(),(long long)device_ota_version_code());
|
||||
}
|
||||
bool device_ota_supported(void){return ready && esp_ota_get_next_update_partition(NULL)!=NULL;}
|
||||
const char *device_ota_version(void){return installed.code>0?installed.version:DEVICE_FIRMWARE_VERSION;}
|
||||
int64_t device_ota_version_code(void){return installed.code>0?installed.code:0;}
|
||||
void device_ota_request(const char *id){if(requests && id && strlen(id)==36){char value[37];memcpy(value,id,37);xQueueSend(requests,value,0);}}
|
||||
static bool report(const char *status){
|
||||
cJSON *j=cJSON_CreateObject();cJSON_AddStringToObject(j,"task_id",state.id);cJSON_AddStringToObject(j,"status",status);
|
||||
if(!strcmp(status,"success")){cJSON_AddStringToObject(j,"firmware_version",state.version);cJSON_AddNumberToObject(j,"version_code",state.code);}
|
||||
if(state.message[0])cJSON_AddStringToObject(j,"message",state.message);
|
||||
esp_err_t err=post("/api/v1/device/ota/report",j,NULL);cJSON_Delete(j);
|
||||
// The server may already have committed a terminal receipt whose response
|
||||
// was lost. A finished task must not block every future remote upgrade.
|
||||
return err==ESP_OK || (!strcmp(platform_last_error_code(),"OTA_FINISHED") && strcmp(status,"accepted"));
|
||||
}
|
||||
static bool same_origin_download(const char *url){
|
||||
const char *origin=ble_cloud_origin();size_t n=strlen(origin);
|
||||
return !strncmp(origin,"https://",8) && !strncmp(url,origin,n) && !strncmp(url+n,"/f/",3) &&
|
||||
strlen(url)<256 && !strchr(url,'\r') && !strchr(url,'\n');
|
||||
}
|
||||
typedef struct {
|
||||
int64_t start_us,begin_us,read_us,write_us,sha_us,end_us;
|
||||
int64_t read_max_us,write_max_us,sha_max_us;
|
||||
uint32_t reads,writes,partial_reads,eagain_reads;
|
||||
} ota_perf_t;
|
||||
static unsigned long ota_ms(int64_t us){return us>0?(unsigned long)(us/1000):0;}
|
||||
static void ota_elapsed(int64_t started,int64_t *sum,int64_t *maximum){
|
||||
int64_t elapsed=esp_timer_get_time()-started;
|
||||
*sum+=elapsed;if(maximum && elapsed>*maximum)*maximum=elapsed;
|
||||
}
|
||||
static esp_err_t ota_http_timeout(esp_http_client_handle_t client,int64_t deadline){
|
||||
int64_t remaining=deadline-esp_timer_get_time();if(remaining<1000)return ESP_ERR_TIMEOUT;
|
||||
int timeout_ms=remaining/1000;if(timeout_ms>20000)timeout_ms=20000;
|
||||
return esp_http_client_set_timeout_ms(client,timeout_ms);
|
||||
}
|
||||
typedef struct {
|
||||
ota_flash_op_t op;
|
||||
esp_ota_handle_t handle;
|
||||
const esp_partition_t *target;
|
||||
SemaphoreHandle_t done;
|
||||
esp_err_t result;
|
||||
} ota_flash_job_t;
|
||||
static void ota_flash_worker(void *arg){
|
||||
ota_flash_job_t *job=arg;
|
||||
switch(job->op){
|
||||
case OTA_FLASH_END:job->result=esp_ota_end(job->handle);break;
|
||||
case OTA_FLASH_BOOT:job->result=esp_ota_set_boot_partition(job->target);break;
|
||||
case OTA_FLASH_ROLLBACK:job->result=esp_ota_mark_app_invalid_rollback();break;
|
||||
default:job->result=ESP_ERR_INVALID_ARG;break;
|
||||
}
|
||||
xSemaphoreGive(job->done);
|
||||
for(;;)vTaskSuspend(NULL);
|
||||
}
|
||||
static esp_err_t ota_internal_flash_call(ota_flash_op_t op,esp_ota_handle_t handle,const esp_partition_t *target,bool *completed){
|
||||
if(completed)*completed=false;
|
||||
SemaphoreHandle_t done=xSemaphoreCreateBinary();
|
||||
if(!done)return ESP_ERR_NO_MEM;
|
||||
ota_flash_job_t job={.op=op,.handle=handle,.target=target,.done=done,.result=ESP_FAIL};
|
||||
TaskHandle_t worker=NULL;
|
||||
// Image verification remaps flash and freezes caches. The control worker
|
||||
// has a PSRAM stack, so end, boot selection and rollback need a DRAM stack.
|
||||
// SDK WithCaps deletion suspends and joins a non-current task before freeing
|
||||
// its stack/TCB; the worker never deletes itself or allocates a cleanup task.
|
||||
if(xTaskCreateWithCaps(ota_flash_worker,"ota_flash",8192,&job,3,&worker,
|
||||
MALLOC_CAP_INTERNAL|MALLOC_CAP_8BIT)!=pdPASS){vSemaphoreDelete(done);return ESP_ERR_NO_MEM;}
|
||||
xSemaphoreTake(done,portMAX_DELAY);
|
||||
vTaskDeleteWithCaps(worker);
|
||||
vSemaphoreDelete(done);
|
||||
if(completed)*completed=true;
|
||||
return job.result;
|
||||
}
|
||||
static esp_err_t download(const char *url,const esp_partition_t *target){
|
||||
if(!same_origin_download(url))return ESP_ERR_INVALID_ARG;
|
||||
const esp_partition_t *running=esp_ota_get_running_partition();
|
||||
if(!target || !running || target->address==running->address || state.size<1024 || state.size>target->size)return ESP_ERR_INVALID_SIZE;
|
||||
ota_perf_t perf={.start_us=esp_timer_get_time()};
|
||||
const int64_t deadline=perf.start_us+900000000;
|
||||
// Flash cannot directly program from PSRAM on SPI1: the SDK otherwise
|
||||
// copies through a 32-byte temporary buffer. Keep the write buffer in DRAM.
|
||||
// Match the SDK's 8KiB flash write chunk without exhausting internal heap.
|
||||
size_t buf_size=8192;unsigned char *buf=NULL;
|
||||
for(;buf_size>=4096;buf_size/=2){
|
||||
buf=heap_caps_malloc(buf_size,MALLOC_CAP_INTERNAL|MALLOC_CAP_8BIT);if(buf)break;
|
||||
}
|
||||
if(!buf)return ESP_ERR_NO_MEM;
|
||||
esp_http_client_config_t cfg={.url=url,.crt_bundle_attach=esp_crt_bundle_attach,.timeout_ms=20000,
|
||||
.disable_auto_redirect=true,.buffer_size=16384,.buffer_size_tx=512,.keep_alive_enable=false};
|
||||
esp_http_client_handle_t client=esp_http_client_init(&cfg);if(!client){heap_caps_free(buf);return ESP_ERR_NO_MEM;}
|
||||
esp_ota_handle_t handle=0;bool active=false;
|
||||
unsigned char hash[32];mbedtls_sha256_context sha;mbedtls_sha256_init(&sha);
|
||||
const char *stage="open";
|
||||
esp_err_t err=ota_http_timeout(client,deadline);
|
||||
if(err==ESP_OK)err=esp_http_client_open(client,0);
|
||||
if(err==ESP_OK && esp_timer_get_time()>=deadline){stage="deadline";err=ESP_ERR_TIMEOUT;}
|
||||
if(err==ESP_OK){stage="headers";err=ota_http_timeout(client,deadline);}
|
||||
if(err==ESP_OK){int64_t length=esp_http_client_fetch_headers(client);
|
||||
if(esp_http_client_get_status_code(client)!=200 || length!=(int64_t)state.size)err=ESP_ERR_INVALID_SIZE;}
|
||||
if(err==ESP_OK && esp_timer_get_time()>=deadline){stage="deadline";err=ESP_ERR_TIMEOUT;}
|
||||
if(err==ESP_OK){
|
||||
stage="begin";int64_t started=esp_timer_get_time();
|
||||
// A known length lets the SDK combine aligned 4KiB sectors into 64KiB
|
||||
// erases before receiving the body. Never erase the running A/B slot.
|
||||
err=esp_ota_begin(target,state.size,&handle);active=handle!=0;
|
||||
ota_elapsed(started,&perf.begin_us,NULL);
|
||||
// The SDK may assign its handle before an erase failure; abort it below.
|
||||
}
|
||||
if(err==ESP_OK && esp_timer_get_time()>=deadline){stage="deadline";err=ESP_ERR_TIMEOUT;}
|
||||
if(err==ESP_OK){stage="sha.init";int64_t started=esp_timer_get_time();
|
||||
if(mbedtls_sha256_starts(&sha,0)!=0)err=ESP_FAIL;
|
||||
ota_elapsed(started,&perf.sha_us,&perf.sha_max_us);}
|
||||
uint32_t total=0;int64_t heartbeat_due=0,progress_due=perf.start_us+30000000;
|
||||
ESP_LOGI(TAG,"OTA buffer: internal=%u free_internal=%u largest_internal=%u erase_ms=%lu",
|
||||
(unsigned)buf_size,(unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL|MALLOC_CAP_8BIT),
|
||||
(unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL|MALLOC_CAP_8BIT),ota_ms(perf.begin_us));
|
||||
unsigned stalled_reads=0;int read_rc=0,socket_errno=0;
|
||||
while(err==ESP_OK && total<state.size){
|
||||
if(esp_timer_get_time()>=heartbeat_due){device_control_heartbeat();heartbeat_due=esp_timer_get_time()+30000000;}
|
||||
stage="timeout";err=ota_http_timeout(client,deadline);
|
||||
if(err!=ESP_OK){if(err==ESP_ERR_TIMEOUT)stage="deadline";break;}
|
||||
size_t wanted=state.size-total;if(wanted>buf_size)wanted=buf_size;
|
||||
stage="read";int64_t started=esp_timer_get_time();
|
||||
int count=esp_http_client_read(client,(char*)buf,wanted);read_rc=count;perf.reads++;
|
||||
int64_t read_before=perf.read_us;
|
||||
ota_elapsed(started,&perf.read_us,&perf.read_max_us);
|
||||
socket_errno=count<=0?esp_http_client_get_errno(client):0;
|
||||
if(count>0 && (size_t)count<wanted)perf.partial_reads++;
|
||||
if(count==-ESP_ERR_HTTP_EAGAIN)perf.eagain_reads++;
|
||||
if(perf.read_us-read_before>=1000000)ESP_LOGI(TAG,"OTA slow read: off=%lu wanted=%u rc=%d ms=%lu",
|
||||
(unsigned long)total,(unsigned)wanted,count,ota_ms(perf.read_us-read_before));
|
||||
if(esp_timer_get_time()>=deadline){stage="deadline";err=ESP_ERR_TIMEOUT;break;}
|
||||
// A timed-out read leaves this connection and write offset intact.
|
||||
// Three consecutive 20s waits bound a stall; real progress resets it.
|
||||
if(count==-ESP_ERR_HTTP_EAGAIN){if(++stalled_reads<3)continue;err=ESP_ERR_HTTP_EAGAIN;break;}
|
||||
if(count<=0){err=ESP_FAIL;break;}
|
||||
stalled_reads=0;stage="write";
|
||||
started=esp_timer_get_time();err=esp_ota_write(handle,buf,count);perf.writes++;
|
||||
ota_elapsed(started,&perf.write_us,&perf.write_max_us);
|
||||
if(err!=ESP_OK)break;
|
||||
total+=count;
|
||||
stage="sha.upd";started=esp_timer_get_time();if(mbedtls_sha256_update(&sha,buf,count)!=0)err=ESP_FAIL;
|
||||
ota_elapsed(started,&perf.sha_us,&perf.sha_max_us);
|
||||
if(err==ESP_OK && esp_timer_get_time()>=deadline){stage="deadline";err=ESP_ERR_TIMEOUT;}
|
||||
int64_t now=esp_timer_get_time();
|
||||
if(now>=progress_due){
|
||||
ESP_LOGI(TAG,"OTA progress: bytes=%lu/%lu ms=%lu read_ms=%lu write_ms=%lu",
|
||||
(unsigned long)total,(unsigned long)state.size,ota_ms(now-perf.start_us),ota_ms(perf.read_us),ota_ms(perf.write_us));
|
||||
progress_due=now+30000000;
|
||||
}
|
||||
}
|
||||
if(err==ESP_OK){stage="sha.end";char hex[65];int64_t started=esp_timer_get_time();if(mbedtls_sha256_finish(&sha,hash)!=0)err=ESP_FAIL;
|
||||
else{hash_hex(hash,hex);if(strcmp(hex,state.sha))err=ESP_ERR_INVALID_CRC;
|
||||
else ESP_LOGI(TAG,"OTA image digest verified: bytes=%lu",(unsigned long)total);}
|
||||
ota_elapsed(started,&perf.sha_us,&perf.sha_max_us);}
|
||||
if(err==ESP_OK && esp_timer_get_time()>=deadline){stage="deadline";err=ESP_ERR_TIMEOUT;}
|
||||
// Release TLS and the DRAM write buffer before allocating the temporary
|
||||
// verification stack, keeping the receive and verification peaks separate.
|
||||
mbedtls_sha256_free(&sha);heap_caps_free(buf);buf=NULL;
|
||||
esp_http_client_close(client);esp_http_client_cleanup(client);client=NULL;
|
||||
if(err==ESP_OK && esp_timer_get_time()>=deadline){stage="deadline";err=ESP_ERR_TIMEOUT;}
|
||||
if(err==ESP_OK){stage="end";int64_t started=esp_timer_get_time();bool completed=false;
|
||||
err=ota_internal_flash_call(OTA_FLASH_END,handle,NULL,&completed);if(completed)active=false;
|
||||
ota_elapsed(started,&perf.end_us,NULL);}
|
||||
int64_t elapsed=esp_timer_get_time()-perf.start_us;
|
||||
if(err==ESP_OK && elapsed>=900000000){stage="deadline";err=ESP_ERR_TIMEOUT;}
|
||||
ESP_LOGI(TAG,"OTA timing: bytes=%lu total_ms=%lu erase_ms=%lu read_ms=%lu/%lu write_ms=%lu/%lu sha_ms=%lu/%lu end_ms=%lu reads=%lu writes=%lu partial=%lu eagain=%lu",
|
||||
(unsigned long)total,ota_ms(elapsed),ota_ms(perf.begin_us),ota_ms(perf.read_us),ota_ms(perf.read_max_us),
|
||||
ota_ms(perf.write_us),ota_ms(perf.write_max_us),ota_ms(perf.sha_us),ota_ms(perf.sha_max_us),ota_ms(perf.end_us),
|
||||
(unsigned long)perf.reads,(unsigned long)perf.writes,(unsigned long)perf.partial_reads,(unsigned long)perf.eagain_reads);
|
||||
if(err!=ESP_OK)snprintf(state.message,sizeof(state.message),
|
||||
"OTA %s e=0x%x off=%lu/%lu rc=%d errno=%d; original retained",stage,(unsigned)err,
|
||||
(unsigned long)total,(unsigned long)state.size,read_rc,socket_errno);
|
||||
else snprintf(state.message,sizeof(state.message),"OTA ms=%lu erase=%lu read=%lu write=%lu sha=%lu buf=%u",
|
||||
ota_ms(elapsed),ota_ms(perf.begin_us),ota_ms(perf.read_us),ota_ms(perf.write_us),ota_ms(perf.sha_us),(unsigned)buf_size);
|
||||
if(active)esp_ota_abort(handle);
|
||||
return err;
|
||||
}
|
||||
void device_ota_poll(void){
|
||||
if(!ready)return;
|
||||
if(state.phase==3 || state.phase==4){
|
||||
if(!report(state.phase==3?"success":"failed"))return;
|
||||
uint32_t previous=state.phase;state.phase=0;
|
||||
if(save()!=ESP_OK){state.phase=previous;return;}
|
||||
ESP_LOGI(TAG,"OTA terminal receipt settled");
|
||||
}
|
||||
char id[37];if(!requests || xQueueReceive(requests,id,0)!=pdTRUE)return;
|
||||
if(!strcmp(id,state.id))return;
|
||||
if(strcmp(id,retry_id)){memcpy(retry_id,id,sizeof(retry_id));manifest_retries=0;}
|
||||
bool idle=call_client_enter_maintenance();
|
||||
cJSON *j=cJSON_CreateObject(),*manifest=NULL;cJSON_AddStringToObject(j,"task_id",id);
|
||||
esp_err_t err=post("/api/v1/device/ota/manifest",j,&manifest);cJSON_Delete(j);
|
||||
if(err!=ESP_OK){
|
||||
// MQTT QoS acknowledges delivery before this worker fetches HTTPS. A
|
||||
// transient HTTP error must not silently discard the only wakeup.
|
||||
// Re-fetch at most twice; the server still enforces the original 30s
|
||||
// acceptance deadline and no URL/digest from MQTT is ever trusted.
|
||||
if(strcmp(platform_last_error_code(),"OTA_TASK_UNAVAILABLE") && ++manifest_retries<=2)device_ota_request(id);
|
||||
if(idle)call_client_leave_maintenance();
|
||||
return;
|
||||
}
|
||||
retry_id[0]=0;manifest_retries=0;
|
||||
const esp_partition_t *target=esp_ota_get_next_update_partition(NULL);
|
||||
const char *digest=str(manifest,"sha256"),*version=str(manifest,"version");
|
||||
int64_t code=number(manifest,"version_code"),size=number(manifest,"size");
|
||||
struct timeval now;gettimeofday(&now,NULL);
|
||||
bool valid=target && size>=1024 && size<=target->size && code>device_ota_version_code() &&
|
||||
!strcmp(id,str(manifest,"task_id")) && !strcmp(ble_factory_model(),str(manifest,"model")) &&
|
||||
strlen(digest)==64 && strspn(digest,"0123456789abcdef")==64 && strlen(version)>0 && strlen(version)<81 &&
|
||||
(int64_t)now.tv_sec*1000+now.tv_usec/1000<number(manifest,"expires_at_ms") && same_origin_download(str(manifest,"download_url"));
|
||||
if(!valid || !idle){
|
||||
memset(&state,0,sizeof(state));state.format=1;state.phase=4;
|
||||
snprintf(state.id,sizeof(state.id),"%s",id);
|
||||
snprintf(state.message,sizeof(state.message),"%s",idle?"Image incompatible, too large, obsolete or expired":"Device busy; conversation was not interrupted");
|
||||
save();cJSON_Delete(manifest);if(idle)call_client_leave_maintenance();return;
|
||||
}
|
||||
memset(&state,0,sizeof(state));state.format=1;state.phase=1;state.address=target->address;state.size=size;state.code=code;
|
||||
snprintf(state.id,sizeof(state.id),"%s",id);snprintf(state.version,sizeof(state.version),"%s",version);snprintf(state.sha,sizeof(state.sha),"%s",digest);
|
||||
err=save();
|
||||
if(err==ESP_OK && !report("accepted"))err=ESP_FAIL;
|
||||
if(err==ESP_OK)ESP_LOGI(TAG,"OTA accepted: target_slot=0x%lx bytes=%lu version_code=%lld",(unsigned long)target->address,(unsigned long)state.size,(long long)state.code);
|
||||
if(err==ESP_OK)err=download(str(manifest,"download_url"),target);
|
||||
cJSON_Delete(manifest);
|
||||
if(err==ESP_OK){state.phase=2;err=save();
|
||||
if(err!=ESP_OK)snprintf(state.message,sizeof(state.message),"OTA journal failed: %s; original retained",esp_err_to_name(err));}
|
||||
if(err==ESP_OK){err=ota_internal_flash_call(OTA_FLASH_BOOT,0,target,NULL);
|
||||
if(err!=ESP_OK)snprintf(state.message,sizeof(state.message),"OTA boot selection failed: %s; original retained",esp_err_to_name(err));}
|
||||
if(err==ESP_OK){ESP_LOGI(TAG,"Verified image installed; restarting for boot validation");device_control_prepare_restart();esp_restart();}
|
||||
state.phase=4;if(!state.message[0])snprintf(state.message,sizeof(state.message),"OTA failed: %s; original firmware retained",esp_err_to_name(err));save();
|
||||
call_client_leave_maintenance();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
#include "esp_err.h"
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
void device_ota_boot_guard(void);
|
||||
void device_ota_boot_ready(void);
|
||||
bool device_ota_supported(void);
|
||||
const char *device_ota_version(void);
|
||||
int64_t device_ota_version_code(void);
|
||||
void device_ota_poll(void);
|
||||
// MQTT is a wakeup only; the manifest is fetched over factory-authenticated TLS.
|
||||
void device_ota_request(const char *task_id);
|
||||
@@ -0,0 +1,76 @@
|
||||
#include "device_power.h"
|
||||
#include "call_client.h"
|
||||
#include "audio_processor.h"
|
||||
#include "volc_hal.h"
|
||||
#include "nvs.h"
|
||||
#include <string.h>
|
||||
|
||||
static int active_brightness=80;
|
||||
|
||||
esp_err_t device_power_boot(void) {
|
||||
nvs_handle_t n;uint8_t standby=0;
|
||||
esp_err_t e=nvs_open("control",NVS_READONLY,&n);
|
||||
if(e==ESP_OK) {
|
||||
e=nvs_get_u8(n,"standby",&standby);nvs_close(n);
|
||||
if(e!=ESP_OK && e!=ESP_ERR_NVS_NOT_FOUND)return e;
|
||||
} else if(e!=ESP_ERR_NVS_NOT_FOUND)return e;
|
||||
if(standby>1)return ESP_ERR_INVALID_STATE;
|
||||
e=call_client_set_standby(standby!=0);
|
||||
if(e==ESP_OK)e=audio_recorder_set_standby(standby!=0);
|
||||
volc_hal_context_t *ctx=volc_get_global_hal_context();
|
||||
if(e==ESP_OK && standby && (!ctx || volc_hal_display_set_brightness(ctx->display_handle,0)))return ESP_FAIL;
|
||||
return e;
|
||||
}
|
||||
|
||||
esp_err_t device_power_apply(const char *mode, bool maintenance_held) {
|
||||
if(!mode || (strcmp(mode,"active") && strcmp(mode,"standby")))return ESP_ERR_INVALID_ARG;
|
||||
bool standby=!strcmp(mode,"standby");
|
||||
if(!maintenance_held && !call_client_enter_maintenance())return ESP_ERR_INVALID_STATE;
|
||||
esp_err_t e=ESP_OK;
|
||||
volc_hal_context_t *ctx=volc_get_global_hal_context();
|
||||
int brightness=ctx?volc_hal_display_get_brightness(ctx->display_handle):-1;
|
||||
if(brightness<0){e=ESP_FAIL;goto done;}
|
||||
if(standby && !call_client_standby() && brightness>0)active_brightness=brightness;
|
||||
// Keep calls excluded until the microphone and display have both changed.
|
||||
if(standby) {
|
||||
e=call_client_set_standby(true);
|
||||
if(e==ESP_OK && !audio_recorder_is_standby())e=audio_recorder_set_standby(true);
|
||||
if(e==ESP_OK && brightness!=0 && volc_hal_display_set_brightness(ctx->display_handle,0))e=ESP_FAIL;
|
||||
} else {
|
||||
if(brightness==0 && volc_hal_display_set_brightness(ctx->display_handle,active_brightness))e=ESP_FAIL;
|
||||
if(e==ESP_OK)e=audio_recorder_set_standby(false);
|
||||
if(e==ESP_OK)e=call_client_set_standby(false);
|
||||
}
|
||||
if(e!=ESP_OK)goto done;
|
||||
brightness=volc_hal_display_get_brightness(ctx->display_handle);
|
||||
if(brightness<0 || (standby ? brightness!=0 || !audio_recorder_is_standby() : brightness==0 || audio_recorder_is_standby())){e=ESP_FAIL;goto done;}
|
||||
nvs_handle_t n;e=nvs_open("control",NVS_READWRITE,&n);
|
||||
if(e==ESP_OK) {
|
||||
uint8_t stored=2;
|
||||
esp_err_t read=nvs_get_u8(n,"standby",&stored);
|
||||
if(read!=ESP_OK && read!=ESP_ERR_NVS_NOT_FOUND)e=read;
|
||||
else if(stored!=(uint8_t)standby) {
|
||||
e=nvs_set_u8(n,"standby",standby);
|
||||
if(e==ESP_OK)e=nvs_commit(n);
|
||||
if(e==ESP_OK)e=nvs_get_u8(n,"standby",&stored);
|
||||
if(e==ESP_OK && stored!=(uint8_t)standby)e=ESP_FAIL;
|
||||
}
|
||||
nvs_close(n);
|
||||
}
|
||||
done:
|
||||
if(!maintenance_held)call_client_leave_maintenance();
|
||||
return e;
|
||||
}
|
||||
|
||||
cJSON *device_power_report(void) {
|
||||
volc_hal_context_t *ctx=volc_get_global_hal_context();
|
||||
int brightness=ctx?volc_hal_display_get_brightness(ctx->display_handle):-1;
|
||||
bool microphone=audio_recorder_is_standby(),standby=call_client_standby();
|
||||
const char *mode=standby && microphone && brightness==0?"standby":!standby && !microphone && brightness>0?"active":"transition";
|
||||
cJSON *j=cJSON_CreateObject();
|
||||
cJSON_AddStringToObject(j,"mode",mode);
|
||||
cJSON_AddBoolToObject(j,"microphone_paused",microphone);
|
||||
cJSON_AddNumberToObject(j,"backlight_percent",brightness);
|
||||
cJSON_AddNumberToObject(j,"microphone_reads",audio_recorder_microphone_reads());
|
||||
return j;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
#pragma once
|
||||
#include "esp_err.h"
|
||||
#include "cJSON.h"
|
||||
#include <stdbool.h>
|
||||
// Restore the microphone/call gate before audio initialization.
|
||||
esp_err_t device_power_boot(void);
|
||||
esp_err_t device_power_apply(const char *mode, bool maintenance_held);
|
||||
cJSON *device_power_report(void);
|
||||
@@ -0,0 +1,122 @@
|
||||
#include "device_reset.h"
|
||||
#include "call_client.h"
|
||||
#include "device_ota.h"
|
||||
#include "ble_cloud.h"
|
||||
#include "cJSON.h"
|
||||
#include "nvs.h"
|
||||
#include "mbedtls/platform_util.h"
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
typedef struct { uint32_t version,phase; char body[512]; } reset_record_t;
|
||||
// phase 1: request persisted, result uncertain; 2: cloud committed, cleanup
|
||||
// pending; 3: local cleanup committed. Never delete this recovery namespace.
|
||||
static esp_err_t load(reset_record_t *r) {
|
||||
memset(r,0,sizeof(*r));nvs_handle_t n;
|
||||
esp_err_t e=nvs_open("factory_reset",NVS_READONLY,&n);
|
||||
if(e==ESP_ERR_NVS_NOT_FOUND)return ESP_OK;
|
||||
if(e!=ESP_OK)return e;
|
||||
size_t size=sizeof(*r);e=nvs_get_blob(n,"record",r,&size);nvs_close(n);
|
||||
if(e==ESP_ERR_NVS_NOT_FOUND){memset(r,0,sizeof(*r));return ESP_OK;}
|
||||
if(e==ESP_OK && (size!=sizeof(*r)||r->version!=1||r->phase<1||r->phase>3||!memchr(r->body,0,sizeof(r->body))))return ESP_ERR_INVALID_STATE;
|
||||
return e;
|
||||
}
|
||||
static esp_err_t save(const reset_record_t *r) {
|
||||
nvs_handle_t n;esp_err_t e=nvs_open("factory_reset",NVS_READWRITE,&n);
|
||||
if(e!=ESP_OK)return e;
|
||||
e=nvs_set_blob(n,"record",r,sizeof(*r));if(e==ESP_OK)e=nvs_commit(n);
|
||||
reset_record_t check;size_t size=sizeof(check);
|
||||
if(e==ESP_OK)e=nvs_get_blob(n,"record",&check,&size);
|
||||
if(e==ESP_OK && (size!=sizeof(check)||memcmp(r,&check,size)))e=ESP_FAIL;
|
||||
nvs_close(n);return e;
|
||||
}
|
||||
static esp_err_t clear_keys(const char *space,const char *const *keys) {
|
||||
nvs_handle_t n;esp_err_t e=nvs_open(space,NVS_READWRITE,&n);if(e!=ESP_OK)return e;
|
||||
if(!keys)e=nvs_erase_all(n);
|
||||
else for(int i=0;keys[i]&&e==ESP_OK;i++) {
|
||||
e=nvs_erase_key(n,keys[i]);if(e==ESP_ERR_NVS_NOT_FOUND)e=ESP_OK;
|
||||
}
|
||||
if(e==ESP_OK)e=nvs_commit(n);
|
||||
nvs_close(n);return e;
|
||||
}
|
||||
static esp_err_t cleanup(reset_record_t *r) {
|
||||
// Keep firmware/OTA metadata and both forms of permanent device identity.
|
||||
const char *const control[]={"speaker","display_name","standby","result","probe",NULL};
|
||||
const char *const platform[]={"instance_id","product_key","device_name","volc_secret","pending_conv",NULL};
|
||||
esp_err_t e=clear_keys("voice",NULL);
|
||||
if(e==ESP_OK)e=clear_keys("platform",platform);
|
||||
if(e==ESP_OK)e=clear_keys("control",control);
|
||||
if(e==ESP_OK)e=clear_keys("ble_setup",NULL);
|
||||
if(e==ESP_OK)e=clear_keys("wifi",NULL);
|
||||
if(e==ESP_OK){r->phase=3;e=save(r);} // A cut before this commit repeats cleanup.
|
||||
return e;
|
||||
}
|
||||
esp_err_t device_reset_boot_cleanup(void) {
|
||||
reset_record_t r;esp_err_t e=load(&r);
|
||||
if(e==ESP_OK && r.phase==2){e=save(&r);if(e==ESP_OK)e=cleanup(&r);}
|
||||
return e;
|
||||
}
|
||||
static void wipe(char *s){if(s){mbedtls_platform_zeroize(s,strlen(s));free(s);}}
|
||||
static esp_err_t post(const char *path,const char *body,cJSON **data) {
|
||||
char *raw=NULL;esp_err_t e=platform_request("POST",path,body,NULL,&raw);
|
||||
if(e==ESP_OK) {
|
||||
cJSON *root=raw?cJSON_Parse(raw):NULL,*value=cJSON_GetObjectItemCaseSensitive(root,"data");
|
||||
if(!cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(root,"ok"))||!cJSON_IsObject(value))e=ESP_ERR_INVALID_RESPONSE;
|
||||
else {*data=cJSON_Duplicate(value,true);if(!*data)e=ESP_ERR_NO_MEM;}
|
||||
cJSON_Delete(root);
|
||||
}
|
||||
wipe(raw);return e;
|
||||
}
|
||||
static const char *str(const cJSON *j,const char *key) {
|
||||
cJSON *v=cJSON_GetObjectItemCaseSensitive(j,key);return cJSON_IsString(v)?v->valuestring:"";
|
||||
}
|
||||
static bool response_matches(const cJSON *data,const cJSON *request) {
|
||||
cJSON *actual=cJSON_GetObjectItemCaseSensitive(data,"reset_counter"),*expected=cJSON_GetObjectItemCaseSensitive(request,"reset_counter");
|
||||
if(strcmp(str(data,"reset_id"),str(request,"reset_id")) || strcmp(str(data,"activation_state"),"CLAIMABLE") ||
|
||||
strcmp(str(data,"license_action"),"RETAINED") || !cJSON_IsNumber(actual)||!cJSON_IsNumber(expected)||actual->valuedouble!=expected->valuedouble)return false;
|
||||
const char *required[]={"clear_volc_credential","clear_user_context","clear_session_state","clear_network_credentials","reboot_to_claimable"};
|
||||
cJSON *actions=cJSON_GetObjectItemCaseSensitive(data,"local_actions");
|
||||
if(!cJSON_IsArray(actions)||cJSON_GetArraySize(actions)!=5)return false;
|
||||
for(int i=0;i<5;i++) {int count=0;const cJSON *a=NULL;cJSON_ArrayForEach(a,actions)if(cJSON_IsString(a)&&!strcmp(a->valuestring,required[i]))count++;if(count!=1)return false;}
|
||||
return true;
|
||||
}
|
||||
esp_err_t device_reset_from_setup(const char *physical_event_id) {
|
||||
if(!physical_event_id||strlen(physical_event_id)!=36||!ble_cloud_configured())return ESP_ERR_INVALID_ARG;
|
||||
reset_record_t r;esp_err_t e=load(&r);if(e!=ESP_OK)return e;
|
||||
if(r.phase==2){e=save(&r);return e==ESP_OK?cleanup(&r):e;}
|
||||
// Same confirmed BLE job may retry after local cleanup finished.
|
||||
cJSON *request=r.phase?cJSON_Parse(r.body):NULL;
|
||||
if(r.phase==3 && request && !strcmp(str(request,"reset_id"),physical_event_id)){cJSON_Delete(request);return ESP_OK;}
|
||||
cJSON_Delete(request);request=NULL;
|
||||
// BLE setup boots before normal SNTP/call initialization. Establish signed
|
||||
// server time before DEVREQ1 timestamps and the durable occurred_at value.
|
||||
if(ble_cloud_sync_time()!=ESP_OK)return ESP_FAIL;
|
||||
if(r.phase!=1) {
|
||||
cJSON *state=NULL;e=post("/api/v1/device/factory-reset/state","{}",&state);
|
||||
if(e!=ESP_OK){cJSON_Delete(state);return e;}
|
||||
cJSON *counter=cJSON_GetObjectItemCaseSensitive(state,"reset_counter");
|
||||
if(!cJSON_IsNumber(counter)||counter->valuedouble<0||counter->valuedouble>=9007199254740991.0||counter->valuedouble!=(double)(int64_t)counter->valuedouble){cJSON_Delete(state);return ESP_ERR_INVALID_RESPONSE;}
|
||||
double next=counter->valuedouble+1;cJSON_Delete(state);
|
||||
time_t now=time(NULL);struct tm utc;char occurred[32];
|
||||
if(now<1700000000 || !gmtime_r(&now,&utc) || !strftime(occurred,sizeof(occurred),"%Y-%m-%dT%H:%M:%SZ",&utc))return ESP_ERR_INVALID_STATE;
|
||||
request=cJSON_CreateObject();if(!request)return ESP_ERR_NO_MEM;
|
||||
cJSON_AddStringToObject(request,"reset_id",physical_event_id);cJSON_AddNumberToObject(request,"reset_counter",next);
|
||||
cJSON_AddStringToObject(request,"occurred_at",occurred);cJSON_AddStringToObject(request,"trigger","device_menu");
|
||||
cJSON_AddStringToObject(request,"firmware_version",device_ota_version());cJSON_AddNumberToObject(request,"pending_turn_count",0);
|
||||
char *body=cJSON_PrintUnformatted(request);cJSON_Delete(request);request=NULL;
|
||||
if(!body)return ESP_ERR_NO_MEM;
|
||||
if(strlen(body)>=sizeof(r.body)){wipe(body);return ESP_ERR_INVALID_SIZE;}
|
||||
memset(&r,0,sizeof(r));r.version=1;r.phase=1;strcpy(r.body,body);wipe(body);
|
||||
if((e=save(&r))!=ESP_OK)return e;
|
||||
}
|
||||
request=cJSON_Parse(r.body);if(!request)return ESP_ERR_INVALID_STATE;
|
||||
cJSON *response=NULL;e=post("/api/v1/device/factory-reset",r.body,&response);
|
||||
if(e==ESP_OK && !response_matches(response,request))e=ESP_ERR_INVALID_RESPONSE;
|
||||
cJSON_Delete(request);cJSON_Delete(response);
|
||||
if(e!=ESP_OK)return e; // Keep the same signed body for uncertain retries.
|
||||
r.phase=2;if((e=save(&r))!=ESP_OK)return e;
|
||||
return cleanup(&r);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#pragma once
|
||||
#include "esp_err.h"
|
||||
|
||||
// Run immediately after NVS initialization, before BLE, audio or voice tasks.
|
||||
// Completes only a reset already authorized by the cloud and durably recorded.
|
||||
esp_err_t device_reset_boot_cleanup(void);
|
||||
// BLE provisioning worker only: after a fresh physical confirmation and Wi-Fi
|
||||
// connection. That boot mode never starts call/control/OTA workers.
|
||||
esp_err_t device_reset_from_setup(const char *physical_event_id);
|
||||
@@ -0,0 +1,100 @@
|
||||
#include "device_screen_wake.h"
|
||||
#include "device_power.h"
|
||||
#include "call_client.h"
|
||||
#include "esp_random.h"
|
||||
#include "nvs.h"
|
||||
#include <stdatomic.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
typedef struct { uint32_t version; int64_t revision; char id[37]; } wake_event_t;
|
||||
static wake_event_t pending;
|
||||
static atomic_bool requested;
|
||||
static bool awake;
|
||||
|
||||
static esp_err_t save_pending(bool keep)
|
||||
{
|
||||
nvs_handle_t n;
|
||||
esp_err_t err = nvs_open("control", NVS_READWRITE, &n);
|
||||
if (err != ESP_OK) return err;
|
||||
err = keep ? nvs_set_blob(n, "screen_wake", &pending, sizeof(pending))
|
||||
: nvs_erase_key(n, "screen_wake");
|
||||
if (err == ESP_ERR_NVS_NOT_FOUND) err = ESP_OK;
|
||||
if (err == ESP_OK) err = nvs_commit(n);
|
||||
nvs_close(n);
|
||||
return err;
|
||||
}
|
||||
|
||||
esp_err_t device_screen_wake_init(void)
|
||||
{
|
||||
memset(&pending, 0, sizeof(pending));
|
||||
awake = false;
|
||||
atomic_store(&requested, false);
|
||||
nvs_handle_t n;
|
||||
esp_err_t err = nvs_open("control", NVS_READONLY, &n);
|
||||
if (err == ESP_ERR_NVS_NOT_FOUND) return ESP_OK;
|
||||
if (err != ESP_OK) return err;
|
||||
size_t size = sizeof(pending);
|
||||
err = nvs_get_blob(n, "screen_wake", &pending, &size);
|
||||
nvs_close(n);
|
||||
if (err == ESP_ERR_NVS_NOT_FOUND) { memset(&pending, 0, sizeof(pending)); return ESP_OK; }
|
||||
if (err != ESP_OK) return err;
|
||||
if (size != sizeof(pending) || pending.version != 1 || pending.revision <= 0 ||
|
||||
pending.id[36] != 0 || strlen(pending.id) != 36) return ESP_ERR_INVALID_STATE;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void device_screen_wake_request(void) { atomic_store(&requested, true); }
|
||||
|
||||
esp_err_t device_screen_wake_poll(int64_t applied_revision)
|
||||
{
|
||||
if (atomic_exchange(&requested, false) && !pending.version && call_client_standby()) {
|
||||
// Retain a tap during initial configuration until its revision is known.
|
||||
// An offline tap after initialization needs no network access.
|
||||
if (applied_revision <= 0) { atomic_store(&requested, true); return ESP_OK; }
|
||||
uint32_t r[4];
|
||||
esp_fill_random(r, sizeof(r));
|
||||
pending.version = 1;
|
||||
pending.revision = applied_revision;
|
||||
snprintf(pending.id, sizeof(pending.id), "%08lx-%04x-4%03x-%04x-%04x%08lx",
|
||||
(unsigned long)r[0], (unsigned)(r[1] >> 16), (unsigned)(r[1] & 0xfff),
|
||||
(unsigned)((r[2] >> 16 & 0x3fff) | 0x8000), (unsigned)(r[2] & 0xffff),
|
||||
(unsigned long)r[3]);
|
||||
esp_err_t err = save_pending(true);
|
||||
if (err != ESP_OK) {
|
||||
memset(&pending, 0, sizeof(pending));
|
||||
atomic_store(&requested, true);
|
||||
return err;
|
||||
}
|
||||
}
|
||||
if (pending.version && !awake) {
|
||||
esp_err_t err = device_power_apply("active", false);
|
||||
if (err != ESP_OK) return err;
|
||||
awake = true;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void device_screen_wake_add_report(cJSON *report)
|
||||
{
|
||||
if (!pending.version || !awake) return;
|
||||
cJSON *event = cJSON_AddObjectToObject(report, "screen_wake");
|
||||
cJSON_AddStringToObject(event, "id", pending.id);
|
||||
cJSON_AddNumberToObject(event, "revision", pending.revision);
|
||||
}
|
||||
|
||||
esp_err_t device_screen_wake_ack(const cJSON *response)
|
||||
{
|
||||
if (!pending.version || !awake) return ESP_OK;
|
||||
const cJSON *event = cJSON_GetObjectItemCaseSensitive(response, "screen_wake");
|
||||
const cJSON *id = cJSON_GetObjectItemCaseSensitive(event, "id");
|
||||
const cJSON *revision = cJSON_GetObjectItemCaseSensitive(event, "revision");
|
||||
const cJSON *result = cJSON_GetObjectItemCaseSensitive(event, "result");
|
||||
if (!cJSON_IsString(id) || strcmp(id->valuestring, pending.id) ||
|
||||
!cJSON_IsNumber(revision) || revision->valuedouble != pending.revision ||
|
||||
!cJSON_IsString(result) || (strcmp(result->valuestring, "applied") &&
|
||||
strcmp(result->valuestring, "superseded"))) return ESP_ERR_INVALID_RESPONSE;
|
||||
esp_err_t err = save_pending(false);
|
||||
if (err == ESP_OK) { memset(&pending, 0, sizeof(pending)); awake = false; }
|
||||
return err;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
#include "esp_err.h"
|
||||
#include "cJSON.h"
|
||||
|
||||
// Only request() runs on the touch task. State/NVS and network work belong to
|
||||
// the control worker; the physical wake survives a reboot or lost HTTP reply.
|
||||
esp_err_t device_screen_wake_init(void);
|
||||
void device_screen_wake_request(void);
|
||||
esp_err_t device_screen_wake_poll(int64_t applied_revision);
|
||||
void device_screen_wake_add_report(cJSON *report);
|
||||
esp_err_t device_screen_wake_ack(const cJSON *response);
|
||||
@@ -0,0 +1,2 @@
|
||||
#pragma once
|
||||
#define DEVICE_FIRMWARE_VERSION "1.7.0-controls.32"
|
||||
@@ -9,11 +9,14 @@
|
||||
#include <esp_event.h>
|
||||
#include <esp_wifi.h>
|
||||
#include <esp_log.h>
|
||||
#include <esp_heap_caps.h>
|
||||
#include <esp_timer.h>
|
||||
#include <nvs.h>
|
||||
#include <nvs_flash.h>
|
||||
|
||||
#include "configuration_ap.h"
|
||||
#include "platform_client.h"
|
||||
#include "ble_cloud.h"
|
||||
|
||||
#define TAG "NETWORK"
|
||||
|
||||
@@ -21,8 +24,33 @@
|
||||
#define WIFI_EVENT_FAILED BIT1
|
||||
#define WIFI_EVENT_STARTED BIT2
|
||||
#define MAX_RECONNECT_COUNT 5
|
||||
#define RECONNECT_DELAY_US (10 * 1000 * 1000)
|
||||
|
||||
static network_t network;
|
||||
static esp_err_t network_error;
|
||||
static esp_timer_handle_t reconnect_timer;
|
||||
static bool reconnect_enabled;
|
||||
esp_err_t network_last_error(void) { return network_error; }
|
||||
|
||||
static void reconnect_saved_network(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
if (!reconnect_enabled ||
|
||||
(xEventGroupGetBits(network.event_group) & WIFI_EVENT_CONNECTED)) return;
|
||||
esp_err_t err = esp_wifi_connect();
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "WiFi reconnect deferred: %s", esp_err_to_name(err));
|
||||
esp_timer_start_once(reconnect_timer, RECONNECT_DELAY_US);
|
||||
}
|
||||
}
|
||||
|
||||
static void schedule_reconnect(void)
|
||||
{
|
||||
// Fast retries recover a short interruption; slower retries continue for as
|
||||
// long as the saved network is unavailable, including a phone hotspot.
|
||||
if (reconnect_enabled && reconnect_timer && !esp_timer_is_active(reconnect_timer))
|
||||
esp_timer_start_once(reconnect_timer, RECONNECT_DELAY_US);
|
||||
}
|
||||
|
||||
int8_t get_rssi()
|
||||
{
|
||||
@@ -49,6 +77,8 @@ void handler_event_wifi_st(void *arg, esp_event_base_t event_base, int32_t event
|
||||
else if (event_id == WIFI_EVENT_STA_DISCONNECTED)
|
||||
{
|
||||
xEventGroupClearBits(network.event_group, WIFI_EVENT_CONNECTED);
|
||||
network.ip_address[0] = '\0';
|
||||
if (!reconnect_enabled) return;
|
||||
if (network.reconnect_count < MAX_RECONNECT_COUNT)
|
||||
{
|
||||
/* Retry the selected AP first, but allow another AP if it is no longer
|
||||
@@ -62,35 +92,47 @@ void handler_event_wifi_st(void *arg, esp_event_base_t event_base, int32_t event
|
||||
ESP_LOGW(TAG, "Preferred AP unavailable; falling back to SSID scan");
|
||||
}
|
||||
}
|
||||
esp_wifi_connect();
|
||||
esp_err_t err = esp_wifi_connect();
|
||||
network.reconnect_count++;
|
||||
ESP_LOGI(TAG, "Reconnecting WiFi (attempt %d)", network.reconnect_count);
|
||||
if (err != ESP_OK) schedule_reconnect();
|
||||
}
|
||||
else
|
||||
{
|
||||
xEventGroupSetBits(network.event_group, WIFI_EVENT_FAILED);
|
||||
ESP_LOGI(TAG, "WiFi connection failed");
|
||||
schedule_reconnect();
|
||||
ESP_LOGI(TAG, "WiFi unavailable; retrying every 10 seconds");
|
||||
}
|
||||
}
|
||||
else if (event_id == WIFI_EVENT_STA_STOP)
|
||||
{
|
||||
reconnect_enabled = false;
|
||||
if (reconnect_timer) esp_timer_stop(reconnect_timer);
|
||||
xEventGroupClearBits(network.event_group, WIFI_EVENT_CONNECTED | WIFI_EVENT_STARTED);
|
||||
network.ip_address[0] = '\0';
|
||||
}
|
||||
}
|
||||
|
||||
void handler_event_ip_st(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data)
|
||||
{
|
||||
ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data;
|
||||
network.reconnect_count = 0;
|
||||
if (reconnect_timer) esp_timer_stop(reconnect_timer);
|
||||
|
||||
esp_ip4addr_ntoa(&event->ip_info.ip, network.ip_address, sizeof(network.ip_address));
|
||||
ESP_LOGI(TAG, "Got IP: %s", network.ip_address);
|
||||
xEventGroupClearBits(network.event_group, WIFI_EVENT_FAILED);
|
||||
xEventGroupSetBits(network.event_group, WIFI_EVENT_CONNECTED);
|
||||
}
|
||||
|
||||
bool is_connected()
|
||||
{
|
||||
return xEventGroupGetBits(network.event_group) & WIFI_EVENT_CONNECTED;
|
||||
return network.event_group && (xEventGroupGetBits(network.event_group) & WIFI_EVENT_CONNECTED);
|
||||
}
|
||||
|
||||
bool configure_network()
|
||||
{
|
||||
network_error=ESP_OK;
|
||||
|
||||
// Create the event group
|
||||
network.ssid[0] = '\0';
|
||||
@@ -98,6 +140,15 @@ bool configure_network()
|
||||
network.reconnect_count = 0;
|
||||
network.ip_address[0] = '\0';
|
||||
network.event_group = xEventGroupCreate();
|
||||
if(!network.event_group){network_error=ESP_ERR_NO_MEM;return false;}
|
||||
if (!reconnect_timer) {
|
||||
const esp_timer_create_args_t timer_config = {
|
||||
.callback = reconnect_saved_network,
|
||||
.name = "wifi_reconnect"
|
||||
};
|
||||
network_error = esp_timer_create(&timer_config, &reconnect_timer);
|
||||
if (network_error != ESP_OK) return false;
|
||||
}
|
||||
|
||||
// Get ssid and password from NVS
|
||||
nvs_handle_t nvs_handle;
|
||||
@@ -143,7 +194,25 @@ bool configure_network()
|
||||
|
||||
// Initialize the WiFi stack in station mode
|
||||
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
|
||||
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
|
||||
// RTC and TLS share internal RAM with the Wi-Fi DMA buffers. Six static
|
||||
// RX/TX frames retain streaming capacity without reserving 32 DMA frames.
|
||||
cfg.static_rx_buf_num=6;
|
||||
cfg.static_tx_buf_num=6;
|
||||
// Dynamic RX buffers prefer PSRAM; match the 32KiB TCP receive window.
|
||||
cfg.dynamic_rx_buf_num=32;
|
||||
cfg.cache_tx_buf_num=8;
|
||||
cfg.rx_ba_win=6;
|
||||
ESP_LOGI(TAG,"WiFi init internal_free=%u largest=%u rx=%d tx=%d",
|
||||
(unsigned)heap_caps_get_free_size(MALLOC_CAP_INTERNAL),
|
||||
(unsigned)heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL),cfg.static_rx_buf_num,cfg.static_tx_buf_num);
|
||||
network_error=esp_wifi_init(&cfg);
|
||||
if(network_error!=ESP_OK){
|
||||
ESP_LOGE(TAG,"WiFi initialization unavailable: %s",esp_err_to_name(network_error));
|
||||
esp_event_handler_instance_unregister(WIFI_EVENT,ESP_EVENT_ANY_ID,instance_any_id);
|
||||
esp_event_handler_instance_unregister(IP_EVENT,IP_EVENT_STA_GOT_IP,instance_got_ip);
|
||||
esp_netif_destroy_default_wifi(station_netif);
|
||||
return false; // No abort/reboot loop on insufficient internal RAM.
|
||||
}
|
||||
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
|
||||
ESP_ERROR_CHECK(esp_wifi_set_storage(WIFI_STORAGE_RAM));
|
||||
|
||||
@@ -158,6 +227,7 @@ bool configure_network()
|
||||
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
|
||||
|
||||
// Start the WiFi stack
|
||||
reconnect_enabled = true;
|
||||
ESP_ERROR_CHECK(esp_wifi_start());
|
||||
ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE));
|
||||
xEventGroupWaitBits(network.event_group, WIFI_EVENT_STARTED, pdFALSE, pdFALSE, pdMS_TO_TICKS(5000));
|
||||
@@ -190,6 +260,8 @@ bool configure_network()
|
||||
// Reset the WiFi stack
|
||||
|
||||
// 取消注册事件处理程序
|
||||
reconnect_enabled = false;
|
||||
esp_timer_stop(reconnect_timer);
|
||||
ESP_ERROR_CHECK(esp_event_handler_instance_unregister(WIFI_EVENT, ESP_EVENT_ANY_ID, instance_any_id));
|
||||
ESP_ERROR_CHECK(esp_event_handler_instance_unregister(IP_EVENT, IP_EVENT_STA_GOT_IP, instance_got_ip));
|
||||
ESP_ERROR_CHECK(esp_wifi_stop());
|
||||
@@ -205,6 +277,18 @@ bool configure_network()
|
||||
|
||||
if (!is_connected())
|
||||
{
|
||||
if (ble_cloud_configured()) {
|
||||
// Completed BLE devices need the same phone flow when taken away from
|
||||
// their old Wi-Fi. Reboot to release normal-mode buffers before NimBLE.
|
||||
network_error = nvs_open("wifi", NVS_READWRITE, &nvs_handle);
|
||||
if (network_error == ESP_OK) {
|
||||
network_error = nvs_set_u8(nvs_handle, "setup_requested", 1);
|
||||
if (network_error == ESP_OK) network_error = nvs_commit(nvs_handle);
|
||||
nvs_close(nvs_handle);
|
||||
}
|
||||
ESP_LOGI(TAG, "Saved WiFi unavailable; requesting Bluetooth setup");
|
||||
return false;
|
||||
}
|
||||
configure_ap();
|
||||
ESP_LOGI(TAG, "WiFi not connected, starting AP");
|
||||
return false;
|
||||
|
||||
@@ -8,10 +8,11 @@ typedef struct
|
||||
{
|
||||
EventGroupHandle_t event_group;
|
||||
|
||||
char ssid[32];
|
||||
char ssid[33];
|
||||
char password[64];
|
||||
int reconnect_count;
|
||||
char ip_address[16];
|
||||
} network_t;
|
||||
|
||||
bool configure_network();
|
||||
bool configure_network();
|
||||
esp_err_t network_last_error(void);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
#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"
|
||||
@@ -21,7 +24,7 @@
|
||||
#include "freertos/task.h"
|
||||
|
||||
#define PLATFORM_API_ORIGIN "https://121.41.46.238"
|
||||
#define PLATFORM_RESPONSE_MAX (32 * 1024)
|
||||
#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"
|
||||
|
||||
@@ -53,6 +56,15 @@ 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;
|
||||
@@ -159,7 +171,7 @@ esp_err_t platform_identity_save(const char *device_id, const char *device_secre
|
||||
|
||||
bool platform_identity_is_configured(void)
|
||||
{
|
||||
return strlen(s_device_id) == 36 && strlen(s_device_secret) >= 32;
|
||||
return ble_cloud_configured() || (strlen(s_device_id) == 36 && strlen(s_device_secret) >= 32);
|
||||
}
|
||||
|
||||
const char *platform_hardware_id(void)
|
||||
@@ -264,10 +276,10 @@ static esp_err_t platform_request_impl(const char *method, const char *path, con
|
||||
const char *idempotency_key, char **response_out)
|
||||
{
|
||||
s_last_error_code[0] = '\0';
|
||||
if (!platform_identity_is_configured()) return ESP_ERR_INVALID_STATE;
|
||||
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", PLATFORM_API_ORIGIN, path);
|
||||
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,
|
||||
};
|
||||
@@ -281,6 +293,7 @@ static esp_err_t platform_request_impl(const char *method, const char *path, con
|
||||
.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;
|
||||
@@ -300,12 +313,19 @@ static esp_err_t platform_request_impl(const char *method, const char *path, con
|
||||
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') {
|
||||
@@ -331,6 +351,7 @@ static esp_err_t platform_request_impl(const char *method, const char *path, con
|
||||
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) &&
|
||||
@@ -351,7 +372,7 @@ static esp_err_t platform_request_impl(const char *method, const char *path, con
|
||||
return err;
|
||||
}
|
||||
|
||||
static esp_err_t platform_request(const char *method, const char *path, const char *body,
|
||||
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;
|
||||
@@ -673,45 +694,65 @@ void platform_agent_session_free(platform_agent_session_t *session)
|
||||
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; }
|
||||
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;
|
||||
return platform_create_agent_session(&s_active_session);
|
||||
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 s_active_session.bot_id; }
|
||||
const char *platform_conversation_update_json(void) { return s_active_session.session_update_json; }
|
||||
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(s_heartbeat_interval_ms);
|
||||
s_heartbeat_due = xTaskGetTickCount() + pdMS_TO_TICKS(5000);
|
||||
return err;
|
||||
}
|
||||
|
||||
int platform_conversation_poll(void)
|
||||
{
|
||||
if (!s_active_session.conversation_id[0]) return 1;
|
||||
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(err == ESP_OK ? s_heartbeat_interval_ms : 5000);
|
||||
if (err == ESP_ERR_INVALID_STATE) return 1;
|
||||
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];
|
||||
@@ -758,6 +799,8 @@ esp_err_t platform_conversation_turn(const char *speaker, const char *source_id,
|
||||
|
||||
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",
|
||||
|
||||
@@ -56,6 +56,7 @@ const char *platform_conversation_bot_id(void);
|
||||
const char *platform_conversation_update_json(void);
|
||||
esp_err_t platform_conversation_started(const char *provider_connection_ref);
|
||||
int platform_conversation_poll(void);
|
||||
bool platform_conversation_audio_authorized(void);
|
||||
esp_err_t platform_conversation_turn(const char *speaker, const char *source_id, const char *text);
|
||||
void platform_conversation_finish(bool failed, const char *error_code);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user