feat(box): routine manifest parser + host test

This commit is contained in:
L'électron rare
2026-06-21 10:41:50 +02:00
parent 5c8b846f05
commit 6cda5d243d
4 changed files with 219 additions and 0 deletions
+1
View File
@@ -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"
)
+107
View File
@@ -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 <string.h>
#include <stdio.h>
#include <stdlib.h>
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
+41
View File
@@ -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
+70
View File
@@ -0,0 +1,70 @@
// Host unit test for the routine manifest parser.
#define LISAEL_HOST_TEST
#include "audio/routine_manifest.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
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;
}