Skip to content

通信プロトコル仕様

対象 Issue: #5 ステータス: Draft v0.4 (Tailscale Funnel + Backend Router + 多 Wi-Fi + Degraded Mode 版)

スタックちゃん本体(M5Stack CoreS3 + Module-LLM)と Mac Studio 上の管理画面サーバーの間で交わされる通信仕様。 音声・軽い対話は本体内で完結し、Mac Studio とは「深い思考の委譲」と「管理画面の状態同期・通知」だけをやり取りする。

遠隔地でもフルスペック動作を目標とし、家・iPhone テザリング・カフェ Wi-Fi など どこで電源を入れても同じ動作になることを保証する。Mac Studio への到達は Tailscale Funnel による固定エンドポイント経由。深い思考の バックエンド(Claude / Codex / Local LLM)は切替可能

0. 本体内 (CoreS3 ↔ Module-LLM)

役割分担(ハイブリッド構成)

担当CoreS3 (ESP32-S3)Module-LLM (AX630C)
マイク❌(封止 or 補助)✅ DSP 入りマイクで収音
ウェイクワード検出✅ 常時 KWS(NPU)
STT (音声→テキスト)✅ Whisper を NPU で
軽い LLM 推論✅ Qwen 等を NPU で
TTS 生成(テキスト→PCM)✅ NPU で
スピーカー出力(PCM→音)✅ I2S → 顔のスピーカー❌(背中側のスピーカーは予備)
LCD / ドットキャラ描画
サーボ・LED 制御
センサ Unit (PIR/ToF/IR) 読取
Unit IR 送信(家電操作)
状態機械の指揮✅(統合点)
Wi-Fi / WSS クライアント
深い思考の判定→ Mac Studio 委譲

設計意図: 「声は顔から出る」を実現するため スピーカー出力だけは CoreS3 側 に寄せる。 その他の音声処理は Module-LLM の NPU と DSP に任せる。

物理接続

経路バス用途帯域要件
Module-LLM → CoreS3UART (M5Module バス)TTS PCM + 認識結果イベント921,600 bps 以上(PCM 16kHz mono を流すため)
CoreS3 → Module-LLMUART (同上)プロンプト・話者切替・割り込み標準で十分

UART ボーレート

標準の 115,200 bps では 16kHz/16bit PCM (256 kbps) を流せない。921,600 bps 以上での運用が前提。 帯域が足りない場合は Opus 圧縮(16〜32 kbps)に切替できる経路を v1 で用意する。USB-CDC 接続にすれば 1.5 Mbps 以上出るのでさらに余裕。

イベント・スキーマ(本体内)

Module-LLM → CoreS3 は JSON テキストフレーム + バイナリ PCM フレーム の2チャンネル。

イベント方向ペイロード説明
wake_detectedLLM → CoreS3{confidence}ウェイクワード検出
asr_partialLLM → CoreS3{text}認識中の途中結果(口パク同期用)
asr_finalLLM → CoreS3{text}認識確定
llm_thinkingLLM → CoreS3{}推論開始(思考表情に切替)
llm_responseLLM → CoreS3{text}LLM の応答テキスト
tts_startLLM → CoreS3{id, sample_rate, channels}TTS PCM 送信開始の宣言
(binary frame)LLM → CoreS3PCM チャンクバイナリで PCM データ
tts_endLLM → CoreS3{id}TTS PCM 送信終了
promptCoreS3 → LLM{text, system?, history?}CoreS3 から推論を依頼
sayCoreS3 → LLM{text, voice?}TTS だけ生成依頼(LLM スキップ)
cancelCoreS3 → LLM{id?}処理キャンセル
modeCoreS3 → LLM`{mode: wakealways

JSON のエンベロープは Mac Studio との通信(§3)と同じ形式を流用する。

公式の詳細仕様は M5Module-LLM ドキュメント に準拠する。本仕様書 §1 以降は 本体⇔Mac Studio のネットワーク経路 を定義する。


1. 設計方針

観点採用理由
物理層Wi-Fi (2.4GHz)CoreS3 内蔵、家庭内 LAN 前提
トランスポートWebSocket over TLS双方向ストリーミング、CoreS3 ライブラリ豊富
アプリ層MCP (Model Context Protocol) をスタックちゃん→Mac Studio に。状態通知は独自イベント管理画面の機能を Claude にも直接渡せるため、二度書きしない
エンコードJSON (UTF-8)デバッグ容易、音声バイナリだけ別経路でバイナリフレーム
エンドポイントTailscale Funnel の固定 URL家・外出・iPhone テザリング問わず同じ URL でアクセス可能
認証共有 PSK(事前共有鍵)+ デバイス IDFunnel は誰でも HTTPS で繋がるため、アプリ層で防御
バックエンドBackend Router(Claude / Codex / Local LLM 切替)モデルロックインを避ける、用途別ルーティング
Wi-Fi 戦略複数 SSID プロファイル + 優先順位フォールバック家・iPhone テザリング・カフェなどを順に試す
動作モードFull / Degraded / Offline の 3 段階Mac Studio 不到達時も Module-LLM だけで雑談・家電操作が成立

2. 接続シーケンス

接続シーケンス図

Tailscale Funnel エンドポイント

フィールド値(例)
URLwss://stackchan-XXX.<tailnet>.ts.net/ws
パス/ws
TLSTailscale が自動付与(Let's Encrypt 由来)
公開範囲Funnel = 公開(誰でも HTTPS で到達可)

エンドポイント URL は本体側に 環境変数 STACKCHAN_SERVER として焼き込む(NVS に保存)。 管理画面から OTA で更新可能。

Wi-Fi プロファイル(本体側)

スタックちゃんに 複数の Wi-Fi 候補 を登録しておく。電源 ON / 切断時に priority 順で接続を試す。

jsonc
[
  { "ssid": "home-2.4G",         "psk": "xxxx", "priority": 1 },
  { "ssid": "stackchan-tether",  "psk": "yyyy", "priority": 2 },  // iPhone テザリング
  { "ssid": "office-guest",      "psk": "zzzz", "priority": 3 }
]

接続戦略:

  • 起動時に priority 順に 1 つずつ試す
  • 接続成功した時点でその SSID を採用、次の優先候補へは行かない
  • 切断時はリスト先頭から再試行
  • 全 SSID 失敗 → Degraded Mode(§4.3)に移行

iPhone テザリングについて

iPhone の Personal Hotspot は 普通の WPA2 Wi-Fi として 登録するだけで動く。専用統合は不要。

項目推奨
SSIDiPhone 設定 → 一般 → 情報 → 名前 を 半角英数 に(例: stackchan-tether
互換性モード「互換性を優先」を ON(2.4GHz 強制、CoreS3 の Wi-Fi が安定)
自動 OFF 対策スタックちゃん起動後、最初の接続を成立させておく(90秒の電池節約タイマーをリセット)
充電iPhone を充電中にすると Personal Hotspot が落ちにくい

認証

  • セットアップ時に 管理画面で1回 PSK(32バイト)を生成 → 本体に焼き込む(v0.1 はシリアル経由、将来 BLE プロビジョニング)
  • 接続時のヘッダ:
    • X-Device-Id: ESP32 の MAC アドレスから生成した固定値
    • X-Auth: HMAC-SHA256(PSK, device_id + ":" + timestamp)
    • X-Timestamp: 接続試行時の Unix ミリ秒(±60s のずれは許容、リプレイ攻撃対策)

Funnel は公開なので、TLS は外側にあるが「正当な本体だけが繋げる」保証はアプリ層 PSK で担保する。


3. メッセージ・スキーマ

3.1 共通エンベロープ

jsonc
{
  "id": "uuid-v4",        // リクエスト/レスポンス対応用
  "type": "voice.chunk",  // ドット区切りで階層化
  "ts": 1731988800123,    // 送信側のミリ秒タイムスタンプ
  "payload": { ... }      // type 固有のデータ
}

3.2 ハンドシェイク

hello (スタックちゃん → Mac Studio)

jsonc
{
  "type": "hello",
  "payload": {
    "fw_version": "0.1.0",
    "device_id": "stackchan-xxxx",
    "capabilities": ["voice.in", "voice.out", "expression", "servo", "led"]
  }
}

welcome (Mac Studio → スタックちゃん)

jsonc
{
  "type": "welcome",
  "payload": {
    "session_id": "sess_xxxx",
    "server_version": "0.1.0",
    "mcp_tools": ["task.list", "mail.unread", "news.latest", ...]
  }
}

3.3 状態・通知

state (双方向 / pub-sub)

スタックちゃん本体の状態(表情・処理中フラグ)を同期。

jsonc
{
  "type": "state",
  "payload": {
    "expression": "thinking",    // idle | thinking | talking | happy | sad | sleep
    "battery": 0.87,             // 0.0 - 1.0
    "wifi_rssi": -52,
    "busy": true
  }
}

notify (Mac Studio → スタックちゃん)

管理画面側で発生したイベント通知(メール・タスク完了)。

jsonc
{
  "type": "notify",
  "payload": {
    "kind": "mail.important",    // mail.important | task.done | task.overdue | agent.session_end
    "summary": "山田さんから返信",
    "speak": true,                // true なら TTS で読み上げ
    "led": {"color": "blue", "pattern": "blink_2"}
  }
}

session (Mac Studio → スタックちゃん) — #106 連続会話セッション

「ねえドナック」で開いた会話セッションの状態を本体に同期する。active=true の間は wake ワード不要で 発話が継続転送され、本体は左上に緑インジケータを表示する。終了ワード/タイムアウト/手動終了で active=false

jsonc
{
  "type": "session",
  "payload": {
    "active": true,                    // true=会話継続中(wake不要) / false=終了
    "conversation_id": "uuid",          // active 時。会話文脈のキー (#191)
    "reason": "end_phrase"              // false 時: end_phrase | idle_timeout | max_duration | manual
  }
}

session.close (スタックちゃん → Mac Studio) — #106

本体の画面左上タップ等でユーザーが会話セッションを明示終了する。bridge が権威でセッションを閉じ、 締めの一言を発話させたうえで session{active:false, reason:"manual"} を返す。

jsonc
{ "type": "session.close" }

3.4 音声経路

v0.2 での変更

Module-LLM 導入により、通常の音声は本体内 (CoreS3 ↔ Module-LLM) で完結する。 下記のバイナリフレーム経路は 「Mac Studio 側にも音声を渡す必要が出た場合の予備設計」 として残してある(例: 録音アーカイブ、Claude に音声をそのまま渡すなど)。 v0.2 の通常運用では、Mac Studio に投げるのは voice.transcript 相当のテキストのみ。

音声は別チャンネル(WebSocket バイナリフレーム)で送り、メタ情報だけ JSON で送る。

音声チャンネル

バイナリフレームは「直前の voice.start / tts.start で宣言された id」に紐付ける。 並走する音声を分離するため、混在しない設計(送信側で逐次化)。

3.5 MCP ツール呼び出し

スタックちゃんが「タスクを読み上げて」と認識した場合、ローカル LLM が MCP ツール呼び出し に変換 → Mac Studio に投げる。

jsonc
{
  "type": "mcp.call",
  "id": "req-001",
  "payload": {
    "tool": "task.list",
    "args": {"date": "today"}
  }
}

レスポンス:

jsonc
{
  "type": "mcp.result",
  "id": "req-001",
  "payload": {
    "ok": true,
    "data": [
      {"id": "t1", "title": "ナレッジページを書く", "due": "2026-05-19T18:00:00+09:00"}
    ]
  }
}

エラー時:

jsonc
{
  "type": "mcp.result",
  "id": "req-001",
  "payload": {
    "ok": false,
    "error": {"code": "tool_not_found", "message": "..."}
  }
}

3.6 深い思考の委譲(Backend Router 経由)

本体から agent.run を投げると、Mac Studio 側の Backend RouterClaude / Codex / Local LLM のいずれかにルーティングしてストリーミング応答を返す。本体側はバックエンドの違いを意識しない。

jsonc
{
  "type": "agent.run",
  "id": "ag-01",
  "payload": {
    "prompt": "今週の売上トレンドをまとめて、改善案を3つ",
    "backend": "claude",      // optional. 省略時は Mac Studio のデフォルト or ルール適用
    "stream": true,
    "context": { "tags": ["work"] }   // optional. タグ別ルール適用に使う
  }
}

ストリーミング応答:

jsonc
{"type":"agent.delta", "id":"ag-01", "payload":{"text":"先週比 +12% …"}}
{"type":"agent.delta", "id":"ag-01", "payload":{"text":"の伸びが見られ …"}}
{"type":"agent.done",  "id":"ag-01", "payload":{"backend":"claude","usage":{"input":1024,"output":2048,"cost_usd":0.018}}}

Backend Router の選択ロジック

優先順位(先に当てはまったものを採用):

  1. リクエストの payload.backend で明示指定があればそれ
  2. タグ別ルール(管理画面で定義)— 例: code → codex, private → local, default → claude
  3. 管理画面のデフォルト — 1 つを選んで運用

サポートする Backend

backend実装特徴
claude@anthropic-ai/claude-agent-sdkMCP ツールネイティブ、サブスク/API キー
codexOpenAI Responses / Codex SDK関数呼び出しで MCP 相当を再現、Function calling
localOllama HTTP API / node-llama-cpp / MLXオフライン可、プライベート、ツール対応はモデル次第

各 Backend は内部で以下の共通 IF を実装する:

ts
interface AgentBackend {
  name: 'claude' | 'codex' | 'local'
  run(req: AgentRequest): AsyncIterable<AgentDelta>
  capabilities: { tools: boolean; vision: boolean; streaming: boolean }
}

4. 接続管理 + 動作モード

4.1 ハートビート

  • 間隔: 15秒
  • スタックちゃんから {"type":"ping"} を送る
  • Mac Studio は {"type":"pong"} で返す
  • 30秒応答が無ければスタックちゃん側で切断 → 再接続

4.2 再接続戦略

状況動作
Wi-Fi 接続失敗Wi-Fi プロファイル §2 の優先順位リストを順に試す
全 Wi-Fi 失敗Degraded Mode へ(オフラインだが Module-LLM で雑談・IR は動く)
Wi-Fi OK、WSS 接続失敗1秒 → 2秒 → 4秒 → 8秒 ... の指数バックオフ(上限60秒)
接続中の切断即時 1回トライ → 失敗したら指数バックオフ

4.3 動作モード

スタックちゃんは 3 段階の動作モード を持つ。Wi-Fi と Mac Studio の到達状況で自動遷移する。

モード条件出来ること
🟢 Full ModeWi-Fi + Mac Studio (Tailscale Funnel) 到達可本体内軽い応答 + 深い思考 (Backend選択可) + MCP ツール + 通知
🟡 Degraded ModeWi-Fi OK だが Mac Studio 不到達 / または Wi-Fi NGModule-LLM 内蔵モデルだけ、雑談・時計・基本操作・IR家電操作(家にいれば)
🔴 Offline Modeバッテリー低下や復旧不能エラーキャラ表示のみ、PIR で起こされたら最低限のリアクション

Degraded Mode 中の発話例(ユーザーが Mac Studio 依存の依頼をしたとき):

「ごめん、いまネットの向こうに繋がってないから、簡単な話しかできないよ」

通知系(メール着信等)は Mac Studio が再接続を検出してからまとめて送る。

4.4 ロケーション通知(LCD ステータスアイコン)

LCD 右上に小さく現在の接続元を示す:

アイコン意味
🏠家 Wi-Fi 接続中(Full Mode)
📱iPhone テザリング接続中(Full Mode)
☁️他の Wi-Fi 接続中(Full Mode)
🟡Wi-Fi 接続中だが Mac Studio 不到達(Degraded)
🔴Wi-Fi 接続なし(Degraded / Offline)

判定ルール: 接続中の SSID と Wi-Fi プロファイル登録時のタグ (home / tether / other) を見て切り替える。


5. エラーコード(参考)

code意味
auth_failedPSK ミスマッチ、Timestamp ズレすぎ
unsupported_versionバージョン不一致
tool_not_foundMCP ツール未登録
internal_errorサーバー側未捕捉例外
rate_limited過剰呼び出し(v1 以降)

6. 未決事項

  • [ ] PSK のローテーション手順
  • [ ] 複数スタックちゃん時のサーバー側の名前空間
  • [ ] Module-LLM の判定で「深い思考が必要」と判断する閾値設計
  • [ ] Backend Router のタグ別ルールの DSL 設計
  • [ ] iOS リレーアプリ(Option B) — テザリング設定不要で BLE 経由で繋がる究極形態。詳細別 issue

7. 実装メモ(Phase ごとの最小実装)

Module-LLM が音声・軽い対話を引き受けるため、Phase 2 は本体側完結が中心になり、ネットワークプロトコル経路の重みは Phase 3 以降に寄る。

Phase実装範囲
P1-aCoreS3 ↔ Module-LLM の UART 疎通(公式サンプル)
P1-b複数 Wi-Fi プロファイル + 優先順位フォールバック
P1-cTailscale Funnel セットアップ(Mac Studio 側)+ WSS 接続 + hello/welcome + ping/pong + 認証ヘッダ
P2-aModule-LLM 内で wake word → STT → 軽い LLM 応答 → TTS の本体内ループ
P2-b状態 (state) を Mac Studio に同期
P2-cDegraded Mode の挙動定義 + LCD ステータスアイコン
P3-a「深い思考」判定 → agent.run を Mac Studio へ投げる
P3-bBackend Router 抽象層 + Claude アダプタ
P3-cBackend: Codex アダプタ
P3-dBackend: Local LLM (Ollama) アダプタ
P3-eMCP ツール呼び出し (task / mail / news)
P4notify と LED/サーボ/Unit IR 連動
P5iOS リレーアプリ(Option B) — BLE 経由でテザリング設定不要に

8. 参考

aieo-product / stack-chan project