前書き
突然ですが、皆さんの家でRaspberry piは活躍していますか?
我が家ではRaspberry pi2およびpi3をそれぞれ1台持っていますが、SAMBAでのファイル共有でしか使っておらず、家族からは、「あれ必要なの?」とよく言われます。
年間電気代1500円ぐらいながら、すでに幾多のSDカードを買い替えて、そこそこ保守費もかさんでおります。
そこで、何か役に立つことをさせよう。ということで、自分の知識習得もかねて、LINEからリビングにあるRaspberry piのUSBカメラ画像を取得するプログラムを作成しました。
Qiita初投稿となりますので、上手いこと書けるか分かりませんが、ご指摘・ご質問あれば、コメントいただければと思います。
構成&動作
以下に構成図と簡単な動作を示します。(ちなみに図はhttps://www.diagrams.net/を使って書きました。)
LINE botがLINE Messageを受信したら、herokuのURLにWebhookで通知します。
herokuでは、node上のJavascriptがLine botからのWebhookを受信したら、以下のメッセージを作成し、いったんユーザに送り返します。
ユーザに届いた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を動かします。
以下、コードとなります。
"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)を使用します。
/**
* 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は
設定するようにしましょう。
なお、実際の設定値は環境変数に指定しております。
ソースコードに値を直接記載するのは何かの弾みでみられるとまずいので、やめたほうが良いです。
/*
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のサーバ側も決まったトークンをセットしている接続以外は、遮断するようにします。
/*
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には、静的ファイルを置くのではなく、動的に取得した画像を渡すようにします。
/*
set callback.
this callback is called when disconnection of websocket is requested.
*/
io.sockets.on("disconnection", (socket) => {
console.log("disconnected");
});
raspberry piからのwebsocketsが切断されたらログを出力します。
/*
* 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が画像を取得しにきた場合は、保存しておいた画像データを送ります。
オリジナルもプレビューも同じデータを送りつけます。
本来は、サイズを分けて、それぞれで対応を変える必要があるはずですが、それでも見れてるのでよしとしています。
/*
* 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つに分けています。
- WebSocketが接続されていない、つまりRaspberry piと接続されていない場合は、
画像が取得できないので、エラーメッセージを返します。 - LINEからのメッセージタイプがmessageの場合、リビングの現在画像ボタンがついたメッセージを送信します。
- LINEからのメッセージタイプがpostbackの場合、送ってきたユーザID,グループIDおよびルームIDをsenderIDsに登録します。画像が取得出来たら、senderIDsに登録されているIDに送信します。
// 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で記述しています。
#!/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の中に閉じ込めるようにします。
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のコードです。
"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イメージを作成しています。
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イメージを制御します。
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の株が上がれば良いのだが・・・