【ラズパイDIY講座】ラズパイゼロで作る監視カメラ⑤ 〜 WebRTCクライアントMomoのシグナリングをハックする
※ 当ページには【広告/PR】を含む場合があります。
2021/07/31
構成
--no-google-stun
$ ./momo --log-level 0 --no-audio-device --no-google-stun test
ws://[Momo 1のIPアドレス]:8080/ws
シグナリングサーバー実装
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('クライアントのコネクションを切断されました');
});
}
Momo-WebRTCプロトコル(Ayame方式)のよるシグナリング
Momo-WebRTCプロトコル
- offer
- answer
- candidate
- bye
- register (※Ayameサーバーが必要)
- accept (※Ayameサーバーが必要)
- reject (※Ayameサーバーが必要)
register
roomId
clientId
{
type: "register",
roomId: "<string>",
clientId: "<string>"
}
accept
reject
accept
{
type: "accept",
isExistClient: "<boolean>"
}
accept
isExistClient
true
offer
isExsistClient
false
answer
reject
{
type: "reject",
reason: "<string>"
}
RTCPeerConnection
WebSocket
offer
accept
isExistClient: true
{
type: "offer",
sdp: "v=0\r\no=- 4765067307885144980..."
}
remoteDescription
answer
localDescription
answer
accept
isExistClient: false
offer
offer
answer
{
type: "answer",
sdp: "v=0\r\no=- 4765067307885144980..."
}
candidate
{
type: "candidate",
ice: {candidate: "...."}
}
bye
{type: "bye"}
bye
RTCPeerConnection
オーディエンス側のWebRTCクライアント(index.html)の実装
register
accept
reject
1. サーバーに接続を試みた際に、カメラ接続開始の「offer」を送る
2. サーバー側からラズパイゼロから送られた「answer」を処理する
3. コネクションが確立したときの、自身の「ice」を送りだす(Trickle-ICE方式)
<!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>
offer
answer
candidate
接続テスト
192.168.0.100
192.168.0.10
$ MOMO_IP=192.168.0.100 node server.js
http://192.168.0.10:41337
index.html
参考サイト
記事を書いた人
ナンデモ系エンジニア
電子工作を身近に知っていただけるように、材料調達からDIYのハウツーまで気になったところをできるだけ細かく記事にしてブログ配信してます。
カテゴリー