MicroPython版の M5Stack Avatar を作ってみた

 以前の記事で MicroPython のスレッドを使ってみたときに、簡単にアバター表示をしてみました。

blog.akanumahiroaki.com

Arduino 版のアバターは @meganetaaan さんが公開されていて、こちらを参考にさせていただいています。

github.com

 前回はとりあえず自分のところで表示させるために実装してましたが、その時のアバターをもう少し使いやすくして、とは言えだいぶ雑ではありますが GitHub に載せてみました。

github.com

機能

 今のところはまだ大した機能はなく、下記のみとなっています。

  • アバターの顔表示&瞬き

  • アバターが話している風にテキストをスクロール表示する

  • 何か気づいた風にエクスクラメーションマークを表示する

  • 青ざめた感じの表情にする

 アバター表示と瞬きはそれぞれスレッドを使用して実行しています。それ以外は単純にLCDに描画しているだけです。

使い方

 使い方は単純で、 m5stack_avatar.py をダウンロードし、 main.py 等から import します。今のところは特に依存している外部ライブラリはないので、 M5Stack での開発ができる環境ができていればその他には必要ありません。

 初期化とアバターの表示までは下記のようにします。

from m5stack_avatar import M5StackAvatar

avatar = M5StackAvatar()
avatar.start()

 喋っている風にテキストをスクロール表示するには speak() メソッドを使用します。

avatar.speak('Hello from M5StackAvatarPython!!')

f:id:akanuma-hiroaki:20181124215207j:plain:w500

 エクスクラメーションを ON/OFF するには exclamation_on()/exclamation_off() メソッドを使用します。

avatar.exclamation_on()
avatar.exclamation_off()

f:id:akanuma-hiroaki:20181124215233j:plain:w500

 青ざめた感じで顔に縦線表示するのは pale_on()/pale_off() メソッドを使います。

avatar.pale_on()
avatar.pale_off()

f:id:akanuma-hiroaki:20181124215303j:plain:w500

使用例

簡単な使用例として、上記機能をループで順番に繰り返す処理は下記のようになります。

from m5stack_avatar import M5StackAvatar

import time

avatar = M5StackAvatar()
avatar.start()

while True:
    avatar.speak('Hello from M5StackAvatarPython!!')
    time.sleep(10)
    avatar.exclamation_on()
    time.sleep(5)
    avatar.exclamation_off()
    time.sleep(5)
    avatar.pale_on()
    time.sleep(5)
    avatar.pale_off()
    time.sleep(5)

 これを実行した様子は下記のような感じになります。

まとめ

ある程度汎用的に使えるようにしようと思うとなかなか難しいところも多いですね。あと太めの斜線を描画しようと思うと簡単にはいかなかったりと色々とありますが、まずは不十分でも公開してみようということで、今後機能追加や改善していけると良いなと思っています。改善点のご指摘等ありましたらぜひいただければと思います。

M5Stack で Google Calendar のスケジュールを表示する(MicroPython)

 M5Stack でスケジュール管理に役立つ機能が実装できないかなと思い、 Google Calendar に登録しているスケジュールを表示させてみました。

Google Calendar API の利用設定

 まずは Google Calendar API を利用できるように設定する必要があります。GCP のコンソールから API とサービスを追加 をクリックします。

f:id:akanuma-hiroaki:20181117143616p:plain

 API のリストの中から Google Calendar API をクリックします。

f:id:akanuma-hiroaki:20181117143733p:plain

 API の詳細ページで 有効にする をクリックして API を使える状態にします。

f:id:akanuma-hiroaki:20181117143844p:plain

 次に API の認証情報を作成します。左メニューから 認証情報 をクリックします。

f:id:akanuma-hiroaki:20181117143949p:plain

 認証情報の種別を選択するプルダウンで サービスアカウントキー を選択します。

f:id:akanuma-hiroaki:20181117144053p:plain

 サービスアカウント名には任意の名前を設定します。役割については今回は参照だけできれば良いので、 閲覧者 を設定しました。サービスアカウント ID はサービスアカウント名から自動的に設定されます。キーのタイプはデフォルトが JSON になっているのでそのままにしておきます。最後に 作成 をすると認証情報が作成され、ダウンロードできるようになりますので、ローカルに取得しておきます。

f:id:akanuma-hiroaki:20181117161048p:plain

Google Calendar の共有設定

 次に先ほど作成したサービスアカウントからカレンダーを参照できるように、共有設定を行います。共有するカレンダーの共有設定画面から ユーザーの追加 をクリックします。

f:id:akanuma-hiroaki:20181117144504p:plain

 作成したサービスアカウントのサービスアカウント ID を設定します。権限は閲覧権限のみにしておきます。設定したら 送信 をクリックします。

f:id:akanuma-hiroaki:20181117144710p:plain

AWS Lambda の関数作成

 Google Calendar 側の設定はここまでで完了なので、次に AWS Lambda の関数を作成します。関数の作成画面で任意の関数名を指定します。ランタイムは今回は Python 3.6 を使用しています。

f:id:akanuma-hiroaki:20181117144835p:plain

 認証情報はコードの中に極力書きたくないので、 Lambda の実装画面で環境変数にサービスアカウントのキー ID を設定しておきます。

f:id:akanuma-hiroaki:20181117144930p:plain

 Google Calendar API を使うために Google が提供しているクライアントモジュールを使用します。 Lambda で外部モジュールを使用するにはローカルで zip に固めたものをアップロードする形になります。まずはローカルのプロジェクト用ディレクトリで pip を使用して google-api-python-client と oauth2client をインストールします。

$ pip3 install --upgrade google-api-python-client oauth2client -t ./

 下記のようなファイルがインストールされます。 Google Calendar API の認証情報作成時にダウンロードした認証情報ファイルも google_key.json として同じディレクトリに置いておきます。

$ ls -l
total 112
drwxr-xr-x   4 akanuma  staff    128 Nov 17 12:39 __pycache__
drwxr-xr-x   4 akanuma  staff    128 Nov 17 12:39 apiclient
drwxr-xr-x  12 akanuma  staff    384 Nov 17 12:39 cachetools
drwxr-xr-x   9 akanuma  staff    288 Nov 17 12:39 cachetools-3.0.0.dist-info
drwxr-xr-x   4 akanuma  staff    128 Nov 17 12:39 google
drwxr-xr-x   7 akanuma  staff    224 Nov 17 12:39 google_api_python_client-1.7.4.dist-info
-rw-r--r--   1 akanuma  staff    539 Nov 17 12:39 google_auth-1.6.1-py3.7-nspkg.pth
drwxr-xr-x   8 akanuma  staff    256 Nov 17 12:39 google_auth-1.6.1.dist-info
drwxr-xr-x   9 akanuma  staff    288 Nov 17 12:39 google_auth_httplib2-0.0.3.dist-info
-rw-r--r--   1 akanuma  staff   8434 Nov 17 12:39 google_auth_httplib2.py
-rw-r--r--@  1 akanuma  staff   2345 Nov 17 11:24 google_key.json
drwxr-xr-x  15 akanuma  staff    480 Nov 17 12:39 googleapiclient
drwxr-xr-x   8 akanuma  staff    256 Nov 17 12:39 httplib2
drwxr-xr-x   7 akanuma  staff    224 Nov 17 12:39 httplib2-0.12.0-py3.6.egg-info
-rw-r--r--   1 akanuma  staff    625 Nov 17 12:37 lambda_function.py
drwxr-xr-x  17 akanuma  staff    544 Nov 17 12:39 oauth2client
drwxr-xr-x   7 akanuma  staff    224 Nov 17 12:39 oauth2client-4.1.3.dist-info
drwxr-xr-x   9 akanuma  staff    288 Nov 17 12:39 pyasn1
drwxr-xr-x  11 akanuma  staff    352 Nov 17 12:39 pyasn1-0.4.4.dist-info
drwxr-xr-x  31 akanuma  staff    992 Nov 17 12:39 pyasn1_modules
drwxr-xr-x  11 akanuma  staff    352 Nov 17 12:39 pyasn1_modules-0.2.2.dist-info
drwxr-xr-x  19 akanuma  staff    608 Nov 17 12:39 rsa
drwxr-xr-x  11 akanuma  staff    352 Nov 17 12:39 rsa-4.0.dist-info
drwxr-xr-x   9 akanuma  staff    288 Nov 17 12:39 six-1.11.0.dist-info
-rw-r--r--   1 akanuma  staff  30888 Nov 17 12:39 six.py
drwxr-xr-x   7 akanuma  staff    224 Nov 17 12:39 uritemplate
drwxr-xr-x   9 akanuma  staff    288 Nov 17 12:39 uritemplate-3.0.0.dist-info

 この中で *.dist-info は不要なので削除しておきます。

$ rm -rf *.dist-info 

 削除後のリストは下記のようになります。

$ ls -l
total 112
drwxr-xr-x   4 akanuma  staff    128 Nov 17 12:39 __pycache__
drwxr-xr-x   4 akanuma  staff    128 Nov 17 12:39 apiclient
drwxr-xr-x  12 akanuma  staff    384 Nov 17 12:39 cachetools
drwxr-xr-x   4 akanuma  staff    128 Nov 17 12:39 google
-rw-r--r--   1 akanuma  staff    539 Nov 17 12:39 google_auth-1.6.1-py3.7-nspkg.pth
-rw-r--r--   1 akanuma  staff   8434 Nov 17 12:39 google_auth_httplib2.py
-rw-r--r--@  1 akanuma  staff   2345 Nov 17 11:24 google_key.json
drwxr-xr-x  15 akanuma  staff    480 Nov 17 12:39 googleapiclient
drwxr-xr-x   8 akanuma  staff    256 Nov 17 12:39 httplib2
drwxr-xr-x   7 akanuma  staff    224 Nov 17 12:39 httplib2-0.12.0-py3.6.egg-info
-rw-r--r--   1 akanuma  staff    625 Nov 17 12:37 lambda_function.py
drwxr-xr-x  17 akanuma  staff    544 Nov 17 12:39 oauth2client
drwxr-xr-x   9 akanuma  staff    288 Nov 17 12:39 pyasn1
drwxr-xr-x  31 akanuma  staff    992 Nov 17 12:39 pyasn1_modules
drwxr-xr-x  19 akanuma  staff    608 Nov 17 12:39 rsa
-rw-r--r--   1 akanuma  staff  30888 Nov 17 12:39 six.py
drwxr-xr-x   7 akanuma  staff    224 Nov 17 12:39 uritemplate

 これを zip に圧縮しておきます。

$ zip -r google_calendar_m5stack.zip ./*

 圧縮した zip ファイルを Lambda のコンソールからアップロードします。

 メインの関数(lambda_function.py)の内容は下記のようにしました。クラスの初期化時に認証情報を取得し、 get_schedules() メソッドで Google Calendar API をコールしてスケジュールの情報を取得しています。今回はとりあえず直近5件のスケジュールの開始日時、終了日時とサマリだけ使用しています。

from dateutil.parser import parse
from apiclient import discovery
from oauth2client.service_account import ServiceAccountCredentials
import datetime
import httplib2
import json
import logging
import os

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

class GoogleCalendar:
    def __init__(self):
        self.service_account_id = os.environ['GOOGLE_SERVICE_ACCOUNT_ID']
        scopes = 'https://www.googleapis.com/auth/calendar.readonly'
        
        self.credentials = ServiceAccountCredentials.from_json_keyfile_name(
            'google_key.json',
            scopes = scopes
        )
        
        self.calendar_id = 'XXXXXXXXXXXXXXX@gmail.com'
        self.max_results = 5

    def get_schedules(self):
        http = self.credentials.authorize(httplib2.Http())
        service = discovery.build('calendar', 'v3', http = http)
        
        now = datetime.datetime.utcnow().isoformat() + 'Z'
        
        events_result = service.events().list(
            calendarId   = self.calendar_id,
            timeMin      = now,
            maxResults   = self.max_results,
            singleEvents = True,
            orderBy      = 'startTime'
        ).execute()
        
        events = events_result.get('items', [])
        
        if not events:
            logger.info('No upcoming events found.')
        
        schedules = []
        for event in events:
            start   = event['start'].get('dateTime', event['start'].get('date'))
            end     = event['end'].get('dateTime', event['end'].get('date'))
            summary = event['summary']
            schedules.append({
                'start':   parse(start).strftime('%Y/%m/%d %H:%M:%S'),
                'end':     parse(end).strftime('%Y/%m/%d %H:%M:%S'),
                'summary': summary
            })

        return schedules

def lambda_handler(event, context):
    calendar = GoogleCalendar()
    schedules = calendar.get_schedules()
    
    return {
        'statusCode': 200,
        'body': json.dumps({'schedules': schedules})
    }

API Gateway の設定

 Lambda の関数が作成できたので、次に関数を API として実行できるように、 API Gateway の設定を行います。 AWS Lambda の実装画面でトリガーの追加メニューから API Gateway を選択します。

f:id:akanuma-hiroaki:20181117145413p:plain

 トリガーの設定フォームで API Gateway の設定が行えます。新規の API を作成するか既存の API から選択するかを選べますので、今回は 新規 API の作成 を選択します。セキュリティでは API キー使用でのオープン を選択して、 API キーで認証するようにしておきます。

f:id:akanuma-hiroaki:20181117145549p:plain

 下記のように未保存の状態で API が作成されますので、 Lambda コンソールの 保存 ボタンをクリックして設定を保存します。

f:id:akanuma-hiroaki:20181117145636p:plain

 すると実際に API が作成され、下記のように API の情報が表示されます。下記画像は色々とマスクしてあります。

f:id:akanuma-hiroaki:20181117145914p:plain

 試しに API キーなしで API にアクセスしてみると、 Forbidden となりアクセスが拒否されます。

$ curl https://XXXXXXXXXX.XXXXXXXXXXX.ap-northeast-1.amazonaws.com/default/googleCalendarM5Stack
{"message":"Forbidden"}

 API キーを Header に設定してリクエストを投げると下記のように情報を取得することができます。

$ curl https://XXXXXXXXXX.XXXXXXXXXXX.ap-northeast-1.amazonaws.com/default/googleCalendarM5Stack --header 'x-api-key:EnXb6oRB957iVzKXXXXXXXXXXXXXXXXXXXXXXXXX'
{"schedules": [{"start": "2018-11-18", "end": "2018-11-19", "summary": "\u6771\u4eac\u30aa\u30d5\u30a3\u30b9\u79fb\u8ee2"}, {"start": "2018-11-18", "end": "2018-11-19", "summary": "\u30bf\u30c3\u30d7\u516c\u6f14\u30ea\u30cf"}, {"start": "2018-11-22", "end": "2018-11-23", "summary": "SORACOM Technology Camp 2018"}, {"start": "2018-11-22T13:30:00+09:00", "end": "2018-11-22T19:30:00+09:00", "summary": "SORACOM Technology Camp 2018"}, {"start": "2018-11-23T12:00:00+09:00", "end": "2018-11-23T22:00:00+09:00", "summary": "TAP\u516c\u6f14\u30ea\u30cf"}]}

M5Stack のファームウェア実装(MicroPython)

 では最後に M5Stack 側の実装です。コードの全体は下記の通りです。主な処理は Lambda 側でやっているので、 M5Stack 側では単純に API を呼んで結果をループで表示しているだけのものになります。

from m5stack import lcd

import time
import ujson
import urequests

class GoogleCalendar:
    def __init__(self):
        self.base_url = 'https://XXXXXXXXXX.XXXXXXXXXXX.ap-northeast-1.amazonaws.com/default/googleCalendarM5Stack'
        self.api_key = 'EnXb6oRB957iVzKXXXXXXXXXXXXXXXXXXXXXXXXX'

        lcd.setCursor(0, 0)
        lcd.setColor(lcd.WHITE)
        lcd.font(lcd.FONT_DejaVu18)
        self.fw, self.fh = lcd.fontSize()

    def get_schedules(self):
        headers = {'x-api-key': self.api_key}
        response = urequests.get(self.base_url, headers = headers)
        json = response.json()
        return json['schedules']

    def display(self, schedules):
        lcd.clear()
        lcd.setCursor(0, 0)
        for schedule in schedules:
            print(schedule)
            lcd.println("{}".format(schedule['start']))
            lcd.println(" {}".format(schedule['summary']))

calendar = GoogleCalendar()
while True:
    schedules = calendar.get_schedules()
    calendar.display(schedules)
    time.sleep(60)

動作確認

 実行した結果は下記のようになります。表示は適当ですが、とりあえず Google Calendar の情報を M5Stack に表示することができました。ただ、日本語はそのままでは表示できませんので、テスト用のスケジュールを英語で登録して表示してみました。

f:id:akanuma-hiroaki:20181117155257j:plain

まとめ

 今回ひとまず Google Calendar から情報が取得できるようになりました。実際に使用するには表示を見やすく工夫したり、エラーハンドリングなどももっとちゃんと作りこむ必要がありますが、スケジュールの情報が使えると色々やれそうな気がします。また、今回 Lambda を経由して Google Calendar にアクセスすることで、 Google Calendar の認証情報は Lambda 側に保持し、デバイス上には持たせない構成になっているのはセキュリティ面では良い点かと思います。

 今回 Google Calendar API の使い方については下記チュートリアルを参考にしました。

Python Quickstart  |  Calendar API  |  Google Developers

 また、 Lambda で外部モジュールを使う方法については下記サイトを参考にさせていただきました。

qiita.com

 Lambda から Google Calendar API を利用する方法については下記サイトを参考にさせていただいています。

www.yamamanx.com

SORACOM LTE-M Button で SMS 送信

 2018年10月下旬に販売開始された SORACOM LTE-M Button が 11月に入って出荷開始されました。

blog.soracom.jp

 ボタンの機能等についてはオフィシャルサイト等参照いただくとして割愛しますが、私も購入して出荷開始後にすぐ届いたので、チュートリアル的にSMS送信までをとりあえずやってみたので、PCでの設定手順を書いてみます。

f:id:akanuma-hiroaki:20181105075002j:plain:w400

デバイス登録

 まずは AWS IoT 1-Click に購入したボタンを登録します。 AWS IoT 1-Click のコンソールから デバイスの登録 をクリックします。

f:id:akanuma-hiroaki:20181105075232p:plain

 登録コードもしくはデバイスIDの登録フォームが表示されますので、 SORACOM LTE-M Button の場合はデバイスIDを入力します。デバイスIDはボタンの裏蓋を外した右下に QR コードと一緒に記載されています。入力したらフォーム右下の 登録 ボタンをクリックします。

f:id:akanuma-hiroaki:20181105075415p:plain

 この時コンソールにログインしているAWSアカウントに IoT 1-Click に関する権限が不足していると、フォーム右上に下記のように「Errors.General.UnknownWithDSN」というエラーが表示されます。

f:id:akanuma-hiroaki:20181105080023p:plain

 ちなみにスマートフォンアプリでの登録時にも権限が不足していればやはりエラーになります。こちらの方がエラーメッセージの内容はわかりやすく、「iot1click:InitiateDeviceClaim」という権限がないというエラーが表示されます。

f:id:akanuma-hiroaki:20181105080312p:plain:w400

 本当は各操作の権限を必要最低限で付与するべきですが、今回はとりあえずお試しということで、 「AWSIoT1ClickFullAccess」というポリシーを追加して、 IoT 1-Click に関する全ての権限を付与します。

f:id:akanuma-hiroaki:20181105080442p:plain

 権限付与後に再度デバイスIDを入力して 登録 ボタンをクリックすると、下記のようにボタンのクリックを待機する画面になります。

f:id:akanuma-hiroaki:20181105080637p:plain

 ここで実際に LTE-M Button をクリックすると、下記画面のように表示が変わり、デバイスが登録されます。

f:id:akanuma-hiroaki:20181105080659p:plain

 完了 ボタンをクリックすると、下記のようにデバイス一覧画面で登録したデバイスが確認できます。

f:id:akanuma-hiroaki:20181105080815p:plain

 デバイスID部分をクリックすると、下記のようにデバイスについての詳細が表示されます。デバイス登録時はデフォルトでは「無効」状態になっているので、 アクション から デバイスの有効化 をクリックして有効にします。

f:id:akanuma-hiroaki:20181106082605p:plain

プロジェクトの作成

 デバイスが登録できたので次はプロジェクトを作成します。プロジェクトとは自分の理解では、ボタンクリック時の動作を定義して、同様の処理を行いたいデバイスをグルーピングするためのもので、今回であれば、「ボタンをクリックしたら SMS を送信するという処理を定義して、その動作をさせたいデバイスを紐づけるためのもの」という事かと思います。

 プロジェクトを作成するにはプロジェクト画面で プロジェクトの作成 ボタンをクリックします。

f:id:akanuma-hiroaki:20181105081019p:plain

 プロジェクト名を入力して 次へ ボタンをクリックします。

f:id:akanuma-hiroaki:20181105081142p:plain

 ボタンクリック時の動作を定義するために、 デバイステンプレートの定義 をクリックします。

f:id:akanuma-hiroaki:20181105081248p:plain

 テンプレートのデバイスタイプとしては現状では すべてのボタンタイプ だけが表示されているのでこれをクリックします。

f:id:akanuma-hiroaki:20181105081337p:plain

 テンプレートが表示され、アクションはデフォルトでは「SMS を送信する」が選択されていますので、デバイステンプレート名を考えて入力します。

 アクションには「SMS を送信する」以外には「E メールを送信する」、「Lambda 関数の選択」という選択肢があります。 SMS ではなく E メールを送信するというだけであれば「E メールを送信する」を選択し、独自の処理を行わせたい時には「Lambda 関数の選択」を選択して、独自に実装した Lambda 関数を呼び出すように設定します。今回は「SMS を送信する」ケースを試します。

 テンプレートを設定したらプレイスメントの属性を設定します。プレイスメントとは、各デバイスが個別にもつ属性値になります。ここではこのテンプレートでのプレイスメントの属性のデフォルト値を設定します。各デバイスでプレイスメントが設定されなかった場合はこのデフォルト値が使われることになります。 SMS 送信のケースでは、電話番号とメッセージのデフォルト属性を設定します。

 最後に プロジェクトの作成 ボタンをクリックするとプロジェクトが作成されます。

f:id:akanuma-hiroaki:20181105081452p:plain

プレイスメントの作成

 デバイスの登録、プロジェクトの作成まで終わったので、最後に登録済みのデバイスをプロジェクトに紐付けます。 プレイスメントの作成 ボタンをクリックします。

f:id:akanuma-hiroaki:20181105081547p:plain

 プレイスメントの作成フォームが表示されますので、まず個別のデバイスを表すプレイスメント名を考えて入力します。

 次に デバイスの選択 をクリックして、登録済みのデバイスの中から今回紐づけるデバイスを選択します。下記画像はデバイス選択済みの状態です。

 最後にこのデバイス固有のプレイスメントとして SMS のメッセージの内容と電話番号を入力したら プレイスメントの作成 をクリックして完了です。

f:id:akanuma-hiroaki:20181105081846p:plain

SMS 送信実行

 ここまでで一通りの設定は完了なので、 LTE-M Button のボタンをクリックして動作を確認します。シングルクリック、ダブルクリック、長押しのそれぞれで下記のような SMS が送信されました。メインのメッセージはプレイスメントで設定したものですが、クリックタイプを示す内容も含まれています。

f:id:akanuma-hiroaki:20181105082508p:plain

f:id:akanuma-hiroaki:20181105082522p:plain

f:id:akanuma-hiroaki:20181105082535p:plain

まとめ

 権限が不足していた部分だけはちょっと手間取りましたが、それ以外は特にプログラミングすることもなく、コンソールから設定するだけで SMS 送信が実行できるようになってしまいました。 IoT 1-Click ではスマートフォンアプリも提供されていて、そちらでも一通りの設定が行えるので、 SMS 送信だけであれば PC すらなくてもボタンを利用することができてしまいます。 LTE-M なので Wi-Fi 設定も必要なく、ここまで手軽に使い始められてしまうのはすごいですね。機能としても3種類のクリックタイプのみというのがシンプルで良いです。今回はテンプレートの SMS 送信だけでしたが、 Lambda を実装すればアイディア次第で色んなことができるので、面白いことができないか考えてみたいと思います。

M5Stack をシリアル接続してデバッグする(MicroPython)

 最近は M5Stack を主に m5cloud を使って MicroPython で色々と試しています。 m5cloud での開発は、 M5Stack が Wi-Fi に繋がっていればPCと直接接続しなくても良いというのもメリットの一つだと思うのですが、その分デバッグはしづらいところがあります。ちょっとした確認であれば lcd.print() 等で画面に出力して確認することもできるのですが、 SyntaxError などでそもそもちゃんと動かない時には起動時に下記のような画面で止まってしまい、原因が何なのかがわかりません。

f:id:akanuma-hiroaki:20181103120541j:plain

 なのでやはりデバッグするにはシリアルケーブルで接続して、出力を確認しながらするのが効率的です。

シリアル接続

 私の Mac 環境では M5Stack を USB ケーブルで接続すると、下記のようなデバイスとして認識されます。

$ ls -l /dev/tty.SLAB_USBtoUART 
crw-rw-rw-  1 root  wheel   21,  24 Nov  2 08:27 /dev/tty.SLAB_USBtoUART

 screen コマンドでデバイスに接続します。ボーレートは 115,200 を指定します。

$ screen /dev/tty.SLAB_USBtoUART 115200

シリアル出力確認

 シリアル接続した状態で M5Stack を再起動すると、下記のようにシリアルコンソールに情報が出力されます。

[M5Cloud] Downloading:/flash/main.py  ......
[M5Cloud] Downloading:/flash/README.md  .
ets Jun  8 2016 00:22:57

rst:0xc (SW_CPU_RESET),boot:0x17 (SPI_FAST_FLASH_BOOT)
configsip: 0, SPIWP:0xee
clk_drv:0x00,q_drv:0x00,d_drv:0x00,cs0_drv:0x00,hd_drv:0x00,wp_drv:0x00
mode:DIO, clock div:2
load:0x3fff0018,len:4
load:0x3fff001c,len:4636
load:0x40078000,len:0
load:0x40078000,len:12948
entry 0x4007852c

Internal FS (SPIFFS): Mounted on partition 'internalfs' [size: 2424832; Flash address: 0x1B0000]
----------------
Filesystem size: 2221568 B
           Used: 56320 B
           Free: 2165248 B
----------------

Device ID:807d3ac471bc
Connect WiFi: SSID:XXXXXXXXXXXXXXXX PASSWD:XXXXXXXX network...
....
Connected. Network config: ('172.20.10.12', '255.255.255.240', '172.20.10.1', '172.20.10.1')
[M5-807d3ac471bc] M5Cloud connected.

FreeRTOS running on BOTH CORES, MicroPython task started on App Core (1).

 Reset reason: Soft CPU reset
    uPY stack: 19456 bytes
     uPY heap: 80000/19280/60720 bytes

MicroPython ESP32_LoBo_v3.2.16 - 2018-05-15 on M5Stack with ESP32
Type "help()" for more information.
>>>

 上記の例はエラーなく正常に起動できたケースですが、例えば SyntaxError があると上記の出力の途中に下記のようにエラーメッセージが出力されます。これでエラーの原因や場所が特定できます。

Traceback (most recent call last):
  File "main.py", line 11
SyntaxError: invalid syntax

 また、print デバッグを行いたいときは、 print() を使用すればその引数がシリアルコンソールに出力されます。例えば下記のように API のレスポンスの内容を print() で出力するようにしてみます。

response      = urequests.get(self.base_url.format(prefecture, self.api_key))
json          = response.json()
main          = json['main']
print(main)

 これを実行すると下記のように API のレスポンスがシリアルコンソールに出力されて行きます。

{'pressure': 1025, 'humidity': 54, 'temp_min': 283.15, 'temp_max': 287.15, 'temp': 284.75}
{'pressure': 1027, 'humidity': 74, 'temp_min': 283.15, 'temp_max': 287.15, 'temp': 284.81}
{'pressure': 1025, 'humidity': 54, 'temp_min': 288.15, 'temp_max': 288.15, 'temp': 288.15}
{'pressure': 1025, 'humidity': 54, 'temp_min': 283.15, 'temp_max': 287.15, 'temp': 284.75}
{'pressure': 1027, 'humidity': 74, 'temp_min': 283.15, 'temp_max': 287.15, 'temp': 284.81}

MicroPython のインタラクティブ実行

 正しく起動していれば、起動時の出力が終わると下記のように MicroPython のインタラクティブシェルが起動しますので、ここでインタラクティブの MicroPython を実行して動作を確認することができます。

MicroPython ESP32_LoBo_v3.2.16 - 2018-05-15 on M5Stack with ESP32
Type "help()" for more information.
>>> import time
>>> time.gmtime()
(1970, 1, 1, 0, 22, 50, 5, 1)
>>> 

 Python にはあっても MicroPython にはないクラスやメソッドもありますので、インタラクティブシェルで実際のボード上での挙動が確認できるのは便利ですね。

まとめ

 直接ケーブルで Mac と接続する必要はあるものの、上記のような情報なしで開発していくのは非効率なので、開発中はシリアルコンソールの利用が必須かと思います。今のところは m5cloud で開発をしていますが、バージョン管理等ができない不便さはあるので、開発もローカルで行ってシリアル接続で転送するやり方も検討してみようかと思います。

M5Stack Gray の MPU9250 の値を MicroPython で読み取る

 M5Stack Gray には MPU9250 が搭載されていて、加速度、ジャイロ、磁気を計測することができますが、買ってすぐに Arduino のサンプルスケッチを動かしてみただけだったので、 MicroPython で値を読み出してみました。

www.switch-science.com

MPU9250 モジュール

 M5Stack の MicroPython では MPU9250 モジュールが組み込まれているので、 import するだけで使えるようになっています。モジュールの内容は下記で参照することができます。

github.com

 MPU9250 は MPU6500(加速度、ジャイロ)+ AK8963(磁気) の組み合わせになっているようで、それぞれを明示的に初期化して MPU9250 の初期化時に指定することもできるようになっています。今回は磁気センサーの値から方位を計算することをしてみようとしたのですが、 AK8963 のキャリブレーションについてはメソッドが用意されているわけでもなさそうで、方法が調べ切れなかったので、ひとまず値をそのまま読み出すところまでにしています。

サンプルコード

 今回実装したサンプルは下記のような内容になります。 MPU9250 は I2C 接続になっていて、 SDA が 21番ピン、 SCL が 22 番ピンになっているようなので、 I2C のインスタンス作成時にそのピンを指定し、 MPU9250 のインスタンス作成時に I2C のインスタンスを渡しています。あとはそのインスタンスから acceleration gyro magnetic でそれぞれの値がタプルで返ってくるので、それをバラして画面表示させています。

from m5stack import lcd
from machine import I2C, Pin
from mpu9250 import MPU9250
import time

i2c = I2C(sda = 21, scl = 22)
sensor = MPU9250(i2c)

lcd.clear()
lcd.setCursor(0, 0)
lcd.setColor(lcd.WHITE)

fw, fh = lcd.fontSize()

lcd.print('Acceleration', 0, 0)
lcd.print('Gyro', 0, fh * 4)
lcd.print('Magnetic', 0, fh * 8)

while True:
    ax, ay, az = sensor.acceleration
    gx, gy, gz = sensor.gyro
    mx, my, mz = sensor.magnetic
    
    lcd.print(' ax: {:+.5f}'.format(ax), 0, fh * 1)
    lcd.print(' ay: {:+.5f}'.format(ay), 0, fh * 2)
    lcd.print(' az: {:+.5f}'.format(az), 0, fh * 3)
    
    lcd.print(' gx: {:+.5f}'.format(gx), 0, fh * 5)
    lcd.print(' gy: {:+.5f}'.format(gy), 0, fh * 6)
    lcd.print(' gz: {:+.5f}'.format(gz), 0, fh * 7)
    
    lcd.print(' mx: {:+.5f}'.format(mx), 0, fh * 9)
    lcd.print(' my: {:+.5f}'.format(my), 0, fh * 10)
    lcd.print(' mz: {:+.5f}'.format(mz), 0, fh * 11)
    
    time.sleep_ms(20)

 これを実行すると下記のような表示になります。

f:id:akanuma-hiroaki:20181027233113j:plain

 公式のサンプルも下記に公開されています。

https://github.com/m5stack/M5Cloud/blob/master/examples/mpu9250/basic/main.py

まとめ

 今回はとりあえずセンサーの値をそのまま読み出すだけでしたが、これらのセンサーの値はこのままでは意味がなく、計算して方位などがわかるようにしたかったのですが、そのためにはキャリブレーションが必要になってくるので、今後その方法も調べて、アバター等と組み合わせて有効に使えるようにしていきたいと思います。

M5Stack UI Flow で画像表示(v0.8.0)

 M5Stack UI Flow の v0.8.0 がリリースされて、簡単に画像が表示できるようになったようなので、試してみました。 公式のツイートはこちら。

 UI Flow の基本的な環境設定についてはこちらもどうぞ。

blog.akanumahiroaki.com

画像のアップロード

 まずは表示したい画像ファイルをアップロードします。 v0.8.0 では画面右上にファイルをアップロードするためのメニューが追加されていますので、これをクリックします。

f:id:akanuma-hiroaki:20181020201534p:plain

 すると下記のダイアログが表示されますので、 Images を選択します。 Blocklys の方は特にファイルのアップロードができるようにはなっていないようなので、今後機能が追加されていくのかもしれません。 Images の方ではすでにアップロード済みのファイルがあればリストが表示されます。アップロードしたファイルは画面をリロードしたり、ブラウザを閉じて再度アクセスした時にも保存されているようです。新しい画像ファイルをアップロードするには、 Add Image ボタンをクリックして、ローカルのファイルを選択してアップロードします。

f:id:akanuma-hiroaki:20181020202043p:plain

 ちなみにアップロードできる画像ファイルは JPEG のみで、25KB以下のものに制限されています。また、ファイル名も10文字以下という制約があります。

f:id:akanuma-hiroaki:20181020202626p:plain

f:id:akanuma-hiroaki:20181020202639p:plain

画像の配置

 画像がアップロードできたら次は画面に画像を配置します。 v0.8.0 では画面のコンポーネントに画像ファイルが追加されていますので、これをドラッグ&ドロップで配置します。

f:id:akanuma-hiroaki:20181020203354p:plain

 配置したモジュールを選択するとプロパティが表示されますので、 imgName のプロパティでアップロード済みの画像ファイルの中から表示したいものを選択します。

f:id:akanuma-hiroaki:20181020203736p:plain

 この状態で実行すると、下記画像のように画像ファイルが表示されます。 UI Flow の画面上で配置した画像コンポーネントには実際の画像サイズは反映されないので、実際の表示は実行して確認する必要があります。

f:id:akanuma-hiroaki:20181020204801j:plain

 他のコンポーネントと組み合わせて表示させれば、簡単に画面を構成することができます。ちなみに v0.8.0 ではラベルのフォントが選択できるようになっています。ラベルのプロパティにフォントのプロパティが追加されていますので、使用したいフォントを選択します。

f:id:akanuma-hiroaki:20181020204518p:plain

 上記の内容で実行した様子は下記画像のようになります。

f:id:akanuma-hiroaki:20181020204824j:plain

 とりあえず画像を表示することはできましたが、確認した限りではロジックの中で扱うことはまだできないようなので、静的な表示に限定されそうです。

m5cloud の場合

 ちなみに m5cloud で同様に画像を表示するとしたら、画像ファイルを m5cloud のメニューからアップロードした上で、下記のようなコードを実行すると大体同じような表示をすることができます。タイトルやラベルの表示はちょっと面倒ですが、画像の配置については lcd.image() で lcd.CENTER や lcd.BOTTOM などの指定をすることができるので、 UI Flow よりも位置が調整しやすいですし、ロジックの中に組み込んで使うことができますので、まだまだこちらの方が実用的ですね。

from m5stack import lcd

lcd.clear()
lcd.setCursor(0, 0)
lcd.setColor(lcd.WHITE, lcd.BLUE)

fw, fh = lcd.fontSize()
ww, wh = lcd.winsize()

lcd.rect(0, 0, ww, fh + 1, lcd.BLUE, lcd.BLUE)
lcd.println("Photo Album")

lcd.font(lcd.FONT_DejaVu24)
lcd.setColor(lcd.WHITE, lcd.BLACK)
lcd.print('My Cat', 10, 30)

lcd.image(lcd.CENTER, lcd.BOTTOM, 'IMG_s.JPG')

まとめ

 UI Flow v0.8.0 で画像を扱うことができるようになり、画像を表示するだけであればコードを書かずに実現できるようになりましたが、まだ静的な表示に限られるので使用用途は限られますね。ただ UI Flow は短いスパンでどんどんアップデートされてきているので、画像についても今後様々な使い方ができるようになってくるかと思います。Remote Config 等と組み合わせられると面白い気もするので、今後の機能追加に期待したいですね。

M5Stack で MicroPython のスレッドを使う

 前回は M5Stack でテキストを簡易的にスクロール表示させる処理を実装してみましたが、画面の下端にテキストをスクロール表示させつつ、残りの部分に何かを表示するにはスレッドを使った処理が必要かと思ったので、今回は M5Stack の _thread モジュールを使った処理を実装してみました。

 _thread モジュールについては下記サイトを参考にさせていただきました。

qiita.com

 また、 M5Stack の github リポジトリにもサンプルが公開されていました。

github.com

サンプル実装

 まずは _thread モジュールがちゃんと使えることを確認するために、ごく簡単なサンプルを実装してみます。下記のコードではテキストを表示する2つのスレッドを生成し、違う間隔でテキストの表示を行います。 _thread.start_new_thread() でメソッドを指定してスレッドを生成しています。

from m5stack import lcd

import time
import _thread

lcd.clear()
lcd.setCursor(0, 0)
lcd.setColor(lcd.WHITE)

def hello():
    while True:
        time.sleep(3)
        lcd.println("Hello World! from: {}".format(_thread.getSelfName()))

def goodby():
    while True:
        time.sleep(5)
        lcd.println("Goodby! from: {}".format(_thread.getSelfName()))
    
_thread.start_new_thread('hello', hello, ())
_thread.start_new_thread('goodby', goodby, ())

 これを実行すると下記の動画のようになります。とりあえず各スレッドでの処理が行われていることが確認できます。

天気情報+アバター表示

 それでは次は前回の天気情報のスクロール表示にアバター表示を組み合わせて、天気情報をしゃべっているような表示を実装してみたいと思います。M5Stack でのアバター表示は @meganetaaan さんが m5stack-avatar を公開されていますので、 Arduino 環境であればこちらを使うのが良いかと思います。

github.com

 検索してみた限りではまだ MicroPython 版のアバター表示ライブラリは内容でしたので、自前で簡易に表示させてみたいと思います。画面イメージは下記画像の通りです。

f:id:akanuma-hiroaki:20181013223503j:plain

 まずはコード全体を掲載しておきます。

from m5stack import lcd

import random
import time
import ujson
import urequests
import _thread

class Weather:
    def __init__(self):
        self.base_url    = 'http://api.openweathermap.org/data/2.5/weather?q={},jp&appid={}'
        self.api_key     = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
        self.prefectures = ['Tokyo', 'Saitama', 'Nagoya']

    def get_weather(self, prefecture):
        response      = urequests.get(self.base_url.format(prefecture, self.api_key))
        json          = response.json()
        main          = json['main']
        self.temp     = main['temp']
        self.pressure = main['pressure']
        self.humidity = main['humidity']
        self.temp_min = main['temp_min']
        self.temp_max = main['temp_max']

    def text(self, prefecture):
        return "[{}] temp: {} pressure: {} humidity: {} temp_min: {} temp_max: {}".format(
            prefecture, self.temp, self.pressure, self.humidity, self.temp_min, self.temp_max
        )

class Face:
    def __init__(self, ww, wh, fw, fh):
        self.ww                 = ww
        self.wh                 = wh
        self.fw                 = fw
        self.fh                 = fh
        self.eye_x              = 90
        self.eye_y              = 80
        self.eye_r              = 10
        self.eye_close_x        = 70
        self.eye_close_width    = 40
        self.eye_close_height   = 5
        self.blink_term_ms      = 500
        self.mouth_x            = 135
        self.mouth_y            = 150
        self.mouth_width        = 50
        self.mouth_height       = 5
        self.mouth_close        = True
        self.mouth_close_height = 20
        
        self.spaces = ' '
        while lcd.textWidth(self.spaces) < self.ww:
            self.spaces += ' '

    def blink(self):
        while True:
            self.eye_close()
            time.sleep_ms(self.blink_term_ms)
            self.eye_open()
            time.sleep(random.randint(2, 6))

    def eye_close(self):
        lcd.circle(self.eye_x, self.eye_y, self.eye_r, lcd.BLACK, lcd.BLACK)
        lcd.circle(self.ww - self.eye_x, self.eye_y, self.eye_r, lcd.BLACK, lcd.BLACK)
        lcd.rect(self.eye_close_x, self.eye_y, self.eye_close_width, self.eye_close_height, lcd.WHITE, lcd.WHITE)
        lcd.rect(
            self.ww - self.eye_close_x - self.eye_close_width,
            self.eye_y, self.eye_close_width,
            self.eye_close_height,
            lcd.WHITE,
            lcd.WHITE
        )

    def eye_open(self):
        lcd.rect(self.eye_close_x, self.eye_y, self.eye_close_width, self.eye_close_height, lcd.BLACK, lcd.BLACK)
        lcd.rect(
            self.ww - self.eye_close_x - self.eye_close_width,
            self.eye_y,
            self.eye_close_width,
            self.eye_close_height,
            lcd.BLACK,
            lcd.BLACK
        )
        lcd.circle(self.eye_x, self.eye_y, self.eye_r, lcd.WHITE, lcd.WHITE)
        lcd.circle(self.ww - self.eye_x, self.eye_y, self.eye_r, lcd.WHITE, lcd.WHITE)
        
    def lipsync(self):
        if self.mouth_close:
            self.lip_open()
        else:
            self.lip_close()

    def lip_close(self):
        lcd.rect(
            self.mouth_x,
            self.mouth_y - (self.mouth_close_height // 2),
            self.mouth_width,
            self.mouth_height + self.mouth_close_height, 
            lcd.BLACK,
            lcd.BLACK
        )
        lcd.rect(self.mouth_x, self.mouth_y, self.mouth_width, self.mouth_height, lcd.WHITE, lcd.WHITE)
        self.mouth_close = True

    def lip_open(self):
        lcd.rect(self.mouth_x, self.mouth_y, self.mouth_width, self.mouth_height, lcd.BLACK, lcd.BLACK)
        lcd.rect(
            self.mouth_x,
            self.mouth_y - (self.mouth_close_height // 2),
            self.mouth_width,
            self.mouth_height + self.mouth_close_height,
            lcd.WHITE,
            lcd.WHITE
        )
        self.mouth_close = False

    def speak(self, text):
        lcd.textClear(0, (self.wh - self.fh) - 1, self.spaces)
        lcd.print(text, 0, (self.wh - self.fh) - 1)
        time.sleep_ms(3000)
        while lcd.textWidth(text) > 0:
            text = text[1:]
            lcd.textClear(0, (self.wh - self.fh) - 1, self.spaces)
            lcd.print(text, 0, (self.wh - self.fh) - 1)
            self.lipsync()
            time.sleep_ms(200)
        self.lip_close()

    def mouth(self):
        lcd.rect(self.mouth_x, self.mouth_y, self.mouth_width, self.mouth_height, lcd.WHITE, lcd.WHITE)
        while True:
            typ, sender, msg = _thread.getmsg()
            if msg:
                self.speak(msg)
            time.sleep_ms(200)

    def display(self):
        _thread.start_new_thread('eye', self.blink, ())
        self.mouth_thread_id = _thread.start_new_thread('mouth', self.mouth, ())
        while True:
            typ, sender, msg = _thread.getmsg()
            if msg:
                _thread.sendmsg(self.mouth_thread_id, msg)
            time.sleep_ms(200)

def display_weather(face_thread_id):
    weather = Weather()
    while True:
        for prefecture in weather.prefectures:
            weather.get_weather(prefecture)
            text = weather.text(prefecture)
            _thread.sendmsg(face_thread_id, text)
            time.sleep(30)

lcd.setCursor(0, 0)
lcd.setColor(lcd.WHITE)
lcd.font(lcd.FONT_DejaVu24)
lcd.clear()

fw, fh = lcd.fontSize()
ww, wh = lcd.winsize()

face = Face(ww, wh, fw, fh)

face_thread_id = _thread.start_new_thread('face', face.display, ())
_thread.start_new_thread('weather', display_weather, (face_thread_id,))

 Weather クラスの内容は前回と同様です。

 Face クラスでは顔の表示と瞬き表示と、口パクとともにテキストをスクロール表示する処理を行います。

 処理の流れとしては、 Face クラスのインスタンスを生成し、その display() メソッドを実行するスレッドを開始します。 display() メソッドではさらに口の動きと独立して瞬き表示を行うためのスレッドと、口の処理を行うスレッドを開始します。

 続いて天気情報を表示するためのスレッドを開始し、メソッドの引数には上記の display() メソッドを実行しているスレッドのIDを渡します。天気情報を取得してテキストを生成したら、そのスレッドIDを指定してテキストを _thread.sendmsg() メソッドでメッセージとして送信します。

 display() メソッドではメッセージを受け取ったらそのメッセージをさらに口の動きを処理しているスレッドに通知し、受け取った側の mouth() メソッドでテキストの表示と口パクの処理を行なっています。口パクの表示ではひとまず今回は1文字スクロールする度に口の開閉を切り替えるようにしてみました。

 目や口の開閉の切り替えは、調べた感じでは描画したオブジェクトを消すという処理はなさそうだったので、背景色と同じ色で描画し直すことで見えなくしてから切り替え後の状態を描画するようにしています。

 図形の表示については下記に API の説明が記載されています。

github.com

動作確認

 上記のコードを実行すると下記の動画のようになります。

 テキストの内容が天気情報なので微妙なところはありますが、それでも口パクがつくだけでアバターが喋ってるような感じに見えますね。 

まとめ

 スレッド処理はそんなに詳しいわけではないので、細かいことはあまり考慮していなかったり、まだまだ実装も適当ですが、とりあえず動かすことができました。ただ、うまく使っていかないと実装がどんどん複雑になっていってしまいそうですし、デバッグ等も難しくなりそうなので、極力シンプルに最低限で使うようにすべきかなと思います。アバター表示はそれっぽいのもができたので、もっと実用的な機能や面白い機能も追加していってアシスタントっぽくできると面白そうかなと考えています。