/** * @file heartbeat.c * @brief HTTP Heartbeat Loop */ #include #include #include #include "heartbeat.h" #include "config.h" #include "esp_log.h" #include "esp_http_client.h" #include "cJSON.h" static const char *TAG = "nexus_heartbeat"; /* Retry config */ #define MAX_RETRIES 3 #define CONNECT_TIMEOUT_MS 10000 #define READ_TIMEOUT_MS 30000 /** * @brief Convert epoch seconds to ISO8601 string * @param epoch Unix timestamp * @param buf Output buffer (at least 25 bytes) * @param buf_len Buffer length */ static void epoch_to_iso8601(int64_t epoch, char *buf, size_t buf_len) { time_t t = (time_t)epoch; struct tm tm_info; gmtime_r(&t, &tm_info); strftime(buf, buf_len, "%Y-%m-%dT%H:%M:%SZ", &tm_info); } /** * @brief HTTP event handler for heartbeat */ static esp_err_t heartbeat_event_handler(esp_http_client_event_t *evt) { switch (evt->event_id) { case HTTP_EVENT_ERROR: ESP_LOGD(TAG, "HTTP_EVENT_ERROR"); break; case HTTP_EVENT_ON_CONNECTED: ESP_LOGD(TAG, "HTTP_EVENT_CONNECTED"); break; case HTTP_EVENT_ON_HEADER: break; case HTTP_EVENT_ON_DATA: ESP_LOGD(TAG, "HTTP_EVENT_ON_DATA, len=%d", evt->data_len); break; case HTTP_EVENT_ON_FINISH: ESP_LOGD(TAG, "HTTP_EVENT_FINISHED"); break; case HTTP_EVENT_DISCONNECTED: ESP_LOGD(TAG, "HTTP_EVENT_DISCONNECTED"); break; default: break; } return ESP_OK; } /** * @brief Helper: copy cJSON string to char buffer */ static void copy_json_str(const cJSON *json, const char *key, char *buf, size_t buf_len) { cJSON *item = cJSON_GetObjectItemCaseSensitive(json, key); if (cJSON_IsString(item) && item->valuestring != NULL) { strncpy(buf, item->valuestring, buf_len - 1); buf[buf_len - 1] = '\0'; } } esp_err_t heartbeat_send(const wg_config_t *config, const char *status, bool tunnel_up, int64_t last_handshake) { if (config == NULL || status == NULL) { return ESP_ERR_INVALID_ARG; } /* Convert last_handshake to ISO8601 */ char handshake_str[25] = "1970-01-01T00:00:00Z"; if (last_handshake > 0) { epoch_to_iso8601(last_handshake, handshake_str, sizeof(handshake_str)); } /* Build request JSON */ cJSON *req_json = cJSON_CreateObject(); cJSON_AddStringToObject(req_json, "device_id", config->device_id); cJSON_AddStringToObject(req_json, "status", status); cJSON_AddBoolToObject(req_json, "tunnel_up", tunnel_up); cJSON_AddStringToObject(req_json, "last_handshake", handshake_str); char *req_body = cJSON_PrintUnformatted(req_json); cJSON_Delete(req_json); if (req_body == NULL) { ESP_LOGE(TAG, "Failed to create heartbeat JSON"); return ESP_ERR_NO_MEM; } /* Build URL — use server_wg_ip if available, else we need external URL * For simplicity, we store server_url elsewhere or use the configured endpoint. * Since we don't have server_url in wg_config_t, we construct from endpoint. * TODO: store server_url in config or pass as param */ char url[192]; /* Use server_wg_ip to build the heartbeat URL */ if (strlen(config->server_wg_ip) > 0) { snprintf(url, sizeof(url), "http://%s:8080/api/v1/heartbeat", config->server_wg_ip); } else { /* Fallback — shouldn't happen in normal operation */ ESP_LOGE(TAG, "No server_wg_ip configured"); free(req_body); return ESP_ERR_INVALID_STATE; } esp_err_t final_err = ESP_FAIL; for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) { ESP_LOGD(TAG, "Heartbeat attempt %d/%d...", attempt, MAX_RETRIES); esp_http_client_config_t http_config = { .url = url, .event_handler = heartbeat_event_handler, .timeout_ms = CONNECT_TIMEOUT_MS, }; esp_http_client_handle_t client = esp_http_client_init(&http_config); if (client == NULL) { ESP_LOGE(TAG, "Failed to init HTTP client"); continue; } 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, req_body, strlen(req_body)); esp_err_t err = esp_http_client_open(client, strlen(req_body)); if (err != ESP_OK) { ESP_LOGE(TAG, "HTTP open failed: %s", esp_err_to_name(err)); esp_http_client_cleanup(client); vTaskDelay(pdMS_TO_TICKS(1000 * attempt)); continue; } int written = esp_http_client_write(client, req_body, strlen(req_body)); if (written < 0) { ESP_LOGE(TAG, "HTTP write failed"); esp_http_client_close(client); esp_http_client_cleanup(client); vTaskDelay(pdMS_TO_TICKS(1000 * attempt)); continue; } int content_length = esp_http_client_fetch_headers(client); int status_code = esp_http_client_get_status_code(client); if (status_code != 200) { ESP_LOGW(TAG, "Heartbeat failed, HTTP %d", status_code); esp_http_client_close(client); esp_http_client_cleanup(client); vTaskDelay(pdMS_TO_TICKS(1000 * attempt)); continue; } /* Read response body for config sync */ if (content_length > 0) { char *response_buf = malloc(content_length + 1); if (response_buf != NULL) { int read_len = esp_http_client_read(client, response_buf, content_length); response_buf[read_len] = '\0'; /* TODO: Parse config sync fields if config_changed is detected * For now, log the response size */ ESP_LOGD(TAG, "Heartbeat response: %d bytes", read_len); free(response_buf); } } esp_http_client_close(client); esp_http_client_cleanup(client); final_err = ESP_OK; break; } free(req_body); return final_err; } bool heartbeat_config_changed(const char *old_hash, const char *new_hash) { if (old_hash == NULL || new_hash == NULL) { /* If either is NULL, treat as changed */ return true; } /* Empty new hash means no hash provided — treat as changed */ if (strlen(new_hash) == 0) { return true; } /* Simple string comparison */ return (strcmp(old_hash, new_hash) != 0); }