こちらはM5Stack Advent Calendar 2020 9日目の記事です。
昨日は@fukuebizさんによるM5StickVをWiFiに繋げる話でした。M5Stackを他のシリーズの拡張モジュールとして使う工夫が素晴らしいですね!
今回実現したのは、Slackでメッセージを送ったらM5Stackが遠隔操作できるシステムです。以下の構成になります。
実際に動作している様子はこちら
アドベントカレンダーのネタとしてSlackでM5Stack遠隔操作できるようにしてみた。
— 1stship (@1st_ship) December 5, 2020
遠隔操作部分はいつもの通りSORACOM Inventoryとeasi、SlackのOutgoing Webhook、Amazon API Gateway、AWS Lambdaとの組み合わせ。
M5Stack、QRコードとか表示できるんだな。知らなかった。 pic.twitter.com/eDFy62vLkp
はじめに
仕事と趣味でIoTを色々やってまして、M5Stackを重宝しています。M5Stackはケース、表示、通信がそこそこ揃っていて、開発環境も作りやすいので、IoTでちょっと思いついたことを試すのにはとてもいいですよね!
最近だと自分の車をコネクテッドカーにしたりしてます。
クラウドはAWS、回線はSORACOMと組み合わせることが多いです。
この記事ではM5Stackの遠隔操作をできるだけ簡単にやってみようと思います。IoTをやるにおいて、データのアップロードは比較的簡単なのですが、遠隔操作は難しくなりがちなのですが、これが簡単にできるようになればアイディアの幅がすごく広がります。
M5Stackがクラウドから遠隔操作を受け付けられるようにする、という点では、自作の遠隔操作プログラムを作って解決しています。SORACOMアカウント持っていれば、かなり簡単に遠隔操作ができるようになります。(ライトニングトークで実演したことありますが、プログラムのダウンロード、ビルド、書き込み、実際の動作をして遠隔操作するまで2分くらいでした。回線で認証するので認証情報を用意しなくていいのが簡単さの秘訣です)
今回はユーザーインターフェースを改善しようと思います。上記のプログラムはSORACOMのコンソールやWeb API、CLIを使って操作する想定ですが、ちょっと簡単に使いたい時には不便です。Webアプリを作るのも面倒ですし、セキュリティ確保するの大変な感じがしますよね。
そこでSlackを使います。Slackでメッセージを送れば遠隔のM5Stackを遠隔操作できる、というようにできれば、割とだれでも使える感じになるんじゃないかな、と思って作ってみました。
必要なもの
- Slackアカウント
- SORACOMアカウント
- デバイス操作権限を持つSAMユーザー
- 遠隔操作プログラムが書き込まれたM5Stack(通信はSORACOM Air)
- API Gatewayから起動されてSORACOM APIにコマンドを送るLambda
- SlackのWebhookで呼び出されてLambdaを起動するAPI Gateway
- 特定のチャネルにメッセージが投稿されたらAPI Gatewayを呼び出すOutgoing Webhook
Slackアカウント、SORACOMアカウントすでに登録済みとします。
それぞれ準備していきましょう。
SAMユーザー
以下の記事を参考にSAMユーザー(SORACOMを利用するためのユーザー)を作成し、認証キーを作成します。
https://dev.soracom.io/jp/start/sam/
権限はできるだけ狭くした方がよいでしょう。
今回はDevice:readDeviceResource、Device:writeDeviceResourceだけで良いので、以下の権限とします。
{
"statements": [
{
"api": [
"Device:readDeviceResource",
"Device:writeDeviceResource"
],
"effect": "allow"
}
]
}
作成した認証キーはLambdaの環境変数に使用します。
M5Stack
easiのM5Stack用コードをダウンロードして展開し、easi.inoを以下のように修正します。
#include "easi.h"
#define EASI_M5_STACK
#define EASI_VERSION "V1.1.0"
Lwm2m lwm2m;
#include <M5Stack.h>
#define TINY_GSM_MODEM_UBLOX
#include <TinyGsmClient.h>
TinyGsm modem(Serial2);
static char commandResult[1024];
void setup() {
// 初期化
Serial.begin(115200);
M5.begin();
M5.Lcd.clear(BLACK);
M5.Lcd.setTextColor(WHITE);
M5.Lcd.println(F("M5Stack + 3G Module"));
M5.Lcd.print(F("Modem Initialize..."));
Serial2.begin(115200, SERIAL_8N1, 16, 17);
modem.restart();
M5.Lcd.println(F("done"));
M5.Lcd.print(F("Connecting 3G..."));
while (!modem.waitForNetwork()) M5.Lcd.print(".");
M5.Lcd.println(F("done"));
M5.Lcd.print(F("Connecting SORACOM..."));
modem.gprsConnect("soracom.io", "sora", "sora");
M5.Lcd.println(F("done"));
M5.Lcd.print(F("Checking Network..."));
while (!modem.isNetworkConnected()) M5.Lcd.print(".");
M5.Lcd.println(F("OK"));
delay(2000);
// LWM2Mエンドポイントとブートストラップサーバの設定
lwm2mInit(&lwm2m, "m5stack");
udpInit(&lwm2m.bootstrapUdp, "bootstrap.soracom.io", 5683);
// ブートストラップをせず払い出したデバイスIDとキーを使用する場合はこちら
// char identity[] = "d-01234567890123456789";
// uint8 psk[16] = { 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff };
// lwm2mSetSecurityParam(&lwm2m, identity, &psk[0]);
// オブジェクトの設定のサンプル
// 以下のオブジェクトリストのうちID:0 〜 ID:9、ID:3200 〜 3203、ID:3300 〜 3350を登録済み
// http://www.openmobilealliance.org/wp/omna/lwm2m/lwm2mregistry.html
// デバイスオブジェクトをインスタンス0番として登録
addInstance(3, 0);
// Light Controlオブジェクトをインスタンス0番として登録
addInstance(3311, 0);
// Addressable Text Displayオブジェクトをインスタンス0番として登録
addInstance(3341, 0);
// オペレーションとメソッドを対応づけのサンプル
setReadResourceOperation ( 3, 0, 2, &getSerial); // READ /3/0/2 でgetSerialが呼ばれるよう設定
setWriteResourceOperation (3311, 0, 5850, &turnOnOffLight); // WRITE /3311/0/5850 でturnOnOffLightが呼ばれるよう設定
setExecuteResourceOperation( 3, 0, 4, &reboot); // EXECUTE /3/0/3 でrebootが呼ばれるよう設定
setWriteResourceOperation (3341, 0, 5527, &receiveCommand); // WRITE /3341/0/5527 でreceiveCommandが呼ばれるよう設定
setReadResourceOperation (3341, 0, 5527, &getCommandResult); // READ /3341/0/5527 でgetCommandResultが呼ばれるよう設定
// ブートストラップ(接続情報取得)実行
// 成功するまで繰り返す
M5.Lcd.println(F("LwM2M Bootstraping..."));
while (!lwm2mBootstrap(&lwm2m)){ }
M5.Lcd.println(F("OK"));
M5.Lcd.println(F("easi version " EASI_VERSION " is ready to play!"));
}
void loop() {
// LWm2mのイベントが無いかチェックし、イベントを処理したらtrue、イベントが無ければfalseを返す
if (!lwm2mCheckEvent(&lwm2m)){
delay(100);
}
}
// READの場合は値をtlvの各要素に代入する
// Integer / Boolean / Timeはtlv->intValue
// Floatはtlv->floatValue
// Stringはtlv->bytesValue
// Opaqueはバイナリをtlv->bytesValue、長さをtlv->bytesLen
// ObjlnkはオブジェクトIDをtlv->ObjectLinkValue、インスタンスIDをtlv->InstanceLinkValue
// にそれぞれ代入する
void getSerial(Lwm2mTLV *tlv){
strcpy((char *)&tlv->bytesValue[0], "123456789");
};
// WRITEの場合は値がtlvの各要素から渡される
// 対応する要素はREADと同じ
void turnOnOffLight(Lwm2mTLV *tlv){
if (tlv->intValue){
M5.Lcd.fillScreen(TFT_WHITE);
} else {
M5.Lcd.fillScreen(TFT_BLACK);
}
};
// EXECUTEの場合、
// パラメータはOpaqueと同じ形式で渡す(使わなくてもよい)
void reboot(Lwm2mTLV *tlv){
esp_restart();
};
void receiveCommand(Lwm2mTLV *tlv){
if (strncmp((const char*)tlv->bytesValue, "setDisplayRed", 13) == 0){
M5.Lcd.fillScreen(RED);
strcpy(commandResult, "");
} else if (strncmp((const char*)tlv->bytesValue, "setDisplayBlue", 14) == 0){
M5.Lcd.fillScreen(BLUE);
strcpy(commandResult, "");
} else if (strncmp((const char*)tlv->bytesValue, "setDisplayBlack", 15) == 0){
M5.Lcd.fillScreen(BLACK);
strcpy(commandResult, "");
} else if (strncmp((const char*)tlv->bytesValue, "setText:", 8) == 0){
M5.Lcd.clear();
M5.Lcd.setTextSize(3);
M5.Lcd.setCursor(0, 0);
M5.Lcd.println((char *)&tlv->bytesValue[8]);
strcpy(commandResult, "");
} else if (strncmp((const char*)tlv->bytesValue, "setQrcode:", 10) == 0){
M5.Lcd.clear();
M5.Lcd.setTextSize(3);
M5.Lcd.setCursor(0, 0);
M5.Lcd.qrcode((char *)&tlv->bytesValue[8]);
strcpy(commandResult, "");
} else if (strncmp((const char*)tlv->bytesValue, "getTime?", 8) == 0){
sprintf(commandResult, "%lu", millis());
}
};
void getCommandResult(Lwm2mTLV *tlv){
strcpy((char *)&tlv->bytesValue[0], commandResult);
strcpy(commandResult, "");
}
基本は自作の簡単マイコン遠隔操作プログラム「easi」を用いています。
基本的な使い方はこちらの記事に書いています。
https://qiita.com/1stship/items/bdba1a4d9f4e99c45139
コマンドを受け取って処理を実行するreceiveCommandメソッドと、コマンド送信結果を返すgetCommandResultメソッドを追加しています。
receiveCommandメソッド内に、送信されるコマンドに対する処理を書いてます。この部分に遠隔操作でさせたい処理を書いていけば良いです。
SORACOM Inventoryが使っているLwM2Mの仕様では書き込み(コマンド送信)に対する応答に任意のデータを返すことはできないため、送信と結果の受信を別にしています。
これをM5Stackに書き込んで動作させるとSORACOM Inventoryに接続されます。
SORACOMコンソールの[SORACOM Inventory] > [デバイス管理]の画面にて、Endpointがm5stackとなっているデバイスのIDを確認しておきます。あとでLambdaの環境変数に使用します。
今回は3G拡張ボードを使ってSORACOM Air回線を用いています。具体的な使い方はこちらをみると良いでしょう。
https://dev.soracom.io/jp/start/m5stack/
ただしこれは携帯回線を使っているので、そこそこの費用が発生します。遠隔操作に使っているSORACOM Inventoryは事前にデバイスの認証情報を作成してそれをデバイスに書き込むことで、WiFiなどの携帯回線ではない通信でも使うことができます。
その場合は、こちらを参考にしてデバイスのIDとシークレットを生成し、コード内の"ブートストラップをせず払い出したデバイスIDとキーを使用する場合はこちら"の部分に記載してコメントを外し、while (!lwm2mBootstrap(&lwm2m)){ }
の部分をコメントアウトすれば良いです。サンプルコードは割愛します。
https://dev.soracom.io/jp/start/inventory_registration_with_keys/
Lambda
Ruby2.7にてLambda関数を作成します。
(やっていること自体は単なるWebAPIへのアクセスなので、どの言語でも良いです)
以下の4つの環境変数を設定します。
キー | 値 |
---|---|
SLACK_TOKEN | SlackのOutgoing WebhookのToken |
SORACOM_AUTH_KEY_ID | SAMユーザーの認証キーID(keyId-で始まる) |
SORACOM_AUTH_KEY_SECRET | SAMユーザーの認証キーシークレット(secret-で始まる) |
SORACOM_DEVICE_ID | SORACOM InventoryのデバイスID(d-で始まる) |
コードは以下のようになります。
require 'json'
require 'base64'
require 'net/http'
require 'uri'
def lambda_handler(event:, context:)
# 必要な情報が入っていなければエラー
if !event.key?('isBase64Encoded') || !event.key?('isBase64Encoded') || !event.key?('body')
return { statusCode: 400, body: 'Invalid request' }
end
body = event['isBase64Encoded'] ? Base64.decode64(event['body']) : event['body']
data = Hash[URI.decode_www_form(body)]
# 正規のトークンが入ってなければエラー
if !data.key?('token') || data['token'] != ENV['SLACK_TOKEN']
return { statusCode: 401, body: 'Invalid token' }
end
# webhook自身のメッセージは無視(これがないと無限ループする)
if data['user_name'] == 'slackbot'
return { statusCode: 200 }
end
command = data['text']
result = 'OK'
token = get_soracom_token
send_command(token, command)
if command.end_with?('?')
result = get_command_result(token)
end
{ statusCode: 200, body: JSON.generate({text: result}) }
end
def get_soracom_token
uri = URI.parse("https://api.soracom.io/v1/auth")
request = Net::HTTP::Post.new(uri)
request.content_type = "application/json"
request["Accept"] = "application/json"
request.body = JSON.generate({
"authKeyId" => ENV['SORACOM_AUTH_KEY_ID'],
"authKey" => ENV['SORACOM_AUTH_KEY_SECRET']
})
response = Net::HTTP.start(uri.hostname, uri.port, { use_ssl: true } ) do |http|
http.request(request)
end
raise "fail to get soracom token" if response.code != '200'
JSON.parse(response.body)
end
def send_command(token, command)
uri = URI.parse("https://api.soracom.io/v1/devices/#{ENV['SORACOM_DEVICE_ID']}/3341/0/5527")
request = Net::HTTP::Put.new(uri)
request.content_type = "application/json"
request["Accept"] = "application/json"
request["X-Soracom-Api-Key"] = token['apiKey']
request["X-Soracom-Token"] = token['token']
request.body = JSON.generate({"value" => command})
response = Net::HTTP.start(uri.hostname, uri.port, { use_ssl: true } ) do |http|
http.request(request)
end
raise "fail to send command #{response.code} #{response.body}" if response.code[0] != '2'
response.code
end
def get_command_result(token)
uri = URI.parse("https://api.soracom.io/v1/devices/#{ENV['SORACOM_DEVICE_ID']}/3341/0/5527")
request = Net::HTTP::Get.new(uri)
request["Accept"] = "application/json"
request["X-Soracom-Api-Key"] = token['apiKey']
request["X-Soracom-Token"] = token['token']
response = Net::HTTP.start(uri.hostname, uri.port, { use_ssl: true } ) do |http|
http.request(request)
end
raise "fail to get command #{response.code} #{response.body}" if response.code[0] != '2'
JSON.parse(response.body)['value']
end
コードの内容を補足します。
eventにはAPI Gatewayからのイベントが入り、そのbodyの中にSlackからの情報が入っています。メッセージはBase64エンコードされているので、デコードして使用します。Slackのトークンが正しいかどうかを確認して、正規のアクセスかどうかを検証しましょう。
基本的にはコマンドを送るだけですが、末尾に?をつけた場合はクエリとして扱い、実行結果を取得するようにしました。応答を返すとその応答がSlackに投稿されますが、その投稿をトリガーにしてまたWebhookが発生して、その応答で次のWebhookが発生する、という無限ループに陥るため、slackbotからのリクエストであれば空の応答を返すようにします。
M5Stackにコマンドを送って、応答を確認するのにはそこそこ時間がかかりますので、Lambdaの基本設定にてタイムアウト時間を10秒程度に伸ばしておいた方がよいでしょう。
API Gateway
SlackからのLambdaを起動する方法として、今回はOutgoing Webhookを採用します。Webhookを受け取るためにはインターネットからアクセスできるWeb APIサーバーが必要となりますが、仮想サーバー立てて動作させておくとかは面倒なので、Web APIを簡単に使えるサービスであるAmazon API Gatewayを使います。
(本当はセキュリティの観点からインターネットに面したサーバーは用意したくないのですが、SlackからLambdaを直接起動できるいい方法が見つかりませんでした。何かあれば教えていただければ幸いです)
APIタイプはHTTP APIを選ぶと楽です。REST APIでもできますがちょっと面倒です。HTTP APIの中の構築をクリックします。
「統合を追加」をクリックして、統合の入力欄を出します。統合はLambdaを選択し、関数に先ほど作成したLambdaを選択します。APIに適当な名前をつけて次へをクリックします。
メソッドはANYのままでも動きますが、POSTに限定しておいた方が良いでしょう。次へをクリックします。
これでAPIが作成されます。URLはSlackのWebhookを設定する時に使いますので控えておきます。
この程度の作業でもうWebAPI使えるようになるので楽でいいですよね。公開APIになってしまうので、なんらかの認証が必要ですが、このAPIではSlackのトークンを検証することとしています。個人を特定した認証はできませんが、今回くらいの用途だと十分でしょう。
Slack
最後はSlackのOutgoing Webhookを設定します。
Outgoing Webhookは、Slackの指定したチャネルに投稿があった時や、特定の文字列から始まる投稿があった時に、その内容を使って外部のHTTPサーバーにアクセスする、というものです。
Outgoing WebHooksを検索して、追加をクリックします。
Outgoing Webhook インテグレーションの追加をクリックします。
インテグレーションの設定をします。ここに表示されるトークンをコピーして、Lambdaの環境変数SLACK_TOKENに指定する必要があります。
チャンネルには連携したいチャンネルを指定しましょう。
引き金となる言葉は特に指定しなくとも良いです。(コマンドを送る場合はcommandで始める、などのルールを設ければ、不必要なLambda呼び出しを減らすことができます。必要に応じて設定しましょう)
URLはAPI GatewayのURLに、パスを追加したものを指定します。デフォルトではLambdaの関数名になっているはずです。URLのパス部分を確認したい場合は、API Gatewayにてルートのメニューを選択します。
設定項目をしたら、設定を保存するをクリックします。
これですべての設定が終了しました。早速試してみましょう!
動作確認
M5Stackに電源を入れます。電源を入れると自動的に回線接続し、遠隔操作待ち受け状態になります。画面に「easi verion V1.1.0 is ready to play!」と表示されたら準備OKです。
LCDディスプレイの背景色を変えてみましょう。
setDisplayRed、setDisplayBlue、setDisplayBlackを送るとそれぞれの色になります。
ちゃんと色が変わりましたね。次はテキストを表示してみましょう。
setText:Hello from Slack! -> OK
指定したテキストを表示できました。次はM5Stackの情報を取得してみましょう。とりあえず起動してからの経過時間(millis)を取得してみます。
取得もできましたね。今回はやっていませんが、温度センサーをつなげて、今の部屋の温度をSlackで問い合わせる、とかもできると思います。
最後にこの記事のQRコードを表示させてみます。
setQrcode:https://qiita.com/1stship/items/ed5bb152ca0c730a8b94 -> OK
表示できましたね。ちゃんとスマホのQRコードリーダーからも読めました。M5Stackの画面に表示しきれない情報を伝える時とかに使えそうです。
SlackからM5Stackの表示を変えたり情報取得できました!
おわりに
M5Stackのようなデバイスは単体で使っても面白いですが、クラウドと連携させるともっと面白くなります。IoTの醍醐味ですね。
今回はSlackのメッセージを単純にM5Stackに流す構成でしたが、Lambdaに文章の解釈とコマンドへの変換をさせれば、物理デバイスと連携できるbotにもなりそうですね。
また既存のSlackチャネルとつなげて、例えば何かエラー通知が出てたらM5Stackの画面を真っ赤にしてエラー内容を表示させる、とかもできそうです。
色々やってみたいですね。
明日は@cinimlさんです。「M5Atom XVCネタになる気がする。」とのことです。FPGA関連の話かな?お楽しみに!