a8f0210567
CI / platformio (pull_request) Failing after 6m0s
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>
756 lines
32 KiB
C
756 lines
32 KiB
C
// 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);
|
||
}
|
||
}
|
||
}
|