【ラズパイDIY講座】ラズパイゼロで作る監視カメラ⑤ 〜 WebRTCクライアントMomoのシグナリングをハックする


※ 当ページには【広告/PR】を含む場合があります。
2021/07/31
【ラズパイDIY講座】ラズパイゼロで作る監視カメラ④ 〜 軽量なWebRTCクライアント Momoで監視カメラを構築する
【ラズパイDIY講座】ラズパイゼロで作る監視カメラ⑥〜Slackアプリから監視カメラ画像を取得してみる
蛸壺の中の工作室|ラズパイゼロで作る監視カメラ⑤

前回インストールしたWebRTCクライアントMomoをもっとより良く利用するためには、改めてWebRTCのシグナリング方式を知る必要があります。

合同会社タコスキングダム|タコツボの中の工作室
【ラズパイDIY講座】ラズパイゼロで作る監視カメラ④ 〜 軽量なWebRTCクライアント Momoで監視カメラを構築する

UV4Lの代替でWebRTCクライアントMomoで置き換えを狙って、セットアップから起動確認までを確認してみます。

幸い姉妹製品であるWebRTCシグナリングサーバーAyameの
シグナリング方式が公開されていますのでこちらを元に独自にシグナリングできるかを検討してみます。


ラズベリーパイ4B 4GB 技適対応品

Raspberry Pi Zero W (ヘッダーハンダ付け済)

Raspberry Pi Zero 2 W 技適取得済

ラズベリーパイピコ

ラズベリーパイ カメラモジュールV2

ラズパイZere/Zero W用カメラFFCケーブル 2本セット(15ピン22ピン15cm)

RAVPower USB充電器 2ポート 24W 【最大出力5V,4.8A】

ラズパイマガジン 2019年12月号 カメラ&センサー工作入門

IoTの基本・仕組み・重要事項が全部わかる教科書

構成

前回に引き続きラズパイゼロにカメラモジュールV2を接続したマシーンをMomoクライアントとして利用します。

また別のLinuxマシーン(手元ではラズパイ3B+)にnode.jsが利用できるようにしておいて、これをシグナリングサーバーとしてネットワーク上に接続させています。

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

Momoクライアントとシグナリングサーバーの内部構成は以下のようになります。

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

これはUV4LサーバーとWebRTC通信を確立させたときの内容と同様です。

合同会社タコスキングダム|タコツボの中の工作室
【ラズパイDIY講座】ラズパイゼロで作る監視カメラ③ 〜 Node.jsでシグナリングサーバ構築

シグナリングサーバーを別のラズパイに立ててからWebRTC通信を確立する方法を検討し、さらに理解を深めていきます。

先にMomoクライアント側を立ち上げておきます。今回は動画配信専用ですので、
SDL設定は不要です。

またローカルネットワークで試すので、Google STUNを利用しないオプションの
--no-google-stunも追加しておきます。

            $ ./momo --log-level 0 --no-audio-device --no-google-stun test
        
起動後のMomoクライアントのWebSocketアクセスエンドポイントはデフォルトでws://[Momo 1のIPアドレス]:8080/wsになります。


ラズベリーパイ4B 4GB 技適対応品

Raspberry Pi Zero W (ヘッダーハンダ付け済)

Raspberry Pi Zero 2 W 技適取得済

ラズベリーパイピコ

ラズベリーパイ カメラモジュールV2

ラズパイZere/Zero W用カメラFFCケーブル 2本セット(15ピン22ピン15cm)

RAVPower USB充電器 2ポート 24W 【最大出力5V,4.8A】

ラズパイマガジン 2019年12月号 カメラ&センサー工作入門

IoTの基本・仕組み・重要事項が全部わかる教科書

シグナリングサーバー実装

前回のUV4L版のシグナリングサーバーのコードをMomo用に書き換えます。

            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 = [];

const webSocketClient = require('faye-websocket');
let momoClient = null;
const momoServerIp = process.env.MOMO_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.`);

    //👇Momoサーバーに接続開始
    createMomoClient();

    //👇クライアントを番号で管理
    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] > [MOMO]');
                momoClient.send(message.utf8Data);
            }
        };
    });

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

function createMomoClient() {
    if (momoClient) return;
    //👇MomoのWebSocket用エンドポイント
    momoClient = new webSocketClient.Client(`ws://${momoServerIp}:8080/ws`);
    momoClient.on('open', (event) => {
        console.log(`MOMOが開きました`);
        //👇初期化処理
        console.log('MOMOサーバーに接続中...');
    });

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

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

    //👇切断時のイベント
    momoClient.on('close', () => {
        console.log('クライアントのコネクションを切断されました');
    });
}
        
前回のシグナリングサーバーのソースコードとほぼ変わりません。

なおポイントとなるwebsocketの使い方を詳しく知りたい方は
前回以前の記事から良く読んでいただくことをおすすめします。


ラズベリーパイ4B 4GB 技適対応品

Raspberry Pi Zero W (ヘッダーハンダ付け済)

Raspberry Pi Zero 2 W 技適取得済

ラズベリーパイピコ

ラズベリーパイ カメラモジュールV2

ラズパイZere/Zero W用カメラFFCケーブル 2本セット(15ピン22ピン15cm)

RAVPower USB充電器 2ポート 24W 【最大出力5V,4.8A】

ラズパイマガジン 2019年12月号 カメラ&センサー工作入門

IoTの基本・仕組み・重要事項が全部わかる教科書

Momo-WebRTCプロトコル(Ayame方式)のよるシグナリング

ではブラウザWebRTCクライアント用のindex.htmlも手直しをする必要があります。

前回のUV4LをWebSocket越しに操作した要領で、momoでも同様の機能を実装していきます。

合同会社タコスキングダム|タコツボの中の工作室
【ラズパイDIY講座】ラズパイゼロで作る監視カメラ③ 〜 Node.jsでシグナリングサーバ構築

シグナリングサーバーを別のラズパイに立ててからWebRTC通信を確立する方法を検討し、さらに理解を深めていきます。

Momo-WebRTCプロトコル

ここで改めて
Momo(Ayame)のシグナリング方式をおさらいしておきましょう。

MomoのWebSocketメッセージフォーマットは以下の種類があります。

            - offer
- answer
- candidate
- bye
- register (※Ayameサーバーが必要)
- accept (※Ayameサーバーが必要)
- reject (※Ayameサーバーが必要)
        
まずはregisterですが、Momoクライアントがサーバー側へ対してroomIdclientIdを登録するためのリクエストです。

            {
    type: "register",
    roomId: "<string>",
    clientId: "<string>"
}
        
シグナリングサーバがAyameの場合、registerリクエストを受け取ったら、そのMomoクライアントが指定したroomIdに入室できるかチェックし、可能であればaccept、不可であればrejectで応答します。

acceptレスポンスは入室が可能であることをMomoクライアントに知らせます。

            {
    type: "accept",
    isExistClient: "<boolean>"
}
        
Momoクライアントはacceptを受け取ると、isExistClienttrueの場合にofferリクエストを送信します。またisExsistClientfalseの場合はanswerメッセージを待ちます。

何からの理由で入室できない場合はサーバーは
rejectをMomoクライアントに知らせます。

            {
    type: "reject",
    reason: "<string>"
}
        
これを受け取ったら、Momoクライアントは開いているRTCPeerConnectionWebSocketを閉じて初期化されます。

offerリクエストはMomoクライアントがacceptレスポンスのisExistClient: trueであった場合に、シグナリングサーバーへ送信されます。

            {
    type: "offer",
    sdp: "v=0\r\no=- 4765067307885144980..."
}
        
これを受け取ったMomoクライアント側で、remoteDescriptionでこのSDPを受け取り保管します。

このSDPを受け取ると
answerレスポンスを同時に生成し、localDescriptionにセットした後に自分のSDPで返答させることができます。

answerレスポンスは、acceptレスポンスでisExistClient: falseを受け取ったクライアントに、他のクライアントからのofferリクエストが届くまで待機させ、offerを受け取ったらanswerレスポンスを送信させることもできます。

            {
    type: "answer",
    sdp: "v=0\r\no=- 4765067307885144980..."
}
        
コネクションが確立したら、candidateレスポンスでICE情報を交換します。

            {
    type: "candidate",
    ice: {candidate: "...."}
}
        
このICEを受け取ったMomoクライアントは相手方のICE Candidateを保管します。

通信を終える場合には
byeリクエストでWebSocket通信をを切断させます。

            {type: "bye"}
        
このbyeを受け取ったクライアントはRTCPeerConnectionを閉じ、WebRTC通信が終了します。

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

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

シグナリングサーバーAyameのようにroomIdやclientId、認証機能は付けていないので、実質
register/accept/rejectの3つは自作シグナリングサーバーには不要です。

            1. サーバーに接続を試みた際に、カメラ接続開始の「offer」を送る
2. サーバー側からラズパイゼロから送られた「answer」を処理する
3. コネクションが確立したときの、自身の「ice」を送りだす(Trickle-ICE方式)
        
Momoクライアントにこの3つの処理をする必要があります。

以下のようにソースコードを修正します。

            <!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>MOMO-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) {
                            ws.send(JSON.stringify({ type: 'candidate', ice: event.candidate }));
                        } 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 = [];
            }

            async function makeOffer() {
                createPeerConnection();
                try {
                    const sessionDescription = await pc.createOffer({
                        'offerToReceiveAudio': false,
                        'offerToReceiveVideo': true
                    })
                    //👇ビデオコーデックをH264に固定
                    sessionDescription.sdp = removeCodec(sessionDescription.sdp, 'VP8');
                    sessionDescription.sdp = removeCodec(sessionDescription.sdp, 'VP9');

                    await pc.setLocalDescription(sessionDescription);
                    ws.send(JSON.stringify(pc.localDescription));
                } catch (error) {
                    console.error('オファー送信中にエラー発生:', error);
                }
            }

            //👇オファー内のSDPから不要なビデオコーデックを除外
            function removeCodec(orgsdp, codec) {
                const internalFunc = (sdp) => {
                    const codecre = new RegExp('(a=rtpmap:(\\d*) ' + codec + '\/90000\\r\\n)');
                    const rtpmaps = sdp.match(codecre);
                    if (rtpmaps == null || rtpmaps.length <= 2) { return sdp; }
                    const rtpmap = rtpmaps[2];
                    let modsdp = sdp.replace(codecre, "");
                    const rtcpre = new RegExp('(a=rtcp-fb:' + rtpmap + '.*\r\n)', 'g');
                    modsdp = modsdp.replace(rtcpre, "");
                    const fmtpre = new RegExp('(a=fmtp:' + rtpmap + '.*\r\n)', 'g');
                    modsdp = modsdp.replace(fmtpre, "");
                    const aptpre = new RegExp('(a=fmtp:(\\d*) apt=' + rtpmap + '\\r\\n)');
                    const aptmaps = modsdp.match(aptpre);
                    let fmtpmap = "";
                    if (aptmaps != null && aptmaps.length >= 3) {
                        fmtpmap = aptmaps[2];
                        modsdp = modsdp.replace(aptpre, "");
                        const rtppre = new RegExp('(a=rtpmap:' + fmtpmap + '.*\r\n)', 'g');
                        modsdp = modsdp.replace(rtppre, "");
                    }
                    let videore = /(m=video.*\r\n)/;
                    const videolines = modsdp.match(videore);
                    if (videolines != null) {
                        let videoline = videolines[0].substring(0, videolines[0].length - 2);
                        const videoelems = videoline.split(" ");
                        let modvideoline = videoelems[0];
                        videoelems.forEach((videoelem, index) => {
                            if (index === 0) return;
                            if (videoelem == rtpmap || videoelem == fmtpmap) { return; }
                            modvideoline += " " + videoelem;
                        })
                        modvideoline += "\r\n";
                        modsdp = modsdp.replace(videore, modvideoline);
                    }
                    return internalFunc(modsdp);
                }
                return internalFunc(orgsdp);
            }

            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;
                        makeOffer();
                    };

                    ws.onmessage = (evt) => {
                        const msg = JSON.parse(evt.data);
                        const what = msg.type !== undefined ? msg.type : null;
                        switch (what) {
                            //👇ピアコネクション確立(セッション)を確認したら、ICE情報の取得を試みる
                            case "offer":
                                pc.setRemoteDescription(new RTCSessionDescription(msg),
                                    () => {
                                        remoteDesc = true;
                                        addIceCandidates();
                                        console.log('SDP情報: onRemoteSdpSuccesイベント');
                                        pc.createAnswer(
                                            (sessionDescription) => {
                                                pc.setLocalDescription(sessionDescription);
                                                const request = {
                                                    type: "answer",
                                                    sdp: JSON.stringify(sessionDescription)
                                                };
                                                ws.send(JSON.stringify(request));
                                            },
                                            (error) => {
                                                alert("アンサーでエラーが発生: " + error);
                                            },
                                            mediaConstraints
                                        );
                                    },
                                    (event) => {
                                        alert('ピアコネクションエラー発生: ' + event);
                                        stop();
                                    }
                                );
                                break;
                            case "answer":
                                const answer = new RTCSessionDescription(msg);
                                pc.setRemoteDescription(answer);
                                addIceCandidates();
                                break;
                            //👇ICE candidateを受け取ったらICE情報の保存する
                            case "candidate":
                                if (!msg.ice) {
                                    console.log("ICE情報取得完了");
                                    break;
                                }
                                const candidate = new RTCIceCandidate(msg.ice);
                                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.iceConnectionState !== 'closed') {
                    pc.close();
                    pc = null;
                    if (ws && ws.readyState === 1) {
                        const message = JSON.stringify({ type: 'close' });
                        ws.send(message);
                    }
                }
                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>
        
実装ではofferanswercandidateを適切なタイミングで送受信するように仕込んで使う必要があります。

実装のポイントを細かく解説すると切がないのですのでソースコード内のコメント程度でご容赦ください...。


ラズベリーパイ4B 4GB 技適対応品

Raspberry Pi Zero W (ヘッダーハンダ付け済)

Raspberry Pi Zero 2 W 技適取得済

ラズベリーパイピコ

ラズベリーパイ カメラモジュールV2

ラズパイZere/Zero W用カメラFFCケーブル 2本セット(15ピン22ピン15cm)

RAVPower USB充電器 2ポート 24W 【最大出力5V,4.8A】

ラズパイマガジン 2019年12月号 カメラ&センサー工作入門

IoTの基本・仕組み・重要事項が全部わかる教科書

接続テスト

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

ここではラズパイゼロ側(Momoクライアント)のIPが
192.168.0.100として、シグナリングサーバー(手元のマシーンではラズパイ3B+, IP: 192.168.0.10)を起動してみます。

            $ MOMO_IP=192.168.0.100 node server.js
        
これでシグナリングサーバー兼Webサーバーが稼働したので、あとはブラウザでhttp://192.168.0.10:41337にアクセスするか、index.htmlを直接開くだけです。

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

...良い感じの被写体が無く何やら汚い作業小屋の天井を見ているだけですが、上手く接続されているとMomoから配信された映像が非常に低遅延で映し出されるようになります。

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

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

参考サイト

テストモードを利用して Momo を動かしてみる

記事を書いた人

記事の担当:taconocat

ナンデモ系エンジニア

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