From 121e718bb10f957ca061b16fe138ca2665092e32 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 08:56:32 +0000 Subject: [PATCH 1/4] feat(gemini): lock product to Gemini Live, English default, Cloud splash, fork OTA Customer build for Google on top of the gemini branch. Gated by CONFIG_STACKCHAN_GEMINI_ONLY (on in sdkconfig.defaults, off in Kconfig so upstream merges stay easy). OpenAI / XiaoZhi source is hidden, not deleted; Provider enum values are unchanged. - Force effective provider to Gemini even with a stale NVS value; ignore other providers on BLE/HTTP. Add NVS + BLE 0x32/0x33 + HTTP for the Live model and voice (default gemini-3.8-live / Aoede). - NVS lang key, English default, Japanese strings kept. No language UI. Hide Japanese-only TTS tools in English. Settings pages overlay in EN. - Google Cloud boot splash (Wikimedia CC BY 4.0 wordmark) at board-native resolutions until the face render task starts, min 2 s. - OTA/voice catalog URL is Kconfig; this fork points at m5stack.github.io/stackchan-idf-gemini. Pages also deploys from gemini. Co-authored-by: Dylan Luo --- .github/workflows/pages.yml | 5 +- README.en.md | 47 ++- README.md | 57 +-- THIRD_PARTY_NOTICES.md | 16 +- assets/splash/SOURCE.txt | 6 + assets/splash/splash_atom.png | Bin 0 -> 3555 bytes assets/splash/splash_cores3.png | Bin 0 -> 11717 bytes assets/splash/splash_stopwatch.png | Bin 0 -> 13898 bytes components/config_service/config_service.cpp | 51 ++- components/config_service/config_store.cpp | 28 +- components/config_service/gatt_settings.cpp | 76 +++- .../include/config_service/config_service.hpp | 10 + .../include/config_service/product.hpp | 63 +++ .../config_service/settings_registry.hpp | 2 +- .../config_service/settings_registry.cpp | 3 + components/conversation/Kconfig | 13 + components/wifi_config_service/CMakeLists.txt | 3 +- components/wifi_config_service/Kconfig | 11 + .../wifi_config_service/http_handlers.cpp | 44 ++- .../wifi_config_service/release_ota.hpp | 1 + .../wifi_config_service/release_ota.cpp | 20 +- components/wifi_config_service/update_url.hpp | 50 +++ .../wifi_config_service/voice_fetch.cpp | 13 +- .../web/settings_wifi.html | 36 +- docs/index.html | 22 +- main/CMakeLists.txt | 7 + main/Kconfig.projbuild | 32 ++ main/agent_delegate.cpp | 24 +- main/agent_tools.cpp | 131 +++--- main/ap_screen.cpp | 5 +- main/app_main.cpp | 12 +- main/boot_splash.cpp | 81 ++++ main/boot_splash.hpp | 19 + main/conversation_task.cpp | 75 +++- main/conversation_task.hpp | 4 + main/demo_loop.cpp | 16 +- main/device_ui.cpp | 114 +++--- main/lt_timer.cpp | 17 + main/render_task.cpp | 23 +- recovery/main/http.cpp | 1 + recovery/sdkconfig.defaults | 4 + sdkconfig.defaults | 13 + tools/avatar_dsl/inject.mjs | 7 + tools/gemini_ui.js | 373 ++++++++++++++++++ tools/gen_google_cloud_splash.py | 116 ++++++ tools/settings.html | 99 ++++- 46 files changed, 1522 insertions(+), 228 deletions(-) create mode 100644 assets/splash/SOURCE.txt create mode 100644 assets/splash/splash_atom.png create mode 100644 assets/splash/splash_cores3.png create mode 100644 assets/splash/splash_stopwatch.png create mode 100644 components/config_service/include/config_service/product.hpp create mode 100644 components/wifi_config_service/update_url.hpp create mode 100644 main/boot_splash.cpp create mode 100644 main/boot_splash.hpp create mode 100644 tools/gemini_ui.js create mode 100644 tools/gen_google_cloud_splash.py diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index ff4ced2..2234e71 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -4,7 +4,7 @@ name: Deploy GitHub Pages # site. Refreshed on doc edits, on each release publish, and on demand. on: push: - branches: [main] + branches: [main, gemini] paths: - 'docs/**' # tools/settings.html is the canonical source; pages.yml copies it @@ -15,6 +15,7 @@ on: # + + diff --git a/docs/index.html b/docs/index.html index 455f6a1..4b1f7cf 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1,5 +1,5 @@ - + @@ -33,18 +33,18 @@

🤖 Stack-chan Web Flasher

- ブラウザから M5Stack CoreS3 にスタックチャン用ファームウェアを書き込みます。 -
初回 / リカバリ時用。普段のアップデートは - BLE 設定ページ から OTA でどうぞ。 -
Chrome / Edge など WebSerial 対応ブラウザが必要です。 + Flash Stack-chan firmware (Gemini Live edition) to an M5Stack board from the browser. +
For first-time / recovery use. Everyday updates go through the + BLE settings page OTA. +
Needs Chrome / Edge or another WebSerial browser.

@@ -122,7 +122,7 @@

🤖 Stack-chan Web Flasher

Bundled with esptool-js. - Source: ciniml/stackchan-idf. + Source: m5stack/stackchan-idf-gemini.

diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index eedb961..3cd01dd 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -41,6 +41,7 @@ set(_main_srcs "atom_status.cpp" "audio_codec.cpp" "battery.cpp" + "boot_splash.cpp" "camera_service.cpp" "dance.cpp" "dance_storage.cpp" @@ -92,6 +93,12 @@ idf_component_register( ) target_compile_features(${COMPONENT_LIB} PRIVATE cxx_std_20) +if(CONFIG_STACKCHAN_BOOT_SPLASH) + target_add_binary_data(${COMPONENT_LIB} "${PROJECT_DIR}/assets/splash/splash_cores3.png" BINARY) + target_add_binary_data(${COMPONENT_LIB} "${PROJECT_DIR}/assets/splash/splash_atom.png" BINARY) + target_add_binary_data(${COMPONENT_LIB} "${PROJECT_DIR}/assets/splash/splash_stopwatch.png" BINARY) +endif() + # --- ADR-001: exttab / bootctl の生成と書き込み (partitions_main_*.csv のとき) -- # 開発時の `make flash` で Main と一緒に拡張テーブルと「起動先=Main (確認済み)」の # bootctl を書く。Recovery は recovery/ プロジェクトから別途書く。 diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild index dbbcf84..db5b872 100644 --- a/main/Kconfig.projbuild +++ b/main/Kconfig.projbuild @@ -100,3 +100,35 @@ config STACKCHAN_MIC_TAP_HOST 同じ時計 (機体 ms) で突き合わせるための診断用。通常は空 (無効)。 endmenu + +menu "Stack-chan product profile (Gemini customer build)" + + config STACKCHAN_GEMINI_ONLY + bool "Lock AI provider to Gemini Live" + default n + help + Hide OpenAI / XiaoZhi from every user-facing config surface, + ignore attempts to select them over BLE / HTTP, and force the + effective provider to Gemini even if NVS still holds an old + value. OpenAI / XiaoZhi source stays in the tree. Turn off to + restore upstream multi-provider behaviour. + + config STACKCHAN_BOOT_SPLASH + bool "Show Google Cloud logo at boot" + default n + help + After Board::begin(), draw a Google Cloud logo on the LCD until + the face render task takes over. Per-board PNGs are embedded + (CoreS3 320x240, Atom 128x128, StopWatch 466x466). + + config STACKCHAN_BOOT_SPLASH_MS + int "Minimum boot-splash display time (ms)" + default 2000 + range 500 5000 + depends on STACKCHAN_BOOT_SPLASH + help + Floor on how long the Google Cloud logo stays visible, even if + the rest of boot finishes earlier. 1500–3000 ms is the intended + range. + +endmenu diff --git a/main/agent_delegate.cpp b/main/agent_delegate.cpp index 6693d7d..d1a1f93 100644 --- a/main/agent_delegate.cpp +++ b/main/agent_delegate.cpp @@ -2,6 +2,7 @@ // SPDX-License-Identifier: BSL-1.0 #include "agent_delegate.hpp" +#include #include #include @@ -44,14 +45,19 @@ conversation::ToolDefinition Delegate::tool_definition() { return conversation::ToolDefinition{ .name = "ask_agent", - .description = + .description = config::tr( + "Ask a smarter home-server agent about things you cannot answer (search, math, " + "looking things up). It takes tens of seconds; say you will check and wait. " + "One request at a time.", "自分では分からないこと (最新情報の検索、計算、文章の下調べ、家のパソコンにある情報など) を、" "家のサーバーで動いている賢いエージェントに頼む。回答には数十秒かかるので、" "呼んだら「聞いてみるね」とだけ伝えて待つ。回答は後で自動的に届くので、届いたら要点を話す。" - "同時に頼めるのは 1 件。", - .parameters_json = - R"({"type":"object","properties":{"question":{"type":"string","maxLength":1000,)" - R"("description":"エージェントへの依頼内容。必要な前提も含めて日本語で具体的に"}},"required":["question"]})", + "同時に頼めるのは 1 件。"), + .parameters_json = config::ui_japanese() + ? R"({"type":"object","properties":{"question":{"type":"string","maxLength":1000,)" + R"("description":"エージェントへの依頼内容。必要な前提も含めて日本語で具体的に"}},"required":["question"]})" + : R"({"type":"object","properties":{"question":{"type":"string","maxLength":1000,)" + R"("description":"The request for the agent, including needed context"}},"required":["question"]})", .non_blocking = true, }; } @@ -154,13 +160,17 @@ std::optional Delegate::poll(std::uint32_t now_ms) if (now_ms - started_ms_ > kJobTimeoutMs) { ESP_LOGW(kTag, "job %s timed out", job_id_.c_str()); - return finish(Result{.ok = false, .error = "エージェントからの回答が時間内に届きませんでした"}); + return finish(Result{.ok = false, + .error = config::tr("The agent did not answer in time", + "エージェントからの回答が時間内に届きませんでした")}); } int status = 0; const auto resp = http("GET", "/jobs/" + job_id_, "", status); if (!resp) return std::nullopt; // transient; retry next interval - if (status == 404) return finish(Result{.ok = false, .error = "エージェント サーバーが依頼を見失いました"}); + if (status == 404) return finish(Result{.ok = false, + .error = config::tr("The agent server lost the request", + "エージェント サーバーが依頼を見失いました")}); if (status != 200) return std::nullopt; cJSON* root = cJSON_Parse(resp->c_str()); diff --git a/main/agent_tools.cpp b/main/agent_tools.cpp index 7f291dd..663efd2 100644 --- a/main/agent_tools.cpp +++ b/main/agent_tools.cpp @@ -17,6 +17,7 @@ #include "settings_sinks.hpp" #include "utf8.hpp" +#include namespace stackchan::app::agent_tools { @@ -103,9 +104,13 @@ std::string tool_get_time() std::tm tm{}; localtime_r(&now, &tm); if (tm.tm_year + 1900 < 2020) { - return R"json({"ok":false,"error":"時計がまだ合っていません (Wi-Fi 未接続か NTP 未同期)"})json"; + return config::ui_japanese() + ? R"json({"ok":false,"error":"時計がまだ合っていません (Wi-Fi 未接続か NTP 未同期)"})json" + : R"json({"ok":false,"error":"clock not set yet (Wi-Fi down or NTP not synced)"})json"; } - static const char* kWeekday[] = {"日", "月", "火", "水", "木", "金", "土"}; + static const char* kWeekdayJa[] = {"日", "月", "火", "水", "木", "金", "土"}; + static const char* kWeekdayEn[] = {"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"}; + const char* const* kWeekday = config::ui_japanese() ? kWeekdayJa : kWeekdayEn; char buf[160]; std::snprintf(buf, sizeof(buf), R"({"ok":true,"date":"%04d-%02d-%02d","time":"%02d:%02d","weekday":"%s","timezone":"JST"})", @@ -158,9 +163,9 @@ std::string tool_remember(const std::string& args) if (deserializeJson(in, args)) return error_json("bad arguments"); std::string key = in["key"] | ""; std::string text = in["text"] | ""; - if (key.empty() || text.empty()) return error_json("key と text が必要です"); + if (key.empty() || text.empty()) return error_json(config::tr("key and text are required", "key と text が必要です")); if (key.size() > kMaxKeyBytes) key.resize(kMaxKeyBytes); - if (text.size() > kMaxValueBytes) return error_json("text が長すぎます (500 バイトまで)"); + if (text.size() > kMaxValueBytes) return error_json(config::tr("text is too long (500 bytes max)", "text が長すぎます (500 バイトまで)")); auto store = make_json_doc(); JsonArray arr = load_items(store); @@ -178,7 +183,7 @@ std::string tool_remember(const std::string& args) item["v"] = text; item["t"] = static_cast(std::time(nullptr)); const std::size_t n = arr.size(); - if (!save_items(store)) return error_json("保存に失敗しました"); + if (!save_items(store)) return error_json(config::tr("save failed", "保存に失敗しました")); ESP_LOGI(kTag, "remember: %s (%u items)", key.c_str(), static_cast(n)); char b[64]; std::snprintf(b, sizeof(b), R"({"ok":true,"stored":%u})", static_cast(n)); @@ -212,14 +217,14 @@ std::string tool_forget(const std::string& args) auto in = make_json_doc(); if (deserializeJson(in, args)) return error_json("bad arguments"); const std::string key = in["key"] | ""; - if (key.empty()) return error_json("key が必要です"); + if (key.empty()) return error_json(config::tr("key is required", "key が必要です")); auto store = make_json_doc(); JsonArray arr = load_items(store); int removed = 0; for (std::size_t i = arr.size(); i-- > 0;) { if (key == (arr[i]["k"] | "")) { arr.remove(i); ++removed; } } - if (!save_items(store)) return error_json("保存に失敗しました"); + if (!save_items(store)) return error_json(config::tr("save failed", "保存に失敗しました")); char b[64]; std::snprintf(b, sizeof(b), R"({"ok":true,"removed":%d})", removed); return b; @@ -234,9 +239,15 @@ std::string tool_set_timer(const std::string& args, SharedState& state) const double seconds = in["seconds"] | -1.0; std::string display = in["message"] | ""; std::string reading = in["reading"] | ""; - if (seconds <= 0 || seconds > 12 * 3600) return error_json("seconds は 1〜43200 の範囲で指定してください"); - if (display.empty()) display = "じかんだよ"; - if (reading.empty()) reading = display; + if (seconds <= 0 || seconds > 12 * 3600) + return error_json(config::tr("seconds must be between 1 and 43200", + "seconds は 1〜43200 の範囲で指定してください")); + if (display.empty()) display = config::tr("It's time", "じかんだよ"); + if (reading.empty()) { + // Japanese formant TTS cannot speak English; leave empty so demo_loop + // falls back to the balloon only. + reading = config::ui_japanese() ? display : std::string{}; + } if (display.size() > 120) display.resize(120); if (reading.size() > 120) reading.resize(120); const std::uint32_t now_ms = static_cast(esp_timer_get_time() / 1000); @@ -274,11 +285,15 @@ std::string tool_set_volume(const std::string& args, SharedState& state) } else if (action == "down") { pct = std::max(kMin, pct - kStep); } else if (action == "set") { - if (level < 0 || level > kMax) return R"({"ok":false,"error":"level は 0〜200 で指定"})"; + if (level < 0 || level > kMax) + return config::ui_japanese() ? R"({"ok":false,"error":"level は 0〜200 で指定"})" + : R"({"ok":false,"error":"level must be 0..200"})"; pct = std::max(kMin, level); muted = false; } else { - return R"({"ok":false,"error":"action は up / down / mute / unmute / set のいずれか"})"; + return config::ui_japanese() + ? R"({"ok":false,"error":"action は up / down / mute / unmute / set のいずれか"})" + : R"({"ok":false,"error":"action must be up / down / mute / unmute / set"})"; } state.speaker.muted.store(muted, std::memory_order_relaxed); settings_sinks::apply_speaker_volume_persist(static_cast(pct)); @@ -300,7 +315,9 @@ std::string tool_lt_timer(const std::string& args, SharedState& state) const double minutes = in["minutes"] | 0.0; char buf[160]; if (action == "start") { - if (minutes < 0.5 || minutes > 180) return R"({"ok":false,"error":"minutes は 0.5〜180 で指定"})"; + if (minutes < 0.5 || minutes > 180) + return config::ui_japanese() ? R"({"ok":false,"error":"minutes は 0.5〜180 で指定"})" + : R"({"ok":false,"error":"minutes must be 0.5..180"})"; const auto total_s = static_cast(minutes * 60.0 + 0.5); state.lt.total_s.store(total_s, std::memory_order_relaxed); state.lt.command.store(1, std::memory_order_release); @@ -320,84 +337,108 @@ std::string tool_lt_timer(const std::string& args, SharedState& state) static_cast(state.lt.total_s.load(std::memory_order_relaxed))); return buf; } - return R"({"ok":false,"error":"action は start / stop / status のいずれか"})"; + return config::ui_japanese() ? R"({"ok":false,"error":"action は start / stop / status のいずれか"})" + : R"({"ok":false,"error":"action must be start / stop / status"})"; } std::vector definitions() { + const bool ja = config::ui_japanese(); return { conv::ToolDefinition{ .name = "lt_timer", - .description = - "発表 (LT) 用のカウントダウン タイマー。action: start (minutes で長さを指定、例 1 や 5)、" - "stop (停止・リセット)、status (残り時間)。画面に残り時間を表示し、残り 1 分・終了・超過を声で知らせる。" - "「3 分のタイマーをセット」「タイマー止めて」「あと何分?」などで使う。" - "単純に N 分後に一言知らせてほしいだけなら set_timer を使う。", + .description = ja + ? "発表 (LT) 用のカウントダウン タイマー。action: start (minutes で長さを指定、例 1 や 5)、" + "stop (停止・リセット)、status (残り時間)。画面に残り時間を表示し、残り 1 分・終了・超過を声で知らせる。" + "「3 分のタイマーをセット」「タイマー止めて」「あと何分?」などで使う。" + "単純に N 分後に一言知らせてほしいだけなら set_timer を使う。" + : "Lightning-talk countdown timer. action: start (minutes, e.g. 1 or 5), " + "stop, status. Shows remaining time on screen. In English mode announcements " + "are on-screen only (no Japanese TTS). Use set_timer for a one-shot reminder.", .parameters_json = R"({"type":"object","properties":{"action":{"type":"string","enum":["start","stop","status"]},)" R"("minutes":{"type":"number","minimum":0.5,"maximum":180}},"required":["action"]})", }, conv::ToolDefinition{ .name = "set_volume", - .description = - "スピーカーの音量を変える / ミュートする。action: up (少し上げる), down (少し下げる), " - "mute (消音), unmute (消音解除), set (level で 0〜200% を直接指定、100 が標準)。" - "「音量上げて」「うるさい」「静かにして」「ミュート」「音出して」などで使う。", + .description = ja + ? "スピーカーの音量を変える / ミュートする。action: up (少し上げる), down (少し下げる), " + "mute (消音), unmute (消音解除), set (level で 0〜200% を直接指定、100 が標準)。" + "「音量上げて」「うるさい」「静かにして」「ミュート」「音出して」などで使う。" + : "Change speaker volume or mute. action: up, down, mute, unmute, set " + "(level 0..200 percent, 100 = default).", .parameters_json = R"({"type":"object","properties":{"action":{"type":"string","enum":["up","down","mute","unmute","set"]},)" R"("level":{"type":"integer","minimum":0,"maximum":200}},"required":["action"]})", }, conv::ToolDefinition{ .name = "get_time", - .description = "現在の日付・時刻・曜日 (日本時間) を取得する。時間や日付を聞かれたら必ずこれで確認する。", + .description = ja ? "現在の日付・時刻・曜日 (日本時間) を取得する。時間や日付を聞かれたら必ずこれで確認する。" + : "Get the current date, time, and weekday (JST). Always call this instead of guessing.", .parameters_json = R"({"type":"object","properties":{}})", }, conv::ToolDefinition{ .name = "get_status", - .description = - "スタックチャン自身の状態を取得する: 近接センサー (手が近くにあるか)、バッテリー残量、" - "起動からの時間、LT タイマーの残り。体調や電池、近くに何かあるか聞かれたときに使う。", + .description = ja + ? "スタックチャン自身の状態を取得する: 近接センサー (手が近くにあるか)、バッテリー残量、" + "起動からの時間、LT タイマーの残り。体調や電池、近くに何かあるか聞かれたときに使う。" + : "Read Stack-chan's own status: proximity (hand nearby), battery, uptime, LT timer remaining.", .parameters_json = R"({"type":"object","properties":{}})", }, conv::ToolDefinition{ .name = "remember", - .description = - "ユーザーに頼まれたことや大事な情報を、電源を切っても残るメモとして保存する。" - "key は短い見出し (例: \"ユーザーの名前\")、text は内容。同じ key は上書き。", + .description = ja + ? "ユーザーに頼まれたことや大事な情報を、電源を切っても残るメモとして保存する。" + "key は短い見出し (例: \"ユーザーの名前\")、text は内容。同じ key は上書き。" + : "Persist a short note across reboots. key is a heading, text is the content. Same key overwrites.", .parameters_json = R"({"type":"object","properties":{"key":{"type":"string","maxLength":48},)" R"("text":{"type":"string","maxLength":500}},"required":["key","text"]})", }, conv::ToolDefinition{ .name = "recall", - .description = "保存してあるメモを読み出す。key を省略すると全部返す。以前の話や約束を聞かれたら使う。", + .description = ja ? "保存してあるメモを読み出す。key を省略すると全部返す。以前の話や約束を聞かれたら使う。" + : "Recall stored notes. Omit key to list all.", .parameters_json = R"({"type":"object","properties":{"key":{"type":"string"}}})", }, conv::ToolDefinition{ .name = "forget", - .description = "保存してあるメモを key を指定して消す。", + .description = ja ? "保存してあるメモを key を指定して消す。" : "Delete a stored note by key.", .parameters_json = R"({"type":"object","properties":{"key":{"type":"string"}},"required":["key"]})", }, conv::ToolDefinition{ .name = "set_timer", - .description = - "指定秒数後にスタックチャンが自分から声をかける (タイマー / リマインダー)。" - "message は画面に出す文、reading はその読み (ひらがな・カタカナのみ、漢字不可)。" - "設定できるのは 1 件だけで、再起動すると消える。", - .parameters_json = - R"({"type":"object","properties":{"seconds":{"type":"integer","minimum":1,"maximum":43200},)" - R"("message":{"type":"string","maxLength":60},"reading":{"type":"string","maxLength":80}},)" - R"("required":["seconds","message","reading"]})", + .description = ja + ? "指定秒数後にスタックチャンが自分から声をかける (タイマー / リマインダー)。" + "message は画面に出す文、reading はその読み (ひらがな・カタカナのみ、漢字不可)。" + "設定できるのは 1 件だけで、再起動すると消える。" + : "One-shot reminder after N seconds. message is shown on screen. " + "reading (hiragana) is optional and ignored in English mode (text balloon only). " + "One reminder at a time; lost on reboot.", + .parameters_json = ja + ? R"({"type":"object","properties":{"seconds":{"type":"integer","minimum":1,"maximum":43200},)" + R"("message":{"type":"string","maxLength":60},"reading":{"type":"string","maxLength":80}},)" + R"("required":["seconds","message","reading"]})" + : R"({"type":"object","properties":{"seconds":{"type":"integer","minimum":1,"maximum":43200},)" + R"("message":{"type":"string","maxLength":60},"reading":{"type":"string","maxLength":80}},)" + R"("required":["seconds","message"]})", }, }; } const char* guidance() { - return "\n\n[ツール] 時刻や日付を聞かれたら get_time、電池や近くの様子を聞かれたら get_status を使って" - "確かめてから答えてください (推測で答えない)。覚えておいてと言われたら remember、" - "前に話したことを聞かれたら recall を使います。「N 分後に教えて」は set_timer を使います " - "(reading はひらがなで)。音量やミュートの指示は set_volume、「N 分のタイマーをセット」は lt_timer を使います。ツールは必ず関数呼び出し (function calling) の仕組みで呼び、" - "「tool_code」や関数名を文章・音声として話さないでください。ツールの結果は要点だけ短く話してください。"; + return config::ui_japanese() + ? "\n\n[ツール] 時刻や日付を聞かれたら get_time、電池や近くの様子を聞かれたら get_status を使って" + "確かめてから答えてください (推測で答えない)。覚えておいてと言われたら remember、" + "前に話したことを聞かれたら recall を使います。「N 分後に教えて」は set_timer を使います " + "(reading はひらがなで)。音量やミュートの指示は set_volume、「N 分のタイマーをセット」は lt_timer を使います。ツールは必ず関数呼び出し (function calling) の仕組みで呼び、" + "「tool_code」や関数名を文章・音声として話さないでください。ツールの結果は要点だけ短く話してください。" + : "\n\n[Tools] Use get_time for the date or time, and get_status for battery / " + "proximity / uptime — do not guess. Use remember / recall / forget for notes. " + "Use set_timer for a one-shot reminder (message only; do not rely on reading). " + "Use set_volume for loudness, lt_timer for a countdown on screen. Always invoke " + "tools via function calling; never speak tool names or tool_code. Summarise tool " + "results briefly."; } std::optional dispatch(const std::string& name, const std::string& args, SharedState& state) diff --git a/main/ap_screen.cpp b/main/ap_screen.cpp index 4947046..e3d0f10 100644 --- a/main/ap_screen.cpp +++ b/main/ap_screen.cpp @@ -7,6 +7,7 @@ #include #include +#include #include "wifi_sta.hpp" namespace stackchan::app::ap_screen { @@ -160,7 +161,7 @@ bool draw(M5GFX& display) : &fonts::lgfxJapanGothic_16); display.setTextColor(TFT_BLACK, TFT_WHITE); display.setTextDatum(lgfx::textdatum_t::top_center); - display.drawString("Wi-Fi 設定モード", W / 2, top_margin); + display.drawString(config::tr("Wi-Fi setup", "Wi-Fi 設定モード"), W / 2, top_margin); } // QR. Version 0 = auto-pick by payload length. The payload is ≤ 80 @@ -203,7 +204,7 @@ bool draw(M5GFX& display) : &fonts::lgfxJapanGothic_16); display.setTextColor(TFT_WHITE, red); display.setTextDatum(lgfx::textdatum_t::middle_center); - display.drawString("終了 (AP モード停止)", + display.drawString(config::tr("Exit (stop AP)", "終了 (AP モード停止)"), btn_x + btn_w / 2, btn_band_y + btn_h / 2); } display.endWrite(); diff --git a/main/app_main.cpp b/main/app_main.cpp index 5a04924..49a89b7 100644 --- a/main/app_main.cpp +++ b/main/app_main.cpp @@ -71,6 +71,8 @@ #include "wifi_audio.hpp" #endif #include "wifi_sta.hpp" +#include "boot_splash.hpp" +#include #include #ifdef CONFIG_TELEGRAM_PHASE1_ENABLED @@ -282,6 +284,7 @@ extern "C" void app_main() // kind (the Module Audio probe below) are folded into this local copy in // one place; everything downstream reads the profile, not board.kind(). stackchan::board::BoardProfile profile = stackchan::board::profile_for(board.kind()); + stackchan::app::boot_splash::show(board.display(), profile.circular_display); // Reserve the conversation task's internal-RAM speaker ring *now*, before // anything else has a chance to chew up DRAM. The conversation task itself @@ -727,6 +730,7 @@ extern "C" void app_main() case StartError::BadTag: return "bad tag"; case StartError::UnknownBoard: return "unknown board"; case StartError::WorkerSpawnFailed: return "spawn failed"; + case StartError::NoCatalog: return "update catalog unset"; } return "fetch failed"; }); @@ -912,7 +916,7 @@ extern "C" void app_main() const char* xiaozhi_token = ""; if (!cfg.openai_enabled) { ESP_LOGI(kTag, "Conversation disabled by configuration"); - } else if (cfg.provider == stackchan::config::Provider::Gemini) { + } else if (stackchan::config::effective_provider(cfg) == stackchan::config::Provider::Gemini) { if (!cfg.gemini_api_key.empty()) { api_key = cfg.gemini_api_key.c_str(); } @@ -936,12 +940,15 @@ extern "C" void app_main() #if CONFIG_STACKCHAN_CONVERSATION_ENABLED g_conversation_args = new stackchan::app::ConversationTaskArgs{ - .state = g_state, .api_key = api_key, .provider = cfg.provider, .touch = head_touch, + .state = g_state, .api_key = api_key, + .provider = stackchan::config::effective_provider(cfg), .touch = head_touch, .xiaozhi_url = xiaozhi_url, .xiaozhi_token = xiaozhi_token, .system_prompt = cfg.system_prompt.c_str(), .extra_headers = cfg.conv_extra_headers.c_str(), .agent_url = cfg.agent_url.c_str(), .agent_token = cfg.agent_token.c_str(), + .gemini_model = cfg.gemini_model.c_str(), + .gemini_voice = cfg.gemini_voice.c_str(), .seg_buf = g_conv_seg_buf}; #else (void)api_key; (void)xiaozhi_url; (void)xiaozhi_token; @@ -952,6 +959,7 @@ extern "C" void app_main() // profile.button_overlay_ui decides). render_task and demo_loop // dispatch through screens:: from here on. stackchan::app::diag_heap("pre-screens/render"); + stackchan::app::boot_splash::wait_min_time(); stackchan::app::screens::init(board.display(), *g_state, profile); stackchan::app::start_render_task(*g_render_args); if (!no_servo_bus && cfg.servo_enabled && g_servo_args != nullptr) { diff --git a/main/boot_splash.cpp b/main/boot_splash.cpp new file mode 100644 index 0000000..08a0f22 --- /dev/null +++ b/main/boot_splash.cpp @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: 2026 Kenta IDA +// SPDX-License-Identifier: BSL-1.0 + +#include "boot_splash.hpp" + +#include "sdkconfig.h" + +#include + +#include +#include +#include +#include +#include + +namespace stackchan::app::boot_splash { + +namespace { +constexpr const char* kTag = "boot-splash"; + +#if CONFIG_STACKCHAN_BOOT_SPLASH +extern "C" { +extern const std::uint8_t splash_cores3_png_start[] asm("_binary_splash_cores3_png_start"); +extern const std::uint8_t splash_cores3_png_end[] asm("_binary_splash_cores3_png_end"); +extern const std::uint8_t splash_atom_png_start[] asm("_binary_splash_atom_png_start"); +extern const std::uint8_t splash_atom_png_end[] asm("_binary_splash_atom_png_end"); +extern const std::uint8_t splash_stopwatch_png_start[] asm("_binary_splash_stopwatch_png_start"); +extern const std::uint8_t splash_stopwatch_png_end[] asm("_binary_splash_stopwatch_png_end"); +} + +std::int64_t g_shown_us = 0; + +void blit(M5GFX& display, const std::uint8_t* start, const std::uint8_t* end) +{ + const std::size_t len = static_cast(end - start); + display.startWrite(); + display.fillScreen(TFT_WHITE); + display.drawPng(start, len, 0, 0); + display.endWrite(); + display.display(); +} +#endif +} // namespace + +void show(M5GFX& display, bool /*circular_display*/) +{ +#if CONFIG_STACKCHAN_BOOT_SPLASH + const int w = display.width(); + if (w >= 400) { + blit(display, splash_stopwatch_png_start, splash_stopwatch_png_end); + } else if (w >= 240) { + blit(display, splash_cores3_png_start, splash_cores3_png_end); + } else { + blit(display, splash_atom_png_start, splash_atom_png_end); + } + g_shown_us = esp_timer_get_time(); + ESP_LOGI(kTag, "Google Cloud splash %dx%d", display.width(), display.height()); +#else + (void)display; +#endif +} + +void wait_min_time() +{ +#if CONFIG_STACKCHAN_BOOT_SPLASH + if (g_shown_us == 0) return; +#ifdef CONFIG_STACKCHAN_BOOT_SPLASH_MS + const std::int64_t min_us = static_cast(CONFIG_STACKCHAN_BOOT_SPLASH_MS) * 1000; +#else + const std::int64_t min_us = 2000 * 1000; +#endif + const std::int64_t elapsed = esp_timer_get_time() - g_shown_us; + if (elapsed < min_us) { + const std::uint32_t remain_ms = + static_cast((min_us - elapsed + 999) / 1000); + vTaskDelay(pdMS_TO_TICKS(remain_ms)); + } +#endif +} + +} // namespace stackchan::app::boot_splash diff --git a/main/boot_splash.hpp b/main/boot_splash.hpp new file mode 100644 index 0000000..af8932c --- /dev/null +++ b/main/boot_splash.hpp @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 Kenta IDA +// SPDX-License-Identifier: BSL-1.0 + +#pragma once + +#include + +namespace stackchan::app::boot_splash { + +// Draw the Google Cloud startup logo on `display` (board-native resolution) +// and record the show timestamp. No-op when CONFIG_STACKCHAN_BOOT_SPLASH is +// unset. Safe to call immediately after Board::begin(). +void show(M5GFX& display, bool circular_display); + +// Block until the minimum splash time has elapsed (CONFIG_STACKCHAN_BOOT_SPLASH_MS, +// default 2000). Call just before the face render task takes over the panel. +void wait_min_time(); + +} // namespace stackchan::app::boot_splash diff --git a/main/conversation_task.cpp b/main/conversation_task.cpp index ca5f51b..48a4310 100644 --- a/main/conversation_task.cpp +++ b/main/conversation_task.cpp @@ -29,6 +29,7 @@ #include "conversation/metrics.hpp" #include "conversation/openai_realtime_client.hpp" #include "conversation/xiaozhi_client.hpp" +#include #include "utf8.hpp" #include "wifi_sta.hpp" @@ -94,18 +95,31 @@ enum class Local : std::uint8_t { Yielded, // I2S handed off to BLE audio streamer (mic + speaker ended) }; -const char* kInstructions = +const char* kInstructionsJa = "あなたは「スタックチャン」という小さな卓上ロボットです。M5Stack CoreS3 で動いています。" "フレンドリーで元気いっぱい、少し子供っぽい口調で、短く返事をします。ユーザーとは日本語で会話してください。ただし、ユーザーが日本語以外の言語(英語)などで話したいと要望したときは、ユーザーが要望する言語に切り替えてください。" "顔の表情と首の向きを変えられます。気持ちに合わせて set_expression や set_head_pose ツールを使ってください。" "また speak_katakoto ツールでロボット風のカタコト声を出すこともできます。" "ものまね・効果音・繰り返しなど演出的に使ってください(ツール呼び出しのターンでは普通の声で続けて喋らなくて構いません)。"; +const char* kInstructionsEn = + "You are Stack-chan, a small desktop robot running on M5Stack. Be friendly, " + "cheerful, and a little childlike. Keep replies short. Speak English with the " + "user (switch only if they clearly ask for another language). You can change " + "your facial expression and head pose; use set_expression and set_head_pose " + "to match the mood."; + +const char* kInstructions() +{ + return config::ui_japanese() ? kInstructionsJa : kInstructionsEn; +} + conv::ToolDefinition make_set_expression_tool() { return conv::ToolDefinition{ .name = "set_expression", - .description = "スタックチャンの顔の表情を変える。感情を表現したいときに使う。", + .description = config::tr("Change Stack-chan's facial expression to match the mood.", + "スタックチャンの顔の表情を変える。感情を表現したいときに使う。"), .parameters_json = R"({"type":"object","properties":{"expression":{"type":"string",)" R"("enum":["neutral","happy","sad","angry","doubt","sleepy"]}},"required":["expression"]})", @@ -116,7 +130,9 @@ conv::ToolDefinition make_set_head_pose_tool() { return conv::ToolDefinition{ .name = "set_head_pose", - .description = "スタックチャンの首の向きを変える。yaw は左右(-40〜40度)、pitch は上下(-10〜25度)。", + .description = config::tr( + "Change Stack-chan's head pose. yaw is left/right (-40 to 40 deg), pitch is up/down (-10 to 25 deg).", + "スタックチャンの首の向きを変える。yaw は左右(-40〜40度)、pitch は上下(-10〜25度)。"), .parameters_json = R"({"type":"object","properties":{)" R"("yaw_deg":{"type":"number"},"pitch_deg":{"type":"number"}},"required":["yaw_deg","pitch_deg"]})", @@ -204,6 +220,7 @@ class Coordinator { board::Si12tTouch* touch, const char* xiaozhi_url, const char* xiaozhi_token, const char* system_prompt, const char* extra_headers, const char* agent_url, const char* agent_token, + const char* gemini_model, const char* gemini_voice, const std::array& seg_buf) : state_{state}, api_key_{api_key != nullptr ? api_key : ""}, provider_{provider}, touch_{touch}, @@ -211,6 +228,8 @@ class Coordinator { xiaozhi_token_{xiaozhi_token != nullptr ? xiaozhi_token : ""}, system_prompt_{system_prompt != nullptr ? system_prompt : ""}, extra_headers_{extra_headers != nullptr ? extra_headers : ""}, + gemini_model_{gemini_model != nullptr ? gemini_model : ""}, + gemini_voice_{gemini_voice != nullptr ? gemini_voice : ""}, delegate_{agent_url != nullptr ? agent_url : "", agent_token != nullptr ? agent_token : ""}, seg_buf_{seg_buf} { @@ -381,7 +400,7 @@ class Coordinator { conv::ConversationConfig cfg{}; // User-set system prompt (over Wi-Fi) wins; empty falls back to the // firmware's built-in persona. - cfg.instructions = system_prompt_.empty() ? kInstructions : system_prompt_; + cfg.instructions = system_prompt_.empty() ? kInstructions() : system_prompt_; // Tool guidance applies whichever persona is in use (a user prompt // replaces kInstructions wholesale, so append rather than embed). cfg.instructions += agent_tools::guidance(); @@ -390,10 +409,10 @@ class Coordinator { if (provider_ == config::Provider::Gemini) { // Gemini Live model + voice. The model is namespaced as // "models/..."; the client prepends that for us when missing. - // gemini-2.0-flash-live-001 was deprecated; the native-audio - // preview is what's actually live on the Developer API right now. - cfg.model = "gemini-2.5-flash-native-audio-preview-12-2025"; - cfg.voice = "Aoede"; // pre-built voice name; OK to leave empty + // Default is the current AI Studio GA native-audio live model + // (Kconfig / NVS); empty voice is also accepted by the API. + cfg.model = gemini_model_.empty() ? config::default_gemini_model() : gemini_model_; + cfg.voice = gemini_voice_.empty() ? config::default_gemini_voice() : gemini_voice_; } else { cfg.model = CONFIG_STACKCHAN_OPENAI_REALTIME_MODEL; cfg.voice = CONFIG_STACKCHAN_OPENAI_VOICE; @@ -405,7 +424,11 @@ class Coordinator { // device is the MCP server) so the xiaozhi server's LLM can call them. cfg.tools.push_back(make_set_expression_tool()); cfg.tools.push_back(make_set_head_pose_tool()); - cfg.tools.push_back(make_speak_katakoto_tool()); + // speak_katakoto drives the Japanese-only formant TTS engine. Hide + // it from the model in English mode; the handler stays in the tree. + if (config::ui_japanese()) { + cfg.tools.push_back(make_speak_katakoto_tool()); + } // XiaoZhi: the server runs ask_agent itself (it sits on the same LAN as // the agent server, whereas the device may be talking through a // tunnel), so don't publish ours over MCP — two tools of the same @@ -655,7 +678,7 @@ class Coordinator { ESP_LOGI(kTag, "barge-in: user touched the head, interrupting reply"); M5.Speaker.stop(); (void)client_->cancel_response(); - state_.set_balloon_text("はいはい?", /*hold_ms=*/1500); + state_.set_balloon_text(config::tr("Yes?", "はいはい?"), /*hold_ms=*/1500); log_playback_metrics(); enter_listening(); } @@ -792,7 +815,9 @@ class Coordinator { ESP_LOGI(kTag, "notice (balloon only on Gemini): %s", text.c_str()); return; } - const std::string turn = "[お知らせ] " + text + " — これをユーザーにそのまま短く日本語で伝えてください"; + const std::string turn = config::ui_japanese() + ? ("[お知らせ] " + text + " — これをユーザーにそのまま短く日本語で伝えてください") + : ("[Notice] " + text + " — Tell the user this briefly, in their language."); if (auto r = client_->push_text(turn); r) { set_local(Local::Thinking); thinking_since_ms_ = now_ms(); @@ -809,10 +834,18 @@ class Coordinator { if (!res) return; std::string text; if (res->ok) { - text = "[エージェントからの回答が届きました。ユーザーの質問「" + delegate_.question() + - "」への答えです。要点を短く話してください]\n" + res->answer; + text = std::string(config::tr( + "[Answer from the agent for the user's question \"", + "[エージェントからの回答が届きました。ユーザーの質問「")) + + delegate_.question() + + config::tr("\". Summarise the key points briefly.]\n", + "」への答えです。要点を短く話してください]\n") + + res->answer; } else { - text = "[エージェントへの依頼は失敗しました: " + res->error + "。ユーザーにそう伝えてください]"; + text = std::string(config::tr("[The agent request failed: ", + "[エージェントへの依頼は失敗しました: ")) + + res->error + + config::tr(". Please tell the user.]", "。ユーザーにそう伝えてください]"); } if (text.size() > 3000) text.resize(3000); // Show the answer itself right away: if the session drops while the @@ -828,7 +861,9 @@ class Coordinator { // Deferred functionResponse for the NON_BLOCKING call. std::string payload = res->ok ? R"({"ok":true,"answer":)" + json_quote(res->answer) : R"({"ok":false,"error":)" + json_quote(res->error); - payload += R"(,"note":"ユーザーに要点を短く話してください","scheduling":"INTERRUPT"})"; + payload += config::ui_japanese() + ? R"(,"note":"ユーザーに要点を短く話してください","scheduling":"INTERRUPT"})" + : R"(,"note":"Summarise the key points briefly for the user.","scheduling":"INTERRUPT"})"; r = client_->submit_tool_result(agent_call_id_, payload); agent_call_id_.clear(); } @@ -845,7 +880,8 @@ class Coordinator { ESP_LOGW(kTag, "push_text failed (%d); announcing via reminder slot", static_cast(r.error())); std::string display = res->ok ? res->answer : res->error; if (display.size() > 120) display.resize(120); - std::string reading = res->ok && !res->reading.empty() ? res->reading : "えーじぇんとからへんじがきたよ"; + std::string reading = res->ok && !res->reading.empty() ? res->reading + : (config::ui_japanese() ? "えーじぇんとからへんじがきたよ" : std::string{}); state_.set_reminder(now_ms(), display, reading); } } @@ -1146,7 +1182,7 @@ class Coordinator { ev.error == conv::ConversationError::TransportInit; if (transport_error) { ESP_LOGE(kTag, "transport error: %s — reconnecting", ev.text.c_str()); - state_.set_balloon_text("接続エラー", /*hold_ms=*/3000); + state_.set_balloon_text(config::tr("Connection error", "接続エラー"), /*hold_ms=*/3000); recover_after_error(); } else { ESP_LOGW(kTag, "server error (non-fatal): %s", ev.text.c_str()); @@ -1383,6 +1419,8 @@ class Coordinator { std::string xiaozhi_token_; std::string system_prompt_; std::string extra_headers_; + std::string gemini_model_; + std::string gemini_voice_; agent_delegate::Delegate delegate_; conv::ConversationConfig config_{}; std::unique_ptr client_; @@ -1450,7 +1488,8 @@ void conversation_task_entry(void* arg) auto& args = *static_cast(arg); auto* coordinator = new Coordinator(*args.state, args.api_key, args.provider, args.touch, args.xiaozhi_url, args.xiaozhi_token, args.system_prompt, - args.extra_headers, args.agent_url, args.agent_token, args.seg_buf); + args.extra_headers, args.agent_url, args.agent_token, + args.gemini_model, args.gemini_voice, args.seg_buf); coordinator->run(); // run() only returns by deleting the task; keep the object alive regardless. vTaskDelete(nullptr); diff --git a/main/conversation_task.hpp b/main/conversation_task.hpp index 24695b4..b83659a 100644 --- a/main/conversation_task.hpp +++ b/main/conversation_task.hpp @@ -39,6 +39,10 @@ struct ConversationTaskArgs { // Empty url hides the tool. const char* agent_url = ""; const char* agent_token = ""; + // Gemini Live model + prebuilt voice. Empty → firmware / Kconfig default. + // Ignored by OpenAI / XiaoZhi. + const char* gemini_model = ""; + const char* gemini_voice = ""; // Internal-RAM playback ring, pre-allocated by app_main in the boot-time // low-fragmentation window. We can't allocate this lazily from the // conversation task: by the time it runs (post Wi-Fi association, ~12 s diff --git a/main/demo_loop.cpp b/main/demo_loop.cpp index 72e6e1b..75c2ec1 100644 --- a/main/demo_loop.cpp +++ b/main/demo_loop.cpp @@ -20,6 +20,7 @@ #include #include #include "config_service/config_service.hpp" +#include "config_service/product.hpp" #include "avatar/expression.hpp" #include "battery.hpp" @@ -407,11 +408,11 @@ constexpr const char* kTag = "stackchan"; g_state->face.expression.store(static_cast(avatar::Expression::Happy), std::memory_order_relaxed); g_state->set_balloon_text(rem_display, /*hold_ms=*/6000); - if (allow_full_demo) { + if (allow_full_demo && config::ui_japanese() && !rem_reading.empty()) { speech.stop(); (void)speech.say(decode_utf8(rem_reading)); next_speech_ms = std::max(next_speech_ms, now_ms + 8000); - } else { + } else if (!allow_full_demo) { // Conversation owns the speaker: let the assistant say it. g_state->post_notice(rem_display); } @@ -449,7 +450,8 @@ constexpr const char* kTag = "stackchan"; if (!wifi_ok && !wifi_warning_active) { speech.stop(); // hold_ms = UINT32_MAX so the balloon stays put until we clear it. - g_state->set_balloon_text("Wi-Fi: 切断中", /*hold_ms=*/UINT32_MAX); + g_state->set_balloon_text(config::tr("Wi-Fi: disconnected", "Wi-Fi: 切断中"), + /*hold_ms=*/UINT32_MAX); balloon_in_flight.store(false, std::memory_order_release); wifi_warning_active = true; } else if (wifi_ok && wifi_warning_active) { @@ -471,7 +473,9 @@ constexpr const char* kTag = "stackchan"; // whose display text (発話内容) the subtitle sink above shows // chunk by chunk, in step with the sound (or all at once if // nothing could be synthesised), so screen and voice always match. - (void)speech.babble(esp_random()); + if (config::ui_japanese()) { + (void)speech.babble(esp_random()); + } next_speech_ms = now_ms + rand_range_ms(kSpeechMinMs, kSpeechMaxMs); } } else { @@ -554,7 +558,7 @@ constexpr const char* kTag = "stackchan"; // subtitle sink; no phrase configured → short balloon only. if (speech.speak_proximity(esp_random()).empty()) { balloon_in_flight.store(true, std::memory_order_release); - g_state->set_balloon_text("なに?", /*hold_ms=*/1500, [] { + g_state->set_balloon_text(config::tr("Huh?", "なに?"), /*hold_ms=*/1500, [] { balloon_in_flight.store(false, std::memory_order_release); }); } @@ -643,7 +647,7 @@ constexpr const char* kTag = "stackchan"; g_state->face.expression.store(static_cast(avatar::Expression::Happy), std::memory_order_relaxed); balloon_in_flight.store(true, std::memory_order_release); - g_state->set_balloon_text("なでなで♡", /*hold_ms=*/2200, [] { + g_state->set_balloon_text(config::tr("Nuzzle ♡", "なでなで♡"), /*hold_ms=*/2200, [] { balloon_in_flight.store(false, std::memory_order_release); }); diff --git a/main/device_ui.cpp b/main/device_ui.cpp index 85e401b..7d8e22a 100644 --- a/main/device_ui.cpp +++ b/main/device_ui.cpp @@ -16,6 +16,7 @@ #include #include +#include #include "servo_limits.hpp" #include "wifi_sta.hpp" @@ -53,7 +54,19 @@ enum Page : int { kLtTimer = 6, kTabCount }; -const char* const kTabLabels[kTabCount] = {"情報", "設定1", "設定2", "操作", "範囲", "会話", "LT"}; +const char* tab_label(int i) +{ + switch (i) { + case kInfo: return config::tr("Info", "情報"); + case kSettings: return config::tr("Set 1", "設定1"); + case kSettings2: return config::tr("Set 2", "設定2"); + case kControl: return config::tr("Ctrl", "操作"); + case kRange: return config::tr("Range", "範囲"); + case kConversation: return config::tr("Chat", "会話"); + case kLtTimer: return "LT"; + } + return "?"; +} int num_tab_pages() { return (kTabCount + kTabsPerPage - 1) / kTabsPerPage; } const auto* const kFontTitle = &fonts::lgfxJapanGothic_24; @@ -285,7 +298,7 @@ void draw_topbar(int page) const TabSlot& s = b.slots[i]; if (s.index == page) g_cv->fillRect(s.x, 0, s.w, kBarH, sel); g_cv->setTextColor(fg); - g_cv->drawString(kTabLabels[s.index], s.x + s.w / 2, kBarH / 2); + g_cv->drawString(tab_label(s.index), s.x + s.w / 2, kBarH / 2); } // Close button. g_cv->fillRect(b.close_x, 0, kCloseW, kBarH, g_cv->color565(120, 60, 60)); @@ -349,15 +362,15 @@ void draw_info() // squeezing every pixel. const int dy = 18; draw_kv(y, "FW", app ? app->version : "?", fg); y += dy; - draw_kv(y, "SSID", g_ssid.empty() ? "(未設定)" : g_ssid.c_str(), fg); y += dy; + draw_kv(y, "SSID", g_ssid.empty() ? config::tr("(unset)", "(未設定)") : g_ssid.c_str(), fg); y += dy; draw_kv(y, "mDNS", g_host.c_str(), fg); y += dy; draw_kv(y, "IP", ip.c_str(), wifi ? fg : off); y += dy; - draw_kv(y, "Wi-Fi", wifi ? "接続中" : "未接続", wifi ? ok : off); y += dy; - draw_kv(y, "BLE", ble ? "接続中" : "待受中", ble ? ok : fg); y += dy; - draw_kv(y, "稼働", uptime, fg); y += dy; - draw_kv(y, "空きRAM", heap, fg); y += dy; - draw_kv(y, "電池", battery, bat_color); y += dy; - draw_kv(y, "MCP", g_has_mcp_token ? "設定済" : "未設定", + draw_kv(y, "Wi-Fi", wifi ? config::tr("up", "接続中") : config::tr("down", "未接続"), wifi ? ok : off); y += dy; + draw_kv(y, "BLE", ble ? config::tr("connected", "接続中") : config::tr("idle", "待受中"), ble ? ok : fg); y += dy; + draw_kv(y, config::tr("Up", "稼働"), uptime, fg); y += dy; + draw_kv(y, config::tr("RAM", "空きRAM"), heap, fg); y += dy; + draw_kv(y, config::tr("Batt", "電池"), battery, bat_color); y += dy; + draw_kv(y, "MCP", g_has_mcp_token ? config::tr("set", "設定済") : config::tr("unset", "未設定"), g_has_mcp_token ? ok : dim); y += dy; } @@ -385,8 +398,8 @@ const char* op_mode_label(std::uint8_t m) const char* audio_output_label(std::uint8_t m) { switch (m) { - case 0: return "自動"; - case 1: return "内蔵スピーカ"; + case 0: return config::tr("Auto", "自動"); + case 1: return config::tr("Internal speaker", "内蔵スピーカ"); case 2: return "Module Audio"; } return "?"; @@ -431,40 +444,40 @@ void draw_button(int i, const char* label, std::uint16_t color, int row_h = kRow // 設定2 — サーボ恒久 / 起動アルペジオ / 適用 (4 rows w/ caption) void draw_settings() { - draw_value_row(0, "動作モード", + draw_value_row(0, config::tr("Mode", "動作モード"), op_mode_label(g_stage_op_mode.load(std::memory_order_relaxed)), kSettingsRowH); - draw_value_row(1, "音声出力", + draw_value_row(1, config::tr("Audio out", "音声出力"), audio_output_label(g_stage_audio_output.load(std::memory_order_relaxed)), kSettingsRowH); - draw_toggle_row(2, "RTP 音声受信", g_stage_rtp.load(std::memory_order_relaxed), kSettingsRowH); - draw_toggle_row(3, "電池ゲージ", g_stage_bat_gauge.load(std::memory_order_relaxed), kSettingsRowH); + draw_toggle_row(2, config::tr("RTP audio", "RTP 音声受信"), g_stage_rtp.load(std::memory_order_relaxed), kSettingsRowH); + draw_toggle_row(3, config::tr("Batt gauge", "電池ゲージ"), g_stage_bat_gauge.load(std::memory_order_relaxed), kSettingsRowH); g_cv->setFont(kFontBody); g_cv->setTextDatum(lgfx::textdatum_t::top_left); g_cv->setTextColor(g_cv->color565(150, 150, 150)); - g_cv->drawString("→ スワイプで 設定2 / 操作 へ", + g_cv->drawString(config::tr("-> swipe to Set 2 / Ctrl", "→ スワイプで 設定2 / 操作 へ"), 12, kContentY + 4 * kSettingsRowH + 4); } void draw_settings2() { - draw_toggle_row(0, "サーボ (恒久)", g_stage_servo_master.load(std::memory_order_relaxed), + draw_toggle_row(0, config::tr("Servo (persist)", "サーボ (恒久)"), g_stage_servo_master.load(std::memory_order_relaxed), kSettingsRowH); - draw_toggle_row(1, "起動アルペジオ", g_stage_boot_arp.load(std::memory_order_relaxed), + draw_toggle_row(1, config::tr("Boot arpeggio", "起動アルペジオ"), g_stage_boot_arp.load(std::memory_order_relaxed), kSettingsRowH); - draw_button(2, "適用(保存して再起動)", g_cv->color565(60, 120, 200), kSettingsRowH); + draw_button(2, config::tr("Apply (save + reboot)", "適用(保存して再起動)"), g_cv->color565(60, 120, 200), kSettingsRowH); g_cv->setFont(kFontBody); g_cv->setTextDatum(lgfx::textdatum_t::top_left); g_cv->setTextColor(g_cv->color565(150, 150, 150)); - g_cv->drawString("設定1+2 の変更を「適用」で保存・再起動", + g_cv->drawString(config::tr("Set 1+2 changes apply on reboot", "設定1+2 の変更を「適用」で保存・再起動"), 12, kContentY + 3 * kSettingsRowH + 4); } void draw_control() { const bool servo_on = g_state->servo.enabled.load(std::memory_order_relaxed); - draw_toggle_row(0, "サーボ(脱力/復帰)", servo_on); - draw_button(1, "姿勢をリセット", g_cv->color565(60, 120, 200)); + draw_toggle_row(0, config::tr("Servo (hold/free)", "サーボ(脱力/復帰)"), servo_on); + draw_button(1, config::tr("Reset pose", "姿勢をリセット"), g_cv->color565(60, 120, 200)); // Speaker volume row: 3 hit zones — left third = -10 %, right third = // +10 %, center shows the current value and toggles mute. Live (no @@ -476,7 +489,7 @@ void draw_control() g_cv->setFont(kFontBody); g_cv->setTextDatum(lgfx::textdatum_t::middle_left); g_cv->setTextColor(g_cv->color565(245, 245, 245)); - g_cv->drawString("音量", rx + 12, ry + rh / 2); + g_cv->drawString(config::tr("Vol", "音量"), rx + 12, ry + rh / 2); // − / + visual hints on the row edges so the user knows they can tap // there to nudge the value. g_cv->setTextDatum(lgfx::textdatum_t::middle_center); @@ -489,7 +502,7 @@ void draw_control() // it back (same zone as the mute tap). if (g_state->speaker.muted.load(std::memory_order_relaxed)) { g_cv->setTextColor(g_cv->color565(230, 110, 110)); - g_cv->drawString("ミュート中", rx + rw / 2, ry + rh / 2); + g_cv->drawString(config::tr("MUTED", "ミュート中"), rx + rw / 2, ry + rh / 2); } else { char buf[16]; std::snprintf(buf, sizeof(buf), "%u %%", static_cast(pct)); @@ -503,7 +516,8 @@ void draw_control() // reflects the live state so the same row dismisses it. const bool ap_on = wifi_ap_active(); draw_button(3, - ap_on ? "AP モード(停止)" : "AP モード(Wi-Fi 再設定)", + ap_on ? config::tr("Stop AP mode", "AP モード(停止)") + : config::tr("AP mode (Wi-Fi setup)", "AP モード(Wi-Fi 再設定)"), ap_on ? g_cv->color565(200, 90, 60) : g_cv->color565(60, 120, 200)); } @@ -603,7 +617,8 @@ void draw_range() draw_range_btn(x0 + bw + gap, L.y_pitch_btns, bw, kRangeBtnRowH - 4, "P-", min_c); draw_range_btn(x0 + 2 * (bw + gap), L.y_pitch_btns, bw, kRangeBtnRowH - 4, "P+", max_c); - draw_range_btn(12, L.y_save, kW - 24, kRangeSaveH, "保存(再起動)", + draw_range_btn(12, L.y_save, kW - 24, kRangeSaveH, + config::tr("Save (reboot)", "保存(再起動)"), g_cv->color565(60, 140, 80)); } @@ -619,31 +634,33 @@ void draw_conversation() const char* status_text = "?"; std::uint16_t status_color = dim; switch (st) { - case ConvStatus::Disabled: status_text = "無効"; status_color = dim; break; - case ConvStatus::WaitingWifi: status_text = "Wi-Fi 待ち"; status_color = warn; break; - case ConvStatus::Connecting: status_text = "接続中…"; status_color = warn; break; - case ConvStatus::Listening: status_text = "接続(待受)"; status_color = ok; break; - case ConvStatus::Talking: status_text = "通話中"; status_color = ok; break; - case ConvStatus::Yielded: status_text = "音声再生中"; status_color = dim; break; - case ConvStatus::Reconnecting: status_text = "再接続中…"; status_color = warn; break; - case ConvStatus::Error: status_text = "接続エラー"; status_color = err; break; - case ConvStatus::UserSpeaking: status_text = "聞き取り中"; status_color = ok; break; - case ConvStatus::Thinking: status_text = "応答待ち"; status_color = ok; break; - case ConvStatus::ToolRunning: status_text = "ツール実行中"; status_color = ok; break; - case ConvStatus::AgentWaiting: status_text = "エージェント待ち"; status_color = ok; break; + case ConvStatus::Disabled: status_text = config::tr("Off", "無効"); status_color = dim; break; + case ConvStatus::WaitingWifi: status_text = config::tr("Waiting for Wi-Fi", "Wi-Fi 待ち"); status_color = warn; break; + case ConvStatus::Connecting: status_text = config::tr("Connecting…", "接続中…"); status_color = warn; break; + case ConvStatus::Listening: status_text = config::tr("Ready", "接続(待受)"); status_color = ok; break; + case ConvStatus::Talking: status_text = config::tr("Speaking", "通話中"); status_color = ok; break; + case ConvStatus::Yielded: status_text = config::tr("Audio out", "音声再生中"); status_color = dim; break; + case ConvStatus::Reconnecting: status_text = config::tr("Reconnecting…", "再接続中…"); status_color = warn; break; + case ConvStatus::Error: status_text = config::tr("Error", "接続エラー"); status_color = err; break; + case ConvStatus::UserSpeaking: status_text = config::tr("Listening", "聞き取り中"); status_color = ok; break; + case ConvStatus::Thinking: status_text = config::tr("Thinking", "応答待ち"); status_color = ok; break; + case ConvStatus::ToolRunning: status_text = config::tr("Running tool", "ツール実行中"); status_color = ok; break; + case ConvStatus::AgentWaiting: status_text = config::tr("Waiting on agent", "エージェント待ち"); status_color = ok; break; } const std::uint32_t reconnects = g_state->conv.reconnects.load(std::memory_order_relaxed); char rc[16]; - std::snprintf(rc, sizeof(rc), "%u 回", static_cast(reconnects)); + std::snprintf(rc, sizeof(rc), config::ui_japanese() ? "%u 回" : "%u", + static_cast(reconnects)); int y = kContentY; const int dy = 26; const char* provider_name = g_provider == 1 ? "Gemini Live" : g_provider == 2 ? "XiaoZhi" : "OpenAI Realtime"; - draw_kv(y, "サービス", provider_name, fg); y += dy; - draw_kv(y, "状態", status_text, status_color); y += dy; - draw_kv(y, "再接続", rc, reconnects > 0 ? warn : fg); y += dy; + if (config::gemini_only()) provider_name = "Gemini Live"; + draw_kv(y, config::tr("Service", "サービス"), provider_name, fg); y += dy; + draw_kv(y, config::tr("Status", "状態"), status_text, status_color); y += dy; + draw_kv(y, config::tr("Retries", "再接続"), rc, reconnects > 0 ? warn : fg); y += dy; } // --- LT (lightning talk) timekeeper page ---------------------------------- @@ -654,7 +671,8 @@ void draw_conversation() // a talk can't change the deadline. constexpr std::uint16_t kLtPresetsS[] = {180, 300, 600}; -constexpr const char* kLtPresetLabels[] = {"3分", "5分", "10分"}; +constexpr const char* kLtPresetLabelsJa[] = {"3分", "5分", "10分"}; +constexpr const char* kLtPresetLabelsEn[] = {"3 min", "5 min", "10 min"}; constexpr int kLtPresetY = 150; constexpr int kLtPresetH = 36; constexpr int kLtStartY = 196; @@ -673,7 +691,9 @@ void draw_lt_timer() const std::uint16_t err = g_cv->color565(235, 100, 100); // Status line. - const char* status = !active ? "待機中" : (remaining < 0 ? "時間超過!" : "計測中"); + const char* status = !active ? config::tr("Idle", "待機中") + : (remaining < 0 ? config::tr("Overtime!", "時間超過!") + : config::tr("Running", "計測中")); const std::uint16_t status_color = !active ? dim : (remaining < 0 ? err : ok); g_cv->setFont(kFontBody); g_cv->setTextDatum(lgfx::textdatum_t::top_center); @@ -708,7 +728,8 @@ void draw_lt_timer() : g_cv->color565(50, 56, 66)); g_cv->fillRoundRect(bx, kLtPresetY, bw, kLtPresetH, 6, bg); g_cv->setTextColor(active ? dim : fg); - g_cv->drawString(kLtPresetLabels[i], bx + bw / 2, kLtPresetY + kLtPresetH / 2); + g_cv->drawString((config::ui_japanese() ? kLtPresetLabelsJa : kLtPresetLabelsEn)[i], + bx + bw / 2, kLtPresetY + kLtPresetH / 2); } // Start / stop. @@ -716,7 +737,8 @@ void draw_lt_timer() : g_cv->color565(60, 160, 90); g_cv->fillRoundRect(12, kLtStartY, kW - 24, kLtStartH, 6, btn_color); g_cv->setTextColor(g_cv->color565(245, 245, 245)); - g_cv->drawString(active ? "ストップ" : "スタート", kW / 2, kLtStartY + kLtStartH / 2); + g_cv->drawString(active ? config::tr("Stop", "ストップ") : config::tr("Start", "スタート"), + kW / 2, kLtStartY + kLtStartH / 2); } void render_page() diff --git a/main/lt_timer.cpp b/main/lt_timer.cpp index 0be1e44..f27712a 100644 --- a/main/lt_timer.cpp +++ b/main/lt_timer.cpp @@ -6,6 +6,7 @@ #include #include +#include #include "shared_state.hpp" #include "speech.hpp" #include "utf8.hpp" @@ -19,6 +20,17 @@ constexpr std::uint32_t kBalloonHoldMs = 4000; void LtTimer::configure(const std::string& json, SharedState* state) { + if (!config::ui_japanese() && json.empty()) { + // English stock phrases when NVS has no custom LT config. Voice + // readings stay empty so announce() skips Japanese-only TTS. + cfg_.warn_display = "1 minute left"; + cfg_.warn_reading.clear(); + cfg_.soon_display = "15 seconds left"; + cfg_.soon_reading.clear(); + cfg_.over_display = "Time's up!"; + cfg_.over_reading.clear(); + return; + } if (json.empty()) return; cJSON* root = cJSON_Parse(json.c_str()); if (root == nullptr) { @@ -80,6 +92,11 @@ void LtTimer::announce(SharedState& state, Speech& speech, state.post_notice(display); return; } + // Local Japanese formant TTS cannot speak English. Balloon-only in + // English mode, or when the reading was left empty. + if (!config::ui_japanese() || reading.empty()) { + return; + } if (!speech.say(decode_utf8(reading))) { ESP_LOGW(kTag, "announcement synthesis failed: %s", reading.c_str()); } diff --git a/main/render_task.cpp b/main/render_task.cpp index 16428b9..fd52dd8 100644 --- a/main/render_task.cpp +++ b/main/render_task.cpp @@ -3,6 +3,7 @@ #include #include "render_task.hpp" +#include #include #include @@ -106,17 +107,17 @@ void draw_conv_status(RichCanvas& canvas, ConvStatus st, int canvas_w, int right const std::uint16_t err = canvas.color565(230, 110, 110); switch (st) { case ConvStatus::Disabled: return; - case ConvStatus::WaitingWifi: text = "Wi-Fi待ち"; dot = busy; break; - case ConvStatus::Connecting: text = "接続中"; dot = busy; break; - case ConvStatus::Listening: text = "待受中"; dot = ok; break; - case ConvStatus::UserSpeaking: text = "聞き取り中"; dot = live; break; - case ConvStatus::Thinking: text = "応答待ち"; dot = busy; break; - case ConvStatus::ToolRunning: text = "ツール実行中"; dot = busy; break; - case ConvStatus::AgentWaiting: text = "エージェント待ち"; dot = busy; break; - case ConvStatus::Talking: text = "応答中"; dot = live; break; - case ConvStatus::Yielded: text = "音声再生中"; dot = canvas.color565(150, 150, 150); break; - case ConvStatus::Reconnecting: text = "再接続中"; dot = busy; break; - case ConvStatus::Error: text = "接続エラー"; dot = err; break; + case ConvStatus::WaitingWifi: text = config::tr("Wi-Fi…", "Wi-Fi待ち"); dot = busy; break; + case ConvStatus::Connecting: text = config::tr("Connecting", "接続中"); dot = busy; break; + case ConvStatus::Listening: text = config::tr("Ready", "待受中"); dot = ok; break; + case ConvStatus::UserSpeaking: text = config::tr("Listening", "聞き取り中"); dot = live; break; + case ConvStatus::Thinking: text = config::tr("Thinking", "応答待ち"); dot = busy; break; + case ConvStatus::ToolRunning: text = config::tr("Tool", "ツール実行中"); dot = busy; break; + case ConvStatus::AgentWaiting: text = config::tr("Agent…", "エージェント待ち"); dot = busy; break; + case ConvStatus::Talking: text = config::tr("Speaking", "応答中"); dot = live; break; + case ConvStatus::Yielded: text = config::tr("Audio", "音声再生中"); dot = canvas.color565(150, 150, 150); break; + case ConvStatus::Reconnecting: text = config::tr("Reconnect", "再接続中"); dot = busy; break; + case ConvStatus::Error: text = config::tr("Error", "接続エラー"); dot = err; break; } if (text == nullptr) return; diff --git a/recovery/main/http.cpp b/recovery/main/http.cpp index 1302ceb..030e549 100644 --- a/recovery/main/http.cpp +++ b/recovery/main/http.cpp @@ -175,6 +175,7 @@ esp_err_t handle_ota_release_post(httpd_req_t* req) case StartError::BadTag: msg = "bad tag"; break; case StartError::UnknownBoard: msg = "unknown board"; break; case StartError::WorkerSpawnFailed: msg = "spawn failed"; break; + case StartError::NoCatalog: msg = "update catalog unset"; break; } return send_error(req, "400 Bad Request", msg); } diff --git a/recovery/sdkconfig.defaults b/recovery/sdkconfig.defaults index 02c0c50..319e6e0 100644 --- a/recovery/sdkconfig.defaults +++ b/recovery/sdkconfig.defaults @@ -65,6 +65,10 @@ CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN=y CONFIG_MBEDTLS_HKDF_C=y CONFIG_MBEDTLS_HARDWARE_AES=y +# Same catalog the main firmware uses so Recovery cannot fetch upstream +# ciniml images onto a Gemini customer device. +CONFIG_STACKCHAN_UPDATE_BASE_URL="https://m5stack.github.io/stackchan-idf-gemini" + # --- HTTP server ------------------------------------------------------------- # Chrome のヘッダは 1 KiB を超える (Main と同じ)。 CONFIG_HTTPD_MAX_REQ_HDR_LEN=4096 diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 3f57dbe..e76f8a5 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -243,3 +243,16 @@ CONFIG_ESP_COREDUMP_DATA_FORMAT_ELF=y CONFIG_ESP_COREDUMP_CHECKSUM_CRC32=y CONFIG_ESP_COREDUMP_CHECK_BOOT=n CONFIG_ESP_COREDUMP_MAX_TASKS_NUM=40 + +# --- Gemini customer build (Google) --------------------------------------- +# Additive product profile: lock the runtime provider to Gemini Live, show +# a Google Cloud boot splash, and point in-app OTA / voice-fetch at this +# fork's GitHub Pages instead of upstream ciniml/stackchan-idf. Kconfig +# defaults stay off / upstream so a tree without these lines (or with +# them commented) builds the original multi-provider firmware. +CONFIG_STACKCHAN_GEMINI_ONLY=y +CONFIG_STACKCHAN_BOOT_SPLASH=y +CONFIG_STACKCHAN_BOOT_SPLASH_MS=2000 +CONFIG_STACKCHAN_GEMINI_DEFAULT_MODEL="gemini-3.8-live" +CONFIG_STACKCHAN_GEMINI_DEFAULT_VOICE="Aoede" +CONFIG_STACKCHAN_UPDATE_BASE_URL="https://m5stack.github.io/stackchan-idf-gemini" diff --git a/tools/avatar_dsl/inject.mjs b/tools/avatar_dsl/inject.mjs index 1ee7cdb..8b22264 100644 --- a/tools/avatar_dsl/inject.mjs +++ b/tools/avatar_dsl/inject.mjs @@ -90,6 +90,12 @@ html = html.replace(/\/\*\{\{SETTINGS_COMMON\}\}\*\//g, () => { return readFileSync(resolve(SCRIPT_DIR, '..', 'settings_common.js'), 'utf8'); }); +let geminiHits = 0; +html = html.replace(/\/\*\{\{GEMINI_UI\}\}\*\//g, () => { + ++geminiHits; + return readFileSync(resolve(SCRIPT_DIR, '..', 'gemini_ui.js'), 'utf8'); +}); + if (bundleHits === 0) { process.stderr.write(`warning: ${templatePath}: no '/*{{AVATAR_DSL_BUNDLE}}*/' placeholder found\n`); } @@ -102,4 +108,5 @@ process.stdout.write( `[avatar_dsl] injected bundle (${bundle.length}B) + ${presets.length} preset(s) ` + `(${presets.map((p) => p.name).join(', ')})` + (commonHits > 0 ? ' + settings_common.js' : '') + + (geminiHits > 0 ? ' + gemini_ui.js' : '') + ` into ${templatePath} -> ${outPath}\n`); diff --git a/tools/gemini_ui.js b/tools/gemini_ui.js new file mode 100644 index 0000000..29a5e8c --- /dev/null +++ b/tools/gemini_ui.js @@ -0,0 +1,373 @@ +// SPDX-FileCopyrightText: 2026 Kenta IDA +// SPDX-License-Identifier: BSL-1.0 +// +// Gemini customer-build overlay for the BLE / Wi-Fi settings pages. +// Loaded as a sibling on Pages (settings.html) and inlined into the +// firmware's settings_wifi.html. Keeps the Japanese HTML source intact +// (small upstream-merge surface) and rewrites the visible UI to English, +// hiding OpenAI / XiaoZhi when the firmware reports gemini_only. + +(function () { + 'use strict'; + + const MODELS = [ + { id: 'gemini-3.8-live', label: 'gemini-3.8-live (recommended, GA)' }, + { id: 'gemini-2.5-flash-native-audio-preview-12-2025', + label: 'gemini-2.5-flash-native-audio-preview-12-2025 (legacy; shutdown 2026-11-17)' }, + ]; + const VOICES = ['Aoede', 'Puck', 'Charon', 'Kore', 'Fenrir', 'Leda', 'Orus', 'Zephyr']; + + const STR = { + '🤖 Stack-chan 設定 (Wi-Fi)': '🤖 Stack-chan settings (Wi-Fi)', + '🤖 Stack-chan 設定': '🤖 Stack-chan settings', + 'HTTP 経由で Wi-Fi と API キーを設定します': 'Configure Wi-Fi and the Gemini API key over HTTP', + 'Web Bluetooth 経由で Wi-Fi と API キーを設定します': 'Configure Wi-Fi and the Gemini API key over Web Bluetooth', + '接続': 'Connect', + 'アバター': 'Avatar', + '音声': 'Audio', + '会話': 'Chat', + 'デバイス': 'Device', + 'メンテ': 'Maint', + '状態を再取得': 'Refresh', + '保存 & 再起動': 'Save & reboot', + '保存 & 再起動': 'Save & reboot', + 'BLE 接続': 'Connect BLE', + '切断': 'Disconnect', + '未接続': 'Disconnected', + '状態取得中…': 'Loading…', + '状態取得 OK': 'Status OK', + '状態取得失敗': 'Status failed', + '接続失敗': 'Connection failed', + 'HTTP 接続中': 'HTTP connected', + '基板': 'Board', + 'バッテリー': 'Battery', + '動作モード': 'Operation mode', + '会話プロバイダ・API キー': 'Gemini Live', + '会話プロバイダ': 'Conversation provider', + 'OpenAI API キー': 'OpenAI API key', + 'Gemini API キー': 'Gemini API key', + 'XiaoZhi サーバー URL': 'XiaoZhi server URL', + 'XiaoZhi トークン': 'XiaoZhi token', + 'システムプロンプト (会話の人格)': 'System prompt (persona)', + '追加 HTTP ヘッダ (会話 API)': 'Extra HTTP headers (conversation API)', + 'エージェント サーバー URL (ask_agent)': 'Agent server URL (ask_agent)', + 'エージェント サーバー トークン': 'Agent server token', + 'タッチ操作': 'Touch', + 'ファームウェア更新 (OTA)': 'Firmware update (OTA)', + 'release を適用': 'Apply release', + '書き込み': 'Write', + 'ファイル選択': 'Choose file', + 'ランダム生成': 'Generate', + '表示': 'Show', + 'クリア': 'Clear', + 'キャンセル': 'Cancel', + '接続パスワード': 'Connection password', + 'パスワード': 'Password', + 'パスワードを表示': 'Show password', + 'デバイス': 'Device', + '設定済': 'set', + '未設定': 'unset', + 'スタート': 'Start', + 'ストップ': 'Stop', + '適用': 'Apply', + 'OpenAI Realtime': 'OpenAI Realtime', + 'Google Gemini Live': 'Google Gemini Live', + 'XiaoZhi サーバー': 'XiaoZhi server', + '会話応答 (Conversation)': 'Conversation', + 'JTTS ランダム発話 (JttsRandom)': 'JTTS random speech', + 'マイクでリップシンク (MicLipSync)': 'Mic lip sync', + 'ローカル音声認識 (AsrLocal)': 'On-device ASR', + 'ESP-NOWリモコン (EspNowRemote)': 'ESP-NOW remote', + 'ESP-NOW姿勢配信 (EspNowSender)': 'ESP-NOW pose send', + '空欄のままにすると現在の値を変更しません': 'Leave blank to keep the current value', + '設定済 — 変更しない場合は空欄': 'set — leave blank to keep', + '未設定 — 空欄なら認証なし': 'unset — blank means no auth', + '(設定済み / 変更しない場合は空欄)': '(set / leave blank to keep)', + '(未設定)': '(unset)', + '(変更しない場合は空欄)': '(leave blank to keep)', + 'AIza… (変更しない場合は空欄)': 'AIza… (leave blank to keep)', + 'sk-… (変更しない場合は空欄)': 'sk-… (leave blank to keep)', + '空欄にすると組み込みの既定プロンプトを使用します': 'Leave blank to use the built-in persona', + 'SSID 設定済み': 'SSID set', + 'Password 設定済み': 'Password set', + 'OpenAI key 設定済み': 'OpenAI key set', + 'OpenAI 有効': 'OpenAI on', + 'Gemini key 設定済み': 'Gemini key set', + 'Provider = Gemini': 'Provider = Gemini', + 'Wi-Fi: 接続中': 'Wi-Fi: up', + 'Wi-Fi 設定 (mDNS)': 'Wi-Fi settings (mDNS)', + 'Wi-Fi 設定 (IP)': 'Wi-Fi settings (IP)', + 'BLE 接続パスワード': 'BLE connection password', + 'このデバイスは認証パスワードが設定されています。': 'This device has an auth password set.', + 'ライセンス / 帰属表示': 'Licenses / attribution', + '最新の release をローカル ファイルで書き込む手順 (iPhone / AP モードでも可)': + 'How to flash a release ZIP as a local file (works in iPhone / AP mode)', + }; + + function tr(s) { + if (s == null) return s; + if (Object.prototype.hasOwnProperty.call(STR, s)) return STR[s]; + const t = String(s).trim(); + if (Object.prototype.hasOwnProperty.call(STR, t)) { + const lead = s.match(/^\s*/)[0]; + const trail = s.match(/\s*$/)[0]; + return lead + STR[t] + trail; + } + // Partial replacements used in log lines / mixed HTML. + let out = s; + const parts = [ + ['状態取得 OK', 'Status OK'], + ['状態取得失敗', 'Status failed'], + ['SSID 送信', 'SSID sent'], + ['パスワード送信', 'Password sent'], + ['OpenAI キー送信', 'OpenAI key sent'], + ['Gemini キー送信', 'Gemini key sent'], + ['プロバイダ', 'Provider'], + ['書き込み完了', 'write complete'], + ['保存中…', 'Saving…'], + ['近接の閾値を適用', 'Applied proximity thresholds'], + ['会話ステータスの LED 設定を適用', 'Applied conversation LED settings'], + ['release tag', 'release tag'], + ['件取得', ' fetched'], + ['取得失敗', 'fetch failed'], + ]; + for (const [ja, en] of parts) { + if (out.indexOf(ja) >= 0) out = out.split(ja).join(en); + } + return out; + } + + function walk(node, fn) { + if (!node) return; + fn(node); + for (const c of Array.from(node.childNodes || [])) walk(c, fn); + } + + function translateTree(root) { + walk(root || document.body, (n) => { + if (n.nodeType === 3) { + const next = tr(n.nodeValue); + if (next !== n.nodeValue) n.nodeValue = next; + } else if (n.nodeType === 1) { + for (const attr of ['placeholder', 'title', 'aria-label']) { + if (n.hasAttribute && n.hasAttribute(attr)) { + const v = n.getAttribute(attr); + const nv = tr(v); + if (nv !== v) n.setAttribute(attr, nv); + } + } + } + }); + document.documentElement.lang = 'en'; + if (document.title && document.title.indexOf('設定') >= 0) { + document.title = document.title.replace('設定', 'settings'); + } + } + + function setVisible(selector, visible) { + document.querySelectorAll(selector).forEach((el) => { + el.style.display = visible ? '' : 'none'; + }); + } + + function hideNonGemini() { + setVisible('label[for="provider"], #provider', false); + setVisible('#provider + p.hint', false); + setVisible('label[for="apikey"], #apikey', false); + setVisible('label[for="xiaozhi-url"], #xiaozhi-url', false); + setVisible('label[for="xiaozhi-token"], #xiaozhi-token', false); + const sel = document.getElementById('provider'); + if (sel) { + sel.value = '1'; + for (const opt of sel.options) { + opt.hidden = opt.value !== '1'; + } + } + const fk = document.getElementById('flag-key'); + const fe = document.getElementById('flag-en'); + if (fk) fk.style.display = 'none'; + if (fe) fe.style.display = 'none'; + const title = document.querySelector('#chat-section h2.section-title'); + if (title) title.textContent = 'Gemini Live'; + } + + function ensureSelect(el, items, current) { + if (!el) return; + const wanted = current || el.value || ''; + const have = new Set(Array.from(el.options).map((o) => o.value)); + for (const it of items) { + const id = typeof it === 'string' ? it : it.id; + const label = typeof it === 'string' ? it : it.label; + if (!have.has(id)) { + const opt = document.createElement('option'); + opt.value = id; + opt.textContent = label; + el.appendChild(opt); + have.add(id); + } + } + if (wanted && !have.has(wanted)) { + const opt = document.createElement('option'); + opt.value = wanted; + opt.textContent = wanted; + el.appendChild(opt); + } + if (wanted) el.value = wanted; + } + + function ensureFields() { + const key = document.getElementById('gemini-apikey'); + if (!key) return; + if (!document.getElementById('gemini-model')) { + const lab = document.createElement('label'); + lab.htmlFor = 'gemini-model'; + lab.textContent = 'Gemini model'; + const sel = document.createElement('select'); + sel.id = 'gemini-model'; + const hint = document.createElement('p'); + hint.className = 'hint'; + hint.textContent = + 'Native-audio Live model. gemini-3.8-live is the current GA default; the 2.5 preview shuts down 2026-11-17.'; + key.insertAdjacentElement('afterend', lab); + lab.insertAdjacentElement('afterend', sel); + sel.insertAdjacentElement('afterend', hint); + } + if (!document.getElementById('gemini-voice')) { + const after = document.getElementById('gemini-model') || key; + const lab = document.createElement('label'); + lab.htmlFor = 'gemini-voice'; + lab.textContent = 'Gemini voice'; + const sel = document.createElement('select'); + sel.id = 'gemini-voice'; + const hint = document.createElement('p'); + hint.className = 'hint'; + hint.textContent = 'Prebuilt Gemini Live voice. Default is Aoede.'; + const anchor = after.nextElementSibling && after.nextElementSibling.classList && + after.nextElementSibling.classList.contains('hint') + ? after.nextElementSibling + : after; + anchor.insertAdjacentElement('afterend', lab); + lab.insertAdjacentElement('afterend', sel); + sel.insertAdjacentElement('afterend', hint); + } + ensureSelect(document.getElementById('gemini-model'), MODELS); + ensureSelect(document.getElementById('gemini-voice'), VOICES, 'Aoede'); + } + + function hideReleaseIfUnset(updateCatalog) { + const btn = document.getElementById('btn-ota-release'); + const sel = document.getElementById('ota-release-select'); + const ch = document.getElementById('ota-release-channel'); + const tag = document.getElementById('ota-release-tag'); + if (updateCatalog === false) { + [btn, sel, ch, tag].forEach((el) => { + if (!el) return; + el.disabled = true; + }); + if (btn) btn.title = 'In-app update catalog is not configured for this build'; + } + } + + function rewritePagesLinks() { + document.querySelectorAll('a[href*="ciniml.github.io/stackchan-idf"]').forEach((a) => { + a.href = a.href.replace('ciniml.github.io/stackchan-idf', 'm5stack.github.io/stackchan-idf-gemini'); + if (a.textContent && a.textContent.indexOf('ciniml.github.io') >= 0) { + a.textContent = a.textContent.replace('ciniml.github.io/stackchan-idf', 'm5stack.github.io/stackchan-idf-gemini'); + } + }); + document.querySelectorAll('a[href*="github.com/ciniml/stackchan-idf"]').forEach((a) => { + a.href = a.href.replace('github.com/ciniml/stackchan-idf', 'github.com/m5stack/stackchan-idf-gemini'); + }); + } + + function applyGeminiOnlyUi(opts) { + const o = opts || {}; + const hide = o.gemini_only !== false; // product default: hide + ensureFields(); + if (hide) hideNonGemini(); + if (o.gemini_model) ensureSelect(document.getElementById('gemini-model'), MODELS, o.gemini_model); + if (o.gemini_voice) ensureSelect(document.getElementById('gemini-voice'), VOICES, o.gemini_voice); + if ('update_catalog' in o) hideReleaseIfUnset(!!o.update_catalog); + translateTree(document.body); + rewritePagesLinks(); + } + + async function afterWifiRefresh() { + try { + const r = await fetch('/api/status', { cache: 'no-store' }); + if (!r.ok) return; + const s = await r.json(); + applyGeminiOnlyUi({ + gemini_only: !!s.gemini_only, + gemini_model: s.gemini_model, + gemini_voice: s.gemini_voice, + update_catalog: s.update_catalog, + }); + } catch (_) { /* keep the English overlay even if status fails */ } + } + + function wrapWifi() { + if (typeof refresh === 'function') { + const orig = refresh; + window.refresh = async function () { + await orig.apply(this, arguments); + await afterWifiRefresh(); + }; + } + if (typeof apply === 'function') { + const orig = apply; + window.apply = async function () { + const model = document.getElementById('gemini-model'); + const voice = document.getElementById('gemini-voice'); + try { + if (model && model.value && typeof postText === 'function') { + await postText('/api/gemini-model', model.value); + } + if (voice && voice.value && typeof postText === 'function') { + await postText('/api/gemini-voice', voice.value); + } + } catch (e) { + if (typeof log === 'function') log('Gemini model/voice: ' + e.message, 'err'); + } + return orig.apply(this, arguments); + }; + } + if (typeof log === 'function') { + const origLog = log; + window.log = function (msg, cls) { + return origLog(tr(String(msg)), cls); + }; + } + } + + const api = { + applyGeminiOnlyUi, + afterWifiRefresh, + hideNonGemini, + ensureFields, + translateTree, + tr, + MODELS, + VOICES, + CHR_GEMINI_MODEL: 'e3f0a032-7b1c-4d2a-9e6f-2c5a8d4b1f00', + CHR_GEMINI_VOICE: 'e3f0a033-7b1c-4d2a-9e6f-2c5a8d4b1f00', + }; + window.StackchanGeminiUi = api; + + function boot() { + ensureFields(); + applyGeminiOnlyUi({ gemini_only: true }); + wrapWifi(); + // First Wi-Fi status fill happens at end of the page script; wrapWifi + // covers later refreshes. Kick one extra pass in case refresh already ran. + if (typeof fetch === 'function' && location.protocol !== 'file:') { + afterWifiRefresh(); + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot); + } else { + boot(); + } +})(); diff --git a/tools/gen_google_cloud_splash.py b/tools/gen_google_cloud_splash.py new file mode 100644 index 0000000..1375c41 --- /dev/null +++ b/tools/gen_google_cloud_splash.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 Kenta IDA +# SPDX-License-Identifier: BSL-1.0 +"""Generate per-board Google Cloud boot-splash PNGs from Wikimedia artwork. + +Source (CC BY 4.0): + https://commons.wikimedia.org/wiki/File:Google_Cloud_logo.svg + https://upload.wikimedia.org/wikipedia/commons/5/51/Google_Cloud_logo.svg + +Renders a white canvas at each board's native resolution with the logo +centred. StopWatch (466x466 round) keeps the artwork inside the inscribed +circle so the square panel corners don't clip it. +""" +from __future__ import annotations + +import io +import pathlib +import sys +import urllib.request + +REPO = pathlib.Path(__file__).resolve().parent.parent +OUT = REPO / "assets" / "splash" +WIKI = "https://commons.wikimedia.org/wiki/File:Google_Cloud_logo.svg" +SVG_URL = "https://upload.wikimedia.org/wikipedia/commons/5/51/Google_Cloud_logo.svg" +PNG_FALLBACK = ( + "https://upload.wikimedia.org/wikipedia/commons/thumb/5/51/" + "Google_Cloud_logo.svg/1280px-Google_Cloud_logo.svg.png" +) + +# Board canvases: (filename, width, height, max_logo_w, max_logo_h) +# StopWatch inscribed-square side is 466/sqrt(2) ≈ 329; keep a margin. +TARGETS = [ + ("splash_cores3.png", 320, 240, 280, 120), + ("splash_atom.png", 128, 128, 112, 56), + ("splash_stopwatch.png", 466, 466, 300, 140), +] + + +def fetch(url: str) -> bytes: + req = urllib.request.Request(url, headers={"User-Agent": "stackchan-idf-gemini-splash/1.0"}) + with urllib.request.urlopen(req, timeout=60) as r: + return r.read() + + +def load_logo(): + from PIL import Image + + # Prefer a rasterized PNG (no cairo required). Fall back to the SVG only + # if a renderer is present. + try: + data = fetch(PNG_FALLBACK) + im = Image.open(io.BytesIO(data)).convert("RGBA") + print(f"fetched PNG fallback {im.size} from {PNG_FALLBACK}", file=sys.stderr) + return im + except Exception as e: + print(f"PNG fallback failed: {e}", file=sys.stderr) + + svg = fetch(SVG_URL) + print(f"fetched SVG {len(svg)} B from {SVG_URL}", file=sys.stderr) + try: + import cairosvg # type: ignore + + png = cairosvg.svg2png(bytestring=svg, output_width=1280) + return Image.open(io.BytesIO(png)).convert("RGBA") + except Exception as e: + print(f"cairosvg unavailable ({e}); cannot rasterize SVG", file=sys.stderr) + raise SystemExit(1) from e + + +def fit(im, max_w: int, max_h: int): + from PIL import Image + + w, h = im.size + scale = min(max_w / w, max_h / h) + nw, nh = max(1, int(w * scale)), max(1, int(h * scale)) + return im.resize((nw, nh), Image.Resampling.LANCZOS) + + +def main() -> int: + from PIL import Image + + OUT.mkdir(parents=True, exist_ok=True) + logo = load_logo() + # Drop near-white background so compositing onto our white canvas is clean. + # The Wikimedia PNG is already transparent around the wordmark. + + source_note = OUT / "SOURCE.txt" + source_note.write_text( + "Google Cloud logo (full color wordmark)\n" + f"Page: {WIKI}\n" + f"File: {SVG_URL}\n" + f"Raster used: {PNG_FALLBACK}\n" + "License: Creative Commons Attribution 4.0 International (CC BY 4.0)\n" + "Customer (Google) approved use of this trademark artwork in firmware.\n", + encoding="utf-8", + ) + + for name, cw, ch, max_w, max_h in TARGETS: + canvas = Image.new("RGBA", (cw, ch), (255, 255, 255, 255)) + mark = fit(logo, max_w, max_h) + x = (cw - mark.size[0]) // 2 + y = (ch - mark.size[1]) // 2 + canvas.alpha_composite(mark, (x, y)) + out = canvas.convert("RGB") + path = OUT / name + out.save(path, format="PNG", optimize=True) + print(f"wrote {path} {out.size} {path.stat().st_size} B", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except ImportError: + print("Pillow is required: pip install pillow", file=sys.stderr) + raise SystemExit(1) diff --git a/tools/settings.html b/tools/settings.html index 77fc604..8f83b08 100644 --- a/tools/settings.html +++ b/tools/settings.html @@ -938,6 +938,25 @@

会話プロバイダ・API キー

+ + +

Native-audio Live model. gemini-3.8-live is current GA; the 2.5 preview shuts down 2026-11-17.

+ + +

Prebuilt Gemini Live voice. Default is Aoede.

+ @@ -1283,6 +1302,8 @@

ファームウェア更新 (OTA)

const CHR_OTA_DATA = 'e3f0a00a-7b1c-4d2a-9e6f-2c5a8d4b1f00'; const CHR_PROVIDER = 'e3f0a00b-7b1c-4d2a-9e6f-2c5a8d4b1f00'; const CHR_GEMINI_KEY = 'e3f0a00c-7b1c-4d2a-9e6f-2c5a8d4b1f00'; +const CHR_GEMINI_MODEL = 'e3f0a032-7b1c-4d2a-9e6f-2c5a8d4b1f00'; +const CHR_GEMINI_VOICE = 'e3f0a033-7b1c-4d2a-9e6f-2c5a8d4b1f00'; const CHR_XIAOZHI_URL = 'e3f0a013-7b1c-4d2a-9e6f-2c5a8d4b1f00'; const CHR_XIAOZHI_TOKEN = 'e3f0a014-7b1c-4d2a-9e6f-2c5a8d4b1f00'; // FaceConfig — encrypted compact JSON of the avatar face tuning. WRITE applies @@ -1323,7 +1344,7 @@

ファームウェア更新 (OTA)

// --- State --- let bleDevice = null, bleServer = null, svc = null; -let chrSsid = null, chrPass = null, chrKey = null, chrApply = null, chrStatus = null, chrKx = null, chrEn = null, chrRtpEn = null, chrJttsIdleEn = null, chrJtts = null, chrOtaCtrl = null, chrOtaData = null, chrProvider = null, chrGeminiKey = null, chrXiaozhiUrl = null, chrXiaozhiToken = null, chrFaceConfig = null, chrBattery = null, chrBatGauge = null, chrStartupArp = null, chrServoEnabled = null, chrMcpToken = null, chrLtConfig = null, chrServoLimits = null, chrServoRangeMode = null, chrServoPositions = null, chrBoardKind = null, chrWifiIp = null, chrWifiMac = null, chrAudioCtrl = null, chrAudioData = null, chrAudioCredit = null, chrAudioMetrics = null, chrLedState = null, chrMicLipGain = null, chrLedMouthSync = null, chrLipSyncMode = null, chrMicLipAgc = null, chrSpeakerVolume = null, chrJttsSay = null, chrSanoTts = null, chrOperationMode = null, chrAudioOutput = null, chrBargeIn = null, chrProximity = null, chrProximityTuning = null, chrDeviceName = null, chrAuthPassword = null, chrAvatarBc = null; +let chrSsid = null, chrPass = null, chrKey = null, chrApply = null, chrStatus = null, chrKx = null, chrEn = null, chrRtpEn = null, chrJttsIdleEn = null, chrJtts = null, chrOtaCtrl = null, chrOtaData = null, chrProvider = null, chrGeminiKey = null, chrGeminiModel = null, chrGeminiVoice = null, chrXiaozhiUrl = null, chrXiaozhiToken = null, chrFaceConfig = null, chrBattery = null, chrBatGauge = null, chrStartupArp = null, chrServoEnabled = null, chrMcpToken = null, chrLtConfig = null, chrServoLimits = null, chrServoRangeMode = null, chrServoPositions = null, chrBoardKind = null, chrWifiIp = null, chrWifiMac = null, chrAudioCtrl = null, chrAudioData = null, chrAudioCredit = null, chrAudioMetrics = null, chrLedState = null, chrMicLipGain = null, chrLedMouthSync = null, chrLipSyncMode = null, chrMicLipAgc = null, chrSpeakerVolume = null, chrJttsSay = null, chrSanoTts = null, chrOperationMode = null, chrAudioOutput = null, chrBargeIn = null, chrProximity = null, chrProximityTuning = null, chrDeviceName = null, chrAuthPassword = null, chrAvatarBc = null; let boardKind = null; // null = unknown (older firmware), else 0/1/2 from BOARD_* let batteryPollTimer = null; // setInterval handle while connected let jttsInitialJson = ''; // last read JSON, used to detect Apply-time changes @@ -1412,9 +1433,11 @@

ファームウェア更新 (OTA)

} let deviceWifiConnected = false; +let lastStatusFlags = 0; function updateStatus(data) { const flags = data.getUint8(0); const wifi = data.getUint8(1); + lastStatusFlags = flags; deviceWifiConnected = !!wifi; document.getElementById('flag-ssid').classList.toggle('set', !!(flags & 0x01)); document.getElementById('flag-pass').classList.toggle('set', !!(flags & 0x02)); @@ -1905,6 +1928,7 @@

ファームウェア更新 (OTA)

const hasOpenAiKey = !!chrKey; const hasGemini = !!(chrProvider && chrGeminiKey); const hasXiaozhi = !!(chrProvider && chrXiaozhiUrl && chrXiaozhiToken); + const geminiOnly = !!(typeof lastStatusFlags === 'number' && (lastStatusFlags & 0x40)); const hasEnable = !!chrEn; const hasRtpEnable = !!chrRtpEn; const hasBatGauge = !!chrBatGauge; @@ -1920,15 +1944,21 @@

ファームウェア更新 (OTA)

el.style.display = visible ? '' : 'none'; } }; - setVisible('label[for="provider"], #provider', hasGemini); - setVisible('#provider + p.hint', hasGemini); + setVisible('label[for="provider"], #provider', hasGemini && !geminiOnly); + setVisible('#provider + p.hint', hasGemini && !geminiOnly); setVisible('label[for="gemini-apikey"], #gemini-apikey', hasGemini); - setVisible('label[for="xiaozhi-url"], #xiaozhi-url', hasXiaozhi); - setVisible('label[for="xiaozhi-token"], #xiaozhi-token', hasXiaozhi); + setVisible('label[for="gemini-model"], #gemini-model', hasGemini); + setVisible('#gemini-model + p.hint', hasGemini); + setVisible('label[for="gemini-voice"], #gemini-voice', hasGemini); + setVisible('#gemini-voice + p.hint', hasGemini); + setVisible('label[for="xiaozhi-url"], #xiaozhi-url', hasXiaozhi && !geminiOnly); + setVisible('label[for="xiaozhi-token"], #xiaozhi-token', hasXiaozhi && !geminiOnly); // Hide the XiaoZhi provider choice entirely on firmware that lacks its chrs. const xzOption = document.querySelector('#provider option[value="2"]'); - if (xzOption) xzOption.hidden = !hasXiaozhi; - setVisible('label[for="apikey"], #apikey', hasOpenAiKey); + if (xzOption) xzOption.hidden = !hasXiaozhi || geminiOnly; + const oaOption = document.querySelector('#provider option[value="0"]'); + if (oaOption) oaOption.hidden = geminiOnly; + setVisible('label[for="apikey"], #apikey', hasOpenAiKey && !geminiOnly); setVisible('label.toggle-label[for="openai-enabled"]', hasEnable); setVisible('#openai-enabled + span', hasEnable); setVisible('label.toggle-label[for="rtp-enabled"]', hasRtpEnable); @@ -2013,6 +2043,10 @@

ファームウェア更新 (OTA)

if (headerInfo) headerInfo.hidden = false; applyFirmwareChannel(fwEl.textContent); } + if (geminiOnly && window.StackchanGeminiUi) { + StackchanGeminiUi.hideNonGemini(); + StackchanGeminiUi.translateTree(document.body); + } } // --- ZIP extraction (deflate-raw via DecompressionStream, no deps) --- @@ -2777,6 +2811,8 @@

ファームウェア更新 (OTA)

['otaData', CHR_OTA_DATA], ['provider', CHR_PROVIDER], ['geminiKey', CHR_GEMINI_KEY], + ['geminiModel', CHR_GEMINI_MODEL], + ['geminiVoice', CHR_GEMINI_VOICE], ['xiaozhiUrl', CHR_XIAOZHI_URL], ['xiaozhiToken', CHR_XIAOZHI_TOKEN], ['faceConfig', CHR_FACE_CONFIG], @@ -2836,6 +2872,8 @@

ファームウェア更新 (OTA)

chrOtaData = found.otaData ?? null; chrProvider = found.provider ?? null; chrGeminiKey= found.geminiKey?? null; + chrGeminiModel = found.geminiModel ?? null; + chrGeminiVoice = found.geminiVoice ?? null; chrXiaozhiUrl = found.xiaozhiUrl ?? null; chrXiaozhiToken = found.xiaozhiToken ?? null; chrFaceConfig = found.faceConfig ?? null; @@ -3165,6 +3203,40 @@

ファームウェア更新 (OTA)

log(`プロバイダ取得失敗: ${e.message}`, 'err'); } } + if (chrGeminiModel) { + try { + const b = await readEncrypted(chrGeminiModel); + const v = dec.decode(b); + const sel = document.getElementById('gemini-model'); + if (sel && v) { + if (![...sel.options].some((o) => o.value === v)) { + const opt = document.createElement('option'); + opt.value = v; opt.textContent = v; sel.appendChild(opt); + } + sel.value = v; + } + log(`Gemini model: ${v || '(default)'}`); + } catch (e) { + log(`Gemini model read failed: ${e.message}`, 'err'); + } + } + if (chrGeminiVoice) { + try { + const b = await readEncrypted(chrGeminiVoice); + const v = dec.decode(b); + const sel = document.getElementById('gemini-voice'); + if (sel && v) { + if (![...sel.options].some((o) => o.value === v)) { + const opt = document.createElement('option'); + opt.value = v; opt.textContent = v; sel.appendChild(opt); + } + sel.value = v; + } + log(`Gemini voice: ${v || '(default)'}`); + } catch (e) { + log(`Gemini voice read failed: ${e.message}`, 'err'); + } + } if (chrJtts) { try { @@ -3260,7 +3332,7 @@

ファームウェア更新 (OTA)

ledStateLoaded = false; clearTimeout(micLipWriteTimer); micLipWriteTimer = null; micLipLoaded = false; - chrSsid = chrPass = chrKey = chrApply = chrStatus = chrKx = chrEn = chrRtpEn = chrJttsIdleEn = chrJtts = chrOtaCtrl = chrOtaData = chrProvider = chrGeminiKey = chrXiaozhiUrl = chrXiaozhiToken = chrFaceConfig = chrBattery = chrBatGauge = chrServoEnabled = chrMcpToken = chrLtConfig = chrServoLimits = chrServoRangeMode = chrServoPositions = chrBoardKind = chrWifiIp = chrWifiMac = chrAudioCtrl = chrAudioData = chrAudioCredit = chrAudioMetrics = chrLedState = chrMicLipGain = chrLedMouthSync = chrLipSyncMode = chrMicLipAgc = chrSpeakerVolume = chrJttsSay = chrOperationMode = chrAudioOutput = chrBargeIn = chrAvatarBc = null; + chrSsid = chrPass = chrKey = chrApply = chrStatus = chrKx = chrEn = chrRtpEn = chrJttsIdleEn = chrJtts = chrOtaCtrl = chrOtaData = chrProvider = chrGeminiKey = chrGeminiModel = chrGeminiVoice = chrXiaozhiUrl = chrXiaozhiToken = chrFaceConfig = chrBattery = chrBatGauge = chrServoEnabled = chrMcpToken = chrLtConfig = chrServoLimits = chrServoRangeMode = chrServoPositions = chrBoardKind = chrWifiIp = chrWifiMac = chrAudioCtrl = chrAudioData = chrAudioCredit = chrAudioMetrics = chrLedState = chrMicLipGain = chrLedMouthSync = chrLipSyncMode = chrMicLipAgc = chrSpeakerVolume = chrJttsSay = chrOperationMode = chrAudioOutput = chrBargeIn = chrAvatarBc = null; if (typeof refreshAvbcButton === 'function') refreshAvbcButton(); boardKind = null; aesKey = null; @@ -3336,6 +3408,16 @@

ファームウェア更新 (OTA)

await writeEncrypted(chrGeminiKey, strToBytes(gkey)); log('Gemini キー 書き込み完了', 'ok'); } + const gmodel = document.getElementById('gemini-model'); + if (chrGeminiModel && gmodel && gmodel.value) { + await writeEncrypted(chrGeminiModel, strToBytes(gmodel.value)); + log('Gemini model write complete', 'ok'); + } + const gvoice = document.getElementById('gemini-voice'); + if (chrGeminiVoice && gvoice && gvoice.value) { + await writeEncrypted(chrGeminiVoice, strToBytes(gvoice.value)); + log('Gemini voice write complete', 'ok'); + } if (xzUrl && chrXiaozhiUrl) { await writeEncrypted(chrXiaozhiUrl, strToBytes(xzUrl)); log('XiaoZhi URL 書き込み完了', 'ok'); @@ -4340,5 +4422,6 @@

ファームウェア更新 (OTA)

// no event listener or chr-probing code needed to change. StackchanSettings.setupTabs('stackchan.settings.ble.activeTab'); + From 90e0520117ec135797a00f28599ccd7863a0376c Mon Sep 17 00:00:00 2001 From: Dylan Luo Date: Thu, 22 Oct 2026 19:23:01 +0800 Subject: [PATCH 2/4] feat(gemini): serve fully translated settings pages --- .github/workflows/pages.yml | 15 +- assets/voices.json | 2 + components/wifi_config_service/CMakeLists.txt | 42 +- .../web/settings_wifi.html | 17 +- main/Kconfig.projbuild | 12 + sdkconfig.defaults | 1 + tools/gemini_ui.js | 145 +- tools/settings.html | 1 + tools/settings_i18n/catalog.json | 1983 +++++++++++++++++ tools/settings_i18n/overrides.json | 17 + tools/settings_i18n/polish.json | 237 ++ tools/settings_i18n/translate.py | 327 +++ 12 files changed, 2661 insertions(+), 138 deletions(-) create mode 100644 tools/settings_i18n/catalog.json create mode 100644 tools/settings_i18n/overrides.json create mode 100644 tools/settings_i18n/polish.json create mode 100755 tools/settings_i18n/translate.py diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 2234e71..550135d 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -16,6 +16,8 @@ on: # settings_wifi.html at build time). - 'tools/settings_common.js' - 'tools/gemini_ui.js' + # Complete English build-time translation catalog and copyeditor rules. + - 'tools/settings_i18n/**' # The avatar preview module (avatar_module.js) and standalone # avatar.html are built from these on every deploy; rebuild when the # source, the VM, the JS DSL compiler or the default DSL changes. @@ -94,8 +96,19 @@ jobs: # Shared settings-page helpers — settings.html references this as # a sibling