LINE BOT経由でRaspberry Piに接続したUSBカメラの画像を取得してみる

# 前書き
突然ですが、皆さんの家でRaspberry piは活躍していますか？
我が家ではRaspberry pi2およびpi3をそれぞれ1台持っていますが、SAMBAでのファイル共有でしか使っておらず、家族からは、「あれ必要なの？」とよく言われます。
年間電気代1500円ぐらいながら、すでに幾多のSDカードを買い替えて、そこそこ保守費もかさんでおります。
そこで、何か役に立つことをさせよう。ということで、自分の知識習得もかねて、LINEからリビングにあるRaspberry piのUSBカメラ画像を取得するプログラムを作成しました。

Qiita初投稿となりますので、上手いこと書けるか分かりませんが、ご指摘・ご質問あれば、コメントいただければと思います。

# 構成＆動作
以下に構成図と簡単な動作を示します。（ちなみに図は[https://www.diagrams.net/](https://www.diagrams.net/"https://www.diagrams.net/")を使って書きました。）
![network.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/704447/230e18fb-6c18-d007-4dfe-3e1171c19b41.png)

LINE botがLINE Messageを受信したら、herokuのURLにWebhookで通知します。
herokuでは、node上のJavascriptがLine botからのWebhookを受信したら、以下のメッセージを作成し、いったんユーザに送り返します。

![line1.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/704447/0049b177-ed7e-6c6c-d750-5fba845c7c92.jpeg)

ユーザに届いたLINEメッセージの「リビングの現在画像」をクリックすると、LINE bot経由でメッセージが飛ぶので、
herokuからWebSocketでつながっているRaspberry piのプログラムに、USBカメラの画像要求を送信します。

Dockerのコンテナ上で動作しているNodeで受信して、同じく別Dockerコンテナで動作しているpython3のプログラムにWebAPIで問い合わせて画像を取得します。

あとは、heroku→LINE bot→ユーザの順に画像を送り返していきます。

# 構築

まず、最初に今回はherokuを使用しますので、herokuの登録をします。
herokuの登録ですが、Web上に数多の記事がありますので、そちらを参考に設定ください。
基本、Free Dynosしか使用しないですが、クレジットカードを登録しないと24時間365日の運用は出来ませんので、注意ください。

:::note info
クレジットカード番号を入力して Heroku アカウントを認証すると、無料の dyno 時間が 1000 時間となります（認証を行っていない場合、無料の dyno 時間は 550 時間分となります）
heroku公式サイトより
:::

次にLINE botの設定ですが、これこそ本当にWeb上に数多の記事がありますので、そちらを参考に設定ください。

## heroku上のコード
herokuでは、nodejsでjavascriptを動かします。
以下、コードとなります。

```javascript:heroku_app.js(1)
"use strict";

const express = require("express");
const line = require("@line/bot-sdk");

const app = express();
const server = require("http").Server(app);
const io = require("socket.io")(server);
```
簡単な作りにするために、webサーバにExpressを使用しております。
また、LINEのメッセージの送受信にbot-sdkを使用しています。
Raspberry piと接続をずっと維持するために、WebSocket(実装はsocket.io)を使用します。

```javascript:heroku_app.js(2)
/**
 * LINE CHANNEL SECRET
 */
const LINE_CHANNEL_SECRET = process.env.LINE_CHANNEL_SECRET;

/**
 * LINE CHANNEL ACCESS TOKEN
 */
const LINE_CHANNEL_ACCESS_TOKEN = process.env.LINE_CHANNEL_ACCESS_TOKEN;

const config = {
    channelAccessToken: LINE_CHANNEL_ACCESS_TOKEN,
    channelSecret: LINE_CHANNEL_SECRET,
};

/*
 * LINE BOT client
 */
const client = new line.Client(config);

/*
 * List of requested userID
 */
let senderIDs = [];

/*
 * Original Data of Image
 */
let origData;
```
各種変数を宣言しています。
セキュリティの事もあるので、LINE_CHANNEL_SECRETとLINE_CHANNEL_ACCESS_TOKENは
設定するようにしましょう。
なお、実際の設定値は環境変数に指定しております。
ソースコードに値を直接記載するのは何かの弾みでみられるとまずいので、やめたほうが良いです。

```javascript:heroku_app.js(3)
/*
 set middlewares.
 this middleware drops packet which does not set the token.
 */
io.sockets.use((socket, next) => {
    let token = socket.handshake.query.token;
    if (token == process.env.WEBSOCKET_TOKEN) {
        return next();
    }
    // do not match token
    console.log("authentication error is occured.");
    return next(new Error("authentication error"));
});
```
念のため、websocketのサーバ側も決まったトークンをセットしている接続以外は、遮断するようにします。

```javascript:heroku_app.js(4)
/*
 set callback.
 this callback is called when connection of websocket is requested.
 */
io.sockets.on("connection", (socket) => {
    console.log("connected from" + socket.id);

    socket.on("GET_LIVINGPIC", (data) => {
        // convert picture data
        origData = Buffer.from(data.imgdata, 'base64');
        //fs.writeFileSync("/tmp/aaa.jpg", decode_file);

        // push api message
        senderIDs.forEach((senderID) => {
            client.pushMessage(senderID, {
                type: "image",
                originalContentUrl: process.env.BASEURL + process.env.ORIGFILENAME + ".img",
                previewImageUrl: process.env.BASEURL + process.env.PREVFILENAME + ".img",
            });

            // If process.env.ownerID is defined, send messages.
            if (typeof process.env.ownerID !== 'undefined' && process.env.ownerID != senderID) {
                let dName = ""
                // get user profile
                if (senderID != null) {
                    client.getProfile(senderID).then((profileData) => {
                        dName = profileData.displayName;
                    });
                }

                // send message to notify
                client.pushMessage(process.env.ownerID, {
                    type: "text",
                    text: "リビングの画像が" + dName + "(" + senderID + ")によって取得されました。",
                });
            }
        });
        // delete all elements of senderIDs
        senderIDs.splice(0);
    });
});
```
長いですが、コメントを読んでいただければ大体わかるかと思います。
raspberry piでとった写真は、base64形式にしてから、websocketで送ってきます。
それをBase64から画像フォーマットに戻して変数に保存しておき、「リビングの現在画像」ボタンを押してきたユーザ達に対して、画像のURLを送りつけます。本来は、フルサイズとプレビューサイズの2種類でわかる必要がありますが、面倒ですので両方同じURLを指定しています。
その際に、もし、ownerIDにユーザIDを登録しておけば、誰に対して画像を送信したかの情報をownerIDに登録した管理者に通知するようにしています。
なお、画像のURLには、静的ファイルを置くのではなく、動的に取得した画像を渡すようにします。


```javascript:heroku_app.js(5)
/*
 set callback.
 this callback is called when disconnection of websocket is requested.
 */
io.sockets.on("disconnection", (socket) => {
    console.log("disconnected");
});
```
raspberry piからのwebsocketsが切断されたらログを出力します。

```javascript:heroku_app.js(6)
/*
 * function is called when image files requests.
 */
app.get("/" + process.env.ORIGFILENAME + ".img", (req, res) => {
    // send living pic data
    res.send(origData)
});

/*
 * function is called when image files requests.
 */
app.get("/" + process.env.PREVFILENAME + ".img", (req, res) => {
    // send living pic data
    res.send(origData)
});
```
LINEに渡した画像URLから、LINEが画像を取得しにきた場合は、保存しておいた画像データを送ります。
オリジナルもプレビューも同じデータを送りつけます。
本来は、サイズを分けて、それぞれで対応を変える必要があるはずですが、それでも見れてるのでよしとしています。

```javascript:heroku_app.js(7)
/*
 * function is called when line message is received from LINE.
 */
app.post("/callback", line.middleware(config), (req, res) => {
    console.log(req.body.events);
    Promise.all(req.body.events.map(handleEvent)).then((result) =>
        res.json(result)
    );
});

/**
 * handler called when line message is received.
 */
function handleEvent(event) {

    // If websocket's connection is none, return error message
    if (io.engine.clientsCount == 0) {
        return client.replyMessage(event.replyToken, {
            type: "text",
            text: "Websocketが接続されていません。",
        });
    } else {
        // type is message
        if (event.type == "message") {
            // return button template
            return client.replyMessage(event.replyToken, {
                type: "template",
                altText: "This is a buttons template",
                template: {
                    type: "buttons",
                    title: "お願いしたいこと",
                    text: "アクションを選択してください。",
                    actions: [
                        {
                            "type": "postback",
                            "label": "リビングの現在画像",
                            "data": "action=getpic"
                        },
                    ]
                }
            });
        }
        // type is postback
        else if (event.type == "postback") {
            // get sender ID
            if (event.source.type == "user") {
                console.log("user " + event.source.userId + "request living pic.");
                // if userID is not included in senderIDs, userID is added.
                if (!senderIDs.includes(event.source.userId)) {
                    senderIDs.push(event.source.userId + "");
                }

            } else if (event.source.type == "group") {
                console.log("group " + event.source.groupId + " " + event.source.userId + "request living pic.");
                // if groupId is not included in senderIDs, groupId is added.
                if (!senderIDs.includes(event.source.groupId)) {
                    senderIDs.push(event.source.groupId + "");
                }
            } else if (event.source.type == "room") {
                console.log("room " + event.source.roomId + " " + event.source.userId + "request living pic.");
                // if roomId is not included in senderIDs, roomId is added.
                if (!senderIDs.includes(event.source.roomId)) {
                    senderIDs.push(event.source.roomId + "");
                }
            }

            // send message to socket.io clients
            io.sockets.emit("GET_LIVINGPIC");
        }
        else {
            // receive only text message or postback
            return Promise.resolve(null);
        }
    }
}
```
LINEからメッセージを取得した際の処理です。
大きく処理を3つに分けています。

1. WebSocketが接続されていない、つまりRaspberry piと接続されていない場合は、
   画像が取得できないので、エラーメッセージを返します。
1. LINEからのメッセージタイプがmessageの場合、リビングの現在画像ボタンがついたメッセージを送信します。
1. LINEからのメッセージタイプがpostbackの場合、送ってきたユーザID,グループIDおよびルームIDをsenderIDsに登録します。画像が取得出来たら、senderIDsに登録されているIDに送信します。

```javascript:heroku_app.js(8)
// heroku assign process.env.PORT dynamiclly.
server.listen(process.env.PORT);
console.log(`Server running at ${process.env.PORT}`);
```
環境変数に指定したポートで待ち受けます。

上記のソースコードでは、以下の環境変数を使用しております。
- BASEURL・・・https://XXXXXXXX.herokuapp.com/ のような感じで。
- LINE_CHANNEL_ACCESS_TOKEN・・・LINE Developers consoleに記載されています。
- LINE_CHANNEL_SECRET・・・LINE Developers consoleに記載されています。
- ORIGFILENAME・・・適当な乱数をセットしてください。（40桁ぐらいにしてます。）
- PREVFILENAME・・・適当な乱数をセットしてください。（40桁ぐらいにしてます。）
- WEBSOCKET_TOKEN・・・適当な乱数をセットしてください。（20桁ぐらいにしてます。）

herokuに上げるProcfileやpackage.jsonファイルを含めて、[tvca-line-bot](https://github.com/TVCAdev/tvca-line-bot/tree/first_QIITA "tvca-line-bot")にありますので、見てください。

## raspberry piのコード1
USB Cameraから写真を撮るコードです。
WEB API部分を簡単に実装するために、Flaskを使用してpython3で記述しています。

```python:camera.py
#!/usr/bin/python3
import cv2
import base64
from flask import Flask, jsonify

app = Flask(__name__)


@app.route('/GET_LIVINGPIC', methods=["GET"])
def livingpic_get():
    # capture /dev/video?
    cap = cv2.VideoCapture(0)

    # if fail, return 500 error.
    if cap.isOpened() is False:
        return "IO Error", 500
    else:
        # format is MJPG
        cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc('M', 'J', 'P', 'G'))
        # Size is 1280x960
        cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
        cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 960)

        # read the frame
        ret, frame = cap.read()

        if (ret == True):
            cap.release()

            # encode to jpg format
            ret, buffer = cv2.imencode('.jpg', frame)
            if (ret == True):
                # encode to base64 format for send on json format
                b64_data = base64.b64encode(buffer)
                return jsonify({'imgdata': b64_data}), 200
            else:
                # error for encoding to jpg
                return "Encode(JPG) Error", 500
        else:
            # frame read error
            cap.release()
            return "Read Error", 500


if __name__ == "__main__":
    app.run(debug=True, host='0.0.0.0', port=5000)
```
カメラ画像の取得にOpenCVを使用しています。
取得した画像を送付するために、Base64形式に変換して送ります。

また、マイクロサービスが良いらしい（？）ので、1サービスづつ、dockerの中に閉じ込めるようにします。

```dockerfile:Dockerfile
FROM python:latest

ENV TOP_DIR /cam_webapi

RUN mkdir ${TOP_DIR}

COPY camera.py ${TOP_DIR}

WORKDIR ${TOP_DIR}
RUN apt-get update && apt-get install -y \
    v4l-utils \
    libopencv-dev \
    python3-opencv \
    python3-flask

CMD ["/usr/bin/python3","camera.py"]
```

Dockerfileとcamera.pyファイルを含めて、[cam_webapi](https://github.com/TVCAdev/cam_webapi/tree/first_QIITA "cam_webapi")にありますので、見てください。


## raspberry piのコード2
herokuにWebsocketで接続に行くnode上で動作しているjavascriptのコードです。

```javascript:tvca_router.js
"use strict";

const fs = require("fs")

const axios = require("axios");
const io_client = require("socket.io-client");

// connect to heroku using websocket
const socket_client = io_client(process.env.LINEBOT_URL, {
    query: {
        token: process.env.WEBSOCKET_TOKEN,
    },
});

// websocket message receive
socket_client.on("GET_LIVINGPIC", () => {
    console.log("GET_LIVINGPIC was accept...");

    axios({
        method: 'get',
        url: process.env.WEBAPI_POST_MSG_URL + "GET_LIVINGPIC",
    })
        .then(function (response) {
            // case of getting camera data successfully
            if (typeof response !== "undefined" && response.status == 200) {
                socket_client.emit("GET_LIVINGPIC", response.data);

                // for debug code
                //let decode_file = Buffer.from(response.data.imgdata, 'base64');
                //fs.writeFileSync("aaa.jpg", decode_file);
            } else if (typeof response === "undefined") {
                console.log('GET_LIVINGPIC: error - response is undefined.');
            } else if (typeof response.status === "undefined") {
                console.log('GET_LIVINGPIC: error - response.status is undefined.');
            } else {
                console.log('GET_LIVINGPIC: error - status is ' + response.status);
            }
        })
        .catch(function (error) {
            if (error.response) {
                // The request was made and the server responded with a status code
                // that falls out of the range of 2xx
                console.log(error.response.data);
                console.log(error.response.status);
                console.log(error.response.headers);
            } else if (error.request) {
                // The request was made but no response was received
                // `error.request` is an instance of XMLHttpRequest in the browser and an instance of
                // http.ClientRequest in node.js
                console.log(error.request);
            } else {
                // Something happened in setting up the request that triggered an Error
                console.log('Error', error.message);
            }
            console.log(error.config);
        });
});
```
コードはそこそこ長いですが、ほとんどがエラー処理ですので、非常に簡単です。
herouk側と常に通信路を確保していくために、Websocketを使用して、接続しています。
カメラ画像の取得命令が届いたら、カメラ画像を取得するWEB APIを実行します。なお、WEB APIの呼び出しなどで検索するとよくヒットするrequestはもうdeprecatedとのことでしたので、axiosを使っています。

上記のソースコードでは、以下の環境変数を使用しております。
- LINEBOT_URL・・・https://XXXXXXXX.herokuapp.com/ のような感じで。
- WEBAPI_POST_MSG_URL・・・次で説明するサービスのURL。
- WEBSOCKET_TOKEN・・・適当な乱数をセットしてください。（20桁ぐらいにしてます。）

以下のDockerfileでDockerイメージを作成しています。

```dockerfile:Dockerfile
FROM node:latest

ENV TOP_DIR /TVCArouter

RUN mkdir ${TOP_DIR}

COPY tvca_router.js ${TOP_DIR}
COPY package.json ${TOP_DIR}
COPY package-lock.json ${TOP_DIR}

WORKDIR ${TOP_DIR}
RUN npm install

CMD ["/usr/local/bin/node","tvca_router.js"]
```

最後にDocker-composeで2つのDockerイメージを制御します。

```docker:docker-compose.yml
version: '3'

services:
  tvca_router:
    image: docker_tvca_router:latest
    restart: always
    networks:
      - docker_net
    environment:
      WEBSOCKET_TOKEN: $WEBSOCKET_TOKEN
      LINEBOT_URL: $LINEBOT_URL
      WEBAPI_POST_MSG_URL: http://cam_webapi:5000/

  cam_webapi:
    image: docker_cam_webapi:latest
    restart: always
    networks:
      - docker_net
    devices:
      - "/dev/video0:/dev/video0"

networks:
  docker_net:
    driver: bridge
    ipam:
      driver: default
```

Docker内のネットワークを作成し、それに各Dockerイメージを所属させます。
Docker-composeで起動した場合、Service名がアドレスに割り当たるとのことで、環境変数WEBAPI_POST_MSG_URLは、Service名を使用したURL形式に変更しています。

Dockerfileやpackage.jsonファイルを含めて、[TVCArouter](https://github.com/TVCAdev/TVCArouter/tree/first_QIITA "TVCArouter")にありますので、見てください。

# 総括
ちょっとしたプログラムを作成しようとしただけだが、色々な知らない技術を勉強することになり、時間がかかった。
これで、少しでも我が家のRaspberry piの株が上がれば良いのだが・・・