【ラズパイDIY講座】ラズパイゼロで作る監視カメラ③ 〜 Node.jsでシグナリングサーバ構築
※ 当ページには【広告/PR】を含む場合があります。
2021/07/16

今回はシグナリングサーバーを別のラズパイに立ててから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サーバー)の注意点

ラズパイゼロからは、ブラウザを立ち上げてWebRTCを使うような利用方法ではなく、監視カメラ専用のデバイスとして利用します。
ラズパイゼロをブラウザレスのWebRTCクライアントとして使う選択肢の一つとして、Node.jsからWebRTCクライアントで接続するためのライブラリ・
とはいえ自分で頑張らなくても、node-webrtc以外にもラズパイゼロでブラウザ無しにWebRTCが使えるようにする方法はいくつか存在します。
その中の選択肢の一つであるUV4LのWebRTCクライアント機能は、色々と面倒なWebRTC周りの深いことを考えなくてもホビー用途程度なら十分使える代物です。
シグナリングサーバーを重複
このシグナリングサーバーの競合状態は、少し扱うのは厄介な代物です。
基本的にWebSocketネットワークは相互間双方向通信でなので、シグナリングサーバー1つに対してWebSocketネットワークは1つを持つのが普通です。 2つのシグナリングサーバーを競合させる場合、本来独立である2つのWebSocketネットワークを上手くブリッジさせる仕組みを作る必要があります。
よって先ほどの
server.js
1. クライアント(オーディエンス)から、自身の担当するWebSocketネットワーク(ws1)
に対してシグナルを受け取ったら、ラズパイゼロ側のWebSocketネットワーク(ws2)へ転送する
2. ラズパイゼロ側(UV4L)から、ws2に対してシグナルを受け取ったら、
ws1へそのシグナルを転送する
という機能を新たにサーバー側に実装しなければいけません。
このため
「サーバーの中に特別なWebSocketクライアント」
この特別なWebSocketクライアントには、多目的かつ高度なWebSocket操作が可能な
修正したserver.jsは後述します。
UV4L-WebRTCプロトコル
先にUV4L操作用のWebSocketクライアントの用いる信号の中身を少しまとめておきましょう。
call
{
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
{
what: "answer",
data: "<local-sdp>"
}
このリクエストを送信後で、trickle ICEを使用した場合
iceCandidate
{
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
<!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越えの話もやっていこうかと思います。