e35058f53d
Backfills the working firmware that had stayed uncommitted across the build sessions, so the branch is self-contained. - audio: audio_player, radio_pipeline, sd_player, calm_tone, aac_player - net: podcast (RSS), udp_log - modes: balloons, plus calm/games/radio tweaks - ui: fonts header, icons; build files (CMakeLists, idf_component.yml, sdkconfig.defaults) - docs/superpowers: Histoires design + plan Note: sdkconfig stays gitignored (Wi-Fi creds + tuned LFN/FS/mbedtls settings); a fresh checkout must reconfigure those.
215 lines
7.2 KiB
C
215 lines
7.2 KiB
C
// open-meteo.com weather fetch for Lisael Box.
|
||
//
|
||
// open-meteo is a free, key-less HTTPS forecast API. We request only the
|
||
// "current" temperature + weather_code for a fixed lat/lon, parse the small
|
||
// JSON reply with cJSON, and publish a thread-safe snapshot. A FreeRTOS task
|
||
// refreshes periodically and tolerates network failures gracefully (snapshot
|
||
// stays .valid=false / the UI shows "—").
|
||
|
||
#include "net/weather.h"
|
||
#include "lisael_config.h"
|
||
|
||
#include <string.h>
|
||
#include <stdlib.h>
|
||
|
||
#include "freertos/FreeRTOS.h"
|
||
#include "freertos/task.h"
|
||
#include "freertos/semphr.h"
|
||
#include "esp_log.h"
|
||
#include "esp_http_client.h"
|
||
#include "esp_crt_bundle.h"
|
||
#include "nvs.h"
|
||
#include "cJSON.h"
|
||
|
||
static const char *TAG = "weather";
|
||
|
||
#define WEATHER_HTTP_BUF 2048
|
||
|
||
static lisael_weather_t s_snapshot;
|
||
static SemaphoreHandle_t s_lock;
|
||
static void (*s_on_update)(const lisael_weather_t *);
|
||
|
||
// --- WMO weather code mapping (subset, child-friendly) ----------------------
|
||
// https://open-meteo.com/en/docs -> "WMO Weather interpretation codes"
|
||
const char *lisael_weather_code_label(int code)
|
||
{
|
||
switch (code) {
|
||
case 0: return "Ciel clair";
|
||
case 1: case 2: case 3: return "Nuageux";
|
||
case 45: case 48: return "Brouillard";
|
||
case 51: case 53: case 55: return "Bruine";
|
||
case 61: case 63: case 65: return "Pluie";
|
||
case 66: case 67: return "Pluie verglacante";
|
||
case 71: case 73: case 75: return "Neige";
|
||
case 77: return "Grains de neige";
|
||
case 80: case 81: case 82: return "Averses";
|
||
case 85: case 86: return "Averses de neige";
|
||
case 95: return "Orage";
|
||
case 96: case 99: return "Orage et grele";
|
||
default: return "—";
|
||
}
|
||
}
|
||
|
||
const char *lisael_weather_code_emoji(int code)
|
||
{
|
||
switch (code) {
|
||
case 0: return "☀️"; // sun
|
||
case 1: case 2: case 3: return "⛅"; // sun behind cloud
|
||
case 45: case 48: return "\U0001F32B️"; // fog
|
||
case 51: case 53: case 55: return "\U0001F326️"; // drizzle
|
||
case 61: case 63: case 65: return "\U0001F327️"; // rain
|
||
case 66: case 67: return "\U0001F328️"; // sleet
|
||
case 71: case 73: case 75:
|
||
case 77: case 85: case 86: return "❄️"; // snow
|
||
case 80: case 81: case 82: return "\U0001F327️"; // showers
|
||
case 95: case 96: case 99: return "⛈️"; // thunderstorm
|
||
default: return "❓"; // question mark
|
||
}
|
||
}
|
||
|
||
static void load_coords(char *lat, size_t lat_len, char *lon, size_t lon_len)
|
||
{
|
||
strlcpy(lat, CONFIG_LISAEL_WEATHER_LAT, lat_len);
|
||
strlcpy(lon, CONFIG_LISAEL_WEATHER_LON, lon_len);
|
||
|
||
nvs_handle_t nvs;
|
||
if (nvs_open(LISAEL_NVS_NS, NVS_READONLY, &nvs) == ESP_OK) {
|
||
size_t len = lat_len;
|
||
if (nvs_get_str(nvs, LISAEL_NVS_LAT, lat, &len) == ESP_OK) {
|
||
len = lon_len;
|
||
nvs_get_str(nvs, LISAEL_NVS_LON, lon, &len);
|
||
}
|
||
nvs_close(nvs);
|
||
}
|
||
}
|
||
|
||
// HTTP event handler accumulating the body into a caller-supplied buffer.
|
||
typedef struct {
|
||
char *buf;
|
||
int len;
|
||
int cap;
|
||
} http_accum_t;
|
||
|
||
static esp_err_t http_event(esp_http_client_event_t *evt)
|
||
{
|
||
if (evt->event_id == HTTP_EVENT_ON_DATA) {
|
||
http_accum_t *acc = (http_accum_t *)evt->user_data;
|
||
int copy = evt->data_len;
|
||
if (acc->len + copy >= acc->cap) {
|
||
copy = acc->cap - acc->len - 1;
|
||
}
|
||
if (copy > 0) {
|
||
memcpy(acc->buf + acc->len, evt->data, copy);
|
||
acc->len += copy;
|
||
acc->buf[acc->len] = '\0';
|
||
}
|
||
}
|
||
return ESP_OK;
|
||
}
|
||
|
||
static bool fetch_once(const char *lat, const char *lon, lisael_weather_t *out)
|
||
{
|
||
char url[256];
|
||
// Plain HTTP on purpose: weather is public, non-sensitive data, and the TLS
|
||
// handshake's transient RAM otherwise starves the Wi-Fi RX DMA buffers
|
||
// ("wifi:mem fail") now that the LVGL draw buffer lives in internal RAM.
|
||
// HTTP avoids mbedtls entirely so the fetch is reliable.
|
||
snprintf(url, sizeof(url),
|
||
"http://api.open-meteo.com/v1/forecast"
|
||
"?latitude=%s&longitude=%s¤t=temperature_2m,weather_code",
|
||
lat, lon);
|
||
|
||
char *body = calloc(1, WEATHER_HTTP_BUF);
|
||
if (!body) {
|
||
return false;
|
||
}
|
||
http_accum_t acc = { .buf = body, .len = 0, .cap = WEATHER_HTTP_BUF };
|
||
|
||
esp_http_client_config_t config = {
|
||
.url = url,
|
||
.event_handler = http_event,
|
||
.user_data = &acc,
|
||
.timeout_ms = 8000,
|
||
};
|
||
esp_http_client_handle_t client = esp_http_client_init(&config);
|
||
bool ok = false;
|
||
|
||
esp_err_t err = esp_http_client_perform(client);
|
||
int status = esp_http_client_get_status_code(client);
|
||
if (err == ESP_OK && status == 200) {
|
||
cJSON *root = cJSON_Parse(body);
|
||
cJSON *current = cJSON_GetObjectItem(root, "current");
|
||
cJSON *temp = current ? cJSON_GetObjectItem(current, "temperature_2m") : NULL;
|
||
cJSON *code = current ? cJSON_GetObjectItem(current, "weather_code") : NULL;
|
||
if (cJSON_IsNumber(temp) && cJSON_IsNumber(code)) {
|
||
out->temperature_c = (float)temp->valuedouble;
|
||
out->weather_code = code->valueint;
|
||
out->valid = true;
|
||
ok = true;
|
||
ESP_LOGI(TAG, "%.1f degC, code %d", out->temperature_c, out->weather_code);
|
||
} else {
|
||
ESP_LOGW(TAG, "unexpected JSON shape");
|
||
}
|
||
cJSON_Delete(root);
|
||
} else {
|
||
ESP_LOGW(TAG, "fetch failed err=%s status=%d", esp_err_to_name(err), status);
|
||
}
|
||
|
||
esp_http_client_cleanup(client);
|
||
free(body);
|
||
return ok;
|
||
}
|
||
|
||
static void weather_task(void *arg)
|
||
{
|
||
(void)arg;
|
||
char lat[24], lon[24];
|
||
load_coords(lat, sizeof(lat), lon, sizeof(lon));
|
||
|
||
const TickType_t period = pdMS_TO_TICKS(
|
||
(uint32_t)LISAEL_WEATHER_REFRESH_MIN * 60U * 1000U);
|
||
|
||
for (;;) {
|
||
lisael_weather_t fresh = { 0 };
|
||
strlcpy(fresh.city, CONFIG_LISAEL_WEATHER_CITY, sizeof(fresh.city));
|
||
fetch_once(lat, lon, &fresh); // leaves .valid=false on failure
|
||
|
||
if (xSemaphoreTake(s_lock, portMAX_DELAY) == pdTRUE) {
|
||
s_snapshot = fresh;
|
||
xSemaphoreGive(s_lock);
|
||
}
|
||
if (s_on_update) {
|
||
s_on_update(&fresh);
|
||
}
|
||
vTaskDelay(period);
|
||
}
|
||
}
|
||
|
||
esp_err_t lisael_weather_start(void (*on_update)(const lisael_weather_t *))
|
||
{
|
||
s_on_update = on_update;
|
||
s_lock = xSemaphoreCreateMutex();
|
||
if (!s_lock) {
|
||
return ESP_ERR_NO_MEM;
|
||
}
|
||
strlcpy(s_snapshot.city, CONFIG_LISAEL_WEATHER_CITY, sizeof(s_snapshot.city));
|
||
|
||
// TLS + HTTP + JSON -> needs a generous stack.
|
||
BaseType_t ok = xTaskCreatePinnedToCore(
|
||
weather_task, "weather", 8192, NULL, 4, NULL, tskNO_AFFINITY);
|
||
return ok == pdPASS ? ESP_OK : ESP_ERR_NO_MEM;
|
||
}
|
||
|
||
void lisael_weather_get(lisael_weather_t *out)
|
||
{
|
||
if (!out) {
|
||
return;
|
||
}
|
||
if (s_lock && xSemaphoreTake(s_lock, pdMS_TO_TICKS(100)) == pdTRUE) {
|
||
*out = s_snapshot;
|
||
xSemaphoreGive(s_lock);
|
||
} else {
|
||
memset(out, 0, sizeof(*out));
|
||
}
|
||
}
|