LoginSignup
13
12

More than 3 years have passed since last update.

WebRTCで常時接続のペットカメラを作ってみた(Nuxt.js + Python + Firebase + SkyWay + ラズパイ)

Last updated at Posted at 2020-12-18

はじめに

catRaspberryPi
1泊2日の楽しい旅行!でも、留守番中の猫が気になってしょうがない。。
愛する猫のため「旅行中のペット見守りカメラ」を作ってみました:camera:
GitHubリポジトリはコチラ

機能追加のため、コチラの基本機能はブランチを上記に変更しました(21/01/24)
追加機能の記事はコチラになります→LEGOでカメラアームを作り遠隔でカメラを操作してみた

実はネタ元としてコチラの優良記事を参考にさせていただいています。
SkyWay WebRTC Gatewayハンズオン

ネタ元様はRubyです。
今回は大変恐縮ですが、Pythonにて機能やファイルをまとめたり、何度もカメラに再接続できるようにするなど改良を重ねさせていただきました。:bow:

特徴

こんなことができます!

  • WEBアプリを通して外出先から自宅のカメラ映像がいつでもどこでも見られる
  • ログイン認証付きで安全
  • LEDを点灯・消灯と操作(将来カメラの角度を動かしたい)
  • WEBアプリからプログラムをシャットダウンできる(バルスと唱える!)

使用技術

  • Nuxt.js(Vue.js): Webアプリの作成
  • Python: 自宅カメラの制御
  • SkyWay(WebRTC): カメラ映像・メッセージの送信
  • Firebase: Webアプリのデプロイ、ログイン機能、SkyWay APIKeyの保存

diagram.png

RaspberryPi

自宅カメラ側として映像配信用に使用します。
USBカメラを接続し、SkyWayモジュールであるwebrtc-gateway、制御用Pythonプログラムを設定します。
大まかな仕組みとして、Pythonプログラムでカメラ映像を取得し、web-rtc-gatewayにアクセスして接続したWEBアプリ側にストリーム配信します。

また、LEDを接続してWebアプリ側からのメッセージで点灯・消灯できるようにしました。
将来的には、GPIOを通してサーボモーターを制御してUSBカメラの向きや角度を操作したいと考えています。

SkyWay

NTTコミュニケーションズのWebRTCを手軽に扱えるSDKです。
自宅カメラとWebアプリを接続し、映像、メッセージ送信のために使用しました。
詳しくはSkyWay公式サイト

案内に沿って無料登録して、APIKEYを取得します。
Webアプリ側は、Node.jsのモジュールとして、SkyWayを操作します。
カメラ側のRaspberryPiは、Webブラウザを立ち上げるわけでなく、ヘッドレスなのでskyway-webrtc-gatewayモジュールを使用します。

skyway-webrtc-gateway

IOT機器で、SkyWayを簡単に実行するためのコンパイルされたモジュール。
Webブラウザなしでも、WebRTCを実現して、映像や音声、メッセージを送受信することができます。
公式github

カメラ側のラズパイに、権限を付与して実行するだけでです。

Firebase

面倒な機能をサクッと実装したかったので、 今回はFirebaseを利用しました。

  • Authentification: ログイン機能
  • Firestore: SkyWayのAPI Keyを保存
  • Hosting: WEBアプリのデプロイ先

Nuxt.js(WEBアプリ側)

Vue.jsのフロントエンドフレームワークであるNuxt.jsを用いて、WEBアプリ側を構築しました。
自宅映像という超プライベートなので、ログイン機能をFirebaseのAuthenticationを利用して実装。ついでにデプロイ先もFirebaseを利用しました。
SkyWayに接続するためのAPI Keyですが、フロントエンドに持たせたく無かったです。そこでFirestoreに保存して、ログイン済みユーザーでないとAPIKeyを取得できないようにしました。

ディレクトリ構成

# 一部省略
frontend
├── components
│   ├── Header.vue
│   ├── Login.vue
│   └── ProjectTitle.vue
├── layouts
│   └── default.vue
├── middleware
│   └── authenticated.js
├── pages
│   ├── index.vue
│   └── operate.vue
├── static
│   └── favicon.ico
├── store
│   └── index.js
└── nuxt.config.js

大まかな処理の流れ

  1. pages/index.vueにユーザーはアクセス
    middleware/authenticated.jsにより、未ログインはindex以外の閲覧が不可
  2. componets/login.vueを通してログイン
  3. ログインと同時に store/index.jsでfirestoreからSkyWay APIKeyを取得
  4. pages/operate.vueにユーザーはアクセス
  5. 取得したAPI Keyを利用してSkyWayに接続
  6. すでに接続済みの自宅カメラ側のラズパイとコネクションを確立
    自宅のラズパイ側のWebRTC-gatewayからカメラ映像を視聴する

一番のポイントは面倒な部分はFirebaseに任せたことです。:thumbsup_tone3:
作りたいのはペットカメラシステムで、立派なWebサイトでは無いので、任せられる機能はFirebaseに丸投げしました。
特にAPIKeyをFirestoreに預けておくのは使い勝手がすごく良いです。

Python(自宅カメラ・ラズパイ側)

メイン&一番楽しい部分です。
WebRTC-gatewayは実行させたら、RESTAPIを通してPythonで制御します。

ディレクトリ構成

backend
├── __init__.py
├── config.py
├── data.py
├── media.py
├── peer.py
├── robot.py
├── util.py
└── webrtc_control.py

実行ファイルはwebrtc_control.pyです。
他のファイルは機能別のモジュールとなります。

webrtc_control.py

WebRTC-gateway、USBカメラを制御するメインプログラムです。
gatewayの次にこちらのプログラムを実行することで、ペットカメラはリモートで使えるようにスタンバイされます。

webrtc_control.py
# ~省略~

def main():
    queue = que.Queue()
    robot = Robot()

    peer_id = config.PEER_ID
    media_connection_id = ''
    data_connection_id = ''
    process_gst = None

    #Peerを確立します(SkyWayへの接続)
    peer_token = Peer.create_peer(config.API_KEY, peer_id)

    if peer_token is None:
        count_create = 1
        retry_peer_id = ''

        # Peerが確立できなかったら、IDを変えて5秒おきにチャレンジ
        while peer_token is None:
            time.sleep(5)
            count_create += 1
            retry_peer_id = peer_id + str(count_create)
            peer_token = Peer.create_peer(config.API_KEY, retry_peer_id)

        peer_id = retry_peer_id

    peer_id, peer_token = Peer.listen_open_event(peer_id, peer_token)

    # Peerへのeventを常時Listenするために、スレッド化します。
    th_listen = threading.Thread(target=Peer.listen_event,
                                 args=(peer_id, peer_token, queue, robot))
    th_listen.setDaemon(True)
    th_listen.start()

    # Webアプリからのメッセージを常時Listen、スレッド化します。
    th_socket = threading.Thread(target=robot.socket_loop, args=(queue,))
    th_socket.setDaemon(True)
    th_socket.start()

    try:
        while True:
           #スレッドからのキューを受け取る。 
           results = queue.get()

            #キューにList内のワード(「バルス」とか)が入っていれば、whileを抜ける
            if 'data' in results.keys():
                if results['data'] in config.SHUTDOWN_LIST:
                    break

            # 映像コネクションが確立したら、映像eventをListenする
            elif 'media_connection_id' in results.keys():
                media_connection_id = results['media_connection_id']

                th_media = threading.Thread(target=Media.listen_media_event,
                                            args=(queue, media_connection_id))
                th_media.setDaemon(True)
                th_media.start()

            #Gstreamerによる映像ストリームを開始したら、そのプロセスを取得する。
            elif 'process_gst' in results.keys():
                process_gst = results['process_gst']

            #データコネクション(メッセージのやり取り)が確立したら、そのIDを格納する。
            elif 'data_connection_id' in results.keys():
                data_connection_id = results['data_connection_id']

            #映像eventで該当する内容(close、error)したら、新たに次に接続できるように
            #使用していた映像ストリーム、MediaConnection,DataConnectionを破棄する
            #この場合、接続していたWebアプリ側が閉じたり、エラーになった場合を指す
            elif 'media_event' in results.keys():
                if results['media_event'] in ['CLOSE', 'ERROR']:
                    process_gst.kill()
                    Media.close_media_connections(media_connection_id)
                    Data.close_data_connections(data_connection_id)

    except KeyboardInterrupt:
        pass

    robot.close()
    Peer.close_peer(peer_id, peer_token)
    process_gst.kill()
    print('all shutdown!')


if __name__ == '__main__':
    main()

マルチスレッド

Peerへのイベントコールを常時Listenするために、th_listenでデーモン化してスレッド並列化しています。
th_socketもスレッドして並列してますが、こちらはDataConnection(webアプリからのmessage)のListenとなります。

while文で、各コネクションの状態が変わった時の処理分けをしています。
各スレッドはそれぞれ受け取ったeventによってqueueを飛ばすように仕込んでおり、このwhile文でそのqueue内容により処理を走らせています。
while内のth_mediaで、MediaConnectionからのイベントをListenします。

各threadからの状態によりConnectionを開放・再準備することで、Webアプリ側が何度も途中で切っても、カメラ側のラズパイと再接続可能にしています。:tada:

本来ですと、media connectionを簡単に開放・すぐに確立できるはずなのですが、仕様なのでしょうか?それとも私が未熟なのか分かりませんが、
2回目以降新たにmedia connectionを作成しようとすると、WebRTC-gatewayが400エラーになってしまいますので、新たにmediaから作る強引な実装となっています。
本来ならもっとスマートな処理のハズだけど、公式通り動かないから多少冗長な書き方にナッテルヨ。です。

util.py

WebRTC-gatewayとはRESTAPIて制御するので、頻繁にリクエストを送ることになります。
多用できるようにメソッドにまとめておきます。

util.py
import requests

import config

def request(method_name, _uri, *args):

    response = None
    uri = config.HOST + _uri
    if method_name == 'get':
        response = requests.get(uri, *args)
    elif method_name == 'post':
        response = requests.post(uri, *args)
    elif method_name == 'put':
        response = requests.put(uri, *args)
    elif method_name == 'delete':
        response = requests.delete(uri)

    else:
        print('There is no method called it')

    return response

robot.py

将来、ロボットアームでカメラ動かしたいと思って、こんな名前のクラスにしています。
このファイルは以下の機能となります。

  • GPIOの制御(Lチカやモータ)
  • socket通信の確立とデータ受け取り

Webアプリとデータコネクションを通して、GPIOへの指示を受けます。
(今はLEDのON/OFFしかできませんw)
以下は、webrtc_control.pyでスレッド化したメソッドです。

robot.py

# ~省略~

class Robot:


    # ~省略~

    # スレッド化したメソッドです。
    # Webアプリからのメッセージを待ち受けます。
    def socket_loop(self, queue):
        """ソケット通信を待ち受ける
        """

        # socket通信を作成
        self.make_socket()
        while True:
            # データ(メッセージ)を受け取る
            data = self.recv_data()
            data = data.decode(encoding="utf8", errors='ignore')
            # メッセージ内容をキューに送信する
            queue.put({'data': data})
            # メッセージ内容によりGPIOを操作(この場合、LEDの点灯・消灯)
            self.pin(data)

# ~省略~

peer.py

SkyWayとのPeer接続を確立して、SkyWayが使える状態にします。
以下は、webrtc_control.pyでスレッド化したメソッドです。

peer.py
# ~省略~

class Peer:
    """SkyWayと接続しセッション管理を行う

    """

    # ~省略~


    # スレッド化します。PeerへのeventをListenします。
    @classmethod
    def listen_event(cls, peer_id, peer_token, queue, robot):
        """Peerオブジェクトのイベントを待ち受ける
        """

        gst_cmd = "gst-launch-1.0 -e v4l2src ! video/x-raw,width=640,height=480,framerate=30/1 ! videoconvert ! vp8enc deadline=1 ! rtpvp8pay pt=96 ! udpsink port={} host={} sync=false"

        uri = "/peers/{}/events?token={}".format(peer_id, peer_token)
        while True:
            _res = request('get', uri)
            res = json.loads(_res.text)

            if 'event' not in res.keys():
                # print('No peer event')
                pass

            # Webアプリから映像接続を要求されたら
            elif res['event'] == 'CALL':
                print('CALL!')
                media_connection_id = res["call_params"]["media_connection_id"]
                queue.put({'media_connection_id': media_connection_id})

                # mediaを作成
                (video_id, video_ip, video_port) = Media.create_media()

                # GstreamerによるUSBカメラから映像ストリームを作成
                cmd = gst_cmd.format(video_port, video_ip)
                process_gst = subprocess.Popen(cmd.split())
                queue.put({'process_gst': process_gst})

                # WebアプリとmediaConnectionを接続する
                Media.answer(media_connection_id, video_id)

            # Webアプリからデータ接続(messageのやり取り)を要求されたら
            elif res['event'] == 'CONNECTION':
                print('CONNECT!')
                data_connection_id = res["data_params"]["data_connection_id"]
                queue.put({'data_connection_id': data_connection_id})

                # Dataを作成
                (data_id, data_ip, data_port) = Data.create_data()
                # データ(メッセージの飛ばし先をGPIOが処理しやすいportにリダイレクトする
                Data.set_data_redirect(data_connection_id, data_id, "127.0.0.1",
                                       robot.port)

            elif res['event'] == 'OPEN':
                print('OPEN!')

            time.sleep(1)

# ~省略~


media.py

PeerでSkyWayと接続が確立された後に、他のPeerユーザーとの映像・音声のやり取りを開始します。
以下は、webrtc_control.pyでスレッド化したメソッドです。

media.py
# ~省略~

class Media:
    """MediaStreamを利用する

    MediaConnectionオブジェクトと転送するメディアの送受信方法について指定
    """

    # ~省略~

    # スレッド化したメソッド
    # mediaのイベントを待ち受ける
    # 端的に:接続したWebアプリが、クローズ、エラーしたら知らせる
    @classmethod
    def listen_media_event(cls, queue, media_connection_id):
        """MediaConnectionオブジェクトのイベントを待ち受ける
        """

        uri = "/media/connections/{}/events".format(media_connection_id)
        while True:
            _res = request('get', uri)
            res = json.loads(_res.text)

            if 'event' in res.keys():
                # イベントをキューに投げる
                queue.put({'media_event': res['event']})

                # close,errorは別のスレッドでこのlitenの大元のmediaが開放されるので、このスレッドを終了する
                if res['event'] in ['CLOSE', 'ERROR']:
                    break

            else:
                # print('No media_connection event')
                pass

            time.sleep(1)

    # ~省略~

data.py

PeerでSkyWayと接続が確立された後に、他のPeerユーザーとのデータのやり取りを開始します。
今回は、文字のやり取りとなります。

Mediaと似ており、特筆することもないのでスキップします。

config.py

設定ファイルです。

config.py
API_KEY = "skyway API Keyを入力"
PEER_ID = "任意のpeer idを入力"
HOST = "http://localhost:8000"
SHUTDOWN_LIST = ['バルス', 'ばるす', 'balus', 'balusu', 'barusu', 'barus']

  • API_KEY: SkyWayのAPIKeyです。
  • PEER_ID: 自宅カメラ側のPEER_IDです。任意の名前に変更できます。
  • HOST: gatewayを立ち上げたときのHOST先となります。
  • SHUZTDOWN_LIST: Webアプリでこの単語を唱えると、プログラムがシャットダウンします。(バルス:skull_crossbones:)

リモートデバッグのススメ

GPIOを使ってLチカ、USBカメラから映像ストリーム取得、WebRTC-gatewayの実行と、ラズパイのハード依存が多かったので、リモートデバッグで開発しました。
今回の場合は、Mac上のIntellijでデバッグをしつつ、実際は同じネットワークのラズパイ上のコードで実行しています。
この方が、Mac上でのいつも通りのコーディング、デバッグしつつ、機種依存の機能もチェックできますのでとても開発しやすかったです。

IntelliJのリモートデバッグ方法

Jetbrains社のIDE:IntelliJでの方法となります。PyCharmでも同じ方法でリモートデバッグ可能です。
スクリーンショット 2020-12-17 22.24.27.png
実行/デバッグ構成からPython Debug Serverを選択

スクリーンショット 2020-12-17 22.29.03.png
IDE ホスト名を入力。ローカルIPアドレスでも可。(この場合は、Mac)
続いて空いている任意のポートを入力(ここもMac)
パスパッピングは、Macとラズパイでディレクトリをマッピングしたい時に指定します。

続いて説明欄の1.2を行います。
eggを追加するよりも、pipでインストールしてしまったほうが楽でした。

#実行マシーンでインストールする(この場合はラズパイ)
pip install pydevd-pycharm~=203.5981.155
#バージョンはIDEにより異なる。

コードにコマンドを追加
今回の場合だと実行ファイルであるwebrtc_control.pyの冒頭に追加します。

import pydevd_pycharm
pydevd_pycharm.settrace(<IDEホスト名>, port=<ポート番号>, stdoutToServer=True, stderrToServer=True, suspend=False)

ここまで準備完了です。
デバッグ実行は

  1. 先に、Mac側のIntelliJでPython Debug Serverをデバッグ実行
  2. ラズパイで、追加コードされたデバッグしたいコードを実行

これで、リモート環境のデバッグが可能になります。
通常のデバッグのようにブレイクポイントも、途中経過の変数の変遷も見ることができます、便利!!:smiley:

開発環境側のMacは固定IPにしておくのがおすすめです。
そうしないと毎回、追加コードのIP部分を書き直すハメになります、、

おわりに

これでも十分にペットカメラとして使えますが、
機会があればロボットアームにカメラをつけて遠隔で動かしたいです。

旅行中の自宅の猫を見るのにすごく便利&重宝しそうです:cat:
ここまで読んでいただいてありがとうございました!

追記:
改良加えてパワーアップしました。よろしければコチラの記事も御覧ください:arrow_lower_left:
LEGOでカメラアームを作り遠隔でカメラを操作してみた

13
12
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
13
12