#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 #include #include #include 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;ivaluestring[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; }