feat: expand companion platform, device controls, and memoir workflows
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:
Codex Local
2026-10-08 10:06:44 +08:00
parent 98fb2965d0
commit bf1233d5df
861 changed files with 94614 additions and 3209 deletions
@@ -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;
}