4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

こそだて×生成AI (byこそぷろ👶) Advent Calendar 2024 12日目です!

子育て未経験ですが、もしかしたら子育てに貢献できるかも?と思ったので記事を書かせていただきます!

ChatGPTを利用しますが、従量課金が必要になりますのでご注意ください。

国旗を覚えよう

私は "国旗" が好きです。
それも幼いころから好きです。

私の "国旗好き" が教育にどのように影響したかを考えてみました。

  1. "観察力" の向上
    国旗は色の組み合わせやデザインの配置において細かい特徴を持っています。幼少期に国旗を学ぶことで、細部に注意を払う習慣が身についたと思います。色の違いや図形の配置を識別する練習を通じて、観察力が自然と鍛えられます。

  2. "記憶力" の向上
    国旗を覚えるためには、視覚的な情報を記憶する必要があります。このプロセスを通じて、視覚記憶力を強化することができたと思います。視覚情報を効果的に記憶する能力は、あらゆる学習で役立ちます。

  3. "想像力" の向上
    国旗にはそれぞれ独自の歴史や物語が込められています。幼少期に国旗を学ぶことで、それらの背景にある物語を想像し、創造的なストーリーテリング能力を養うことができたと思います。
    また、国旗のデザインや色の意味を探求する過程で、豊かな想像力が育まれるので、問題解決能力や独創的な思考が育まれます。

子どもは触ることが好き

以前、国旗を勉強できるLINE Botを開発しました。

LINEのリッチメニューを使うことで文字を入力しなくても国旗の画像と国旗の雑学を出力できるようにしています。

国旗の雑学を生成AI(ChatGPT)で生成して出力しています。

ただ、子どもは画面をタップするよりも実際に物理ボタンを押す方が好きなのではないかと考えました。

というのも、スーパーマーケットに行くと全商品に触れていく子どもってよく見かけませんか?
(躾どうこうの話は一旦置いておいて)

なので、実際に押した感覚(触覚)がある方が興味を惹かせることができるのではないかと考えました。

使うもの

  • AtomS3:ディスプレイとボタンが一体になったデバイス

  • Atom TailBAT:Atom用の外付けバッテリー(必要であれば)

  • Node-RED:どの環境でもいいのですが、今回はenebularを使おうと思います。

Node-RED(enebular)の環境構築

すでにNode-REDを利用できる環境が整っている方は読まなくてOKです。enebularでなくても問題ありません。

①. enebularを開きます。
アカウント登録していない方はアカウント登録が必要です。

②. 右上のプロジェクトの作成から任意の名前でプロジェクト名をつけて作成してください。
デフォルトのプライベートノードを利用するはチェックをいれたままにしてください。

③. 作成したプロジェクトを開いてフローというタブを選択します。

④. 右下の+マークからフローを作成します。

⑤. フローが生成されます。

⑥. 左側のメニューからクラウド実行環境を選択
右下の+マークボタンからクラウド実行環境を作成します。

⑦. クラウド実行環境ができます。
右側のデプロイを選択して、先ほど作成したフローを選択してデプロイします。

⑧. デプロイ履歴が残れば完了です。

⑨. 次に設定タブに移って右側の設定を編集するを選択します。

⑩. HTTPトリガーをONにして、任意の文字をパスをに入力します。

⑪. HTTPトリガーのURLが発行されるので記録しておきます。
以上で準備作業は完了です。

上限はありますが、これでenebularでNode-REDを常時起動していなくてもクラウド実行環境でノードを実行できる準備ができました。

Oracle Cloud上にインストールするやり方もあります。
私は普段こちらを使っています。

ノードの設定

①. まず、Node-REDを開いて以下のJSONファイルをインポートします。
インポートは右上のハンバーガーメニュー読み込みJSON形式のフローデータを貼り付けに以下のJSONファイルをそのまま貼り付けて読み込みをしてください。

インポート用のJSONファイル
flows.json
[
    {
        "id": "741ec682142afdce",
        "type": "tab",
        "label": "AtomS3",
        "disabled": false,
        "info": "",
        "env": []
    },
    {
        "id": "49627bed5f6b38e3",
        "type": "tab",
        "label": "AtomS3",
        "disabled": false,
        "info": "",
        "env": []
    },
    {
        "id": "ca6bacc047d6562b",
        "type": "gauth",
        "name": "vanchan2625@helical-bonsai-418503.iam.gserviceaccount.com"
    },
    {
        "id": "be7b8e90599fbfa5",
        "type": "http in",
        "z": "741ec682142afdce",
        "name": "",
        "url": "/atom",
        "method": "get",
        "upload": false,
        "swaggerDoc": "",
        "x": 260,
        "y": 100,
        "wires": [
            [
                "0851e81fee8f713b"
            ]
        ]
    },
    {
        "id": "041837d90cf4649d",
        "type": "inject",
        "z": "741ec682142afdce",
        "name": "",
        "props": [
            {
                "p": "payload"
            },
            {
                "p": "topic",
                "vt": "str"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": false,
        "onceDelay": 0.1,
        "topic": "",
        "payload": "",
        "payloadType": "date",
        "x": 240,
        "y": 140,
        "wires": [
            [
                "0851e81fee8f713b"
            ]
        ]
    },
    {
        "id": "bb58a04c6783f0dc",
        "type": "http response",
        "z": "741ec682142afdce",
        "name": "",
        "statusCode": "",
        "headers": {},
        "x": 910,
        "y": 580,
        "wires": []
    },
    {
        "id": "6eb34b8347b8e206",
        "type": "template",
        "z": "741ec682142afdce",
        "name": "",
        "field": "url",
        "fieldType": "msg",
        "format": "handlebars",
        "syntax": "mustache",
        "template": "https://flagcdn.com/120x90/{{payload.value1}}.png",
        "output": "str",
        "x": 580,
        "y": 220,
        "wires": [
            [
                "c34c3d5a5612b5db"
            ]
        ]
    },
    {
        "id": "0fb6d332f202b1e4",
        "type": "debug",
        "z": "741ec682142afdce",
        "name": "debug 8",
        "active": true,
        "tosidebar": true,
        "console": false,
        "tostatus": false,
        "complete": "true",
        "targetType": "full",
        "statusVal": "",
        "statusType": "auto",
        "x": 1060,
        "y": 520,
        "wires": []
    },
    {
        "id": "411ac1ec0cd150ae",
        "type": "function",
        "z": "741ec682142afdce",
        "name": "function 10",
        "func": "const random = Math.floor( Math.random() * 220 ) + 2;\n\nmsg.payload = {\n    value1: msg.payload[random][1],\n    value2: msg.payload[random][0]\n};\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 530,
        "y": 160,
        "wires": [
            [
                "6eb34b8347b8e206"
            ]
        ]
    },
    {
        "id": "23a9bf6997bbd594",
        "type": "change",
        "z": "741ec682142afdce",
        "name": "",
        "rules": [
            {
                "t": "set",
                "p": "name",
                "pt": "msg",
                "to": "payload.value2",
                "tot": "msg"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 700,
        "y": 340,
        "wires": [
            [
                "648259248961deb3"
            ]
        ]
    },
    {
        "id": "648259248961deb3",
        "type": "change",
        "z": "741ec682142afdce",
        "name": "",
        "rules": [
            {
                "t": "set",
                "p": "payload",
                "pt": "msg",
                "to": "name",
                "tot": "msg"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 740,
        "y": 400,
        "wires": [
            [
                "9191617a54e0e970"
            ]
        ]
    },
    {
        "id": "c34c3d5a5612b5db",
        "type": "template",
        "z": "741ec682142afdce",
        "name": "",
        "field": "url2",
        "fieldType": "msg",
        "format": "handlebars",
        "syntax": "mustache",
        "template": "https://flagcdn.com/16x12/{{payload.value1}}.png",
        "output": "str",
        "x": 640,
        "y": 280,
        "wires": [
            [
                "23a9bf6997bbd594"
            ]
        ]
    },
    {
        "id": "f230e2d77f6f5015",
        "type": "function",
        "z": "741ec682142afdce",
        "name": "function 11",
        "func": "msg.payload = {\n    gpt: msg.payload,\n    url: msg.url,\n    url2: msg.url2,\n    name: msg.name\n};\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 870,
        "y": 520,
        "wires": [
            [
                "da83a076068b5311",
                "bb58a04c6783f0dc",
                "0fb6d332f202b1e4"
            ]
        ]
    },
    {
        "id": "0851e81fee8f713b",
        "type": "GSheet",
        "z": "741ec682142afdce",
        "creds": "ca6bacc047d6562b",
        "method": "get",
        "action": "",
        "sheet": "1Ben4-5-XcPDw7N9L0WoBxKCyxkPYvwYem9fXGtfXnhM",
        "cells": "シート2!B2:C220",
        "flatten": false,
        "name": "",
        "x": 470,
        "y": 100,
        "wires": [
            [
                "411ac1ec0cd150ae"
            ]
        ]
    },
    {
        "id": "9191617a54e0e970",
        "type": "simple-chatgpt",
        "z": "741ec682142afdce",
        "name": "",
        "Token": "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",
        "Model": "gpt-4o-mini",
        "SystemSetting": "幼稚園児でも分かるように20文字以内で国旗の雑学を\"ひらがな\"\"カタカナ\"で教えて",
        "functions": "",
        "functionsType": "str",
        "function_call": "auto",
        "function_callType": "str",
        "x": 800,
        "y": 460,
        "wires": [
            [
                "f230e2d77f6f5015"
            ]
        ]
    },
    {
        "id": "c8b8bf9df35218f6",
        "type": "LCDP-in",
        "z": "741ec682142afdce",
        "name": "",
        "x": 270,
        "y": 60,
        "wires": [
            [
                "0851e81fee8f713b"
            ]
        ]
    },
    {
        "id": "da83a076068b5311",
        "type": "LCDP-out",
        "z": "741ec682142afdce",
        "name": "",
        "x": 910,
        "y": 620,
        "wires": []
    },
    {
        "id": "90dae3240ef2a603",
        "type": "http in",
        "z": "49627bed5f6b38e3",
        "name": "",
        "url": "/atom",
        "method": "get",
        "upload": false,
        "swaggerDoc": "",
        "x": 260,
        "y": 100,
        "wires": [
            [
                "695292ec89fdbd80"
            ]
        ]
    },
    {
        "id": "d13de88800c0fba9",
        "type": "inject",
        "z": "49627bed5f6b38e3",
        "name": "",
        "props": [
            {
                "p": "payload"
            },
            {
                "p": "topic",
                "vt": "str"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": false,
        "onceDelay": 0.1,
        "topic": "",
        "payload": "",
        "payloadType": "date",
        "x": 240,
        "y": 140,
        "wires": [
            [
                "695292ec89fdbd80"
            ]
        ]
    },
    {
        "id": "75a7aaeb5b37b48f",
        "type": "http response",
        "z": "49627bed5f6b38e3",
        "name": "",
        "statusCode": "",
        "headers": {},
        "x": 910,
        "y": 580,
        "wires": []
    },
    {
        "id": "1e8c327940e09434",
        "type": "template",
        "z": "49627bed5f6b38e3",
        "name": "",
        "field": "url",
        "fieldType": "msg",
        "format": "handlebars",
        "syntax": "mustache",
        "template": "https://flagcdn.com/120x90/{{payload.value1}}.png",
        "output": "str",
        "x": 580,
        "y": 220,
        "wires": [
            [
                "4b68fc859b8ba814"
            ]
        ]
    },
    {
        "id": "a9e6caa473a77d18",
        "type": "debug",
        "z": "49627bed5f6b38e3",
        "name": "debug 8",
        "active": true,
        "tosidebar": true,
        "console": false,
        "tostatus": false,
        "complete": "true",
        "targetType": "full",
        "statusVal": "",
        "statusType": "auto",
        "x": 1060,
        "y": 520,
        "wires": []
    },
    {
        "id": "734169b8bb550311",
        "type": "function",
        "z": "49627bed5f6b38e3",
        "name": "function 10",
        "func": "const random = Math.floor( Math.random() * 220 ) + 2;\n\nmsg.payload = {\n    value1: msg.payload[random][1],\n    value2: msg.payload[random][0]\n};\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 530,
        "y": 160,
        "wires": [
            [
                "1e8c327940e09434"
            ]
        ]
    },
    {
        "id": "9702b896a07493a0",
        "type": "change",
        "z": "49627bed5f6b38e3",
        "name": "",
        "rules": [
            {
                "t": "set",
                "p": "name",
                "pt": "msg",
                "to": "payload.value2",
                "tot": "msg"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 700,
        "y": 340,
        "wires": [
            [
                "db73aef9cb0b40b7"
            ]
        ]
    },
    {
        "id": "db73aef9cb0b40b7",
        "type": "change",
        "z": "49627bed5f6b38e3",
        "name": "",
        "rules": [
            {
                "t": "set",
                "p": "payload",
                "pt": "msg",
                "to": "name",
                "tot": "msg"
            }
        ],
        "action": "",
        "property": "",
        "from": "",
        "to": "",
        "reg": false,
        "x": 740,
        "y": 400,
        "wires": [
            [
                "d195e2aec5adf9d2"
            ]
        ]
    },
    {
        "id": "4b68fc859b8ba814",
        "type": "template",
        "z": "49627bed5f6b38e3",
        "name": "",
        "field": "url2",
        "fieldType": "msg",
        "format": "handlebars",
        "syntax": "mustache",
        "template": "https://flagcdn.com/16x12/{{payload.value1}}.png",
        "output": "str",
        "x": 640,
        "y": 280,
        "wires": [
            [
                "9702b896a07493a0"
            ]
        ]
    },
    {
        "id": "069aa5b17297441d",
        "type": "function",
        "z": "49627bed5f6b38e3",
        "name": "function 11",
        "func": "msg.payload = {\n    gpt: msg.payload,\n    url: msg.url,\n    url2: msg.url2,\n    name: msg.name\n};\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 870,
        "y": 520,
        "wires": [
            [
                "86f9252a5e7ee01b",
                "75a7aaeb5b37b48f",
                "a9e6caa473a77d18"
            ]
        ]
    },
    {
        "id": "695292ec89fdbd80",
        "type": "GSheet",
        "z": "49627bed5f6b38e3",
        "creds": "",
        "method": "get",
        "action": "",
        "sheet": "",
        "cells": "シート1!B2:C220",
        "flatten": false,
        "name": "",
        "x": 470,
        "y": 100,
        "wires": [
            [
                "734169b8bb550311"
            ]
        ]
    },
    {
        "id": "d195e2aec5adf9d2",
        "type": "simple-chatgpt",
        "z": "49627bed5f6b38e3",
        "name": "",
        "Token": "",
        "Model": "gpt-4o-mini",
        "SystemSetting": "幼稚園児でも分かるように20文字以内で国旗の雑学を\"ひらがな\"\"カタカナ\"で教えて",
        "functions": "",
        "functionsType": "str",
        "function_call": "auto",
        "function_callType": "str",
        "x": 800,
        "y": 460,
        "wires": [
            [
                "069aa5b17297441d"
            ]
        ]
    },
    {
        "id": "74d3311ea1b94b0d",
        "type": "LCDP-in",
        "z": "49627bed5f6b38e3",
        "name": "",
        "x": 270,
        "y": 60,
        "wires": [
            [
                "695292ec89fdbd80"
            ]
        ]
    },
    {
        "id": "86f9252a5e7ee01b",
        "type": "LCDP-out",
        "z": "49627bed5f6b38e3",
        "name": "",
        "x": 910,
        "y": 620,
        "wires": []
    }
]

enebularを使っていない方はLCDP-inLCDP-outのノードは削除してください。

②. インポートをしたら右上のハンバーガーメニューパレットの管理ノードを追加のタブを選択して以下2つのノードを追加します。

  • node-red-contrib-google-sheets
  • node-red-contrib-simple-chatgpt

③. 以下の記事を参考に、コピーしたGoogleスプレッドシートとNode-REDの連携をしてください。

④. GSheet-Getのノードを編集してSpreadsheetIDにコピーしたGoogleスプレッドシートのIDを入力してください。
credsには上記③で連携したアカウント情報を選択します。

⑤. 以下の記事を参考に、OpenAIのAPIキーの取得してください。

⑥. simple-chatgptのノードを編集してTokenに取得したAPIキーをコピペしてください。

コード実装

開発環境はVS CodeのPlatformIOを利用しました。
環境構築に関しては以下の記事が参考になるかと思います。

PlatformIOの環境設定を行うといろいろなフォルダが追加されるかと思いますが、ソースコードファイルを src/ というフォルダに <任意の名前>.cpp で格納すればOKです。私はmain.cppとしました。

あとは、M5unifiedなどのライブラリの読み込みに必要な情報をplatformio.iniに追加していきます。
残りのフォルダに関してはノータッチでOKです。

main.cppはこちらから
main.cpp
#include <M5Unified.h>      // M5Stack用のライブラリを読み込む (M5スタック系デバイスを簡単に扱うため)
#include <WiFi.h>           // WiFi通信を使うためのライブラリを読み込む (ESP32内蔵WiFiを使う)
#include <HTTPClient.h>     // HTTP通信をするためのライブラリを読み込む (HTTPリクエストを送るため)
#include <ArduinoJson.h>    // JSONデータを扱うためのライブラリを読み込む (取得したデータをJSON形式で解析)
#include <WiFiManager.h>    // Wi-Fiの接続設定を簡単に行うためのライブラリ (APを立ち上げて、そこからWi-Fi設定可能)
#include <Preferences.h>    // データを保存するためのライブラリ (不揮発性メモリに設定などを保存するため)

// 定数の定義
const char* nodeRedUrl = "YOUR_WEBHOOK_URL"; // Node-REDにデータを送受信するURL
#define LARGE_IMAGE_WIDTH 120                // 大きな画像の幅を指定
#define LARGE_IMAGE_HEIGHT 90                // 大きな画像の高さを指定
#define SMALL_IMAGE_WIDTH 16                 // 小さな画像(答え用)の幅を指定
#define SMALL_IMAGE_HEIGHT 12                // 小さな画像(答え用)の高さを指定
#define IMAGE_SIZE_FACTOR 2                  // 画像サイズチェック用の倍率 (画像が大きすぎないか確認で使用)

// 画像データを保存するためのバッファとサイズ
uint8_t* gIMGBuf = nullptr; // メイン(大きな)画像データを入れるためのメモリ領域へのポインタ
size_t gIMGSize = 0;        // メイン画像のサイズを入れる変数

uint8_t* answerIMGBuf = nullptr; // 答え用の小さな画像データを入れるためのメモリ領域へのポインタ
size_t answerIMGSize = 0;        // 答え用画像のサイズを入れる変数

// 国旗に関する情報を保存する変数
String answerImageUrl; // 答え画像のURLを格納 (小さい国旗画像)
String name;           // 国旗の名前を格納
String trivia;         // 国旗に関する雑学を格納

// 状態を管理するための列挙型
enum State {
    WAITING,     // ボタン押下待ち状態 (初期状態)
    SHOW_IMAGE,  // 大きな国旗を表示する状態
    SHOW_ANSWER, // 答え(小さな国旗と名前・雑学)を表示する状態
    SHOW_ERROR   // エラーを表示する状態
};

// 現在と前回の状態を追跡する変数
State currentState = WAITING; // 現在の状態をWAITING(待ち)で初期化
State lastState = WAITING;    // 前回の状態をWAITINGで初期化

// 前回表示した内容を追跡する変数
String lastImageUrl = "";        // 前回表示した画像URLを保存 (変更確認用)
String lastAnswerImageUrl = "";  // 前回表示した答えの画像URLを保存 (変更確認用)
String lastErrorMessage = "";    // 前回表示したエラーメッセージを保存 (変更確認用)

// Wi-Fi関連のグローバル変数
Preferences preferences; // データ保存用のオブジェクト (今回は使わないが残している)

// 画像をディスプレイに表示する関数
void displayImage(const uint8_t* imageBuf, size_t imageSize) {
    // SHOW_IMAGE状態で、前回と同じ画像でない場合のみ表示更新
    if (currentState != SHOW_IMAGE || lastImageUrl == answerImageUrl) {
        Serial.println("DisplayImage: No change detected or incorrect state."); // 状態や画像に変化なし
        return;
    }

    Serial.println("Displaying large image."); // 画像表示のログ出力

    M5.Display.startWrite(); // ディスプレイへの描画開始
    M5.Display.fillScreen(WHITE); // 背景を白でクリア

    int16_t w = LARGE_IMAGE_WIDTH;  // 大きな画像の横幅
    int16_t h = LARGE_IMAGE_HEIGHT; // 大きな画像の高さ

    // 画像を中央に表示するためのX,Y座標計算
    int16_t x = (M5.Display.width() - w) / 2;
    int16_t y = (M5.Display.height() - h) / 2;

    M5.Display.drawPng(imageBuf, imageSize, x, y); // PNG画像を指定位置に描画
    M5.Display.endWrite(); // 描画終了

    lastImageUrl = answerImageUrl; // 前回表示した画像URLを更新
    lastState = SHOW_IMAGE;         // 前回の状態をSHOW_IMAGEに更新
}

// 答えをディスプレイに表示する関数
void displayAnswer() {
    // SHOW_ANSWER状態で、前回と同じ答え画像でない場合のみ表示更新
    if (currentState != SHOW_ANSWER || lastAnswerImageUrl == answerImageUrl) {
        Serial.println("DisplayAnswer: No change detected or incorrect state."); // 状態や画像に変化なし
        return;
    }

    Serial.println("Displaying answer."); // 答え表示のログ出力

    M5.Display.startWrite(); // 描画開始
    M5.Display.fillScreen(WHITE); // 背景を白でクリア

    int16_t w = SMALL_IMAGE_WIDTH;  // 小さな国旗画像の横幅
    int16_t h = SMALL_IMAGE_HEIGHT; // 小さな国旗画像の高さ

    int16_t x = (M5.Display.width() - w) / 2; // 水平方向中央
    int16_t y = 10;                           // 画面上端から10ピクセル下へ描画

    M5.Display.drawPng(answerIMGBuf, answerIMGSize, x, y); // 小さな国旗画像を描画

    M5.Display.setTextColor(BLACK); // テキスト色を黒に設定
    int16_t textY = y + h + 5;      // 国旗名テキストの表示Y座標 (画像の下)

    M5.Display.setFont(&fonts::lgfxJapanGothic_16); // フォントを16px相当の日本語対応フォントに設定

    int textWidth = M5.Display.textWidth(name); // 国旗名テキストの横幅を取得
    bool isTwoLine = false; // 複数行になるかどうかのフラグ

    // テキストが画面幅を超える場合は小さいフォントに変更
    if (textWidth > M5.Display.width()) {
        M5.Display.setFont(&fonts::lgfxJapanGothic_12);
        textWidth = M5.Display.textWidth(name);

        // それでも幅オーバーなら2行に分ける
        if (textWidth > M5.Display.width()) {
            isTwoLine = true;
        }
    }

    // テキスト表示処理
    if (!isTwoLine) {
        // 一行に収まる場合
        if (textWidth <= M5.Display.width()) {
            int textx = (M5.Display.width() - textWidth) / 2; // 中央揃え
            M5.Display.setCursor(textx, textY);
        } else {
            M5.Display.setCursor(0, textY); // フォント変更しても収まらない場合は左揃え
        }

        M5.Display.println(name); // 国旗名を出力
    } else {
        // 2行表示の場合
        int splitIndex = name.indexOf(' ', 0); // スペースで分割位置を探す
        if (splitIndex == -1) {
            // スペースがない場合は文字列半分で分割
            splitIndex = name.length() / 2;
        }

        // 前半と後半を分割して取得
        String firstLine = name.substring(0, splitIndex);
        String secondLine = name.substring(splitIndex);

        // 一行目を中央揃え
        int firstLineWidth = M5.Display.textWidth(firstLine);
        int firstLineX = (M5.Display.width() - firstLineWidth) / 2;
        M5.Display.setCursor(firstLineX, textY);
        M5.Display.println(firstLine);

        // 二行目を中央揃え
        int secondLineWidth = M5.Display.textWidth(secondLine);
        int secondLineX = (M5.Display.width() - secondLineWidth) / 2;
        M5.Display.setCursor(secondLineX, textY + 10);
        M5.Display.println(secondLine);
    }

    // 雑学表示
    M5.Display.setCursor(10, isTwoLine ? textY + 35 : textY + 20);
    M5.Display.println(trivia); // 国旗の雑学を表示

    M5.Display.endWrite(); // 描画終了

    lastAnswerImageUrl = answerImageUrl; // 前回表示した答え画像URL更新
    lastState = SHOW_ANSWER;             // 前回状態更新
}

// エラーメッセージを表示するためのヘルパー関数
void displayError(const String& message) {
    // 同じエラーメッセージか、状態がSHOW_ERRORでない場合は何もしない
    if (currentState != SHOW_ERROR || lastErrorMessage == message) {
        Serial.println("DisplayError: No change detected or incorrect state.");
        return;
    }

    Serial.println("Displaying error: " + message); // エラー表示ログ

    M5.Display.startWrite(); // 描画開始
    M5.Display.fillScreen(WHITE); // 背景白
    M5.Display.setCursor(0, 0);   // 左上から表示
    M5.Display.setTextColor(RED); // テキスト色を赤
    M5.Display.println(message);  // エラーメッセージ描画
    M5.Display.endWrite(); // 描画終了

    lastErrorMessage = message; // 前回エラーメッセージ更新
    lastState = SHOW_ERROR;     // 状態をSHOW_ERRORに更新
}

// 画像をダウンロードする関数
bool downloadImage(const String& url, uint8_t** buffer, size_t& size) {
    HTTPClient imgHttp; // 画像取得用HTTPクライアント生成
    imgHttp.begin(url); // 指定URLに接続準備
    int imgHttpCode = imgHttp.GET(); // GETリクエスト送信

    if (imgHttpCode > 0) { // HTTP要求成功
        if (imgHttpCode == HTTP_CODE_OK) { // ステータスコード200(OK)か
            int len = imgHttp.getSize(); // 画像サイズ取得
            // 画像が大きすぎないかチェック
            if (len > LARGE_IMAGE_WIDTH * LARGE_IMAGE_HEIGHT * IMAGE_SIZE_FACTOR) {
                displayError("画像サイズが大きすぎます"); // エラー表示
                imgHttp.end();
                return false;
            }

            // 前回画像用に確保したメモリがあれば開放
            if (*buffer) free(*buffer);
            *buffer = (uint8_t*)malloc(len); // 必要なサイズ分メモリ確保
            size = len; // サイズ保存

            if (*buffer) {
                WiFiClient* stream = imgHttp.getStreamPtr(); // 画像データ取得用ストリーム
                size_t bytesRead = stream->readBytes(*buffer, len); // 画像データ読み込み

                imgHttp.end(); // HTTP終了

                // 全部読み込めたか確認
                if (bytesRead == len) {
                    return true; // 成功
                } else {
                    displayError("Read image data失敗"); // 読込失敗
                    return false;
                }
            } else {
                displayError("Memory allocation失敗"); // メモリ確保失敗
                imgHttp.end();
                return false;
            }
        } else {
            displayError("Image download失敗"); // HTTPは成功したがコードがOKでない場合
            imgHttp.end();
            return false;
        }
    } else {
        displayError("Image HTTP GET失敗"); // HTTP要求自体が失敗
        imgHttp.end();
        return false;
    }
}

// ボタンが押された時の処理を行う関数
void sendButtonPress() {
    Serial.println("Button A pressed. Current State: " + String(currentState)); // ボタンAが押されたログ

    // 待ち(WAITING)状態または答え表示(SHOW_ANSWER)状態のとき
    if (currentState == WAITING || currentState == SHOW_ANSWER) {
        if (WiFi.status() == WL_CONNECTED) { // Wi-Fi接続中か確認
            HTTPClient http;    // HTTPクライアント生成
            http.begin(nodeRedUrl); // Node-REDのURL指定
            int httpResponseCode = http.GET(); // GETリクエスト送信

            if (httpResponseCode > 0) { // HTTP成功
                String payload = http.getString(); // レスポンスデータ取得
                JsonDocument doc; // JSONパース用
                DeserializationError error = deserializeJson(doc, payload); // JSONをパース

                if (!error) { // JSONパース成功
                    // JSONからデータ抽出
                    String imageUrl = doc["url"].as<String>();     // 大きな画像URL
                    answerImageUrl = doc["url2"].as<String>();     // 答え用画像URL
                    name = doc["name"].as<String>();               // 国旗名
                    trivia = doc["gpt"].as<String>();              // 雑学

                    Serial.println("Fetched data successfully."); // データ取得成功ログ
                    Serial.println("Image URL: " + imageUrl);
                    Serial.println("Answer Image URL: " + answerImageUrl);
                    Serial.println("Name: " + name);
                    Serial.println("Trivia: " + trivia);

                    // 大きな画像ダウンロード
                    if (downloadImage(imageUrl, &gIMGBuf, gIMGSize)) { // 成功なら
                        Serial.println("Main image downloaded successfully.");
                        currentState = SHOW_IMAGE; // 状態を画像表示へ
                    } else {
                        // downloadImage内でエラー表示済み
                    }
                } else {
                    displayError("JSON Parseエラー"); // JSON解析失敗
                }
            } else {
                displayError("Node-REDエラー"); // Node-RED側問題
            }

            http.end(); // HTTP終了
        } else {
            displayError("Wi-Fi接続失敗"); // Wi-Fiが切れている場合
        }
    }
    // 画像表示中にボタンを押した場合(SHOW_IMAGE)
    else if (currentState == SHOW_IMAGE) {
        // 答えの画像URLがあるか確認
        if (answerImageUrl.length() > 0) {
            // 答え画像をダウンロード
            if (downloadImage(answerImageUrl, &answerIMGBuf, answerIMGSize)) {
                Serial.println("Answer image downloaded successfully.");
                currentState = SHOW_ANSWER; // 答え表示状態へ
            } else {
                // エラーはdownloadImage内で表示
            }
        } else {
            displayError("No answer image URLエラー"); // 答え用URLなし
        }
    }
}

// プログラムの初期設定を行う関数
void setup() {
    Serial.begin(115200); // シリアル初期化 (デバッグ用)
    while (!Serial) { delay(10); } // シリアルポート準備待ち

    auto cfg = M5.config(); // M5Unified設定取得
    M5.begin(cfg);          // M5初期化

    M5.Display.startWrite();               // ディスプレイ描画開始
    M5.Display.setFont(&fonts::lgfxJapanGothic_12); // フォント設定(小さめ)
    M5.Display.setTextWrap(true);          // テキストの自動改行有効
    M5.Display.fillScreen(WHITE);          // 背景白
    M5.Display.setTextColor(BLACK);        // テキスト黒

    M5.Display.setCursor(0, 0);            // 左上にカーソル
    M5.Display.fillScreen(WHITE);          // 再度白クリア
    M5.Display.setTextColor(RED);          // 強調:赤
    M5.Display.println("【初回のみ】");     // 初回のみの手順説明タイトル
    M5.Display.setTextColor(BLACK);        
    M5.Display.print("スマホ、PCなどから"); 
    M5.Display.setTextColor(RED);
    M5.Display.print("「AtomS3-AP」");      // AP名を赤で強調
    M5.Display.setTextColor(BLACK);
    M5.Display.print("に接続して開いたページの");
    M5.Display.setTextColor(RED);
    M5.Display.print("「Configure WiFi」");  // WiFi設定リンク名を赤で強調
    M5.Display.setTextColor(BLACK);
    M5.Display.print("からSSIDとパスワードを入力して");
    M5.Display.setTextColor(RED);
    M5.Display.print("「Save」");            // セーブボタンを赤で強調
    M5.Display.setTextColor(BLACK);
    M5.Display.print("してください。");
    M5.Display.endWrite();                  // 描画終了
    M5.Display.setFont(&fonts::lgfxJapanGothic_16); // フォントサイズ戻す

    // WiFiManagerインスタンス生成
    WiFiManager wifiManager;
    wifiManager.setConfigPortalTimeout(180); // APモードで待つ時間(秒)

    // autoConnectでAP「AtomS3-AP」を立ち上げ、Wi-Fi接続を試みる
    if (!wifiManager.autoConnect("AtomS3-AP")) {
        // 接続できなければエラー表示して再起動
        displayError("Wi-Fi接続に失敗しました");
        delay(3000);
        ESP.restart();
    }

    Serial.println("Wi-Fi connected. IP address: " + WiFi.localIP().toString()); // 接続成功ログ

    M5.Display.startWrite();
    M5.Display.fillScreen(WHITE);
    M5.Display.setCursor(0, 0);
    M5.Display.println("じゅんびできたよ");    // 接続完了メッセージ
    M5.Display.println("ボタンをおしてね"); // ユーザへの指示
    M5.Display.endWrite();

    // ここまででWi-Fi設定完了、単一Wi-Fi運用のため複雑な処理不要
}

// メインのループ処理
void loop() {
    M5.update(); // M5デバイス状態更新 (ボタン状態など)

    // ボタンAが押されたかチェック
    if (M5.BtnA.wasPressed()) {
        sendButtonPress(); // ボタンが押された時の処理
    }

    // 状態が変わった時のみ表示を更新
    if (currentState != lastState) {
        Serial.println("State changed to: " + String(currentState)); // 状態変化ログ
        switch (currentState) {
            case SHOW_IMAGE:
                displayImage(gIMGBuf, gIMGSize); // 大きな画像表示
                break;
            case SHOW_ANSWER:
                displayAnswer(); // 答え(小さな国旗と名前・雑学)表示
                break;
            case SHOW_ERROR:
                // エラー時は既にdisplayErrorで表示済み
                break;
            case WAITING:
                // 待機状態は特になし
                break;
        }
        lastState = currentState; // 前回状態を更新
    }
}

"YOUR_WEBHOOK_URL"にはNode-RED(enebular)の環境構築で発行したHTTPトリガーのURLもしくはenebularを使用しない方はhttp inノードのWebhookのURLを書き換えてください。

platformio.iniはこちらから
platformio.ini
; PlatformIO Project Configuration File
;
;   Build options: build flags, source filter
;   Upload options: custom upload port, speed and extra flags
;   Library options: dependencies, extra library storages
;   Advanced options: extra scripting
;
; Please visit documentation for the other options and examples
; https://docs.platformio.org/page/projectconf.html

[env:m5stack-atoms3]
platform = espressif32
board = m5stack-atoms3
framework = arduino
monitor_speed = 115200
lib_deps = 
	m5stack/M5Unified@^0.1.17
	bblanchon/ArduinoJson@^7.2.0
	tzapu/WiFiManager@^2.0.17

最後にPCとAtomS3を接続して、VS CodeからPlatformIO: Uploadでソフトの書き込みを行います。

遊び方

AtomS3の電源を投入すると以下のような表示になります。

「AtomS3-AP」 というWi-Fiに接続します。すると 「192.168.4.1」 という画面が出てくるかと思います。
もし出ない場合はURLに 「192.168.4.1」 を直打ちしてください。
以下はスマホ画面ですが、PC、タブレットでも同様です。

Configure WiFiを選択します。

SSIDPasswordに使用するWi-Fiの情報を入力して画面下のSaveをします。

Save完了画面が表示されます。

AtomsS3の画面が切り替わります。
これで準備完了です!
ボタンを押していくと国旗と国旗雑学が切り替わっていきます!

デモ

あとがき というか"反省"

"子ども" と一口で言っても幅が広いので、どの年齢層を対象にするかにもよりますが、

今回はAtomS3で開発をしましたが、小さいお子さんだと飲み込んでしまう可能性があるのでは?

舐めまわして感電ということもなくはないのでは?

など、他にも大人が考えつかないようなことリスクがたくさん潜んでいるのではないかと感じました。

もちろん子どもに限らずですが、使用者が安心して楽しんでいただけることを大前提に開発していきたいと思いました!

今回は技術的な学びというより、考え方の学びが深い開発になりました。

こそだて×生成AI (byこそぷろ👶) Advent Calendar 2024 また明日以降もお楽しみに!

4
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?