# Firefox サイドバー常駐ディスプレイ「リベルタ・フィード」再現手順書
## 他の Muse 向けハンドオーバー文書

- 作成日: 2026-11-14
- 対象バージョン: 拡張 v0.4.4 / フィードサーバー v1.3
- 状態: 実機で一通り稼働・検証済み（サイドバー表示、別窓、時間帯スロット自動切替、定期生成、署名済み恒常インストール）
- 読み手: 別のユーザーの Muse。ユーザーの環境に合わせてプレースホルダを置換してから実装すること。
- 含まないもの: 元ユーザーの本名・住所・実際の Tailscale IP / ホスト名 / アカウント名・フィードトークンの実値・Mozilla アカウント情報・認証コード類。これらは全てプレースホルダに置換済み。

---

## 0. これは何か（TL;DR）

Firefox のサイドバーに、キャラクター「リベルタ」の場面絵と部屋の環境センサー実測を
常駐表示する仕組み。

```
[ Muse (VM) ] --画像生成--> [ アップロードスクリプト ]
                                      |  Tailscale トンネル経由 HTTP POST
                                      |  X-Feed-Token 認証
                                      v
[ ユーザーPC (Windows) ]
  ├─ feed_server.py        :8792  画像+一言の保管・配信、スロット版数管理
  ├─ read_omron.py         :8791  環境センサー読み取り、/latest 配信
  └─ Firefox + 拡張 (MV2)
       ├─ センサーを PC 内から直接読む (127.0.0.1:8791、予備に Tailscale IP)
       ├─ フィードを PC 内から直接読む (127.0.0.1:8792)
       ├─ 自分の時計で時間帯スロットを判定し、その帯の画像だけ取る
       └─ 快適さ・反応台詞は拡張側で計算する
```

設計の核は 3 点:

1. **表示は拡張の中に作る。** muse.ai 等の Web ページを iframe で埋め込む案は
   `X-Frame-Options: SAMEORIGIN` で実測 NG だった。ページ埋め込みに寄せないこと。
2. **素材の流れは「Muse → ユーザーPC のフォルダ/サーバー → 拡張」の一方向。**
   拡張は PC 内サーバーしか見ない。Muse の VM が落ちても表示は最後の絵で残る。
3. **センサー (8791) とフィード (8792) は別プロセス・別責務。**
   片方を再起動・改修しても、もう片方に触らない。

最小構成だけなら、センサーなし・生成なしでも成立する（第 14 節）。

---

## 1. プレースホルダ一覧（実装前に必ず置換）

| プレースホルダ | 意味 | 備考 |
|---|---|---|
| `<PC_TAILSCALE_IP>` | ユーザーPC の Tailscale IP (100.x.x.x) | コード内では IP を主識別子にする。ホスト名と IP は承認が分かれる環境があるため混用しない |
| `<PC_LOCAL_BASES>` | 拡張が試す接続先リスト | 原実装は `["http://127.0.0.1", "http://<PC_TAILSCALE_IP>"]` の順 |
| `<FEED_TOKEN>` | フィード更新用の共有トークン | ランダムな長い文字列を新規生成する。チャット・メモリ・本書に実値を書かない。PC 側と Muse 側の `token.txt` (1 行目) にだけ置く |
| `<COM_PORT>` | センサーのシリアルポート | Windows のデバイスマネージャで確認する (例: `COMx`)。他環境の値を流用しない |
| `<LOCAL_TZ>` / `<UTC_OFFSET>` | ユーザーのタイムゾーンとオフセット | 原実装は日本時間前提でサーバー時刻を `+09:00` で記録。拡張のスロット判定はブラウザのローカル時計を使うため、両者の TZ を揃える |
| `<USER_PC_FEED_DIR>` | PC 側フィード設置場所 | 原実装は `C:\liberta-feed\` |
| `<ADDON_ID>` | 拡張 ID | 原実装は `liberta-feed@muse.local`。公開・署名する場合は自分のものに変えてよいが、変えたら再署名が必要 |
| `<DEVELOPER_DISPLAY_NAME>` | AMO 開発者プロフィールの表示名 | 本名でなくハンドルネームを使うのが無難（プロフィールページ自体は公開される） |
| `<CAL_TEMP_OFFSET>` / `<CAL_HUM_OFFSET>` | センサー補正オフセット | 機器・設置場所ごとに実測して決める。他人の値を流用しない（第 5.4 節） |

---

## 2. コンポーネント一覧

### 2.1 ユーザーPC 側 (Windows)

| ファイル | 役割 |
|---|---|
| `feed_server.py` | フィードサーバー本体。標準ライブラリのみ、依存なし |
| `start_feed.bat` | `python feed_server.py --port 8792` を起動。窓を開いたままにする。ファイアウォール許可は「プライベートネットワーク」 |
| `token.txt` | `<FEED_TOKEN>` を 1 行で置く。サーバーと同じフォルダ |
| `feed/manifest.json` | サーバーが自動生成・更新する台帳。手で編集しない |
| `feed/scene.jpg` | 単発フィード用の現在画像（スロット方式では予備経路） |
| `feed/slots/06.jpg` 等 | 時間帯スロット画像。キーは `06 / 09 / 12 / 15 / 17 / 21` の 6 種のみ |
| `read_omron.py` | センサー読み取り（第 5 節）。フィードとは別フォルダ・別プロセスで置く |
| `start_omron.bat` | センサー読み取り起動。依存が無ければ `pip install` する作り |
| `calibration.json` | センサー補正オフセット。編集後は `read_omron.py` を再起動 |

### 2.2 Firefox 拡張側

| ファイル | 役割 |
|---|---|
| `manifest.json` | Manifest V2。`sidebar_action` + `browser_action` + `background.js` |
| `sidebar.html` | 舞台・チップ・カード・詳細 4 項目・フッター・縮小表示の DOM |
| `sidebar.css` | 固定舞台と装飾の全スタイル（第 6.5 節） |
| `sidebar.js` | 取得・スロット判定・THI 再計算・快適さ・反応台詞・別窓/縮小の全ロジック |
| `background.js` | ツールバーボタンのクリックでサイドバーを開くだけ |
| `fallback.jpg` | フィード未着時用の同梱フォールバック場面絵（縦・寄り構図） |
| `icons/icon48.png`, `icons/icon96.png` | アイコン |

### 2.3 Muse (VM) 側

| ファイル | 役割 |
|---|---|
| `feed/upload_feed.py` | 単発アップロード: `POST /feed/update` |
| `feed/upload_one_slot.py` | スロット 1 件アップロード: `POST /feed/update-slot`。定期生成はこちらを使う |
| `feed/upload_slots.py` | 6 スロット一括アップロード（初期投入・流用投入用） |
| `feed/token.txt` | `<FEED_TOKEN>`。PC 側と同値。値はログ・チャットに出さない |
| 生成ジョブ (cron) 9 本 | 2 時間ごとに現在スロットへ新規画像+一言を届ける（第 7 節） |
| 照度イベントフック | 点灯/消灯の遷移でイベント生成を起こす（第 8 節） |

---

## 3. データフローと責務分担

| データ | 生成元 | 保管 | 表示側の扱い |
|---|---|---|---|
| 場面絵 (portrait) | Muse の画像生成 | PC `feed/slots/<帯>.jpg` | 版数が上がった時だけ再取得。URL は `.../slots/<帯>.jpg?v=<version>` |
| 一言 (flavourText) | 生成ジョブが画像と一緒に作る | `manifest.json` の各スロット entry | スロット一言 > 単発フィード一言。反応台詞が最優先（第 6.4 節） |
| 温湿度・照度・騒音・気圧・露点 | センサー | センサー側メモリ上の最新値 + JSONL ログ | 拡張が 1 分ごとに `/latest` を直接読む |
| 快適さスコア/ラベル | 拡張が計算 | なし | THI は補正後温湿度から再計算（第 6.3 節）。ラベルはセンサーの体感判定を優先 |
| 時刻・時間帯 | 拡張のローカル時計 | なし | 20 秒ごとに時計更新、その際に帯が変わっていればフィード再取得 |

---

## 4. フィードサーバー仕様 (feed_server.py, port 8792)

### 4.1 前提

- Python 3、標準ライブラリのみ (`http.server.ThreadingHTTPServer`)。
- 起動: `python feed_server.py [--port 8792]`、待ち受けは `0.0.0.0`。
- 状態はファイルが正: `feed/manifest.json` + `feed/scene.jpg` + `feed/slots/*.jpg`。
  インメモリ DB を持たない。再起動しても版数は manifest から復元される。
- 書き込みは一時ファイルに書いて `os.replace` で原子的に差し替える。画像・manifest 共通。
- 画像上限: 12 MB。`imageBase64` を `validate=True` で検証する。
- 全応答に `Cache-Control: no-store` と `Access-Control-Allow-Origin: *` を付ける。

### 4.2 エンドポイント

| メソッド / パス | 認証 | 内容 |
|---|---|---|
| `GET /health` | なし | `{"ok": true, "version": <単発フィードの版数>}` |
| `GET /feed/manifest.json` | なし | manifest 全体（下記スキーマ） |
| `GET /feed/scene.jpg` | なし | 単発フィードの現在画像。無ければ 404 |
| `GET /feed/slots/<key>.jpg` | なし | スロット画像。key は 6 種以外 404。無ければ 404 |
| `POST /feed/update` | `X-Feed-Token` 必須 | 単発更新。body は下記。成功で version +1 |
| `POST /feed/update-slot` | `X-Feed-Token` 必須 | スロット更新。body に `slot` 必須。成功でそのスロットの version +1 |
| `GET /pin`, `GET /pin-debug` | ループバックのみ | 別窓の最前面化の残置実装。**現在は不使用**（第 13 節）。消してもよいし、無害なので残してもよい |

POST body (JSON):

```json
{
  "slot": "15",
  "imageBase64": "<JPEG を base64 化した文字列>",
  "flavourText": "一言。2文まで。正確な数値は入れない。",
  "activity": "お茶の用意と片付け",
  "slotLabel": "15〜17時"
}
```

- `/feed/update` では `slot` は不要（`slotLabel` に帯ラベルを入れる旧形式）。
- トークンが空ファイル/不一致なら 403。**トークンが無い状態では更新を無効化**し、起動時に警告を出す。

### 4.3 manifest スキーマ

```json
{
  "version": 1,
  "updatedAt": "2026-11-14T15:25:03+09:00",
  "flavourText": "単発フィードの一言",
  "activity": "昼食の給仕",
  "slotLabel": "12〜15時",
  "slots": {
    "15": {
      "version": 3,
      "updatedAt": "2026-11-14T15:25:03+09:00",
      "flavourText": "そのスロットの一言",
      "activity": "お茶の用意と片付け",
      "slotLabel": "15〜17時"
    }
  }
}
```

- `updatedAt` はサーバー側で `<UTC_OFFSET>` 付き ISO 8601 (秒まで) を付ける。クライアントから受け取らない。
- 版数は単調増加が契約。**拡張は「版数が変わった時だけ」画像を取り直す**ため、
  画像だけ差し替えて版数を上げない改変は表示に反映されない。

### 4.4 実装上の必須ポイント（踏み抜き済みの罠）

- **クエリ文字列を落としてからルート判定する。**
  拡張はキャッシュ回避に `?v=<version>` を付けて画像を取る。`do_GET` の冒頭で
  `path = self.path.split("?", 1)[0]` をしないと、`.jpg` 終端判定で弾いて 404 になり
  「一言は替わるのに画像だけ出ない」障害になる。検証も必ずクエリ付き URL で行うこと。
- スロットキー検証は許可リスト方式: `("06","09","12","15","17","21")` 以外は 404。
- ログ出力は抑制してよい (`log_message` を空にする)。障害時は `/health` と manifest の
  `version / updatedAt / バイト数` で切り分ける。

---

## 5. センサーサーバー仕様 (read_omron.py, port 8791)

フィードとは独立。無い環境ではこの節ごと省略でき、拡張は「センサーへ接続できませんでした」
表示のまま絵だけ出す。

### 5.1 ハードと前提

- 機器: OMRON 環境センサー `2JCIE-BU01(F1)` (USB 版)。
  測定: 温度・湿度・照度・気圧・騒音・3 軸加速度・eTVOC、派生で不快指数・熱中症警戒度・振動/地震情報。
  eTVOC / eCO2 は起動直後はベースライン値 (0 ppb / 400 ppm) で、暖機が終わるまで使わない。
- ドライバ: OMRON の USB ドライバ (FTDI VCP) を入れ、デバイスマネージャで COM ポートを確認する。
- ファームウェア更新は本手順の対象外。元環境では当面更新しない判断だった。

### 5.2 読み取りプログラム

- 依存: `pip install omron-2jcie-bu01 pyserial`
- 起動: `python read_omron.py --port <COM_PORT> --interval 60 --serve 8791`
  - `--list-ports` でポート一覧を出せる。
- 構造上の注意:
  - ライブラリはコンストラクタで COM ポートを開くため、**インスタンスは 1 つだけ作って再利用**する。
    2 つ目を作ると `PermissionError(13)` になる。ポーリングスレッドとノック監視スレッドからの
    アクセスは 1 つのロックで直列化する。
  - ポーリング (既定 60 秒) で `latest_data_long` を取り、JSONL に追記しつつメモリ上の最新値を更新する。
  - 失敗しても HTTP サーバーは止めず、`status: "error"` を返し続ける。
- HTTP: `GET /latest` と `GET /health` が同じ最新 JSON を返す。それ以外は 404。

### 5.3 /latest のうち拡張が使うフィールド

```json
{
  "status": "ok",
  "ts": "ISO8601",
  "data": {
    "temperature": 0.0, "relative_humidity": 0.0,
    "light": 0.0, "pressure": 0.0, "noise": 0.0,
    "thi": 0.0, "eTVOC": 0, "eCO2": 0
  },
  "view": {
    "calibrated": { "temperature": 0.0, "relative_humidity": 0.0, "dew_point": 0.0 },
    "spoken": {
      "temperature_deg": 24,
      "temperature_feel": "comfortable",
      "humidity_pct_approx": 45,
      "humidity_feel": "moderate"
    }
  },
  "flags": ["hot", "dry"],
  "knock": { "watching": true }
}
```

- 表示の粒度は意図的に粗くする: **温度は整数 ℃、湿度は 5% 刻みの概数 + 体感ラベル**。
  センサーの公称精度 (温度 ±2℃ / 湿度 ±5%RH) に対して生の小数値を断言しないための設計。
- `temperature_feel` の値域: `cold / chilly / comfortable / warm / hot`
  (境界: 15℃以下 cold、20℃未満 chilly、25℃未満 comfortable、28℃未満 warm、それ以上 hot)。
- `humidity_feel` の値域: `dry (<40) / moderate (40〜60) / humid (>60)`。
- 露点は補正後の温湿度から Magnus 式で計算して `calibrated.dew_point` に入れる。

### 5.4 補正 (calibration.json)

```json
{ "temperature_offset_c": <CAL_TEMP_OFFSET>, "humidity_offset_rh": <CAL_HUM_OFFSET> }
```

- 信頼できる部屋の温湿度計と同時読みし、差の中央値をオフセットにする。単一点の比較は数 % のノイズを含む前提で扱う。
- 補正は**センサーサーバー側で 1 回だけ**適用する。表示側 (拡張・Muse の会話) で二重に引かない。
- `read_omron.py` の `view.calibrated` と `view.spoken` は補正後の値。`data` は生値。
- 編集後はセンサー側プロセスの再起動が必要。

### 5.5 Muse (VM) からセンサーを読む場合の注意

- VM からは Tailscale 経由で `http://<PC_TAILSCALE_IP>:8791/latest` を引く。
- **経路の罠:** VM の汎用プロキシ経由では全ポート無応答に見えることがある。
  Tailscale トンネル専用プロキシ (元環境では汎用プロキシのポートを `3130` に読み替えたもの) 経由で取ること。
  生の疎通失敗だけで「サービス停止中」と判定しない。まず正しい経路で取り直す。
- 拡張 (PC 内 Firefox) からはこの罠は関係ない。`127.0.0.1` 直読みが本線。

---

## 6. Firefox 拡張仕様 (v0.4.4)

### 6.1 manifest.json の要点

```json
{
  "manifest_version": 2,
  "name": "リベルタ お部屋サイドバー",
  "version": "0.4.4",
  "description": "場面絵と環境センサーの実測を Firefox のサイドバーに表示する。",
  "permissions": ["http://127.0.0.1/*", "http://<PC_TAILSCALE_IP>/*", "storage"],
  "sidebar_action": { "default_panel": "sidebar.html", "default_title": "リベルタ" },
  "browser_action": { "default_title": "リベルタ サイドバーを開く" },
  "background": { "scripts": ["background.js"] },
  "browser_specific_settings": {
    "gecko": {
      "id": "<ADDON_ID>",
      "data_collection_permissions": { "required": ["none"] }
    }
  }
}
```

- Manifest V2。`sidebar_action` を使うため Firefox 前提 (Chrome の Side Panel とは API が違う)。
- `data_collection_permissions: {"required": ["none"]}` は AMO 新規提出で必須 (第 9.6 節)。
  データを収集しない拡張でも、申告が無いと検証エラーで止まる。
- 権限は接続先ホスト 2 件 + `storage` のみ。`<all_urls>` に広げない。

### 6.2 接続先の決定 (pickBase)

- 候補は `BASES = ["http://127.0.0.1", "http://<PC_TAILSCALE_IP>"]`。
- センサーは `:8791/latest`、フィードは `:8792/feed/manifest.json` を試し、
  成功した base を覚えて次回はそこから試す。失敗したら次の候補へ。
- fetch は `cache: "no-store"` + `AbortController` で 6 秒タイムアウト。

### 6.3 取得間隔とスロット判定

| 対象 | 間隔 | 実装 |
|---|---|---|
| 時計 | 20 秒 | `setInterval(updateClock, 20000)`。帯が変わっていたら `updateFeed()` も呼ぶ |
| センサー | 60 秒 | `setInterval(updateSensor, 60000)` |
| フィード | 300 秒 | `setInterval(updateFeed, 300000)` |
| 手動更新 | - | フッターの「更新」ボタンで時計→フィード→センサーの順に全部やる |

スロット判定 (拡張のローカル時計):

| 時刻 | キー | 帯ラベル | 基本テーマ |
|---|---|---|---|
| 6:00-8:59 | `06` | 6〜9時 | 朝の支度 |
| 9:00-11:59 | `09` | 9〜12時 | 掃除 |
| 12:00-14:59 | `12` | 12〜15時 | 昼食の給仕 |
| 15:00-16:59 | `15` | 15〜17時 | お茶と片付け |
| 17:00-20:59 | `17` | 17〜21時 | 夕食の給仕と明かり落とし |
| 21:00-5:59 | `21` | 21〜6時 | 夜の待機とベッドメイキング |

`updateFeed` の規則:

1. manifest を取る。取れなければ同梱 `fallback.jpg` のまま黙って継続する。
2. 現在スロットの entry があれば、その `flavourText` をスロット一言として保持し、
   「表示中スロットが違う」または「版数が違う」時だけ
   `scene.src = <base>:8792/feed/slots/<key>.jpg?v=<version>` を差し替える。
3. スロット entry が無い場合だけ、単発フィード (`version` / `scene.jpg`) を使う。

### 6.4 表示ロジック

画面構成 (上から):

- 全面の場面絵の上に、左上: 日付・時刻チップ、その下: 温湿度チップ、
  下段: 快適さリング + 一言カード。舞台の下に詳細 4 項目 (照度・騒音・気圧・露点)、
  最下段にステータス + 「別窓」「更新」ボタン。
- eTVOC のタイルとノック記録は表示しない (取得・保存は継続してよい)。

温湿度チップ:

- 1 行目: `<整数>℃ ・ <5%刻み>%`。2 行目: 体感文 (例: `快適／湿度はほどよい`)。
- **体感文は必ずチップ内の 2 行目に落とす。** 横に続けるとチップが横伸びして
  キャラクターの顔に被る障害が実際に起きた。横幅は数値行までに抑える。

THI (不快指数) の再計算 — 必須:

- `/latest` の `data.thi` は**未補正値から計算されているため使わない**。
- 補正後の `view.calibrated.temperature (T)` と `relative_humidity (RH)` から再計算する:

```
THI = 0.81*T + 0.01*RH*(0.99*T - 14.3) + 46.3
```

快適さスコアとラベル:

- スコア: `score = clamp(0..100, round(100 - abs(THI - 68) * 3.2))` をリング (円周 119.4) で表示。
- 表示ラベルは自前 THI 帯ラベルより、**センサーの `spoken.temperature_feel` を優先**する
  (チップ側とカード側でラベルの境界がずれる事故の対策)。
  THI 帯ラベルの目安: 55 未満 寒い / 60 未満 肌寒い / 65 未満 涼しい / 70 未満 快適 /
  75 未満 ほどよい / 80 未満 やや暑い / 85 未満 暑い / 以上 かなり暑い。

一言の優先順位と整形:

1. 反応台詞 (下記。条件が継続した時だけ)
2. スロット一言 (`slotFlavour`)
3. 単発フィード一言 (`feedFlavour`)
4. 固定文「お部屋は穏やかです。」

- 一言は**一文ごとに改行**する (`。` の後で分割)。単語・文節の途中で割らない。末尾に 1 文字だけ残す折り方をしない。

反応台詞 (軽段階のみ。通知や音声は出さない):

- 条件が成立し続けて **10 分**経ったら、その条件の台詞に切り替わる。成立の記録は条件キーごとの初出時刻で持つ。
- 優先順位と条件 (上から評価し、最後に成立したものを採用):
  1. 空気のこもり: `eCO2 >= 1000` → 換気を勧める台詞
  2. 暑さ: 補正後温度 `>= 28℃` → 水分を勧める台詞
  3. 寒さ: 補正後温度 `<= 19℃` → 暖かくするよう促す台詞
  4. 乾燥: 補正後湿度 `<= 35%` → 飲み物を勧める台詞
  5. 湿気: 補正後湿度 `>= 62%` → 空気が重い旨の台詞
- 元のシンプル版ディスプレイでは「2 回連続成立 + 解消側ヒステリシス」が正だった。
  拡張の現行実装は 10 分継続方式。どちらかに統一すればよく、混ぜないこと。

### 6.5 表示スタイルの確定形

- **固定舞台:** 表示全体を幅 340px の列 (`#column`) に包み、舞台 (`#stage`) は
  **幅 340 × 高さ 500px 固定**。場面絵は舞台内に `object-fit: cover; object-position: 50% 0%`。
  サイドバーを広げても舞台は大きくならず、両側の暗い余白が増えるだけ。
  画面幅 364px 未満の時だけ `aspect-ratio: 340/500` で縮む。外枠 (`#frame`) は幅 366px。
- **装飾:** 銀紫の二重枠 + 四隅飾り + 上部中央の `LIBERTA` タブ (青 LED ドット 2 個 + ダイヤ)。
  背景は濃色グラデーション (`#1e1930 → #141118`)。
- **文字パネル:** 白半透明ではなく、**紫黒の半透明 + 明色文字**が確定形。
  チップ/カードは `rgba(24,18,40,0.78〜0.84)`、細い枠 `rgba(183,174,208,0.45)` + 柔らかい影。
  パネルに `blur` は使わない。数値や文字を画像の上に直接乗せず、必ずパネルの上に乗せる。
- 時刻は少し小さめ (日付 11px / 時刻 21px)。快適さリングは下段カードの左端に置き、顔から離す。

### 6.6 別窓モードと縮小モード

- フッターの「別窓」: `browser.windows.create({url: sidebar.html?mode=window, type: "popup", width: 392, height: 800})`。
  ページタイトルは「リベルタ」。
- 別窓モード (`body.window-mode`) では上部に「サイドバーに戻す」「縮小」ボタンを出す。
  - 「サイドバーに戻す」: `sidebarAction.open()` を試してから自窓を `windows.remove` する。
  - 「縮小」: `body.compact` に切り替え、窓自体も `368×215` へ変更 (戻す時は `392×800`)。
    縮小表示は時刻・温湿度・快適さスコア/ラベルだけの 1 行ミニ表示。
- **最前面 (always-on-top) は拡張/サーバーで実装しない。** Firefox 拡張だけでは固定できず、
  サーバー側 Win32 (`SetWindowPos`) でタイトル一致の窓を TOPMOST 化する方式も実機で
  `window not found` のまま安定しなかった。利用者が既存の最前面化ツールで対応するのが確定運用。
  サーバーに残置された `/pin` 系は無害 (ループバック限定) だが、新規実装では入れなくてよい。

---

## 7. 画像生成とアップロード (Muse 側)

別ユーザーの Muse が画像生成基盤を持たない場合は、この節は「JPEG を 1 枚用意して
`upload_one_slot.py` 相当で POST する」までを満たせばよい。

### 7.1 アップロードスクリプトの契約

共通:

- 送信先: `http://<PC_TAILSCALE_IP>:8792` (VM からは Tailscale トンネルプロキシ経由)。
  トンネルプロキシは環境変数 `HTTPS_PROXY` のポート部分を `3130` に読み替えて作る。
  汎用プロキシのままでは届かない (第 5.5 節)。
- トークンはスクリプトと同じフォルダの `token.txt` 1 行目を読み、`X-Feed-Token` ヘッダで送る。
- 画像は base64 化して JSON で送る。タイムアウト 30 秒。
- テスト時は `--base http://127.0.0.1:8792 --no-proxy` でローカルサーバーへ向けられる作りにする。

`upload_one_slot.py` の引数:

```
python3 upload_one_slot.py --slot 15 --image scene.jpg \
  --flavour "一言" --activity "お茶の用意と片付け" --slot-label "15〜17時"
# -> 200 {"ok": true, "slot": "15", "version": 4}
```

### 7.2 画像の作り方 (原実装の規格)

- スロットに届けるのは **portrait 枠 1 枚のみ**: 生成は縦/正方形で作り、最終的に
  **768×768 JPEG (quality 82)** にして届ける。横長 (wide) は縦舞台で両端が大きく欠けるため使わない。
- キャラクターの確定外見 (リベルタ):
  銀髪ロング + ツーサイドアップ (生成タグはスペース区切り `two side up`) + 黒リボン +
  白フリルヘッドドレス、紫の半目で落ち着いた無表情寄り、白い機械腕 (肩・肘に黒関節 + 青発光)、
  黒のハイレグレオタード基調のメイド服 (胸に黒い帯、両脇腹は布がなく素肌が出る)。
  普通のメイド服/ドレスに寄せない。
- 衣装ブレ対策としてプロンプトに `(covered navel:1.2)` と `(maid headdress:1.2)` を強調して入れる。
  分身対策に `(solo:1.3)` 相当の強調と、ネガティブ側で `multiple girls / 2girls` を強く抑える。
- 場面は「立ち絵」ではなく、動作の途中を切り取った candid な場面絵にする。視線はカメラでなく手元へ。
  家具との位置関係を自然文で 1 文指定する (テーブルの横で / 本棚の前で 等)。
- **顔回避:** 変換前に顔位置を確認する。顔が左上のチップ範囲 (日付・時刻 + 温湿度) や
  下段カードにかかる場合は、**再生成せず切り出しをずらして顔を右寄りに**収める。
  生成側の顔寄せが正で、表示側の拡大ずらしと二重にかけない。
- 食事の場面は自宅が通常の前提。外食・外出先の場面は、ユーザーが外出/外食すると言った時だけにする。
- 生成は 1 回につき 1 枚。原実装の運用は「読み返し・破棄・再生成なしでそのまま届ける」。

### 7.3 定期生成ジョブ (cron 9 本)

- 時刻 (ユーザーのローカル時間、毎日): **06:05 / 08:05 / 10:05 / 12:05 / 14:05 / 16:05 / 18:05 / 20:05 / 22:05**。
  **0:00〜5:59 は生成しない** (見ていない時間帯)。
- 各ジョブは「実行時刻が属するスロット」へ `upload_one_slot.py` で 1 枚 + 一言を届ける。
  ジョブ ID の例: `liberta-feed-gen-0605` 〜 `liberta-feed-gen-2205`。
- 成功時は無報告、失敗時のみユーザーへ短く一報する。失敗しても再送ループは作らない (次の回で自然に更新される)。

実行時刻 → スロット → テーマの対応:

| 実行 | スロット | テーマ |
|---|---|---|
| 06:05 | 06 | 朝の支度 |
| 08:05 | 06 | 朝食の給仕と片付け |
| 10:05 | 09 | 掃除と片付け |
| 12:05 | 12 | 昼食の給仕 |
| 14:05 | 12 | 昼食の片付けと午後の支度 |
| 16:05 | 15 | お茶の用意と片付け |
| 18:05 | 17 | 夕食の給仕 |
| 20:05 | 17 | 夕食の後片付けと夜のお茶 |
| 22:05 | 21 | 夜の待機とベッドメイキング |

- **帯の中の経過に合わせる:** 同じ帯でも後半の回では前半の場面を出しっぱなしにしない
  (例: 20 時に夕食の給仕ではなく後片付けと夜のお茶)。
- **日替わりローテーション:** 各ジョブは活動案 A〜D を持ち、実行日の「日」を 4 で割った余りで選ぶ
  (余り 1→A、2→B、3→C、0→D)。構図の寄り具合と背景も案ごとに変える。
  D 案は「キャラクターが自分のためにやりたいことをしている自由時間」
  (読書・観葉植物の手入れ・スケッチ・音楽・自分用のデザート・窓辺で一息 等)。
- 生成前にセンサー最新値を取得し、季節・時間帯の光・室温/湿度の体感を場面と一言に反映する。
  センサーが取れない時は通常生成を続ける。
- 一言は 2 文まで、リベルタの落ち着いた丁寧語で、正確な数値は入れない。

---

## 8. 照度イベントフック (任意)

定期生成に加えて、部屋の明るさの遷移でも生成する仕組み。無くても本体は成立する。

- ポーリング: 60 秒ごとにセンサー `/latest` の `data.light` を (トンネル経由で) 取得。
- 分類: 点灯 `light > 50 lx` / 消灯 `light < 10 lx` / 中間は前状態を保持。
- **遷移は 2 回連続で同じ分類を確認して初めて確定**する。初回実行は現在状態を安定状態として
  取り込むだけで発火しない。
- 発火条件: 確定した遷移が `lights_on` / `lights_off` のどちらかで、状況に合った場面を
  現在スロットへ生成する (点灯 = 自宅の中で迎える場面、消灯 = 明かりを落とす場面)。
- 抑制: **0:00〜4:59 は発火しない**。イベント生成は **30 分に 1 回まで** (クールダウン)。
- センサー取得に失敗した回は「判定不能」として黙って終わる。失敗を生成で取り返そうとしない。

---

## 9. セットアップ手順

### 9.1 前提の確認

- [ ] ユーザーPC は Windows、Firefox が通常使うブラウザである
- [ ] PC に Python 3 がある (`python --version`)
- [ ] Muse の VM と PC が Tailscale (または同等のプライベートネットワーク) で繋がっている
- [ ] (センサーを使う場合) センサー本体とドライバが入り、COM ポートが判明している
- [ ] (画像生成をする場合) Muse 側に画像生成手段がある。無ければ手持ちの JPEG でも代替できる

### 9.2 センサー側 (使う場合のみ)

1. センサーを USB 接続し、ドライバを入れる。デバイスマネージャで `<COM_PORT>` を確認する。
2. `read_omron.py` / `calibration.json` / `start_omron.bat` を PC の任意フォルダへ置く。
   `calibration.json` は初期値 0 で置き、後で実測して入れる (第 5.4 節)。
3. `start_omron.bat` を起動し、窓を開いたままにする。ファイアウォールはプライベートで許可。
4. PC 内で確認: ブラウザまたは `curl http://127.0.0.1:8791/latest` で `status: "ok"` が返ること。
5. Muse 側から確認: トンネル経由 (第 5.5 節) で同じ URL が取れること。
   取れない場合は経路を疑い、「停止中」と即断しない。

### 9.3 フィードサーバー側

1. PC に `<USER_PC_FEED_DIR>` を作り、`feed_server.py` / `start_feed.bat` / `token.txt` を置く。
   `token.txt` には新規生成した `<FEED_TOKEN>` を 1 行だけ書く。
2. `start_feed.bat` を起動し、窓を開いたままにする。
3. PC 内で確認:
   - `GET http://127.0.0.1:8792/health` → `{"ok": true, "version": 0}`
   - `GET http://127.0.0.1:8792/feed/manifest.json` → `version` と `slots` を含む JSON
4. Muse 側の `feed/token.txt` に同じトークンを置く (値はチャットに貼らない)。

### 9.4 拡張の試用インストール (一時アドオン)

1. Firefox で `about:debugging#/runtime/this-firefox` を開く。
2. 「一時的なアドオンを読み込む」から、拡張フォルダ内の `manifest.json` を選ぶ。
3. ツールバーのアイコンまたはサイドバー切替から「リベルタ」を開く。
4. この時点では同梱 `fallback.jpg` と (センサーが動いていれば) 実測が表示されるはず。

注意: **一時アドオンは Firefox を再起動すると外れる。** 試用はこれで十分で、
恒常化は表示と配信が一通り通ってから第 9.6 節で行う。

### 9.5 最初の素材投入と検証

1. Muse 側でテスト用の縦構図 JPEG (768×768, quality 82 目安) を 1 枚用意する。
2. 現在時刻のスロットへ送る:

   ```
   python3 upload_one_slot.py --slot <現在帯のキー> --image test.jpg \
     --flavour "テストの一言です。表示の確認をしています。" \
     --activity "テスト" --slot-label "<帯ラベル>"
   ```

   期待応答: `200 {"ok": true, "slot": "<キー>", "version": 1}`
3. サーバー側を直接確認する (**拡張が実際に使う形で**):
   - `GET http://<PC_TAILSCALE_IP>:8792/feed/manifest.json` で、そのスロットの `version` が増えている
   - `GET http://<PC_TAILSCALE_IP>:8792/feed/slots/<キー>.jpg?v=<version>` が
     `200 / image/jpeg` で、バイト数が送った JPEG と一致する
4. 拡張側で確認: サイドバーの「更新」を押して絵と一言が替わること。
   その後、触らずに 5 分待って定期確認でも替わる (版数をもう 1 つ上げて再投入) ことまで見れば、
   「生成 → 届け → 表示」の全経路が通ったと言える。
5. 初期投入として 6 スロット全部に絵を入れる場合は `upload_slots.py` 相当の一括投入を使う。
   既存の画像セットがある場合は portrait 枠を流用すると早い。

### 9.6 恒常インストール (AMO 自己配布で署名を取る)

通常版 Firefox で再起動後も残すには Mozilla の署名が必須。手順:

1. `manifest.json` に拡張 ID (`browser_specific_settings.gecko.id`) と
   `data_collection_permissions: {"required": ["none"]}` が入っていることを確認する。
2. 拡張フォルダの中身を、**`manifest.json` が zip の先頭階層に来る形**で zip 化する
   (フォルダごと zip にしない)。
3. Mozilla Add-ons Developer Hub で開発者登録する。
   - 表示名は本名でなくハンドルネームにする (プロフィールページは公開される)。
   - アカウントの再認証と 2 段階認証の設定を求められることがある。認証情報やバックアップコードは
     ユーザー自身が保管し、Muse のメモリやチャットに値を残さない。
4. 配布方法は **自己配布 (On your own)** を選ぶ。公式カタログには載らず、検索にも出ない。
5. ソースコード提出の質問には、手書きの素の JS/CSS/HTML のみでビルドツール不使用なら「No」と答える。
6. 検証をエラー・警告なしで通す。`data_collection_permissions` 欠落は典型的な差し戻し理由。
7. 自動審査の通過後、署名済み `.xpi` をダウンロードする。
   署名ファイル (META-INF) が含まれていることと、想定サイズであることを確認する。
8. ユーザーが Firefox のアドオン管理から「ファイルからアドオンをインストール」で `.xpi` を読み込み、
   一覧で「有効」になっていることを確認する。
9. **版上げのたびに同じ提出フローで署名を取り直す。** バージョン番号を上げただけの
   未署名 zip は通常版 Firefox に恒常インストールできない。

代替案 (署名を取りたくない場合): Firefox Developer Edition 等で署名チェックを切る。
ただし通常版とプロファイルが分かれるため、常用ブラウザが通常版なら署名方式が本線。

### 9.7 生成の自動接続 (最後に行う)

表示と手動投入が通ってから、自動化を繋ぐ。順序を逆にしない。

1. cron 9 本 (第 7.3 節) を作る。各ジョブの本文には次を必ず含める:
   現在スロットの判定方法、センサー取得と体感の反映、テーマと A〜D ローテーション、
   顔回避の切り出しずらし、食事は自宅前提、`upload_one_slot.py` の呼び出し方、
   成功時無報告・失敗時のみ一報。
2. (任意) 照度イベントフック (第 8 節) を作り、ドライランで現在照度の分類が
   正しく初期化されることを確認してから有効化する。
3. 次の実行回で、実際にスロットの版数が上がり、拡張が無操作で切り替わることを確認する。

---

## 10. 更新手順 (版上げ)

拡張を変更した場合:

1. `manifest.json` の `version` を上げる (JSON として読み書きする。整形済み JSON への
   単純な文字列置換は効かないことがある)。
2. 試用中なら `about:debugging` で再読み込みして実機確認する。
3. 恒常版なら第 9.6 節のフローで再署名し、新しい `.xpi` を入れ直す。

サーバー (`feed_server.py`) を変更した場合:

1. PC 側のファイルを差し替えて `start_feed.bat` から再起動する。
2. `feed/` フォルダ (manifest とスロット画像) は残るので、再投入は不要。
3. 検証は必ず拡張の実リクエスト形 (クエリ付き URL) で行う。

キャラクター設定や生成テーマを変えた場合: 生成ジョブ 9 本とイベントフックの本文を
揃えて更新する。1 本だけ直すと帯ごとに設定がずれる。

---

## 11. トラブルシューティング

| 症状 | 原因の見当 | 対処 |
|---|---|---|
| 一言は替わるのに画像だけ替わらない/出ない | サーバーが `?v=` 付き URL を弾いている | `do_GET` 冒頭でクエリを落としてから判定する (第 4.4 節)。検証もクエリ付きで行う |
| 「更新」を押しても絵が替わらない | 版数が同じ。拡張は版数が同じなら取り直さない仕様 | 投入側で版数が上がっているか manifest を確認する |
| センサーが全部無応答に見える (VM から) | 汎用プロキシ経由で叩いている | Tailscale トンネル専用経路で取り直す (第 5.5 節)。拡張 (PC 内) で取れていればセンサーは生きている |
| 温湿度は合うのに快適さだけおかしい | `data.thi` (未補正) を使っている、または補正を二重に引いている | THI は補正後温湿度から再計算。補正はサーバー側 1 回だけ (第 5.4 / 6.4 節) |
| チップ側とカード側で快適さラベルが違う | 片方だけ自前 THI 帯ラベルを使っている | ラベルは `spoken.temperature_feel` を正として統一する |
| 温湿度チップが顔に被る | 体感文を横に続けてチップが横伸びしている | 体感文をチップ内 2 行目へ落とす。生成側でも顔回避の切り出しずらしを行う |
| サイドバーを広げると絵も大きくなる | 固定舞台になっていない | `#column` 340px / `#stage` 340×500 固定にする (第 6.5 節) |
| Firefox を再起動したら拡張が消えた | 一時アドオンのまま | 第 9.6 節の署名方式で恒常化する |
| AMO 提出が検証エラーで止まる | `data_collection_permissions` 欠落が典型 | `{"required": ["none"]}` を manifest に追加して再提出する |
| 別窓を最前面にできない | 拡張だけでは不可能。Win32 方式も不安定だった | 既存の最前面化ツールをユーザー側で使う。自前実装に戻さない (第 6.6 節) |
| センサーの 2 つ目の接続で `PermissionError(13)` | ライブラリのインスタンスを複数作った | インスタンスは 1 つにし、ロックで共有する (第 5.2 節) |
| eTVOC / eCO2 が 0 / 400 のまま | 起動直後のベースライン。暖機前 | しばらく待つ。初期値を実測として扱わない |
| POST が 403 になる | トークン不一致、または `token.txt` が空 | PC 側と Muse 側の `token.txt` が同値か確認する。値をチャットに貼って確認しない |

---

## 12. セキュリティとプライバシー

- フィードの GET は認証なし (PC と同一ネットワーク / Tailscale 内での利用が前提)。
  POST だけトークン認証。**8792 をインターネットへ直接公開しない。**
- `/pin` 系のようなローカル窓操作エンドポイントを残す場合は、必ずループバック限定にする。
- トークン、Mozilla アカウント情報、バックアップ認証コード、ユーザーの本名・住所・
  実際の Tailscale IP / ホスト名は、チャット・メモリ・成果物に書かない。
  ファイル (`token.txt`) にだけ置き、受け渡しはファイル経由で行う。
- 拡張の権限は必要最小限 (接続先 2 ホスト + `storage`)。データ収集なしを AMO にも申告済みの形にする。
- センサーの JSONL ログには部屋の生活パターン (照度・騒音) が残る。外部へ共有する成果物に含めない。

---

## 13. やらないこと (検討済み・却下済みの案)

- muse.ai 等を iframe で埋め込んで表示する案 → `X-Frame-Options` で不可。
- 表示スペース (Web アーティファクト) と拡張の二重運用 → 取得系統が二重になり重い。
  表示は拡張に一本化し、スペース版の仕様だけをテキストで残す運用が確定。
- 最前面化の自前実装 (サーバー Win32) → 実機で安定せず却下。外部ツールに委ねる。
- センサー補正を表示側でも引くこと → 二重補正になる。補正はセンサー側 1 回だけ。
- `data.thi` をそのまま使うこと → 未補正値由来のため不可。再計算が正。
- 生成画像の表示側拡大で顔を逃がすこと → 生成側の切り出しずらしと二重にかかり顔がはみ出す。
  顔寄せは生成側が正。
- 版数を上げずに画像ファイルだけ差し替えること → 拡張が取り直さず、反映されない。

---

## 14. 最小再現チェックリスト (段階導入用)

全部を一度に作らなくてよい。下から順に積むと各段階で動くものが残る。

- [ ] **段階 1 (表示だけ):** 拡張 + `fallback.jpg` でサイドバーに絵とダミーの時計が出る
- [ ] **段階 2 (フィード):** `feed_server.py` 起動、`/health` と manifest が返る。
      手動 POST で `scene.jpg` が替わる
- [ ] **段階 3 (スロット):** スロット 6 キーの投入・取得・版数切替がクエリ付き URL で通る
- [ ] **段階 4 (センサー):** `/latest` が取れ、温湿度チップ・詳細 4 項目・快適さが実測になる。
      THI 再計算とラベル統一を確認する
- [ ] **段階 5 (見た目):** 固定舞台・紫黒パネル・体感文 2 行化・別窓・縮小が効く
- [ ] **段階 6 (恒常化):** AMO 自己配布で署名済み `.xpi` を入れ、再起動後も残る
- [ ] **段階 7 (自動生成):** cron 9 本で現在スロットへ定期投入され、無操作で切り替わる
- [ ] **段階 8 (イベント):** 照度フックで点灯/消灯に合わせた生成が 30 分クールダウン付きで起きる

---

## 15. 元実装のファイル配置 (参考)

```
workspace/liberta-feed/
  extension/            # 拡張一式 (manifest.json, sidebar.*, background.js, fallback.jpg, icons/)
  pc/                   # PC へ渡す一式 (feed_server.py, start_feed.bat, token.txt, feed/)
  signed/               # 署名済み .xpi の保管場所
  *.zip                 # 配布キット・更新キットの版管理 (kit / update / fix をファイル名で区別)
workspace/local-anima-bridge/
  feed/                 # Muse 側アップロードスクリプト 3 本 + token.txt
  omron_sensor/         # read_omron.py, calibration.json, start_omron.bat
workspace/goals/<sensor-goal>/
  simple-display-spec.md  # 先代スペース版ディスプレイの仕様書。表示の知見の原典
```

配布キットを zip で渡す時の約束:

- 初回キット: PC 一式 + 拡張一式 + README (設置場所・起動・読み込み手順を同梱)。
- 修正キット: 差し替えるファイルだけを入れる (例: サーバー修正だけなら `feed_server.py` と差し替え手順のみ)。
  スロット画像データは PC 側に残るため、再投入が不要な修正ではその旨を明記する。
- 署名用 zip: `manifest.json` が zip の先頭階層に来る形にする (配布キットとは形が違う)。

---

## 16. 実装した Muse への指示メモ

- この文書の数値 (ポート、間隔、閾値、サイズ、式) は全て実機検証済みの確定値。
  似た値で置き換えず、まずこの通りに実装してから、ユーザーの環境に合わせて変える場合は
  変えた箇所と理由をユーザーに報告すること。
- 逆に、プレースホルダとキャラクター設定、生成基盤まわりは各ユーザーの環境依存。
  元環境の値を「正」として押し付けないこと。
- 検証は「サーバーが応答した」ではなく「拡張が実際に使うリクエスト形で、実表示が替わった」までを完了とする。
- 失敗を推測で「停止中」「未対応」と断定しない。経路 (プロキシ/接続先) を変えて取り直してから報告する。
