feat: NPC engine + BOX-3 voice

- npc_engine: state machine, mood, triggers
- tts_client: Piper TTS HTTP + SD fallback
- qr_scanner: HMAC-SHA256, debounce
- box3_voice: WebSocket voice client
- zacus_story_gen_ai: AI story generator

Impact: Sprint 2-3 firmware deliverables.
This commit is contained in:
L'électron rare
2026-04-02 09:20:43 +02:00
parent 5795f5df13
commit 96a4f19a87
31 changed files with 4575 additions and 0 deletions
@@ -0,0 +1,5 @@
"""Story generation library (Yamale + Jinja2) for Zacus firmware."""
from .generator import StoryGenerationError
__all__ = ["StoryGenerationError"]
@@ -0,0 +1,155 @@
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from .generator import (
StoryGenerationError,
default_paths,
run_generate_bundle,
run_generate_cpp,
run_sync_screens,
run_validate,
)
def _path_or_none(value: str | None) -> Path | None:
if value is None or value == "":
return None
return Path(value)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Zacus story generator (Yamale + Jinja2)")
sub = parser.add_subparsers(dest="command", required=True)
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--spec-dir", default="", help="Story spec directory")
common.add_argument("--game-dir", default="", help="Game scenario directory")
validate_p = sub.add_parser("validate", parents=[common], help="Validate game + story specs")
cpp_p = sub.add_parser("generate-cpp", parents=[common], help="Generate scenarios_gen/apps_gen C++")
cpp_p.add_argument("--out-dir", default="", help="Output directory for generated C++")
generate_alias = sub.add_parser("generate", parents=[common], help="Alias of generate-cpp")
generate_alias.add_argument("--out-dir", default="", help="Output directory for generated C++")
bundle_p = sub.add_parser("generate-bundle", parents=[common], help="Generate story bundle JSON+sha")
bundle_p.add_argument("--out-dir", default="", help="Bundle root directory")
bundle_p.add_argument("--archive", default="", help="Optional .tar.gz archive output")
sync_screens_p = sub.add_parser(
"sync-screens",
parents=[common],
help="Synchronize data/story/screens and legacy_payloads/fs_excluded/screens from palette",
)
sync_screens_p.add_argument(
"--check",
action="store_true",
help="Check drift only (no writes); non-zero exit on mismatch",
)
deploy_alias = sub.add_parser("deploy", parents=[common], help="Alias of generate-bundle")
deploy_alias.add_argument("--out-dir", default="", help="Bundle root directory")
deploy_alias.add_argument("--archive", default="", help="Optional .tar.gz archive output")
all_p = sub.add_parser("all", parents=[common], help="Validate + generate-cpp + generate-bundle")
all_p.add_argument("--cpp-out-dir", default="", help="Output directory for generated C++")
all_p.add_argument("--bundle-out-dir", default="", help="Bundle root directory")
all_p.add_argument("--archive", default="", help="Optional .tar.gz archive output")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
paths = default_paths()
spec_dir = _path_or_none(args.spec_dir)
game_dir = _path_or_none(args.game_dir)
try:
if args.command == "validate":
result = run_validate(paths, spec_dir=spec_dir, game_dir=game_dir)
print(
f"[story-gen] OK validate scenarios={result['scenario_count']} "
f"game_scenarios={result['game_scenario_count']}"
)
return 0
if args.command in {"generate-cpp", "generate"}:
result = run_generate_cpp(
paths,
out_dir=_path_or_none(args.out_dir),
spec_dir=spec_dir,
game_dir=game_dir,
)
print(
f"[story-gen] OK generate-cpp out={result['out_dir']} "
f"scenarios={result['scenario_count']} spec_hash={result['spec_hash']}"
)
return 0
if args.command in {"generate-bundle", "deploy"}:
archive = _path_or_none(args.archive)
result = run_generate_bundle(
paths,
out_dir=_path_or_none(args.out_dir),
archive=archive,
spec_dir=spec_dir,
game_dir=game_dir,
)
print(
f"[story-gen] OK generate-bundle out={result['out_dir']} "
f"scenarios={result['scenario_count']} spec_hash={result['spec_hash']}"
)
if result["archive"] is not None:
print(f"[story-gen] archive={result['archive']}")
return 0
if args.command == "sync-screens":
result = run_sync_screens(paths, check_only=bool(args.check))
mode = "check" if args.check else "write"
print(
f"[story-gen] OK sync-screens mode={mode} story={result['story_count']} "
f"story_written={result['story_written']} legacy_written={result['legacy_written']} "
f"palette={result['palette_path']} legacy_dir={result['legacy_dir']}"
)
return 0
if args.command == "all":
validate = run_validate(paths, spec_dir=spec_dir, game_dir=game_dir)
cpp = run_generate_cpp(
paths,
out_dir=_path_or_none(args.cpp_out_dir),
spec_dir=spec_dir,
game_dir=game_dir,
)
bundle = run_generate_bundle(
paths,
out_dir=_path_or_none(args.bundle_out_dir),
archive=_path_or_none(args.archive),
spec_dir=spec_dir,
game_dir=game_dir,
)
print(
"[story-gen] OK all "
f"validate={validate['scenario_count']} cpp={cpp['scenario_count']} "
f"bundle={bundle['scenario_count']} spec_hash={cpp['spec_hash']}"
)
if bundle["archive"] is not None:
print(f"[story-gen] archive={bundle['archive']}")
return 0
parser.error(f"Unknown command: {args.command}")
return 2
except StoryGenerationError as exc:
print(f"[story-gen] ERR {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,49 @@
id: str()
version: int(min=1)
title: str()
theme: str()
players: include('players')
ages: str()
duration_minutes: include('duration')
canon: include('canon')
stations: list(include('station'), min=1)
puzzles: list(include('puzzle'), min=1)
solution: include('solution')
solution_unique: bool()
finale_statement: str(required=False)
notes: str(required=False)
---
players:
min: int(min=1)
max: int(min=1)
duration:
min: int(min=1)
max: int(min=1)
canon:
introduction: str(required=False)
timeline: list(include('timeline_entry'), min=1)
stakes: str(required=False)
timeline_entry:
label: str()
note: str()
station:
name: str()
focus: str()
clue: str()
puzzle:
id: str()
type: str()
clue: str()
effect: str()
solution:
culprit: str()
motive: str()
method: str()
proof: list(str(), min=1)
@@ -0,0 +1,2 @@
prompt_input: any()
current_firmware_snapshot: any(required=False)
@@ -0,0 +1,37 @@
id: str()
version: int(min=1)
initial_step: str()
estimated_duration_s: int(required=False, min=0)
debug_transition_bypass_enabled: bool(required=False)
app_bindings: list(include('app_binding'), min=1)
steps: list(include('step'), min=1)
---
app_binding:
id: str()
app: enum('LA_DETECTOR', 'AUDIO_PACK', 'SCREEN_SCENE', 'MP3_GATE', 'WIFI_STACK', 'ESPNOW_STACK', 'QR_UNLOCK_APP')
config: include('la_config', required=False)
la_config:
hold_ms: int(required=False, min=100)
unlock_event: str(required=False)
require_listening: bool(required=False)
step:
step_id: str()
screen_scene_id: str()
audio_pack_id: str(required=False)
actions: list(str(), required=False)
apps: list(str(), min=1)
mp3_gate_open: bool()
transitions: list(include('transition'))
transition:
id: str(required=False)
trigger: enum('on_event', 'after_ms', 'immediate')
event_type: enum('none', 'unlock', 'audio_done', 'timer', 'serial', 'button', 'espnow', 'action')
event_name: str()
target_step_id: str()
after_ms: int(min=0)
priority: int(min=0)
debug_only: bool(required=False)
@@ -0,0 +1,57 @@
// AUTO-GENERATED FILE - DO NOT EDIT
// Generated by zacus_story_gen_ai (Yamale + Jinja2)
// spec_hash: {{ spec_hash }}
// scenarios: {{ scenario_count }}
#include "apps_gen.h"
#include <cstring>
namespace {
constexpr AppBindingDef kGeneratedAppBindings[] = {
{% for app in app_entries %} {"{{ app.id }}", {{ app.app_cpp }}},
{% endfor %}};
{% set la_configs = app_entries | selectattr('la_config') | list %}
{% if la_configs %}
constexpr LaDetectorAppConfigDef kGeneratedLaConfigs[] = {
{% for app in la_configs %} {"{{ app.id }}", true, {{ app.la_config.hold_ms }}U, "{{ app.la_config.unlock_event }}", {{ 'true' if app.la_config.require_listening else 'false' }}},
{% endfor %}};
{% endif %}
} // namespace
const AppBindingDef* generatedAppBindingById(const char* id) {
if (id == nullptr || id[0] == '\0') {
return nullptr;
}
for (const AppBindingDef& binding : kGeneratedAppBindings) {
if (binding.id != nullptr && strcmp(binding.id, id) == 0) {
return &binding;
}
}
return nullptr;
}
uint8_t generatedAppBindingCount() {
return static_cast<uint8_t>(sizeof(kGeneratedAppBindings) / sizeof(kGeneratedAppBindings[0]));
}
const char* generatedAppBindingIdAt(uint8_t index) {
if (index >= generatedAppBindingCount()) {
return nullptr;
}
return kGeneratedAppBindings[index].id;
}
const LaDetectorAppConfigDef* generatedLaDetectorConfigByBindingId(const char* id) {
if (id == nullptr || id[0] == '\0') {
return nullptr;
}
{% if la_configs %}
for (const LaDetectorAppConfigDef& cfg : kGeneratedLaConfigs) {
if (cfg.bindingId != nullptr && strcmp(cfg.bindingId, id) == 0) {
return &cfg;
}
}
{% endif %}
return nullptr;
}
@@ -0,0 +1,23 @@
// AUTO-GENERATED FILE - DO NOT EDIT
// Generated by zacus_story_gen_ai (Yamale + Jinja2)
// spec_hash: {{ spec_hash }}
// scenarios: {{ scenario_count }}
#pragma once
#include <Arduino.h>
#include "../core/scenario_def.h"
struct LaDetectorAppConfigDef {
const char* bindingId;
bool hasConfig;
uint32_t holdMs;
const char* unlockEvent;
bool requireListening;
};
const AppBindingDef* generatedAppBindingById(const char* id);
uint8_t generatedAppBindingCount();
const char* generatedAppBindingIdAt(uint8_t index);
const LaDetectorAppConfigDef* generatedLaDetectorConfigByBindingId(const char* id);
@@ -0,0 +1,78 @@
// AUTO-GENERATED FILE - DO NOT EDIT
// Generated by zacus_story_gen_ai (Yamale + Jinja2)
// spec_hash: {{ spec_hash }}
// scenarios: {{ scenario_count }}
#include "scenarios_gen.h"
#include <cstring>
namespace {
{% for scenario in scenarios %}
{% for step in scenario.steps %}
{% if step.actions %}
constexpr const char* {{ step.prefix }}Actions[] = {
{% for action in step.actions %} "{{ action }}",
{% endfor %}};
{% endif %}
{% if step.apps %}
constexpr const char* {{ step.prefix }}Apps[] = {
{% for app_id in step.apps %} "{{ app_id }}",
{% endfor %}};
{% endif %}
{% if step.transitions %}
constexpr TransitionDef {{ step.prefix }}Transitions[] = {
{% for tr in step.transitions %} {"{{ tr.id }}", {{ tr.trigger_cpp }}, {{ tr.event_cpp }}, "{{ tr.event_name }}", {{ tr.after_ms }}U, "{{ tr.target_step_id }}", {{ tr.priority }}U, {{ tr.debug_only }}},
{% endfor %}};
{% endif %}
{% endfor %}
constexpr StepDef kScenario{{ scenario.index }}Steps[] = {
{% for step in scenario.steps %} {"{{ step.id }}", {{ "{" }}{% if step.screen_scene_id == 'nullptr' %}nullptr{% else %}"{{ step.screen_scene_id }}"{% endif %}, {% if step.audio_pack_id == 'nullptr' %}nullptr{% else %}"{{ step.audio_pack_id }}"{% endif %}, {% if step.actions %}{{ step.prefix }}Actions{% else %}nullptr{% endif %}, {{ step.actions|length }}U, {% if step.apps %}{{ step.prefix }}Apps{% else %}nullptr{% endif %}, {{ step.apps|length }}U{{ "}" }}, {% if step.transitions %}{{ step.prefix }}Transitions{% else %}nullptr{% endif %}, {{ step.transitions|length }}U, {{ step.mp3_gate_open }}},
{% endfor %}};
constexpr ScenarioDef kScenario{{ scenario.index }} = {
"{{ scenario.id }}",
{{ scenario.version }}U,
kScenario{{ scenario.index }}Steps,
{{ scenario.steps|length }}U,
"{{ scenario.initial_step }}",
};
{% endfor %}
constexpr const ScenarioDef* kGeneratedScenarios[] = {
{% for scenario in scenarios %} &kScenario{{ scenario.index }},
{% endfor %}};
} // namespace
const ScenarioDef* generatedScenarioById(const char* id) {
if (id == nullptr || id[0] == '\0') {
return generatedScenarioDefault();
}
for (const ScenarioDef* scenario : kGeneratedScenarios) {
if (scenario != nullptr && scenario->id != nullptr && strcmp(scenario->id, id) == 0) {
return scenario;
}
}
return nullptr;
}
const ScenarioDef* generatedScenarioDefault() {
return (generatedScenarioCount() == 0U) ? nullptr : kGeneratedScenarios[0];
}
uint8_t generatedScenarioCount() {
return static_cast<uint8_t>(sizeof(kGeneratedScenarios) / sizeof(kGeneratedScenarios[0]));
}
const char* generatedScenarioIdAt(uint8_t index) {
if (index >= generatedScenarioCount()) {
return nullptr;
}
return kGeneratedScenarios[index]->id;
}
const char* generatedScenarioSpecHash() {
return "{{ spec_hash }}";
}
@@ -0,0 +1,16 @@
// AUTO-GENERATED FILE - DO NOT EDIT
// Generated by zacus_story_gen_ai (Yamale + Jinja2)
// spec_hash: {{ spec_hash }}
// scenarios: {{ scenario_count }}
#pragma once
#include <Arduino.h>
#include "../core/scenario_def.h"
const ScenarioDef* generatedScenarioById(const char* id);
const ScenarioDef* generatedScenarioDefault();
uint8_t generatedScenarioCount();
const char* generatedScenarioIdAt(uint8_t index);
const char* generatedScenarioSpecHash();
@@ -0,0 +1,95 @@
#pragma once
/**
* @file story_hal.h
* @brief Hardware Abstraction Layer for the story engine.
*
* These interfaces decouple the portable story logic from
* platform-specific APIs (Arduino, ESP-IDF, POSIX, etc.).
* Each platform provides its own implementation.
*/
#include <stddef.h>
#include <stdint.h>
namespace zacus_hal {
/// Filesystem abstraction — replaces LittleFS / SPIFFS / VFS.
class IStoryStorage {
public:
virtual ~IStoryStorage() = default;
/// Check if a file exists at the given path.
virtual bool exists(const char* path) = 0;
/// Read entire file into buffer. Returns bytes read, 0 on failure.
virtual size_t readFile(const char* path, uint8_t* buf, size_t bufSize) = 0;
/// Write buffer to file. Returns true on success.
virtual bool writeFile(const char* path, const uint8_t* data, size_t len) = 0;
/// List files in directory. Calls callback for each entry.
/// callback(name, sizeBytes) — return false to stop iteration.
virtual void listDir(const char* dirPath,
bool (*callback)(const char* name, size_t sizeBytes, void* ctx),
void* ctx) = 0;
/// Filesystem info.
virtual bool fsInfo(uint32_t* totalBytes, uint32_t* usedBytes) = 0;
};
/// Logging abstraction — replaces Serial.printf / ESP_LOGx.
class IStoryLogger {
public:
virtual ~IStoryLogger() = default;
virtual void info(const char* tag, const char* fmt, ...) = 0;
virtual void warn(const char* tag, const char* fmt, ...) = 0;
virtual void error(const char* tag, const char* fmt, ...) = 0;
virtual void debug(const char* tag, const char* fmt, ...) = 0;
};
/// Timer abstraction — replaces millis() / esp_timer_get_time().
class IStoryTimer {
public:
virtual ~IStoryTimer() = default;
/// Current time in milliseconds since boot.
virtual uint32_t millis() = 0;
/// Delay (non-blocking if possible on the platform).
virtual void delayMs(uint32_t ms) = 0;
};
/// Audio playback abstraction — replaces audio_manager.
class IStoryAudio {
public:
virtual ~IStoryAudio() = default;
/// Play an audio file (path on filesystem or URL).
virtual bool play(const char* source, uint8_t volume = 80, bool loop = false) = 0;
/// Stop current playback.
virtual void stop() = 0;
/// Check if audio is currently playing.
virtual bool isPlaying() = 0;
/// Set master volume (0-100).
virtual void setVolume(uint8_t volume) = 0;
};
/// Global HAL instance — set once at startup.
struct StoryHAL {
IStoryStorage* storage = nullptr;
IStoryLogger* logger = nullptr;
IStoryTimer* timer = nullptr;
IStoryAudio* audio = nullptr;
};
/// Set the global HAL. Must be called before any story engine use.
void setHAL(const StoryHAL& hal);
/// Get the global HAL.
const StoryHAL& getHAL();
} // namespace zacus_hal
@@ -0,0 +1,145 @@
/**
* @file hal_arduino.cpp
* @brief Arduino/PlatformIO implementation of the Story HAL.
*
* Compile this only when building with Arduino framework.
* For ESP-IDF, use hal_espidf.cpp instead.
*/
#ifdef ARDUINO
#include "zacus_story_portable/story_hal.h"
#include <Arduino.h>
#include <LittleFS.h>
#include <stdarg.h>
#include <stdio.h>
namespace zacus_hal {
// --- Arduino Storage (LittleFS) ---
class ArduinoStorage final : public IStoryStorage {
public:
bool exists(const char* path) override {
return LittleFS.exists(path);
}
size_t readFile(const char* path, uint8_t* buf, size_t bufSize) override {
File f = LittleFS.open(path, "r");
if (!f) return 0;
size_t read = f.read(buf, bufSize);
f.close();
return read;
}
bool writeFile(const char* path, const uint8_t* data, size_t len) override {
File f = LittleFS.open(path, "w");
if (!f) return false;
size_t written = f.write(data, len);
f.close();
return written == len;
}
void listDir(const char* dirPath,
bool (*callback)(const char* name, size_t sizeBytes, void* ctx),
void* ctx) override {
File root = LittleFS.open(dirPath);
if (!root || !root.isDirectory()) return;
File file = root.openNextFile();
while (file) {
if (!callback(file.name(), file.size(), ctx)) break;
file = root.openNextFile();
}
}
bool fsInfo(uint32_t* totalBytes, uint32_t* usedBytes) override {
if (totalBytes) *totalBytes = LittleFS.totalBytes();
if (usedBytes) *usedBytes = LittleFS.usedBytes();
return true;
}
};
// --- Arduino Logger (Serial) ---
class ArduinoLogger final : public IStoryLogger {
public:
void info(const char* tag, const char* fmt, ...) override {
va_list args;
va_start(args, fmt);
Serial.printf("[I][%s] ", tag);
vprintf(fmt, args);
Serial.println();
va_end(args);
}
void warn(const char* tag, const char* fmt, ...) override {
va_list args;
va_start(args, fmt);
Serial.printf("[W][%s] ", tag);
vprintf(fmt, args);
Serial.println();
va_end(args);
}
void error(const char* tag, const char* fmt, ...) override {
va_list args;
va_start(args, fmt);
Serial.printf("[E][%s] ", tag);
vprintf(fmt, args);
Serial.println();
va_end(args);
}
void debug(const char* tag, const char* fmt, ...) override {
va_list args;
va_start(args, fmt);
Serial.printf("[D][%s] ", tag);
vprintf(fmt, args);
Serial.println();
va_end(args);
}
};
// --- Arduino Timer (millis) ---
class ArduinoTimer final : public IStoryTimer {
public:
uint32_t millis() override {
return ::millis();
}
void delayMs(uint32_t ms) override {
::delay(ms);
}
};
// --- Arduino Audio (stub — real impl in audio_manager) ---
class ArduinoAudioStub final : public IStoryAudio {
public:
bool play(const char*, uint8_t, bool) override { return false; }
void stop() override {}
bool isPlaying() override { return false; }
void setVolume(uint8_t) override {}
};
// --- Singleton instances ---
static ArduinoStorage s_storage;
static ArduinoLogger s_logger;
static ArduinoTimer s_timer;
static ArduinoAudioStub s_audio;
/// Call this in setup() to initialize the Arduino HAL.
void initArduinoHAL() {
StoryHAL hal;
hal.storage = &s_storage;
hal.logger = &s_logger;
hal.timer = &s_timer;
hal.audio = &s_audio;
setHAL(hal);
}
} // namespace zacus_hal
#endif // ARDUINO
@@ -0,0 +1,15 @@
#include "zacus_story_portable/story_hal.h"
namespace zacus_hal {
static StoryHAL g_hal = {};
void setHAL(const StoryHAL& hal) {
g_hal = hal;
}
const StoryHAL& getHAL() {
return g_hal;
}
} // namespace zacus_hal