// Telemetry HTTP client — see telemetry_client.h. // // UNVERIFIED AGAINST REAL HARDWARE: written against ESP-IDF's documented // esp_http_client API and the design spec's contract (see header comment), // but never run against a live backend from real firmware. The request // shape (JSON body, header names) is copied verbatim from the spec, which // is binding for this workstream — see // docs/superpowers/specs/2026-07-23-esp32-sensor-node-design.md. #include #include #include #include "telemetry_client.h" #include "device_config.h" #include "sensor_registry.h" #include "wifi_manager.h" #include "esp_http_client.h" #include "esp_log.h" #include "cJSON.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" static const char *TAG = "telemetry_client"; // Matches the backend contract's own cap ("cap readings array length, e.g. // 64 per request") with headroom to spare for this firmware's small, // fixed set of drivers. #define TELEMETRY_MAX_READINGS 32 static esp_err_t build_request_body(const sensor_reading_t *readings, size_t count, char **out_json, size_t *out_len) { cJSON *root = cJSON_CreateObject(); if (root == NULL) { for (size_t i = 0; i < count; i++) { cJSON_Delete(readings[i].metadata); } return ESP_ERR_NO_MEM; } cJSON *arr = cJSON_AddArrayToObject(root, "readings"); if (arr == NULL) { cJSON_Delete(root); for (size_t i = 0; i < count; i++) { cJSON_Delete(readings[i].metadata); } return ESP_ERR_NO_MEM; } for (size_t i = 0; i < count; i++) { cJSON *item = cJSON_CreateObject(); if (item == NULL) { cJSON_Delete(root); for (size_t j = i; j < count; j++) { cJSON_Delete(readings[j].metadata); } return ESP_ERR_NO_MEM; } cJSON_AddStringToObject(item, "sensor_type", readings[i].sensor_type); cJSON_AddNumberToObject(item, "value", readings[i].value); cJSON_AddStringToObject(item, "unit", readings[i].unit); // metadata is required by the contract even when a driver has // nothing extra to say -- an empty object, never a missing key. cJSON *meta = readings[i].metadata ? readings[i].metadata : cJSON_CreateObject(); cJSON_AddItemToObject(item, "metadata", meta); cJSON_AddItemToArray(arr, item); } char *json = cJSON_PrintUnformatted(root); cJSON_Delete(root); // recursively frees every item + its metadata too if (json == NULL) { return ESP_ERR_NO_MEM; } *out_json = json; *out_len = strlen(json); return ESP_OK; } esp_err_t telemetry_client_post_readings(const sensor_reading_t *readings, size_t count) { if (count == 0) { return ESP_OK; } char *json = NULL; size_t json_len = 0; esp_err_t err = build_request_body(readings, count, &json, &json_len); if (err != ESP_OK) { ESP_LOGE(TAG, "failed to build request body: %s", esp_err_to_name(err)); return err; } char url[256]; int url_n = snprintf(url, sizeof(url), "%s/api/device/telemetry", DEVICE_BACKEND_BASE_URL); if (url_n < 0 || (size_t)url_n >= sizeof(url)) { ESP_LOGE(TAG, "DEVICE_BACKEND_BASE_URL too long for url buffer"); free(json); return ESP_ERR_INVALID_SIZE; } char auth_header[512]; int auth_n = snprintf(auth_header, sizeof(auth_header), "Bearer %s", DEVICE_PAIRING_TOKEN); if (auth_n < 0 || (size_t)auth_n >= sizeof(auth_header)) { ESP_LOGE(TAG, "DEVICE_PAIRING_TOKEN too long for header buffer"); free(json); return ESP_ERR_INVALID_SIZE; } esp_http_client_config_t config = { .url = url, .method = HTTP_METHOD_POST, .timeout_ms = 10000, }; esp_http_client_handle_t client = esp_http_client_init(&config); if (client == NULL) { ESP_LOGE(TAG, "esp_http_client_init failed"); free(json); return ESP_FAIL; } esp_http_client_set_header(client, "Content-Type", "application/json"); esp_http_client_set_header(client, "Authorization", auth_header); esp_http_client_set_post_field(client, json, (int)json_len); err = esp_http_client_perform(client); if (err == ESP_OK) { int status = esp_http_client_get_status_code(client); ESP_LOGI(TAG, "telemetry POST -> HTTP %d (%d readings, %d bytes)", status, (int)count, (int)json_len); if (status < 200 || status >= 300) { err = ESP_FAIL; } } else { ESP_LOGW(TAG, "telemetry POST transport error: %s", esp_err_to_name(err)); } esp_http_client_cleanup(client); free(json); // cJSON_PrintUnformatted allocates via cJSON's hooks (malloc by default) return err; } static void telemetry_task(void *arg) { static sensor_reading_t s_readings[TELEMETRY_MAX_READINGS]; while (1) { if (!wifi_manager_is_connected()) { ESP_LOGI(TAG, "Wi-Fi not connected, skipping this reporting cycle"); } else { size_t count = 0; sensor_registry_collect(s_readings, TELEMETRY_MAX_READINGS, &count); if (count == 0) { ESP_LOGD(TAG, "no readings produced this cycle"); } else { esp_err_t err = telemetry_client_post_readings(s_readings, count); if (err != ESP_OK) { ESP_LOGW(TAG, "telemetry post did not succeed this cycle: %s", esp_err_to_name(err)); } } } vTaskDelay(pdMS_TO_TICKS(DEVICE_REPORT_INTERVAL_SEC * 1000)); } } void telemetry_client_start_task(void) { // Stack sized generously: cJSON body building + TLS handshake state for // esp_http_client both live on this task's stack. xTaskCreate(telemetry_task, "telemetry_task", 8192, NULL, 5, NULL); }