diff --git a/doc/realtime_api.md b/doc/realtime_api.md index bb11427..559b6e0 100644 --- a/doc/realtime_api.md +++ b/doc/realtime_api.md @@ -1,28 +1,68 @@ # Realtime API - [概要](#概要) -- [設定方法](#設定方法) +- [ビルド方法](#ビルド方法) +- [設定方法① Web UIによる設定(推奨)](#設定方法-web-uiによる設定推奨) +- [設定方法② SDカードによる設定](#設定方法-sdカードによる設定) - [YAMLの設定① (Wi-Fi、APIキー)](#yamlの設定-wi-fiapiキー) - [YAMLの設定② (LLM)](#yamlの設定-llm) - [YAMLの設定③ (サーボ)](#yamlの設定-サーボ) - - [ビルド&書き込み](#ビルド書き込み) - [使い方](#使い方) - [リアルタイム会話](#リアルタイム会話) - [サーボ動作の停止、再開](#サーボ動作の停止再開) - [Function Calling及びMCP](#function-calling及びmcp) - [TTSとの組み合わせ (OpenAI Realtimeのみ)](#ttsとの組み合わせ-openai-realtimeのみ) - - [設定方法](#設定方法-1) + - [設定方法](#設定方法) ## 概要 Realtime APIを利用することで、従来よりもリアルタイムに近い応答速度で会話を楽しむことができます。OpenAI Realtime API 及び Gemini Live APIに対応しています。 -## 設定方法 -Realtime APIを有効にするために次の設定を行います。 +## ビルド方法 +下図のように、VSCode(PlatformIO)のGUIで"env:m5stack-xxx-realtime"を選択してビルド&書き込みを実行します。 -・YAMLファイル(3種類)を作成しSDカードに保存(※) -・ビルド&書き込み +> Note: +> PlatformIOで初めてプロジェクトを開いてビルドするまでの手順は、[基本的な利用方法 2.2.ビルド&書き込み](basic_usage.md#22-ビルド書き込み)を参照ください。 + +![](../images/realtime_api_select_env.png) + + + +## 設定方法① Web UIによる設定(推奨) +SDカード不要の、Web UIによる設定方法です。次の手順で設定します。 -> ※ AtomS3RはSDカード非対応のため、SPIFFSにYAMLファイルを書き込みます。書き込み方法は[こちら](./atoms3r.md)を参照ください。 +① M5Stackの電源ON。 + +② 初回はWi-Fi未設定のため、次のようなモード選択画面が表示される。 + 「Config AP」を選択してAPモードで起動する。 + +- Config AP : APモードで起動しWeb UIで設定を行うモード +- Offline : オフラインのまま起動するモード + + ![](../images/ap_mode_select.png) + +③ APモードで起動すると次のような画面になるので、表示されているSSIDにスマートフォンやPCで接続し、表示されているURL(もしくはQRコード)によりConfigページにアクセスする。 + + ![](../images/ap_mode_ssid_and_url.png) + +④ Configページの各タブで設定を入力し、Saveボタンで保存する。 +> Note: +> 全タブ入力後にSaveボタンを1度押せば、全タブの内容が保存されます。 + +- Wi-Fi:接続先Wi-FiアクセスポイントのSSIDとパスワードの設定 +- AI Service:利用するリアルタイムAPIの選択、及びAPIキーの設定 +- Servo:サーボの種類、ピン番号の設定 +- MCPs(Option):MCPサーバーの設定(任意) + + ![](../images/config_page.png) + +⑤ RestartボタンでM5Stackを再起動すると設定が反映される。 + +## 設定方法② SDカードによる設定 +従来のようにSDカードのYAMLファイルで設定する方法です。 +以下の3種類のYAMLファイルをSDカードに保存し、M5Stackのスロットに挿入して電源を入れなおすことで反映されます。 + +> Note: +> AtomS3RはSDカード非対応のため、SPIFFSにYAMLファイルを書き込みます。書き込み方法は[こちら](./atoms3r.md)を参照ください。 ### YAMLの設定① (Wi-Fi、APIキー) SDカードフォルダ:/yaml @@ -61,11 +101,6 @@ SDカードフォルダ:/yaml サーボの種類、ポート等を[基本的な利用方法 2.1.YAMLによる初期設定](./basic_usage.md#sc_basicconfigyaml)に従い設定します。サーボを使わない場合は省略して問題ありません。 -### ビルド&書き込み -下図のように、VSCode(Platformio)のGUIで"env:m5stack-xxx-realtime"を選択してビルド&書き込みを実行します。 - -![](../images/realtime_api_select_env.png) - ## 使い方 ### リアルタイム会話 @@ -73,6 +108,7 @@ SDカードフォルダ:/yaml ② M5Core画面の上部(アバターの額のあたり)をタッチすると吹き出しが"Listening..."に変わり、リアルタイム会話を開始します(もう一度タッチするとリアルタイム会話を停止します)。 +> Note: > AtomS3Rは画面自体が物理ボタンになっているため、画面中央を少し強めに押し込んでください。 ③ 30秒以上会話が無い状態が続くとリアルタイム会話を終了し、吹き出しが"Please touch"に戻ります。 diff --git a/doc/realtime_api_en.md b/doc/realtime_api_en.md index 8c15b73..b71d213 100644 --- a/doc/realtime_api_en.md +++ b/doc/realtime_api_en.md @@ -1,11 +1,12 @@ # Realtime API - [Overview](#overview) -- [How to setup](#how-to-setup) +- [How to build](#how-to-build) +- [Setup Method 1: Web UI (Recommended)](#setup-method-1-web-ui-recommended) +- [Setup Method 2: SD Card](#setup-method-2-sd-card) - [YAML① (Wi-Fi、API key)](#yaml-wi-fiapi-key) - [YAML② (LLM)](#yaml-llm) - [YAML③ (Servo)](#yaml-servo) - - [Build and write](#build-and-write) - [How to use](#how-to-use) - [Real-time conversation](#real-time-conversation) - [Stopping and restarting servo operation](#stopping-and-restarting-servo-operation) @@ -15,11 +16,51 @@ By using Realtime API, you can enjoy conversations with response speeds closer to real time than ever before. Compatible with OpenAI Realtime API and Gemini Live API. -## How to setup -To enable the Realtime API, do the following: +## How to build +As shown below, select "env:m5stack-xxx-realtime" in the VSCode (PlatformIO) GUI, then build and upload the firmware. -・Create YAML files (3 types) and save them to the SD card -・Build and write +> Note: +> If this is your first time opening and building the project with PlatformIO, see [Basic Usage 2.2. Build & Flash](basic_usage_en.md#22-build--flash). + +![](../images/realtime_api_select_env.png) + + +## Setup Method 1: Web UI (Recommended) +This method uses the Web UI and does not require an SD card. Follow these steps to configure the device. + +① Turn on the M5Stack device. + +② Because Wi-Fi is not configured on first startup, a mode selection screen like the one below appears. + Select "Config AP" to start the device in AP mode. + +- Config AP: Starts the device in AP mode so you can configure it using the Web UI +- Offline: Starts the device in offline mode + + ![](../images/ap_mode_select.png) + +③ After the device starts in AP mode, a screen like the one below appears. Connect your smartphone or PC to the displayed SSID, then access the Config page using the displayed URL or QR code. + + ![](../images/ap_mode_ssid_and_url.png) + +④ Enter the settings on each tab of the Config page, then click Save. +> Note: +> After completing all tabs, you only need to click Save once to save the settings from every tab. + +- Wi-Fi: SSID and password for the Wi-Fi access point to connect to +- AI Service: Realtime API selection and API key +- Servo: Servo type and pin number +- MCPs (Optional): MCP server settings + + ![](../images/config_page.png) + +⑤ Click Restart to restart the M5Stack device and apply the settings. + +## Setup Method 2: SD Card +This is the conventional method of configuring the device with YAML files on an SD card. +Save the following three YAML files to the SD card, insert it into the M5Stack device, and restart the device to apply the settings. + +> Note: +> AtomS3R does not support SD cards, so write the YAML files to SPIFFS instead. See [AtomS3R](./atoms3r.md) for instructions. ### YAML① (Wi-Fi、API key) SD card folder:/yaml @@ -58,19 +99,15 @@ File name:SC_BasicConfig.yaml Configure the servo type, port, etc. according to [Basic Usage 2.1.Initial Setup with YAML](./basic_usage_en.md#sc_basicconfigyaml). If you are not using servos, you can omit this. - -### Build and write -As shown below, select "env:m5stack-core2(s3)-realtime" in the VSCode (Platformio) GUI and run build and write. - -![](../images/realtime_api_select_env.png) - - ## How to use ### Real-time conversation ① After starting M5Core and the avatar is displayed, the text in the speech bubble will change from "Connecting..." to "Please touch." ② When you touch the top of the M5Core screen (around the avatar's forehead), the speech bubble will change to "Listening..." and real-time conversation will begin (Touch again to stop real-time conversation). +> Note: +> On AtomS3R, the screen itself is a physical button, so press the center of the screen a little firmly. + ③ If there is no conversation for more than 30 seconds, the real-time conversation will end and the speech bubble will return to "Please touch." ### Stopping and restarting servo operation diff --git a/firmware/doc/codex/steering/20260630-web-config-spiffs.md b/firmware/doc/codex/steering/20260630-web-config-spiffs.md new file mode 100644 index 0000000..844879f --- /dev/null +++ b/firmware/doc/codex/steering/20260630-web-config-spiffs.md @@ -0,0 +1,283 @@ +# Web Config on SPIFFS + +## 目的 + +SD カードの YAML 設定と同等の内容を M5Stack 内蔵 Web アプリから設定できるようにし、SD カードが無い状態でも Wi-Fi と API キーを設定できる導線を追加する。 + +初期実装は、Wi-Fi と API key を含む `SC_SecConfig.yaml` の設定導線を全ビルド共通にし、`SC_ExConfig.yaml` など Realtime API 固有または構成差分が大きい設定編集は `REALTIME_API` ビルドのみを対象にする。 + +## 対象範囲 + +- 起動時の設定読み込み順序 + - SD カードに設定 YAML がある場合は SD を優先する。 + - SD カードまたは SD 上の YAML が使えない場合は SPIFFS の YAML を使う。 + - SPIFFS にも設定が無い場合は既存のデフォルト値と未設定扱いで起動し、設定用 AP または Wi-Fi なし起動を選べるようにする。 +- Wi-Fi 接続失敗時のユーザー選択 + - YAML の Wi-Fi 設定で接続できない場合、画面上のボタンで AP モード起動または Wi-Fi なし起動を選択する。 + - SmartConfig は廃止し、AP モードでの Web 設定を標準導線にする。 +- 設定用 AP + - AP モードで起動した場合は Web サーバーを起動し、画面に設定用 Web アプリ URL の QR コードを表示する。 + - AP の SSID、パスワード、IP は固定デフォルトを持たせ、必要なら後続で変更可能にする。 +- Web アプリ設定保存 + - 全ビルドで `SC_SecConfig.yaml` を Web UI から編集またはフォーム入力し、既存 YAML フォーマットで SPIFFS に保存できるようにする。 + - `REALTIME_API` ビルドでは `SC_ExConfig.yaml` と必要に応じた `SC_BasicConfig.yaml` の編集も扱う。 + - SD カードの YAML を SPIFFS にコピーするボタンを Web UI に設ける。 + +## Phase1 実装範囲 + +最初の実装では、設定対象を `SC_SecConfig.yaml` のみに限定する。 + +- 実装する + - SPIFFS の `/SC_SecConfig.yaml` 読み書き。 + - Web UI の `home.html` / `config.html` / `config.js` 追加。 + - `GET /config`、`POST /config`、`POST /config/restart`。 + - YAML の Wi-Fi 設定だけを使った接続。 + - 接続失敗時の AP/Offline 選択。 + - AP モードでの Web サーバー起動と QR 表示。 + - `SC_ExConfig.yaml`、`SC_SecConfig.yaml`、`SC_BasicConfig.yaml` の一式が揃わない場合は通常機能を起動せず、設定 Web へ誘導する。 +- 実装しない + - `SC_ExConfig.yaml` の Web 編集。 + - `SC_BasicConfig.yaml` の Web 編集。 + - Realtime API 固有タブやフォーム。 + +Phase1 後の追加実装で、`REALTIME_API` ビルドに限って `SC_ExConfig.yaml` と必要に応じた `SC_BasicConfig.yaml` を拡張する。 + +設定ファイル一式が不足している場合は、`REALTIME_API` ビルドでは `SC_SecConfig.yaml` だけを読み込んで Wi-Fi 接続を試す。接続できれば STA 接続上で設定 Web を起動し、接続できなければ Config AP を起動する。AtomS3R は画面が小さいため QR は表示せず、Wi-Fi 接続失敗時は Config AP へ直接進む。`REALTIME_API` 以外のビルドでは、不完全な設定を画面に表示して待機する。 + +## Phase2 実装範囲 + +Phase2 では `REALTIME_API` ビルド向けに、`SC_SecConfig.yaml`、`SC_BasicConfig.yaml`、`SC_ExConfig.yaml` をフォーム入力から生成して SPIFFS に保存する。 + +- 実装する + - Config page を YAML 直書き textarea から項目別フォームへ変更する。 + - `GET /config` を JSON 応答に変更し、現在値、設定元、設定ファイル一式の状態を返す。 + - `POST /config` を JSON 入力に変更し、フォーム値から 3 つの YAML を生成して SPIFFS に保存する。 + - `SC_SecConfig.yaml` + - `wifi.ssid` + - `wifi.password` + - `apikey.aiservice` + - `apikey.tts` + - `apikey.stt` + - `SC_BasicConfig.yaml` + - Servo 関連のみ生成する。 + - Servo 以外の BasicConfig 項目は AI_StackChan_Ex では使わないため YAML から省略する。 + - `SC_ExConfig.yaml` + - `llm.type` + - `llm.enableMemory` + - Realtime API ビルドで不要な ExConfig 項目は YAML から省略する。 + - Realtime AI Service の選択肢 + - OpenAI Realtime -> `llm.type: 0` + - Google Gemini Live -> `llm.type: 3` + - モデル名は Realtime API ビルドでは固定のため設定対象外。 + - Servo Type 選択時は、Pin/Offset/Center/Limit/Enable Takao Base のプリセット適用前に確認ダイアログを出す。 + - Save 成功後は「設定した値を有効にするために、Restart する前に SD カードを抜く」注意を表示する。 +- 実装しない + - Copy from SD ボタン。仕様が複雑になるため Phase2 で削除する。 + - Realtime API 以外のビルド向けフォーム拡張。 + - Realtime API で使わない `tts`、`stt`、`wakeword`、`audio`、`moduleLLM`、`mcpServers`、`customEndpoint` などのフォーム項目。 + +### Phase2 API + +- `GET /config` + - JSON を返す。 + - 例: + - `source`: `sd` / `spiffs` / `partial` / `none` + - `complete`: 3 YAML が揃っているか + - `sec`: Wi-Fi と API key + - `basic`: Servo 設定 + - `ex`: Realtime AI Service と memory 設定 +- `POST /config` + - JSON を受け取る。 + - 入力値を検証し、`SC_SecConfig.yaml`、`SC_BasicConfig.yaml`、`SC_ExConfig.yaml` を SPIFFS へ保存する。 + - 保存後は RAM 上の `system_config` に反映できる範囲は反映するが、実運用上は Restart を促す。 +- `POST /config/copy_sd` + - Phase2 で削除する。 +- `POST /config/restart` + - 既存どおり再起動する。 + +### Phase2 YAML 生成方針 + +生成する YAML は、動作に必要な最小キーのみを含める。 + +- `SC_SecConfig.yaml` + - `wifi` + - `apikey` +- `SC_BasicConfig.yaml` + - `servo` + - `takao_base` + - `servo_type` +- `SC_ExConfig.yaml` + - `llm.type` + - `llm.enableMemory` + +既存 YAML を読み込める場合でも、Phase2 の Save ではフォーム対象外項目を SPIFFS 側の生成 YAML へ持ち越さない。 + +## 主な変更候補ファイル + +- `src/main.cpp` + - 設定読み込み元の選択、Wi-Fi 接続失敗時の AP/Offline 選択、AP 起動、QR 表示。 +- `src/StackchanExConfig.*` + - 必要に応じて、読み込み元状態や設定保存補助の追加。ただし既存 `StackchanSystemConfig::loadConfig()` の使い方を優先する。 +- `src/WebAPI.*` + - 設定取得、保存、検証、SD から SPIFFS へのコピー API を追加する。 +- `incbin/personalize.html` +- `incbin/personalize.js` + - 既存 Personalize 画面に設定ページへの導線を追加するか、設定用 HTML/JS を別ファイルとして incbin する。 +- `incbin/home.html` + - Web アプリの入口。Personalize と Config への分岐を置く。 +- `incbin/config.html` +- `incbin/config.js` + - SPIFFS 設定編集用 UI。 +- `doc/fw_design.md` + - 起動時設定優先順位、SPIFFS 永続化ファイル、Web API を追記する。 + +## 設計方針 + +### 設定ファイル配置 + +SPIFFS 側のファイル名は既存 AtomS3R の配置に合わせる。 + +- `/SC_BasicConfig.yaml` +- `/SC_SecConfig.yaml` +- `/SC_ExConfig.yaml` + +SD 側は現行の `loadConfig(SD, "/app/AiStackChanEx/SC_ExConfig.yaml")` に合わせ、既存ライブラリのデフォルトパスである `/yaml/SC_SecConfig.yaml` と `/yaml/SC_BasicConfig.yaml` を使う。SD から SPIFFS へのコピーでは、この 3 ファイルを対象にする。 + +### 読み込み優先順位 + +1. SPIFFS は全ボードで先に `begin()` する。 +2. SD が利用可能で、少なくとも SD の主要 YAML が開ける場合は SD から `system_config.loadConfig()` する。 +3. SD が利用できない、または SD 設定が不足する場合は SPIFFS から `system_config.loadConfig()` する。 +4. どちらも無い場合は SPIFFS から存在しないファイルとして読み込み、既存の default/callback に寄せる。ただし SD 無しを理由に再起動しない。 + +SD 優先を保つため、SD が存在する場合に SPIFFS 設定を暗黙に上書きしない。SD から SPIFFS への反映は Web UI の明示操作のみとする。 + +### Wi-Fi 起動状態 + +Wi-Fi 接続は読み込んだ YAML の `wifi.ssid` / `wifi.password` のみで試す。 + +1. `wifi.ssid` が空の場合は接続を試さず、画面に AP/Offline 選択 UI を表示する。 +2. `wifi.ssid` がある場合は `WiFi.begin(ssid, password)` で接続を試す。 +3. 接続に失敗した場合、画面に選択 UI を表示する。 + - BtnA または画面左: AP モードで設定する。 + - BtnC または画面右: Wi-Fi なしで起動する。 + +AP モード選択時は `WiFi.mode(WIFI_AP)` と `WiFi.softAP()` を使い、Web サーバーを起動する。AP モードは「設定可能だが通常のオンライン AI サービスは未接続」として扱うため、会話処理は offline 相当にする。 + +引数なしの `WiFi.begin()` による前回接続情報の試行は行わない。SmartConfig 廃止後は前回接続情報と YAML 設定の二重管理を避け、起動時の接続元を YAML に一本化する。 + +### QR 表示 + +QR は既存 `avatar.updateSubWindowQrcode()` の利用を第一候補にする。ただし Avatar 初期化前の起動中画面で必要な場合は、M5GFX の QR 描画 API または簡易表示関数を別途確認して使う。 + +表示 URL は AP IP に合わせて `http://192.168.4.1/` を基本とする。 + +### Web API + +`SC_SecConfig.yaml` を扱う最小 API は全ビルドで有効化する。`SC_ExConfig.yaml` や Realtime API 固有項目を扱う API は `REALTIME_API` ビルドのみで有効化する。 + +- `GET /config` + - SPIFFS 上の YAML 内容と、現在の設定読み込み元を返す。 + - 全ビルドでは `SC_SecConfig.yaml` を返す。 + - `REALTIME_API` ビルドでは `SC_ExConfig.yaml` と必要に応じた `SC_BasicConfig.yaml` も返す。 +- `POST /config` + - YAML 文字列または JSON フォーム入力を受け取り、YAML として SPIFFS に保存する。 + - 全ビルドでは `SC_SecConfig.yaml` のみ保存対象にする。 + - `REALTIME_API` ビルドでは `SC_ExConfig.yaml` と必要に応じた `SC_BasicConfig.yaml` も保存対象にする。 + - 最初の実装では YAML 文字列保存を優先し、フォーム UI は主要項目から段階的に増やす。 +- `POST /config/restart` + - 保存後に再起動するための明示 API。自動再起動は UI 側で確認してから呼ぶ。 + +保存前に `deserializeYml()` で最低限の構文検証を行い、壊れた YAML を保存しない。 + +### `StackchanExConfig` の追加責務 + +`StackchanSystemConfig` は外部ライブラリ配下のため直接変更しない。派生クラスである `StackchanExConfig` に、Web UI から扱う設定ファイルの薄い保存・検証 API を追加する。 + +`SC_SecConfig.yaml` は親クラスの protected メンバー `_secret_config` と protected 関数 `setSecretConfig()` / `loadSecretConfig()` が担当しているため、`StackchanExConfig` 側ではこれらを public な用途別関数で包む。 + +追加候補の public 関数: + +- `bool validateSecretConfigYaml(const String& yaml, String* error = nullptr)` + - Web UI から受け取った YAML を `deserializeYml()` で構文検証する。 + - `wifi.ssid`、`wifi.password`、`apikey.aiservice`、`apikey.tts`、`apikey.stt` のキーが扱える形か確認する。 + - API key は空文字を許可する。Wi-Fi SSID も、意図的に未設定で保存する余地を残すため空文字自体はエラーにしない。 +- `bool saveSecretConfigYaml(fs::FS& fs, const char* path, const String& yaml, String* error = nullptr, bool apply_now = true)` + - `validateSecretConfigYaml()` で検証する。 + - `path + ".tmp"` に一度書き込み、成功後に本ファイルへ反映する。 + - `apply_now` が true の場合は、保存した YAML を `setSecretConfig()` に渡して RAM 上の `_secret_config` へ反映する。 + - ただし Wi-Fi 再接続はこの関数では行わず、Web UI から再起動を促す。 +- `bool loadSecretConfigYaml(fs::FS& fs, const char* path, uint32_t yaml_size = 2048)` + - 起動時や保存後の明示再読み込み用の wrapper。 + - 内部では親クラスの `loadSecretConfig()` を呼ぶ。 +- `String exportSecretConfigYaml(bool mask_secret = false)` + - RAM 上の `_secret_config` から `SC_SecConfig.yaml` 形式の YAML を生成する。 + - `mask_secret` が true の場合は password/API key を伏せた表示用文字列にする。 + - 実保存や編集用には原則として SPIFFS 上のファイル内容を返し、ファイルが無い場合の初期テンプレートとして使う。 + +追加候補の private 関数: + +- `bool writeFileAtomic(fs::FS& fs, const char* path, const String& data, String* error)` + - 一時ファイルへの書き込み、flush/close、rename をまとめる。 +- `bool parseSecretConfigYaml(const String& yaml, DynamicJsonDocument& doc, String* error)` + - 検証と `setSecretConfig()` の両方で使う YAML parse 共通処理。 + +Web UI から `SC_SecConfig.yaml` を保存する内部処理は次の流れにする。 + +1. `config.js` が `/config` へ対象 `SC_SecConfig.yaml` と YAML 本文を POST する。 +2. `WebAPI.cpp` が本文を受け取り、`system_config.saveSecretConfigYaml(SPIFFS, "/SC_SecConfig.yaml", body, &error)` を呼ぶ。 +3. `StackchanExConfig` が YAML を parse し、壊れた YAML や想定外のトップレベル構造を拒否する。 +4. 検証成功時だけ SPIFFS へ一時ファイル経由で保存する。 +5. 保存後、RAM 上の `_secret_config` を更新する。 +6. `WebAPI.cpp` は成功を返し、UI は「再起動すると新しい Wi-Fi 設定で接続する」旨を表示して `/config/restart` の操作を促す。 + +この分担により、Web API 側は HTTP とファイル種別の分岐に集中し、YAML の意味や `_secret_config` の更新は `StackchanExConfig` に閉じ込める。 + +### Web UI + +初期実装では設定の完全性と実装リスクを優先し、以下の二段構えにする。 + +- YAML 編集ビュー + - 全ビルドでは `SC_SecConfig.yaml` を編集。 + - `REALTIME_API` ビルドでは `SC_SecConfig.yaml`、`SC_ExConfig.yaml`、必要なら `SC_BasicConfig.yaml` をタブで編集。 + - 保存、再読み込み、再起動、SD からコピーの操作を提供する。 +- 主要項目フォーム + - Wi-Fi SSID/password。 + - OpenAI/Realtime API 用 API key。 + - Realtime API で必要な最小限のモデルや音量など。 + +既存 `personalize.html/js` は Role/Memory 用として残し、設定 UI は `config.html/js` を追加する方針を第一候補にする。 + +### 画面切り替え + +Web アプリの入口として `home.html` を追加し、`/` は `home.html` を返す。`home.html` には次の 2 つの導線を置く。 + +- Personalize + - 既存の `/personalize.html` に遷移する。 + - Role と Memory の管理を行う。 +- Config + - 新規の `/config.html` に遷移する。 + - Wi-Fi、API key、Realtime API 用 YAML 設定、SD から SPIFFS へのコピーを行う。 + +設定用 AP モードで QR に載せる URL も `/` とし、スマートフォンで開いた直後に用途を選べるようにする。既存利用者向けに `/personalize.html` はそのまま残す。 + +`home.html` は説明を増やしすぎず、2 つの大きな操作ボタンと現在の接続状態または設定読み込み元の短い表示に留める。AP モードでは Config を主導線として上に配置し、通常 STA 接続時は Personalize と Config を並列に扱う。 + +## 設計上の注意点 + +- API キーや Wi-Fi パスワードをログへ出さない。既存ログに表示される箇所は今回の実装時に抑制を検討する。 +- SPIFFS 保存中の電源断で設定が壊れる可能性があるため、可能なら一時ファイルへ書いてからリネームする。 +- AP モードで Web サーバーを動かす場合、`isOffline` の意味が「Web サーバー停止」と衝突する。`wifiMode` のような状態を分ける設計にする。 +- `StackchanSystemConfig` は外部ライブラリ配下のため、直接変更せず `StackchanExConfig` と起動側で吸収する。 +- AtomS3R は画面とボタン制約が異なるため、初期実装の対象デバイス確認を Core2/CoreS3 優先にする。 +- `SC_SecConfig.yaml` の AP 設定導線は全ビルドに入るため、`WebAPI.cpp` の基本ルートと SPIFFS 書き込み処理は `REALTIME_API` に依存しない構成にする。 +- `REALTIME_API` 以外のビルドでは、Config UI 上の Realtime API 固有タブや項目は表示しない。 + +## 確認方法 + +- `m5stack-core2-realtime` または `m5stack-cores3-realtime` でビルド確認する。 +- SD あり、SD なし、SPIFFS 設定あり、SPIFFS 設定なしの起動分岐をログで確認する。 +- Wi-Fi 接続失敗時に AP/Offline を選択できることを確認する。 +- AP 接続後、QR の URL から Web UI に到達できることを確認する。 +- Web UI で SPIFFS に YAML 保存し、再起動後に設定が読み込まれることを確認する。 +- SD から SPIFFS へのコピー後、SD を抜いた状態で同じ設定が使われることを確認する。 diff --git a/firmware/doc/codex/steering/20260718-gemini-root-ca-bundle.md b/firmware/doc/codex/steering/20260718-gemini-root-ca-bundle.md new file mode 100644 index 0000000..204315d --- /dev/null +++ b/firmware/doc/codex/steering/20260718-gemini-root-ca-bundle.md @@ -0,0 +1,78 @@ +# Gemini Live Root CA バンドル化 + +## 目的 + +Gemini Live API 接続時に、利用環境によって Google から提示される証明書チェーンが異なり、単一の信頼証明書では TLS 検証に失敗する問題を軽減する。 +`src/rootCA/rootCAgoogleGemini.h` に複数の Google 向け CA 証明書を連結した PEM バンドルを組み込み、mbedTLS が提示されたチェーンに対応する信頼アンカーを選択できるようにする。 + +## 対象範囲 + +- Gemini Live API の WebSocket TLS 接続で使用する組み込み CA 証明書 +- 対象ホスト: `generativelanguage.googleapis.com:443` +- `WebSocketsClient::beginSslWithCA()` へ渡す CA データの内容 + +Custom OpenAI Endpoint 用の `customRootCAFile` / `customRootCAFiles`、Google Speech-to-Text、OpenAI Realtime API、TTS の CA 設定は変更しない。 + +## 主な変更ファイル + +- `src/rootCA/rootCAgoogleGemini.h` + - 単一証明書を、複数の PEM 証明書を連結した Google 向け CA バンドルへ変更する。 + - 各証明書の Subject、Issuer、有効期限、SHA-256 fingerprint、取得元をコメントで記録する。 +- `doc/codex/steering/20260718-gemini-root-ca-bundle.md` + - 本作業の方針と確認方法を記録する。 + +既存の接続処理を変更する必要がなければ、`src/llm/Gemini/GeminiLive.cpp` は変更しない。 + +## 実装方針 + +1. Google Trust Services の公式リポジトリを基準に、実際に報告された複数の証明書チェーンを検証できる CA 証明書を選定する。 +2. `openssl s_client` が表示したサーバー証明書をそのまま採用せず、Subject、Issuer、fingerprint を照合して証明書の位置付けを確認する。 +3. 選定した証明書を、各 `BEGIN CERTIFICATE` / `END CERTIFICATE` ブロックの間に改行を入れて1つの文字列へ連結する。 +4. 既存の変数名 `root_ca_google_gemini` と `beginSslWithCA()` の呼び出しを維持し、変更範囲を証明書ヘッダーに限定する。 +5. 古い証明書を無条件に残すのではなく、Google公式情報、実環境で観測されたチェーン、証明書の有効期限を根拠に必要な証明書だけを含める。 +6. CAを無効化する `setInsecure()` は使用しない。 + +## 設計上の注意点 + +- 複数CAの組み込みは、環境ごとに異なる証明書チェーンへ対応するための信頼候補を増やすものであり、証明書検証を無効化するものではない。 +- バンドルに含まれない将来のGoogle証明書チェーンには対応できないため、ファームウェア更新時にGoogle Trust Servicesの公開情報を再確認する。 +- 端末時刻の未同期、TLSインスペクションによる独自CAへの差し替え、古いmbedTLSの暗号方式非対応は、本変更だけでは解決しない。 +- Googleは特定の中間CAやRoot CAの固定を避けるよう案内している。一方、組み込み機器では一般的なOSのRoot Storeを自動更新できないため、本実装では対象をGoogle向けの必要最小限のCAバンドルに限定し、ファームウェア更新で保守する。 +- 複数PEMによるフラッシュ使用量と、TLSハンドシェイク時のヒープ使用量増加を確認する。 +- APIキー、Wi-Fi情報などの秘密情報は証明書ヘッダーやログへ追加しない。 + +## 確認方法 + +### 証明書の静的確認 + +- 各PEMをOpenSSLで解析できることを確認する。 +- Subject、Issuer、有効期限、SHA-256 fingerprintがGoogle公式情報と一致することを確認する。 +- PEMブロック数と区切りを確認し、文字列の欠落や重複定義がないことを確認する。 + +### 接続確認 + +- 従来接続できていた環境でGemini Live APIへ接続し、回帰がないことを確認する。 +- エラー報告があった環境で接続し、`X509 - Certificate verification failed` が解消することを確認する。 +- 可能であれば、各環境で次のコマンドにより提示チェーンを採取し、バンドル内のCAへ検証経路が到達することを確認する。 + +```sh +openssl s_client -connect generativelanguage.googleapis.com:443 -servername generativelanguage.googleapis.com -showcerts -verify_return_error +``` + +### ビルド確認 + +- `m5stack-core2-realtime` +- `m5stack-cores3-realtime` +- `m5stack-atoms3r-realtime` + +依存ライブラリ取得などでネットワークアクセスが必要な場合は、実行前にユーザーの了承を得る。 + +## 戻し方 + +`src/rootCA/rootCAgoogleGemini.h` のCA文字列を変更前の単一証明書へ戻す。接続処理や設定構造は変更しないため、証明書ヘッダーの差し戻しだけで元の動作へ復帰できる。 + +## 残リスク + +- Google側で新しいCAや証明書チェーンへ切り替わった場合は、バンドルの再更新が必要になる。 +- 報告環境でTLSインスペクションが行われている場合、Google公式CAの追加だけでは接続できない。 +- 実機を用意できない環境については、ビルドと証明書チェーンの静的検証までとなる。 diff --git a/firmware/doc/codex/steering/20260719-config-page-tabs.md b/firmware/doc/codex/steering/20260719-config-page-tabs.md new file mode 100644 index 0000000..9665ac7 --- /dev/null +++ b/firmware/doc/codex/steering/20260719-config-page-tabs.md @@ -0,0 +1,57 @@ +# Config Page Tabs + +## 目的 + +Web UI の Config ページを Wi-Fi、AI Service、Servo の3つのタブに整理し、設定項目を探しやすくする。 +Save、Reload、Restart はタブ共通の操作としてタブ領域の外に置き、Save では表示中のタブに限らず全タブの入力値をまとめて保存する。 + +## 対象範囲 + +- `incbin/config.html` のタブUIと設定パネルの構造、スタイル +- `incbin/config.js` のタブ切り替え処理 +- `doc/fw_design.md` の Config page 設計記述 + +Web API、JSON形式、YAML生成処理、設定項目そのものは変更しない。 + +## 主な変更ファイル + +- `incbin/config.html` +- `incbin/config.js` +- `doc/fw_design.md` +- `doc/codex/steering/20260719-config-page-tabs.md` + +## 実装方針 + +1. Configフォーム内に `Wi-Fi`、`AI Service`、`Servo` のタブボタンを追加する。 +2. 既存の3つの設定領域を、それぞれ対応するタブパネルとして構成する。 +3. 初期表示は `Wi-Fi` タブとする。 +4. タブ選択時は対応するパネルのみ表示し、ほかのパネルは非表示にする。非表示パネル内の入力要素はDOM上に保持し、入力値を失わないようにする。 +5. Save、Reload、Restart とステータス/エラーメッセージはタブパネルの外に配置する。 +6. Save は既存の `collectConfig()` を使用し、全タブの入力値から従来どおり1つのJSONを生成して `/config` にPOSTする。 +7. Reload は全設定を再取得して全タブへ反映し、現在選択中のタブは維持する。 +8. タブには `role="tablist"`、`role="tab"`、`role="tabpanel"` と対応するARIA属性を設定する。クリックに加え、左右矢印、Home、Endキーでタブを移動できるようにする。 +9. 狭い画面でも3つのタブ名が収まり、共通操作ボタンが既存どおり扱えるレスポンシブ表示にする。 + +## 設計上の注意点 + +- タブ切り替えだけではフォーム送信やAPI通信を行わない。 +- タブを切り替えても未保存の入力値を保持する。 +- `fieldset` と見出しの意味構造を維持しつつ、選択中タブが視覚的に明確になるようにする。 +- パスワード表示切り替え、Servo Type変更確認、Pin Presetなど既存のイベント処理を維持する。 +- Save成功後のSDカード取り外し案内とRestart確認ダイアログを維持する。 +- 埋め込みファイル容量への影響を小さくし、外部ライブラリは追加しない。 + +## 確認方法 + +- 初期表示で Wi-Fi タブだけが表示されること。 +- 各タブをクリックおよびキーボードで切り替えられること。 +- タブをまたいで入力した値が、切り替え後も保持されること。 +- Save時のJSONに Wi-Fi、AI Service、Servo の全設定が含まれること。 +- Reloadで全タブの値が更新され、選択中のタブが維持されること。 +- Save、Reload、Restartがどのタブからも操作できること。 +- デスクトップ幅とスマートフォン幅で、タブ、入力欄、操作ボタンに重なりやはみ出しがないこと。 +- `m5stack-core2-realtime` と `m5stack-cores3-realtime` をビルドし、埋め込みWeb UIを含めてコンパイルできること。 + +## 戻し方 + +タブボタンとタブ切り替え処理を削除し、3つの設定領域を常時表示へ戻す。APIおよび保存形式は変更しないため、設定データの移行や復元は不要。 diff --git a/firmware/doc/codex/steering/20260719-incbin-build-dependencies.md b/firmware/doc/codex/steering/20260719-incbin-build-dependencies.md new file mode 100644 index 0000000..8e0440e --- /dev/null +++ b/firmware/doc/codex/steering/20260719-incbin-build-dependencies.md @@ -0,0 +1,104 @@ +# incbin Build Dependencies + +> **実装保留中:** 本ファイルは検討中の設計方針を記録するものであり、現時点ではビルドスクリプト、`platformio.ini`、`WebAPI.cpp`の変更を行わない。実装開始前に方式と警告条件を再レビューする。 + +## 目的 + +`incbin`内のHTML/JavaScriptを変更したときに、対象ファイルを埋め込む`WebAPI.cpp`が自動的に再コンパイルされるようにする。 + +また、今後Web UIファイルを追加した際に`IMPORT_FILE()`への登録を忘れた場合、ビルド時に検出できるようにする。 + +## 現状と課題 + +`WebAPI.cpp`の`IMPORT_FILE()`は、インラインアセンブラの`.incbin`ディレクティブで`incbin`内のファイルを取り込んでいる。 + +`.incbin`のファイルパスはC/C++の通常のinclude依存解析では認識されないため、HTML/JavaScriptだけを変更しても`WebAPI.cpp`が再コンパイルされず、変更前の内容がファームウェアへ残る場合がある。 + +依存ファイル一覧をビルドスクリプト側へ手作業で重複定義すると、Web UIファイル追加時に一覧の更新を忘れる可能性が残る。 + +## 対象範囲 + +- `src/WebAPI.cpp`内の`IMPORT_FILE()`による埋め込み +- `incbin/*.html` +- `incbin/*.js` +- PlatformIOの追加ビルドスクリプト +- 全PlatformIOビルド環境で共通となる`extra_scripts`設定 + +Web APIのルーティング、MIME type、HTML/JavaScriptの内容、設定保存処理は本作業の対象外とする。 + +## 想定する主な変更ファイル + +- `platformio.ini` +- 新規PlatformIOビルドスクリプト(配置先とファイル名は実装前に決定する) +- `src/WebAPI.cpp` + - `IMPORT_FILE()`付近への保守コメントのみ +- `doc/fw_design.md` +- `doc/codex/steering/20260719-incbin-build-dependencies.md` + +## 実装方針案 + +1. PlatformIOのPRE追加スクリプトからBuild Middlewareを登録する。 +2. ビルドスクリプトで`src/WebAPI.cpp`を読み、文字列リテラルを指定した`IMPORT_FILE()`呼び出しから埋め込みファイル名を抽出する。 +3. 抽出した各ファイルを、`WebAPI.cpp`から生成されるオブジェクトファイルの依存関係としてSConsへ登録する。 +4. 埋め込みファイルの更新日時または内容が変化した場合、PlatformIOの通常の差分ビルドによって`WebAPI.cpp`を再コンパイルする。 +5. `incbin`直下の`.html`と`.js`を走査し、`IMPORT_FILE()`から参照されていないファイルをビルド時に警告する。 +6. `IMPORT_FILE()`から参照されているファイルが存在しない場合は、埋め込み不能であるためビルドエラーとする。 +7. `WebAPI.cpp`の`IMPORT_FILE()`付近に、依存関係がビルドスクリプトによって生成されることと、未参照ファイルが警告対象になることを短いコメントで記載する。 + +## 検出ルール案 + +### 警告 + +次の条件を満たすファイルがある場合、ファイル名を含む警告を表示する。 + +- `incbin`直下に存在する +- 拡張子が`.html`または`.js` +- `WebAPI.cpp`の`IMPORT_FILE()`から参照されていない + +警告例: + +```text +Warning: incbin/settings.html is not referenced by IMPORT_FILE() +``` + +未参照ファイルを直ちにビルドエラーにはせず、開発途中で配置したファイルも扱えるよう警告に留める案とする。 + +### エラー + +次の条件ではビルドを停止する。 + +- `IMPORT_FILE()`で指定されたファイルが`incbin`内に存在しない +- `IMPORT_FILE()`の記述を安全に解釈できず、依存関係を正しく登録できない + +## 設計上の注意点 + +- 埋め込み対象の正は`WebAPI.cpp`の`IMPORT_FILE()`呼び出しとし、Python側に同じファイル一覧を手書きしない。 +- マクロ定義そのものではなく、文字列リテラルを使用した呼び出しだけを抽出対象とする。 +- コメントアウトされた`IMPORT_FILE()`を誤検出しない方法を検討する。単純な正規表現で十分か、軽量な前処理が必要かは実装前に確認する。 +- ファイル名にサブディレクトリを許可するか、現在どおり`incbin`直下だけに限定するかを実装前に決定する。 +- `.css`、画像、JSONなどを将来埋め込む場合に、警告対象拡張子を容易に追加できる構造とする。 +- Build Middlewareの対象指定がPlatformIOの各環境で同じように`src/WebAPI.cpp`へ一致することを確認する。 +- IDEのコード解析など、通常ビルド以外のIntegration Dumpでは不要な警告や副作用を発生させない。 +- Web UIファイルにAPIキー、Wi-Fiパスワードなどの実データを追加しない。 + +## 実装前に再確認する事項 + +- 追加スクリプトの配置先と命名 +- 未参照ファイルを警告に留めるか、CIではエラーに昇格させるか +- コメントアウトされた`IMPORT_FILE()`の扱い +- `incbin`サブディレクトリと追加拡張子をサポートするか +- Build Middlewareで依存関係を付与する対象ノードの指定方法 + +## 確認方法 + +1. 変更のない状態で連続ビルドし、`WebAPI.cpp`が不要に再コンパイルされないことを確認する。 +2. `incbin/config.html`だけを変更し、`WebAPI.cpp`が自動的に再コンパイルされることを確認する。 +3. `incbin/config.js`だけを変更した場合も同様に確認する。 +4. テスト用HTML/JavaScriptを`incbin`へ追加し、`IMPORT_FILE()`未登録の警告が表示されることを確認する。 +5. `IMPORT_FILE()`から存在しないファイルを参照し、分かりやすいビルドエラーになることを確認する。 +6. `m5stack-core2-realtime`、`m5stack-cores3-realtime`、`m5stack-atoms3r-realtime`で動作を確認する。 +7. 必要に応じてRealtime API以外の環境でも共通スクリプトが問題なく動作することを確認する。 + +## 戻し方 + +`platformio.ini`から追加スクリプトの登録を削除し、追加したビルドスクリプトと`WebAPI.cpp`の案内コメントを削除する。`IMPORT_FILE()`による現在の埋め込み方式自体は変更しないため、ファームウェア側の復元作業は不要。 diff --git a/firmware/doc/codex/steering/20260720-config-page-mcp-settings.md b/firmware/doc/codex/steering/20260720-config-page-mcp-settings.md new file mode 100644 index 0000000..dbf0566 --- /dev/null +++ b/firmware/doc/codex/steering/20260720-config-page-mcp-settings.md @@ -0,0 +1,68 @@ +# Config Page MCP Settings + +## 目的 + +Web UI の Config ページに任意設定用の `MCPs (Option)` タブを追加し、`SC_ExConfig.yaml` の `llm.mcpServers` を最大5件まで編集、保存できるようにする。 +MCPサーバー設定は `REALTIME_API` ビルドでも利用される既存設定であり、今回そのWeb UI未対応部分を補う。 + +## 対象範囲 + +- `incbin/config.html` の4つ目のタブとMCPサーバー入力欄 +- `incbin/config.js` のMCPサーバー設定の読込、入力値収集、画面側の検証 +- `src/WebAPI.cpp` の `llm.mcpServers` のJSON応答と `SC_ExConfig.yaml` 生成 +- `src/StackchanExConfig.h`、`src/StackchanExConfig.cpp` のMCPサーバー最大件数と安全な読込 +- `doc/fw_design.md` の Config page、API、保存形式の設計記述 + +MCPサーバー設定の形式と既存のMCPクライアント処理は変更しない。 + +## 主な変更ファイル + +- `incbin/config.html` +- `incbin/config.js` +- `src/WebAPI.cpp` +- `src/StackchanExConfig.h` +- `src/StackchanExConfig.cpp` +- `doc/fw_design.md` +- `doc/codex/steering/20260720-config-page-mcp-settings.md` + +## 実装方針 + +1. Configページの既存タブ列へ、4つ目の `MCPs (Option)` タブを追加する。 +2. MCPタブには5件分の入力枠を固定表示し、各枠で既存設定形式に対応する `name`、`disabled`、`url`、`port` を編集可能にする。`disabled` は `true` / `false` のドロップダウンで選択する。 +3. `LLM_N_MCP_SERVERS_MAX` を10件から5件へ変更し、Web UI、設定読込、ランタイムの上限を統一する。 +4. `StackchanExConfig` はYAML内の `mcpServers` を共通上限まで読み込み、6件目以降を無視して配列範囲外へ書き込まないようにする。 +5. `GET /config` の `ex.llm.mcpServers` に、SPIFFS上の `SC_ExConfig.yaml` から読み込んだ設定を返す。SPIFFS設定がない場合はRAM上の `StackchanExConfig` から返す。 +6. Web UIのReload時は取得した配列を5枠へ反映し、残りの枠を空にする。 +7. Save時は、未使用の空欄を除外し、入力済みの枠だけを表示順に `ex.llm.mcpServers` 配列へ格納する。 +8. 使用する枠は `name` と `url` を必須とし、`port` は1から65535の整数として検証する。入力途中など一部だけ値がある枠は保存せず黙って捨てるのではなく、エラー表示して保存を中止する。 +9. `disabled` はドロップダウンの選択値を既存YAML形式の真偽値で保持する。空欄の枠は `disabled` の状態にかかわらず配列へ含めない。 +10. `POST /config` は受け取った配列を共通上限で検証し、既存の `llm.type`、`llm.enableMemory` とともに `SC_ExConfig.yaml` の `llm.mcpServers` として生成する。 +11. YAML文字列は既存のクォート処理を利用し、名前やURLにYAMLの予約文字が含まれても構造を壊さないようにする。 +12. 既存のタブ切り替え、全タブ一括Save、選択中タブを維持するReload、Restartの動作を維持する。 + +## 設計上の注意点 + +- `LLM_N_MCP_SERVERS_MAX` を唯一のランタイム上限とし、固定配列とMCPクライアント配列も5件に統一する。 +- 既存の `SC_ExConfig.yaml` に6件以上ある場合は先頭5件だけを使用する。Web APIから6件以上を保存する要求はエラーにする。 +- MCPサーバー設定は任意であり、0件の `mcpServers` 配列を保存可能にする。 +- `url` は既存 `MCPClient` が接続先ホストとして扱う値をそのまま保存し、今回URLスキームの変換や接続確認は追加しない。 +- API側でも配列型、件数、必須文字列、ポート範囲を確認し、不正な入力から壊れたYAMLを生成しない。 +- JSONドキュメント容量は5件分の文字列を扱えるよう見直し、組み込み機器のヒープ消費を必要以上に増やさない。 +- 外部ライブラリは追加しない。 + +## 確認方法 + +- `MCPs (Option)` タブをクリックおよびキーボード操作で選択できること。 +- MCPサーバーを0件から5件まで入力し、Save後の `SC_ExConfig.yaml` に `name`、`disabled`、`url`、`port` が正しい配列として保存されること。 +- 各入力枠の `disabled` を `true` / `false` のドロップダウンで選択でき、Reload後も選択値が復元されること。 +- Reloadおよびページ再読込で、保存済みの最大5件が同じ順序と値で復元されること。 +- 空欄の入力枠が保存配列へ含まれないこと。 +- 一部項目だけ入力した枠、範囲外または整数でないport、6件以上を含む直接APIリクエストがエラーになること。 +- MCP設定を0件で保存しても、既存のWi-Fi、AI Service、Servo設定が維持されること。 +- 既存YAMLに6件以上あっても配列範囲外アクセスが発生せず、先頭5件だけが読み込まれること。 +- デスクトップ幅とスマートフォン幅で、4つのタブと5件分の入力欄に重なりや横方向のはみ出しがないこと。 +- `m5stack-core2-realtime` と `m5stack-cores3-realtime` をビルドし、埋め込みWeb UIとAPI変更を含めてコンパイルできること。 + +## 戻し方 + +`MCPs (Option)` タブとWeb UIのMCP入出力処理を削除し、`GET /config`、`POST /config`、`SC_ExConfig.yaml` 生成を従来の `llm.type` と `llm.enableMemory` のみに戻す。`LLM_N_MCP_SERVERS_MAX` を10へ戻し、`StackchanExConfig` の上限処理を戻す。MCP設定形式自体は変更しないため、データ移行は不要。 diff --git a/firmware/doc/fw_design.md b/firmware/doc/fw_design.md index e34bbce..94aece8 100644 --- a/firmware/doc/fw_design.md +++ b/firmware/doc/fw_design.md @@ -2,11 +2,16 @@ Notes on FW design, etc. - [Task](#task) -- [ESP-NOW Remote Control Mod](#esp-now-remote-control-mod) -- [Realtime API Function Calling](#realtime-api-function-calling) +- [Mod](#mod) + - [ESP-NOW Remote Control Mod](#esp-now-remote-control-mod) +- [Function Calling](#function-calling) - [Avatar Expression](#avatar-expression) +- [Wi-Fi config portal](#wi-fi-config-portal) - [Web App](#web-app) + - [Home page](#home-page) + - [Config page](#config-page) - [Personalize page](#personalize-page) +- [Status Monitor](#status-monitor) - [Head Touch Sensor](#head-touch-sensor) @@ -23,7 +28,8 @@ Notes on FW design, etc. | asyncTtsStreamTask | TTS streaming play | 5 * 1024 | 2 | | webSocketLoopTask | WebSocket processing for LLM Realtime API | 6 * 1024 | 3 | -## ESP-NOW Remote Control Mod +## Mod +### ESP-NOW Remote Control Mod 初期実装では `src/mod/EspNowRemote` に Receiver 固定の Mod を追加する。 @@ -37,7 +43,7 @@ Notes on FW design, etc. - ESP-NOW Mod 実行中は Wi-Fi channel 変更により Web/FTP/Realtime API と干渉する可能性がある。 - ESP-NOW Mod 離脱時は ESP-NOW を停止し、offline mode でなければ Wi-Fi STA の再接続を最大 5 秒待つ。 -## Realtime API Function Calling +## Function Calling ### Avatar Expression @@ -57,9 +63,166 @@ Realtime API ビルドでは、Function Calling により AI が会話中の感 - `json_Functions` は通常 ChatGPT や Gemini Live からも参照されるため、この関数の schema と実行処理は `#if defined(REALTIME_API)` で限定する。 - `systemRole_realtimeAvatarExpression` は Realtime 系 LLM の `load_role()` で `systemRole_memory` または `systemRole_noMemory` に追加する。 + +## Wi-Fi config portal + +SmartConfig は使わず、起動時の Wi-Fi 接続元は YAML の `wifi.ssid` / `wifi.password` のみにする。 + +- SD に設定 YAML がある場合は SD を優先する。 +- SD が無い、または主要 YAML が無い場合は SPIFFS の YAML を使う。 +- `SC_ExConfig.yaml`、`SC_SecConfig.yaml`、`SC_BasicConfig.yaml` の一式が揃っていない場合は通常機能を起動しない。 + - `REALTIME_API` ビルドでは、`SC_SecConfig.yaml` がある場合は Wi-Fi 接続だけ試し、成功したら STA 接続上で設定 Web を起動する。 + - `REALTIME_API` ビルドでは、Wi-Fi 接続できない場合、または `SC_SecConfig.yaml` も無い場合は Config AP を起動する。 + - `REALTIME_API` 以外のビルドでは、不完全な設定を画面に表示し、そのまま待機する。 +- 設定一式が揃っている通常起動時は、`wifi.ssid` が空、または接続に失敗した場合に画面上で Config AP 起動または Offline 起動を選ぶ。 +- Config AP は SSID `StackChanEx-Config-NNNNNN`、password `stackchan` で起動し、`http://192.168.4.1/` の QR コードを画面に表示する。SSID 末尾の 6 桁数字は AP 起動時に生成する。 +- AP モード中は会話機能は offline 相当として扱うが、Web サーバーは動かし続ける。 +- AtomS3R は画面が小さいため QR コードは表示せず、Wi-Fi 接続失敗時は Config AP を直接起動する。 + + ## Web App WebAPI.cpp のインラインアセンブラ(マクロ:IMPORT_FILE)で incbinフォルダ内のhtmlファイルやjsファイルをプログラム領域に埋め込む。 +### Home page +Web アプリの入口 `/` として `home.html` を返す。`home.html` には次の 2 つの導線を置く。 + +- Personalize + - `/personalize.html` に遷移する。 + - Role と Memory の管理を行う。 +- Config + - `/config.html` に遷移する。 + - Wi-Fi、API key、Realtime API 用 YAML 設定、SD から SPIFFS へのコピーを行う。 + +設定用 AP モードで QR に載せる URL も `/` とし、スマートフォンで開いた直後に用途を選べるようにする。 + +### Config page + +- Wi-Fi、AI Service、Servo、MCPs (Option) の設定領域をタブで切り替えて表示する。 +- タブを切り替えても未保存の入力値は保持し、Save は全タブの設定内容をまとめて保存する。 +- Save、Reload、Restart と処理結果のメッセージはタブ領域の外に配置し、どのタブからでも操作可能とする。 +- Reload は全タブの設定値を更新し、現在選択中のタブは維持する。 + +- ファイル構成 + - `incbin/config.html` + - `incbin/config.js` +- 言語 + - ページやダイアログ内の言語は英語版のみ。 +- 概要 + - 画面の入力内容を SC_SecConfig.yaml、SC_BasicConfig.yaml、SC_ExConfig.yaml のフォーマットにしてAPIで設定する。 + - 画面表示時、更新時、Reloadボタン押下時はAPIで設定値を取得して画面の入力値に反映する。 +- 画面構成 + - Wi-Fi + - SSID + - Password (値は'*'で隠す/表示するを切り換え可能とする) + - Realtime AI Service + - ドロップダウン内の以下サービスから選択 + - OpenAI Realtime + - Google Gemini Live + - API Key (値は'*'で隠す/表示するを切り換え可能とする) + - Enable Memory (true or false を設定) + - MCPs (Option) + - MCPサーバーを最大5件設定する。未使用の入力枠は保存しない。 + - 各サーバーに Name、Disabled、URL / Host、Port を設定する。 + - Disabled は `true` / `false` のドロップダウンで選択する。 + - Name、URL / Host、Port の一部だけが入力された場合は保存エラーとする。 + - Servo + - 以下項目を設定。詳細は [Servo Setting Details](#servo-setting-details) に記載 + - Type + - Pin + - Offset + - Center + - Lower limit + - Upper limit + - Enable Takao Base + - Save ボタン + - 画面の入力値をSC_SecConfig.yaml、SC_BasicConfig.yaml、SC_ExConfig.yaml のフォーマットにしてAPIで設定する。 + - Reload ボタン + - APIで現在の設定を取得し、画面の入力値を更新する。 + - Restart ボタン + - 設定内容をシステムに反映するためのシステムリセットを実行。 + +#### Servo Setting Details + +- Type + - ドロップダウン内の以下タイプから選択 + - PWM : SG90PWMServo + - SCS : Feetech SCS0009 + - DYN_XL330 : Dynamixel XL330 + - RT_DYN_XL330 : RTVersion + - M5_SCS : M5StackChan Servo +- Pin (x, y) + - 選んだTypeに応じて選択肢をドロップダウンで提示 (シリアルサーボは(x:RX, y:TX)に読み替え)。任意の値に変更も可。 + - PWM, SCS, DYN_XL330 + - Core2 PortA x:33, y:32 + - Core2 PortB x:36, y:26 + - Core2 PortC x:13, y:14 + - CoreS3 PortA x:2, y:1 + - CoreS3 PortB x:9, y:8 + - CoreS3 PortC x:17, y:18 + - RT_DYN_XL330 + - x:6, y:7 + - M5_SCS + - x:7, y:6 +- Offset (x, y) + - Typeによらないが、Typeを選んだときに x:0, y:0 に初期化。任意の値に変更も可。 +- Center (x, y) + - サーボの初期位置を、選択したTypeに応じて以下のように初期化。任意の値に変更も可。 + - PWM + - x:90, y:90 + - SCS + - x:150, y:150 + - DYN_XL330 + - x:180, y:270 + - RT_DYN_XL330 + - x:180, y:5 + - M5_SCS + - x:150, y:85 +- Lower limit (x, y) + - サーボの可動範囲の下限を、選択したTypeに応じて以下のように初期化。任意の値に変更も可。 + - PWM + - x:0, y:60 + - SCS + - x:0, y:120 + - DYN_XL330 + - x:0, y:220 + - RT_DYN_XL330 + - x:90, y:-5 + - M5_SCS + - x:0, y:0 +- Upper limit (x, y) + - サーボの可動範囲の上限を、選択したTypeに応じて以下のように初期化。任意の値に変更も可。 + - PWM + - x:180, y:90 + - SCS + - x:300, y:150 + - DYN_XL330 + - x:360, y:270 + - RT_DYN_XL330 + - x:270, y:15 + - M5_SCS + - x:300, y:90 +- Enable Takao Base + - ドロップダウンで true か false を選択。Type選択時は false に初期化。 + +#### API +- `GET /config`: JSONで現在の設定値を返す。SPIFFS の設定ファイルが無い場合はデフォルト値またはRAM上の設定値を返す。 + - `source`: `spiffs` または `none` + - `complete`: SPIFFS に `SC_SecConfig.yaml`、`SC_BasicConfig.yaml`、`SC_ExConfig.yaml` が揃っているか + - `sec`: Wi-Fi と API key + - `basic`: Servo 設定 + - `ex`: Realtime AI Service、Enable Memory、最大5件のMCPサーバー設定 +- `POST /config`: POST body の JSON を検証し、SPIFFS に `SC_SecConfig.yaml`、`SC_BasicConfig.yaml`、`SC_ExConfig.yaml` として保存する。 +- `POST /config/restart`: 設定反映のため再起動する。 + +#### 保存処理 +- `SC_SecConfig.yaml` は `StackchanExConfig::saveSecretConfigYaml()` が `deserializeYml()` で構文と `wifi` / `apikey` セクションを検証する。 +- `SC_BasicConfig.yaml` は Servo 関連、`takao_base`、`servo_type` のみを生成する。 +- `SC_ExConfig.yaml` は `llm.type`、`llm.enableMemory`、`llm.mcpServers` を生成する。MCPサーバーは最大5件とし、各要素に `name`、`disabled`、`url`、`port` を保存する。 +- `LLM_N_MCP_SERVERS_MAX` は5とし、設定読込、Web API、MCPクライアント配列で共通の上限として使用する。YAMLに6件以上ある場合は先頭5件だけを読み込む。 +- 保存後は RAM 上の `_secret_config` も更新するが、Wi-Fi 再接続は行わず Web UI から再起動を促す。 +- Save成功時は、設定した値を有効にするため Restart 前に SD カードを抜くよう画面に表示する。 + + ### Personalize page - ファイル構成 - incbin/personalize.html @@ -68,6 +231,7 @@ WebAPI.cpp のインラインアセンブラ(マクロ:IMPORT_FILE)で incbin - ページやダイアログ内の言語は英語版のみ。 - 画面構成 - Role (Custom Instructions) + - Memory #### Role (Custom Instructions) - 構成 @@ -88,6 +252,20 @@ WebAPI.cpp のインラインアセンブラ(マクロ:IMPORT_FILE)で incbin - 画面更新時の動作 - API /memory_get をPOSTし、記憶内容を取得してフォームに表示する。 +## Status Monitor + +`StatusMonitorMod` shows runtime state in tab form on the avatar sub-window. + +| Tab | Contents | +| --- | --- | +| System | Existing system status: firmware version, Wi-Fi IP/MAC, heap, battery. | +| AI Service | AI service name, memory enabled/disabled, MCP server list. | + +The AI Service tab reads `llm.type`, `llm.enableMemory`, and `llm.mcpServers` from `StackchanExConfig`. The old Function Call info view is not shown because it belongs to the previous Function Calling design. + +The graphical tab header is drawn through `Avatar::updateSubWindowCustom()`. This keeps Avatar running and lets `StatusMonitorMod` render directly into the SubWindow during the Avatar draw cycle. +The tabs are touch targets. `StatusMonitorMod` owns tab `box_t` hit areas aligned with the drawn tab rectangles, while physical BtnA/BtnC still move to the previous/next tab. + ## Head Touch Sensor `src/driver/HeadTouchSensor.*` provides the shared polling driver for the official CoreS3 head touch sensor. diff --git a/firmware/incbin/config.html b/firmware/incbin/config.html new file mode 100644 index 0000000..3ea4e3d --- /dev/null +++ b/firmware/incbin/config.html @@ -0,0 +1,393 @@ + + + + + Config - AI Stack-chan Ex + + + + + +
+
+

Config

+ Home +
+
+
+ + + + +
+ +
+

Wi-Fi

+
+
+ + +
+
+
+ + +
+ +
+
+
+ + + + + + + +
+ + + +
+
+
+
+
+ + diff --git a/firmware/incbin/config.js b/firmware/incbin/config.js new file mode 100644 index 0000000..d833632 --- /dev/null +++ b/firmware/incbin/config.js @@ -0,0 +1,416 @@ +document.addEventListener('DOMContentLoaded', function () { + const form = document.getElementById('configForm'); + const reloadButton = document.getElementById('reloadButton'); + const restartButton = document.getElementById('restartButton'); + const statusDiv = document.getElementById('status'); + const errorDiv = document.getElementById('error'); + const tabs = Array.from(document.querySelectorAll('[role="tab"]')); + const mcpFields = Array.from(document.querySelectorAll('[data-mcp-server]')).map(function (server) { + return { + name: server.querySelector('[data-mcp-name]'), + disabled: server.querySelector('[data-mcp-disabled]'), + url: server.querySelector('[data-mcp-url]'), + port: server.querySelector('[data-mcp-port]') + }; + }); + + const fields = { + wifiSsid: document.getElementById('wifiSsid'), + wifiPassword: document.getElementById('wifiPassword'), + aiService: document.getElementById('aiService'), + enableMemory: document.getElementById('enableMemory'), + aiApiKey: document.getElementById('aiApiKey'), + servoType: document.getElementById('servoType'), + takaoBase: document.getElementById('takaoBase'), + servoPinPreset: document.getElementById('servoPinPreset'), + pinX: document.getElementById('pinX'), + pinY: document.getElementById('pinY'), + offsetX: document.getElementById('offsetX'), + offsetY: document.getElementById('offsetY'), + centerX: document.getElementById('centerX'), + centerY: document.getElementById('centerY'), + lowerX: document.getElementById('lowerX'), + lowerY: document.getElementById('lowerY'), + upperX: document.getElementById('upperX'), + upperY: document.getElementById('upperY') + }; + + const servoPresets = { + PWM: { + pins: [ + ['Core2 PortA', 33, 32], + ['Core2 PortB', 36, 26], + ['Core2 PortC', 13, 14], + ['CoreS3 PortA', 2, 1], + ['CoreS3 PortB', 9, 8], + ['CoreS3 PortC', 17, 18] + ], + offset: [0, 0], + center: [90, 90], + lower: [0, 60], + upper: [180, 90], + takaoBase: false + }, + SCS: { + pins: [ + ['Core2 PortA', 33, 32], + ['Core2 PortB', 36, 26], + ['Core2 PortC', 13, 14], + ['CoreS3 PortA', 2, 1], + ['CoreS3 PortB', 9, 8], + ['CoreS3 PortC', 17, 18] + ], + offset: [0, 0], + center: [150, 150], + lower: [0, 120], + upper: [300, 150], + takaoBase: false + }, + DYN_XL330: { + pins: [ + ['Core2 PortA', 33, 32], + ['Core2 PortB', 36, 26], + ['Core2 PortC', 13, 14], + ['CoreS3 PortA', 2, 1], + ['CoreS3 PortB', 9, 8], + ['CoreS3 PortC', 17, 18] + ], + offset: [0, 0], + center: [180, 270], + lower: [0, 220], + upper: [360, 270], + takaoBase: false + }, + RT_DYN_XL330: { + pins: [['Default', 6, 7]], + offset: [0, 0], + center: [180, 5], + lower: [90, -5], + upper: [270, 15], + takaoBase: false + }, + M5_SCS: { + pins: [['Default', 7, 6]], + offset: [0, 0], + center: [150, 85], + lower: [0, 0], + upper: [300, 90], + takaoBase: false + } + }; + + let currentServoType = fields.servoType.value; + + function activateTab(tab, moveFocus) { + tabs.forEach(function (item) { + const selected = item === tab; + item.setAttribute('aria-selected', selected ? 'true' : 'false'); + item.tabIndex = selected ? 0 : -1; + document.getElementById(item.getAttribute('aria-controls')).hidden = !selected; + }); + if (moveFocus) { + tab.focus(); + } + } + + tabs.forEach(function (tab, index) { + tab.addEventListener('click', function () { + activateTab(tab, false); + }); + tab.addEventListener('keydown', function (event) { + let nextIndex = index; + if (event.key === 'ArrowRight') { + nextIndex = (index + 1) % tabs.length; + } else if (event.key === 'ArrowLeft') { + nextIndex = (index - 1 + tabs.length) % tabs.length; + } else if (event.key === 'Home') { + nextIndex = 0; + } else if (event.key === 'End') { + nextIndex = tabs.length - 1; + } else { + return; + } + event.preventDefault(); + activateTab(tabs[nextIndex], true); + }); + }); + + function showStatus(message) { + statusDiv.textContent = message; + errorDiv.textContent = ''; + } + + function showError(message) { + errorDiv.textContent = message; + statusDiv.textContent = ''; + } + + async function readTextResponse(res) { + const text = await res.text(); + if (!res.ok) { + throw new Error(text || ('HTTP ' + res.status)); + } + return text; + } + + function numberValue(input, fallback) { + const value = Number(input.value); + return Number.isFinite(value) ? value : fallback; + } + + function setNumber(input, value) { + input.value = Number.isFinite(Number(value)) ? String(value) : '0'; + } + + function setBoolSelect(select, value) { + select.value = value ? 'true' : 'false'; + } + + function boolSelectValue(select) { + return select.value === 'true'; + } + + function updatePinPresetOptions(type, selectedX, selectedY) { + const preset = servoPresets[type] || servoPresets.PWM; + fields.servoPinPreset.innerHTML = ''; + preset.pins.forEach(function (pinSet, index) { + const option = document.createElement('option'); + option.value = String(index); + option.textContent = pinSet[0] + ' x:' + pinSet[1] + ' y:' + pinSet[2]; + fields.servoPinPreset.appendChild(option); + if (pinSet[1] === selectedX && pinSet[2] === selectedY) { + fields.servoPinPreset.value = String(index); + } + }); + const match = preset.pins.some(function (pinSet) { + return pinSet[1] === selectedX && pinSet[2] === selectedY; + }); + if (!match) { + const custom = document.createElement('option'); + custom.value = 'custom'; + custom.textContent = 'Custom'; + fields.servoPinPreset.appendChild(custom); + fields.servoPinPreset.value = 'custom'; + } + } + + function applyServoPreset(type) { + const preset = servoPresets[type] || servoPresets.PWM; + const pins = preset.pins[0]; + fields.servoType.value = type; + updatePinPresetOptions(type, pins[1], pins[2]); + fields.servoPinPreset.value = '0'; + setNumber(fields.pinX, pins[1]); + setNumber(fields.pinY, pins[2]); + setNumber(fields.offsetX, preset.offset[0]); + setNumber(fields.offsetY, preset.offset[1]); + setNumber(fields.centerX, preset.center[0]); + setNumber(fields.centerY, preset.center[1]); + setNumber(fields.lowerX, preset.lower[0]); + setNumber(fields.lowerY, preset.lower[1]); + setNumber(fields.upperX, preset.upper[0]); + setNumber(fields.upperY, preset.upper[1]); + setBoolSelect(fields.takaoBase, preset.takaoBase); + currentServoType = type; + } + + function applyConfig(config) { + const sec = config.sec || {}; + const wifi = sec.wifi || {}; + const apikey = sec.apikey || {}; + const basic = config.basic || {}; + const servo = basic.servo || {}; + const ex = config.ex || {}; + const llm = ex.llm || {}; + + fields.wifiSsid.value = wifi.ssid || ''; + fields.wifiPassword.value = wifi.password || ''; + fields.aiApiKey.value = apikey.aiservice || ''; + fields.aiService.value = String(llm.type === 3 ? 3 : 0); + setBoolSelect(fields.enableMemory, !!llm.enableMemory); + const mcpServers = Array.isArray(llm.mcpServers) ? llm.mcpServers : []; + mcpFields.forEach(function (mcp, index) { + const server = mcpServers[index] || {}; + mcp.name.value = server.name || ''; + setBoolSelect(mcp.disabled, !!server.disabled); + mcp.url.value = server.url || ''; + mcp.port.value = Number.isInteger(Number(server.port)) && Number(server.port) > 0 + ? String(server.port) + : ''; + }); + + const type = basic.servo_type || 'PWM'; + fields.servoType.value = servoPresets[type] ? type : 'PWM'; + currentServoType = fields.servoType.value; + + const pin = servo.pin || {}; + const offset = servo.offset || {}; + const center = servo.center || {}; + const lower = servo.lower_limit || {}; + const upper = servo.upper_limit || {}; + const pinX = Number.isFinite(Number(pin.x)) ? Number(pin.x) : 33; + const pinY = Number.isFinite(Number(pin.y)) ? Number(pin.y) : 32; + updatePinPresetOptions(currentServoType, pinX, pinY); + setNumber(fields.pinX, pinX); + setNumber(fields.pinY, pinY); + setNumber(fields.offsetX, Number.isFinite(Number(offset.x)) ? Number(offset.x) : 0); + setNumber(fields.offsetY, Number.isFinite(Number(offset.y)) ? Number(offset.y) : 0); + setNumber(fields.centerX, Number.isFinite(Number(center.x)) ? Number(center.x) : 90); + setNumber(fields.centerY, Number.isFinite(Number(center.y)) ? Number(center.y) : 90); + setNumber(fields.lowerX, Number.isFinite(Number(lower.x)) ? Number(lower.x) : 0); + setNumber(fields.lowerY, Number.isFinite(Number(lower.y)) ? Number(lower.y) : 60); + setNumber(fields.upperX, Number.isFinite(Number(upper.x)) ? Number(upper.x) : 180); + setNumber(fields.upperY, Number.isFinite(Number(upper.y)) ? Number(upper.y) : 90); + setBoolSelect(fields.takaoBase, !!basic.takao_base); + } + + function collectConfig() { + const mcpServers = []; + mcpFields.forEach(function (mcp, index) { + const name = mcp.name.value.trim(); + const url = mcp.url.value.trim(); + const portText = mcp.port.value.trim(); + if (name === '' && url === '' && portText === '') { + return; + } + const port = Number(portText); + if (name === '' || url === '' || portText === '') { + throw new Error('MCP Server ' + (index + 1) + ': Name, URL / Host, and Port are required.'); + } + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error('MCP Server ' + (index + 1) + ': Port must be an integer from 1 to 65535.'); + } + mcpServers.push({ + name: name, + disabled: boolSelectValue(mcp.disabled), + url: url, + port: port + }); + }); + return { + sec: { + wifi: { + ssid: fields.wifiSsid.value, + password: fields.wifiPassword.value + }, + apikey: { + aiservice: fields.aiApiKey.value, + tts: '', + stt: '' + } + }, + basic: { + servo_type: fields.servoType.value, + takao_base: boolSelectValue(fields.takaoBase), + servo: { + pin: { + x: numberValue(fields.pinX, 33), + y: numberValue(fields.pinY, 32) + }, + offset: { + x: numberValue(fields.offsetX, 0), + y: numberValue(fields.offsetY, 0) + }, + center: { + x: numberValue(fields.centerX, 90), + y: numberValue(fields.centerY, 90) + }, + lower_limit: { + x: numberValue(fields.lowerX, 0), + y: numberValue(fields.lowerY, 60) + }, + upper_limit: { + x: numberValue(fields.upperX, 180), + y: numberValue(fields.upperY, 90) + } + } + }, + ex: { + llm: { + type: Number(fields.aiService.value), + enableMemory: boolSelectValue(fields.enableMemory), + mcpServers: mcpServers + } + } + }; + } + + async function loadConfig() { + try { + const res = await fetch('/config', { cache: 'no-store' }); + const text = await readTextResponse(res); + applyConfig(JSON.parse(text)); + showStatus('Loaded.'); + } catch (err) { + showError('Load failed: ' + err.message); + } + } + + document.querySelectorAll('[data-toggle-secret]').forEach(function (button) { + button.addEventListener('click', function () { + const input = document.getElementById(button.getAttribute('data-toggle-secret')); + if (input.type === 'password') { + input.type = 'text'; + button.textContent = 'Hide'; + } else { + input.type = 'password'; + button.textContent = 'Show'; + } + }); + }); + + fields.servoType.addEventListener('change', function () { + const nextType = fields.servoType.value; + if (window.confirm('Apply default values for the selected servo type? Current servo values will be overwritten.')) { + applyServoPreset(nextType); + } else { + fields.servoType.value = currentServoType; + } + }); + + fields.servoPinPreset.addEventListener('change', function () { + const preset = servoPresets[fields.servoType.value] || servoPresets.PWM; + const selected = fields.servoPinPreset.value; + if (selected === 'custom') { + return; + } + const pinSet = preset.pins[Number(selected)]; + if (pinSet) { + setNumber(fields.pinX, pinSet[1]); + setNumber(fields.pinY, pinSet[2]); + } + }); + + form.addEventListener('submit', async function (event) { + event.preventDefault(); + try { + const res = await fetch('/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json;charset=UTF-8' }, + body: JSON.stringify(collectConfig()) + }); + await readTextResponse(res); + showStatus('Saved. Remove the SD card before restarting to use the saved SPIFFS settings.'); + } catch (err) { + showError('Save failed: ' + err.message); + } + }); + + reloadButton.addEventListener('click', loadConfig); + + restartButton.addEventListener('click', async function () { + if (!window.confirm('Restart now?')) { + return; + } + try { + const res = await fetch('/config/restart', { method: 'POST' }); + await readTextResponse(res); + showStatus('Restarting...'); + } catch (err) { + showError('Restart request failed: ' + err.message); + } + }); + + applyServoPreset('PWM'); + loadConfig(); +}); diff --git a/firmware/incbin/home.html b/firmware/incbin/home.html new file mode 100644 index 0000000..a61c8bf --- /dev/null +++ b/firmware/incbin/home.html @@ -0,0 +1,89 @@ + + + + + AI Stack-chan Ex + + + + +
+

AI Stack-chan Ex

+

Open configuration or personalization.

+ +
+ + diff --git a/firmware/incbin/personalize.html b/firmware/incbin/personalize.html index 1b17eee..ad08218 100644 --- a/firmware/incbin/personalize.html +++ b/firmware/incbin/personalize.html @@ -19,6 +19,21 @@ font-size: 1.5em; margin-top: 0; } + header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 1em; + } + header h1 { + margin: 0; + } + a { + color: #0077cc; + font-weight: bold; + text-decoration: none; + } label { display: block; margin-bottom: 0.5em; @@ -97,8 +112,11 @@ -

Personalize

+
+

Personalize

+ Home +

Role (Custom Instructions)

diff --git a/firmware/lib/m5stack-avatar/src/Avatar.cpp b/firmware/lib/m5stack-avatar/src/Avatar.cpp index cf2a4da..960eda1 100644 --- a/firmware/lib/m5stack-avatar/src/Avatar.cpp +++ b/firmware/lib/m5stack-avatar/src/Avatar.cpp @@ -265,6 +265,14 @@ void Avatar::updateSubWindowQrcode(String& txt, int top) { face->subWindowPos->setSize(0, 0); } +//motoh +void Avatar::updateSubWindowCustom(SubWindowDrawCallback callback, void *userData, + int top, int left, int width, int height) { + face->subWindow->updateDrawContentCustom(callback, userData); + face->subWindowPos->setPosition(top, left); + face->subWindowPos->setSize(width, height); +} + //motoh void Avatar::set_isSubWindowEnable(bool isEnable){ face->subWindow->set_isDrawEnable(isEnable); diff --git a/firmware/lib/m5stack-avatar/src/Avatar.h b/firmware/lib/m5stack-avatar/src/Avatar.h index 86e303f..c01737c 100644 --- a/firmware/lib/m5stack-avatar/src/Avatar.h +++ b/firmware/lib/m5stack-avatar/src/Avatar.h @@ -70,6 +70,9 @@ class Avatar { bool updateSubWindowJpg(String& fname); //motoh void updateSubWindowTxt(String txt, int top = 0, int left = 0, int width = 320, int height = 240); //motoh void updateSubWindowQrcode(String& txt, int top = 0); //motoh + void updateSubWindowCustom(SubWindowDrawCallback callback, void *userData, + int top = 0, int left = 0, + int width = 320, int height = 240); //motoh void set_isSubWindowEnable(bool isEnable); //motoh void setFaceOffsetX(int16_t offset_x); //motoh void setFaceOffsetY(int16_t offset_y); //motoh diff --git a/firmware/lib/m5stack-avatar/src/SubWindow.cpp b/firmware/lib/m5stack-avatar/src/SubWindow.cpp index f510781..376fba1 100644 --- a/firmware/lib/m5stack-avatar/src/SubWindow.cpp +++ b/firmware/lib/m5stack-avatar/src/SubWindow.cpp @@ -11,7 +11,9 @@ namespace m5avatar { SubWindow::SubWindow() : drawType(SUB_DRAW_TYPE_NONE), isDrawEnable(false), - drawingBufIdx(0) + drawingBufIdx(0), + customDrawCallback(nullptr), + customDrawUserData(nullptr) { //LCD_WIDTH = 320; //LCD_HEIGHT = 240; @@ -112,6 +114,9 @@ void SubWindow::draw(M5Canvas *spi, BoundingRect rect, DrawContext *ctx) { else if(drawType == SUB_DRAW_TYPE_TXT){ pushSpriteTxt(spi, rect, ctx, subWdTxtBuf); } + else if(drawType == SUB_DRAW_TYPE_CUSTOM && customDrawCallback != nullptr){ + customDrawCallback(spi, rect, ctx, customDrawUserData); + } else if(drawType == SUB_DRAW_TYPE_QRCODE){ int y = rect.getTop(); if(y != 0){ @@ -132,6 +137,8 @@ void SubWindow::set_isDrawEnable(bool _isDrawEnable){ isDrawEnable = _isDrawEnable; if(!isDrawEnable){ drawType = SUB_DRAW_TYPE_NONE; + customDrawCallback = nullptr; + customDrawUserData = nullptr; } } @@ -233,6 +240,24 @@ void SubWindow::updateDrawContentQrcode(String txt){ // SDカードのファイルを配列にコピー +void SubWindow::updateDrawContentCustom(SubWindowDrawCallback callback, void *userData){ + if((drawType != SUB_DRAW_TYPE_NONE) && (drawType != SUB_DRAW_TYPE_CUSTOM)){ + Serial.println("Can't update sub window (sub window is used for another type"); + return ; + } + + if(callback == nullptr){ + drawType = SUB_DRAW_TYPE_NONE; + customDrawCallback = nullptr; + customDrawUserData = nullptr; + return ; + } + + drawType = SUB_DRAW_TYPE_CUSTOM; + customDrawCallback = callback; + customDrawUserData = userData; +} + size_t SubWindow::copySDFileToRAM(const char *path, uint8_t *out, int outBufSize) { size_t error = 0; size_t readed_size; diff --git a/firmware/lib/m5stack-avatar/src/SubWindow.h b/firmware/lib/m5stack-avatar/src/SubWindow.h index 4f7e9e9..890d72f 100644 --- a/firmware/lib/m5stack-avatar/src/SubWindow.h +++ b/firmware/lib/m5stack-avatar/src/SubWindow.h @@ -20,9 +20,13 @@ typedef enum e_sub_draw_type SUB_DRAW_TYPE_CAM565, SUB_DRAW_TYPE_JPG, SUB_DRAW_TYPE_TXT, - SUB_DRAW_TYPE_QRCODE + SUB_DRAW_TYPE_QRCODE, + SUB_DRAW_TYPE_CUSTOM } SUB_DRAW_TYPE; +typedef void (*SubWindowDrawCallback)(M5Canvas *spi, BoundingRect rect, + DrawContext *ctx, void *userData); + class SubWindow final : public Drawable { private: uint16_t r; @@ -42,6 +46,8 @@ class SubWindow final : public Drawable { //テキスト表示用 String subWdTxtBuf; bool isDrawEnable; + SubWindowDrawCallback customDrawCallback; + void *customDrawUserData; uint16_t drawType; //画像 or テキスト M5Canvas *spriteTxt; //テキスト表示用のスプライト @@ -62,6 +68,7 @@ class SubWindow final : public Drawable { bool updateDrawContentJpg(String& fname); void updateDrawContentTxt(String txt); void updateDrawContentQrcode(String txt); + void updateDrawContentCustom(SubWindowDrawCallback callback, void *userData); }; } // namespace m5avatar diff --git a/firmware/src/StackchanExConfig.cpp b/firmware/src/StackchanExConfig.cpp index 42ce3bd..4878120 100644 --- a/firmware/src/StackchanExConfig.cpp +++ b/firmware/src/StackchanExConfig.cpp @@ -4,6 +4,141 @@ StackchanExConfig::StackchanExConfig() {}; StackchanExConfig::~StackchanExConfig() {}; +bool StackchanExConfig::parseSecretConfigYaml(const String& yaml, DynamicJsonDocument& doc, String* error) +{ + DeserializationError err = deserializeYml(doc, yaml.c_str()); + if(err){ + if(error != nullptr){ + *error = String("YAML parse error: ") + err.c_str(); + } + return false; + } + + if(!doc["wifi"].is()){ + if(error != nullptr){ + *error = "Missing wifi section"; + } + return false; + } + if(!doc["apikey"].is()){ + if(error != nullptr){ + *error = "Missing apikey section"; + } + return false; + } + return true; +} + +bool StackchanExConfig::validateSecretConfigYaml(const String& yaml, String* error) +{ + DynamicJsonDocument doc(2048); + if(!parseSecretConfigYaml(yaml, doc, error)){ + return false; + } + + JsonObject wifi = doc["wifi"]; + JsonObject apikey = doc["apikey"]; + const bool has_wifi_keys = wifi.containsKey("ssid") && wifi.containsKey("password"); + const bool has_apikey_keys = apikey.containsKey("aiservice") + && apikey.containsKey("tts") + && apikey.containsKey("stt"); + if(!has_wifi_keys || !has_apikey_keys){ + if(error != nullptr){ + *error = "SC_SecConfig.yaml must contain wifi.ssid/password and apikey.aiservice/tts/stt"; + } + return false; + } + return true; +} + +bool StackchanExConfig::writeFileAtomic(fs::FS& fs, const char* path, const String& data, String* error) +{ + String temp_path = String(path) + ".tmp"; + File temp = fs.open(temp_path.c_str(), FILE_WRITE); + if(!temp){ + if(error != nullptr){ + *error = String("Cannot open temp file: ") + temp_path; + } + return false; + } + + size_t written = temp.print(data); + temp.flush(); + temp.close(); + if(written != data.length()){ + fs.remove(temp_path.c_str()); + if(error != nullptr){ + *error = "Failed to write full YAML data"; + } + return false; + } + + if(fs.exists(path)){ + fs.remove(path); + } + if(!fs.rename(temp_path.c_str(), path)){ + fs.remove(temp_path.c_str()); + if(error != nullptr){ + *error = String("Failed to rename temp file to ") + path; + } + return false; + } + return true; +} + +bool StackchanExConfig::saveSecretConfigYaml(fs::FS& fs, const char* path, const String& yaml, + String* error, bool apply_now) +{ + DynamicJsonDocument doc(2048); + if(!parseSecretConfigYaml(yaml, doc, error)){ + return false; + } + if(!validateSecretConfigYaml(yaml, error)){ + return false; + } + if(!writeFileAtomic(fs, path, yaml, error)){ + return false; + } + if(apply_now){ + setSecretConfig(doc); + } + return true; +} + +bool StackchanExConfig::loadSecretConfigYaml(fs::FS& fs, const char* path, uint32_t yaml_size) +{ + loadSecretConfig(fs, path, yaml_size); + return true; +} + +String StackchanExConfig::quoteYamlString(const String& value) +{ + String quoted = "\""; + for(size_t i = 0; i < value.length(); i++){ + char c = value.charAt(i); + if(c == '\\' || c == '"'){ + quoted += '\\'; + } + quoted += c; + } + quoted += "\""; + return quoted; +} + +String StackchanExConfig::exportSecretConfigYaml(bool mask_secret) +{ + const String masked = "********"; + String yaml = ""; + yaml += "wifi:\n"; + yaml += " ssid: " + quoteYamlString(_secret_config.wifi_info.ssid) + "\n"; + yaml += " password: " + quoteYamlString(mask_secret ? masked : _secret_config.wifi_info.password) + "\n"; + yaml += "apikey:\n"; + yaml += " aiservice: " + quoteYamlString(mask_secret ? masked : _secret_config.api_key.ai_service) + "\n"; + yaml += " tts: " + quoteYamlString(mask_secret ? masked : _secret_config.api_key.tts) + "\n"; + yaml += " stt: " + quoteYamlString(mask_secret ? masked : _secret_config.api_key.stt) + "\n"; + return yaml; +} + void StackchanExConfig::basicConfigNotFoundCallback(void) { @@ -144,7 +279,10 @@ void StackchanExConfig::setExtendSettings(DynamicJsonDocument doc) { _ex_parameters.llm.type = doc["llm"]["type"].as(); _ex_parameters.llm.model = doc["llm"]["model"].as(); - _ex_parameters.llm.nMcpServers = doc["llm"]["mcpServers"].size(); + int nMcpServers = doc["llm"]["mcpServers"].size(); + _ex_parameters.llm.nMcpServers = nMcpServers < LLM_N_MCP_SERVERS_MAX + ? nMcpServers + : LLM_N_MCP_SERVERS_MAX; for(int i=0; i<_ex_parameters.llm.nMcpServers; i++){ _ex_parameters.llm.mcpServer[i].name = doc["llm"]["mcpServers"][i]["name"].as(); _ex_parameters.llm.mcpServer[i].disabled = doc["llm"]["mcpServers"][i]["disabled"].as(); diff --git a/firmware/src/StackchanExConfig.h b/firmware/src/StackchanExConfig.h index 52591e9..483b287 100644 --- a/firmware/src/StackchanExConfig.h +++ b/firmware/src/StackchanExConfig.h @@ -33,7 +33,7 @@ #define LLM_TYPE_MODULE_LLM_FNCL 2 #define LLM_TYPE_GEMINI 3 #define LLM_TYPE_CUSTOM_OPENAI 4 -#define LLM_N_MCP_SERVERS_MAX 10 +#define LLM_N_MCP_SERVERS_MAX 5 #define TTS_TYPE_WEB_VOICEVOX 0 #define TTS_TYPE_ELEVENLABS 1 @@ -115,6 +115,12 @@ class StackchanExConfig : public StackchanSystemConfig ex_config_s getExConfig() { return _ex_parameters; } void setExConfig(ex_config_s config) { _ex_parameters = config; } + bool validateSecretConfigYaml(const String& yaml, String* error = nullptr); + bool saveSecretConfigYaml(fs::FS& fs, const char* path, const String& yaml, + String* error = nullptr, bool apply_now = true); + bool loadSecretConfigYaml(fs::FS& fs, const char* path, uint32_t yaml_size = 2048); + String exportSecretConfigYaml(bool mask_secret = false); + void basicConfigNotFoundCallback(void) override; void secretConfigNotFoundCallback(void) override; void extendConfigNotFoundCallback(void); @@ -123,6 +129,9 @@ class StackchanExConfig : public StackchanSystemConfig // Read a PEM file from _extend_fs and append it to _ex_parameters.llm.customRootCA, // building one bundle that may hold several concatenated root certificates. void appendRootCAFromFile(const String& ca_path); + bool parseSecretConfigYaml(const String& yaml, DynamicJsonDocument& doc, String* error); + bool writeFileAtomic(fs::FS& fs, const char* path, const String& data, String* error); + String quoteYamlString(const String& value); }; diff --git a/firmware/src/WebAPI.cpp b/firmware/src/WebAPI.cpp index 2857b28..ae601ae 100644 --- a/firmware/src/WebAPI.cpp +++ b/firmware/src/WebAPI.cpp @@ -1,7 +1,10 @@ #include #include +#include +#include #include "WebAPI.h" #include "Avatar.h" +#include "StackchanExConfig.h" #include "llm/ChatGPT/ChatGPT.h" #include "llm/ChatGPT/FunctionCall.h" #include "Robot.h" @@ -10,6 +13,7 @@ using namespace m5avatar; extern Avatar avatar; extern uint8_t m5spk_virtual_channel; extern String STT_API_KEY; +extern StackchanExConfig system_config; ESP32WebServer server(80); @@ -22,108 +26,6 @@ static const char HEAD[] PROGMEM = R"KEWL( AIスタックチャン )KEWL"; -static const char APIKEY_HTML[] PROGMEM = R"KEWL( - - - - - APIキー設定 - - -

APIキー設定

- - -
- -
- -
- - - - -)KEWL"; - -#if 0 -static const char ROLE_HTML[] PROGMEM = R"KEWL( - - - - ロール設定 - - - - - -

ロール設定

-
-
-

- -
- - -)KEWL"; -#endif #define IMPORT_FILE(section, filename, symbol) \ static constexpr const char* filename_##symbol = filename; \ @@ -139,15 +41,236 @@ asm(\ ".balign 4\n"\ ".section \".text\"\n") -//IMPORT_FILE(.rodata, "index.html", index_html); +IMPORT_FILE(.rodata, "home.html", home_html); +IMPORT_FILE(.rodata, "config.html", config_html); +IMPORT_FILE(.rodata, "config.js", config_js); IMPORT_FILE(.rodata, "personalize.html", personalize_html); IMPORT_FILE(.rodata, "personalize.js", personalize_js); +static const char* SPIFFS_SEC_CONFIG_PATH = "/SC_SecConfig.yaml"; +static const char* SPIFFS_BASIC_CONFIG_PATH = "/SC_BasicConfig.yaml"; +static const char* SPIFFS_EX_CONFIG_PATH = "/SC_ExConfig.yaml"; + +String read_text_file(fs::FS& fs, const char* path) +{ + File file = fs.open(path, FILE_READ); + if(!file){ + return ""; + } + String data = file.readString(); + file.close(); + return data; +} + +bool write_text_file_atomic(fs::FS& fs, const char* path, const String& data, String* error) +{ + String temp_path = String(path) + ".tmp"; + File temp = fs.open(temp_path.c_str(), FILE_WRITE); + if(!temp){ + if(error != nullptr){ + *error = String("Cannot open temp file: ") + temp_path; + } + return false; + } + size_t written = temp.print(data); + temp.flush(); + temp.close(); + if(written != data.length()){ + fs.remove(temp_path.c_str()); + if(error != nullptr){ + *error = String("Failed to write full data: ") + path; + } + return false; + } + if(fs.exists(path)){ + fs.remove(path); + } + if(!fs.rename(temp_path.c_str(), path)){ + fs.remove(temp_path.c_str()); + if(error != nullptr){ + *error = String("Failed to rename temp file: ") + path; + } + return false; + } + return true; +} + +bool parse_yaml_file(fs::FS& fs, const char* path, DynamicJsonDocument& doc) +{ + String yaml = read_text_file(fs, path); + if(yaml.length() == 0){ + return false; + } + return !deserializeYml(doc, yaml.c_str()); +} + +String quote_yaml_string(const String& value) +{ + String quoted = "\""; + for(size_t i = 0; i < value.length(); i++){ + char c = value.charAt(i); + if(c == '\\' || c == '"'){ + quoted += '\\'; + } + quoted += c; + } + quoted += "\""; + return quoted; +} + +String json_string_or_empty(JsonVariantConst value) +{ + if(value.is()){ + return value.as(); + } + return ""; +} + +int json_int_or(JsonVariantConst value, int default_value) +{ + if(value.is()){ + return value.as(); + } + return default_value; +} + +bool json_bool_or(JsonVariantConst value, bool default_value) +{ + if(value.is()){ + return value.as(); + } + return default_value; +} + +bool validate_mcp_servers(JsonObjectConst llm, String* error) +{ + JsonVariantConst value = llm["mcpServers"]; + if(value.isNull()){ + return true; + } + if(!value.is()){ + *error = "llm.mcpServers must be an array"; + return false; + } + JsonArrayConst servers = value.as(); + if(servers.size() > LLM_N_MCP_SERVERS_MAX){ + *error = "llm.mcpServers must contain at most " + String(LLM_N_MCP_SERVERS_MAX) + " servers"; + return false; + } + int index = 0; + for(JsonVariantConst item : servers){ + if(!item.is()){ + *error = "llm.mcpServers[" + String(index) + "] must be an object"; + return false; + } + JsonObjectConst server = item.as(); + if(!server["name"].is() || server["name"].as().length() == 0){ + *error = "llm.mcpServers[" + String(index) + "].name is required"; + return false; + } + if(!server["disabled"].is()){ + *error = "llm.mcpServers[" + String(index) + "].disabled must be true or false"; + return false; + } + if(!server["url"].is() || server["url"].as().length() == 0){ + *error = "llm.mcpServers[" + String(index) + "].url is required"; + return false; + } + if(!server["port"].is()){ + *error = "llm.mcpServers[" + String(index) + "].port must be an integer"; + return false; + } + int port = server["port"].as(); + if(port < 1 || port > 65535){ + *error = "llm.mcpServers[" + String(index) + "].port must be from 1 to 65535"; + return false; + } + index++; + } + return true; +} + +String build_secret_yaml(JsonObjectConst sec) +{ + JsonObjectConst wifi = sec["wifi"]; + JsonObjectConst apikey = sec["apikey"]; + String yaml; + yaml += "wifi:\n"; + yaml += " ssid: " + quote_yaml_string(json_string_or_empty(wifi["ssid"])) + "\n"; + yaml += " password: " + quote_yaml_string(json_string_or_empty(wifi["password"])) + "\n"; + yaml += "apikey:\n"; + yaml += " aiservice: " + quote_yaml_string(json_string_or_empty(apikey["aiservice"])) + "\n"; + yaml += " tts: " + quote_yaml_string(json_string_or_empty(apikey["tts"])) + "\n"; + yaml += " stt: " + quote_yaml_string(json_string_or_empty(apikey["stt"])) + "\n"; + return yaml; +} + +String build_basic_yaml(JsonObjectConst basic) +{ + JsonObjectConst servo = basic["servo"]; + String servo_type = json_string_or_empty(basic["servo_type"]); + if(servo_type.length() == 0){ + servo_type = "PWM"; + } + String yaml; + yaml += "servo:\n"; + yaml += " pin:\n"; + yaml += " x: " + String(json_int_or(servo["pin"]["x"], 33)) + "\n"; + yaml += " y: " + String(json_int_or(servo["pin"]["y"], 32)) + "\n"; + yaml += " offset:\n"; + yaml += " x: " + String(json_int_or(servo["offset"]["x"], 0)) + "\n"; + yaml += " y: " + String(json_int_or(servo["offset"]["y"], 0)) + "\n"; + yaml += " center:\n"; + yaml += " x: " + String(json_int_or(servo["center"]["x"], 90)) + "\n"; + yaml += " y: " + String(json_int_or(servo["center"]["y"], 90)) + "\n"; + yaml += " lower_limit:\n"; + yaml += " x: " + String(json_int_or(servo["lower_limit"]["x"], 0)) + "\n"; + yaml += " y: " + String(json_int_or(servo["lower_limit"]["y"], 60)) + "\n"; + yaml += " upper_limit:\n"; + yaml += " x: " + String(json_int_or(servo["upper_limit"]["x"], 180)) + "\n"; + yaml += " y: " + String(json_int_or(servo["upper_limit"]["y"], 90)) + "\n"; + yaml += "takao_base: "; + yaml += json_bool_or(basic["takao_base"], false) ? "true\n" : "false\n"; + yaml += "servo_type: " + quote_yaml_string(servo_type) + "\n"; + return yaml; +} + +String build_extend_yaml(JsonObjectConst ex) +{ + JsonObjectConst llm = ex["llm"]; + String yaml; + yaml += "llm:\n"; + yaml += " type: " + String(json_int_or(llm["type"], LLM_TYPE_CHATGPT)) + "\n"; + yaml += " enableMemory: "; + yaml += json_bool_or(llm["enableMemory"], false) ? "true\n" : "false\n"; + JsonArrayConst servers = llm["mcpServers"].as(); + if(servers.size() == 0){ + yaml += " mcpServers: []\n"; + }else{ + yaml += " mcpServers:\n"; + for(JsonObjectConst server : servers){ + yaml += " - name: " + quote_yaml_string(server["name"].as()) + "\n"; + yaml += " disabled: "; + yaml += server["disabled"].as() ? "true\n" : "false\n"; + yaml += " url: " + quote_yaml_string(server["url"].as()) + "\n"; + yaml += " port: " + String(server["port"].as()) + "\n"; + } + } + return yaml; +} void handleRoot() { //Serial.println("handleRoot"); //server.send(200, "text/plain", "hello from m5stack!"); - server.send_P(200, "text/html", (const char*)personalize_html, (size_t)sizeof_personalize_html); + server.send_P(200, "text/html", (const char*)home_html, (size_t)sizeof_home_html); +} + +void handle_config_html() { + server.send_P(200, "text/html", (const char*)config_html, (size_t)sizeof_config_html); +} + +void handle_config_js() { + server.send_P(200, "application/javascript", (const char*)config_js, (size_t)sizeof_config_js); } void handle_personalize_html() { @@ -175,6 +298,10 @@ void handleNotFound(){ } void handle_speech() { + if(robot == nullptr){ + server.send(503, "text/plain", String("Robot is not ready")); + return; + } String message = server.arg("say"); String speaker = server.arg("voice"); //if(speaker != "") { @@ -190,6 +317,10 @@ void handle_speech() { } void handle_chat() { + if(robot == nullptr){ + server.send(503, "text/plain", String("Robot is not ready")); + return; + } static String response = ""; // tts_parms_no = 1; String text = server.arg("text"); @@ -203,43 +334,11 @@ void handle_chat() { server.send(200, "text/html", String(HEAD)+String("")+response+String("")); } -void handle_apikey() { - // ファイルを読み込み、クライアントに送信する - server.send(200, "text/html", APIKEY_HTML); -} - -#if 0 -void handle_apikey_set() { - // POST以外は拒否 - if (server.method() != HTTP_POST) { +void handle_role_set() { + if(robot == nullptr || robot->llm == nullptr){ + server.send(503, "text/plain", String("LLM is not ready")); return; } - // openai - String openai = server.arg("openai"); - // voicetxt - String voicevox = server.arg("voicevox"); - // voicetxt - String sttapikey = server.arg("sttapikey"); - - OPENAI_API_KEY = openai; - VOICEVOX_API_KEY = voicevox; - STT_API_KEY = sttapikey; - Serial.println(openai); - Serial.println(voicevox); - Serial.println(sttapikey); - - uint32_t nvs_handle; - if (ESP_OK == nvs_open("apikey", NVS_READWRITE, &nvs_handle)) { - nvs_set_str(nvs_handle, "openai", openai.c_str()); - nvs_set_str(nvs_handle, "voicevox", voicevox.c_str()); - nvs_set_str(nvs_handle, "sttapikey", sttapikey.c_str()); - nvs_close(nvs_handle); - } - server.send(200, "text/plain", String("OK")); -} -#endif - -void handle_role_set() { String html = ""; // POST以外は拒否 @@ -250,45 +349,40 @@ void handle_role_set() { // JSONデータをSPIFFSに保存 if(robot->llm->save_userRole(role)){ -#if 0 - // 整形したJSONデータを出力するHTMLデータを作成する - serializeJsonPretty(robot->llm->get_chat_doc(), html); - html = "
" + html + "
"; - //Serial.println(html); -#endif server.send(200, "text/plain", String("Role set successful")); } else{ //html = "Failed to save role to SPIFFS."; server.send(500, "text/plain", String("Role set failed")); } - - // HTMLデータをシリアルに出力する - //server.send(200, "text/html", html); }; void handle_role_get() { -#if 0 - String html = ""; - serializeJsonPretty(robot->llm->get_chat_doc(), html); - html = "
" + html + "
"; + if(robot == nullptr || robot->llm == nullptr){ + server.send(503, "text/plain", String("LLM is not ready")); + return; + } - // HTMLデータをシリアルに出力する - //Serial.println(html); - server.send(200, "text/html", String(HEAD) + html); -#endif Serial.println("http request: handle_role_get"); Serial.println(robot->llm->get_userRole()); server.send(200, "text/plain", robot->llm->get_userRole()); }; void handle_memory_get() { + if(robot == nullptr || robot->llm == nullptr){ + server.send(503, "text/plain", String("LLM is not ready")); + return; + } Serial.println("http request: handle_memory_get"); Serial.println(robot->llm->get_userInfo()); server.send(200, "text/plain", robot->llm->get_userInfo()); }; void handle_memory_clear() { + if(robot == nullptr || robot->llm == nullptr){ + server.send(503, "text/plain", String("LLM is not ready")); + return; + } Serial.println("http request: handle_memory_clear"); bool result = robot->llm->clear_userInfo(); if(result){ @@ -314,46 +408,158 @@ void handle_face() { server.send(200, "text/plain", String("OK")); } -#if 0 -void handle_setting() { - String value = server.arg("volume"); - String led = server.arg("led"); - String speaker = server.arg("speaker"); -// volume = volume + "\n"; - Serial.println(speaker); - Serial.println(value); - size_t speaker_no; - - if(speaker != ""){ - speaker_no = speaker.toInt(); - if(speaker_no > 60) { - speaker_no = 60; +void handle_config_get() { + DynamicJsonDocument response(8192); + bool has_sec = SPIFFS.exists(SPIFFS_SEC_CONFIG_PATH); + bool has_basic = SPIFFS.exists(SPIFFS_BASIC_CONFIG_PATH); + bool has_ex = SPIFFS.exists(SPIFFS_EX_CONFIG_PATH); + response["source"] = (has_sec || has_basic || has_ex) ? "spiffs" : "none"; + response["complete"] = has_sec && has_basic && has_ex; + + JsonObject sec = response["sec"].to(); + JsonObject wifi = sec["wifi"].to(); + JsonObject apikey = sec["apikey"].to(); + wifi["ssid"] = ""; + wifi["password"] = ""; + apikey["aiservice"] = ""; + apikey["tts"] = ""; + apikey["stt"] = ""; + DynamicJsonDocument sec_doc(2048); + if(parse_yaml_file(SPIFFS, SPIFFS_SEC_CONFIG_PATH, sec_doc)){ + wifi["ssid"] = sec_doc["wifi"]["ssid"].as(); + wifi["password"] = sec_doc["wifi"]["password"].as(); + apikey["aiservice"] = sec_doc["apikey"]["aiservice"].as(); + apikey["tts"] = sec_doc["apikey"]["tts"].as(); + apikey["stt"] = sec_doc["apikey"]["stt"].as(); + }else{ + secret_config_s* secret = system_config.getSecretSetting(); + if(secret != nullptr){ + wifi["ssid"] = secret->wifi_info.ssid; + wifi["password"] = secret->wifi_info.password; + apikey["aiservice"] = secret->api_key.ai_service; + apikey["tts"] = secret->api_key.tts; + apikey["stt"] = secret->api_key.stt; + } + } + + JsonObject basic = response["basic"].to(); + JsonObject servo = basic["servo"].to(); + servo["pin"]["x"] = 33; + servo["pin"]["y"] = 32; + servo["offset"]["x"] = 0; + servo["offset"]["y"] = 0; + servo["center"]["x"] = 90; + servo["center"]["y"] = 90; + servo["lower_limit"]["x"] = 0; + servo["lower_limit"]["y"] = 60; + servo["upper_limit"]["x"] = 180; + servo["upper_limit"]["y"] = 90; + basic["takao_base"] = false; + basic["servo_type"] = "PWM"; + DynamicJsonDocument basic_doc(2048); + if(parse_yaml_file(SPIFFS, SPIFFS_BASIC_CONFIG_PATH, basic_doc)){ + servo["pin"]["x"] = basic_doc["servo"]["pin"]["x"] | 33; + servo["pin"]["y"] = basic_doc["servo"]["pin"]["y"] | 32; + servo["offset"]["x"] = basic_doc["servo"]["offset"]["x"] | 0; + servo["offset"]["y"] = basic_doc["servo"]["offset"]["y"] | 0; + servo["center"]["x"] = basic_doc["servo"]["center"]["x"] | 90; + servo["center"]["y"] = basic_doc["servo"]["center"]["y"] | 90; + servo["lower_limit"]["x"] = basic_doc["servo"]["lower_limit"]["x"] | 0; + servo["lower_limit"]["y"] = basic_doc["servo"]["lower_limit"]["y"] | 60; + servo["upper_limit"]["x"] = basic_doc["servo"]["upper_limit"]["x"] | 180; + servo["upper_limit"]["y"] = basic_doc["servo"]["upper_limit"]["y"] | 90; + basic["takao_base"] = basic_doc["takao_base"] | false; + basic["servo_type"] = basic_doc["servo_type"].as().length() > 0 + ? basic_doc["servo_type"].as() + : String("PWM"); + } + + JsonObject ex = response["ex"].to(); + JsonObject llm = ex["llm"].to(); + llm["type"] = LLM_TYPE_CHATGPT; + llm["enableMemory"] = false; + JsonArray mcp_servers = llm["mcpServers"].to(); + DynamicJsonDocument ex_doc(4096); + if(parse_yaml_file(SPIFFS, SPIFFS_EX_CONFIG_PATH, ex_doc)){ + llm["type"] = ex_doc["llm"]["type"] | LLM_TYPE_CHATGPT; + llm["enableMemory"] = ex_doc["llm"]["enableMemory"] | false; + JsonArrayConst stored_servers = ex_doc["llm"]["mcpServers"].as(); + int count = 0; + for(JsonObjectConst stored_server : stored_servers){ + if(count >= LLM_N_MCP_SERVERS_MAX){ + break; + } + JsonObject server = mcp_servers.createNestedObject(); + server["name"] = stored_server["name"].as(); + server["disabled"] = stored_server["disabled"] | false; + server["url"] = stored_server["url"].as(); + server["port"] = stored_server["port"] | 0; + count++; } - TTS_SPEAKER_NO = String(speaker_no); - TTS_PARMS = TTS_SPEAKER + TTS_SPEAKER_NO; - } - - if(value == "") value = "180"; - size_t volume = value.toInt(); - uint8_t led_onoff = 0; - uint32_t nvs_handle; - if (ESP_OK == nvs_open("setting", NVS_READWRITE, &nvs_handle)) { - if(volume > 255) volume = 255; - nvs_set_u32(nvs_handle, "volume", volume); - if(led != "") { - if(led == "on") led_onoff = 1; - else led_onoff = 0; - nvs_set_u8(nvs_handle, "led", led_onoff); + }else{ + ex_config_s ex_config = system_config.getExConfig(); + if(ex_config.llm.type == LLM_TYPE_CHATGPT || ex_config.llm.type == LLM_TYPE_GEMINI){ + llm["type"] = ex_config.llm.type; } - nvs_set_u8(nvs_handle, "speaker", speaker_no); + llm["enableMemory"] = ex_config.llm.enableMemory; + for(int i = 0; i < ex_config.llm.nMcpServers && i < LLM_N_MCP_SERVERS_MAX; i++){ + JsonObject server = mcp_servers.createNestedObject(); + server["name"] = ex_config.llm.mcpServer[i].name; + server["disabled"] = ex_config.llm.mcpServer[i].disabled; + server["url"] = ex_config.llm.mcpServer[i].url; + server["port"] = ex_config.llm.mcpServer[i].port; + } + } + + String json; + serializeJson(response, json); + server.send(200, "application/json", json); +} - nvs_close(nvs_handle); +void handle_config_post() { + if(server.method() != HTTP_POST){ + server.send(405, "text/plain", String("Method Not Allowed")); + return; } - M5.Speaker.setVolume(volume); - M5.Speaker.setChannelVolume(m5spk_virtual_channel, volume); - server.send(200, "text/plain", String("OK")); + + String body = server.arg("plain"); + String error = ""; + DynamicJsonDocument request(8192); + DeserializationError parse_error = deserializeJson(request, body); + if(parse_error){ + server.send(400, "text/plain", String("JSON parse error: ") + parse_error.c_str()); + return; + } + if(!validate_mcp_servers(request["ex"]["llm"], &error)){ + server.send(400, "text/plain", error); + return; + } + + String sec_yaml = build_secret_yaml(request["sec"]); + String basic_yaml = build_basic_yaml(request["basic"]); + String ex_yaml = build_extend_yaml(request["ex"]); + + if(!system_config.saveSecretConfigYaml(SPIFFS, SPIFFS_SEC_CONFIG_PATH, sec_yaml, &error)){ + server.send(400, "text/plain", error.length() > 0 ? error : String("Invalid SC_SecConfig.yaml")); + return; + } + if(!write_text_file_atomic(SPIFFS, SPIFFS_BASIC_CONFIG_PATH, basic_yaml, &error)){ + server.send(500, "text/plain", error.length() > 0 ? error : String("Failed to save SC_BasicConfig.yaml")); + return; + } + if(!write_text_file_atomic(SPIFFS, SPIFFS_EX_CONFIG_PATH, ex_yaml, &error)){ + server.send(500, "text/plain", error.length() > 0 ? error : String("Failed to save SC_ExConfig.yaml")); + return; + } + + server.send(200, "application/json", String("{\"status\":\"ok\"}")); +} + +void handle_config_restart() { + server.send(200, "text/plain", String("Restarting")); + delay(200); + ESP.restart(); } -#endif void init_web_server(void) @@ -361,6 +567,8 @@ void init_web_server(void) // Files // server.on("/", handleRoot); + server.on("/config.html", handle_config_html); + server.on("/config.js", handle_config_js); server.on("/personalize.html", handle_personalize_html); server.on("/personalize.js", handle_personalize_js); @@ -370,13 +578,13 @@ void init_web_server(void) server.on("/speech", handle_speech); server.on("/face", handle_face); server.on("/chat", handle_chat); - server.on("/apikey", handle_apikey); - //server.on("/setting", handle_setting); - //server.on("/apikey_set", HTTP_POST, handle_apikey_set); server.on("/role_set", HTTP_POST, handle_role_set); server.on("/role_get", handle_role_get); server.on("/memory_get", handle_memory_get); server.on("/memory_clear", handle_memory_clear); + server.on("/config", HTTP_GET, handle_config_get); + server.on("/config", HTTP_POST, handle_config_post); + server.on("/config/restart", HTTP_POST, handle_config_restart); // Other // @@ -387,7 +595,7 @@ void init_web_server(void) server.begin(); Serial.println("HTTP server started"); - M5.Lcd.println("HTTP server started"); + //M5.Lcd.println("HTTP server started"); } void web_server_handle_client(void) diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index d40574e..671b8c9 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -28,6 +28,7 @@ #include #include +#include #include #include "SpiRamJsonDocument.h" #include @@ -52,8 +53,10 @@ #endif StackchanExConfig system_config; -Robot* robot; +Robot* robot = nullptr; bool isOffline = false; +bool isWebServerEnabled = false; +bool isConfigPortalMode = false; // NTP接続情報 NTP connection information. @@ -79,6 +82,83 @@ const Expression expressions_table[] = { FtpServer ftpSrv; //set #define FTP_DEBUG in ESP8266FtpServer.h to see ftp verbose on serial +static const char* SPIFFS_EX_CONFIG_PATH = "/SC_ExConfig.yaml"; +static const char* SPIFFS_SEC_CONFIG_PATH = "/SC_SecConfig.yaml"; +static const char* SPIFFS_BASIC_CONFIG_PATH = "/SC_BasicConfig.yaml"; +static const char* SD_EX_CONFIG_PATH = "/app/AiStackChanEx/SC_ExConfig.yaml"; +static const char* SD_SEC_CONFIG_PATH = "/yaml/SC_SecConfig.yaml"; +static const char* SD_BASIC_CONFIG_PATH = "/yaml/SC_BasicConfig.yaml"; +static const char* CONFIG_AP_SSID_PREFIX = "StackChanEx-Config"; +static const char* CONFIG_AP_PASSWORD = "stackchan"; +static const char* CONFIG_PORTAL_URL = "http://192.168.4.1/"; + +enum class WifiFallbackMode { + ConfigAp, + Offline +}; + +struct ScreenButton { + int32_t x; + int32_t y; + int32_t w; + int32_t h; +}; + +bool is_point_in_button(int32_t x, int32_t y, const ScreenButton& button) +{ + return x >= button.x && x < (button.x + button.w) + && y >= button.y && y < (button.y + button.h); +} + +void draw_centered_text(const char* text, int32_t x, int32_t y, int32_t w) +{ + int32_t text_width = M5.Display.textWidth(text); + M5.Display.setCursor(x + ((w - text_width) / 2), y); + M5.Display.print(text); +} + +void draw_wifi_fallback_button(const ScreenButton& button, uint16_t fill_color, uint16_t border_color, const char* title, const char* caption) +{ + M5.Display.fillRoundRect(button.x, button.y, button.w, button.h, 10, fill_color); + M5.Display.drawRoundRect(button.x, button.y, button.w, button.h, 10, border_color); + M5.Display.setTextColor(TFT_WHITE, fill_color); + M5.Display.setTextSize(1); + draw_centered_text(title, button.x, button.y + 22, button.w); + draw_centered_text(caption, button.x, button.y + button.h - 24, button.w); +} + +String generate_config_ap_ssid() +{ + char ssid[32]; + uint32_t suffix = esp_random() % 1000000; + snprintf(ssid, sizeof(ssid), "%s-%06u", CONFIG_AP_SSID_PREFIX, (unsigned)suffix); + return String(ssid); +} + +bool fs_file_exists(fs::FS& fs, const char* path) +{ + if(!fs.exists(path)){ + Serial.printf("%s not found\n", path); + return false; + } + File file = fs.open(path, FILE_READ); + if(!file){ + Serial.printf("%s exists but cannot open\n", path); + return false; + } + bool exists = !file.isDirectory() && file.size() > 0; + Serial.printf("%s %s size:%u\n", path, exists ? "exist" : "invalid", (unsigned)file.size()); + file.close(); + return exists; +} + +bool has_required_config_files(fs::FS& fs, const char* ex_path, const char* sec_path, const char* basic_path) +{ + return fs_file_exists(fs, ex_path) + && fs_file_exists(fs, sec_path) + && fs_file_exists(fs, basic_path); +} + void lipSync(void *args) { @@ -157,7 +237,6 @@ void battery_check(void *args) { bool Wifi_connection_check() { unsigned long start_millis = millis(); - // 前回接続時情報で接続する while (WiFi.status() != WL_CONNECTED) { M5.Display.print("."); Serial.print("."); @@ -171,32 +250,164 @@ bool Wifi_connection_check() { return true; } -bool WifiSmartConfig() { -#if defined(USE_LLM_MODULE) - // LLMモジュール使用時は普通はオフラインが前提のため、Smart Config待ちはしない - return false; -#else - unsigned long start_millis = millis(); +bool connect_wifi_from_yaml(wifi_s* wifi_info) +{ + if(wifi_info == nullptr || wifi_info->ssid.length() == 0){ + Serial.println("WiFi SSID is not configured."); + return false; + } + + Serial.printf("Connecting to WiFi SSID: %s\n", wifi_info->ssid.c_str()); + WiFi.disconnect(); + WiFi.softAPdisconnect(true); WiFi.mode(WIFI_STA); - WiFi.beginSmartConfig(); - M5.Display.println("Waiting for SmartConfig"); - Serial.println("Waiting for SmartConfig"); - while (!WiFi.smartConfigDone()) { - delay(1000); - M5.Display.print("#"); - Serial.print("#"); - // 30秒以上接続できなかったら抜ける - if ( 30000 < millis() - start_millis) { - Serial.println(""); - //Serial.println("Reset"); - //ESP.restart(); - return false; + WiFi.begin(wifi_info->ssid.c_str(), wifi_info->password.c_str()); + return Wifi_connection_check(); +} + +WifiFallbackMode select_wifi_fallback_mode() +{ +#if defined(ARDUINO_M5STACK_ATOMS3R) + return WifiFallbackMode::ConfigAp; +#endif + + M5.Display.fillScreen(TFT_BLACK); + M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); + M5.Display.setTextSize(1); + draw_centered_text("Wi-Fi connection failed", 0, 18, M5.Display.width()); + draw_centered_text("Choose how to continue", 0, 48, M5.Display.width()); + + const int32_t margin = 16; + const int32_t gap = 12; + const int32_t button_y = 88; + const int32_t button_h = 92; + const int32_t button_w = (M5.Display.width() - (margin * 2) - gap) / 2; + const ScreenButton config_ap_button = { margin, button_y, button_w, button_h }; + const ScreenButton offline_button = { margin + button_w + gap, button_y, button_w, button_h }; + draw_wifi_fallback_button(config_ap_button, TFT_DARKCYAN, TFT_CYAN, "Config AP", "BtnA"); + draw_wifi_fallback_button(offline_button, TFT_DARKGREY, TFT_LIGHTGREY, "Offline", "BtnC"); + + M5.Display.setTextColor(TFT_LIGHTGREY, TFT_BLACK); + + while(true){ + M5.update(); + if(M5.BtnA.wasPressed()){ + return WifiFallbackMode::ConfigAp; + } + if(M5.BtnC.wasPressed()){ + return WifiFallbackMode::Offline; } +#if defined(ARDUINO_M5STACK_Core2) || defined( ARDUINO_M5STACK_CORES3 ) + if(M5.Touch.getCount()){ + auto touch = M5.Touch.getDetail(); + if(touch.wasPressed()){ + if(is_point_in_button(touch.x, touch.y, config_ap_button)){ + return WifiFallbackMode::ConfigAp; + } + if(is_point_in_button(touch.x, touch.y, offline_button)){ + return WifiFallbackMode::Offline; + } + } + } +#endif + delay(20); } - return true; +} + +void show_config_portal_qr(const String& url, bool ap_mode, const String& ap_ssid = String()) +{ + M5.Display.fillScreen(TFT_WHITE); + M5.Display.setTextColor(TFT_BLACK, TFT_WHITE); + M5.Display.setCursor(0, 0); + if(ap_mode){ + M5.Display.printf("SSID: %s\n", ap_ssid.c_str()); + M5.Display.printf("PASS: %s\n", CONFIG_AP_PASSWORD); + }else{ + M5.Display.println("Config web"); + } + M5.Display.println(url); +#if !defined(ARDUINO_M5STACK_ATOMS3R) + int32_t qr_size = min(M5.Display.width(), M5.Display.height()) - 80; + if(qr_size < 120){ + qr_size = min(M5.Display.width(), M5.Display.height()) - 20; + } + int32_t qr_x = (M5.Display.width() - qr_size) / 2; + int32_t qr_y = (M5.Display.height() - qr_size) / 2; + if(qr_y < 70){ + qr_y = 70; + } + M5.Display.qrcode(url, qr_x, qr_y, qr_size, 5); #endif } +void start_config_portal() +{ + String ap_ssid = generate_config_ap_ssid(); + WiFi.disconnect(); + WiFi.mode(WIFI_AP); + WiFi.softAP(ap_ssid.c_str(), CONFIG_AP_PASSWORD); + Serial.printf("Config AP started. SSID: %s URL: %s\n", ap_ssid.c_str(), CONFIG_PORTAL_URL); + show_config_portal_qr(CONFIG_PORTAL_URL, true, ap_ssid); + init_web_server(); + isOffline = true; + isWebServerEnabled = true; + isConfigPortalMode = true; +} + +void start_config_web_server_sta() +{ + String url = "http://" + WiFi.localIP().toString() + "/"; + Serial.printf("Config web server started. URL: %s\n", url.c_str()); + show_config_portal_qr(url, false); + init_web_server(); + isOffline = true; + isWebServerEnabled = true; + isConfigPortalMode = true; +} + +void show_config_portal_avatar_qr() +{ + String url = CONFIG_PORTAL_URL; + avatar.updateSubWindowQrcode(url, 78); + avatar.setSpeechText("Config: http://192.168.4.1/"); + avatar.set_isSubWindowEnable(true); + avatar.setScale(0.7); + avatar.setFaceOffsetY(-80); +} + +bool load_system_config(bool sd_available) +{ + bool use_sd_config = false; + bool full_config_loaded = false; +#if defined(ARDUINO_M5STACK_ATOMS3R) + use_sd_config = false; +#else + if(sd_available){ + use_sd_config = has_required_config_files(SD, SD_EX_CONFIG_PATH, SD_SEC_CONFIG_PATH, SD_BASIC_CONFIG_PATH); + } +#endif + + if(use_sd_config){ + Serial.println("Loading config from SD."); + system_config.loadConfig(SD, SD_EX_CONFIG_PATH); + full_config_loaded = true; + }else if(has_required_config_files(SPIFFS, SPIFFS_EX_CONFIG_PATH, SPIFFS_SEC_CONFIG_PATH, SPIFFS_BASIC_CONFIG_PATH)){ + Serial.println("Loading config from SPIFFS."); + system_config.loadConfig(SPIFFS, SPIFFS_EX_CONFIG_PATH, 2048, + SPIFFS_SEC_CONFIG_PATH, 2048, + SPIFFS_BASIC_CONFIG_PATH, 2048); + full_config_loaded = true; + }else{ + Serial.println("Config files are incomplete. Loading only SC_SecConfig.yaml if available."); + if(sd_available && fs_file_exists(SD, SD_SEC_CONFIG_PATH)){ + system_config.loadSecretConfigYaml(SD, SD_SEC_CONFIG_PATH); + }else if(fs_file_exists(SPIFFS, SPIFFS_SEC_CONFIG_PATH)){ + system_config.loadSecretConfigYaml(SPIFFS, SPIFFS_SEC_CONFIG_PATH); + } + } + return full_config_loaded; +} + void time_sync(const char* ntpsrv, long gmt_offset, int daylight_offset) { struct tm timeInfo; char buf[60]; @@ -362,85 +573,80 @@ void setup() init_mic_spk(); /// settings -#if defined(ARDUINO_M5STACK_ATOMS3R) - if (SPIFFS.begin()) { - // この関数ですべてのYAMLファイル(Basic, Secret, Extend)を読み込む - system_config.loadConfig(SPIFFS, "/SC_ExConfig.yaml", 2048, - "/SC_SecConfig.yaml", 2048, - "/SC_BasicConfig.yaml", 2048); -#else - if (SD.begin(GPIO_NUM_4, SPI, 25000000)) { - // この関数ですべてのYAMLファイル(Basic, Secret, Extend)を読み込む - system_config.loadConfig(SD, "/app/AiStackChanEx/SC_ExConfig.yaml"); + if(!SPIFFS.begin()){ + M5.Lcd.print("Failed to mount SPIFFS. System reset after 5 seconds."); + delay(5000); + ESP.restart(); + } + + bool sd_available = false; +#if !defined(ARDUINO_M5STACK_ATOMS3R) + sd_available = SD.begin(GPIO_NUM_4, SPI, 25000000); #endif - // Wifi設定読み込み - wifi_s* wifi_info = system_config.getWiFiSetting(); - Serial.printf("\nSSID: %s\n",wifi_info->ssid.c_str()); - Serial.printf("Key: %s\n",wifi_info->password.c_str()); - - // 前回設定で接続 - Serial.println("Connecting to WiFi"); - WiFi.disconnect(); - WiFi.softAPdisconnect(true); - WiFi.mode(WIFI_STA); - WiFi.begin(); - if(Wifi_connection_check()){ - Serial.println("Successfully connected to Wi-Fi using the previous settings."); - }else{ - // 前回設定での接続に失敗。SDカード設定による接続にトライ。 - Serial.println("The previous WiFi connection failed. Attempting to connect using the SD card settings."); - if(wifi_info->ssid.length() == 0){ - // SDカード設定の取得に失敗。Smart Configをスタート。 - Serial.println("Can't get WiFi settings. Start Smart Config."); - if(!WifiSmartConfig()){ - // Smart Config失敗。オフラインモード。 - Serial.println("Smart Config failed. Running in offline mode."); - isOffline = true; - } - }else{ - WiFi.begin(wifi_info->ssid.c_str(), wifi_info->password.c_str()); - if(Wifi_connection_check()){ - // SDカード設定による接続に成功。 - Serial.println("Successfully established a Wi-Fi connection via the SD card settings."); - }else{ - // SDカード設定による接続に失敗。Smart Configをスタート。 - Serial.println("WiFi connection failed due to SD card settings. Start Smart Config."); - if(!WifiSmartConfig()){ - // Smart Config失敗。オフラインモード。 - Serial.println("Smart Config failed. Running in offline mode."); - isOffline = true; - } - } - } - } + bool full_config_loaded = load_system_config(sd_available); - if(!isOffline){ - Serial.println(WiFi.localIP()); - M5.Lcd.println(WiFi.localIP()); - delay(1000); + // Wifi設定読み込み + wifi_s* wifi_info = system_config.getWiFiSetting(); + Serial.printf("\nSSID: %s\n", wifi_info->ssid.c_str()); + Serial.printf("Key: %s\n", wifi_info->password.length() > 0 ? "(set)" : "(empty)"); - //Webサーバ設定 - init_web_server(); - //FTPサーバ設定(SPIFFS用) - ftpSrv.begin("stackchan","stackchan"); //username, password for ftp. set ports in ESP8266FtpServer.h (default 21, 50009 for PASV) - Serial.println("FTP server started"); - M5.Lcd.println("FTP server started"); + if(!full_config_loaded){ +#if defined(REALTIME_API) + Serial.println("Full config set is missing. Starting config web flow."); + if(connect_wifi_from_yaml(wifi_info)){ + start_config_web_server_sta(); + }else{ + start_config_portal(); + } +#else + M5.Display.fillScreen(TFT_BLACK); + M5.Display.setTextColor(TFT_WHITE, TFT_BLACK); + M5.Display.setCursor(0, 0); + M5.Display.println("Config incomplete."); + M5.Display.println(""); + M5.Display.println("Missing one or more files:"); + M5.Display.println("SC_BasicConfig.yaml"); + M5.Display.println("SC_SecConfig.yaml"); + M5.Display.println("SC_ExConfig.yaml"); + while(true){ + M5.update(); + delay(100); + } +#endif + goto END; + } - //時刻同期 - time_sync(NTPSRV, GMT_OFFSET, DAYLIGHT_OFFSET); + if(connect_wifi_from_yaml(wifi_info)){ + Serial.println("Successfully connected to Wi-Fi using YAML settings."); + }else{ + WifiFallbackMode fallback = select_wifi_fallback_mode(); + if(fallback == WifiFallbackMode::ConfigAp){ + start_config_portal(); + goto END; }else{ - M5.Lcd.print("Can't connect to WiFi. Start offline mode.\n"); + isOffline = true; + M5.Lcd.print("Start offline mode.\n"); } + } - robot = new Robot(system_config); + if(!isOffline){ + Serial.println(WiFi.localIP()); + M5.Lcd.println(WiFi.localIP()); + delay(1000); - //SD.end(); - } else { - M5.Lcd.print("Failed to load SD card settings. System reset after 5 seconds."); - delay(5000); - ESP.restart(); - //WiFi.begin(); + //Webサーバ設定 + init_web_server(); + isWebServerEnabled = true; + //FTPサーバ設定(SPIFFS用) + ftpSrv.begin("stackchan","stackchan"); //username, password for ftp. set ports in ESP8266FtpServer.h (default 21, 50009 for PASV) + Serial.println("FTP server started"); + M5.Lcd.println("FTP server started"); + + //時刻同期 + time_sync(NTPSRV, GMT_OFFSET, DAYLIGHT_OFFSET); } + + robot = new Robot(system_config); mp3_init(); @@ -460,6 +666,10 @@ void setup() avatar.init(16); #endif + if(isConfigPortalMode){ + show_config_portal_avatar_qr(); + } + avatar.addTask(lipSync, "lipSync", 2048, 2); avatar.addTask(servo, "servo", 2048); avatar.addTask(battery_check, "battery_check", 2048); @@ -489,6 +699,9 @@ void setup() check_heap_free_size(); check_heap_largest_free_block(); +END: + // Nothing to do. + return ; } @@ -499,6 +712,13 @@ void loop() M5.update(); //get_elapsed_time_micro("M5.update time"); ModBase* mod = get_current_mod(); + if(mod == nullptr){ + if(isWebServerEnabled){ + web_server_handle_client(); + } + delay(10); + return; + } mod->idle(); //get_elapsed_time_micro("Mod idle time"); @@ -574,8 +794,10 @@ void loop() #endif //get_elapsed_time_micro("Callback process time"); - if(!isOffline){ + if(isWebServerEnabled){ web_server_handle_client(); + } + if(!isOffline){ ftpSrv.handleFTP(); } diff --git a/firmware/src/mod/ModManager.cpp b/firmware/src/mod/ModManager.cpp index 7a3f4ba..5f13dd9 100644 --- a/firmware/src/mod/ModManager.cpp +++ b/firmware/src/mod/ModManager.cpp @@ -51,6 +51,10 @@ ModBase* change_mod(bool reverse) ModBase* get_current_mod(void) { - return modList[0]; + if(modList.size() > 0){ + return modList[0]; + }else{ + return nullptr; + } } diff --git a/firmware/src/mod/StatusMonitor/StatusMonitorMod.cpp b/firmware/src/mod/StatusMonitor/StatusMonitorMod.cpp index 8821f5e..aac66d7 100644 --- a/firmware/src/mod/StatusMonitor/StatusMonitorMod.cpp +++ b/firmware/src/mod/StatusMonitor/StatusMonitorMod.cpp @@ -3,12 +3,7 @@ #include "mod/ModManager.h" #include "StatusMonitorMod.h" #include "Robot.h" -#include "Scheduler.h" -#include "MySchedule.h" -#include "llm/ChatGPT/ChatGPT.h" -#include "llm/ChatGPT/FunctionCall.h" -#include "llm/ChatGPT/MCPClient.h" -#include +#include #include using namespace m5avatar; @@ -19,12 +14,46 @@ extern void sw_tone(); /////////////// +namespace { +const uint16_t STATUS_BG = 0x0841; +const uint16_t STATUS_PANEL = 0xffff; +const uint16_t STATUS_LINE = 0xbdf7; +const uint16_t STATUS_TEXT = 0x0000; +const uint16_t STATUS_MUTED = 0x7bef; +const uint16_t STATUS_PRIMARY = 0x0471; +const int32_t STATUS_SUBWINDOW_WIDTH = 320; +const int32_t TAB_MARGIN_X = 6; +const int32_t TAB_Y = 6; +const int32_t TAB_H = 28; +const int32_t TAB_GAP = 4; +const int32_t TAB_W = (STATUS_SUBWINDOW_WIDTH - (TAB_MARGIN_X * 2) - TAB_GAP) / 2; + +void drawTextLines(M5Canvas *spi, const String& text, int32_t x, int32_t y, int32_t lineHeight) +{ + int start = 0; + int cursorY = y; + while(start <= text.length()){ + int end = text.indexOf('\n', start); + if(end < 0){ + end = text.length(); + } + String line = text.substring(start, end); + spi->drawString(line, x, cursorY); + cursorY += lineHeight; + if(end >= text.length()){ + break; + } + start = end + 1; + } +} +} + StatusMonitorMod::StatusMonitorMod(void) { box_BtnA.setupBox(0, 100, 40, 60); box_BtnC.setupBox(280, 100, 40, 60); - box_BtnUA.setupBox(0, 0, 80, 60); - box_BtnUC.setupBox(240, 0, 80, 60); + box_TabSystem.setupBox(TAB_MARGIN_X, TAB_Y, TAB_W, TAB_H); + box_TabAiService.setupBox(TAB_MARGIN_X + TAB_W + TAB_GAP, TAB_Y, TAB_W, TAB_H); current_page_no = 0; } @@ -40,72 +69,168 @@ void StatusMonitorMod::pause(void) avatar.set_isSubWindowEnable(false); } -void StatusMonitorMod::update(int page_no) +void StatusMonitorMod::drawSubWindow(M5Canvas *spi, BoundingRect rect, + DrawContext *ctx, void *userData) +{ + if(userData == nullptr){ + return; + } + static_cast(userData)->drawStatusMonitor(spi, rect, ctx); +} + +String StatusMonitorMod::buildSystemStatus() { String str = ""; char tmp[256]; - str += "<<>>\n"; - - if(page_no == 0){ - byte mac[6]; - esp_efuse_mac_get_default(mac); - - str += "======== System status ========\n"; - sprintf(tmp, "Firmware Version:%s\n", FW_VERSION); - str += tmp; - sprintf(tmp, "Wifi:\n IP addr:%s\n MAC addr:%02x:%02x:%02x:%02x:%02x:%02x\n", - WiFi.localIP().toString().c_str(), - mac[0], mac[1], mac[2], mac[3], mac[4], mac[5] ); - str += tmp; - sprintf(tmp, "Heap largest free block:\n DMA:%d\n SPIRAM:%d\n", - heap_caps_get_largest_free_block(MALLOC_CAP_DMA), - heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM) ); - str += tmp; + byte mac[6]; + esp_efuse_mac_get_default(mac); + + //str += "======== System status ========\n"; + sprintf(tmp, "Firmware Version:%s\n", FW_VERSION); + str += tmp; + sprintf(tmp, "Wifi:\n IP addr:%s\n MAC addr:%02x:%02x:%02x:%02x:%02x:%02x\n", + WiFi.localIP().toString().c_str(), + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5] ); + str += tmp; + sprintf(tmp, "Heap largest free block:\n DMA:%d\n SPIRAM:%d\n", + heap_caps_get_largest_free_block(MALLOC_CAP_DMA), + heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM) ); + str += tmp; #if 0 - sprintf(tmp, "Prompt buffer usage:\n %d / %d [byte]\n", - robot->llm->chat_doc.memoryUsage(), - robot->llm->chat_doc.capacity() ); - str += tmp; + sprintf(tmp, "Prompt buffer usage:\n %d / %d [byte]\n", + robot->llm->chat_doc.memoryUsage(), + robot->llm->chat_doc.capacity() ); + str += tmp; #endif - sprintf(tmp, "Battery level: %d %%\n", M5.Power.getBatteryLevel()); - str += tmp; + sprintf(tmp, "Battery level: %d %%\n", M5.Power.getBatteryLevel()); + str += tmp; + return str; +} + +const char* StatusMonitorMod::getAiServiceName(int llm_type) +{ + switch(llm_type){ + case LLM_TYPE_CHATGPT: + return "OpenAI Realtime"; + case LLM_TYPE_GEMINI: + return "Gemini Live"; + case LLM_TYPE_CUSTOM_OPENAI: + return "Custom OpenAI"; + case LLM_TYPE_MODULE_LLM: + return "Module LLM"; + case LLM_TYPE_MODULE_LLM_FNCL: + return "Module LLM Function"; + default: + return "Unknown"; } - else if(page_no == 1){ - str += "========= MCP Servers =========\n"; - str += g_mcpInitResult; +} + +String StatusMonitorMod::buildAiServiceStatus() +{ + String str = ""; + //str += "========= AI Service ==========\n"; + + if(robot == nullptr){ + str += "Robot is not initialized.\n"; + return str; } - else if(page_no == 2){ - str += "===== Function call info =====\n"; - str += "Reminder:\n"; - for(int i=0; iget_sched_type() == SCHED_REMINDER) { - ScheduleReminder *sched_rem = (ScheduleReminder*)sched; - str += " " + sched_rem->get_time_string() + " " + sched_rem->get_remind_string() + "\n"; - } + + ex_config_s ex_config = robot->m_config.getExConfig(); + str += "Service:\n "; + str += getAiServiceName(ex_config.llm.type); + str += "\n"; + str += "Memory: "; + str += ex_config.llm.enableMemory ? "Enabled" : "Disabled"; + str += "\n"; + str += "MCP Servers:\n"; + + if(ex_config.llm.nMcpServers <= 0){ + str += " (none)\n"; + }else{ + for(int i=0; i 0 ? server.name : "(unnamed)"; + if(server.disabled){ + str += " [disabled]"; } + str += "\n"; + str += " "; + str += server.url; + str += ":"; + str += String(server.port); + str += "\n"; } } + return str; +} +void StatusMonitorMod::drawStatusMonitor(M5Canvas *spi, BoundingRect rect, + DrawContext *ctx) +{ + int32_t x = rect.getLeft(); + int32_t y = rect.getTop(); + int32_t w = rect.getWidth(); + int32_t h = rect.getHeight(); + if(w <= 0){ + w = 320; + } + if(h <= 0){ + h = 240; + } + + spi->fillRect(x, y, w, h, STATUS_BG); + spi->fillRoundRect(x + 2, y + 2, w - 4, h - 4, 6, STATUS_PANEL); + spi->drawRoundRect(x + 2, y + 2, w - 4, h - 4, 6, STATUS_LINE); + spi->setFont(&fonts::Font0); + spi->setTextSize(1.5); + + const int32_t tabY = y + TAB_Y; + const int32_t tabH = TAB_H; + const int32_t tabGap = TAB_GAP; + const int32_t tabW = (w - (TAB_MARGIN_X * 2) - tabGap) / 2; + const char* tabNames[TAB_COUNT] = {"System", "AI Service"}; + for(int i = 0; i < TAB_COUNT; i++){ + int32_t tabX = x + TAB_MARGIN_X + (tabW + tabGap) * i; + bool active = (i == current_page_no); + uint16_t fill = active ? STATUS_PRIMARY : 0xe71c; + uint16_t text = active ? TFT_WHITE : STATUS_TEXT; + spi->fillRoundRect(tabX, tabY, tabW, tabH, 5, fill); + spi->drawRoundRect(tabX, tabY, tabW, tabH, 5, active ? STATUS_PRIMARY : STATUS_LINE); + spi->setTextColor(text, fill); + spi->setTextDatum(middle_center); + spi->drawString(tabNames[i], tabX + tabW / 2, tabY + tabH / 2); + } - //M5.Display.print(str); - avatar.updateSubWindowTxt(str); + int32_t bodyX = x + 10; + int32_t bodyY = y + 42; + int32_t bodyW = w - 20; + int32_t bodyH = h - 50; + spi->fillRect(bodyX - 2, bodyY - 2, bodyW + 4, bodyH + 4, TFT_WHITE); + spi->setTextDatum(top_left); + spi->setTextColor(STATUS_TEXT, TFT_WHITE); + drawTextLines(spi, current_page_no == 0 ? buildSystemStatus() : buildAiServiceStatus(), + bodyX, bodyY, 20); +} + +void StatusMonitorMod::update(int page_no) +{ + avatar.updateSubWindowCustom(StatusMonitorMod::drawSubWindow, this, 0, 0, 320, 240); } void StatusMonitorMod::btnA_pressed(void) { current_page_no--; - if(current_page_no < 0) current_page_no = MAX_PAGE_NO - 1; + if(current_page_no < 0) current_page_no = TAB_COUNT - 1; update(current_page_no); } void StatusMonitorMod::btnC_pressed(void) { current_page_no++; - if(current_page_no >= MAX_PAGE_NO) current_page_no = 0; + if(current_page_no >= TAB_COUNT) current_page_no = 0; update(current_page_no); } @@ -122,14 +247,18 @@ void StatusMonitorMod::display_touched(int16_t x, int16_t y) //sw_tone(); } - if (box_BtnUA.contain(x, y)) + if (box_TabSystem.contain(x, y)) { - btnA_pressed(); + current_page_no = 0; + update(current_page_no); + return; } - if (box_BtnUC.contain(x, y)) + if (box_TabAiService.contain(x, y)) { - btnC_pressed(); + current_page_no = 1; + update(current_page_no); + return; } } diff --git a/firmware/src/mod/StatusMonitor/StatusMonitorMod.h b/firmware/src/mod/StatusMonitor/StatusMonitorMod.h index e738ace..751d5de 100644 --- a/firmware/src/mod/StatusMonitor/StatusMonitorMod.h +++ b/firmware/src/mod/StatusMonitor/StatusMonitorMod.h @@ -3,16 +3,24 @@ #include #include "mod/ModBase.h" +#include class StatusMonitorMod: public ModBase{ private: box_t box_BtnA; box_t box_BtnC; - box_t box_BtnUA; - box_t box_BtnUC; - const int MAX_PAGE_NO = 2; + box_t box_TabSystem; + box_t box_TabAiService; + static const int TAB_COUNT = 2; int current_page_no; + String buildSystemStatus(); + String buildAiServiceStatus(); + const char* getAiServiceName(int llm_type); + void drawStatusMonitor(M5Canvas *spi, m5avatar::BoundingRect rect, + m5avatar::DrawContext *ctx); + static void drawSubWindow(M5Canvas *spi, m5avatar::BoundingRect rect, + m5avatar::DrawContext *ctx, void *userData); public: StatusMonitorMod(void); void init(void); @@ -24,4 +32,4 @@ class StatusMonitorMod: public ModBase{ void idle(void); }; -#endif //_STATUS_MONITOR_MOD_H \ No newline at end of file +#endif //_STATUS_MONITOR_MOD_H diff --git a/firmware/src/rootCA/rootCAgoogleGemini.h b/firmware/src/rootCA/rootCAgoogleGemini.h index 753cb2a..d65d512 100644 --- a/firmware/src/rootCA/rootCAgoogleGemini.h +++ b/firmware/src/rootCA/rootCAgoogleGemini.h @@ -1,39 +1,60 @@ -// To get the certificate for your region run: -// openssl s_client -showcerts -connect speech.googleapis.com:443 -// Copy the certificate (all lines between and including ---BEGIN CERTIFICATE--- -// and --END CERTIFICATE--) to root.cert and put here on the root_cert variable. - // certificate for https://speech.googleapis.com -// GlobalSign Root CA, valid until Fri Jan 28 2028, size: 1927 bytes -static const char* root_ca_google_gemini = \ -"-----BEGIN CERTIFICATE-----\n" \ -"MIIFWjCCA0KgAwIBAgIQbkepxUtHDA3sM9CJuRz04TANBgkqhkiG9w0BAQwFADBH\n" \ -"MQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExM\n" \ -"QzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIy\n" \ -"MDAwMDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNl\n" \ -"cnZpY2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwggIiMA0GCSqGSIb3DQEB\n" \ -"AQUAA4ICDwAwggIKAoICAQC2EQKLHuOhd5s73L+UPreVp0A8of2C+X0yBoJx9vaM\n" \ -"f/vo27xqLpeXo4xL+Sv2sfnOhB2x+cWX3u+58qPpvBKJXqeqUqv4IyfLpLGcY9vX\n" \ -"mX7wCl7raKb0xlpHDU0QM+NOsROjyBhsS+z8CZDfnWQpJSMHobTSPS5g4M/SCYe7\n" \ -"zUjwTcLCeoiKu7rPWRnWr4+wB7CeMfGCwcDfLqZtbBkOtdh+JhpFAz2weaSUKK0P\n" \ -"fyblqAj+lug8aJRT7oM6iCsVlgmy4HqMLnXWnOunVmSPlk9orj2XwoSPwLxAwAtc\n" \ -"vfaHszVsrBhQf4TgTM2S0yDpM7xSma8ytSmzJSq0SPly4cpk9+aCEI3oncKKiPo4\n" \ -"Zor8Y/kB+Xj9e1x3+naH+uzfsQ55lVe0vSbv1gHR6xYKu44LtcXFilWr06zqkUsp\n" \ -"zBmkMiVOKvFlRNACzqrOSbTqn3yDsEB750Orp2yjj32JgfpMpf/VjsPOS+C12LOO\n" \ -"Rc92wO1AK/1TD7Cn1TsNsYqiA94xrcx36m97PtbfkSIS5r762DL8EGMUUXLeXdYW\n" \ -"k70paDPvOmbsB4om3xPXV2V4J95eSRQAogB/mqghtqmxlbCluQ0WEdrHbEg8QOB+\n" \ -"DVrNVjzRlwW5y0vtOUucxD/SVRNuJLDWcfr0wbrM7Rv1/oFB2ACYPTrIrnqYNxgF\n" \ -"lQIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNV\n" \ -"HQ4EFgQU5K8rJnEaK0gnhS9SZizv8IkTcT4wDQYJKoZIhvcNAQEMBQADggIBADiW\n" \ -"Cu49tJYeX++dnAsznyvgyv3SjgofQXSlfKqE1OXyHuY3UjKcC9FhHb8owbZEKTV1\n" \ -"d5iyfNm9dKyKaOOpMQkpAWBz40d8U6iQSifvS9efk+eCNs6aaAyC58/UEBZvXw6Z\n" \ -"XPYfcX3v73svfuo21pdwCxXu11xWajOl40k4DLh9+42FpLFZXvRq4d2h9mREruZR\n" \ -"gyFmxhE+885H7pwoHyXa/6xmld01D1zvICxi/ZG6qcz8WpyTgYMpl0p8WnK0OdC3\n" \ -"d8t5/Wk6kjftbjhlRn7pYL15iJdfOBL07q9bgsiG1eGZbYwE8na6SfZu6W0eX6Dv\n" \ -"J4J2QPim01hcDyxC2kLGe4g0x8HYRZvBPsVhHdljUEn2NIVq4BjFbkerQUIpm/Zg\n" \ -"DdIx02OYI5NaAIFItO/Nis3Jz5nu2Z6qNuFoS3FJFDYoOj0dzpqPJeaAcWErtXvM\n" \ -"+SUWgeExX6GjfhaknBZqlxi9dnKlC54dNuYvoS++cJEPqOba+MSSQGwlfnuzCdyy\n" \ -"F62ARPBopY+Udf90WuioAnwMCeKpSwughQtiue+hMZL77/ZRBIls6Kl0obsXs7X9\n" \ -"SQ98POyDGCBDTtWTurQ0sR8WNh8M5mQ5Fkzc4P4dyKliPUDqysU0ArSuiYgzNdws\n" \ -"E3PYJ/HQcu51OyLemGhmW/HGY0dVHLqlCFF1pkgl\n" \ -"-----END CERTIFICATE-----\n" \ -""; +// Root CA bundle for https://generativelanguage.googleapis.com. +// Source: https://pki.goog/roots.pem +// +// GTS Root R1 +// Subject/Issuer: C=US, O=Google Trust Services LLC, CN=GTS Root R1 +// Valid: 2016-06-22 through 2036-06-22 +// SHA-256: D9:47:43:2A:BD:E7:B7:FA:90:FC:2E:6B:59:10:1B:12: +// 80:E0:E1:C7:E4:E4:0F:A3:C6:88:7F:FF:57:A7:F4:CF +// +// GTS Root R4 +// Subject/Issuer: C=US, O=Google Trust Services LLC, CN=GTS Root R4 +// Valid: 2016-06-22 through 2036-06-22 +// SHA-256: 34:9D:FA:40:58:C5:E2:63:12:3B:39:8A:E7:95:57:3C: +// 4E:13:13:C8:3F:E6:8F:93:55:6C:D5:E8:03:1B:3C:7D +static const char* root_ca_google_gemini = R"( +-----BEGIN CERTIFICATE----- +MIIFVzCCAz+gAwIBAgINAgPlk28xsBNJiGuiFzANBgkqhkiG9w0BAQwFADBHMQsw +CQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEU +MBIGA1UEAxMLR1RTIFJvb3QgUjEwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAw +MDAwWjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZp +Y2VzIExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjEwggIiMA0GCSqGSIb3DQEBAQUA +A4ICDwAwggIKAoICAQC2EQKLHuOhd5s73L+UPreVp0A8of2C+X0yBoJx9vaMf/vo +27xqLpeXo4xL+Sv2sfnOhB2x+cWX3u+58qPpvBKJXqeqUqv4IyfLpLGcY9vXmX7w +Cl7raKb0xlpHDU0QM+NOsROjyBhsS+z8CZDfnWQpJSMHobTSPS5g4M/SCYe7zUjw +TcLCeoiKu7rPWRnWr4+wB7CeMfGCwcDfLqZtbBkOtdh+JhpFAz2weaSUKK0Pfybl +qAj+lug8aJRT7oM6iCsVlgmy4HqMLnXWnOunVmSPlk9orj2XwoSPwLxAwAtcvfaH +szVsrBhQf4TgTM2S0yDpM7xSma8ytSmzJSq0SPly4cpk9+aCEI3oncKKiPo4Zor8 +Y/kB+Xj9e1x3+naH+uzfsQ55lVe0vSbv1gHR6xYKu44LtcXFilWr06zqkUspzBmk +MiVOKvFlRNACzqrOSbTqn3yDsEB750Orp2yjj32JgfpMpf/VjsPOS+C12LOORc92 +wO1AK/1TD7Cn1TsNsYqiA94xrcx36m97PtbfkSIS5r762DL8EGMUUXLeXdYWk70p +aDPvOmbsB4om3xPXV2V4J95eSRQAogB/mqghtqmxlbCluQ0WEdrHbEg8QOB+DVrN +VjzRlwW5y0vtOUucxD/SVRNuJLDWcfr0wbrM7Rv1/oFB2ACYPTrIrnqYNxgFlQID +AQABo0IwQDAOBgNVHQ8BAf8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E +FgQU5K8rJnEaK0gnhS9SZizv8IkTcT4wDQYJKoZIhvcNAQEMBQADggIBAJ+qQibb +C5u+/x6Wki4+omVKapi6Ist9wTrYggoGxval3sBOh2Z5ofmmWJyq+bXmYOfg6LEe +QkEzCzc9zolwFcq1JKjPa7XSQCGYzyI0zzvFIoTgxQ6KfF2I5DUkzps+GlQebtuy +h6f88/qBVRRiClmpIgUxPoLW7ttXNLwzldMXG+gnoot7TiYaelpkttGsN/H9oPM4 +7HLwEXWdyzRSjeZ2axfG34arJ45JK3VmgRAhpuo+9K4l/3wV3s6MJT/KYnAK9y8J +ZgfIPxz88NtFMN9iiMG1D53Dn0reWVlHxYciNuaCp+0KueIHoI17eko8cdLiA6Ef +MgfdG+RCzgwARWGAtQsgWSl4vflVy2PFPEz0tv/bal8xa5meLMFrUKTX5hgUvYU/ +Z6tGn6D/Qqc6f1zLXbBwHSs09dR2CQzreExZBfMzQsNhFRAbd03OIozUhfJFfbdT +6u9AWpQKXCBfTkBdYiJ23//OYb2MI3jSNwLgjt7RETeJ9r/tSQdirpLsQBqvFAnZ +0E6yove+7u7Y/9waLd64NnHi/Hm3lCXRSHNboTXns5lndcEZOitHTtNCjv0xyBZm +2tIMPNuzjsmhDYAPexZ3FL//2wmUspO8IFgV6dtxQ/PeEMMA3KgqlbbC1j+Qa3bb +bP6MvPJwNQzcmRk13NfIRmPVNnGuV/u3gm3c +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICCTCCAY6gAwIBAgINAgPlwGjvYxqccpBQUjAKBggqhkjOPQQDAzBHMQswCQYD +VQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2VzIExMQzEUMBIG +A1UEAxMLR1RTIFJvb3QgUjQwHhcNMTYwNjIyMDAwMDAwWhcNMzYwNjIyMDAwMDAw +WjBHMQswCQYDVQQGEwJVUzEiMCAGA1UEChMZR29vZ2xlIFRydXN0IFNlcnZpY2Vz +IExMQzEUMBIGA1UEAxMLR1RTIFJvb3QgUjQwdjAQBgcqhkjOPQIBBgUrgQQAIgNi +AATzdHOnaItgrkO4NcWBMHtLSZ37wWHO5t5GvWvVYRg1rkDdc/eJkTBa6zzuhXyi +QHY7qca4R9gq55KRanPpsXI5nymfopjTX15YhmUPoYRlBtHci8nHc8iMai/lxKvR +HYqjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQW +BBSATNbrdP9JNqPV2Py1PsVq8JQdjDAKBggqhkjOPQQDAwNpADBmAjEA6ED/g94D +9J+uHXqnLrmvT/aDHQ4thQEd0dlq7A/Cr8deVl5c1RxYIigL9zC2L7F8AjEA8GE8 +p/SgguMh1YQdc4acLa/KNJvxn7kjNuK8YAOdgLOaVsjh4rsUecrNIdSUtUlD +-----END CERTIFICATE----- +)"; diff --git a/firmware/src/share/Version.h b/firmware/src/share/Version.h index 0b242b6..f5d1fdb 100644 --- a/firmware/src/share/Version.h +++ b/firmware/src/share/Version.h @@ -1,6 +1,6 @@ #ifndef _VERSION_H #define _VERSION_H -#define FW_VERSION "0.22.1" +#define FW_VERSION "0.23.0" #endif \ No newline at end of file diff --git a/images/ap_mode_select.png b/images/ap_mode_select.png new file mode 100644 index 0000000..4c4a3e9 Binary files /dev/null and b/images/ap_mode_select.png differ diff --git a/images/ap_mode_ssid_and_url.png b/images/ap_mode_ssid_and_url.png new file mode 100644 index 0000000..5857c25 Binary files /dev/null and b/images/ap_mode_ssid_and_url.png differ diff --git a/images/config_page.png b/images/config_page.png new file mode 100644 index 0000000..a2932d4 Binary files /dev/null and b/images/config_page.png differ