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:
@@ -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
|
||||
Reference in New Issue
Block a user