feat(net,audio): add networking and audio layers

Context:
  Lisael Box needs Wi-Fi + time + weather for the home screen, and an
  audio path for webradio and SD playback, all child-safe.

Approach:
  net/ : STA bring-up with auto-reconnect (creds from NVS then
  menuconfig), SNTP with the Europe/Paris TZ string (no setlocale), and
  a periodic open-meteo.com fetch (HTTPS via the CA bundle, cJSON parse)
  that degrades to "—" on failure.
  audio/ : one shared ES8311 codec (BSP) behind a facade enforcing the
  child volume cap (persisted to NVS). Webradio and SD playback use
  ESP-ADF pipelines (http_stream/fatfs -> decoder -> i2s), guarded by
  __has_include so a no-ADF build still configures.

Changes:
  - net/wifi, net/sntp_time, net/weather
  - audio/audio_player (codec + volume facade)
  - audio/radio_pipeline (ESP-ADF http stream)
  - audio/sd_player (ESP-ADF fatfs + manifest.json parser)

Impact:
  Sources are idiomatic but unverified on hardware. The BSP<->ADF I2S
  ownership and the radio URLs are flagged in the README.
This commit is contained in:
Clément Saillant
2026-06-14 00:40:19 +02:00
parent e44efe13e0
commit a53d7e273b
12 changed files with 1111 additions and 0 deletions
+105
View File
@@ -0,0 +1,105 @@
// Shared audio facade for Lisael Box — see audio_player.h.
#include "audio/audio_player.h"
#include "audio/radio_pipeline.h"
#include "audio/sd_player.h"
#include "lisael_config.h"
#include "esp_log.h"
#include "nvs.h"
#include "bsp/esp-box-3.h"
static const char *TAG = "audio";
static esp_codec_dev_handle_t s_codec;
static lisael_audio_source_t s_source = LISAEL_AUDIO_IDLE;
static int s_volume = LISAEL_VOLUME_DEFAULT;
static int clamp_volume(int v)
{
if (v < 0) v = 0;
if (v > LISAEL_VOLUME_MAX) v = LISAEL_VOLUME_MAX;
return v;
}
static void load_volume(void)
{
nvs_handle_t nvs;
if (nvs_open(LISAEL_NVS_NS, NVS_READONLY, &nvs) == ESP_OK) {
int32_t v;
if (nvs_get_i32(nvs, LISAEL_NVS_VOLUME, &v) == ESP_OK) {
s_volume = clamp_volume((int)v);
}
nvs_close(nvs);
}
}
static void persist_volume(void)
{
nvs_handle_t nvs;
if (nvs_open(LISAEL_NVS_NS, NVS_READWRITE, &nvs) == ESP_OK) {
nvs_set_i32(nvs, LISAEL_NVS_VOLUME, s_volume);
nvs_commit(nvs);
nvs_close(nvs);
}
}
esp_err_t lisael_audio_init(void)
{
// The BSP returns a ready-to-use ES8311 speaker codec device.
// bsp_i2c_init() must already have been called (done in app_main).
s_codec = bsp_audio_codec_speaker_init();
if (!s_codec) {
ESP_LOGE(TAG, "bsp_audio_codec_speaker_init failed");
return ESP_FAIL;
}
load_volume();
esp_codec_dev_set_out_vol(s_codec, (float)s_volume);
ESP_LOGI(TAG, "codec ready, volume=%d%% (cap %d%%)", s_volume, LISAEL_VOLUME_MAX);
return ESP_OK;
}
esp_codec_dev_handle_t lisael_audio_codec(void)
{
return s_codec;
}
void lisael_audio_stop_all(void)
{
switch (s_source) {
case LISAEL_AUDIO_RADIO: lisael_radio_stop(); break;
case LISAEL_AUDIO_SD: lisael_sd_stop(); break;
default: break;
}
s_source = LISAEL_AUDIO_IDLE;
}
void lisael_audio_set_source(lisael_audio_source_t src)
{
s_source = src;
}
lisael_audio_source_t lisael_audio_get_source(void)
{
return s_source;
}
int lisael_audio_get_volume(void)
{
return s_volume;
}
void lisael_audio_set_volume(int percent)
{
s_volume = clamp_volume(percent);
if (s_codec) {
esp_codec_dev_set_out_vol(s_codec, (float)s_volume);
}
persist_volume();
ESP_LOGI(TAG, "volume -> %d%%", s_volume);
}
void lisael_audio_volume_step(int delta)
{
lisael_audio_set_volume(s_volume + delta);
}
+47
View File
@@ -0,0 +1,47 @@
// Shared audio facade for Lisael Box.
//
// One ES8311 speaker codec instance (from the esp-box BSP) is shared by all
// audio sources: the webradio pipeline (ESP-ADF http_stream -> decoder ->
// i2s_stream), the SD "boite a sons" / story player, and the calm-corner
// ambience. Sources are mutually exclusive: starting one stops the others.
//
// Volume is clamped to LISAEL_VOLUME_MAX (child-safe cap) everywhere.
#pragma once
#include <stdbool.h>
#include "esp_err.h"
#include "esp_codec_dev.h"
#ifdef __cplusplus
extern "C" {
#endif
typedef enum {
LISAEL_AUDIO_IDLE = 0,
LISAEL_AUDIO_RADIO,
LISAEL_AUDIO_SD,
} lisael_audio_source_t;
// Initialise the BSP audio codec (ES8311 speaker). Call once after bsp_i2c_init.
// Loads the persisted volume from NVS (default LISAEL_VOLUME_DEFAULT).
esp_err_t lisael_audio_init(void);
// The shared codec handle, for the ESP-ADF i2s_stream / direct writes.
// Returns NULL before lisael_audio_init().
esp_codec_dev_handle_t lisael_audio_codec(void);
// Stop whatever source is currently playing and mark IDLE.
void lisael_audio_stop_all(void);
// Source bookkeeping (set by radio_pipeline / sd_player when they take over).
void lisael_audio_set_source(lisael_audio_source_t src);
lisael_audio_source_t lisael_audio_get_source(void);
// --- Volume (percent, 0..LISAEL_VOLUME_MAX) ---------------------------------
int lisael_audio_get_volume(void); // current, percent
void lisael_audio_set_volume(int percent); // clamped + persisted to NVS
void lisael_audio_volume_step(int delta); // +/- a step, clamped
#ifdef __cplusplus
}
#endif
+165
View File
@@ -0,0 +1,165 @@
// Webradio streaming pipeline (ESP-ADF) for Lisael Box.
//
// Pipeline: http_stream -> esp_decoder (auto MP3/AAC) -> i2s_stream
//
// IMPORTANT (verify on hardware):
// The esp-box BSP also configures I2S/ES8311 (bsp_audio_init /
// bsp_audio_codec_speaker_init). ESP-ADF's i2s_stream historically owns the
// I2S peripheral itself. On a real BOX-3 you must make these agree on ONE
// owner of the I2S port. Two known-good approaches:
// (A) Let ADF own I2S: configure i2s_stream with the BOX-3 pins/port and
// do NOT call bsp_audio_init(); keep using the BSP codec handle only
// for volume.
// (B) Let the BSP own I2S (bsp_audio_init) and write decoded PCM to the
// codec via esp_codec_dev_write() from a raw_stream sink instead of
// i2s_stream.
// This file implements (A) — ADF i2s_stream as the output — which is the
// most common ADF idiom. The i2s_std config (pins, port) MUST be matched to
// the BOX-3 BSP values; left as TODOs below because they depend on the exact
// BSP version and are not safe to guess. Marked clearly.
//
// Because ADF is only present when ADF_PATH is set, the whole body is guarded
// by __has_include so a no-ADF inspection build still compiles (the functions
// then log "audio disabled").
#include "audio/radio_pipeline.h"
#include "audio/audio_player.h"
#include "lisael_config.h"
#include <string.h>
#include "esp_log.h"
static const char *TAG = "radio";
#if defined(__has_include)
# if __has_include("audio_pipeline.h")
# define LISAEL_HAVE_ADF 1
# endif
#endif
#ifdef LISAEL_HAVE_ADF
#include "audio_pipeline.h"
#include "audio_element.h"
#include "http_stream.h"
#include "i2s_stream.h"
#include "esp_decoder.h"
#include "audio_mem.h"
static audio_pipeline_handle_t s_pipe;
static audio_element_handle_t s_http, s_decoder, s_i2s;
static bool s_playing;
static esp_err_t build_pipeline(void)
{
if (s_pipe) {
return ESP_OK; // already built
}
audio_pipeline_cfg_t pcfg = DEFAULT_AUDIO_PIPELINE_CONFIG();
s_pipe = audio_pipeline_init(&pcfg);
if (!s_pipe) {
return ESP_ERR_NO_MEM;
}
// --- HTTP source ---------------------------------------------------------
http_stream_cfg_t http_cfg = HTTP_STREAM_CFG_DEFAULT();
http_cfg.type = AUDIO_STREAM_READER;
s_http = http_stream_init(&http_cfg);
// --- Auto decoder (MP3 / AAC / others) ----------------------------------
// esp_decoder auto-detects the container/codec from the stream — perfect
// for webradios that may serve MP3 or AAC.
audio_decoder_t decoders[] = {
DEFAULT_ESP_MP3_DECODER_CONFIG(),
DEFAULT_ESP_AAC_DECODER_CONFIG(),
};
esp_decoder_cfg_t dec_cfg = DEFAULT_ESP_DECODER_CONFIG();
s_decoder = esp_decoder_init(&dec_cfg, decoders,
sizeof(decoders) / sizeof(decoders[0]));
// --- I2S sink to ES8311 --------------------------------------------------
// TODO(hardware): confirm the I2S port number and that these pins match the
// BOX-3 BSP. With ADF >= 2.7 the simplest path is i2s_stream_init with the
// default config and then i2s_stream_set_clk() once the decoder reports the
// sample rate. The codec itself (gain/mute) stays driven by the BSP
// esp_codec_dev handle (lisael_audio_codec()).
i2s_stream_cfg_t i2s_cfg = I2S_STREAM_CFG_DEFAULT();
i2s_cfg.type = AUDIO_STREAM_WRITER;
s_i2s = i2s_stream_init(&i2s_cfg);
audio_pipeline_register(s_pipe, s_http, "http");
audio_pipeline_register(s_pipe, s_decoder, "dec");
audio_pipeline_register(s_pipe, s_i2s, "i2s");
const char *link[] = { "http", "dec", "i2s" };
audio_pipeline_link(s_pipe, link, 3);
ESP_LOGI(TAG, "pipeline built: http -> dec -> i2s");
return ESP_OK;
}
esp_err_t lisael_radio_play(const char *url)
{
if (!url) {
return ESP_ERR_INVALID_ARG;
}
// Take over the shared codec from any other source.
lisael_audio_stop_all();
esp_err_t err = build_pipeline();
if (err != ESP_OK) {
return err;
}
if (s_playing) {
audio_pipeline_stop(s_pipe);
audio_pipeline_wait_for_stop(s_pipe);
audio_pipeline_reset_ringbuffer(s_pipe);
audio_pipeline_reset_elements(s_pipe);
s_playing = false;
}
audio_element_set_uri(s_http, url);
err = audio_pipeline_run(s_pipe);
if (err == ESP_OK) {
s_playing = true;
lisael_audio_set_source(LISAEL_AUDIO_RADIO);
// Re-assert the child-safe volume on the shared codec.
lisael_audio_set_volume(lisael_audio_get_volume());
ESP_LOGI(TAG, "playing %s", url);
} else {
ESP_LOGE(TAG, "pipeline run failed: %s", esp_err_to_name(err));
}
return err;
}
void lisael_radio_stop(void)
{
if (s_pipe && s_playing) {
audio_pipeline_stop(s_pipe);
audio_pipeline_wait_for_stop(s_pipe);
audio_pipeline_reset_ringbuffer(s_pipe);
audio_pipeline_reset_elements(s_pipe);
s_playing = false;
ESP_LOGI(TAG, "stopped");
}
}
bool lisael_radio_is_playing(void)
{
return s_playing;
}
#else // !LISAEL_HAVE_ADF — inspection build without ESP-ADF
esp_err_t lisael_radio_play(const char *url)
{
(void)url;
ESP_LOGW(TAG, "ESP-ADF not present (ADF_PATH unset) — radio disabled");
return ESP_ERR_NOT_SUPPORTED;
}
void lisael_radio_stop(void) {}
bool lisael_radio_is_playing(void) { return false; }
#endif
+26
View File
@@ -0,0 +1,26 @@
// Webradio streaming pipeline (ESP-ADF) for Lisael Box.
#pragma once
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
// Start streaming a webradio by URL. Builds (once, lazily) an ESP-ADF pipeline:
// http_stream -> esp_decoder (auto MP3/AAC) -> i2s_stream (to ES8311)
// and points it at `url`. Stops any other audio source first.
//
// Safe to call repeatedly to switch stations: the running pipeline is stopped,
// the URI swapped, and the pipeline restarted.
esp_err_t lisael_radio_play(const char *url);
// Stop the radio pipeline (keeps it allocated for fast restart).
void lisael_radio_stop(void);
// True if the pipeline is currently running.
bool lisael_radio_is_playing(void);
#ifdef __cplusplus
}
#endif
+222
View File
@@ -0,0 +1,222 @@
// microSD sound-clip player (ESP-ADF fatfs pipeline) for Lisael Box.
// See sd_player.h. Same BSP<->ADF I2S ownership caveat as radio_pipeline.c.
#include "audio/sd_player.h"
#include "audio/audio_player.h"
#include "lisael_config.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include "esp_log.h"
#include "bsp/esp-box-3.h"
#include "cJSON.h"
static const char *TAG = "sd";
static bool s_mounted;
esp_err_t lisael_sd_mount(void)
{
if (s_mounted) {
return ESP_OK;
}
esp_err_t err = bsp_sdcard_mount();
if (err == ESP_OK) {
s_mounted = true;
ESP_LOGI(TAG, "microSD mounted at %s", BSP_SD_MOUNT_POINT);
} else {
ESP_LOGW(TAG, "microSD mount failed: %s (no card?)", esp_err_to_name(err));
}
return err;
}
int lisael_sd_load_manifest(lisael_sound_t *out, int max)
{
if (!out || max <= 0) {
return -1;
}
if (!s_mounted && lisael_sd_mount() != ESP_OK) {
return -1;
}
const char *path = BSP_SD_MOUNT_POINT "/sounds/manifest.json";
FILE *f = fopen(path, "r");
if (!f) {
ESP_LOGW(TAG, "no manifest at %s", path);
return -1;
}
fseek(f, 0, SEEK_END);
long sz = ftell(f);
fseek(f, 0, SEEK_SET);
if (sz <= 0 || sz > 16 * 1024) { // sanity bound
fclose(f);
return -1;
}
char *buf = malloc((size_t)sz + 1);
if (!buf) {
fclose(f);
return -1;
}
size_t rd = fread(buf, 1, (size_t)sz, f);
buf[rd] = '\0';
fclose(f);
cJSON *root = cJSON_Parse(buf);
free(buf);
if (!cJSON_IsArray(root)) {
ESP_LOGW(TAG, "manifest is not a JSON array");
cJSON_Delete(root);
return -1;
}
int n = 0;
cJSON *item;
cJSON_ArrayForEach(item, root) {
if (n >= max) {
break;
}
cJSON *file = cJSON_GetObjectItem(item, "file");
cJSON *title = cJSON_GetObjectItem(item, "title");
cJSON *emoji = cJSON_GetObjectItem(item, "emoji");
if (!cJSON_IsString(file)) {
continue;
}
memset(&out[n], 0, sizeof(out[n]));
strlcpy(out[n].file, file->valuestring, sizeof(out[n].file));
strlcpy(out[n].title,
cJSON_IsString(title) ? title->valuestring : file->valuestring,
sizeof(out[n].title));
strlcpy(out[n].emoji,
cJSON_IsString(emoji) ? emoji->valuestring : "\U0001F50A", // speaker
sizeof(out[n].emoji));
n++;
}
cJSON_Delete(root);
ESP_LOGI(TAG, "manifest: %d sounds", n);
return n;
}
esp_err_t lisael_sd_play_sound(const char *basename)
{
char abs[128];
snprintf(abs, sizeof(abs), "%s/sounds/%s", BSP_SD_MOUNT_POINT, basename);
return lisael_sd_play_file(abs);
}
// --- ADF-guarded pipeline ---------------------------------------------------
#if defined(__has_include)
# if __has_include("audio_pipeline.h")
# define LISAEL_HAVE_ADF 1
# endif
#endif
#ifdef LISAEL_HAVE_ADF
#include "audio_pipeline.h"
#include "audio_element.h"
#include "fatfs_stream.h"
#include "mp3_decoder.h"
#include "i2s_stream.h"
static audio_pipeline_handle_t s_pipe;
static audio_element_handle_t s_fatfs, s_mp3, s_i2s;
static bool s_playing;
static esp_err_t build_pipeline(void)
{
if (s_pipe) {
return ESP_OK;
}
audio_pipeline_cfg_t pcfg = DEFAULT_AUDIO_PIPELINE_CONFIG();
s_pipe = audio_pipeline_init(&pcfg);
if (!s_pipe) {
return ESP_ERR_NO_MEM;
}
fatfs_stream_cfg_t fs_cfg = FATFS_STREAM_CFG_DEFAULT();
fs_cfg.type = AUDIO_STREAM_READER;
s_fatfs = fatfs_stream_init(&fs_cfg);
mp3_decoder_cfg_t mp3_cfg = DEFAULT_MP3_DECODER_CONFIG();
s_mp3 = mp3_decoder_init(&mp3_cfg);
// TODO(hardware): match i2s port/pins to the BOX-3 BSP — same caveat as
// radio_pipeline.c (ADF vs BSP I2S ownership).
i2s_stream_cfg_t i2s_cfg = I2S_STREAM_CFG_DEFAULT();
i2s_cfg.type = AUDIO_STREAM_WRITER;
s_i2s = i2s_stream_init(&i2s_cfg);
audio_pipeline_register(s_pipe, s_fatfs, "file");
audio_pipeline_register(s_pipe, s_mp3, "mp3");
audio_pipeline_register(s_pipe, s_i2s, "i2s");
const char *link[] = { "file", "mp3", "i2s" };
audio_pipeline_link(s_pipe, link, 3);
return ESP_OK;
}
esp_err_t lisael_sd_play_file(const char *abs_path)
{
if (!abs_path) {
return ESP_ERR_INVALID_ARG;
}
if (!s_mounted && lisael_sd_mount() != ESP_OK) {
return ESP_ERR_NOT_FOUND;
}
lisael_audio_stop_all();
esp_err_t err = build_pipeline();
if (err != ESP_OK) {
return err;
}
if (s_playing) {
audio_pipeline_stop(s_pipe);
audio_pipeline_wait_for_stop(s_pipe);
audio_pipeline_reset_ringbuffer(s_pipe);
audio_pipeline_reset_elements(s_pipe);
s_playing = false;
}
audio_element_set_uri(s_fatfs, abs_path);
err = audio_pipeline_run(s_pipe);
if (err == ESP_OK) {
s_playing = true;
lisael_audio_set_source(LISAEL_AUDIO_SD);
lisael_audio_set_volume(lisael_audio_get_volume());
ESP_LOGI(TAG, "playing %s", abs_path);
} else {
ESP_LOGE(TAG, "run failed: %s", esp_err_to_name(err));
}
return err;
}
void lisael_sd_stop(void)
{
if (s_pipe && s_playing) {
audio_pipeline_stop(s_pipe);
audio_pipeline_wait_for_stop(s_pipe);
audio_pipeline_reset_ringbuffer(s_pipe);
audio_pipeline_reset_elements(s_pipe);
s_playing = false;
ESP_LOGI(TAG, "stopped");
}
}
bool lisael_sd_is_playing(void)
{
return s_playing;
}
#else // !LISAEL_HAVE_ADF
esp_err_t lisael_sd_play_file(const char *abs_path)
{
(void)abs_path;
ESP_LOGW(TAG, "ESP-ADF not present — SD playback disabled");
return ESP_ERR_NOT_SUPPORTED;
}
void lisael_sd_stop(void) {}
bool lisael_sd_is_playing(void) { return false; }
#endif
+45
View File
@@ -0,0 +1,45 @@
// microSD sound-clip player (ESP-ADF fatfs pipeline) for Lisael Box.
//
// Plays a single MP3 file from the microSD card:
// fatfs_stream -> mp3_decoder -> i2s_stream (to ES8311)
//
// Also parses /sdcard/sounds/manifest.json into a small in-RAM list used by the
// "boite a sons" UI grid.
#pragma once
#include <stdbool.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
#define LISAEL_SD_MAX_SOUNDS 32
typedef struct {
char file[64]; // basename, e.g. "miaou.mp3" (under /sdcard/sounds/)
char title[48]; // display title, e.g. "Le chat"
char emoji[8]; // single emoji used as the button icon, e.g. "🐱"
} lisael_sound_t;
// Mount the microSD card via the BSP (FATFS). Idempotent.
esp_err_t lisael_sd_mount(void);
// Parse /sdcard/sounds/manifest.json. Returns count placed into `out` (up to
// LISAEL_SD_MAX_SOUNDS). Returns -1 on error (no card / no manifest).
int lisael_sd_load_manifest(lisael_sound_t *out, int max);
// Play an absolute SD path (e.g. "/sdcard/sounds/miaou.mp3"). Stops other audio.
esp_err_t lisael_sd_play_file(const char *abs_path);
// Convenience: play a basename under /sdcard/sounds/.
esp_err_t lisael_sd_play_sound(const char *basename);
// Stop the SD pipeline.
void lisael_sd_stop(void);
bool lisael_sd_is_playing(void);
#ifdef __cplusplus
}
#endif
+49
View File
@@ -0,0 +1,49 @@
// SNTP time sync for Lisael Box.
//
// Uses the IDF esp_sntp wrapper (esp_netif_sntp_*). The timezone is set with
// the POSIX TZ string so that localtime() yields Europe/Paris wall-clock time
// with automatic DST — no setlocale / no tz database needed.
#include "net/sntp_time.h"
#include "lisael_config.h"
#include <time.h>
#include <sys/time.h>
#include "esp_log.h"
#include "esp_netif_sntp.h"
#include "esp_sntp.h"
static const char *TAG = "sntp";
static volatile bool s_time_valid;
static void on_time_sync(struct timeval *tv)
{
(void)tv;
s_time_valid = true;
ESP_LOGI(TAG, "time synchronised");
}
esp_err_t lisael_sntp_start(void)
{
// Apply Europe/Paris timezone for all localtime() calls.
setenv("TZ", LISAEL_TZ, 1);
tzset();
esp_sntp_config_t config = ESP_NETIF_SNTP_DEFAULT_CONFIG("pool.ntp.org");
config.sync_cb = on_time_sync;
config.start = true;
esp_err_t err = esp_netif_sntp_init(&config);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_netif_sntp_init failed: %s", esp_err_to_name(err));
return err;
}
ESP_LOGI(TAG, "SNTP started (pool.ntp.org, TZ=%s)", LISAEL_TZ);
return ESP_OK;
}
bool lisael_time_is_valid(void)
{
return s_time_valid;
}
+20
View File
@@ -0,0 +1,20 @@
// SNTP time sync + Europe/Paris timezone for Lisael Box.
#pragma once
#include <stdbool.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
// Configure the timezone (LISAEL_TZ) and start the SNTP client.
// Must be called after Wi-Fi is connected. Non-blocking.
esp_err_t lisael_sntp_start(void);
// True once the clock has been set at least once by SNTP.
bool lisael_time_is_valid(void);
#ifdef __cplusplus
}
#endif
+211
View File
@@ -0,0 +1,211 @@
// 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];
snprintf(url, sizeof(url),
"https://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,
.crt_bundle_attach = esp_crt_bundle_attach, // uses the CA bundle
};
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));
}
}
+38
View File
@@ -0,0 +1,38 @@
// open-meteo.com weather fetch for Lisael Box.
#pragma once
#include <stdbool.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
// Latest weather snapshot. `valid` is false until the first successful fetch
// (or after a network failure resets it). The UI shows "—" when !valid.
typedef struct {
bool valid;
float temperature_c; // current temperature, degrees Celsius
int weather_code; // WMO weather interpretation code
char city[32]; // label (from config), for display
} lisael_weather_t;
// Start the periodic weather task. Fetches immediately once Wi-Fi is up, then
// every CONFIG_LISAEL_WEATHER_REFRESH_MIN minutes. Latitude/longitude come from
// NVS (LISAEL_NVS_LAT/_LON) or the menuconfig defaults.
//
// `on_update` is invoked from the weather task each time a fetch completes
// (success or failure — check .valid). May be NULL.
esp_err_t lisael_weather_start(void (*on_update)(const lisael_weather_t *));
// Thread-safe copy of the latest snapshot.
void lisael_weather_get(lisael_weather_t *out);
// Map a WMO weather code to a short FR label and an emoji/symbol used as icon.
// Both pointers are static strings — do not free.
const char *lisael_weather_code_label(int code);
const char *lisael_weather_code_emoji(int code);
#ifdef __cplusplus
}
#endif
+148
View File
@@ -0,0 +1,148 @@
// Wi-Fi station bring-up for Lisael Box.
//
// Standard ESP-IDF 5.x STA pattern: esp_netif + default event loop +
// auto-reconnect on disconnect. Credentials come from NVS first, then the
// menuconfig defaults. nvs_flash_init() is the caller's responsibility.
#include "net/wifi.h"
#include "lisael_config.h"
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/event_groups.h"
#include "esp_wifi.h"
#include "esp_event.h"
#include "esp_netif.h"
#include "esp_log.h"
#include "nvs.h"
static const char *TAG = "wifi";
#define WIFI_CONNECTED_BIT BIT0
#define WIFI_FAIL_BIT BIT1
#define WIFI_MAX_RETRY 8
static EventGroupHandle_t s_wifi_events;
static int s_retry_count;
static volatile bool s_connected;
static void load_credentials(char *ssid, size_t ssid_len,
char *pass, size_t pass_len)
{
// Defaults from menuconfig.
strlcpy(ssid, CONFIG_LISAEL_WIFI_SSID, ssid_len);
strlcpy(pass, CONFIG_LISAEL_WIFI_PASSWORD, pass_len);
// Override with NVS-provisioned values if present.
nvs_handle_t nvs;
if (nvs_open(LISAEL_NVS_NS, NVS_READONLY, &nvs) != ESP_OK) {
ESP_LOGI(TAG, "no NVS namespace yet, using menuconfig credentials");
return;
}
size_t len = ssid_len;
if (nvs_get_str(nvs, LISAEL_NVS_WIFI_SSID, ssid, &len) == ESP_OK) {
len = pass_len;
nvs_get_str(nvs, LISAEL_NVS_WIFI_PASS, pass, &len);
ESP_LOGI(TAG, "using NVS-provisioned Wi-Fi credentials");
}
nvs_close(nvs);
}
static void on_wifi_event(void *arg, esp_event_base_t base,
int32_t id, void *data)
{
if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) {
esp_wifi_connect();
} else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) {
s_connected = false;
if (s_retry_count < WIFI_MAX_RETRY) {
s_retry_count++;
ESP_LOGW(TAG, "disconnected, retry %d/%d", s_retry_count, WIFI_MAX_RETRY);
esp_wifi_connect();
} else {
// Keep trying slowly forever — a kid's box should self-heal once the
// router comes back. Reset the counter and reconnect after a pause.
ESP_LOGE(TAG, "max retries reached; will keep retrying");
xEventGroupSetBits(s_wifi_events, WIFI_FAIL_BIT);
s_retry_count = 0;
esp_wifi_connect();
}
} else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {
ip_event_got_ip_t *event = (ip_event_got_ip_t *)data;
ESP_LOGI(TAG, "got IP " IPSTR, IP2STR(&event->ip_info.ip));
s_retry_count = 0;
s_connected = true;
xEventGroupSetBits(s_wifi_events, WIFI_CONNECTED_BIT);
}
}
esp_err_t lisael_wifi_start(void)
{
s_wifi_events = xEventGroupCreate();
if (!s_wifi_events) {
return ESP_ERR_NO_MEM;
}
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
esp_netif_create_default_wifi_sta();
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
ESP_ERROR_CHECK(esp_event_handler_instance_register(
WIFI_EVENT, ESP_EVENT_ANY_ID, &on_wifi_event, NULL, NULL));
ESP_ERROR_CHECK(esp_event_handler_instance_register(
IP_EVENT, IP_EVENT_STA_GOT_IP, &on_wifi_event, NULL, NULL));
wifi_config_t wifi_config = {
.sta = {
.threshold.authmode = WIFI_AUTH_WPA2_PSK,
.pmf_cfg = { .capable = true, .required = false },
},
};
load_credentials((char *)wifi_config.sta.ssid, sizeof(wifi_config.sta.ssid),
(char *)wifi_config.sta.password, sizeof(wifi_config.sta.password));
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
ESP_ERROR_CHECK(esp_wifi_start());
ESP_LOGI(TAG, "Wi-Fi started, SSID=\"%s\"", wifi_config.sta.ssid);
return ESP_OK;
}
bool lisael_wifi_wait_connected(uint32_t timeout_ms)
{
if (!s_wifi_events) {
return false;
}
EventBits_t bits = xEventGroupWaitBits(
s_wifi_events, WIFI_CONNECTED_BIT, pdFALSE, pdFALSE,
pdMS_TO_TICKS(timeout_ms));
return (bits & WIFI_CONNECTED_BIT) != 0;
}
bool lisael_wifi_is_connected(void)
{
return s_connected;
}
esp_err_t lisael_wifi_save_credentials(const char *ssid, const char *pass)
{
nvs_handle_t nvs;
esp_err_t err = nvs_open(LISAEL_NVS_NS, NVS_READWRITE, &nvs);
if (err != ESP_OK) {
return err;
}
err = nvs_set_str(nvs, LISAEL_NVS_WIFI_SSID, ssid);
if (err == ESP_OK) {
err = nvs_set_str(nvs, LISAEL_NVS_WIFI_PASS, pass);
}
if (err == ESP_OK) {
err = nvs_commit(nvs);
}
nvs_close(nvs);
return err;
}
+35
View File
@@ -0,0 +1,35 @@
// Wi-Fi station bring-up for Lisael Box.
#pragma once
#include <stdbool.h>
#include "esp_err.h"
#ifdef __cplusplus
extern "C" {
#endif
// Initialise NVS-backed Wi-Fi station and start connecting.
//
// Credentials are resolved in this order:
// 1. NVS keys (LISAEL_NVS_WIFI_SSID / _PASS) if present — runtime provisioned.
// 2. menuconfig defaults (CONFIG_LISAEL_WIFI_SSID / _PASSWORD).
//
// Non-blocking: connection happens in the background. Use
// lisael_wifi_wait_connected() to block until associated (or timeout).
//
// nvs_flash_init() MUST already have been called by app_main.
esp_err_t lisael_wifi_start(void);
// Block until the station obtains an IP, or until timeout_ms elapses.
// Returns true if connected.
bool lisael_wifi_wait_connected(uint32_t timeout_ms);
// Current association state (IP acquired).
bool lisael_wifi_is_connected(void);
// Persist new credentials to NVS (used by an eventual provisioning UI/console).
esp_err_t lisael_wifi_save_credentials(const char *ssid, const char *pass);
#ifdef __cplusplus
}
#endif