本を抜くだけで学習時間を自動記録するスマート本棚「StudyShelf」|M5Stack Tab5×Notion×AI開発

本記事について

高校受験に向けた家庭学習を、子どもに余計な入力をさせず記録したい。そんな課題から、教材を本棚から抜いて戻すだけで学習時間を記録する「Smart StudyShelf」を開発しました。
本記事では、M5Stack Tab5と10個のマイクロスイッチを使った仕組み、Notion連携、組み込みGUI、AIを活用した仕様駆動開発、実機テストで遭遇した問題までまとめます。

なお、本システムのソースコードはCodex、Claude CodeなどのAIコーディングツールを活用して作成しており、筆者自身はソースコードを一切記述していません。

受験勉強では、「今日は何を勉強するか」と同じくらい、「実際にどれだけ取り組めたか」を把握することが大切です。

しかし、学習時間を毎回アプリへ入力する運用は、長く続きません。勉強を始めるたびにタイマーを起動し、終わったら停止し、教科を選ぶ。大人でも忘れる操作を、受験生に毎日求めるのは現実的ではありません。

そこで、普段の動作そのものを入力にすることにしました。

flowchart LR
    Remove["教材を本棚から抜く"] --> Start["学習開始"]
    Start --> Return["教材を本棚へ戻す"]
    Return --> Save["学習終了・自動保存"]

    classDef action fill:#e8f1ff,stroke:#2563eb,color:#172554,stroke-width:2px
    classDef system fill:#ecfdf5,stroke:#059669,color:#064e3b,stroke-width:2px
    class Remove,Return action
    class Start,Save system

この考え方を形にしたのが、今回開発したSmart StudyShelfです。

1. Smart StudyShelfでできること

Smart StudyShelfは、教材を置く10個のスロットと、タッチディスプレイを備えた学習ダッシュボードです。

各スロットには、画面上の選択肢から教科を設定します。教材が抜かれたことを検出すると、そのスロットの学習セッションを開始します。教材が戻ると時間を確定し、microSDへ履歴を保存します。

主な機能は次のとおりです。

flowchart LR
    Book["教材の抜き差し"] --> Switch["マイクロスイッチ"]
    Switch --> GPIO["PCAL9555A"]
    GPIO --> Core["ESP32-P4 / 学習セッション管理"]
    Core --> SD["microSD / 学習履歴"]
    Core --> UI["Tab5 / 学習ダッシュボード"]
    Notion["Notion / 予定・学習実績"] --> WiFi["Wi-Fi・HTTPS"]
    WiFi --> Core
    Core --> WiFi
    Notion --> Parent["保護者のスマートフォン・PC"]

保護者から見ると、記録のために子どもへ新しい習慣を強制しないことが最大の利点です。IoT開発者から見ると、スイッチ入力、状態管理、ローカル永続化、ネットワーク同期、タッチGUIを一つにまとめたシステムになっています。

2. 使用したデバイス

2.1. M5Stack Tab5

メイン端末には、M5Stack Tab5を採用しました。

Tab5はESP32-P4を搭載したIoT開発端末です。5インチ・1280×720のIPSタッチディスプレイ、16MB Flash、32MB PSRAM、microSDスロット、RTC、Wi-Fi用ESP32-C6などを一体化しています。

今回の用途では、次の点が特に魅力でした。

2.2. PCAL9555APW I2C GPIOエクスパンダ

10個のスイッチ入力をまとめて扱うため、NXPのPCAL9555APWを使用しました。

PCAL9555Aは16ビットのI/Oエクスパンダです。Tab5とはI2Cで接続し、各マイクロスイッチの状態を読み取ります。10入力を確保しても端子に余裕があり、配線とスロット番号の対応も管理しやすくなります。

2.3. NC接点のマイクロスイッチを10個

各スロットには、NC(Normally Closed)接点を持つマイクロスイッチを配置しました。

今回の機構では、本が格納されるとスイッチが押されてNC接点が開き、入力はHIGHになります。本を抜くと接点が閉じ、入力はLOWになります。

flowchart TB
    subgraph Wiring["配線(1スロット分)"]
        VCC["3.3V"] -->|"内部100kΩプルアップ"| Input["PCAL9555A<br/>入力 Px"]
        Input --- NC["マイクロスイッチ<br/>NC端子"]
        NC -.->|"スイッチ内部のNC-C接点"| COM["マイクロスイッチ<br/>C(COM)端子"]
        COM --- Ground["GND"]
        NO["NO端子<br/>使用しない"]
    end

    subgraph Behavior["本の状態と入力値"]
        Stored["本を格納<br/>スイッチを押す"] --> Open["NC-C接点が開く"] --> High["入力 Px = HIGH"]
        Removed["本を抜く<br/>スイッチが戻る"] --> Closed["NC-C接点が閉じる"] --> Low["入力 Px = LOW"]
    end

    Wiring ~~~ Behavior

    classDef high fill:#ecfdf5,stroke:#059669,color:#064e3b,stroke-width:2px
    classDef low fill:#eff6ff,stroke:#2563eb,color:#1e3a8a,stroke-width:2px
    classDef unused fill:#f8fafc,stroke:#94a3b8,color:#475569,stroke-dasharray:5 5
    class High high
    class Low low
    class NO unused

外付けプルアップ抵抗は使わず、PCAL9555Aの内部100kΩプルアップを使用しました。実機では全10スロットについて、格納・取り出し時の論理と配線順を一つずつ確認しています。

2.4. microSDカード

microSDには、学習イベントをJSON Lines形式で保存します。Notionから取得した本日の予定もキャッシュするため、Wi-Fiや外部サービスが一時的に利用できなくても、ローカル機能を継続できます。

2.5. 3Dプリンタで製作したケースとブックスタンド

電子回路とファームウェアだけでなく、Tab5を収めるデバイスケースと、10冊の教材を検出するブックスタンドも今回の用途に合わせて製作しました。

機構設計にはOpenSCADを採用しました。寸法、壁厚、スイッチ位置、固定穴などをCodexへ伝え、パラメトリックなOpenSCADのソースコードを生成します。生成された形状を画面上で確認し、寸法や干渉部分を修正した後、STLへ変換して3Dプリンタで出力しました。

設計時には、使用するマイクロスイッチとM5Stack Tab5の寸法図面をCodexへ渡しました。Codexが図面に記載された外形寸法、取り付け位置、穴の間隔などを読み取り、部品を固定するネジ穴や開口部をOpenSCADモデル上へ自動的に配置します。図面の数値を手作業で一つずつソースコードへ入力する必要がなく、実物に合わせたケースとブックスタンドの基本形状を効率よく作成できました。

flowchart TD
    Define["必要寸法と構造を整理"] --> Generate["CodexでOpenSCADソースを生成"]
    Generate --> Preview["OpenSCADで形状と干渉を確認"]
    Preview --> Adjust["寸法・壁厚・穴位置を調整"]
    Adjust --> STL["STLへ変換"]
    STL --> Print["3Dプリンタで試作"]
    Print --> Assemble["実物へ組み込み"]
    Assemble --> Decision{"修正が必要か"}
    Decision -->|はい| Adjust
    Decision -->|いいえ| Complete["ケース・ブックスタンド完成"]

OpenSCADは形状をコードとして管理できるため、「スロット間隔を変更する」「ケースの壁を厚くする」「固定穴を数mm移動する」といった修正を数値で反映できます。Codexとも相性が良く、会話で設計条件を伝えながら、再生成可能なソースとして残せる点が便利でした。

一度で完成形を出すのではなく、印刷した部品へ実際にTab5やマイクロスイッチを取り付け、配線スペース、教材の抜き差し、スイッチの押し込み量を確認しながら形状を調整しています。

この工程によって、AIの活用範囲はファームウェア開発だけでなく、ケースとブックスタンドの機構設計まで広がりました。

3. 開発環境

開発環境は次の構成です。

項目 採用したもの
ホストOS Windows
メインSoC ESP32-P4
SDK ESP-IDF v5.4.2
言語 C / C++
GUI LVGL 9
GUI設計 LVGL XML / LVGL Pro Editor
仕様管理 spec-workflow-mcp / Markdown
機構設計 Codex / OpenSCAD / STL
製作 3Dプリンタ
ビルド CMake / Ninja / PowerShellスクリプト
デスクトップ確認 SDL2ベースのViewer
ホストテスト clang++、フェイクLVGL、ローカルfixture
外部サービス Notion API、NTP
実機検証 シリアルログとHILチェックポイント

ESP-IDFプロジェクトとしては、M5Stack公式のTab5 UserDemoを開発ベースにしました。ビルド、フラッシュ、シリアルモニターはPowerShellスクリプトへまとめ、毎回同じ手順で実行できるようにしています。

.\scripts\build.ps1
.\scripts\flash.ps1 -Port COM3 -Monitor

ESP-IDFでは、機能をコンポーネント単位に分割してCMakeで組み立てられます。今回も入力、セッション、保存、ネットワーク、Notion、画面といった責務を分離しました。

4. 機能仕様

4.1. 教材を抜くと学習開始、戻すと学習終了

スイッチの生値をそのまま学習記録へ変換すると、機械接点のチャタリングで開始と終了が何度も発生します。そのため、入力が約200ms安定してから状態を確定するデバウンス処理を入れました。

確定後の基本ルールはシンプルです。

操作 処理
教材を抜く そのスロットの学習セッションを開始
教材を戻す 経過時間を確定
30秒未満で戻す 学習履歴には保存しない
複数冊を抜く スロットごとに独立して時間を計測
起動時から抜かれている 一度戻るまで開始イベントとして扱わない

最後の「起動時から抜かれている教材を無視する」は、小さく見えて重要です。電源投入前から本が机に出ていた場合、起動時刻を学習開始時刻にしてしまうと、実際とは異なる履歴が作られるためです。

4.2. ホーム画面で、予定と実績を一つにする

ホーム画面では、子どもが次の3点を一目で確認できることを重視しました。

Notionから取得した今日の学習予定、教科別の進捗、現在進行中のセッション、Wi-Fi・同期・保護者モードの状態を1280×720の画面へまとめています。

予定は画面上で完了にできます。完了情報は自動的にNotionのデータベースへ反映されます。日付が変わると翌日の予定を表示し、前日までに完了したタスクは画面上で非表示になります。

4.3. Notionは「今日の予定」と学習実績をつなぐ

Notionは、メモ、文書、タスク、予定表、データベースなどを一か所で管理できるクラウド型の情報管理ツールです。Webブラウザやスマートフォン、PCのアプリから同じ情報を確認・編集できるため、個人の予定管理からチームの情報共有まで幅広く利用されています。

Notionのデータベースでは、一つひとつの予定をカードとして登録し、日付、教科、完了状態などの情報を持たせることができます。同じデータを表やカレンダーなど、目的に応じた表示へ切り替えられる点も特徴です。また、APIを利用すると外部のアプリやデバイスから予定を取得したり、完了状態を書き戻したりできます。Smart StudyShelfでは、このデータベースとAPIを学習予定の管理に活用しています。

家庭内では、教材や予定をすでにNotionで管理していました。そこで、新しい管理画面を別に作るのではなく、既存のNotionデータをSmart StudyShelfへ取り込む方針にしました。

Notionで学習予定を立てるときは、データベースのカレンダービューを利用します。予定を日付ごとに見渡せるだけでなく、予定のカードを別の日へ移動することで、実施予定日を簡単に変更できます。

学校行事、塾の宿題、模試の結果などに合わせて計画を組み直す場合も、日付プロパティを一件ずつ編集する必要がありません。保護者はスマートフォンやPCのNotion上で予定を調整し、Smart StudyShelfを同期すると、変更後の日付に応じた「今日の学習予定」が端末へ反映されます。

flowchart LR
    Calendar["Notionのカレンダービューを開く"] --> Move["学習予定を別の日へ移動"]
    Move --> Update["予定日が更新される"]
    Update --> Sync["Smart StudyShelfで同期"]
    Sync --> Display["変更後の『今日の学習予定』を表示"]

端末は、共有された6つのデータベースを順番に照会します。各データベースについて、内容・教科・日付のプロパティと型を確認し、本日分だけを共通形式へ変換します。

取得後は次の処理を行います。

flowchart TD
    Query["6データベースを順番に照会"] --> Validate["スキーマと日付を検証"]
    Validate --> Normalize["共通形式へ変換"]
    Normalize --> Deduplicate["重複を整理"]
    Deduplicate --> Sort["時刻・教科順に並べ替え"]
    Sort --> Display["ホーム画面へ表示"]
    Display --> Cache["microSDへキャッシュ"]

組み込み機器から外部APIを使うときは、通信成功だけを前提にできません。そこで、次のルールを入れています。

この設計で大切にしたのは、Notionを便利な補助機能にとどめ、学習時間の記録そのものはNotionやWi-Fiへ依存させないことです。

4.4. 集計した学習時間をNotionへ保存する

教材の抜き差しから得た学習時間は端末内だけで完結させず、集計結果をNotionのデータベースへ保存します。

これにより、保護者はSmart StudyShelfの前まで行かなくても、スマートフォンやPCからNotionを開き、学習状況を確認できます。自宅の外にいるときでも、普段利用しているNotionの画面から学習の積み重ねを把握できます。

flowchart LR
    Return["教材を棚へ戻す"] --> Finish["学習セッションを確定"]
    Finish --> Local["microSDへ学習履歴を保存"]
    Local --> Aggregate["教科別・期間別に学習時間を集計"]
    Aggregate --> Notion["Notionデータベースへ保存"]
    Notion --> Parent["保護者がスマートフォンやPCから確認"]

端末側の画面は、その場で子どもが予定と実績を確認するためのものです。Notion側は、保護者が場所や端末を問わず確認し、長期的な学習状況を振り返るための画面として役割を分けています。

Wi-FiやNotionが一時的に利用できない場合でも、学習時間は先にmicroSDへ保存します。通信障害によって学習記録そのものが失われないよう、ローカル保存を基本とし、ネットワーク連携をその上に重ねる構成です。

4.5. 保護者モード

保護者が教材の進み具合を確認するために本を抜き差しすると、それが学習時間として記録されてしまいます。これを防ぐため、保護者モードを設けました。

ただし、モードを解除し忘れると、今度は子どもの学習が記録されません。そこで、単純なON/OFFではなく状態遷移として設計しました。

stateDiagram-v2
    [*] --> OFF
    OFF --> ACTIVE_IDLE: 保護者モードON
    ACTIVE_IDLE --> ACTIVE_HANDLING: 教材を抜く
    ACTIVE_HANDLING --> AUTO_RELEASE_WAIT: 全教材を戻す
    AUTO_RELEASE_WAIT --> OFF: 15分間操作なし
    AUTO_RELEASE_WAIT --> ACTIVE_HANDLING: 再び教材を抜く
    ACTIVE_IDLE --> OFF: 手動解除
    ACTIVE_HANDLING --> OFF: 手動解除

全教材が戻ってから15分間操作がなければ、自動的に通常モードへ戻ります。待機中に教材が抜かれた場合は、「学習を始めますか?」と確認し、子どもの学習開始を取りこぼさないようにしました。

保護者モード中に教材が5分以上戻らない場合も、学習を始めた可能性を警告します。自動的に学習へ切り替えず、人に確認を求めるのは、保護者による長めの教材確認を誤記録しないためです。

4.6. 学習履歴と統計

30秒以上のセッションはmicroSDへJSON Lines形式で追記し、端末上の履歴と統計へ反映します。

履歴画面では期間を切り替え、スロット番号、教科、開始時刻、学習時間を確認できます。統計画面では今日・今週の合計や教科別の内訳を表示します。

ネットワーク時刻がまだ確定していない場合でも、学習そのものを失わないよう、時刻未確定の記録として保存できるようにしました。NTP同期後はRTCへ反映し、ネットワークが切れている間はRTCを時刻の基準として使います。

4.7. 画面スリープ

学習机の横で長時間使う機器なので、画面の常時点灯は避けました。

単なるタイマーではなく、教材の状態と画面操作を組み合わせて判断しています。

5. データをどこへ保存するか

Smart StudyShelfが扱うデータは、性質がそれぞれ異なります。学習履歴のように増え続けるデータ、スロットの教科設定のように小さくても失いたくない設定、Wi-FiパスワードやNotionトークンのような秘密情報です。これらを一つの保存先へまとめず、性質に合わせて使い分けました。

データ 保存先 理由
学習履歴・Notionキャッシュ microSD(JSON Lines) 増え続けるデータを追記できる。PCへ挿して直接確認できる
スロットの教科設定・保護者モード状態 内蔵フラッシュのNVS microSDが抜かれていても端末設定を失わない
Wi-Fiプロファイル・Notionトークン 内蔵フラッシュの暗号化NVS 取り外せるmicroSDへ秘密情報を置かない

NVS(Non-Volatile Storage)は、ESP-IDFが提供するキー・バリュー型の保存領域です。内蔵フラッシュ上のパーティションへ、名前空間とキーを付けて小さなデータを保存できます。設定のような「小さくて上書き型」のデータはNVS、履歴のような「大きくて追記型」のデータはmicroSDという分担です。

5.1. 設定はバージョン付きレコードとして保存する

スロットの教科設定は、構造体をそのままNVSへ書くのではなく、マジックナンバー、バージョン、サイズを先頭へ付けたレコードとして保存しています。

読み出し時にはこの3つを検証し、一致しない場合は破損した値を使わず「設定なし」として初期値から始めます。将来レコードの構造が変わっても、バージョンを見て移行処理を選べます。フラッシュ上のバイナリデータは、ファームウェア更新をまたいで残り続けるため、書いた時点で将来の読み手を想定しておく必要があります。

5.2. 学習履歴は追記型のJSON Linesにする

学習履歴は、月ごとのファイルへ1セッション1行のJSONとして追記します。追記だけで完結するため、書き込み中に電源が切れても影響は最後の1行に限られ、仮に1行が壊れても他の行の読み出しには影響しません。子どもが使う機器では、シャットダウン操作を期待できないことを前提にしています。

保護者モードのような「再起動をまたいで維持したい状態」はNVS側へ保存しています。教材確認の途中で端末が再起動しても、保護者モードが勝手に解除されて学習時間が誤記録されることはありません。

6. 開発手法

6.1. 仕様を先に文章化する(SDD:仕様駆動開発)

このプロジェクトでは、いきなりコードを書かず、機能ごとに仕様と受け入れ条件をMarkdownで定義しました。

今回の開発では、機能ごとに合計13件の仕様書を作成し、それらを実装可能な単位へ分解した171件のタスクを生成しました。各タスクには実装内容と完了条件を記載し、仕様から実装、テスト、実機確認までを段階的に進めました。

仕様駆動開発の基盤には、spec-workflow-mcpを利用しました。プロダクト全体の前提をsteeringとして整理し、機能ごとにrequirements、design、tasksを順番に作成します。

flowchart TD
    Steering["steering"] --> Product["product.md"]
    Steering --> Tech["tech.md"]
    Steering --> Structure["structure.md"]

    Product --> Requirements["requirements.md"]
    Tech --> Requirements
    Structure --> Requirements
    Requirements --> Design["design.md"]
    Design --> Tasks["tasks.md"]
    Tasks --> Approval{"tasksを承認"}
    Approval --> Implementation["実装・テスト・実機確認"]

requirements.mdでは「何を満たすべきか」、design.mdでは「どのような構造で実現するか」、tasks.mdでは「どの順番で実装し、何をもって完了とするか」を定義しました。

AIエージェントとの会話だけに仕様を残すと、スレッドが変わったときに前提が抜けたり、実装中の判断が仕様へ反映されなかったりします。spec-workflow-mcpを使って仕様をMarkdownファイルとしてリポジトリ内へ残すことで、設計、実装、レビューが同じ正本を参照できるようになりました。

この仕組みにより、開発途中でCodexからClaude Codeへ、またはClaude CodeからCodexへ切り替えても、requirements、design、tasksを読み込むだけで、設計上の判断、完了した作業、次に行うタスクを共有できました。特定のAIエージェントの会話履歴だけに開発過程を依存させていないため、複数のAIコーディングエージェントを切り替えながらでも、引き継ぎに支障なく開発を継続できました。

たとえばスイッチ入力であれば、「10個読める」だけでは完了にしません。

というように、異常系や境界条件まで完了条件に含めました。

6.2. AIエージェントを使い、仕様・実装・検証を往復する

設計整理、タスク分割、実装、テスト追加、ログ解析にはAIコーディングエージェントを活用しました。

基本的な流れは、spec-workflow-mcpでrequirements、design、tasksを固め、承認したタスクをCodexまたはClaude Codeへ渡して実装し、その結果をテストと実機観測で検証する形です。実機で新しい問題が見つかった場合は、その場限りの修正にせず、仕様、テスト、受け入れ条件へ戻しました。

ただし、AIへ「全部作って」と依頼する方法では、組み込み開発の品質を保てません。プロジェクト内に共通ルールを置き、変更範囲、生成物の扱い、テスト、実機確認の条件を固定しました。

flowchart TD
    Spec["要求・受け入れ条件を文章化"] --> Split["機能を小さなコンポーネントへ分割"]
    Split --> Host["ホストでロジックをテスト"]
    Host --> Desktop["デスクトップでGUIをビルド"]
    Desktop --> Firmware["ESP32-P4向けファームウェアをビルド"]
    Firmware --> HIL["実機でHIL確認"]
    HIL --> Result{"問題が見つかったか"}
    Result -->|はい| Spec
    Result -->|いいえ| Complete["受け入れ完了"]

AIが得意なのは、仕様に沿った実装候補の作成や、大量のログとコードの対応付けです。一方、スイッチを実際に押した結果、画面上の読みにくさ、長時間運転時の挙動は、人間が実機で観察しなければ分かりません。

そのため、AIによる自動化と人による物理確認を役割分担しました。

6.3. ドメインとランタイムを分離する

主要機能は、純粋なロジック、ESP-IDF上のランタイム接続、テストを分けています。

flowchart TD
    Domain["components/study_history<br/>学習履歴の解析・集計"]
    Runtime["components/study_history_runtime<br/>microSDや画面との接続"]
    Tests["tests/study_history<br/>ホストテスト"]

    Runtime --> Domain
    Tests --> Domain

同じ構成を、学習セッション、スロット設定、Notion同期、ネットワーク、保護者モード、画面電源管理などにも適用しました。

この分離により、ハードウェアが接続されていなくても、日付計算、状態遷移、重複排除、キャッシュ、エラー分類といった大部分をWindows上で高速にテストできます。

6.4. UIの正本をLVGL XMLにする

GUIはLVGL 9とLVGL Pro Editorで作成しました。

画面の正本はXMLです。Editorから生成したC/Hファイルを直接編集すると、次回のエクスポートで変更が消えるため、生成コードは手で修正しないルールにしています。

flowchart TD
    Spec["画面仕様"] --> XML["LVGL XMLを編集"]
    XML --> Preview["LVGL Pro Editorでプレビュー"]
    Preview --> Export["Cコードをエクスポート"]
    Export --> Import["生成物をファームウェアへ取り込み"]
    Import --> Verify["契約テスト・デスクトップ・実機で確認"]
    Verify --> Result{"修正が必要か"}
    Result -->|はい| XML
    Result -->|いいえ| Complete["UI変更完了"]

「生成コードを手で修正しない」というルールは、人の注意力だけに頼らず、スクリプトで機械的に強制しています。Editorのエクスポートはそのままコピーせず、インポートスクリプトが内容を検証してから取り込み、ソースと生成物のSHA-256ハッシュをマニフェストへ記録します。チェックモードを実行すれば、リポジトリ内の生成コードがEditorのエクスポートとバイト一致しているかをいつでも確認できます。

この仕組みは、AIコーディングエージェントとの相性という点でも重要でした。AIは目の前のビルドエラーを解消するために、生成ファイルへ直接パッチを当てようとすることがあります。規約を文章で伝えるだけでなく機械的に検出できる形にしておくことで、AIの変更が正しい経路を通っているかを常に確認できます。

日本語表示には、モリサワが公開しているBIZ UDGothicを使用しました。子どもが毎日見る画面なので、読みやすさに配慮したユニバーサルデザインフォントを選んでいます。SIL Open Font License 1.1で提供されているため、ライセンス文を同梱すれば、TTFデータをファームウェアへ埋め込んで配布できます。

6.5. 画面構造の破壊を契約テストで検出する

LVGL Editorが生成するコードには、個々のウィジェットを名前で参照するハンドルがありません。そのためランタイムは、各ウィジェットを「親から数えた子番号の経路」で特定します。たとえばホーム画面の現在の学習時間ラベルは、画面ルートから {1, 1, 1, 2} とたどった位置にあります。

この方式は生成コードに手を入れずに済む反面、XMLでウィジェットを一つ追加・削除・並べ替えるだけで、後続の兄弟の経路がすべてずれ、配線が静かに壊れます。コンパイルは通ってしまうため、そのままでは実機で画面を開くまで気づけません。

そこで、次の多層の防御を置きました。

これにより、必須ウィジェットが消えた、順番が変わった、イベント配線が外れたといった問題を、実機へ書き込む前に検出できます。

6.6. UI変更の手順書をAIと共有する

UI変更は影響範囲の見極めが難しく、AIへ「ボタンを追加して」と依頼するだけでは、バインディングや契約テストの連動修正が漏れます。そこで、UI変更の標準手順をMarkdownの手順書としてリポジトリへ置き、変更を3タイプへ分類してから作業する運用にしました。

タイプ 変更内容 必要な作業
A: 挙動のみ 遷移先の変更、実行時の表示切り替え C++のみ。XML・エクスポート不要
B: 文言・スタイル ラベル文言、フォント、色 XML編集+エクスポート。構造が変わらないためバインディングは無傷
C: 構造変更 ウィジェットの追加・削除・並べ替え、新規画面 XML+エクスポート+バインディング・契約・フェイクツリーの連動修正

Claude Codeではこの手順書をスキルとして登録し、UI変更のタスクで自動的に読み込まれるようにしました。仕様をspec-workflow-mcpでリポジトリへ残したのと同じ考え方で、作業手順も会話ではなくリポジトリ内の文書を正本にしています。人間とAIが同じ手順書に従うため、どちらが作業しても検証の抜けが起きにくくなりました。

6.7. 自動テストとHILを分ける

テストは大きく3段階に分けました。

段階 確認すること
ホストテスト 状態遷移、集計、保存形式、エラー処理、UIバインディング
ビルド・デスクトップ 生成コードとの整合、リンク、日本語フォント、画面構造
実機HIL スイッチ、LCD、タッチ、microSD、Wi-Fi、RTC、Notion、長時間運転

ホストテストは、実機やLVGL本体がなくても動くことを重視しました。UIバインディングのテストでは、テストスクリプトがフェイクのLVGLヘッダーを生成し、実画面の子構造を再現したフェイクツリーに対して、バインディング解決とモデル適用をWindows上で検証します。ハードウェアを机へ並べなくても、画面構造の変更ミスを数秒で検出できます。

実機受け入れでは、PCAL9555A、10スロット、画面、保存、ネットワーク・時刻、Notion、長時間運転を29個のチェックポイントに分けました。自動テストが通っただけで実機PASSにせず、実際に観測した項目だけをPASSにする運用です。

7. 実機テストで分かったこと

7.1. FatFsの8.3ファイル名制約でJSONLを書けなかった

学習履歴は月ごとの YYYY-MM.jsonl に保存する設計でした。しかし実機では、ファイルを開く処理が EINVAL で失敗しました。

原因はESP-IDFのFatFs設定です。Long File Nameが無効で8.3形式しか使えない状態では、5文字の拡張子 .jsonl を持つファイル名を作れません。

最初のストレージ自己診断では STORTEST.TMP を使っていたため、8.3形式に収まり、問題を見逃していました。

対策として、次を行いました。

この経験から、自己診断は「ストレージへ何か書けるか」ではなく、本番と同じパス・名前・データ形式で書けるかを確認しなければ意味がないと分かりました。

組み込みの設定は実装の一部

PC上で正しく動くファイル名やAPIでも、FatFsのKconfig、メモリ配置、タスクスタックなどの設定によって実機だけ失敗することがあります。ソースコードだけでなく、sdkconfigと実機の観測結果を同じ重要度で管理する必要があります。

7.2. ESP32-C6の起動を待たずにSDIOを開けなかった

Tab5のWi-Fiは、メインのESP32-P4ではなく、ESP-Hostedのスレーブファームウェアが動作するESP32-C6が担当します。P4からはSDIO経由でC6を制御し、アプリケーションからは通常のWi-Fi APIとほぼ同じ形で利用できます。

実機では、C6の電源を入れた直後にP4がSDIOホストの初期化を始めると、タイムアウトエラー(0x107)で失敗することがありました。C6側のファームウェアが起動してSDIOスレーブとして応答できるようになる前に、ホスト側が通信を開始していたためです。

現在は、C6の電源投入後に1秒待ってからSDIOホストを開始する順序制御を入れています。デュアルチップ構成では、ソフトウェアの初期化順序だけでなく、もう一方のチップの起動時間まで含めて設計する必要があります。

7.3. GUIの画面遷移でスタック保護エラーが発生した

学習履歴画面を開いたとき、LVGLタスクでスタック保護エラーが発生したこともありました。

原因は、複数の固定長プレゼンテーションオブジェクトを画面タスクのスタック上へ同時に確保していたことでした。PCでは問題が見えなくても、組み込みタスクには厳しいスタック上限があります。

対象データをスタック外へ移し、ホストテスト、ファームウェアビルド、実機の画面遷移をやり直しました。

この問題も、デスクトップシミュレータだけでは完了できない理由の一つです。

7.4. 「保存できた」というイベントは、保存成功後に出す

初期実装では、学習終了後にストレージ書き込みが失敗しても、UI側へ記録済みイベントが届く経路がありました。利用者には保存できたように見えてしまいます。

現在は、JSONLへの追記と読み戻しが成功した後だけ STUDY_RECORDED を発行します。失敗時はストレージエラーを通知し、誤った成功表示を出しません。

IoT機器では、センサーで値を得たこと、メモリ上で処理したこと、永続化したこと、外部サービスへ同期したことは、それぞれ別の成功です。イベント名と画面表示も、その境界に合わせる必要があります。

8. セキュリティと障害時の考え方

8.1. 秘密情報は暗号化NVSで管理する

Wi-FiパスワードやNotionトークンは、ソースコードや通常ログへ書かず、暗号化NVSへ保存します。

ESP-IDFのNVS暗号化は、データ本体を保存するnvsパーティションと、暗号鍵を保持するnvs_keysパーティションを分けて管理します。ファームウェア側の資格情報ストアはfail-closed設計にしており、鍵パーティションが見つからない場合は、暗号化なしのまま初期化を続行せず、エラーとして停止します。

秘密情報の書き込みには、専用のプロビジョニングスクリプトを用意しました。

flowchart TD
    Input["秘密値を画面入力で受け取る"] --> CSV["一時ディレクトリへCSVを生成"]
    CSV --> Gen["nvs_partition_gen.pyで<br/>暗号化NVSイメージと鍵を生成"]
    Gen --> Flash["esptoolでnvs_keys・nvs<br/>パーティションへ書き込み"]
    Flash --> Clean["一時ファイルを削除"]

スクリプトは秘密値をコマンド引数で受け取らず、画面入力で読み取ります。コマンド履歴やプロセス一覧へ平文を残さないためです。暗号化イメージの生成にはESP-IDF付属のnvs_partition_gen.pyを使い、書き込み後に一時ファイルを削除します。

端末の設定画面で値を確認するときも、トークンは一部だけのマスク表示にし、読み出しへ使ったメモリ上のバッファは使用後にゼロクリアしています。Notion APIとの通信は、ESP-IDFの証明書バンドルを使ったHTTPSです。

8.2. 障害時もローカル機能を止めない

障害時もローカル機能を止めないことを基本方針にしています。

障害 動作
Wi-Fi切断 教材検出と学習記録を継続
Notion取得失敗 当日分のキャッシュを表示
学習実績のNotion保存失敗 microSDの学習履歴を保持し、ローカル機能を継続
一部DBだけ失敗 成功したDBの予定を表示
NTP未同期 RTCまたは時刻未確定状態で継続
microSD不在 UIを止めず、保存できないことを案内

クラウド連携機器であっても、家庭内の基本動作までクラウドの状態へ巻き込まないことが大切です。

9. 受験生の家庭で使う道具として考えたこと

このデバイスの目的は、子どもを監視することではありません。

学習時間は、長ければ必ず良いわけではありません。理解度や集中度までスイッチで測ることもできません。それでも、普段どの教科に時間を使っているか、予定に対して実行できたかを、親子で振り返る材料にはなります。

入力操作を減らしたのも、数字を細かく集めるためではなく、子どもが勉強へ入りやすくするためです。

IoTは、これまで見えなかった情報を集める技術です。一方で、利用者の負担を増やしてしまえば、家庭では使われなくなります。今回の開発では、精密な計測より先に、生活の流れを邪魔しないことを優先しました。

10. まとめ

Smart StudyShelfは、本を抜いて戻すという自然な動作を、学習記録へ変換するIoTデバイスです。

開発を通じて特に重要だと感じたのは、次の点です。

M5Stack Tab5は、高解像度タッチ画面、microSD、RTC、Wi-Fiを一台にまとめられるため、今回のような家庭向けIoT端末の試作に適したプラットフォームでした。

単なるタイマーではなく、子どもが今日の予定を理解し、保護者が無理なく見守り、学習の積み重ねを親子で確認できる道具へ育てていきたいと思います。

11. FAQ

本を少し確認しただけでも学習時間になりますか?
30秒未満で戻した操作は学習履歴へ保存しません。また、保護者が教材を確認するときは保護者モードを利用できます。
Wi-Fiが切れると記録できませんか?
教材の検出と学習履歴の保存はローカルで行うため、Wi-Fiがなくても継続できます。Wi-FiはNotion予定の取得、集計した学習時間のNotion保存、NTP時刻同期に使用します。
保護者は外出先から学習状況を確認できますか?
はい。集計した学習時間をNotionデータベースへ保存するため、保護者はスマートフォンやPCからNotionを開いて確認できます。
複数の教材を同時に使えますか?
はい。各スロットは独立したセッションとして管理されるため、複数冊を抜いた場合もそれぞれの時間を記録します。
学習内容が保護者に監視されすぎませんか?
この仕組みで分かるのは、各スロットの教材が棚から出ていた時間です。理解度や集中度を断定するものではなく、親子で学習を振り返るための目安として使います。
なぜスマートフォンアプリではなく専用端末にしたのですか?
勉強の開始時にスマートフォンを開くと、通知や別アプリへ注意がそれやすくなります。学習机の横に常設し、本の操作だけで記録できる専用端末を選びました。