バイスタンプとLINEを繋げてリバイスドライバー風変身エフェクトをつくる

今回は、LINEを使ってリバイスドライバー風の変身エフェクトを再現することにチャレンジしてみました。

制作経緯

『リバイス』初の玩具改造ということで、これまでどおりでいくなら、とりあえず何かしらレジェンドライダーの要素を持ち込んだオリジナルのバイスタンプを作ってみる、というのが基本路線でした。ただ、個人的にそういうのばっかりやっていてもあんまり面白くないなあという思いがあり、もう少しリバイス特有の何かができないかを突き詰めて考えてみた結果、今回の変身エフェクト再現にチャレンジすることを思いつきました。

個人的なテーマとして、クラウド技術を組み合わせた何かを作ってみたいという思いも前からあったので、LINE連携が必須なこのテーマならそれも叶うだろう、ということで、『リバイス』放映開始ぐらいのタイミングで開発スタートしました。

そこからまあ、苦労の連続だったわけですが。

特徴

今回『モノ』として作成したのは、バイスタンプに取り付ける『アダプター』です。これをバイスタンプに取り付けると電源が入り、赤色LEDが点灯します。

LEDが青色になれば準備OK(=クラウドとの接続完了)です。

この状態で天面ボタンを押すと、LINE上で一輝とバイスのトークが始まります。

展開されるトークの内容は、アダプターを取り付けたバイスタンプごとに変化します。実際にそのバイスタンプを使ったときに実際に劇中で表示されていたものか、または汎用トークとしてオープニングで表示されている内容か、その中のいずれかからのランダム選択になります。劇中だと、トークの文字が隠れて全文が確認できないことも多いので、そういうときのために汎用トークを設定しています。

また、劇中ではトーク開始のタイミングは「天面ボタンを押したとき」と「リバイスドライバーに押印したとき」の二種類があるのですが、今回はシンプルな実装にしたかったので、天面ボタンを押すタイミングでトークを開始するようにしています。

トーク開始後、バイスタンプを通常通りリバイスドライバーにセットして、スタンプをレバー代わりに倒すと、変身エフェクトの締めとしてバイスタンプの画像が表示されます。

 

制作にあたり、アダプター型ではなく、一つのバイスタンプの中に機能を作り込んで見た目をスッキリさせる、という方向性もあったかと思うのですが、

アダプターを挟んでも壊滅的に見た目が悪くなるわけでもないですし、何より手軽にいろんなバイスタンプで遊べることの方が大事だったので、使い回しが容易なアダプタータイプのモノを作ることにしました。

ハードウェア解説

今回使用した主な具材は以下の3つです。

メインのマイコンとしては、M5Stamp Pico Mateを使用しました。狙ったわけではないのですが、バイスタンプの改造に、奇しくも『Stamp』の名を持つデバイスを採用することになりました。意味合い的にはこっち『判子』というより『切手』ですが。

今回の作品は、このマイコンなしには成立しなかったのではないかと思います。その理由は追々わかって頂けると思います。

続いて赤外線LED。2つ使用しました。今回の私の作品というよりは、バイスタンプ&リバイスドライバーの最重要部品、という感じがします。後で詳述しますが、1つの赤外線LEDで送信・受信両方行います。

最後に、いつものリチウムイオンポリマー充電池です。できれば容量400mAhぐらいを使いたかったのですが、サイズ優先でいつもの110mAhにしています。

 

いつものディテクタスイッチ充電池用コネクタなどの小物を除けば、メインの部品はこれだけです。今回の作品ですが、ハードウェアの構成としては、おそらく過去のどのライダー玩具改造作品と比較しても、最もシンプルだと思います。

回路図はこんな感じです。シンプル。

実際の配線はこんな感じです。シンプル。ちなみに筐体は3Dプリンタで自作しました。

シンプルなのですが、実は最終的にこの構成に行き着くまでにかなり試行錯誤しています。詳しくは述べませんが、Arduino Pro MiniとM5Stampのダブルマイコン構成で完成一歩手前までいったのが最後の最後で頓挫し、間でArduino MKR WiFi 1010一本構成での検討を挟み、最後にM5Stamp一個で何とかなることに気がついた、という感じです。紆余曲折あった分、かなり時間はかかってしまいましたが、その分だけ色々勉強にはなりました。

また、ハードウェアの具材、とはちょっと意味合いが違うのですが、LINE画面投影のためのプロジェクターには以下を使用しました。

出来上がったものを見せるのに、流石にスマホ画面では絵面的に寂しい…ということで、プロジェクターの導入は検討はしていたものの、流石にこれだけのために数万円払うのはいかがなものか…と思っていたのですが、Amazonのタイムセールで値引きされていたのを見てうっかりポチってしまいました。後悔はしていません。

ソフトウェア解説

先述のとおり、ハードウェアについては過去最高にシンプルですが、ソフトウェアについては過去最高にややこしい…というよりは、もはやシステムと言った方が良い気がします。これまでと違い、マイコン単体では完結せず、クラウドサービスとの連携によって実現しているため、話が広範囲に及びます。

全体構成図としては、こんな感じになります。

以下、3つに分けて説明していきます。

①バイスタンプ⇄リバイスドライバー通信

今回は自身初のリバイス玩具の改造ということで、まず初めに、バイスタンプとリバイスドライバーの通信のポイントについて説明します。

まず、皆様ご存知のとおり、バイスタンプには通信用の赤外線LEDらしきものが一つしかありません。

これを見て、私は初め「バイスタンプ→リバイスドライバー」の一方向通信かと思いました。というのも、家電などで使われている一般的な赤外線通信は、受信用モジュールと送信用モジュール(=赤外線LED)が別になっているからです。ところが、バイスタンプとリバイスドライバーは双方向通信していることが後に判明し、その理屈について考えてみました。

「通常のLEDが発光するだけでなく実は光検出器としても使える」というのは、以前にmicro:bitの製品仕様で見かけて知っていました。詳しい理屈は知らないのですが、通常のLEDは光を当てることで起電力が発生し、僅かに電圧が上昇するようです。

バイスタンプの赤外線LEDもこの現象を利用しているのでは、と考え、赤外線LEDに赤外線を当てたときとそうでないときの電圧をanalogRead()で適当に計測してみると、

赤外線 非受信時ログ

赤外線 受信時ログ

確かに、明らかな違いが現れました。この差を使えば、赤外線LEDでも受信処理ができそうです。

専用の受信モジュールを用いない分、通信距離が制限されるため、一般的にはあまり使われない通信方式だと思いますが、コスト削減・秘匿性が求められるライダー玩具にピッタリの方式だと思います。

デザインに与える影響も抑えられるので、考えた人は本当にすごいと思います。今後のライダー玩具のスタンダードな認識方式になり得るのではないかと思っています。

 

続いて赤外線通信のフォーマットですが、これはバイスタンプが赤外線信号を送っているときの赤外線LEDの電圧変化をオシロスコープで観測することでわかります。

オシロスコープはこれまで使ったことがなかったので新たに購入する必要があったのですが、良さげなものはどれもそれなりにお高く、また大抵場所もとるので、今回はお試しと割り切って、安くて小型なこちらを購入しました(2021/11/23時点在庫切れ中)。うまくいかなくてもまあこの値段(数千円)なら諦めつくか、と思いましたが、今回は無事に役目を果たしてくれて良かったです。

さて、この波形パターンですが、先人が解析して下さったドライブドライバーのそれと酷似しています。

「6000μsのHIGH、500μsのLOW」が信号開始の合図で、その後の2000μsごとのHIGH・LOWの時間割合で0・1が決まります。8個分の0・1で、1 byte、0〜255の整数が表現されています。

この形式でバイスタンプとリバイスドライバーが互いに整数値を送り合うことで、変身や必殺技、リミックスの音声、発光が発動するようになっています。バイスタンプとリバイスドライバーは、一度通信が始まると、およそ200ms程度の時間間隔で互いに整数値を送り続けており、この双方向通信が途絶えると、バイスタンプはリバイスドライバーから外されたと認識して、解除音が鳴るようになっています。

 

さて、赤外線通信の仕組みがわかったところで、後はこの赤外線通信の内容をどう読み取るかです。

今回作ったアダプターは、ハードウェア解説の回路図で示したとおり、バイスタンプ側とリバイスドライバー側に赤外線LEDが仕込まれています。これがバイスタンプとリバイスドライバーの間に挟まることで、このアダプターが信号伝達を仲介することになり、バイスタンプとリバイスドライバーの間でやりとりされる情報をマイコンがキャッチして処理することができるようになります。今回の作品では、バイスタンプの識別IDと、リバイスドライバーからの変身開始要求のコマンドを捉えています。

プログラムでの信号の読み取り方ですが、フォーマットは先に説明したとおりなので、開始信号をキャッチしたら、あとはおよそ2000μsごとのHIGH・LOWをチェックすれば、0か1かを知ることができます。

HIGH・LOW判定のための電圧の閾値については、起動時にキャリブレーションさせるなど色々試してはみたのですが、最終的にはエイヤと決め打ちにしました。最後にプログラムの全文は掲載しますが、ここの閾値は使用するマイコンの電圧や出力値のレンジによって変わってくるところだと思うので注意してください。

また、「およそ2000μsごと」と書いていますが、ピッタリ2000μsにすると何故か上手くいかなかったため、実装では2000μsから少しだけズラしています。よくわかっていませんが、ここも使用するマイコンによって調整した方が良いポイントだと思います。特に、「analogRead()の処理にどれぐらい時間がかかるか」はキチンと計測・確認しておいた方が良いと思います。Arduino Pro Miniだと100μsぐらいかかりますし、例えばSeeeduino XIAOなどに搭載されているSAMD21系だと、(何もしなければ)もっとかかると思います。

②LINE上での一輝とバイスのトークの実現

次に話は変わって、どのようにLINE上で一輝とバイスの掛け合いを実現しているかを説明します。

LINEは開発者向けに、メッセージを投稿するための2つのAPI (Application Programming Interface)、Notify APIMessaging APIを提供しています。このうち、お手軽に使用できるのはNotify APIなのですが、こちらはアイコンが変更できなかったり、メッセージの先頭にトークン名が必ず表示されたりと、一輝とバイスの掛け合いの表現には向いていないので、準備がちょっと面倒ですが、アイコン変更可能で自然な会話表現ができそうなMessaging APIを使用することにしました。

Messaging APIを使用するには応答用のサーバを自分で用意する必要がありますが、稀にしか使用しないようなサーバになるので、今回はAWS(Amazon Web Service)のAPI GatewayとLambdaの組み合わせでサーバレス構成としています。このあたりの構築の仕方については、以下の記事を参考にさせて頂きました。

この構成を作った後に、自身のスマホのLINEアプリから自分のチャンネルにメッセージを送ると、自身のサーバ(Lambda)で自分のアカウントに対してメッセージを送るためのユーザIDを取得できるので、後は取得したIDを指定してプッシュメッセージ送信用APIを叩けば、自身の端末にメッセージを送ることができます。

メッセージ内容として送るためのテキストや画像データはS3上に保管し、Lambdaでアダプターから受信したバイスタンプのIDや変身コマンドの情報に基づいて送信するテキストや画像データを選択、LINEのChannelへ送信しています。

{
  "talks": [
    {
      "use": 0,
      "stamp_id": 1,
      "talk": [
        {"character": "ikki", "text": "おい悪魔! 騙したな!"},
        {"character": "vice", "text": "何がだよ、ちゃんと助けただろが"},
        {"character": "ikki", "text": "俺のかーちゃんをまた食おうとしただろ!"},
        {"character": "vice", "text": "してない! してない!"},
        {"character": "ikki", "text": "とぼけるな!"},
        {"character": "vice", "text": "ちょっとこの人↑、何言っちゃってるんですかぁ?"},
        {"character": "ikki", "text": "もういい、お前には頼らない。俺が家族を守る!"},
        {"character": "vice", "text": "ふふーん、じゃあ俺っちも勝手にするもん!"},
        {"character": "ikki", "text": "勝手にしろ!"}
      ]
    },
    {
      "use": 0,
      "stamp_id": 0,
      "talk": [
        {"character": "vice", "text": "俺っち、待ちきれなーい!"},
        {"character": "ikki", "text": "沸いてきたぜ!"},
        {"character": "vice", "text": "よっ! 五十嵐家の長男坊!\n五十嵐一輝!"},
        {"character": "ikki", "text": "バイス!\nしっかりサポートしろよ!"},
        {"character": "vice", "text": "あいあいさー!"},
        {"character": "ikki", "text": "一気に行くぜ!"}
      ]
    },
    {
      "use": 1,
      "stamp_id": 1,
      "talk": [
        {"character": "vice", "text": "待ってました、変身ターイム!"},
        {"character": "ikki", "text": "はしゃぐな!"},
        {"character": "vice", "text": "早く、早く、俺っち待ちきれなーい!"},
        {"character": "ikki", "text": "分かってるから落ち着け!"},
        {"character": "vice", "text": "俺っちの活躍を全国のみんなが待ってるぜ!"},
        {"character": "ikki", "text": "そんなわけないだろ!"},
        {"character": "vice", "text": "あー、ヤキモチ焼いてるー"},
        {"character": "ikki", "text": "ちょっと黙ってろ!"}
      ]
    },
    (以下同様)
  ]
}

一輝とバイスのトークのテキストはこのような感じで、JSONフォーマットで定義しています。バイスタンプのID”stamp_id”に基づいて、対応するテキストを選択できるようにしています。

また、”use”でこのトークを使用するかしないかを選べるようにもしています。先にも少し述べましたが、劇中ではトークの全文を確認できないことが多く、そういう中途半端なものは、このJSONファイルにわかる範囲で書いてはいるものの、”use”で0を指定して選択させないようにしています。いつかどこかで全文公開されることを期待しています。今回はとにかく本編をコマ送り再生しまくって自力でトーク内容を確認しました。TTFC(東映特撮ファンクラブ)万歳。

あと、アイコンやスタンプの画像データですが、LINEのMessaging APIの仕様上、画像データを直接送るのではなく、画像データの置き場所のURLを送る形になります。その置き場所にスマホがアクセスしてくる形になるので、Web上に公開してよい場所に画像データを置いておく必要があるのですが、そのための場所をわざわざ用意するのも面倒なので、S3を直接参照させるようにしています。ただ、S3を常時公開しておくのはセキュリティ上やりたくないので、Lambda上で署名付きURLを発行して、超短期間だけアクセスを許可するようにしています。このあたりのやり方は、以下を参照させて頂きました。

 

LINE連携について、最後に一つ注意事項があります。このMessaging APIを使った仕組みですが、あんまりやり過ぎると、Messaging APIのレート制限や、LINEの開発ガイドラインに引っかかってしまいます。詳しくは以下をご参照ください。

おそらく引っかかりやすいのは、「メッセージ送信: 60リクエスト/時」「同一ユーザーへの大量送信の禁止」だと思います。これらに引っ掛かると、プッシュメッセージ送信のAPIを叩いたときに”HTTP Error 429: Too Many Requests“が返ってきて、メッセージが送信できなくなります。こうなってしまったときの解除条件は不明ですが、最悪解除できない可能性もありますので、実施については無闇やたらに行わず、あくまで自身の技術検証目的ぐらいでの実施に留めておきましょう。何よりプラットフォームの提供者にご迷惑をかけるのは良くないので。

③アダプター → クラウド連携

ここまで来れば後はアダプターとクラウドを繋ぐだけですが、最後に一つ、クリアしなければならない課題があります。

最初に説明したとおり、バイスタンプとリバイスドライバーは、スタンプがセットされると200ms程度の時間間隔で互いに通信し続けなければなりません。もし、読み取った情報をクラウドに送るためにこの赤外線信号の送受信処理が200ms以上停止しまうと、その時点でスタンプからは解除音が鳴り、変身シーケンスはリセットされてしまいます。そのため、この赤外線信号の送受信を継続させつつ、クラウドに情報を送るための実装の工夫が必要になります。

これについては、実は最初はArduino Pro MiniとM5Stamp Picoのダブルマイコン構成で解決させようとしていました。つまり、赤外線処理はArduino Pro Miniに任せて、読み取った信号をシリアル通信でM5Stampに送って通信処理を任せてしまえば、赤外線処理を中断させることなくクラウドとも通信できる、という考え方です。

実際それでプログラムまで作ったのですが、いざ動かしてみると、ダブルマイコンを動かすのにリチウムイオンポリマー充電池の出力が足りないのか、シリアル通信が安定せず誤動作連発…ということで、「これでもやれるかもしれないけど、ちょっと難しそうだな」と思って保留にしていた、シングルマイコンでのFreeRTOSによるマルチタスクによる解決を図ることにしました。今回採用したM5Stamp Picoに採用されているマイコン(ESP32-PICO-D4)はデュアルコアらしいので、赤外線通信処理とクラウド通信の処理を別のコアで並行動作させれば問題は解決するハズです。

このESP32のデュアルコアでのマルチタスク処理ですが、「できる!」と書いているところはたくさん見つかるのですが、詳細や実用的なサンプルコードを公開しているサイトがかなり少なく、そもそもの勉強のハードルが結構高かったのですが、以下のサイトの情報を頼りに何とか実現できました。ありがとうございます。

ソースコードの全文は最後に載せますが、通常のloop処理で赤外線の送受信を行わせ、それより優先度を下げたクラウド通信用のタスクを別コアで動作させ、キューでタスク間の情報を受け渡すようにしています。

 

また、併せてクラウドとの通信時間はできるだけ短くするべきですが、既にLINE向けに用意しているAPI Gatewayを流用しようとすると、

  • HTTPS通信になって時間がかかる
  • Lambdaの非同期呼び出し

などへの対応を考えなくてはいけなくなるので、今回はAPI Gatewayとは別にAWS IoT Coreを使ってMQTT接続にすることで、データをPublishすれば済むようにして、短時間で通信を終えるようにしました。

ESP32とIoT Coreの接続、およびIoT CoreからのLambda呼び出しについては、2年近く前の私が「今後に備えて」と一度トライしていたので、当時の自身の記事を参照しながらセットアップしました。偉いぞ昔の私。

まとめ

 

以上、LINE連携によるリバイスドライバー風変身エフェクトの作成でした。今回は自分としては技術ハードルが高く、完成までにかなり時間がかかってしまったのですが、その分色々新たに学ぶことができました。

ちなみに今回はLINEの画面をプロジェクターで投影しましたが、プロジェクターについてはCSMブレイバックルの畳、もといオリハルコンエレメントのプロジェクターで採用実績があるので、将来リバイスドライバーがCSM化されることがあれば、このトークの再現機能は間違いなく搭載されると思います。

個人的に『リバイス』は、今のところ令和ライダーの中で一番気に入っています。今後も楽しみです。

 

ソースコード

以下、ソースコードの全文です。今回は以下の3つのソースコードが存在します。

  1. LINEチャンネルからのメッセージ受信Lambda (Python)
  2. LINEチャンネルへのメッセージ送信Lambda (Python)
  3. アダプター(M5Stamp Pico)

以下、順に掲載していきます。

①LINEチャンネルからのメッセージ受信Lambda (Python)

import os
import json
import urllib.request
import boto3
import logging

logger = logging.getLogger()
logger.setLevel(logging.INFO)

URL_REAPLY = 'https://api.line.me/v2/bot/message/reply'
HDADERS = {
            'Content-Type': 'application/json',
            'Authorization': 'Bearer ' + os.environ['LINE_CHANNEL_ACCESS_TOKEN']
          }

def lambda_handler(event, context):

    # S3アクセス準備
    s3 = boto3.client(
        service_name='s3',
        aws_access_key_id = os.environ['ACCESS_KEY_ID'],
        aws_secret_access_key = os.environ['SECRET_ACCESS_KEY'],
        region_name = os.environ['REGION']
    )

    # アイコン画像の取得準備(S3の署名付きURL発行(180秒間))
    icon_urls= {}

    icon_urls['vice'] = s3.generate_presigned_url(
        ClientMethod = 'get_object',
        Params = {
            'Bucket': os.environ['S3_BUCKET'],
            'Key': 'icon/vice.png'
        },
        ExpiresIn = 180,
        HttpMethod = 'GET'
    )

    # API GatewayからHTTP POSTデータを取得
    post_data = json.loads(event['body'])

    # LINEからのアクセス(主にuserIdの初回確認用)
    for message_event in post_data['events']:
        print('userId: '  + message_event['source']['userId'])
        body = {
            'replyToken': message_event['replyToken'],
            'messages': [
                {
                    "type": "text",
                    "text": "確認したぜ!",
                    "sender":{
                        "iconUrl": icon_urls['vice']
                    }
                }
            ]
        }
        req = urllib.request.Request(URL_REAPLY, headers=HDADERS,  method='POST', data=json.dumps(body).encode('utf-8'))
        with urllib.request.urlopen(req) as res:
            logger.info(res.read().decode("utf-8"))

    return {
        'statusCode': 200,
        'body': json.dumps('Revice Driver Effector End.')
    }

os.environ[“XXX”]になっているところはLambdaの環境変数で定義しています。今回のサンプルは、私の遊びで、バイスが応答しているかのように見せるためにS3上のバイスのアイコン画像を引っ張ってきているのですが、そのあたり特に拘らなければ、S3関係の記述はバッサリ削除で問題ないはずです。

②LINEチャンネルへのメッセージ送信Lambda (Python)

import os
import json
import random
import time
import urllib.request
import boto3
import logging

logger = logging.getLogger()
logger.setLevel(logging.INFO)

URL_PUSH = 'https://api.line.me/v2/bot/message/push'
HDADERS = {
            'Content-Type': 'application/json',
            'Authorization': 'Bearer ' + os.environ['LINE_CHANNEL_ACCESS_TOKEN']
          }

def lambda_handler(event, context):

    # S3アクセス準備
    s3 = boto3.client(
        service_name='s3',
        aws_access_key_id = os.environ['ACCESS_KEY_ID'],
        aws_secret_access_key = os.environ['SECRET_ACCESS_KEY'],
        region_name = os.environ['REGION']
    )

    # アイコン画像の取得準備(S3の署名付きURL発行(180秒間))
    icon_urls= {}

    icon_urls['ikki'] = s3.generate_presigned_url(
        ClientMethod = 'get_object',
        Params = {
            'Bucket': os.environ['S3_BUCKET'],
            'Key': 'icon/ikki.png'
        },
        ExpiresIn = 180,
        HttpMethod = 'GET'
    )

    icon_urls['vice'] = s3.generate_presigned_url(
        ClientMethod = 'get_object',
        Params = {
            'Bucket': os.environ['S3_BUCKET'],
            'Key': 'icon/vice.png'
        },
        ExpiresIn = 180,
        HttpMethod = 'GET'
    )

    # メッセージ内容を取得
    state    = int(event["state"])
    stamp_id = int(event["stamp_id"])

    if state == 1: # 変身前

        # シナリオデータを取得
        res   = s3.get_object(Bucket=os.environ['S3_BUCKET'], Key='talks.json')
        body  = res['Body'].read()
        talks_dic = json.loads(body.decode('utf-8'))
        talks = talks_dic['talks']

        # バイスタンプのIDで選択する
        talk_candidate = []
        for index, item in enumerate(talks):
            id = item['stamp_id']
            if id in [0, stamp_id] and item['use'] == 1:
                # id == 0 はバイスタンプに依存しない汎用トーク
                talk_candidate.append(index)

        talk_num = random.choice(talk_candidate)

        # 選択されたトークを開始
        for sentence in talks[talk_num]['talk']:
            line_message = {
                "to": os.environ['LINE_USER_ID'],
                "messages":[
                    {
                        "type": "text",
                        "text": sentence['text'],
                        "sender":{
                            "iconUrl": icon_urls[sentence['character']]
                        }
                    }
                ]
            }

            req = urllib.request.Request(URL_PUSH, headers=HDADERS,  method='POST', data=json.dumps(line_message).encode('utf-8'))
            with urllib.request.urlopen(req) as res:
                logger.info(res.read().decode("utf-8"))

            time.sleep(1.0)

    elif state == 2: # 変身

        # スタンプ画像の取得準備(S3の署名付きURL発行(180秒間))
        stamp_url = s3.generate_presigned_url(
            ClientMethod = 'get_object',
            Params = {
                "Bucket": os.environ['S3_BUCKET'],
                "Key": 'stamp/' + str(stamp_id) + '.png'
            },
            ExpiresIn = 180,
            HttpMethod = 'GET'
        )

        line_message = {
            "to": os.environ['LINE_USER_ID'],
            "messages":[
                {
                    "type": "image",
                    "originalContentUrl": stamp_url,
                    "previewImageUrl": stamp_url,
                    "sender":{
                        "iconUrl": icon_urls['ikki']
                    }
                }
            ]
        }

        req = urllib.request.Request(URL_PUSH, headers=HDADERS,  method='POST', data=json.dumps(line_message).encode('utf-8'))
        with urllib.request.urlopen(req) as res:
            logger.info(res.read().decode("utf-8"))

    else:
        pass

    return "End."

こちらも①と同様、os.environ[“XXX”]になっているところはLambdaの環境変数で定義しています。

③アダプター(M5Stamp Pico)

////////// 基本設定 /////////////////////////////////////////////////////////////

// 使用するのがM5Stampでも、M5Atom用のコードでコンパイル&ボード設定書き込み可能
#include "M5Atom.h"

#define IR_RX_TX_FOR_STAMP_PIN  32
#define IR_RX_TX_FOR_DRIVER_PIN 33

#define STAMP_ID_MIN  1
#define STAMP_ID_MAX 75
#define COMMAND_CHANGE 152

#define RESET_MS 1000

// 全体としての状態
#define STATE_INIT   0
#define STATE_READY  1
#define STATE_CHANGE 2
uint8_t state = STATE_INIT;
uint8_t stamp_id = 0;
boolean is_set_driver = false;

void reset_parameter(){
  state = STATE_INIT;
  stamp_id = 0;
  is_set_driver = false;
  Serial.println("Reset Parameters.");
}

typedef struct send_data {
  uint8_t state;
  uint8_t stamp_id;
} SEND_DATA;

// マルチタスク用設定
QueueHandle_t xQueueMailbox;

////////// ネットワーク処理 //////////////////////////////////////////////////////////

#include <WiFiClient.h>
#include <WiFiClientSecure.h>
#include <PubSubClient.h>

// WiFi設定情報(自分の無線LAN環境)
const char* SSID     = "xxxxxxxxxxxxx";
const char* PASSWORD = "xxxxxxxxxxxxx";

// AWS IoT設定情報
const char* AWS_ENDPOINT = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.amazonaws.com";
const int   AWS_PORT     = 8883;
const char* PUB_TOPIC    = "xxxxxxxxxxxxxxxx";
const char* CLIENT_ID    = "xxxxxxxxxxxxxxxx";

const char* ROOT_CA = "-----BEGIN CERTIFICATE-----\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"-----END CERTIFICATE-----\n";

const char* CERTIFICATE = "-----BEGIN CERTIFICATE-----\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"-----END CERTIFICATE-----\n";

const char* PRIVATE_KEY = "-----BEGIN RSA PRIVATE KEY-----\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n" \
"-----END RSA PRIVATE KEY-----\n";

WiFiClientSecure httpsClient;
PubSubClient mqttClient(httpsClient);
char pubMessage[128];

void setup_wifi(){
  Serial.print("Connecting to ");
  Serial.println(SSID);

  uint8_t retry_counter = 0;
  WiFi.begin(SSID, PASSWORD);
  while(WiFi.status() != WL_CONNECTED){
      retry_counter++;
      delay(500);
      Serial.print(".");
      if(retry_counter % 6 == 0){
        // 3秒経って接続できなければ、WiFi.beginを再実行
        WiFi.disconnect();
        WiFi.begin(SSID, PASSWORD);
        Serial.println("");
      }
      if(retry_counter >= 30){
        // 2回やり直してもダメなら、本体そのものをリセット
        ESP.restart();
      }
  }

  Serial.println("");
  Serial.println("WiFi connected");
  Serial.println("IP address: ");
  Serial.println(WiFi.localIP());
}

void setup_awsiot(){
  httpsClient.setCACert(ROOT_CA);
  httpsClient.setCertificate(CERTIFICATE);
  httpsClient.setPrivateKey(PRIVATE_KEY);
  mqttClient.setServer(AWS_ENDPOINT, AWS_PORT);
}

void connect_awsiot() {
  while (!mqttClient.connected()) {
    Serial.print("Attempting MQTT connection...");
    if(mqttClient.connect(CLIENT_ID)) {
      Serial.println("Connected.");
    }else{
      Serial.print("Failed, rc=");
      Serial.print(mqttClient.state());
      Serial.println(" Try again in 3 seconds");
      // 3秒後にリトライ
      delay(3000);
    }
  }
}

void send_message(uint8_t state, uint8_t stamp_id){
  sprintf(pubMessage, "{\"state\": %d, \"stamp_id\": %d}", state, stamp_id);
  Serial.print("Publishing message to topic ");
  Serial.println(PUB_TOPIC);
  Serial.println(pubMessage);
  mqttClient.publish(PUB_TOPIC, pubMessage);
  Serial.println("Published.");
}

////////// IR信号送受信処理 ////////////////////////////////////////////////////////////

#define LEN_IR_DATA  8
#define IR_THRESHOLD_FOR_STAMP  800
#define IR_THRESHOLD_FOR_DRIVER 800
#define SEND_IR_DELAY_MS 20

// デバッグ用プリント
void print_binary(uint8_t data){
  for(uint8_t i=0;i<LEN_IR_DATA;i++){
    Serial.print(bitRead(data, LEN_IR_DATA-1-i)); // 8bitの左端から表示
  }
  Serial.println();
}

void send_start(uint8_t pin){
  digitalWrite(pin, HIGH);
  delayMicroseconds(6000);
  digitalWrite(pin, LOW);
  delayMicroseconds(500);
}

void send_bit_0(uint8_t pin){
  digitalWrite(pin, HIGH);
  delayMicroseconds(1500);
  digitalWrite(pin, LOW);
  delayMicroseconds(500);
}

void send_bit_1(uint8_t pin){
  digitalWrite(pin, HIGH);
  delayMicroseconds(500);
  digitalWrite(pin, LOW);
  delayMicroseconds(1500);
}

void send_ir(uint8_t pin, uint8_t data){
  pinMode(pin, OUTPUT); 

  send_start(pin);

  for(uint8_t i=0; i<LEN_IR_DATA; i++){
    // dataを左端から順に送信する
    if(bitRead(data, LEN_IR_DATA-1-i) == 0){
      send_bit_0(pin);
    }else{
      send_bit_1(pin);
    }
  }
}

void stamp_to_driver(){
  // スタンプから送出される信号をドライバーに伝える
  pinMode(IR_RX_TX_FOR_STAMP_PIN, INPUT_PULLUP);

  while(analogRead(IR_RX_TX_FOR_STAMP_PIN) < IR_THRESHOLD_FOR_STAMP){
    // 受信待ち待機
    ;
  }

  // 閾値を超えた(ONになった)ので、5500μs待つ
  delayMicroseconds(5500);

  // 送受信データは丁度1byteなので、1byteの変数に受信結果を収める
  uint8_t recv_data = 0;
  if(analogRead(IR_RX_TX_FOR_STAMP_PIN) >= IR_THRESHOLD_FOR_STAMP){
    // ここでも閾値を超えていれば、スタンプから信号が来たとみなし、およそ2000μsごとの読み取りを開始する
    for(uint8_t i=0; i<LEN_IR_DATA; i++){
      delayMicroseconds(2030);
      if(analogRead(IR_RX_TX_FOR_STAMP_PIN) < IR_THRESHOLD_FOR_STAMP){
        // ON:1500μs, OFF: 500μs ... 0
        // ON: 500μs, OFF:1500μs ... 1
        // なので、2000μ秒ごとのサイクルで1000μsの位置がONなら0、OFFなら1となる
        recv_data = recv_data | (1 << (LEN_IR_DATA-1-i));
      }
    }

    // クラウドへのデータ送信
    if(state == STATE_INIT && STAMP_ID_MIN <= recv_data && recv_data <= STAMP_ID_MAX){
      // バイスタンプのIDを1〜75と仮定
      stamp_id = recv_data;
      state = STATE_READY;
      // キューに送信指示を送る
      SEND_DATA send_data = {state, stamp_id};
      xQueueOverwrite(xQueueMailbox, &send_data);
    }

    delay(SEND_IR_DELAY_MS);

    // 読み取ったデータをドライバーへ伝える
    send_ir(IR_RX_TX_FOR_DRIVER_PIN, recv_data);

    // スタンプからドライバーへ伝えた結果を表示する
    Serial.print("Stamp -> Driver: ");
    //print_binary(recv_data);
    Serial.println(recv_data);
  }

  return;
}

void driver_to_stamp(){
  // ドライバーから送出される信号をスタンプに伝える
  pinMode(IR_RX_TX_FOR_DRIVER_PIN, INPUT_PULLUP);

  unsigned long start_millis = millis();
  while(analogRead(IR_RX_TX_FOR_DRIVER_PIN) < IR_THRESHOLD_FOR_DRIVER){
    if(is_set_driver){
      // ドライバーにセットされているときのみ実施する判定処理
      if(millis() - start_millis > RESET_MS){
        // 所定時間受信がなければ、パラメータをリセットして受信待ちを解除する
        reset_parameter();
        return;
      }
    }
  }

  // 閾値を超えた(ONになった)ので、5500μs待つ
  delayMicroseconds(5500);

  // 送受信データは丁度1byteなので、1byteの変数に受信結果を収める
  uint8_t recv_data = 0;
  if(analogRead(IR_RX_TX_FOR_DRIVER_PIN) >= IR_THRESHOLD_FOR_DRIVER){
    // ここでも閾値を超えていれば、ドライバーから信号が来たとみなし、およそ2000μsごとの読み取りを開始する
    for(uint8_t i=0; i<LEN_IR_DATA; i++){
      delayMicroseconds(2030);
      if(analogRead(IR_RX_TX_FOR_DRIVER_PIN) < IR_THRESHOLD_FOR_DRIVER){
        // ON:1500μs, OFF: 500μs ... 0
        // ON: 500μs, OFF:1500μs ... 1
        // なので、2000μ秒ごとのサイクルで1000μsの位置がONなら0、OFFなら1となる
        recv_data = recv_data | (1 << (LEN_IR_DATA-1-i));
      }
    }

    // ここまで受信できたら、スタンプがドライバーにセットされたと判定
    is_set_driver = true;

    // クラウドへのデータ送信
    if(state == STATE_READY && recv_data == COMMAND_CHANGE){
      state = STATE_CHANGE;
      send_message(state, stamp_id);
    }

    delay(SEND_IR_DELAY_MS);

    // 読み取ったデータをスタンプへ伝える
    send_ir(IR_RX_TX_FOR_STAMP_PIN, recv_data);

    // ドライバーからスタンプへ伝えた結果を表示する
    Serial.print("Stamp <- Driver: ");
    //print_binary(recv_data);
    Serial.println(recv_data);
  }  

  return;
}

////////// タスク定義 ////////////////////////////////////////////////////////////

void wifi_task(void *pvParameters){
  while(1){
    if(mqttClient.connected()){
      // キューに何か入っていれば、データをクラウドに送信する
      if(uxQueueMessagesWaiting(xQueueMailbox) > 0){
        SEND_DATA send_data;
        xQueueReceive(xQueueMailbox, &send_data, 0);
        send_message(send_data.state, send_data.stamp_id);
      }
    }else{
      // 接続が切れていれば、再接続する
      M5.dis.drawpix(0, 0x00ff00); // 赤 (0xGGRRBB)
      connect_awsiot();
      M5.dis.drawpix(0, 0x0000ff); // 青 (0xGGRRBB)
    }
    delay(10);
  }
}

////////// メイン処理 ////////////////////////////////////////////////////////////

void setup(){
  M5.begin(true, false, true); // UART, I2C, LEDの初期化設定。LEDは初期化しないと使えない
  M5.dis.setBrightness(50);
  M5.dis.drawpix(0, 0x00ff00); // 赤 (0xGGRRBB)

  Serial.begin(115200);
  pinMode(IR_RX_TX_FOR_STAMP_PIN, INPUT_PULLUP);
  pinMode(IR_RX_TX_FOR_DRIVER_PIN, INPUT_PULLUP);

  // ネットワーク接続
  setup_wifi();
  setup_awsiot(); 

  // タスク間連携用のキュー(メールボックス)を作成
  xQueueMailbox = xQueueCreate(1, sizeof(SEND_DATA));

  // タスク生成、開始。優先度は loop_task(=1) > wifi_taskにする。
  // WiFi通信はloop_taskとは別のコアで実施させる
  xTaskCreateUniversal(
    wifi_task,          // 作成するタスク関数
    "wifi_task",        // 表示用タスク名
    8192,               // スタックメモリ量
    NULL,               // 起動パラメータ(設定しない)
    0,                  // 優先度 (低 0 <---> 24 高)
    NULL,               // タスクハンドル(設定しない)
    PRO_CPU_NUM         // 実行するコア(PRO_CPU_NUM: 0, APP_CPU_NUM: 1)
  );
}

void loop(){
  stamp_to_driver();
  driver_to_stamp();
  delay(1);
}

“xxxxxxx”となっている箇所が多いですが、ここは自宅のWiFi環境や各自のAWS環境によって変わってくるところになります。AWS関係のところは、こちらでもう少し詳しく書いていますので、必要に応じて併せてご参照ください。