8
3

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-12-23

前書き

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

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

構成&動作

以下に構成図と簡単な動作を示します。(ちなみに図はhttps://www.diagrams.net/を使って書きました。)
network.png

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

line1.jpg

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

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

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

構築

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

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

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

heroku上のコード

herokuでは、nodejsで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)を使用します。

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は
設定するようにしましょう。
なお、実際の設定値は環境変数に指定しております。
ソースコードに値を直接記載するのは何かの弾みでみられるとまずいので、やめたほうが良いです。

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のサーバ側も決まったトークンをセットしている接続以外は、遮断するようにします。

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には、静的ファイルを置くのではなく、動的に取得した画像を渡すようにします。

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が切断されたらログを出力します。

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が画像を取得しにきた場合は、保存しておいた画像データを送ります。
オリジナルもプレビューも同じデータを送りつけます。
本来は、サイズを分けて、それぞれで対応を変える必要があるはずですが、それでも見れてるのでよしとしています。

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と接続されていない場合は、 画像が取得できないので、エラーメッセージを返します。
  2. LINEからのメッセージタイプがmessageの場合、リビングの現在画像ボタンがついたメッセージを送信します。
  3. LINEからのメッセージタイプがpostbackの場合、送ってきたユーザID,グループIDおよびルームIDをsenderIDsに登録します。画像が取得出来たら、senderIDsに登録されているIDに送信します。
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にありますので、見てください。

raspberry piのコード1

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

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
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にありますので、見てください。

raspberry piのコード2

herokuにWebsocketで接続に行くnode上で動作している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
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-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にありますので、見てください。

総括

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

8
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
3