diff --git a/firmware/main/apps/app_launcher/app_launcher.cpp b/firmware/main/apps/app_launcher/app_launcher.cpp index e1e3d209..02eebc1e 100644 --- a/firmware/main/apps/app_launcher/app_launcher.cpp +++ b/firmware/main/apps/app_launcher/app_launcher.cpp @@ -26,12 +26,11 @@ void AppLauncher::onLauncherOpen() LvglLockGuard lock; - if (!_startup_checked && !GetHAL().isAppConfiged()) { - mclog::tagInfo(getAppInfo().name, "app not configured, start startup worker"); - _startup_worker = std::make_unique(); - } else { - create_launcher_view(); - } + // For this fork, prefer entering SHIP.RECEIPTS directly instead of blocking + // on first-run setup flows like Wi-Fi scanning. + _startup_checked = true; + _startup_worker.reset(); + create_launcher_view(); } void AppLauncher::onLauncherRunning() @@ -70,11 +69,33 @@ void AppLauncher::onLauncherDestroy() void AppLauncher::create_launcher_view() { _view = std::make_unique(); - _view->init(getAppProps()); + auto app_props = getAppProps(); + _view->init(app_props); _view->onAppClicked = [&](int appID) { mclog::tagInfo(getAppInfo().name, "handle open app, app id: {}", appID); openApp(appID); }; + + maybe_open_initial_ship_receipts(); +} + +bool AppLauncher::maybe_open_initial_ship_receipts() +{ + if (_opened_initial_ship_receipts) { + return false; + } + + for (const auto& props : getAppProps()) { + if (props.info.name == "SHIP.RECEIPTS") { + if (openApp(props.appID)) { + _opened_initial_ship_receipts = true; + mclog::tagInfo(getAppInfo().name, "auto-open ship receipts app, app id: {}", props.appID); + return true; + } + } + } + + return false; } void AppLauncher::screensaver_update() diff --git a/firmware/main/apps/app_launcher/app_launcher.h b/firmware/main/apps/app_launcher/app_launcher.h index c62cca79..8ca5038e 100644 --- a/firmware/main/apps/app_launcher/app_launcher.h +++ b/firmware/main/apps/app_launcher/app_launcher.h @@ -25,7 +25,9 @@ class AppLauncher : public mooncake::templates::AppLauncherBase { std::unique_ptr _startup_worker; uint32_t _screensaver_timecount = 0; bool _startup_checked = false; + bool _opened_initial_ship_receipts = false; void create_launcher_view(); void screensaver_update(); + bool maybe_open_initial_ship_receipts(); }; diff --git a/firmware/main/apps/app_ship_receipts/README.md b/firmware/main/apps/app_ship_receipts/README.md new file mode 100644 index 00000000..59a8465d --- /dev/null +++ b/firmware/main/apps/app_ship_receipts/README.md @@ -0,0 +1,272 @@ +# Ship Receipts app shell + +This directory is the first explicit app boundary for integrating `ship-receipts` +with StackChan. + +## Intent + +`ship-receipts` should be an **app on top of StackChan core**, not logic wired +into generic firmware subsystems. + +That means: + +- keep **transport**, **motion**, **audio**, **diagnostics**, and **renderer + primitives** in StackChan core +- keep **mode/content logic**, **copy**, **scene sequencing**, and + `ship-receipts` semantics in this app layer + +## Why this matters + +This split gives us: + +- a clean place to build host-driven game modes +- better odds of upstreaming generic capabilities later +- less pressure to turn StackChan core into a `ship-receipts`-specific product + +## What belongs here later + +- host scene packet consumers for Ship Receipts modes +- local / global / party mode selection +- app-local diagnostics relevant to Ship Receipts mode +- branding and copy specific to this app +- app-level use of generic core capabilities such as: + - avatar skin + speech + - motion updates + - audio cue playback + - transport-fed scene packets + +## Current demo pacing + +The built-in `local` / `global` / `party` demo beats now apply a small amount of +app-local pacing policy before touching hardware: + +- `card` beats are quieter: + - head motion is re-centered and slowed down + - RGB output is dimmed +- `avatar` beats are still expressive, but motion is clamped to a smaller + range than the raw authored payload + +This keeps the app readable on a desk and avoids making the built-in demo loop +feel like a constant hardware burn-in test. + +## What should stay out + +- generic servo transport code +- generic audio plumbing +- generic small-screen layout helpers +- generic packet logging / debugging + +## Current shell behavior + +The current shell is still intentionally small, but it now demonstrates an +app-local "director" loop backed by a tiny scene payload parser. + +Each sample beat is authored as app-local JSON and then mapped onto generic +StackChan primitives. + +- opens as a normal StackChan app +- attaches the default avatar +- rotates through a few Ship Receipts sample beats +- uses: + - status text for the beat label + - avatar speech for the beat line + - built-in emotions for expression + - head motion for staging + - RGB color changes for mood + - built-in notification audio for cueing +- uses the standard home indicator and status bar + +This keeps the seam app-local while proving a stronger point than the original +stub: Ship Receipts can direct StackChan through existing core APIs without +turning the core firmware into a Ship Receipts-specific fork. + +## Why the tiny parser matters + +The new parser is intentionally app-local: + +- it lets us evolve Ship Receipts scene semantics without changing StackChan + core first +- it gives us a clean place to experiment with app-specific scene packets +- it creates an extraction seam later if some subset becomes generic enough to + upstream + +## Current ingest paths + +The app now accepts app-local scene payloads through existing transport +surfaces, without inventing a Ship Receipts-specific core protocol first: + +- BLE config writes +- websocket text messages from the `ship-receipts` sender name + +BLE path details: + +- service UUID: `e2e5e5e0-1234-5678-1234-56789abcdef0` +- config characteristic UUID: `e2e5e5e3-1234-5678-1234-56789abcdef0` + +Accepted shapes: + +- a raw scene object +- or a command envelope: + +```json +{ + "cmd": "shipReceiptsScene", + "data": { + "title": "LOCAL MODE", + "line": "No accounts. No auth. Just build and score receipts.", + "emotion": "happy", + "duration_ms": 3600, + "motion": {"yaw_angle": 140, "pitch_angle": 160, "speed": 320}, + "led": {"r": 0, "g": 64, "b": 28}, + "play_notification": true + } +} +``` + +This keeps the transport generic while letting the Ship Receipts app own its +scene semantics. + +Supported control envelope: + +```json +{ + "cmd": "shipReceiptsResumeDemo" +} +``` + +That command explicitly releases any current live override or sticky scene and +returns the app to built-in demo rotation. + +Additional status command: + +```json +{ + "cmd": "shipReceiptsShowStatus" +} +``` + +That command causes the app to surface a short status summary using current +app-local state, including: + +- sequence state +- current mode +- current scene id + +Mode selection command: + +```json +{ + "cmd": "shipReceiptsSetMode", + "mode": "local" +} +``` + +That command explicitly jumps the built-in demo rotation to the first beat for +the requested Ship Receipts game mode. + +## Current sequence states + +The app currently distinguishes between these app-local sequencing states: + +- `DemoRotation` + - rotates through built-in sample beats +- `LiveOverride` + - temporarily applies an externally injected scene from BLE or websocket text + - when that beat's `duration_ms` expires, the app returns to demo rotation +- `LiveSticky` + - applies an externally injected scene that stays active after its first + duration window + - remains active until another scene replaces it or the app is closed + +This keeps "what the app is doing right now" as app state instead of pushing +Ship Receipts sequencing policy down into StackChan core. + +## Current scene fields + +The app-local scene payload currently understands: + +- `id` (optional scene identity for host/app orchestration) +- `mode` (optional app mode such as `local`, `global`, or `party`) +- `title` +- `line` +- `speaker` (optional) +- `presentation_type` (`card` or `avatar`) +- `visual_template` (currently metadata, preserved for future renderer choices) +- `emotion` +- `duration_ms` +- `motion` +- `led` +- `play_notification` +- `sticky` + +Current rendering behavior: + +- `presentation_type = "avatar"` + - keeps the authored emotion + - prefixes speech with `speaker says:` when a speaker is present +- `presentation_type = "card"` + - forces a neutral face + - treats speech as supporting card copy instead of character dialogue + +Current built-in mode coverage: + +- `local` + - offline single-player loop + - score, streak, and party growth stay on-device +- `global` + - opt-in proof envelope export + - public verification and canonical reputation +- `party` + - friendly comparison against party members and benchmarks + - local-first competition until someone exports a proof + +The built-in demo rotation now carries four beats per mode so the app can show +more of each mode's actual semantics before it loops. + +Current metadata behavior: + +- `id` + - preserved so host and app can refer to a stable beat identity + - logged by the app when a scene is loaded/applied +- `mode` + - preserved so higher-level Ship Receipts mode selection can stay app-local + - logged by the app when a scene is loaded/applied +- `sticky` + - `false` means a live injected scene expires back to demo rotation + - `true` means a live injected scene remains active until replaced + +## Example payloads and host helper + +Checked-in examples live here: + +- [examples/local_scene.json](./examples/local_scene.json) +- [examples/global_scene.json](./examples/global_scene.json) +- [examples/party_scene.json](./examples/party_scene.json) +- [examples/ship_receipts_scene_command.json](./examples/ship_receipts_scene_command.json) +- [examples/ship_receipts_resume_demo_command.json](./examples/ship_receipts_resume_demo_command.json) +- [examples/ship_receipts_show_status_command.json](./examples/ship_receipts_show_status_command.json) +- [examples/ship_receipts_set_mode_command.json](./examples/ship_receipts_set_mode_command.json) + +A tiny helper can emit the supported payload shapes without adding any new +dependencies: + +- [tools/emit_ship_receipts_scene.py](./tools/emit_ship_receipts_scene.py) + +Examples: + +```bash +python3 firmware/main/apps/app_ship_receipts/tools/emit_ship_receipts_scene.py --scene local --format scene +python3 firmware/main/apps/app_ship_receipts/tools/emit_ship_receipts_scene.py --scene local --format command +python3 firmware/main/apps/app_ship_receipts/tools/emit_ship_receipts_scene.py --scene local --format ble-config +python3 firmware/main/apps/app_ship_receipts/tools/emit_ship_receipts_scene.py --scene local --format ws-text +python3 firmware/main/apps/app_ship_receipts/tools/emit_ship_receipts_scene.py --format transport-note +python3 firmware/main/apps/app_ship_receipts/tools/emit_ship_receipts_scene.py --format resume-demo +python3 firmware/main/apps/app_ship_receipts/tools/emit_ship_receipts_scene.py --format show-status +python3 firmware/main/apps/app_ship_receipts/tools/emit_ship_receipts_scene.py --scene local --format set-mode +``` + +The `ws-text` format emits the `name` + `content` shape expected by the app's +websocket text-message ingest path. + +The `ble-config` format is the exact JSON shape accepted by the existing BLE +config characteristic. diff --git a/firmware/main/apps/app_ship_receipts/app_ship_receipts.cpp b/firmware/main/apps/app_ship_receipts/app_ship_receipts.cpp new file mode 100644 index 00000000..2dc90e63 --- /dev/null +++ b/firmware/main/apps/app_ship_receipts/app_ship_receipts.cpp @@ -0,0 +1,418 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "app_ship_receipts.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace mooncake; +using namespace stackchan; + +namespace { + +int clampValue(int value, int min_value, int max_value) +{ + return std::max(min_value, std::min(value, max_value)); +} + +uint8_t scaleColor(uint8_t value, float scale) +{ + return static_cast(std::round(static_cast(value) * scale)); +} + +std::string buildSpeech(const ship_receipts::ScenePayload& beat) +{ + if (beat.speaker.empty()) { + return beat.line; + } + + if (beat.presentation_type == "card") { + return fmt::format("{} · {}", beat.speaker, beat.line); + } + + return fmt::format("{} says: {}", beat.speaker, beat.line); +} + +std::string buildStatusLabel(const ship_receipts::ScenePayload& beat) +{ + if (beat.mode.empty()) { + return "SHIP.RECEIPTS"; + } + return fmt::format("SHIP.RECEIPTS · {}", beat.mode); +} + +std::string buildAppReadyMessage(std::string_view mode) +{ + if (mode.empty()) { + return "Ship Receipts app ready"; + } + return fmt::format("Ship Receipts · {} mode", mode); +} + +const char* resolveEmotion(const ship_receipts::ScenePayload& beat) +{ + if (beat.presentation_type == "card") { + return "neutral"; + } + return beat.emotion.c_str(); +} + +int resolveYawAngle(const ship_receipts::ScenePayload& beat) +{ + if (beat.presentation_type == "card") { + return clampValue(beat.yaw_angle / 3, -60, 60); + } + return clampValue(beat.yaw_angle, -180, 180); +} + +int resolvePitchAngle(const ship_receipts::ScenePayload& beat) +{ + if (beat.presentation_type == "card") { + return clampValue(std::max(60, beat.pitch_angle / 2), 60, 140); + } + return clampValue(std::max(90, beat.pitch_angle), 90, 190); +} + +int resolveMotionSpeed(const ship_receipts::ScenePayload& beat) +{ + if (beat.presentation_type == "card") { + return clampValue(beat.speed / 2, 120, 220); + } + return clampValue(beat.speed, 180, 320); +} + +std::tuple resolveLedColor(const ship_receipts::ScenePayload& beat) +{ + const auto scale = beat.presentation_type == "card" ? 0.28f : 0.45f; + return { + scaleColor(beat.led_r, scale), + scaleColor(beat.led_g, scale), + scaleColor(beat.led_b, scale), + }; +} +} // namespace + +AppShipReceipts::AppShipReceipts() +{ + setAppInfo().name = "SHIP.RECEIPTS"; + + static auto icon = assets::get_image("icon_sentinel.bin"); + setAppInfo().icon = (void*)&icon; + + static uint32_t theme_color = 0x4A79E8; + setAppInfo().userData = (void*)&theme_color; +} + +void AppShipReceipts::onCreate() +{ + mclog::tagInfo(getAppInfo().name, "on create"); +} + +void AppShipReceipts::onOpen() +{ + mclog::tagInfo(getAppInfo().name, "on open"); + + std::unique_ptr loading_page; + { + LvglLockGuard lock; + loading_page = std::make_unique(0x93C5FD, 0x0F172A); + loading_page->setMessage("Starting\n scene services..."); + } + + GetHAL().startBleServer(); + GetHAL().startWebSocketAvatarService([&](std::string_view msg) { + LvglLockGuard lock; + loading_page->setMessage(msg); + }); + + { + LvglLockGuard lock; + loading_page.reset(); + + if (auto* display = Board::GetInstance().GetDisplay()) { + display->SetupUI(); + } + + view::create_home_indicator([&]() { close(); }, 0x93C5FD, 0x0F172A); + view::create_status_bar(0x93C5FD, 0x0F172A); + } + + GetHAL().onBleConfigData.connect([&](const char* data) { enqueueSceneJson(data ? data : ""); }); + GetHAL().onWsTextMessage.connect([&](const WsTextMessage_t& message) { + if (message.name == "ship-receipts") { + enqueueSceneJson(message.content); + } + }); + + enterDemoRotation(); + view::pop_a_toast(buildAppReadyMessage(_selected_demo_mode), view::ToastType::Info, 1800); +} + +void AppShipReceipts::onRunning() +{ + LvglLockGuard lock; + + std::string queued_scene; + if (dequeueSceneJson(queued_scene)) { + handleQueuedCommand(queued_scene); + } + + GetStackChan().update(); + view::update_home_indicator(); + view::update_status_bar(); + + if (_beat_started_at > 0) { + if (GetHAL().millis() - _beat_started_at >= _active_scene.duration_ms) { + if (_sequence_state == SequenceState::LiveSticky) { + _beat_started_at = 0; + } else if (_sequence_state == SequenceState::LiveOverride) { + resumeDemoRotation(); + } else { + advanceBeat(); + } + } + } +} + +void AppShipReceipts::onClose() +{ + mclog::tagInfo(getAppInfo().name, "on close"); + + LvglLockGuard lock; + + clearBeat(); + GetStackChan().resetAvatar(); + GetHAL().onBleConfigData.clear(); + GetHAL().onWsTextMessage.clear(); + view::destroy_home_indicator(); + view::destroy_status_bar(); + + GetHAL().requestWarmReboot(1); +} + +bool AppShipReceipts::loadScene(size_t index, ship_receipts::ScenePayload& out_scene) +{ + std::string error_message; + if (!ship_receipts::parse_scene_payload(_scene_json[index], out_scene, &error_message)) { + mclog::tagError(getAppInfo().name, "failed to parse scene %u: %s", static_cast(index), + error_message.c_str()); + return false; + } + mclog::tagInfo(getAppInfo().name, "loaded scene id='{}' mode='{}' presentation='{}'", out_scene.scene_id, + out_scene.mode, out_scene.presentation_type); + return true; +} + +void AppShipReceipts::applyBeat(const ship_receipts::ScenePayload& beat) +{ + auto* display = Board::GetInstance().GetDisplay(); + auto& stack = GetStackChan(); + auto& motion = stack.motion(); + auto speech = buildSpeech(beat); + + if (display) { + auto status = buildStatusLabel(beat); + display->SetStatus(status.c_str()); + display->SetEmotion(resolveEmotion(beat)); + display->SetChatMessage("assistant", speech.c_str()); + display->ShowNotification(status.c_str(), 1400); + } + + mclog::tagInfo(getAppInfo().name, "apply beat id='{}' mode='{}' template='{}' sticky={} status='{}'", beat.scene_id, + beat.mode, beat.visual_template, beat.sticky, buildStatusLabel(beat)); + + if (beat.play_notification) { + mclog::tagInfo(getAppInfo().name, "skip notification audio for ship receipts beat '{}'", beat.scene_id); + } + + const auto [led_r, led_g, led_b] = resolveLedColor(beat); + const auto yaw_angle = resolveYawAngle(beat); + const auto pitch_angle = resolvePitchAngle(beat); + const auto motion_speed = resolveMotionSpeed(beat); + + mclog::tagInfo(getAppInfo().name, + "resolved beat id='{}' motion=({}, {}, {}) led=({}, {}, {})", + beat.scene_id, yaw_angle, pitch_angle, motion_speed, + static_cast(led_r), static_cast(led_g), static_cast(led_b)); + + GetHAL().showRgbColor(led_r, led_g, led_b); + motion.moveWithSpeed(yaw_angle, pitch_angle, motion_speed); + _beat_started_at = GetHAL().millis(); +} + +void AppShipReceipts::enterDemoRotation() +{ + _sequence_state = SequenceState::DemoRotation; + if (!setDemoMode(_selected_demo_mode)) { + _beat_index = 0; + if (loadScene(_beat_index, _active_scene)) { + applyBeat(_active_scene); + } + mclog::tagInfo(getAppInfo().name, "sequence state -> demo rotation (fallback)"); + return; + } + mclog::tagInfo(getAppInfo().name, "sequence state -> demo rotation"); +} + +void AppShipReceipts::resumeDemoRotation() +{ + _sequence_state = SequenceState::DemoRotation; + const auto start_index = _beat_index; + for (size_t offset = 1; offset <= _scene_json.size(); ++offset) { + const auto next_index = (start_index + offset) % _scene_json.size(); + ship_receipts::ScenePayload candidate; + if (!loadScene(next_index, candidate)) { + continue; + } + if (!_selected_demo_mode.empty() && candidate.mode != _selected_demo_mode) { + continue; + } + _beat_index = next_index; + _active_scene = std::move(candidate); + applyBeat(_active_scene); + mclog::tagInfo(getAppInfo().name, "sequence state -> demo rotation (resumed)"); + return; + } + if (loadScene(start_index, _active_scene)) { + _beat_index = start_index; + applyBeat(_active_scene); + } + mclog::tagInfo(getAppInfo().name, "sequence state -> demo rotation (single-mode fallback)"); +} + +bool AppShipReceipts::setDemoMode(std::string_view mode) +{ + for (size_t i = 0; i < _scene_json.size(); ++i) { + ship_receipts::ScenePayload candidate; + if (!loadScene(i, candidate)) { + continue; + } + if (candidate.mode == mode) { + _sequence_state = SequenceState::DemoRotation; + _selected_demo_mode = std::string(mode); + _beat_index = i; + _active_scene = std::move(candidate); + applyBeat(_active_scene); + mclog::tagInfo(getAppInfo().name, "sequence state -> demo rotation (mode={})", mode); + return true; + } + } + return false; +} + +const char* AppShipReceipts::sequenceStateLabel() const +{ + switch (_sequence_state) { + case SequenceState::DemoRotation: + return "demo"; + case SequenceState::LiveOverride: + return "live"; + case SequenceState::LiveSticky: + return "sticky"; + } + return "unknown"; +} + +std::string AppShipReceipts::statusSummary() const +{ + return fmt::format("state={} mode={} selected={} scene={}", sequenceStateLabel(), + _active_scene.mode.empty() ? "-" : _active_scene.mode, + _selected_demo_mode.empty() ? "-" : _selected_demo_mode, + _active_scene.scene_id.empty() ? "-" : _active_scene.scene_id); +} + +void AppShipReceipts::enqueueSceneJson(std::string json) +{ + std::lock_guard lock(_queue_mutex); + _pending_scene_json.push_back(std::move(json)); +} + +bool AppShipReceipts::dequeueSceneJson(std::string& out_json) +{ + std::lock_guard lock(_queue_mutex); + if (_pending_scene_json.empty()) { + return false; + } + out_json = std::move(_pending_scene_json.front()); + _pending_scene_json.pop_front(); + return true; +} + +void AppShipReceipts::handleQueuedCommand(const std::string& json) +{ + ship_receipts::ControlAction action; + std::string mode; + std::string control_error; + if (ship_receipts::parse_control_command(json.c_str(), action, &mode, &control_error)) { + if (action == ship_receipts::ControlAction::ResumeDemo) { + resumeDemoRotation(); + view::pop_a_toast("Ship Receipts demo rotation resumed", view::ToastType::Info, 1200); + return; + } + if (action == ship_receipts::ControlAction::ShowStatus) { + auto summary = statusSummary(); + mclog::tagInfo(getAppInfo().name, "status -> {}", summary); + view::pop_a_toast(summary, view::ToastType::Info, 1800); + return; + } + if (action == ship_receipts::ControlAction::SetMode) { + if (setDemoMode(mode)) { + view::pop_a_toast(fmt::format("Ship Receipts mode -> {}", mode), view::ToastType::Info, 1500); + } else { + view::pop_a_toast(fmt::format("Unknown Ship Receipts mode: {}", mode), view::ToastType::Warning, + 1800); + } + return; + } + } + + ship_receipts::ScenePayload parsed; + std::string error_message; + if (!ship_receipts::parse_scene_command(json.c_str(), parsed, &error_message)) { + mclog::tagError(getAppInfo().name, "failed to parse incoming scene: %s", error_message.c_str()); + view::pop_a_toast("Ship Receipts scene parse failed", view::ToastType::Error, 2200); + return; + } + + _sequence_state = parsed.sticky ? SequenceState::LiveSticky : SequenceState::LiveOverride; + _active_scene = std::move(parsed); + applyBeat(_active_scene); + mclog::tagInfo(getAppInfo().name, "sequence state -> {}", _sequence_state == SequenceState::LiveSticky + ? "live sticky" + : "live override"); + view::pop_a_toast(_sequence_state == SequenceState::LiveSticky ? "Sticky Ship Receipts scene received" + : "Ship Receipts scene received", + view::ToastType::Info, 1200); +} + +void AppShipReceipts::clearBeat() +{ + if (auto* display = Board::GetInstance().GetDisplay()) { + display->ClearChatMessages(); + display->SetStatus(""); + } + + GetHAL().showRgbColor(0, 0, 0); + GetStackChan().motion().goHome(320); + _beat_started_at = 0; + std::lock_guard lock(_queue_mutex); + _pending_scene_json.clear(); +} + +void AppShipReceipts::advanceBeat() +{ + resumeDemoRotation(); +} diff --git a/firmware/main/apps/app_ship_receipts/app_ship_receipts.h b/firmware/main/apps/app_ship_receipts/app_ship_receipts.h new file mode 100644 index 00000000..66672b5a --- /dev/null +++ b/firmware/main/apps/app_ship_receipts/app_ship_receipts.h @@ -0,0 +1,73 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once +#include "ship_receipts_scene.h" +#include +#include +#include +#include +#include +#include + +/** + * @brief Ship Receipts app shell. + * + * This is intentionally a thin app boundary on top of StackChan core: + * generic device capabilities stay in core firmware, while Ship Receipts + * mode/content can evolve as an app. + */ +class AppShipReceipts : public mooncake::AppAbility { +public: + AppShipReceipts(); + + void onCreate() override; + void onOpen() override; + void onRunning() override; + void onClose() override; + +private: + enum class SequenceState { + DemoRotation = 0, + LiveOverride, + LiveSticky, + }; + + static constexpr std::array _scene_json{{ + R"json({"id":"local-card-1","mode":"local","title":"LOCAL MODE","line":"Everything works offline. No accounts, auth, or server calls.","presentation_type":"card","visual_template":"title_card","emotion":"neutral","duration_ms":3200,"motion":{"yaw_angle":0,"pitch_angle":0,"speed":420},"led":{"r":0,"g":48,"b":22},"play_notification":false})json", + R"json({"id":"local-avatar-1","mode":"local","title":"LOCAL MODE","speaker":"BUILDER","line":"Ship work, create a receipt, and get immediate score feedback.","presentation_type":"avatar","visual_template":"avatar_beat","emotion":"happy","duration_ms":3600,"motion":{"yaw_angle":140,"pitch_angle":160,"speed":320},"led":{"r":0,"g":64,"b":28},"play_notification":true})json", + R"json({"id":"local-card-2","mode":"local","title":"LOCAL MODE","line":"No score without proof. Empty receipts stay at zero.","presentation_type":"card","visual_template":"status_card","emotion":"neutral","duration_ms":3200,"motion":{"yaw_angle":40,"pitch_angle":120,"speed":300},"led":{"r":20,"g":88,"b":32},"play_notification":false})json", + R"json({"id":"local-avatar-2","mode":"local","title":"LOCAL MODE","speaker":"STREAK","line":"Only 6-plus point receipts extend the streak and grow the party.","presentation_type":"avatar","visual_template":"avatar_beat","emotion":"sleepy","duration_ms":3600,"motion":{"yaw_angle":-100,"pitch_angle":150,"speed":280},"led":{"r":12,"g":72,"b":24},"play_notification":false})json", + R"json({"id":"global-card-1","mode":"global","title":"GLOBAL MODE","line":"Export a proof envelope only when you want public verification.","presentation_type":"card","visual_template":"status_card","emotion":"neutral","duration_ms":3200,"motion":{"yaw_angle":-80,"pitch_angle":90,"speed":300},"led":{"r":0,"g":28,"b":72},"play_notification":false})json", + R"json({"id":"global-avatar-1","mode":"global","title":"GLOBAL MODE","speaker":"LEDGER","line":"Verified receipts earn canonical reputation, not just local score.","presentation_type":"avatar","visual_template":"avatar_beat","emotion":"doubtful","duration_ms":3800,"motion":{"yaw_angle":-220,"pitch_angle":110,"speed":320},"led":{"r":0,"g":40,"b":96},"play_notification":true})json", + R"json({"id":"global-card-2","mode":"global","title":"GLOBAL MODE","line":"The server re-checks the proof. Local hints never outrank verification.","presentation_type":"card","visual_template":"title_card","emotion":"neutral","duration_ms":3400,"motion":{"yaw_angle":-140,"pitch_angle":130,"speed":300},"led":{"r":0,"g":56,"b":112},"play_notification":false})json", + R"json({"id":"global-avatar-2","mode":"global","title":"GLOBAL MODE","speaker":"VERIFY","line":"Global badges and gold shields only appear after the pipeline passes.","presentation_type":"avatar","visual_template":"avatar_beat","emotion":"happy","duration_ms":3600,"motion":{"yaw_angle":-120,"pitch_angle":150,"speed":300},"led":{"r":0,"g":52,"b":132},"play_notification":false})json", + R"json({"id":"party-card-1","mode":"party","title":"PARTY MODE","line":"Track friends, rivals, and benchmarks beside your streaks.","presentation_type":"card","visual_template":"title_card","emotion":"neutral","duration_ms":3200,"motion":{"yaw_angle":80,"pitch_angle":40,"speed":300},"led":{"r":72,"g":24,"b":72},"play_notification":false})json", + R"json({"id":"party-avatar-1","mode":"party","title":"PARTY MODE","speaker":"SCOUT","line":"Add GitHub users to the party and compare proof-first progress.","presentation_type":"avatar","visual_template":"avatar_beat","emotion":"happy","duration_ms":3800,"motion":{"yaw_angle":220,"pitch_angle":120,"speed":320},"led":{"r":96,"g":28,"b":96},"play_notification":true})json", + R"json({"id":"party-card-2","mode":"party","title":"PARTY MODE","line":"Friendly ranks break ties by streak first, then by join date.","presentation_type":"card","visual_template":"status_card","emotion":"neutral","duration_ms":3400,"motion":{"yaw_angle":120,"pitch_angle":90,"speed":300},"led":{"r":124,"g":52,"b":124},"play_notification":false})json", + R"json({"id":"party-avatar-2","mode":"party","title":"PARTY MODE","speaker":"PARTY","line":"Benchmarks stay local until someone chooses to export a proof.","presentation_type":"avatar","visual_template":"avatar_beat","emotion":"sleepy","duration_ms":3600,"motion":{"yaw_angle":120,"pitch_angle":140,"speed":280},"led":{"r":90,"g":36,"b":90},"play_notification":false})json", + }}; + + bool loadScene(size_t index, ship_receipts::ScenePayload& out_scene); + void enqueueSceneJson(std::string json); + bool dequeueSceneJson(std::string& out_json); + void handleQueuedCommand(const std::string& json); + void applyBeat(const ship_receipts::ScenePayload& beat); + void enterDemoRotation(); + void resumeDemoRotation(); + bool setDemoMode(std::string_view mode); + const char* sequenceStateLabel() const; + std::string statusSummary() const; + void clearBeat(); + void advanceBeat(); + + std::mutex _queue_mutex; + std::deque _pending_scene_json; + uint32_t _beat_started_at = 0; + size_t _beat_index = 0; + SequenceState _sequence_state = SequenceState::DemoRotation; + std::string _selected_demo_mode = "local"; + ship_receipts::ScenePayload _active_scene{}; +}; diff --git a/firmware/main/apps/app_ship_receipts/examples/global_scene.json b/firmware/main/apps/app_ship_receipts/examples/global_scene.json new file mode 100644 index 00000000..b839ec0c --- /dev/null +++ b/firmware/main/apps/app_ship_receipts/examples/global_scene.json @@ -0,0 +1,22 @@ +{ + "id": "global-card-1", + "mode": "global", + "title": "GLOBAL MODE", + "line": "Proof envelopes can be exported for public verification.", + "presentation_type": "card", + "visual_template": "status_card", + "emotion": "neutral", + "duration_ms": 3200, + "motion": { + "yaw_angle": -80, + "pitch_angle": 90, + "speed": 300 + }, + "led": { + "r": 0, + "g": 28, + "b": 72 + }, + "play_notification": false, + "sticky": false +} diff --git a/firmware/main/apps/app_ship_receipts/examples/local_scene.json b/firmware/main/apps/app_ship_receipts/examples/local_scene.json new file mode 100644 index 00000000..41cb1062 --- /dev/null +++ b/firmware/main/apps/app_ship_receipts/examples/local_scene.json @@ -0,0 +1,23 @@ +{ + "id": "local-avatar-1", + "mode": "local", + "title": "LOCAL MODE", + "speaker": "BUILDER", + "line": "No accounts. No auth. Just build and score receipts.", + "presentation_type": "avatar", + "visual_template": "avatar_beat", + "emotion": "happy", + "duration_ms": 3600, + "motion": { + "yaw_angle": 140, + "pitch_angle": 160, + "speed": 320 + }, + "led": { + "r": 0, + "g": 64, + "b": 28 + }, + "play_notification": true, + "sticky": false +} diff --git a/firmware/main/apps/app_ship_receipts/examples/party_scene.json b/firmware/main/apps/app_ship_receipts/examples/party_scene.json new file mode 100644 index 00000000..f95aa46d --- /dev/null +++ b/firmware/main/apps/app_ship_receipts/examples/party_scene.json @@ -0,0 +1,23 @@ +{ + "id": "party-avatar-1", + "mode": "party", + "title": "PARTY MODE", + "speaker": "SCOUT", + "line": "Add GitHub users to the party and chase better proofs.", + "presentation_type": "avatar", + "visual_template": "avatar_beat", + "emotion": "happy", + "duration_ms": 3800, + "motion": { + "yaw_angle": 220, + "pitch_angle": 120, + "speed": 320 + }, + "led": { + "r": 96, + "g": 28, + "b": 96 + }, + "play_notification": true, + "sticky": false +} diff --git a/firmware/main/apps/app_ship_receipts/examples/ship_receipts_resume_demo_command.json b/firmware/main/apps/app_ship_receipts/examples/ship_receipts_resume_demo_command.json new file mode 100644 index 00000000..76ee9569 --- /dev/null +++ b/firmware/main/apps/app_ship_receipts/examples/ship_receipts_resume_demo_command.json @@ -0,0 +1,3 @@ +{ + "cmd": "shipReceiptsResumeDemo" +} diff --git a/firmware/main/apps/app_ship_receipts/examples/ship_receipts_scene_command.json b/firmware/main/apps/app_ship_receipts/examples/ship_receipts_scene_command.json new file mode 100644 index 00000000..e85fcad1 --- /dev/null +++ b/firmware/main/apps/app_ship_receipts/examples/ship_receipts_scene_command.json @@ -0,0 +1,26 @@ +{ + "cmd": "shipReceiptsScene", + "data": { + "id": "local-avatar-1", + "mode": "local", + "title": "LOCAL MODE", + "speaker": "BUILDER", + "line": "No accounts. No auth. Just build and score receipts.", + "presentation_type": "avatar", + "visual_template": "avatar_beat", + "emotion": "happy", + "duration_ms": 3600, + "motion": { + "yaw_angle": 140, + "pitch_angle": 160, + "speed": 320 + }, + "led": { + "r": 0, + "g": 64, + "b": 28 + }, + "play_notification": true, + "sticky": false + } +} diff --git a/firmware/main/apps/app_ship_receipts/examples/ship_receipts_set_mode_command.json b/firmware/main/apps/app_ship_receipts/examples/ship_receipts_set_mode_command.json new file mode 100644 index 00000000..1bc0ce53 --- /dev/null +++ b/firmware/main/apps/app_ship_receipts/examples/ship_receipts_set_mode_command.json @@ -0,0 +1,4 @@ +{ + "cmd": "shipReceiptsSetMode", + "mode": "local" +} diff --git a/firmware/main/apps/app_ship_receipts/examples/ship_receipts_show_status_command.json b/firmware/main/apps/app_ship_receipts/examples/ship_receipts_show_status_command.json new file mode 100644 index 00000000..6226e481 --- /dev/null +++ b/firmware/main/apps/app_ship_receipts/examples/ship_receipts_show_status_command.json @@ -0,0 +1,3 @@ +{ + "cmd": "shipReceiptsShowStatus" +} diff --git a/firmware/main/apps/app_ship_receipts/ship_receipts_scene.cpp b/firmware/main/apps/app_ship_receipts/ship_receipts_scene.cpp new file mode 100644 index 00000000..b383316a --- /dev/null +++ b/firmware/main/apps/app_ship_receipts/ship_receipts_scene.cpp @@ -0,0 +1,188 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#include "ship_receipts_scene.h" +#include + +namespace ship_receipts { + +namespace { + +void set_error(std::string* error_message, const char* message) +{ + if (error_message) { + *error_message = message; + } +} + +uint8_t clamp_color(int value) +{ + if (value < 0) { + return 0; + } + if (value > 255) { + return 255; + } + return static_cast(value); +} + +} // namespace + +bool parse_scene_payload(const char* json, ScenePayload& out_payload, std::string* error_message) +{ + if (!json) { + set_error(error_message, "scene payload was null"); + return false; + } + + ArduinoJson::JsonDocument doc; + auto error = ArduinoJson::deserializeJson(doc, json); + if (error) { + set_error(error_message, error.c_str()); + return false; + } + + if (doc["id"].is()) { + out_payload.scene_id = doc["id"].as(); + } + if (doc["mode"].is()) { + out_payload.mode = doc["mode"].as(); + } + if (doc["title"].is()) { + out_payload.title = doc["title"].as(); + } + if (doc["line"].is()) { + out_payload.line = doc["line"].as(); + } + if (doc["speaker"].is()) { + out_payload.speaker = doc["speaker"].as(); + } + if (doc["emotion"].is()) { + out_payload.emotion = doc["emotion"].as(); + } + if (doc["presentation_type"].is()) { + out_payload.presentation_type = doc["presentation_type"].as(); + } + if (doc["visual_template"].is()) { + out_payload.visual_template = doc["visual_template"].as(); + } + if (doc["duration_ms"].is()) { + out_payload.duration_ms = doc["duration_ms"].as(); + } + if (doc["play_notification"].is()) { + out_payload.play_notification = doc["play_notification"].as(); + } + if (doc["sticky"].is()) { + out_payload.sticky = doc["sticky"].as(); + } + + if (doc["motion"].is()) { + auto motion = doc["motion"].as(); + if (motion["yaw_angle"].is()) { + out_payload.yaw_angle = motion["yaw_angle"].as(); + } + if (motion["pitch_angle"].is()) { + out_payload.pitch_angle = motion["pitch_angle"].as(); + } + if (motion["speed"].is()) { + out_payload.speed = motion["speed"].as(); + } + } + + if (doc["led"].is()) { + auto led = doc["led"].as(); + if (led["r"].is()) { + out_payload.led_r = clamp_color(led["r"].as()); + } + if (led["g"].is()) { + out_payload.led_g = clamp_color(led["g"].as()); + } + if (led["b"].is()) { + out_payload.led_b = clamp_color(led["b"].as()); + } + } + + return true; +} + +bool parse_scene_command(const char* json, ScenePayload& out_payload, std::string* error_message) +{ + if (!json) { + set_error(error_message, "scene command was null"); + return false; + } + + ArduinoJson::JsonDocument doc; + auto error = ArduinoJson::deserializeJson(doc, json); + if (error) { + set_error(error_message, error.c_str()); + return false; + } + + if (doc["cmd"].is() && std::string_view(doc["cmd"].as()) == "shipReceiptsScene") { + if (!doc["data"].is()) { + set_error(error_message, "shipReceiptsScene command missing object data"); + return false; + } + + std::string nested; + ArduinoJson::serializeJson(doc["data"], nested); + return parse_scene_payload(nested.c_str(), out_payload, error_message); + } + + return parse_scene_payload(json, out_payload, error_message); +} + +bool parse_control_command(const char* json, ControlAction& out_action, std::string* out_mode, + std::string* error_message) +{ + out_action = ControlAction::None; + if (out_mode) { + out_mode->clear(); + } + + if (!json) { + set_error(error_message, "control command was null"); + return false; + } + + ArduinoJson::JsonDocument doc; + auto error = ArduinoJson::deserializeJson(doc, json); + if (error) { + set_error(error_message, error.c_str()); + return false; + } + + if (!doc["cmd"].is()) { + set_error(error_message, "control command missing cmd"); + return false; + } + + auto cmd = std::string_view(doc["cmd"].as()); + if (cmd == "shipReceiptsResumeDemo") { + out_action = ControlAction::ResumeDemo; + return true; + } + if (cmd == "shipReceiptsShowStatus") { + out_action = ControlAction::ShowStatus; + return true; + } + if (cmd == "shipReceiptsSetMode") { + if (!doc["mode"].is()) { + set_error(error_message, "shipReceiptsSetMode missing mode"); + return false; + } + out_action = ControlAction::SetMode; + if (out_mode) { + *out_mode = doc["mode"].as(); + } + return true; + } + + set_error(error_message, "unknown ship receipts control command"); + return false; +} + +} // namespace ship_receipts diff --git a/firmware/main/apps/app_ship_receipts/ship_receipts_scene.h b/firmware/main/apps/app_ship_receipts/ship_receipts_scene.h new file mode 100644 index 00000000..ee95dc20 --- /dev/null +++ b/firmware/main/apps/app_ship_receipts/ship_receipts_scene.h @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: 2026 M5Stack Technology CO LTD + * + * SPDX-License-Identifier: MIT + */ +#pragma once +#include +#include + +namespace ship_receipts { + +enum class ControlAction { + None = 0, + ResumeDemo, + ShowStatus, + SetMode, +}; + +struct ScenePayload { + std::string scene_id = ""; + std::string mode = ""; + std::string title = "SHIP.RECEIPTS"; + std::string line = ""; + std::string speaker = ""; + std::string emotion = "neutral"; + std::string presentation_type = "avatar"; + std::string visual_template = ""; + uint32_t duration_ms = 3600; + int yaw_angle = 0; + int pitch_angle = 0; + int speed = 320; + uint8_t led_r = 0; + uint8_t led_g = 0; + uint8_t led_b = 0; + bool play_notification = false; + bool sticky = false; +}; + +bool parse_scene_payload(const char* json, ScenePayload& out_payload, std::string* error_message = nullptr); +bool parse_scene_command(const char* json, ScenePayload& out_payload, std::string* error_message = nullptr); +bool parse_control_command(const char* json, ControlAction& out_action, std::string* out_mode = nullptr, + std::string* error_message = nullptr); + +} // namespace ship_receipts diff --git a/firmware/main/apps/app_ship_receipts/tools/emit_ship_receipts_scene.py b/firmware/main/apps/app_ship_receipts/tools/emit_ship_receipts_scene.py new file mode 100755 index 00000000..67432410 --- /dev/null +++ b/firmware/main/apps/app_ship_receipts/tools/emit_ship_receipts_scene.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +import argparse +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +EXAMPLES = ROOT / "examples" + +SCENES = { + "local": EXAMPLES / "local_scene.json", + "global": EXAMPLES / "global_scene.json", + "party": EXAMPLES / "party_scene.json", +} + + +def load_scene(name: str) -> dict: + return json.loads(SCENES[name].read_text()) + + +def build_output(scene: dict, fmt: str) -> dict: + if fmt == "scene": + return scene + if fmt in {"command", "ble-config"}: + return {"cmd": "shipReceiptsScene", "data": scene} + if fmt == "resume-demo": + return {"cmd": "shipReceiptsResumeDemo"} + if fmt == "show-status": + return {"cmd": "shipReceiptsShowStatus"} + if fmt == "set-mode": + return {"cmd": "shipReceiptsSetMode", "mode": scene.get("mode", "")} + if fmt == "ws-text": + return { + "name": "ship-receipts", + "content": json.dumps({"cmd": "shipReceiptsScene", "data": scene}, ensure_ascii=False), + } + if fmt == "transport-note": + return { + "ble": { + "service_uuid": "e2e5e5e0-1234-5678-1234-56789abcdef0", + "characteristic_uuid": "e2e5e5e3-1234-5678-1234-56789abcdef0", + "shape": "ble-config", + }, + "websocket": { + "surface": "text-message", + "sender_name": "ship-receipts", + "shape": "ws-text", + }, + } + if fmt == "metadata": + return { + "id": scene.get("id", ""), + "mode": scene.get("mode", ""), + "presentation_type": scene.get("presentation_type", ""), + "visual_template": scene.get("visual_template", ""), + "sticky": scene.get("sticky", False), + } + raise ValueError(f"unknown format: {fmt}") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Emit Ship Receipts app scene payloads for StackChan") + parser.add_argument("--scene", choices=sorted(SCENES.keys()), default="local") + parser.add_argument( + "--format", + choices=["scene", "command", "ble-config", "ws-text", "transport-note", "metadata", "resume-demo", "show-status", "set-mode"], + default="command", + ) + args = parser.parse_args() + + scene = load_scene(args.scene) + payload = build_output(scene, args.format) + print(json.dumps(payload, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/firmware/main/apps/apps.h b/firmware/main/apps/apps.h index 77cc9b71..6ad92232 100644 --- a/firmware/main/apps/apps.h +++ b/firmware/main/apps/apps.h @@ -12,3 +12,4 @@ #include "app_app_center/app_app_center.h" #include "app_ezdata/app_ezdata.h" #include "app_dance/app_dance.h" +#include "app_ship_receipts/app_ship_receipts.h" diff --git a/firmware/main/main.cpp b/firmware/main/main.cpp index 164a7516..3ef0714a 100644 --- a/firmware/main/main.cpp +++ b/firmware/main/main.cpp @@ -34,6 +34,7 @@ extern "C" void app_main(void) GetMooncake().installApp(std::make_unique()); GetMooncake().installApp(std::make_unique()); GetMooncake().installApp(std::make_unique()); + GetMooncake().installApp(std::make_unique()); GetMooncake().installApp(std::make_unique()); // Main loop