先约定一个能故意失败的接口
这一篇让S3主动访问你自己的局域网测试服务,拿到有单位、序号和有效性的JSON,而不是把“能访问网页”当成数据已经可用。固定IDF5.5.5、同一v1.1 N8R8板,只接USB-to-UART;不接传感器,也没有真实温度输入。
服务明确提供temperature_c数字与sequence整数,合成22.5只用于协议验收。先用同网电脑验证接口,再接S3;设备上的127.0.0.1指设备本身,不能当作电脑服务。默认192.0.2.10是文档占位,必须填写实际私有IP。
- 做到这里,你应该看到
- 说清楚服务在哪台电脑、板子访问什么实际IP,以及22.5为什么不能写成测量结果。
参考:[1] ESP-IDF v5.5.5 esp_http_client公开接口、超时与事件[2] ESP-IDF v5.5.5 HTTP客户端完整上游例程[3] ESP-IDF v5.5.5 esp_http_client官方说明[6] 乐鑫 DevKitC-1 v1.1 固定文档:修订、供电与排针
同一服务准备200、404、坏JSON、超长与超时
下载包里的test_server.py只用Python标准库。先绑定127.0.0.1并用curl检查,结束本机试验后停止服务,再绑定你电脑实际私有LAN IP,并只对该实验网络开放端口。无需把服务绑定到公网地址或使用第三方账号。
/cycle按ok、404、bad、missing、slow、ok轮换,序号随请求递增。它适合一次烧录观察先成功、再失败、再恢复;/large单独验证正文上限。服务是合成故障输入,不是公网天气API,也不能用主机请求成功代替板上请求记录。
| 路径 | 应提供的输入 |
|---|---|
| /ok | 200;数字temperature_c=22.5、整数sequence |
| /404 /bad | 非200状态 / 200但无效JSON |
| /missing /type | 缺字段 / 数字被写成字符串 |
| /large | 正文超过1024字节 |
| /slow | 等待5秒,超过客户端3秒超时 |
| /cycle | 有效→几类故障→恢复,序号递增 |
python3 test_server.py --bind 127.0.0.1 --port 8080
curl -i http://127.0.0.1:8080/ok
curl -i http://127.0.0.1:8080/404
curl -i http://127.0.0.1:8080/bad
curl --max-time 2 http://127.0.0.1:8080/slow- 做到这里,你应该看到
- 主机能分别得到这些故障输入,再把同一服务开放到实际实验LAN接口。
参考:[1] ESP-IDF v5.5.5 esp_http_client公开接口、超时与事件[2] ESP-IDF v5.5.5 HTTP客户端完整上游例程[3] ESP-IDF v5.5.5 esp_http_client官方说明
用E01的环境编译,Wi-Fi只决定能否发起请求
在项目根目录执行set-target和menuconfig,Demo configuration中填写2.4GHz SSID、WPA2密码和完整URL,例如你自己的http://私有IP:8080/cycle。sdkconfig已忽略,凭据不进入仓库、下载包或日志;占位凭据时不发起联网。
main/net.c使用IDF事件与2/4/8/16/30秒退避,拿到IP才设置ONLINE,断开时立即清除。HTTP在app_main中运行,连接/读取等网络操作超时配置为3000ms;它不是整次perform的总deadline,慢速分块可令总耗时更长,操作结束后再回到周期;不在Wi-Fi事件回调里解析JSON,也不在入口无限等待网络。组件清单和lock都固定5.5.5,不另装Arduino JSON库。
idf.py --version
idf.py set-target esp32s3
idf.py menuconfig
idf.py build
idf.py -p PORT flash monitor- 做到这里,你应该看到
- 版本、target和URL明确,只有ONLINE后才出现HTTP请求;没有实板时该行为仍待验证。
参考:[1] ESP-IDF v5.5.5 esp_http_client公开接口、超时与事件[2] ESP-IDF v5.5.5 HTTP客户端完整上游例程[4] ESP-IDF v5.5.5 Wi-Fi station官方说明与事件[6] 乐鑫 DevKitC-1 v1.1 固定文档:修订、供电与排针
分块正文、HTTP状态与JSON字段分别检查
timeout_ms是网络操作超时,非端到端总时限。HTTP_EVENT_ON_DATA可能分多次到来;本例逐块追加到本轮新建的1024字节缓冲。超过上限就记录BODY_TOO_LARGE,截断的JSON不会进入解析。重定向被关闭,不把一个跳转后的地址悄悄当成原接口。
perform返回ESP_OK仍可能是404,所以先检查传输,再检查200,之后才检查完整JSON对象和字段。temperature_c必须是有限数字且在-40…125°C;sequence必须为0…UINT32_MAX整数。字符串、布尔、缺字段、正文尾部其他数据都拒绝,不能用0补齐。这个范围属于本例schema,不代表任何在线服务都采用同一约定。
| 检查 | 失败时 |
|---|---|
| 传输ESP_OK | TRANSPORT_ERROR;保留错误名 |
| HTTP200 | HTTP_ERROR;记录状态码 |
| 正文≤1024字节且完整JSON对象 | BODY_TOO_LARGE或INVALID_JSON_OR_FIELDS |
| 单位、数值范围与整数序号 | 不提交本轮candidate |
- 做到这里,你应该看到
- 能解释为什么404和200坏JSON都不能成为新样本,而不是只看Wi-Fi图标。
参考:[1] ESP-IDF v5.5.5 esp_http_client公开接口、超时与事件[2] ESP-IDF v5.5.5 HTTP客户端完整上游例程[3] ESP-IDF v5.5.5 esp_http_client官方说明[5] cJSON v1.7.19 解析、类型检查与内存归属
current与last_valid分开,旧值带年龄
每次请求先建立全新candidate;只有所有门都通过才覆盖last_valid并记录本机接收时间。失败时current=NA,另列最后有效温度、last_sequence与age_ms。服务端序号和本机接收时间用途不同,不能把age误说成服务端测量时间。
用/cycle应能观察有效样本后,HTTP/JSON/超时阶段序号保持、年龄增长,最后新的有效样本再更新。没有历史成功时输出last_valid=NONE。这样即使页面或日志保留旧数,也不会让人以为当前仍正常。
- 做到这里,你应该看到
- 失败阶段没有产生新的正常温度;旧样本有明确last标签和持续增长的年龄。
参考:[1] ESP-IDF v5.5.5 esp_http_client公开接口、超时与事件[3] ESP-IDF v5.5.5 esp_http_client官方说明[5] cJSON v1.7.19 解析、类型检查与内存归属
完整主程序与可修改边界
下方是配套工程的完整main.c;网络封装、Kconfig、服务端、组件lock与默认配置都在下载包。改字段协议时一起修改服务、校验和说明,不只修改日志中的单位字符串。
本例只处理你控制的可信LAN明文HTTP。要接真实公网HTTPS服务,需要重新核对证书、时钟、认证、配额和服务协议;这份LAN测试不能证明那些条件已经满足。
// SPDX-License-Identifier: MIT
#include <string.h>
#include <math.h>
#include <inttypes.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_system.h"
#include "esp_timer.h"
#include "esp_http_client.h"
#include "cJSON.h"
#include "sdkconfig.h"
#include "net.h"
#define BODY_LIMIT 1024
static const char *TAG = "N03";
typedef struct { char bytes[BODY_LIMIT + 1]; size_t length; bool overflow; } body_t;
typedef struct { bool available; double temperature; uint32_t sequence; int64_t at_us; } sample_t;
static sample_t last = {0};
static esp_err_t http_event(esp_http_client_event_t *event) {
if (event->event_id == HTTP_EVENT_ON_DATA && event->data_len > 0) {
body_t *body = event->user_data;
if ((size_t)event->data_len > BODY_LIMIT - body->length) body->overflow = true;
else if (!body->overflow) {
memcpy(body->bytes + body->length, event->data, (size_t)event->data_len);
body->length += (size_t)event->data_len;
body->bytes[body->length] = '\0';
}
}
return ESP_OK;
}
static bool parse_sample(const body_t *body, sample_t *out) {
const char *end = NULL;
cJSON *root = cJSON_ParseWithLengthOpts(body->bytes, body->length + 1, &end, true);
if (!cJSON_IsObject(root) || end != body->bytes + body->length) { cJSON_Delete(root); return false; }
cJSON *temp = cJSON_GetObjectItemCaseSensitive(root, "temperature_c");
cJSON *seq = cJSON_GetObjectItemCaseSensitive(root, "sequence");
bool valid = cJSON_IsNumber(temp) && isfinite(temp->valuedouble) && temp->valuedouble >= -40 && temp->valuedouble <= 125 &&
cJSON_IsNumber(seq) && isfinite(seq->valuedouble) && seq->valuedouble >= 0 && seq->valuedouble <= UINT32_MAX && floor(seq->valuedouble) == seq->valuedouble;
if (valid) *out = (sample_t){.available = true, .temperature = temp->valuedouble, .sequence = (uint32_t)seq->valuedouble, .at_us = esp_timer_get_time()};
cJSON_Delete(root); return valid;
}
static void report_last(const char *state) {
if (!last.available) ESP_LOGW(TAG, "state=%s current=NA last_valid=NONE", state);
else ESP_LOGW(TAG, "state=%s current=NA last_valid_T_C=%.2f last_sequence=%" PRIu32 " age_ms=%" PRIi64, state, last.temperature, last.sequence, (esp_timer_get_time() - last.at_us) / 1000);
}
void app_main(void) {
net_start();
ESP_LOGI(TAG, "IDF=%s HTTP timeout=3000ms max_body=%u; no real network result recorded by this source", esp_get_idf_version(), BODY_LIMIT);
for (;;) {
if (!net_online()) { report_last("OFFLINE"); vTaskDelay(pdMS_TO_TICKS(5000)); continue; }
body_t body = {0};
esp_http_client_config_t config = {.url = CONFIG_DEMO_HTTP_URL, .timeout_ms = 3000, .event_handler = http_event, .user_data = &body, .disable_auto_redirect = true, .keep_alive_enable = false};
esp_http_client_handle_t client = esp_http_client_init(&config);
if (!client) { report_last("CLIENT_ALLOC_FAILED"); vTaskDelay(pdMS_TO_TICKS(5000)); continue; }
esp_http_client_set_header(client, "Accept", "application/json");
esp_err_t result = esp_http_client_perform(client);
int status = esp_http_client_get_status_code(client);
sample_t candidate = {0};
if (result != ESP_OK) { ESP_LOGW(TAG, "transport=%s status=%d", esp_err_to_name(result), status); report_last("TRANSPORT_ERROR"); }
else if (status != 200) { ESP_LOGW(TAG, "HTTP status=%d; body is not a sample", status); report_last("HTTP_ERROR"); }
else if (body.overflow) report_last("BODY_TOO_LARGE");
else if (!parse_sample(&body, &candidate)) report_last("INVALID_JSON_OR_FIELDS");
else {
last = candidate;
ESP_LOGI(TAG, "state=VALID T_C=%.2f sequence=%" PRIu32 " received_ms=%" PRIi64, last.temperature, last.sequence, last.at_us / 1000);
}
esp_http_client_cleanup(client);
vTaskDelay(pdMS_TO_TICKS(5000));
}
}
参考:[1] ESP-IDF v5.5.5 esp_http_client公开接口、超时与事件[2] ESP-IDF v5.5.5 HTTP客户端完整上游例程[3] ESP-IDF v5.5.5 esp_http_client官方说明[5] cJSON v1.7.19 解析、类型检查与内存归属
用故障输入定位层级,再保留实际证据
完整观察/cycle一轮,再指向/large:记录真实状态、错误名、序号与年龄。始终OFFLINE先查凭据与事件原因;ONLINE而传输失败,先从同网电脑访问同一个私有IP和端口,再查监听接口、隔离或防火墙;HTTP404优先核对路径,200字段失败优先核对schema。
本站已实际编译客户端,并在本机逐个检查fixture的状态、正文和超时输入;没有本站ESP32访问服务、AP断开或串口运行结果。observations.csv留空,所有实板行为保持待验收。退出时Ctrl+]结束监视、Ctrl+C停服务并取消临时开放端口,回到下一篇MQTT前先确认这个数据有效性模型。
- 做到这里,你应该看到
- 每项记录能追溯到主机或ESP32实际执行端,未执行步骤没有被填成成功。
参考:[1] ESP-IDF v5.5.5 esp_http_client公开接口、超时与事件[3] ESP-IDF v5.5.5 esp_http_client官方说明[4] ESP-IDF v5.5.5 Wi-Fi station官方说明与事件[5] cJSON v1.7.19 解析、类型检查与内存归属
资料来源与版本说明
按指定板级修订、框架与上游源码制作。真实构建、主机辅助检查与实板行为分别记录;没有把主机测试或示意图写成本站硬件结果。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。