line_image

はじめに

この記事はフィヨルドブートキャンプ Advent Calendar 2020の 21 日目の記事です。

@shimewtrと申します。
普段は Ruby on Rails を用いてスマートフォンアプリのバックエンド開発をしています。

世界中のエンジニアは外出するとき、極力持ち物を減らしたいと思っていますよね?
もちろん私も思っています。
家の鍵にスマートロックの SESAME をつけてスマートフォンから鍵の解錠・施錠をできるようにしています。
鍵やかばんといった不要なものを持たず、スマートフォンだけを持って外出することを心がけています。

SESAME には大変お世話になっているのですが公式アプリの挙動に不満があります…。
公式アプリは鍵の状態確認が長く、接続するまでに時間がかかるため、家の扉の前で接続を待って立ちすくむことが何度もありました。
(家に近づくと Wi-Fi を拾ってしまい、接続し直すということもあります…。)

SESAME の APIで鍵を操作するとステータス確認をせず直接鍵の操作を行えるため、API から操作して不満を解消しようと考えました。

つくったもの

構成の概要は以下です。

line_image

  1. LINE 上のボタンをタップ
  2. Webhook で Lambda を発火
  3. SESAME API を叩く
  4. 結果を Lambda に返す
  5. 結果に応じて LINE で返信するテキストを返す
  6. LINE でメッセージを返す

我が家は私と妻の二人暮らしで、妻は IT に明るくないです。
アプリの追加や複雑な操作を必要とせず、日頃から使っている LINEから鍵の操作を行えるようにしました。

↓の画像のように LINE Messaging API を用いるとボタン上の UI を簡単に作れます。
(下部の鍵のアイコンがそれぞれボタンになっています。)

line_image

また、LINE からのメッセージを受け取り、SESAME の API を叩く処理は Lambda で実装しています。API Gateway で LINE からの Webhook の受け口を簡単に用意でき、十分な無料枠があり、費用をかけずに実装できるためです。

api_gateway_lambda

LINEの設定

LINE Developersのサイトから登録しましょう。
LINE のアカウントを持っていればすぐに登録できます。

line_login

登録が済んだら LINE の API を使えるように設定をしていきます。
Create a new provider を選択し、必要事項を入力後、

line_provider

Create a Messaging API channel を選択して再度必要事項を入力すると自分の channel を開設できます。

line_messaging_api

Messaging API タブに表示される ID で検索するか、QR コードから友達追加できます。

line_add_friend

同じく、Messaging API タブにある Webhook settings から Webhook の送り先の URL を設定できます。
メッセージの送信に対するイベントはもちろん、友達追加、友達解除など多数のイベントをトリガーに Webhook が送信されます。
詳しくはこちらをご確認ください。

今回は Lambda で LINE からの Webhook を受けて処理を行うため、Lambda に追加した API ゲートウェイのエンドポイントを設定しています。

line_webhook

受け取った Webhook イベントに対して返信をする際などに、 Messaging API タブの最下部にある Channel access token が必要です。
トークンを発行してメモしておきましょう。

line_access_token

LINE のトーク画面下部のメニューは LINE Official Account Manager から設定できます。
ボタンとして表示するテキストや画像、ボタンタップ時のアクションなどを設定できます。
今回は鍵が開いた画像をタップすると「鍵あけて」、鍵が閉まった画像をタップすると「鍵しめて」というテキストを送信するようにしています。
ここで設定したテキストに応じて Lambda で処理を行います。

line_rich_menu

コード

Lambda で実行しているスクリプトは以下です。
以下の環境変数を設定しています。

  • AUTH_TOKEN
    • SESAME API のアクセストークン
  • KEY_TOKENS
    • SESAME の ID
    • 我が家では 2 つの SESAME を使用しているため 2 つ設定
  • LINE_CHANNEL_ACCESS_TOKEN
    • LINE の Channel access token
  • AUTHORIZED_USERS
    • LINE のユーザー ID
    • 私と妻の 2 人分設定
import json
import os
import random
import requests
import sys
import time
from linebot import LineBotApi
from linebot.models import TextSendMessage

AUTH_TOKEN = os.environ['AUTH_TOKEN']
KEY_TOKENS = [os.environ['KEY_TOKEN_1'], os.environ['KEY_TOKEN_2']]
LINE_CHANNEL_ACCESS_TOKEN = os.environ['LINE_CHANNEL_ACCESS_TOKEN']
AUTHORIZED_USERS = [os.environ['USER_ID_1'], os.environ['USER_ID_2']]


class LinkingLineSesame():
    HEADERS = {
        'Authorization': AUTH_TOKEN,
        'Content-Type': 'application/json'
    }

    SESAME_API_URI = 'https://api.candyhouse.co/public/'

    SESAME_COMMAND = {
        "鍵あけて": {
            "send_message": "鍵をあけました",
            "command": '{"command":"unlock"}',
        },
        "鍵しめて": {
            "send_message": "鍵をしめました",
            "command": '{"command":"lock"}',
        },
    }

    UNAUTHORIZED_MESSAGES = [
        'ユーザー認証に失敗しました',
    ]

    def __init__(self, event):
        body = json.loads(event['body'])
        self.text = body['events'][0]['message']['text']
        self.user_id = body['events'][0]['source']['userId']
        self.reply_token = body['events'][0]['replyToken']
        self.sesame_command = self.SESAME_COMMAND.get(self.text)

        if not self.user_id in AUTHORIZED_USERS:
            self.send_message = random.choice(self.UNAUTHORIZED_MESSAGES)
        elif bool(self.sesame_command):
            self.send_message = self.sesame_command.get("send_message")
            self.command = self.sesame_command.get("command")
            self.control_sesame()
        else:
            self.send_message = "鍵を操作するにはメニューのボタンをタップしてください。"
        self.reply_message()

    def control_sesame(self):
        task_ids = self.post_sesame_control()
        self.check_sesame_status(task_ids)

    def post_sesame_control(self):
        task_ids = []

        try:
            for key_token in KEY_TOKENS:
                uri = self.SESAME_API_URI + 'sesame/{}'.format(key_token)
                res = requests.post(uri, headers=self.HEADERS, data=self.command)
                task_ids.append(json.loads(res.text)['task_id'])
        except requests.RequestException as e:
            print(e)
            self.send_message = '鍵の動作中にエラーが発生しました。'

        return task_ids

    def check_sesame_status(self, task_ids):
        if len(task_ids) < len(KEY_TOKENS):
            self.send_message = '鍵が正しく作動していません'
            return None
        try:
            attempts_num = 3
            for i in range(attempts_num):
                time.sleep(5)
                for task_id in task_ids:
                    api_uri = 'https://api.candyhouse.co/public/action-result?task_id={}'.format(task_id)
                    res = requests.get(api_uri, headers=self.HEADERS)
                    res = json.loads(res.text)
                    if res['status'] == "terminated" and res["successful"] == True:
                        task_ids = [i for i in task_ids if i == task_id]
                if len(task_ids) == 0:
                    break
                elif i == attempts_num - 1:
                    self.send_message = '鍵のステータスが正しく取得できません。'
        except requests.RequestException as e:
            print(e)
            self.send_message = '鍵のステータス取得中にエラーが発生しました。'

    def reply_message(self):
        line_bot_api = LineBotApi(LINE_CHANNEL_ACCESS_TOKEN)
        line_bot_api.reply_message(
            self.reply_token, TextSendMessage(text=self.send_message))


def lambda_handler(event, context):
    print("Received event: " + json.dumps(event, indent=2))
    LinkingLineSesame(event)
    return 'finished'

ユーザーの判定

        if not self.user_id in AUTHORIZED_USERS:
            self.send_message = random.choice(self.UNAUTHORIZED_MESSAGES)

ユーザーの認証を上記で行っています。
環境変数に設定したユーザー ID 以外のユーザーからのメッセージの場合、UNAUTHORIZED_MESSAGESで設定したメッセージを送信して処理を終えます。
LINE Channel はプライベートにできないため(やり方を知っている方がいれば教えて下さい)、 ID がわかれば誰でも友達登録できます。
他の人が我が家の鍵を解錠・施錠できないようにユーザー ID で判断しています。

また、API Gateway の設定で LINE からのリクエスト以外は許可しない設定をし、Lambda を無駄に実行しない設定をすると安心です。

SESAMEの操作

    def control_sesame(self):
        task_ids = self.post_sesame_control()
        self.check_sesame_status(task_ids)

    def post_sesame_control(self):
        task_ids = []

        try:
            for key_token in KEY_TOKENS:
                uri = self.SESAME_API_URI + 'sesame/{}'.format(key_token)
                res = requests.post(uri, headers=self.HEADERS, data=self.command)
                task_ids.append(json.loads(res.text)['task_id'])
        except requests.RequestException as e:
            print(e)
            self.send_message = '鍵の動作中にエラーが発生しました。'

        return task_ids

SESAME の操作は上記で行っています。
2 つの鍵に対して、送信されたテキストに応じて鍵を操作しています。
鍵を操作する API のレスポンスは操作するコマンドを送信できたか否かしかわからないため、実際に鍵の操作を正しく行えたかは別の API を叩く必要があります。

鍵の状態確認

    def check_sesame_status(self, task_ids):
        if len(task_ids) < len(KEY_TOKENS):
            self.send_message = '鍵が正しく作動していません'
            return None
        try:
            attempts_num = 3
            for i in range(attempts_num):
                time.sleep(5)
                for task_id in task_ids:
                    api_uri = 'https://api.candyhouse.co/public/action-result?task_id={}'.format(task_id)
                    res = requests.get(api_uri, headers=self.HEADERS)
                    res = json.loads(res.text)
                    if res['status'] == "terminated" and res["successful"] == True:
                        task_ids = [i for i in task_ids if i == task_id]
                if len(task_ids) == 0:
                    break
                elif i == attempts_num - 1:
                    self.send_message = '鍵のステータスが正しく取得できません。'
        except requests.RequestException as e:
            print(e)
            self.send_message = '鍵のステータス取得中にエラーが発生しました。'

上記がステータスを確認している部分です。
鍵を操作する API を叩いてから実際に鍵が動作を終えるまではタイムラグがあるため、一定時間ごとにコマンドの実行状況を確認し、正常に動作が終了したかを判定しています。

まとめ

LINE から家の鍵の解錠・施錠ができるようになりました。
これまでは接続に時間がかかり家の前で待ちぼうけになっていましたが、LINE を起動してワンタップで鍵の解錠・施錠を行えるため非常に快適です。
ちょっとした不便をプログラミングの力で解決できるのでぜひ試してみてください!

ご意見やご感想は@shimewtrまでお願いいたします。