Files
thermostat-hw/main/api/ambient_weather.c
T
mattandClaude d4b2ab7f99 Work through the remaining review items in TODO.md
Keep the HTTP session alive between weather polls (TODO #1). Re-creating the
client each time forced a fresh TLS handshake inside the window where the UI
is frozen. Measured on hardware: the fetch went from ~1.3s to ~80ms, so the
pause is no longer long enough to matter once thermostat controls exist. The
timeout drops from 30s to 10s once a session is warm, since only a cold
handshake needs the longer budget. Any transport error or non-200 tears the
session down so a half-open connection cannot poison every later poll.

The response buffer is now reset per request rather than on
HTTP_EVENT_ON_CONNECTED -- a kept-alive connection does not re-fire that
event, so the buffer would have accumulated across polls.

Indicate stale readings (TODO #2). After two consecutive failures the
temperature dims and the status line shows the age. Age comes from tick
counts, not weather_data_t.last_update: there is no SNTP client here, so
time(NULL) starts at the epoch and any age derived from it is meaningless.

Gate the touch readout behind UI_DEBUG_TOUCH, default off (TODO #3). Kept the
note about LVGL not bubbling events to parents -- it documents a real trap.

Delete main/utils (TODO #4); it had no callers outside itself. Also refreshed
project_structure.txt, which was additionally missing i2c_bus and backlight.

Lay out two 4MB OTA slots (TODO #5) while no devices are fielded, and document
in the README that an OTA download cannot run while the panel is scanning --
the flash write faults the bounce-buffer ISR.

Log the truncation warning once per response instead of once per chunk, and
record the weather task's stack high-water mark (TODO #6): ~2400 bytes spare
of 6144.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 09:08:51 -07:00

246 lines
7.1 KiB
C

/*
* Ambient Weather REST API client.
*
* GET https://rt.ambientweather.net/v1/devices/{macAddress}
* ?apiKey=...&applicationKey=...&limit=1
*
* Returns a JSON array of data records, newest first.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include "cJSON.h"
#include "esp_crt_bundle.h"
#include "esp_http_client.h"
#include "esp_log.h"
#include "app_config.h"
#include "ambient_weather.h"
static const char *TAG = "ambient_weather";
#define WEATHER_URL_MAX 320
#define WEATHER_RESP_MAX 4096
/*
* Two timeouts, because the first request costs far more than the rest.
*
* A cold TLS handshake on this board takes well over 10s. Once the session is
* established and kept alive, a poll is just a short request/response. Holding
* the cold timeout forever would mean the caller has to assume a 30s stall on
* every poll -- and the caller pauses the UI for the duration of this call, so
* that assumption is expensive.
*/
#define WEATHER_TIMEOUT_COLD_MS 30000
#define WEATHER_TIMEOUT_WARM_MS 10000
static char resp_buf[WEATHER_RESP_MAX];
static size_t resp_len = 0;
static bool resp_truncated = false;
/*
* The client is kept alive between polls. Re-creating it each time forced a
* fresh TLS handshake -- seconds of work, all of it inside the window where
* the UI is frozen.
*/
static esp_http_client_handle_t client = NULL;
static char url[WEATHER_URL_MAX];
static bool session_warm = false;
static esp_err_t http_event_handler(esp_http_client_event_t *evt)
{
switch (evt->event_id) {
case HTTP_EVENT_ON_DATA:
/*
* Accumulate into a fixed buffer. The response is chunked, so
* Content-Length is often -1 -- sizing an allocation from it (as this
* previously did) yields a zero-length buffer and a heap overflow.
*/
if (resp_len + evt->data_len < sizeof(resp_buf)) {
memcpy(resp_buf + resp_len, evt->data, evt->data_len);
resp_len += evt->data_len;
} else if (!resp_truncated) {
/* Once per response, not once per remaining chunk. */
resp_truncated = true;
ESP_LOGW(TAG, "Response exceeds %d bytes, truncating", WEATHER_RESP_MAX);
}
break;
default:
break;
}
return ESP_OK;
}
/* Tear the session down so the next poll rebuilds it from scratch. Used after
* any transport error -- a half-open connection would fail every subsequent
* poll otherwise. */
static void client_drop(void)
{
if (client != NULL) {
esp_http_client_cleanup(client);
client = NULL;
}
session_warm = false;
}
static esp_err_t client_ensure(void)
{
if (client != NULL) {
return ESP_OK;
}
const esp_http_client_config_t config = {
.url = url,
.method = HTTP_METHOD_GET,
.timeout_ms = WEATHER_TIMEOUT_COLD_MS,
.event_handler = http_event_handler,
.crt_bundle_attach = esp_crt_bundle_attach,
.keep_alive_enable = true,
};
client = esp_http_client_init(&config);
return (client != NULL) ? ESP_OK : ESP_ERR_NO_MEM;
}
esp_err_t weather_init(void)
{
if (AMBIENT_WEATHER_API_KEY[0] == '\0' ||
AMBIENT_WEATHER_APP_KEY[0] == '\0' ||
AMBIENT_WEATHER_DEVICE_ID[0] == '\0') {
ESP_LOGW(TAG, "API credentials not set in main/app_config.h -- "
"weather updates will be skipped");
return ESP_ERR_INVALID_STATE;
}
/* Credentials are compile-time constants, so the URL never changes. */
const int written = snprintf(url, sizeof(url),
"https://rt.ambientweather.net/v1/devices/%s"
"?apiKey=%s&applicationKey=%s&limit=1",
AMBIENT_WEATHER_DEVICE_ID, AMBIENT_WEATHER_API_KEY, AMBIENT_WEATHER_APP_KEY);
if (written < 0 || written >= (int)sizeof(url)) {
ESP_LOGE(TAG, "URL truncated -- check credential lengths");
return ESP_ERR_INVALID_SIZE;
}
return ESP_OK;
}
static void parse_record(const cJSON *record, weather_data_t *data)
{
const cJSON *tempf = cJSON_GetObjectItemCaseSensitive(record, "tempf");
if (cJSON_IsNumber(tempf)) {
data->temp_f = (float)tempf->valuedouble;
data->has_temp = true;
}
const cJSON *humidity = cJSON_GetObjectItemCaseSensitive(record, "humidity");
if (cJSON_IsNumber(humidity)) {
data->humidity = (float)humidity->valuedouble;
data->has_humidity = true;
}
const cJSON *wind = cJSON_GetObjectItemCaseSensitive(record, "windspeedmph");
if (cJSON_IsNumber(wind)) {
data->wind_mph = (float)wind->valuedouble;
data->has_wind = true;
}
}
esp_err_t weather_update(weather_data_t *data)
{
if (data == NULL) {
return ESP_ERR_INVALID_ARG;
}
/* Caller may reuse the struct across polls; start from a known state so a
* partial response cannot leave stale values looking fresh. */
memset(data, 0, sizeof(*data));
esp_err_t err = client_ensure();
if (err != ESP_OK) {
return err;
}
/*
* Reset per request, not on HTTP_EVENT_ON_CONNECTED: a kept-alive session
* reuses the connection, so that event does not fire again and the buffer
* would accumulate across polls.
*/
resp_len = 0;
resp_truncated = false;
esp_http_client_set_timeout_ms(client, session_warm ? WEATHER_TIMEOUT_WARM_MS
: WEATHER_TIMEOUT_COLD_MS);
err = esp_http_client_perform(client);
if (err != ESP_OK) {
ESP_LOGE(TAG, "HTTP request failed: %s", esp_err_to_name(err));
client_drop();
return err;
}
const int status = esp_http_client_get_status_code(client);
if (status != 200) {
ESP_LOGE(TAG, "HTTP %d from Ambient Weather", status);
/* Could be a stale session the server has since closed. */
client_drop();
return ESP_FAIL;
}
/* Connection is good; subsequent polls skip the handshake. */
session_warm = true;
if (resp_len == 0) {
ESP_LOGE(TAG, "Empty response");
return ESP_FAIL;
}
resp_buf[resp_len] = '\0';
cJSON *root = cJSON_Parse(resp_buf);
if (root == NULL) {
ESP_LOGE(TAG, "Failed to parse JSON response");
return ESP_FAIL;
}
err = ESP_FAIL;
if (cJSON_IsArray(root) && cJSON_GetArraySize(root) > 0) {
parse_record(cJSON_GetArrayItem(root, 0), data);
if (data->has_temp) {
data->last_update = time(NULL);
err = ESP_OK;
} else {
ESP_LOGW(TAG, "Response contained no tempf field");
}
} else {
ESP_LOGW(TAG, "Response was not a non-empty array");
}
cJSON_Delete(root);
return err;
}
void weather_get_string(const weather_data_t *data, char *buf, size_t len)
{
if (data == NULL || buf == NULL || len == 0) {
return;
}
int n = snprintf(buf, len, "%.1f°F", data->has_temp ? data->temp_f : 0.0f);
if (data->has_humidity && n > 0 && (size_t)n < len) {
n += snprintf(buf + n, len - n, " · %.0f%% RH", data->humidity);
}
if (data->has_wind && n > 0 && (size_t)n < len) {
snprintf(buf + n, len - n, " · %.1f mph", data->wind_mph);
}
}