【ラズパイDIY講座】ラズパイゼロで作る監視カメラ③ 〜 Node.jsでシグナリングサーバ構築


2021/07/16
蛸壺の中の工作室|ラズパイゼロで作る監視カメラ③

前の回ではUV4Lのビルドインシグナリングサーバー機能を使ってWebRTC通信の基礎勉強がてら、ラズパイで自作監視カメラの構築方法を解析していました。今回はシグナリングサーバーを別のラズパイに立ててからWebRTC通信を確立する方法を検討し、さらに理解を深めてみたいと思います。


WebRTCの構成

シグナリングサーバーを別にLAN内の何処かに設置する場合、以下のような構成になります。

合同会社タコスキングダム|蛸壺の中の工作室

今回の動作確認したネットワーク環境(ハード版):

            + Raspberry Pi Zero W + カメラモジュールV2 (※1)
+ Raspberry Pi 3B+ (シグナリングサーバー)
+ Debian Linux パソコン (というかブラウザが動けばなんでもOK)
+ 家庭用の無線LANルータ (※2)
        
※1: 前回の内容でセットアップしたもの

※2: 後日、外部のネットワークからNAT越えしてVPN接続を試みるため、ポートフォワーディング機能があったほうが良い。

UV4LからWebRTC監視カメラとして使う場合、UV4Lサーバーが提供しているWebRTCシグナリング機能はそのまま利用し、node.jsで立てたWebSocketクライアントを経由して、同じネットワークのWebSocketシグナリングサーバーへデータをパイプしてあげる必要があります。

合同会社タコスキングダム|蛸壺の中の工作室

前回の内容との違いは、ラズパイゼロの外部にシグナリングサーバーが追加されるところが今回はちょっとだけ違います。

ともかく多少強引でも、ネットワーク内にシグナリングサーバーが複数あろうとも、WebRTCクライアント間の
オファーアンサーICEの情報を適切に交換すればコネクションが確立できます。

シグナリングサーバーが自由に作れるようになれば、WebRTCを使ったアプリケーションの幅も広がりますので、以降でじっくりこの中身の話を解説していきましょう。


Node.jsによるシグナリングサーバー構築

まず最初にUV4Lサーバーと連帯可能な外部シグナリングサーバーの作成を目指します。

通常のシグナリングサーバー

シグナリングサーバーの基本はWebsocketによって実現しています。

合同会社タコスキングダム|蛸壺の中の工作室

シグナリングサーバーの役目はWebSocketを通じて、クライアントピア同士の情報(SDPとICE Candidate)を交換させるだけです。

この
「普通」のシグナリングサーバーの利用方法は別ブログで特集しましたので、詳しくはそちらをご覧ください。

シグナリングサーバーとして立てたいnode.jsがインストールされているパソコンであれば何でもOKですが、今回は手持ちのラズパイ3B+を利用しています。

以下のようなjsコードを
server.jsとして保存しておきます。

            const webSocketsServerPort = 41337;
const webSocketServer = require('websocket').server;
const http = require('http');
const server = http.createServer((request, response) => {});
const clients = [];

server.listen(webSocketsServerPort, () => {
    console.log(`${(new Date()).toISOString()} | Server is listening on port# ${webSocketsServerPort} .`);
});

const wsServer = new webSocketServer({httpServer: server});

//👇登録されたクライアントから接続requestを受け取った
wsServer.on('request', (request) => {
    console.log(`${(new Date()).toISOString()} | Connection from Origin ${request.origin} .`);
    const connection = request.accept(null, request.origin);
    console.log(`${(new Date()).toISOString()} | Connection accepted.`);

    //👇クライアントを番号で管理
    const index = clients.push(connection) - 1;

    //👇クライアントからmessageを受信した
    connection.on('message', (message) => {
        for (let i=0; i < clients.length; i++) {
            if (connection === clients[i]) {
                console.log('SDPの逆転送はキャンセルします');
            } else {
                console.log(message);
                //👇接続中のすべてのwsクライアントへメッセージ送信
                clients[i].sendUTF(message.utf8Data);
            }
        };
    });

    //👇クライアントが切断された
    connection.on('close', (connection) => {
        console.log(`${(new Date()).toISOString()} | Peer# ${connection} disconnected.`);
        //👇切断したクライアントを削除
        clients.splice(index, 1);
    });
});

//👇異常終了の際のイベント
process.on('unhandledRejection', (reason, promise) => {
    process.exit(1);
});
        
nodeコマンドからサーバーを起動する場合には、

            $ node server.js
        
とします。なおここでは一例としてシグナリングサーバーのローカルIPが192.168.0.10に固定割り振りされているものとします。

配信側(UV4Lサーバー)の注意点

前回のブログの内容まででUV4Lを使ってカメラデバイスをリアルタイム配信できたわけですが、今回も引き続きWebRTCシグナリング機能を利用しています。

合同会社タコスキングダム|蛸壺の中の工作室

ラズパイゼロからは、ブラウザを立ち上げてWebRTCを使うような利用方法ではなく、監視カメラ専用のデバイスとして利用します。

ラズパイゼロをブラウザレスのWebRTCクライアントとして使う選択肢の一つとして、Node.jsからWebRTCクライアントで接続するためのライブラリ・
node-webrtcを利用する方法も考えられたのですが、そもそもnode-webrtcはラズパイゼロのCPUアーキテクチャであるarmv6l用にパッチを当ててカスタムビルドするのはかなり険しい道です。

とはいえ自分で頑張らなくても、node-webrtc以外にもラズパイゼロでブラウザ無しにWebRTCが使えるようにする方法はいくつか存在します。

その中の選択肢の一つであるUV4LのWebRTCクライアント機能は、色々と面倒なWebRTC周りの深いことを考えなくてもホビー用途程度なら十分使える代物です。

前回でも説明したように、通常のUV4LはビルドインWebRTCシグナリングサーバーを内蔵していますので、クライアント側から直接WebSocket接続させて使うのが普通です。上記でも触れたように、今回はシグナリングサーバーを外部にもう一つ別に立てるような使い方をしますので、シグナリングサーバーを重複させる必要があります。

このシグナリングサーバーの競合状態は、少し扱うのは厄介な代物です。

基本的にWebSocketネットワークは相互間双方向通信でなので、シグナリングサーバー1つに対してWebSocketネットワークは1つを持つのが普通です。2つのシグナリングサーバーを競合させる場合、本来独立である2つのWebSocketネットワークを上手くブリッジさせる仕組みを作る必要があります。

よって先ほどの
server.jsには以下の機能を追加で作成する必要があります。

            1. クライアント(オーディエンス)から、自身の担当するWebSocketネットワーク(ws1)
    に対してシグナルを受け取ったら、ラズパイゼロ側のWebSocketネットワーク(ws2)へ転送する

2. ラズパイゼロ側(UV4L)から、ws2に対してシグナルを受け取ったら、
    ws1へそのシグナルを転送する
        
という機能を新たにサーバー側に実装しなければいけません。

このため
「サーバーの中に特別なWebSocketクライアント」を設ける必要があります。

この特別なWebSocketクライアントには、多目的かつ高度なWebSocket操作が可能な
faye-websocketというユーティリティを利用します。

修正したserver.jsは後述します。

UV4L-WebRTCプロトコル

先にUV4L操作用のWebSocketクライアントの用いる信号の中身を少しまとめておきましょう。

UV4L専用のビルドインシグナリングプロトコルを参考に以下まとめていきます。

callリクエストはUV4Lとのセッションを始める際に送信するJSONオブジェクトです。

            {
    what: "call",
    options: {
        force_hw_vcodec: true,
        vformat: 30,
        trickle_ice: true
    }
}
        
このcall requestをUV4Lに発信後、offerアンサーが返信されてきます。

ここでは

            {
    what: "offer",
    data: "<uv4l-sdp>"
}
        
このアンサーの中のdataによりUV4L側のSDP情報が提供されます。

UV4Lからのオファーに対して
answerリクエストで現在のクライアントのSDP情報を返信する必要があります。

            {
    what: "answer",
    data: "<local-sdp>"
}
        
このリクエストを送信後で、trickle ICEを使用した場合iceCandidateメッセージがUV4L側から送信されてきます。

            {
    what: "iceCandidate",
    data: <single-uv4l-ice-candidate or empty string>
}
        
UV4LとICE情報を交換することでコネクションが確立します。

なおvanilla ICE方式を採用する場合には
iceCandidatesメッセージが送信されてきます。

            {
    what: "iceCandidates",
    data: <array-of-uv4l-ice-candidates>
}
        
なお今回はvanilla ICEは利用しません。

UV4Lにクライアント側のICEを登録して欲しい場合には
addIceCandidateリクエストを送信します。

            {
    what: "addIceCandidate",
    data: "<local-ice-candidate>"
}
        
またUV4Lからのエラーはmessageメッセージから取得できます。

            {
    what: "message",
    data: "<error-message>"
}
        
UV4LとのWebSocket通信を切断したい場合にはhangupリクエストを送信します。

            {
   what: "hangup"
}
        

シグナリングサーバーの改造

以上を踏まえて、最初に紹介していたserver.jsを以下のように修正しておきます。

            const webSocketsServerPort = 41337;
const webSocketServer = require('websocket').server;
const http = require('http');
const fs = require('fs');

const server = http.createServer((request, response) => {
    //👇index.htmlの中身は後述
    fs.readFile('./index.html', 'utf-8', (error, data) => {
        response.writeHead(200, {'Content-Type' : 'text/html'});
        response.write(data);
        response.end();
    });
});
const clients = [];

//👇シグナリングサーバーを跨ぐためのWebSocketクライアント
const webSocketClient = require('faye-websocket');
let uv4lClient = null;
let isUv4lClientOpen = false;
const uvl4ServerIp = process.env.UV4L_IP || '127.0.0.1';

server.listen(webSocketsServerPort, () => {
    console.log(`${(new Date()).toISOString()} | Server is listening on port# ${webSocketsServerPort} .`);
});

const wsServer = new webSocketServer({httpServer: server});

//👇登録されたクライアントから接続requestを受け取った
wsServer.on('request', (request) => {
    console.log(`${(new Date()).toISOString()} | Connection from Origin ${request.origin} .`);
    console.log(request);
    const connection = request.accept(null, request.origin);
    console.log(`${(new Date()).toISOString()} | Connection accepted.`);

    //👇UV4Lサーバーに接続開始
    createUv4lClient();

    //👇クライアントを番号で管理
    const index = clients.push(connection) - 1;

    //👇クライアントからmessageを受信した
    connection.on('message', (message) => {
        for (let i=0; i < clients.length; i++) {
            if (connection === clients[i]) {
                console.log('[Listener] > [UV4L]');
                uv4lClient.send(message.utf8Data);
            }
        };
    });

    //👇クライアントが切断された
    connection.on('close', (connection) => {
        console.log(`${(new Date()).toISOString()} | ピア# ${connection} を切断しました`);
        //👇切断したクライアントを削除
        clients.splice(index, 1);
        if (uv4lClient) uv4lClient.close();
    });
});

function createUv4lClient() {
    if (uv4lClient) return;
    uv4lClient = new webSocketClient.Client(`ws://${uvl4ServerIp}:4300/stream/webrtc`);

    uv4lClient.on('open', (event) => {
        console.log(`UV4Lが開きました`);
        //👇初期化処理
        console.log('UV4Lサーバーに接続中...');
    });

    uv4lClient.on('message', (event) => {
        console.log(`UV4Lからメッセージ`);
        for (let i=0; i < clients.length; i++) {
            console.log('[UV4L] > [WRTC]');
            console.log(event.data);
            //👇接続中のすべてのwsクライアントへメッセージ送信
            clients[i].sendUTF(event.data);
        };
    });

    uv4lClient.on('error', (event) => {
        console.log(`UV4Lエラー発生`);
    });

    //👇切断時のイベント
    uv4lClient.on('close', () => {
        console.log('クライアントのコネクションを切断されました');
    });
}
        
なお、クライアント(オーディエンス)用のindex.htmlをwebサーバーで立てておきます。このindex.htmlの中身は次の節で紹介します。


オーディエンス側のWebRTCクライアント(index.html)の実装

合同会社タコスキングダム|蛸壺の中の工作室

クライアントはブラウザ側からのシグナリングサーバーへのアクセスを試みます。

            1. サーバーに接続を試みた際に、カメラ接続開始の「call」を送る
2. サーバー側からラズパイゼロから送られたオファーに対する「answer」を送る
3. コネクションが確立すると、自身の「ice」が送られる
        
内容としては前回のindex.htmlとほぼ同じです。クライアントを以下のように定義してindex.htmlとして、server.jsと同じ場所に保存しておきます。

            <!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>UV4L-WebRTC</title>
        <script type="text/javascript">
            let ws = null, pc, trickle_ice = true, remoteDesc = false, iceCandidates = [];

            //👇①シグナリングサーバーのIPアドレス(192.168.0.10)とポートを設定
            const signalling_server_address = "localhost:41337";
            //👇Webサーバーを利用しない場合(ローカルファイルを開く等)ではサーバーIPを指定
            //const signalling_server_address = "192.168.0.10:41337";
            const pcConfig = { iceServers: [] };
            const pcOptions = { optional: [] };
            const mediaConstraints = {
                optional: [],
                mandatory: {
                    OfferToReceiveAudio: false,
                    OfferToReceiveVideo: true
                }
            };

            //👇②ブラウザ組込のWebRCT APIを呼び出し
            RTCPeerConnection = window.RTCPeerConnection;
            RTCSessionDescription = window.RTCSessionDescription;
            RTCIceCandidate = window.RTCIceCandidate;

            function createPeerConnection() {
                try {
                    pc = new RTCPeerConnection(pcConfig, pcOptions);
                    pc.onicecandidate = (event) => {
                        if (event.candidate && event.candidate.candidate) {
                            const candidate = {
                                sdpMLineIndex: event.candidate.sdpMLineIndex,
                                sdpMid: event.candidate.sdpMid,
                                candidate: event.candidate.candidate
                            };
                            const request = {
                                what: "addIceCandidate",
                                data: JSON.stringify(candidate)
                            };
                            ws.send(JSON.stringify(request));
                        } else {
                            console.log("ICE candidateの情報取得完了");
                        }
                    }
                    if ('ontrack' in pc) {
                        pc.ontrack = (event) => {
                            console.log("トラックを追加");
                            document.getElementById('remote-video').srcObject = event.streams[0];
                        }
                    } else {
                        pc.onaddstream = (event) => {
                            console.log("リモートストリームを追加: ", event.stream);
                            document.getElementById('remote-video').srcObect = event.stream;
                        }
                    }
                    pc.onremovestream = (event) => {
                        document.getElementById('remote-video').srcObject = null;
                        document.getElementById('remote-video').src = '';
                    }
                    console.log("ピアのコネクションか確立しました!");
                } catch (e) {
                    console.error("ピアのコネクションが失敗...");
                }
            }

            function addIceCandidates() {
                iceCandidates.forEach((candidate) => {
                    pc.addIceCandidate(candidate, () => {
                            console.log("ICE candidateが追加されました: " + JSON.stringify(candidate));
                        }, (error) => {
                            console.error("ICE追加中にエラーが発生: " + error);
                        }
                    );
                });
                iceCandidates = [];
            }

            function start() {
                if ("WebSocket" in window) {
                    document.getElementById("stop").disabled = false;
                    document.getElementById("start").disabled = true;
                    document.documentElement.style.cursor = 'wait';

                    //👇③シグナリングを担当するWebSocketオブジェクトを生成
                    ws = new WebSocket('ws://' + signalling_server_address);

                    //👇④WebSocket開始と同時にピアコネクションも作成する
                    ws.onopen = (stream) => {
                        console.log("WebSocket: onopenイベント発生");
                        iceCandidates = [];
                        remoteDesc = false;
                        createPeerConnection();
                        const request = {
                            what: "call",
                            options: {
                                //👇外部ドライバではハードウェアコーディックが有効化できない
                                force_hw_vcodec: false,
                                vformat: 10,
                                trickle_ice: true
                            }
                        };
                        ws.send(JSON.stringify(request));
                        console.log("コールされたリクエスト: " + JSON.stringify(request));
                    };

                    ws.onmessage = (evt) => {
                        const msg = JSON.parse(evt.data);
                        const what = msg.what !== undefined ? msg.what : null;
                        const data = msg.what !== undefined ? msg.data : null;
                        switch (what) {
                            //👇⑤ピアコネクション確立(セッション)を確認したら、ICE情報の取得を試みる
                            case "offer":
                                pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data)),
                                    () => {
                                        remoteDesc = true;
                                        addIceCandidates();
                                        console.log('SDP情報: onRemoteSdpSuccesイベント');
                                        pc.createAnswer(
                                            (sessionDescription) => {
                                                pc.setLocalDescription(sessionDescription);
                                                const request = {
                                                    what: "answer",
                                                    data: JSON.stringify(sessionDescription)
                                                };
                                                ws.send(JSON.stringify(request));
                                            },
                                            (error) => {
                                                alert("アンサーでエラーが発生: " + error);
                                            },
                                            mediaConstraints
                                        );
                                    },
                                    (event) => {
                                        alert('ピアコネクションエラー発生: ' + event);
                                        stop();
                                    }
                                );
                                break;
                            case "answer":
                                break;
                            case "message":
                                alert(msg.data);
                                break;
                            //👇⑥ICE candidateを受け取ったらICE情報の保存する
                            case "iceCandidate": //👈trickleICEの場合
                                if (!msg.data) {
                                    console.log("ICE情報取得完了");
                                    break;
                                }
                                const elt = JSON.parse(msg.data);
                                const candidate = new RTCIceCandidate({sdpMLineIndex: elt.sdpMLineIndex, candidate: elt.candidate});
                                iceCandidates.push(candidate);
                                if (remoteDesc) addIceCandidates();
                                document.documentElement.style.cursor = 'default';
                                break;
                            case "iceCandidates": //👈vanillaICEの場合
                                const candidates = JSON.parse(msg.data);
                                for (let i = 0; candidates && i < candidates.length; i++) {
                                    const elt = candidates[i];
                                    const candidate = new RTCIceCandidate({sdpMLineIndex: elt.sdpMLineIndex, candidate: elt.candidate});
                                    iceCandidates.push(candidate);
                                }
                                if (remoteDesc) addIceCandidates();
                                document.documentElement.style.cursor = 'default';
                                break;
                        }
                    };

                    ws.onclose = (evt) => {
                        if (pc) {
                            pc.close();
                            pc = null;
                        }
                        document.getElementById("stop").disabled = true;
                        document.getElementById("start").disabled = false;
                        document.documentElement.style.cursor = 'default';
                        console.log("WebSocket切断完了");
                    };

                    ws.onerror = (evt) => {
                        alert("WebSocketエラー発生中!");
                        ws.close();
                    };
                } else {
                    alert("このブラウザはWebSocketに対応していません");
                }
            }

            function stop() {
                document.getElementById('remote-video').srcObject = null;
                document.getElementById('remote-video').src = '';
                if (pc) {
                    pc.close();
                    pc = null;
                }
                if (ws) {
                    ws.close();
                    ws = null;
                }
                document.getElementById("stop").disabled = true;
                document.getElementById("start").disabled = false;
                document.documentElement.style.cursor = 'default';
            }

            window.onbeforeunload = () => {
                if (ws) {
                    ws.onclose = () => {};
                    stop();
                }
            };
        </script>
        <style>
            #container {
                display: flex;
                flex-flow: row nowrap;
                align-items: flex-end;
            }
            video {
                background: #eee none repeat scroll 0 0;
                border: 1px solid #aaa;
            }
        </style>
    </head>
    <body>
        <div id="container">
            <div class="overlayWrapper">
                <video id="remote-video" autoplay="" width="640" height="480"></video>
            </div>
        </div>
        <div id="commands">
            <button id="start" style="background-color: green; color: white" onclick="start();">Call</button>
            <button disabled id="stop" style="background-color: red; color: white" onclick="stop();">Hang up</button>
        </div>
    </body>
</html>
        


接続テスト

では実際にこれらが動くかどうか検証してみます。

ここではラズパイゼロ側(UV4Lサーバー)のIPが192.168.0.100として、

            $ UV4L_IP=192.168.0.100 node server.js
        
これでシグナリングサーバー兼Webサーバーが起動しましたので、あとはhttp://192.168.0.10:41337をブラウザで開くだけです。

これで接続すると配信された映像が映し出されるようになります。

なおWebサーバーからindex.htmlをクライアント側へ配給するやり方では、Firefoxでアクセスした場合、
WebRTC: ICE failed, add a STUN server and see about:webrtc for more detailsのエラーが出ます。

Firefoxでlocalhost経由でICEを取得する場合には外部STUNサーバーの設定を空にはできないようですので、その際にはChromeの方をご利用ください。


まとめ

今回はシグナリングサーバーに注目し、別のラズパイ上にシグナリングサーバーを設けて、UV4L側のシグナリングサーバー機能を接続させるというトリッキーな環境の構築方法を解説しました。

こうしておくことで、ハードウェアとしての性能やリソースに限りがあるラズパイゼロには出来るだけ映像配信に専念させて、それ以外の機能は別のハードウェアへ切り出して利用させる、といった処理負荷分散のメリットがあります。

次回以降からはそろそろローカル環境での利用から卒業し、お家の外でもアクセスできるようにNAT越えの話もやっていこうかと思います。

参考サイト

faye-websocket

シグナリングサーバーを動かそう ーWebRTC入門2016

WebRTC P2Pを使って2つのマシンを接続する

記事を書いた人

記事の担当:taconocat

ナンデモ系エンジニア

電子工作を身近に知っていただけるように、材料調達からDIYのハウツーまで気になったところをできるだけ細かく記事にしてブログ配信してます。