From 6cda5d243da6a3ca5b74e61f7808f6d08f38d9a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Sun, 21 Jun 2026 10:41:50 +0200 Subject: [PATCH] feat(box): routine manifest parser + host test --- main/CMakeLists.txt | 1 + main/audio/routine_manifest.c | 107 ++++++++++++++++++++++++++++++ main/audio/routine_manifest.h | 41 ++++++++++++ test/host/test_routine_manifest.c | 70 +++++++++++++++++++ 4 files changed, 219 insertions(+) create mode 100644 main/audio/routine_manifest.c create mode 100644 main/audio/routine_manifest.h create mode 100644 test/host/test_routine_manifest.c diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index c1444e7..72bf4e3 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -37,6 +37,7 @@ set(srcs "audio/aac_player.c" "audio/calm_tone.c" "audio/podcast_manifest.c" + "audio/routine_manifest.c" "net/podcast.c" ) diff --git a/main/audio/routine_manifest.c b/main/audio/routine_manifest.c new file mode 100644 index 0000000..0c6dbce --- /dev/null +++ b/main/audio/routine_manifest.c @@ -0,0 +1,107 @@ +// See routine_manifest.h. The pure parser is host-testable (no ESP-IDF deps); +// the SD loader is compiled only on-target (#ifndef LISAEL_HOST_TEST). +#include "audio/routine_manifest.h" +#include "cJSON.h" +#include +#include +#include + +int lisael_routine_parse(const char *json, lisael_routine_t *routines, int max_routines) +{ + if (!json || !routines || max_routines <= 0) { + return -1; + } + cJSON *root = cJSON_Parse(json); + if (!cJSON_IsObject(root)) { + cJSON_Delete(root); + return -1; + } + cJSON *arr = cJSON_GetObjectItem(root, "routines"); + if (!cJSON_IsArray(arr)) { + cJSON_Delete(root); + return -1; + } + + int n_routines = 0; + cJSON *jr; + cJSON_ArrayForEach(jr, arr) { + if (n_routines >= max_routines) { + break; + } + cJSON *jid = cJSON_GetObjectItem(jr, "id"); + cJSON *jtitle = cJSON_GetObjectItem(jr, "title"); + cJSON *jicon = cJSON_GetObjectItem(jr, "icon"); + cJSON *jsteps = cJSON_GetObjectItem(jr, "steps"); + if (!cJSON_IsString(jid) || !cJSON_IsArray(jsteps)) { + continue; + } + lisael_routine_t *r = &routines[n_routines]; + memset(r, 0, sizeof(*r)); + snprintf(r->id, sizeof(r->id), "%s", jid->valuestring); + if (cJSON_IsString(jtitle)) { + snprintf(r->title, sizeof(r->title), "%s", jtitle->valuestring); + } else { + snprintf(r->title, sizeof(r->title), "%s", jid->valuestring); + } + if (cJSON_IsString(jicon)) { + snprintf(r->icon, sizeof(r->icon), "%s", jicon->valuestring); + } + cJSON *jstep; + cJSON_ArrayForEach(jstep, jsteps) { + if (r->n_steps >= LISAEL_MAX_STEPS) { + break; + } + cJSON *jtext = cJSON_GetObjectItem(jstep, "text"); + cJSON *jsicon = cJSON_GetObjectItem(jstep, "icon"); + cJSON *jaudio = cJSON_GetObjectItem(jstep, "audio"); + if (!cJSON_IsString(jtext)) { + continue; // a step with no label is useless; skip it + } + lisael_step_t *s = &r->steps[r->n_steps]; + snprintf(s->text, sizeof(s->text), "%s", jtext->valuestring); + if (cJSON_IsString(jsicon)) { + snprintf(s->icon, sizeof(s->icon), "%s", jsicon->valuestring); + } + if (cJSON_IsString(jaudio)) { + snprintf(s->audio, sizeof(s->audio), "%s", jaudio->valuestring); + } + r->n_steps++; + } + if (r->n_steps > 0) { + n_routines++; // skip routines with no usable step + } + } + cJSON_Delete(root); + return n_routines; +} + +#ifndef LISAEL_HOST_TEST +#include "esp_log.h" +int lisael_routine_load(lisael_routine_t *routines, int max_routines) +{ + const char *path = "/sdcard/routines/routines.json"; + FILE *f = fopen(path, "r"); + if (!f) { + return -1; + } + fseek(f, 0, SEEK_END); + long sz = ftell(f); + fseek(f, 0, SEEK_SET); + if (sz <= 0 || sz > 64 * 1024) { + 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); + int n = lisael_routine_parse(buf, routines, max_routines); + free(buf); + ESP_LOGI("routine", "loaded %d routine(s) from %s", n, path); + return n; +} +#endif diff --git a/main/audio/routine_manifest.h b/main/audio/routine_manifest.h new file mode 100644 index 0000000..d0fb0cc --- /dev/null +++ b/main/audio/routine_manifest.h @@ -0,0 +1,41 @@ +// Routine manifest for the Lisael Box "Routine" mode. +// /sdcard/routines/routines.json = JSON object { "routines": [ ... ] }, each +// routine has an id, a title, a tile icon, and an ordered list of steps. ASCII +// file names; UTF-8 title/text strings. The pure parser is host-testable. +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +// Caps kept small: lisael_routine_t is ~2 KB; mode keeps a static array of +// these in PSRAM, never a task stack. Two fixed routines (matin/soir), <=12 +// steps each. +#define LISAEL_MAX_ROUTINES 2 +#define LISAEL_MAX_STEPS 12 + +typedef struct { + char text[80]; // UTF-8 step label (e.g. "Habille-toi") + char icon[40]; // .bin basename under /sdcard/routines/ ("matin_01.bin") + char audio[40]; // .mp3 basename under /sdcard/routines/ ("matin_01.mp3") +} lisael_step_t; + +typedef struct { + char id[16]; // ASCII id ("matin", "soir") + char title[48]; // UTF-8 display title ("Le matin") + char icon[40]; // tile icon basename ("matin.bin"), "" if none + lisael_step_t steps[LISAEL_MAX_STEPS]; + int n_steps; +} lisael_routine_t; + +// Pure parser: JSON string -> routines[]. Expects a root OBJECT with a +// "routines" array. Skips routines with zero steps. Returns routine count +// (>=0), or -1 if the JSON is malformed or has no "routines" array. +int lisael_routine_parse(const char *json, lisael_routine_t *routines, int max_routines); + +// Read /sdcard/routines/routines.json and parse it. Returns count or -1. +int lisael_routine_load(lisael_routine_t *routines, int max_routines); + +#ifdef __cplusplus +} +#endif diff --git a/test/host/test_routine_manifest.c b/test/host/test_routine_manifest.c new file mode 100644 index 0000000..2aeb59e --- /dev/null +++ b/test/host/test_routine_manifest.c @@ -0,0 +1,70 @@ +// Host unit test for the routine manifest parser. +#define LISAEL_HOST_TEST +#include "audio/routine_manifest.h" +#include +#include +#include + +static const char *SAMPLE = +"{\"routines\":[" + "{\"id\":\"matin\",\"title\":\"Le matin\",\"icon\":\"matin.bin\",\"steps\":[" + "{\"text\":\"Habille-toi\",\"icon\":\"matin_01.bin\",\"audio\":\"matin_01.mp3\"}," + "{\"text\":\"Petit-déjeuner\",\"icon\":\"matin_02.bin\",\"audio\":\"matin_02.mp3\"}]}," + "{\"id\":\"soir\",\"title\":\"Le soir\",\"icon\":\"soir.bin\",\"steps\":[" + "{\"text\":\"Brosse tes dents\",\"icon\":\"soir_01.bin\",\"audio\":\"soir_01.mp3\"}]}," + "{\"id\":\"vide\",\"title\":\"Vide\",\"icon\":\"vide.bin\",\"steps\":[]}" +"]}"; + +int main(void) { + lisael_routine_t r[LISAEL_MAX_ROUTINES]; + int n = lisael_routine_parse(SAMPLE, r, LISAEL_MAX_ROUTINES); + assert(n == 2); // empty routine skipped + assert(strcmp(r[0].id, "matin") == 0); + assert(strcmp(r[0].title, "Le matin") == 0); + assert(strcmp(r[0].icon, "matin.bin") == 0); + assert(r[0].n_steps == 2); + assert(strcmp(r[0].steps[0].text, "Habille-toi") == 0); + assert(strcmp(r[0].steps[1].icon, "matin_02.bin") == 0); + assert(strcmp(r[0].steps[1].audio, "matin_02.mp3") == 0); + assert(strcmp(r[1].id, "soir") == 0); + assert(r[1].n_steps == 1); + + // malformed / wrong-shape inputs -> -1 + assert(lisael_routine_parse("not json", r, LISAEL_MAX_ROUTINES) == -1); + assert(lisael_routine_parse("[]", r, LISAEL_MAX_ROUTINES) == -1); // array root, not object + assert(lisael_routine_parse("{\"foo\":1}", r, LISAEL_MAX_ROUTINES) == -1); // no "routines" + + // empty routines array -> 0 (valid, just no content) + lisael_routine_t r2[LISAEL_MAX_ROUTINES]; + assert(lisael_routine_parse("{\"routines\":[]}", r2, LISAEL_MAX_ROUTINES) == 0); + + // a step missing "text" is skipped; icon/audio optional (stay "") + int m = lisael_routine_parse( + "{\"routines\":[{\"id\":\"x\",\"title\":\"X\",\"steps\":[" + "{\"icon\":\"a.bin\"}," // no text -> skipped + "{\"text\":\"Range\"}]}]}", // no icon/audio -> "" + r2, LISAEL_MAX_ROUTINES); + assert(m == 1 && r2[0].n_steps == 1); + assert(strcmp(r2[0].steps[0].text, "Range") == 0); + assert(r2[0].steps[0].icon[0] == '\0'); + assert(r2[0].steps[0].audio[0] == '\0'); + + // guard clauses + assert(lisael_routine_parse(NULL, r2, LISAEL_MAX_ROUTINES) == -1); + assert(lisael_routine_parse("{\"routines\":[]}", r2, 0) == -1); + + // caps: never exceed LISAEL_MAX_STEPS even if JSON has more + char big[2048]; + int o = snprintf(big, sizeof(big), "{\"routines\":[{\"id\":\"b\",\"title\":\"B\",\"steps\":["); + for (int i = 0; i < 20; i++) { // 20 > LISAEL_MAX_STEPS (12) + o += snprintf(big + o, sizeof(big) - o, + "%s{\"text\":\"s%d\"}", i ? "," : "", i); + } + snprintf(big + o, sizeof(big) - o, "]}]}"); + lisael_routine_t r3[LISAEL_MAX_ROUTINES]; + int k = lisael_routine_parse(big, r3, LISAEL_MAX_ROUTINES); + assert(k == 1 && r3[0].n_steps == LISAEL_MAX_STEPS); + + printf("OK: %d routines\n", n); + return 0; +}