Files
lisael-box/main/net/weather.c
T
Clément Saillant e35058f53d chore(box): commit firmware base + feature docs
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.
2026-06-15 00:20:47 +02:00

215 lines
7.2 KiB
C
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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&current=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));
}
}