Files
ESP32_ZACUS/idf_zacus/main/main.c
T
clement a8f0210567
CI / platformio (pull_request) Failing after 6m0s
feat(p4): montage microSD (SDMMC 1-bit) sur /sdcard
Nouveau composant sd_storage : monte la carte microSD du Freenove en SDMMC
1-bit (CMD=38, CLK=39, D0=40 — pinout FREENOVE_SDMMC_* du firmware Arduino)
sur /sdcard via esp_vfs_fat_sdmmc_mount. Best-effort au boot : carte absente
= non fatal, fallback LittleFS. Lève le plafond LittleFS 5 Mo pour les assets.

Validé sur Freenove : carte ~3839 MiB montée à /sdcard au boot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 15:49:44 +02:00

756 lines
32 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Zacus master — ESP-IDF entry point (P1 slice 2).
//
// Responsibilities at this slice:
// 1. Initialize NVS (required by Wi-Fi).
// 2. Initialize esp_netif + default event loop.
// 3. Read Wi-Fi creds from NVS namespace "wifi" (keys "ssid" / "pwd").
// - If creds present : start STA, wait for IP_EVENT_STA_GOT_IP.
// - If creds absent : fall back to open AP "zacus-setup" so the
// operator can still reach the OTA endpoint
// (and provision creds in a later slice).
// 4. Once the network is up, call ota_server_init() so the inherited
// HTTP server (port 80) starts answering /version, /status, /ota.
// 5. Mount the LittleFS "storage" partition on /littlefs and list it.
// 6. Log heap stats + idle loop with periodic heartbeat (60 s).
//
// Subsequent slices port the NPC engine, voice pipeline, media manager, etc.
// See docs/superpowers/specs/2026-05-03-voice-pipeline-esp-sr-design.md.
#include <stdio.h>
#include <string.h>
#include <dirent.h>
#include <sys/stat.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/event_groups.h"
#include "esp_log.h"
#include "esp_system.h"
#include "esp_heap_caps.h"
#include "esp_err.h"
#include "esp_event.h"
#include "esp_netif.h"
#include "esp_wifi.h"
#include "esp_littlefs.h"
#include "nvs_flash.h"
#include "nvs.h"
#include "mdns.h"
#include "ota_server.h"
#include "media_manager.h"
#include "sd_storage.h"
#include "npc_engine.h"
#include "hints_client.h"
#include "voice_pipeline.h"
#include "voice_dispatcher.h"
#include "voice_hook_endpoint.h"
#include "game_endpoint.h"
#include "scenario_mesh.h"
#include "puzzle_state.h"
#include "local_puzzles.h"
#include "qr_puzzle.h"
#include "buttons_input.h"
#include "mic_broker.h"
#include "board_pins_mediakit.h"
#include "display_ui.h"
// Hints engine endpoint (slice 5). Hardcoded for now — slice 7 will move
// this to NVS so the field operator can repoint the firmware without a flash.
#define ZACUS_HINTS_BASE_URL "http://192.168.0.150:8302"
// Slice 7: voice-bridge WebSocket on the MacStudio (Tailscale address).
// Hardcoded here for the same reason as ZACUS_HINTS_BASE_URL — moves to
// NVS in a follow-up slice. The bridge endpoint is documented in
// docs/superpowers/specs/2026-05-03-voice-pipeline-esp-sr-design.md.
#define ZACUS_VOICE_BRIDGE_WS_URL "ws://100.116.92.12:8200/voice/ws"
static const char *TAG = "zacus_main";
// ─── Puzzle aggregation state ─────────────────────────────────────────────────
static puzzle_state_t s_pstate;
// Soft-AP fallback when no creds in NVS yet.
#define ZACUS_FALLBACK_AP_SSID "zacus-setup"
#define ZACUS_FALLBACK_AP_CHAN 6
#define ZACUS_STA_MAX_RETRY 8
// ─── Wi-Fi state ─────────────────────────────────────────────────────────────
static EventGroupHandle_t s_wifi_event_group;
#define WIFI_CONNECTED_BIT BIT0
#define WIFI_FAIL_BIT BIT1
static int s_sta_retry = 0;
// ─── ota_server externs (real implementations come with the puzzle/master
// state in a later P1 slice; for now we provide trivial stubs so the
// component links cleanly). ─────────────────────────────────────────────
int puzzle_get_battery_pct(void) {
return 100;
}
int puzzle_get_espnow_peer_count(void) {
return scenario_mesh_peer_count();
}
// Slice 6: wake-word callback. Runs on the voice_pipeline capture task,
// keep it short. The pipeline already auto-transitioned to LISTENING
// before invoking us; here we just log + ensure capture is running so
// downstream STT (slice 7) has audio to consume.
static void on_voice_wake(const char *wake_word, void *user_ctx) {
(void) user_ctx;
ESP_LOGI(TAG, "WAKE: \"%s\" detected, transitioning to LISTENING",
wake_word ? wake_word : "(null)");
// Capture is already running (esp-sr feeds it), but if a future
// slice toggles it off between wakes, this keeps us robust.
esp_err_t err = voice_pipeline_start_capture();
if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
ESP_LOGW(TAG, "voice_pipeline_start_capture from wake cb: %s",
esp_err_to_name(err));
}
}
// Slice 7/8: STT callback. Runs on the WebSocket event-loop task —
// keep it short. The actual routing (keyword fast-path → hints engine,
// non-keyword → defer to LLM intent path) is owned by voice_dispatcher,
// which voice_pipeline_ws calls in parallel with this user callback.
// We keep the log here as a diagnostic breadcrumb for field debugging.
static void on_voice_stt(const char *text, bool final, void *user_ctx) {
(void) user_ctx;
ESP_LOGI(TAG, "STT(final=%d): %s", final ? 1 : 0,
text ? text : "(null)");
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
static void log_heap_stats(const char *phase) {
ESP_LOGI(TAG, "[heap @ %s] free=%u internal=%u psram=%u",
phase,
(unsigned) esp_get_free_heap_size(),
(unsigned) esp_get_free_internal_heap_size(),
(unsigned) heap_caps_get_free_size(MALLOC_CAP_SPIRAM));
}
static esp_err_t mount_littlefs(void) {
const esp_vfs_littlefs_conf_t conf = {
.base_path = "/littlefs",
.partition_label = "storage",
.format_if_mount_failed = true,
.dont_mount = false,
};
esp_err_t err = esp_vfs_littlefs_register(&conf);
if (err != ESP_OK) {
ESP_LOGE(TAG, "LittleFS mount failed: %s", esp_err_to_name(err));
return err;
}
size_t total = 0, used = 0;
if (esp_littlefs_info(conf.partition_label, &total, &used) == ESP_OK) {
ESP_LOGI(TAG, "LittleFS mounted at %s — %u / %u bytes used",
conf.base_path, (unsigned) used, (unsigned) total);
}
return ESP_OK;
}
static void list_littlefs_root(void) {
DIR *dir = opendir("/littlefs");
if (!dir) {
ESP_LOGW(TAG, "opendir(/littlefs) failed");
return;
}
struct dirent *ent;
int count = 0;
while ((ent = readdir(dir)) != NULL) {
ESP_LOGI(TAG, " /littlefs/%s (type=%d)", ent->d_name, ent->d_type);
count++;
}
closedir(dir);
ESP_LOGI(TAG, "LittleFS root contains %d entries", count);
}
// ─── ESP-NOW relay peer registry seed ────────────────────────────────────────
//
// The /game/scenario/relay endpoint resolves a peer alias (e.g. "box3",
// "plip", "p7_coffre") to a MAC via the scenario_mesh registry. Seed it from
// NVS namespace "peers": one entry per alias whose value is the 6-byte MAC
// blob (set with `idf.py nvs-partition-gen` or from the dashboard). This keeps
// MAC addresses out of the firmware image and lets the GM repoint a satellite
// board without a reflash.
//
// Provisioning example (nvs CSV):
// key,type,encoding,value
// peers,namespace,,
// box3,data,base64,<base64 of the 6 MAC bytes>
static void seed_relay_peers_from_nvs(void) {
nvs_iterator_t it = NULL;
esp_err_t err = nvs_entry_find("nvs", "peers", NVS_TYPE_BLOB, &it);
if (err != ESP_OK) {
ESP_LOGI(TAG, "no NVS 'peers' namespace — relay registry empty "
"(set MACs to enable /game/scenario/relay)");
return;
}
nvs_handle_t h;
if (nvs_open("peers", NVS_READONLY, &h) != ESP_OK) {
nvs_release_iterator(it);
return;
}
int seeded = 0;
while (err == ESP_OK && it != NULL) {
nvs_entry_info_t info;
nvs_entry_info(it, &info);
uint8_t mac[6];
size_t len = sizeof(mac);
if (nvs_get_blob(h, info.key, mac, &len) == ESP_OK && len == 6) {
if (scenario_mesh_register_peer(info.key, mac) == ESP_OK) {
seeded++;
}
} else {
ESP_LOGW(TAG, "peers/%s: not a 6-byte MAC blob — skipped",
info.key);
}
err = nvs_entry_next(&it);
}
nvs_release_iterator(it);
nvs_close(h);
ESP_LOGI(TAG, "relay peer registry seeded: %d peer(s)", seeded);
}
// ─── Wi-Fi: NVS creds + event handler ────────────────────────────────────────
// Reads NVS namespace "wifi" keys "ssid" + "pwd". Returns ESP_OK if both
// keys are present and ssid is non-empty. Buffers are NUL-terminated.
static esp_err_t load_wifi_creds(char *ssid, size_t ssid_len,
char *pwd, size_t pwd_len) {
nvs_handle_t h;
esp_err_t err = nvs_open("wifi", NVS_READONLY, &h);
if (err != ESP_OK) {
ESP_LOGI(TAG, "NVS namespace 'wifi' not found (%s)", esp_err_to_name(err));
return err;
}
size_t len = ssid_len;
err = nvs_get_str(h, "ssid", ssid, &len);
if (err != ESP_OK || len <= 1) {
ESP_LOGI(TAG, "NVS 'wifi/ssid' missing (%s)", esp_err_to_name(err));
nvs_close(h);
return ESP_ERR_NOT_FOUND;
}
len = pwd_len;
err = nvs_get_str(h, "pwd", pwd, &len);
if (err != ESP_OK) {
// Empty password is acceptable (open network).
pwd[0] = '\0';
}
nvs_close(h);
return ESP_OK;
}
static void wifi_event_handler(void *arg, esp_event_base_t base,
int32_t id, void *data) {
if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START) {
ESP_LOGI(TAG, "STA start — connecting…");
esp_wifi_connect();
} else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) {
if (s_sta_retry < ZACUS_STA_MAX_RETRY) {
s_sta_retry++;
ESP_LOGW(TAG, "STA disconnected — retry %d/%d", s_sta_retry, ZACUS_STA_MAX_RETRY);
esp_wifi_connect();
} else {
ESP_LOGE(TAG, "STA give up after %d retries", ZACUS_STA_MAX_RETRY);
xEventGroupSetBits(s_wifi_event_group, WIFI_FAIL_BIT);
}
} 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, "STA got IP " IPSTR, IP2STR(&event->ip_info.ip));
s_sta_retry = 0;
xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT);
} else if (base == WIFI_EVENT && id == WIFI_EVENT_AP_START) {
ESP_LOGI(TAG, "AP started — SSID=\"%s\" (open)", ZACUS_FALLBACK_AP_SSID);
} else if (base == WIFI_EVENT && id == WIFI_EVENT_AP_STACONNECTED) {
ESP_LOGI(TAG, "AP: client joined");
}
}
// Returns true if STA connected, false if AP fallback (or STA gave up).
static bool wifi_bring_up(void) {
s_wifi_event_group = xEventGroupCreate();
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
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, &wifi_event_handler, NULL, NULL));
ESP_ERROR_CHECK(esp_event_handler_instance_register(
IP_EVENT, IP_EVENT_STA_GOT_IP, &wifi_event_handler, NULL, NULL));
char ssid[33] = {0};
char pwd[65] = {0};
bool have_creds = (load_wifi_creds(ssid, sizeof(ssid), pwd, sizeof(pwd)) == ESP_OK);
if (have_creds) {
ESP_LOGI(TAG, "Wi-Fi: STA mode (ssid=\"%s\")", ssid);
esp_netif_create_default_wifi_sta();
wifi_config_t wc = {0};
strncpy((char *) wc.sta.ssid, ssid, sizeof(wc.sta.ssid) - 1);
strncpy((char *) wc.sta.password, pwd, sizeof(wc.sta.password) - 1);
wc.sta.threshold.authmode = WIFI_AUTH_OPEN; // accept any; we don't pin
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wc));
ESP_ERROR_CHECK(esp_wifi_start());
EventBits_t bits = xEventGroupWaitBits(
s_wifi_event_group,
WIFI_CONNECTED_BIT | WIFI_FAIL_BIT,
pdFALSE, pdFALSE,
pdMS_TO_TICKS(20000));
if (bits & WIFI_CONNECTED_BIT) {
return true;
}
ESP_LOGW(TAG, "STA failed within 20 s — fallback to AP");
esp_wifi_stop();
} else {
ESP_LOGI(TAG, "Wi-Fi: no creds in NVS, starting AP fallback");
}
// AP fallback (open network — provisioning will be added later).
esp_netif_create_default_wifi_ap();
wifi_config_t ap_cfg = {0};
strncpy((char *) ap_cfg.ap.ssid, ZACUS_FALLBACK_AP_SSID, sizeof(ap_cfg.ap.ssid) - 1);
ap_cfg.ap.ssid_len = strlen(ZACUS_FALLBACK_AP_SSID);
ap_cfg.ap.channel = ZACUS_FALLBACK_AP_CHAN;
ap_cfg.ap.max_connection = 4;
ap_cfg.ap.authmode = WIFI_AUTH_OPEN;
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_AP));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &ap_cfg));
ESP_ERROR_CHECK(esp_wifi_start());
return false;
}
// ─── mDNS bring-up (slice 12) ────────────────────────────────────────────────
//
// Publishes `zacus-master.local` once Wi-Fi STA is up so PLIP and the
// dashboard can discover the master without a DHCP reservation. We
// also advertise a `_zacus._tcp` service on port 80 with TXT records
// pointing at the voice-hook URI — useful for `dns-sd -B _zacus._tcp`
// style introspection from the workshop laptop.
//
// In AP-fallback mode we deliberately skip mDNS: there is no upstream
// resolver to claim the hostname against, and several tooling stacks
// (avahi, bonjour) trip over a duplicate-name race when the AP later
// goes back to STA. The PLIP fallback in that scenario is the static
// AP IP (192.168.4.1).
static void start_mdns(void) {
esp_err_t err = mdns_init();
if (err != ESP_OK) {
ESP_LOGW(TAG, "mdns_init failed: %s", esp_err_to_name(err));
return;
}
err = mdns_hostname_set("zacus-master");
if (err != ESP_OK) {
ESP_LOGW(TAG, "mdns_hostname_set: %s", esp_err_to_name(err));
// Continue — the daemon is up, just no hostname claim.
}
err = mdns_instance_name_set("Zacus Master ESP32-S3");
if (err != ESP_OK) {
ESP_LOGW(TAG, "mdns_instance_name_set: %s", esp_err_to_name(err));
}
// Advertise the master HTTP surface as a `_zacus._tcp` service on
// port 80. The TXT records let PLIP firmware confirm it found the
// right device + which voice-hook path to POST to (so a future
// protocol bump can be discovered without reflashing PLIP).
err = mdns_service_add(NULL, "_zacus", "_tcp", 80, NULL, 0);
if (err != ESP_OK) {
ESP_LOGW(TAG, "mdns_service_add(_zacus._tcp:80): %s",
esp_err_to_name(err));
return;
}
(void) mdns_service_instance_name_set("_zacus", "_tcp",
"Zacus Master Voice Hook");
mdns_txt_item_t txt[] = {
{"path", "/voice/hook"},
{"version", "1"},
};
err = mdns_service_txt_set("_zacus", "_tcp", txt,
sizeof(txt) / sizeof(txt[0]));
if (err != ESP_OK) {
ESP_LOGW(TAG, "mdns_service_txt_set: %s", esp_err_to_name(err));
}
ESP_LOGI(TAG, "mDNS up — hostname=zacus-master.local, "
"service=_zacus._tcp:80");
}
// ─── app_main ────────────────────────────────────────────────────────────────
void app_main(void) {
ESP_LOGI(TAG, "Zacus master booting (ESP-IDF scaffold, P1 slice 2)");
log_heap_stats("boot");
esp_err_t nvs_err = nvs_flash_init();
if (nvs_err == ESP_ERR_NVS_NO_FREE_PAGES || nvs_err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
nvs_err = nvs_flash_init();
}
ESP_ERROR_CHECK(nvs_err);
ESP_LOGI(TAG, "NVS initialized");
// Phase 1 display: bring up the ST7796 TFT early so the splash is visible
// while the rest of the subsystems initialise. Non-fatal on failure.
esp_err_t disp_err = display_ui_init();
if (disp_err != ESP_OK) {
ESP_LOGW(TAG, "display_ui_init: %s (continuing without display)",
esp_err_to_name(disp_err));
} else {
// Phase 3: mirror QR camera frames to the scene-view viewfinder.
qr_puzzle_set_preview_cb(display_ui_camera_frame);
// 5-way buttons (view toggle + brightness). Non-fatal.
esp_err_t btn_err = buttons_input_init();
if (btn_err != ESP_OK) {
ESP_LOGW(TAG, "buttons_input_init: %s", esp_err_to_name(btn_err));
}
}
bool sta_ok = wifi_bring_up();
ESP_LOGI(TAG, "Wi-Fi up (mode=%s)", sta_ok ? "STA" : "AP-fallback");
// Slice 12: publish zacus-master.local once we're on a real LAN.
// Skip in AP-fallback to avoid hostname-claim races when STA later
// recovers (and because there is no upstream resolver anyway).
if (sta_ok) {
start_mdns();
} else {
ESP_LOGW(TAG, "mDNS not started in AP mode "
"(PLIP must use the AP IP fallback)");
}
esp_err_t ota_err = ota_server_init();
if (ota_err != ESP_OK) {
ESP_LOGE(TAG, "ota_server_init failed: %s", esp_err_to_name(ota_err));
} else {
ESP_LOGI(TAG, "OTA server listening on :%d", OTA_SERVER_PORT);
// Slice 10: piggyback the PLIP /voice/hook endpoint on the same
// esp_http_server instance. Independent of voice_pipeline_init
// success — the handler tolerates a degraded pipeline (the
// voice_pipeline_* APIs return ESP_ERR_INVALID_STATE which we
// log and report as a 200 with whatever state we have).
httpd_handle_t httpd = ota_server_get_handle();
esp_err_t hook_err = voice_hook_endpoint_init(httpd);
if (hook_err != ESP_OK) {
ESP_LOGW(TAG, "voice_hook_endpoint_init: %s",
esp_err_to_name(hook_err));
}
// Slice 12: REST surface for runtime game tuning. Today this
// exposes /game/group_profile (GET + POST) so the dashboard /
// GM can swap the hints policy without reflashing NVS. The
// POST handler validates via hints_client_set_group_profile()
// and persists to NVS namespace "zacus" / key "group_profile"
// (the same slot main.c reads at boot).
esp_err_t game_err = game_endpoint_init(httpd);
if (game_err != ESP_OK) {
ESP_LOGW(TAG, "game_endpoint_init: %s",
esp_err_to_name(game_err));
} else {
// game_endpoint_init brought up scenario_mesh (ESP-NOW). Seed the
// relay peer registry from NVS so /game/scenario/relay can resolve
// aliases to MACs without a reflash.
seed_relay_peers_from_nvs();
// Task C: give the endpoint access to the master puzzle state so
// POST /game/step and GET /game/puzzle_state become live.
// Called here so s_pstate is valid (puzzle_state_init runs below).
// game_endpoint_set_puzzle_state tolerates being called before
// puzzle_state_init — it only stores the pointer; actual reads
// happen on-demand in the httpd task after boot completes.
game_endpoint_set_puzzle_state(&s_pstate);
}
}
// P4: best-effort microSD mount (SDMMC 1-bit on /sdcard). A missing card
// is non-fatal — assets fall back to LittleFS.
if (sd_storage_mount() == ESP_OK) {
ESP_LOGI(TAG, "microSD ready (%lu MiB) at %s",
(unsigned long) sd_storage_capacity_mb(), SD_STORAGE_MOUNT_POINT);
}
if (mount_littlefs() == ESP_OK) {
list_littlefs_root();
// Slice 3: bring up the ported media_manager. Catalog dirs live on
// LittleFS so this must run *after* the mount succeeds.
media_manager_config_t media_cfg;
media_manager_default_config(&media_cfg);
esp_err_t media_err = media_manager_init(&media_cfg);
if (media_err != ESP_OK) {
ESP_LOGE(TAG, "media_manager_init failed: %s",
esp_err_to_name(media_err));
} else {
// Smoke test: try to play /littlefs/intro.mp3. The file is
// unlikely to exist this early — that's fine, the manager
// returns ESP_ERR_NOT_FOUND and logs a warning, no crash.
esp_err_t play_err = media_manager_play("/littlefs/intro.mp3");
ESP_LOGI(TAG, "media smoke play -> %s",
esp_err_to_name(play_err));
// Slice 4: bring up the ported npc_engine. Cue table is empty
// at this stage — wiring the scenario IR-driven cue catalog is
// a follow-up slice. The engine still boots, accepts ticks
// (no-op when auto_evaluate is false), and is ready to receive
// trigger_cue calls from REST/diagnostic surfaces.
const npc_engine_config_t npc_cfg = {
.cues = NULL,
.cue_count = 0,
.auto_evaluate = false,
.auto_play_decisions = false,
};
esp_err_t npc_err = npc_engine_init(&npc_cfg);
if (npc_err != ESP_OK) {
ESP_LOGE(TAG, "npc_engine_init failed: %s",
esp_err_to_name(npc_err));
}
// Slice 5: bring up the hints HTTP client (so npc_engine can
// route hint requests through the real backend) and the voice
// pipeline (I2S capture stub + state machine, no auto-start).
esp_err_t hints_err = hints_client_init(ZACUS_HINTS_BASE_URL);
if (hints_err != ESP_OK) {
ESP_LOGW(TAG, "hints_client_init failed: %s — npc will use stub",
esp_err_to_name(hints_err));
} else {
// Slice 11 (P5): load the group profile from NVS so the
// hints engine can tune answers per audience (TECH /
// NON_TECH / MIXED / BOTH). Default to MIXED when the
// key is absent or holds an unknown value.
// TODO(slice-11): endpoint /game/group_profile to update
// NVS at runtime — for now the value is flashed by the
// dashboard or `idf.py nvs-partition-gen` outputs.
nvs_handle_t gh;
esp_err_t open_err = nvs_open("zacus", NVS_READONLY, &gh);
char profile[HINTS_CLIENT_GROUP_PROFILE_MAX] = "MIXED";
if (open_err == ESP_OK) {
size_t plen = sizeof(profile);
esp_err_t kerr = nvs_get_str(gh, "group_profile",
profile, &plen);
if (kerr != ESP_OK) {
ESP_LOGI(TAG, "NVS zacus/group_profile missing (%s) "
"— defaulting to MIXED",
esp_err_to_name(kerr));
strncpy(profile, "MIXED", sizeof(profile) - 1);
profile[sizeof(profile) - 1] = '\0';
}
nvs_close(gh);
} else {
ESP_LOGI(TAG, "NVS namespace 'zacus' not found (%s) "
"— defaulting group_profile=MIXED",
esp_err_to_name(open_err));
}
esp_err_t set_err = hints_client_set_group_profile(profile);
if (set_err != ESP_OK) {
// Validation rejected the NVS value — force MIXED
// so the engine still has a usable hint policy.
ESP_LOGW(TAG, "group_profile \"%s\" rejected — falling "
"back to MIXED", profile);
(void) hints_client_set_group_profile("MIXED");
}
}
// Slice 8: voice → npc_engine routing layer. Must come
// after npc_engine_init / hints_client_init so the hint
// fast-path lands on the real backend (fallback: local stub).
esp_err_t disp_err = voice_dispatcher_init();
if (disp_err != ESP_OK) {
ESP_LOGW(TAG, "voice_dispatcher_init failed: %s",
esp_err_to_name(disp_err));
}
// Task 7: init puzzle aggregation + local puzzle wiring.
puzzle_state_init(&s_pstate);
local_puzzles_init(&s_pstate);
ESP_LOGI(TAG, "puzzle_state + local_puzzles initialised");
// Task 7: mic_broker takes ownership of the Media Kit I2S IN
// pins (3/14/46 per board_pins_mediakit.h) BEFORE
// voice_pipeline_init — the pipeline's own init call then
// no-ops with ESP_ERR_INVALID_STATE, which it tolerates.
// Note (a): a HARD failure here (anything other than
// ESP_ERR_INVALID_STATE) will surface again inside
// voice_pipeline_init — same root cause, two log warnings.
// Note (b): the readout path is also deferred — nothing
// consumes puzzle_state_code yet; that lands with the
// scenario hook slice.
esp_err_t mic_err = mic_broker_init(MIC_PIN_BCLK, MIC_PIN_WS,
MIC_PIN_DIN, 16000);
if (mic_err != ESP_OK && mic_err != ESP_ERR_INVALID_STATE) {
ESP_LOGW(TAG, "mic_broker_init: %s", esp_err_to_name(mic_err));
} else {
ESP_LOGI(TAG, "mic_broker initialised (bclk=%d ws=%d din=%d 16kHz)",
MIC_PIN_BCLK, MIC_PIN_WS, MIC_PIN_DIN);
}
voice_pipeline_config_t voice_cfg;
voice_pipeline_default_config(&voice_cfg);
// Slice 6: bring up esp-sr AFE + WakeNet (placeholder
// wn9_hiesp). Auto-start capture so the wake detector is
// hot from boot — saying "Hi ESP" fires the callback below.
voice_cfg.enable_wake_word = true;
voice_cfg.auto_start_capture = true;
// Slice 7: stream post-AFE PCM to the MacStudio voice-bridge
// over WebSocket once the wake word fires. The bridge runs
// STT (whisper) and may auto-route to the LLM intent layer.
voice_cfg.voice_bridge_ws_url = ZACUS_VOICE_BRIDGE_WS_URL;
// Slice 9 + 10: enable the I2S TX leg so TTS payloads coming
// back from the bridge land on the MAX98357A DAC. Wake-word
// stays enabled — PLIP hook is the primary path for voice
// sessions, but "hi esp" remains a backup if the phone is
// unplugged or its hook switch fails.
voice_cfg.enable_tts_playback = true;
// Task 7 / Task 0 pin alignment: override voice_pipeline
// defaults (mic 14/15/22, spk 11/12/13) which collide with
// the FNK0102H camera pins. Use Media Kit values from
// board_pins_mediakit.h (mic: 3/14/46, spk: 42/41/1).
voice_cfg.i2s_bclk_pin = MIC_PIN_BCLK; // 3
voice_cfg.i2s_ws_pin = MIC_PIN_WS; // 14
voice_cfg.i2s_din_pin = MIC_PIN_DIN; // 46
voice_cfg.i2s_out_bclk_pin = SPK_PIN_BCLK; // 42
voice_cfg.i2s_out_lrc_pin = SPK_PIN_LRC; // 41
voice_cfg.i2s_out_din_pin = SPK_PIN_DIN; // 1
voice_pipeline_set_wake_callback(on_voice_wake, NULL);
voice_pipeline_set_stt_callback(on_voice_stt, NULL);
esp_err_t voice_err = voice_pipeline_init(&voice_cfg);
if (voice_err != ESP_OK) {
ESP_LOGW(TAG, "voice_pipeline_init failed: %s",
esp_err_to_name(voice_err));
} else if (voice_pipeline_wake_word_active()) {
ESP_LOGI(TAG, "voice: wake-word detector active "
"(placeholder \"hi esp\")");
} else {
ESP_LOGW(TAG, "voice: wake-word inactive — running "
"in slice-5 stub mode");
}
// Arming is driven by POST /game/step (game_endpoint);
// readout via GET /game/puzzle_state.
}
}
log_heap_stats("post-init");
// Mark this firmware valid only after subsystems came up cleanly.
ota_server_mark_valid();
ESP_LOGI(TAG, "entering idle loop (heartbeat every 60 s)");
uint32_t tick = 0;
uint32_t disp_sub = 0; // sub-counter: display refresh every 2 s
for (;;) {
// Sleep in 2 s increments so the display stays responsive.
// Every 30 sub-ticks (~60 s) we do the full heartbeat log.
// 500 ms so scene changes (POST /game/step) reach the display fast.
vTaskDelay(pdMS_TO_TICKS(500));
disp_sub++;
// ── Display status refresh (every 500 ms) ───────────────────────
if (disp_err == ESP_OK) {
display_status_t ds;
memset(&ds, 0, sizeof(ds));
// IP address (STA only; empty when in AP mode)
esp_netif_t *sta_if = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
if (sta_if) {
esp_netif_ip_info_t ip_info;
if (esp_netif_get_ip_info(sta_if, &ip_info) == ESP_OK &&
ip_info.ip.addr != 0) {
snprintf(ds.ip, sizeof(ds.ip), IPSTR,
IP2STR(&ip_info.ip));
}
}
// Wake-word detector state
ds.wake_active = voice_pipeline_wake_word_active();
// Step + armed type from game_endpoint
game_endpoint_get_puzzle_status(ds.step_id, sizeof(ds.step_id),
ds.armed, sizeof(ds.armed));
// Solved count + assembled code from puzzle_state
ds.solved_count = 0;
for (int i = 1; i <= PUZZLE_MAX_ID; i++) {
if (s_pstate.solved[i]) {
ds.solved_count++;
}
}
puzzle_state_code(&s_pstate, ds.code, sizeof(ds.code));
// Scene metadata (title/subtitle/symbol/effect) of the step.
scene_binding_t sb;
game_endpoint_get_scene(&sb);
if (sb.present) {
memcpy(ds.scene_title, sb.title, sizeof(ds.scene_title));
memcpy(ds.scene_subtitle, sb.subtitle, sizeof(ds.scene_subtitle));
memcpy(ds.scene_symbol, sb.symbol, sizeof(ds.scene_symbol));
}
ds.scene_effect = (uint8_t) sb.effect;
display_ui_set_status(&ds);
// Shell app launch (dynamic tiles from /littlefs/apps/<id>/):
// the app's step.txt names the scenario step to arm.
char app_id[32];
if (display_ui_take_pending_launch(app_id, sizeof(app_id))) {
char path[80], step[64] = {0};
snprintf(path, sizeof(path), "/littlefs/apps/%s/step.txt",
app_id);
FILE *af = fopen(path, "r");
if (af) {
if (fgets(step, sizeof(step), af)) {
step[strcspn(step, "\r\n")] = '\0';
}
fclose(af);
}
if (step[0]) {
char armed[8];
esp_err_t lerr = game_endpoint_apply_step(step, armed,
sizeof(armed));
ESP_LOGI(TAG, "app '%s' -> step '%s': %s (armed=%s)",
app_id, step, esp_err_to_name(lerr),
(lerr == ESP_OK) ? armed : "-");
} else {
ESP_LOGW(TAG, "app '%s': no step.txt — nothing to do",
app_id);
}
}
}
// ── 60 s heartbeat (every 120 × 500 ms sub-ticks) ───────────────
if (disp_sub >= 120) {
disp_sub = 0;
tick++;
const uint32_t uptime_ms = (uint32_t) esp_log_timestamp();
ESP_LOGI(TAG, "heartbeat #%u — uptime=%llu s",
(unsigned) tick,
(unsigned long long) (uptime_ms / 1000));
// Drive the slice-3/4 subsystems from the heartbeat.
media_manager_update(uptime_ms);
npc_engine_update(uptime_ms);
}
}
}