Первая статья – Управляем роботами из VR

В прошлой статье, мы провели начальную подготовку и реализовали сигнализацию для компонентов средствами websocket. В этой статье мы реализуем работу по WebRTC (Части 3, 4).

Часть 3. Настройка WebRTC Connection + DataChannel

Итак, у нас реализован сервер сигнализации, который мы сможем использовать для обмена контекстом WebRTC - offer, answer, ice, подробнее про webrtc можно почитать в тут, не будем останавливаться на деталях.

В этой части мы будем реализовывать организацию WebRTC соединения, и создание DataChannel между 2мя пирами в JS-Python-C#.

Серверный компонент

Добавим в созданную ранее разметку main.html новые элементы:

main.html
...
  <div class="row-md-6">
    <form class="form-inline">
      <div class="form-group">
        <label for="content">Send offer to:</label>
        <input type="text" id="offerTo" class="form-control" placeholder="Offer to...">
      </div>
      <button id="createOffer" class="btn btn-primary" type="submit">Create Offer</button>
    </form>
  </div>
  <div class="row-md-6">
    <form class="form-inline">
      <div class="form-group">
        <label for="content">Message directly:</label>
        <input type="text" id="content2" class="form-control" placeholder="Message here...">
      </div>
      <button id="sendMessageDirectly" class="btn btn-primary" type="submit">Directly message</button>
    </form>
  </div>
...

Для реализации datachannel на стороне браузера доработаем наш скрипт следующим образом – создадим процесс обмена sdp/ice для отправки и приема OFFER. Создание DataChаannel и отправку сообщений между пирами:

robo.js
var connection;
var userId = 'unknown';
var peerConnection;
var dataChannel;
var configuration = {
  "iceServers" : [{
  "urls" : "stun:stun2.1.google.com:19302"
  }]
};

function connect(){
  connection = new WebSocket('wss://' + window.location.host + '/robopi_webrtc');
  console.log("Connsection sucsess");
  
  initRTCPeerConnection();
  
  connection.onmessage = function(msg) {
    
    var resp = JSON.parse(msg.data);
      
    if(resp.type == 'USERID'){
      console.log();
      userId = resp.data;
      document.getElementById("username").textContent = userId;
    }
    if(resp.type == 'NEWMEMBER'){
      if(userId != resp.userId){
        console.log("NEWMEMDER:" + resp.userId);
      }
    }
    if(resp.type == 'OFFER'){
      if(userId != resp.userId){
        console.log(resp);
        handleOffer(resp.payload, resp.userId)
      }
    }
    if(resp.type == 'ICE'){
      if(userId != resp.userId){
        console.log(resp);
        receiveIceCandidate(resp.payload);
      }
    }
    if(resp.type == 'ANSWER'){
      if(userId != resp.userId){
        console.log(resp);
        handleAnswer(resp.payload);
      }
    }
  }
}

function login() {
  connection.send(JSON.stringify({'userId' : '', 'type' : 'LOGIN', 'data' : '' , 'toUserId' : ''}));
}

function newmember() {
  connection.send(JSON.stringify({'userId' : userId, 'type' : 'NEWMEMBER', 'data' : '' , 'toUserId' : ''}));
}

function initRTCPeerConnection(){
  peerConnection = new RTCPeerConnection(configuration);
  console.log("peerConnection created");
  // remote datachannel handler
  peerConnection.ondatachannel = function(event){
    dataChannel = event.channel;
    // open handling
    dataChannel.onopen = function(){
    console.log("Data channel is open!");
    }
    // error handling
    dataChannel.onerror = function(error){
      console.log("Error in datachannel:", error);
    };
    // messaging handler
    dataChannel.onmessage = function(event) {
      console.log("incoming message:", event.data);
    };
    // closing handler
    dataChannel.onclose = function() {
      console.log("Data channel is closed");
    };
  };
}

function createRTCDatachannel(){
  dataChannel = peerConnection.createDataChannel("datachannel", { 
    reliable: true 
  });
  // open handling
  dataChannel.onopen = function(){
    console.log("Data channel is open!");
  }
  // error handling
  dataChannel.onerror = function(error){
    console.log("Error in datachannel:", error);
  };
  // messaging handler
  dataChannel.onmessage = function(event) {
    console.log("incoming message:", event.data);
  };
  // closing handler
  dataChannel.onclose = function() {
    console.log("Data channel is closed");
  };
}

function createOffer(){
  createRTCDatachannel();
  peerConnection.createOffer(function(offer) {
    connection.send(JSON.stringify({'userId' : userId, 'type' : 'OFFER', 'payload' : offer , 'toUserId' : $("#offerTo").val()}));
    peerConnection.setLocalDescription(offer);
  },
  function(error) {
    console.log("error in offer creating:" + error);
  });
  sendIceCandidate();
}

function sendIceCandidate(){
  peerConnection.onicecandidate = function(event) {
  if (event.candidate) {
    console.log("sending ice candidate:" + event.candidate);
    connection.send(JSON.stringify({'userId' : userId, 'type' : 'ICE', 'payload' : event.candidate , 'toUserId' : $("#offerTo").val()}));
  }
  };
}

function receiveIceCandidate(candidate){
  peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
  console.log("sucsess receiving ice candidate");
}

function handleAnswer(answer){
  peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
  console.log("handling amswer successfully!!");
}

function handleOffer(offer, fromUser) {
  peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
  // create and send an answer to an offer
  peerConnection.createAnswer(function(answer) {
    peerConnection.setLocalDescription(answer);
    connection.send(JSON.stringify({'userId' : userId, 'type' : 'ANSWER', 'payload' : answer , 'toUserId' : fromUser}));
  }, function(error) {
    console.log("error creating answer:" + error);
  });
};

function sendMessageDirectly(){
  var message = $("#content2").val();
  dataChannel.send(message);
  console.log("send message:" + message);
}

$(function () {
  $("form").on('submit', function (e) {
  e.preventDefault();
  });
  
$( "#connect" ).click(function() { connect(); });
$( "#login" ).click(function() { login(); });
$( "#newmember" ).click(function() { newmember(); });
$( "#createOffer" ).click(function() { createOffer(); });
$( "#sendMessageDirectly" ).click(function() { sendMessageDirectly(); });
});

Делаем проверку между 2мя браузерами, проверяем работоспособность, у нас должен создаваться datachannel и мы можем отправлять принимать сообщения между браузерами:

проверка:

Исполнительный компонент(Python-скрипт на RPI)

Для работы с WebRTC из пайтон будем использовать бибилиотеку aiortc:

pip3 install aiortc

Тут возникают первые проблемы с совместимостью реализаций WebRTC (ICE) и учитываем особенность – Python не будет являться инициатором соединения, т. е. он должен только обрабатывать входящие офферы:

part2.py
import asyncio
import websockets
import json
import ssl
from websockets import WebSocketClientProtocol
from aiortc import RTCIceCandidate, RTCPeerConnection, RTCSessionDescription, RTCConfiguration, RTCIceServer

async def wsconsume(wsurl: str) -> None:
    ssl_context = ssl.SSLContext()
    async with websockets.connect(wsurl, ssl=ssl_context) as websocket:
        await websocket.send(json.dumps({"userId": "", "type": "LOGIN", "data": "", "payload": "", "toUserId": ""}))
        await wsconsumer_handler(websocket)


async def wsconsumer_handler(websocket: WebSocketClientProtocol) -> None:
    local_user_id = ""
    ice_servers = [RTCIceServer(urls=["stun:stun2.l.google.com:19302"])]
    peer_conn = RTCPeerConnection(RTCConfiguration(iceServers=ice_servers))
    
    @peer_conn.on("connectionstatechange")
    async def on_connectionstatechange():
        print("Connection state is %s" % peer_conn.connectionState)
        if peer_conn.connectionState == "failed":
            await peer_conn.close()

    @peer_conn.on("signalingstatechange")
    async def on_signalingstatechange():
        print(f"changed signalingstatechange {peer_conn.signalingState}")

    @peer_conn.on("icegatheringstatechange")
    async def on_icegatheringstatechange():
        print(f"changed icegatheringstatechange {peer_conn.iceGatheringState}")
        
    @peer_conn.on("datachannel")
    async def on_datachannel(channel):
        print(f"changed datachannel to {channel}")
        @channel.on("message")
        async def on_message(rtc_message):
            if isinstance(rtc_message, str):
                print("New message from datachannel " + rtc_message)
                channel.send("Reply from PyPi - " + rtc_message)
    
    async for message in websocket:

        msg = json.loads(message)

        if msg.get("type") == 'USERID' and local_user_id != msg.get("userId"):
            local_user_id = msg.get("data")
            print("SET UID: " + local_user_id)
            await websocket.send(json.dumps({"userId": local_user_id, "type": "NEWMEMBER", "data": "",
                                             "payload": "", "toUserId": ""}))

        if msg.get("type") == 'OFFER' and local_user_id == msg.get("toUserId"):
            print("Handling offer: " + str(msg.get("payload")))
            
            await peer_conn.setRemoteDescription(
                RTCSessionDescription(sdp=msg.get("payload").get("sdp"), type=msg.get("payload").get("type")))

            
            answer = await peer_conn.createAnswer()
            print("Creating answer:" + str(answer))
            await peer_conn.setLocalDescription(answer)

            await websocket.send(json.dumps({"userId": local_user_id, "type": "ANSWER", "data": "",
                                             "payload": {"sdp": answer.sdp, "type": answer.type},
                                             "toUserId": msg.get("userId")}))
            
        if msg.get("type") == 'ICE' and local_user_id == msg.get("toUserId"):
            print("ICE INCOMING")
            candidate = msg.get("payload").get("candidate").split()
            new_ice = RTCIceCandidate(
                component=int(candidate[1]),
                foundation=candidate[0].split(":")[1],
                ip=candidate[4],
                port=int(candidate[5]),
                priority=int(candidate[3]),
                protocol=candidate[2],
                type=candidate[7],
                relatedAddress=None,
                relatedPort=None,
                sdpMid=msg.get("payload").get("sdpMid"),
                sdpMLineIndex=int(msg.get("payload").get("sdpMLineIndex")),
                tcpType=None
            )
            await peer_conn.addIceCandidate(new_ice)

        if msg.get("type") == 'ANSWER' and local_user_id == msg.get("toUserId"):
            print("ANSWER INCOMING")
            await peer_conn.setRemoteDescription(
                RTCSessionDescription(sdp=msg.get("payload").get("sdp"), type=msg.get("payload").get("type")))

async def main():
    task = asyncio.create_task(wsconsume('wss://192.168.10.146:9000/robopi_webrtc'))
    await task


if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    try:
        loop.run_until_complete(main())
    except KeyboardInterrupt:
        loop.stop()
        pass

Делаем проверку между браузером и пайтон скриптом, проверяем работоспособность, у нас должен создаваться datachannel и мы можем отправлять принимать сообщения между браузером и python скриптом:

проверка:

Управляющий компонент(Unity VR)

Для работы с WebRTC unity будем использовать библиотеку WebRTC for Unity

Добавляем ее по инструкции, там же приведены основные референсы по работе(чаcть не совсем корректна, поэтому нужно внимательно смотреть на сэмплы к пакету!)

добавление:

Cкрипты из браузера и Unity могут отправлять и принимать offer в обе стороны, т.е. могут быть инициаторами соединения, скрипт из Python может только принимать входящий offer и отправлять встречный offer после создания контекста, но инициатором соединения он не будет.

Дополняем наш UI в Unity новыми элементами:

Dropdown – для отображения Member.

Create Offer – для инициации WebRTC call.

Send Hello – для отправки сообщения, по нажатию на которую в datachannel будет отправляться типовое сообщение с датой/временем. Для отображения сообщений из datachannel будем писать их просто в debug log Unity.

Дополняем наш скрипт следующим образом:

Connection.cs
using System;
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
using Unity.WebRTC;
using UnityEngine;
using UnityEngine.UI;
using WebSocketSharp;

public class Connection : MonoBehaviour
{

    private GameObject uuid;
    private WebSocket ws;
    private ConcurrentQueue<string> incomingWebsocketMessages;
    private string userId = "unknown";

    private GameObject wscandidates;
    private List<string> dropOptions;

    private RTCPeerConnection webrtcConnection;
    private RTCDataChannel dataChannel, remoteDataChannel;

    private DelegateOnDataChannel onDataChannel;
    private DelegateOnOpen onDataChannelOpen;
    private DelegateOnMessage onDataChannelMessage;
    private DelegateOnClose onDataChannelClose;
    private DelegateOnIceConnectionChange onIceConnectionChange;
    private DelegateOnIceCandidate onIceCandidate;

    void Start()
    {
        uuid = GameObject.FindGameObjectWithTag("uuid");
        incomingWebsocketMessages = new ConcurrentQueue<string>();

        ws = new WebSocket("wss://192.168.10.146:9000/robopi_webrtc");
        ws.SslConfiguration.EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12;

        ws.OnOpen += (sender, e) =>
        {
            Debug.Log("OPEN WEBSOCKET");
        };

        ws.OnMessage += (sender, e) =>
        {
            if (e.IsText)
            {
                incomingWebsocketMessages.Enqueue(e.Data);
                Debug.Log("Incoming websocket message:" + e.Data);
            }
        };

        ws.OnClose += (sender, e) => {
            Debug.Log("CLOSE WEBSOCKET:" + e.Reason);
        };

        dropOptions = new List<string> { "No candidates!" };
        wscandidates = GameObject.FindGameObjectWithTag("candidates");
        wscandidates.GetComponent<Dropdown>().AddOptions(dropOptions);

        webrtcConnection = new RTCPeerConnection();

        onDataChannelOpen = () =>
        {
            Debug.Log("OPEN LOCAL DATACHANNEL");
            var welcome = "Welcome to Unity WebRTC! Local creation!";
            dataChannel.Send(welcome);
            dataChannel.OnMessage = onDataChannelMessage;
        };

        onDataChannel = channel =>
        {
            remoteDataChannel = channel;
            Debug.Log("OPEN REMOTE DATACHANNEL");
            var welcome = "Welcome to Unity WebRTC! Remote creation!";
            remoteDataChannel.Send(welcome);
            remoteDataChannel.OnMessage = onDataChannelMessage;
        };

        onDataChannelMessage = bytes =>
        {
            var messageText = System.Text.Encoding.UTF8.GetString(bytes);
            Debug.Log("Incoming datachannel message: " + messageText);
        };

        onDataChannelClose = () =>
        {
            Debug.Log("CLOSE DATACHANNEL");
        };

        onIceConnectionChange = state => {
            OnIceConnectionChange(webrtcConnection, state);
        };

        onIceCandidate = candidate => {
            OnIceCandidate(webrtcConnection, candidate);
        };

    }

    void Update()
    {
        if (incomingWebsocketMessages.TryDequeue(out var wsmessage))
        {
            var answer = JsonUtility.FromJson<WSMessage<string>>(wsmessage);

            if (answer.type.Equals("USERID") && !answer.data.Equals(userId))
            {
                userId = answer.data;
                SetUserId(userId);
            }
            else if (answer.type.Equals("NEWMEMBER") && !answer.userId.Equals(userId))
            {
                string newcandidate = answer.userId;
                AddNewWsCandidate(newcandidate);
            }
            else if (answer.type.Equals("OFFER") && !answer.userId.Equals(userId))
            {
                var incomingOffer = JsonUtility.FromJson<WSMessage<Offer>>(wsmessage);
                Debug.Log("INCOMING OFFER:" + incomingOffer);

                var desc = new RTCSessionDescription();
                desc.type = RTCSdpType.Offer;
                desc.sdp = incomingOffer.payload.sdp;

                wscandidates.GetComponent<Dropdown>().captionText.text = answer.userId;

                StartCoroutine(HandleCall(desc));
            }
            else if (answer.type.Equals("ICE") && !answer.userId.Equals(userId))
            {
                var iceCandidate = JsonUtility.FromJson<WSMessage<Ice>>(wsmessage);

                Debug.Log("INCOMING ICE:" + answer);

                RTCIceCandidateInit init = new RTCIceCandidateInit();
                init.candidate = iceCandidate.payload.candidate;
                init.sdpMid = iceCandidate.payload.sdpMid;
                init.sdpMLineIndex = iceCandidate.payload.sdpMLineIndex;

                RemoteIceCandidate(webrtcConnection, new RTCIceCandidate(init));
            }
            else if (answer.type.Equals("ANSWER") && !answer.userId.Equals(userId))
            {
                var incomingAnswer = JsonUtility.FromJson<WSMessage<Answer>>(wsmessage);
                Debug.Log("INCOMING ANSWER:" + incomingAnswer.payload.type + "  sdp:" + incomingAnswer.payload.sdp);

                var desc = new RTCSessionDescription();
                desc.type = RTCSdpType.Answer;
                desc.sdp = incomingAnswer.payload.sdp;

                StartCoroutine(ConsumeAnswer(desc));
            }
        }
    }
    public void AddNewWsCandidate(string candidate)
    {
        dropOptions.Add(candidate);
        wscandidates.GetComponent<Dropdown>().ClearOptions();
        wscandidates.GetComponent<Dropdown>().AddOptions(dropOptions);
        wscandidates.GetComponent<Dropdown>().RefreshShownValue();
    }

    IEnumerator HandleCall(RTCSessionDescription desc)
    {

        webrtcConnection.OnIceCandidate = onIceCandidate;
        webrtcConnection.OnIceConnectionChange = onIceConnectionChange;
        webrtcConnection.OnDataChannel = onDataChannel;

        var op = webrtcConnection.SetRemoteDescription(ref desc);
        yield return op;

        if (!op.IsError)
        {
            Debug.Log("Set Remote Description complete");
        }
        else
        {
            var error = op.Error;
            Debug.Log("ERROR Set Session Description: " + error);
        }

        var op2 = webrtcConnection.CreateAnswer();
        yield return op2;
        if (!op2.IsError)
        {
            string remoteUuid = wscandidates.GetComponent<Dropdown>().captionText.text;

            var wsAnswer = new WSMessage<Answer>
            {
                userId = userId,
                type = "ANSWER",
                data = "",
                payload = new Answer(op2.Desc.sdp, "answer"),
                toUserId = remoteUuid
            };

            Debug.Log("CREATE ANSWER:" + JsonUtility.ToJson(wsAnswer));

            ws.Send(JsonUtility.ToJson(wsAnswer));

            Debug.Log("SEND ANSWER:" + JsonUtility.ToJson(wsAnswer));

            yield return OnCreateAnswerSuccess(op2.Desc);
        }
        else
        {
            Debug.Log("ERROR Create Session Description:" + op2.Error.message);
        }

    }

    IEnumerator OnCreateAnswerSuccess(RTCSessionDescription desc)
    {
        var op = webrtcConnection.SetLocalDescription(ref desc);
        yield return op;

        if (!op.IsError)
        {
            Debug.Log("Set Local Description complete");
        }
        else
        {
            var error = op.Error;
            Debug.Log("ERROR Set Session Description: " + error.message);
        }
    }

    IEnumerator ConsumeAnswer(RTCSessionDescription desc)
    {

        var op = webrtcConnection.SetRemoteDescription(ref desc);
        yield return op;

        if (!op.IsError)
        {
            Debug.Log("Set Remote Description complete");
        }
        else
        {
            var error = op.Error;
            Debug.Log("ERROR Set Session Description: " + error.message);
        }

    }

    public void CallWebRTC()
    {
        StartCoroutine(Call());
    }

    IEnumerator Call()
    {
        webrtcConnection.OnIceCandidate = onIceCandidate;
        webrtcConnection.OnIceConnectionChange = onIceConnectionChange;

        var option = new RTCDataChannelInit();
        dataChannel = webrtcConnection.CreateDataChannel("datachannel", option);
        dataChannel.OnOpen = onDataChannelOpen;
        dataChannel.OnClose = onDataChannelClose;
        webrtcConnection.OnDataChannel = onDataChannel;

        var op = webrtcConnection.CreateOffer();
        yield return op;

        if (!op.IsError)
        {

            string remoteUuid = wscandidates.GetComponent<Dropdown>().captionText.text;

            var wsOffer = new WSMessage<Offer>
            {
                userId = userId,
                type = "OFFER",
                data = "",
                payload = new Offer(op.Desc.sdp, "offer"),
                toUserId = remoteUuid
            };

            Debug.Log("CREATE OFFER:" + JsonUtility.ToJson(wsOffer));

            ws.Send(JsonUtility.ToJson(wsOffer));

            Debug.Log("SEND OFFER:" + JsonUtility.ToJson(wsOffer));

            yield return StartCoroutine(OnCreateOfferSuccess(op.Desc));
        }
        else
        {
            Debug.Log("ERROR Create Session Description: " + op.Error);
        }

    }

    IEnumerator OnCreateOfferSuccess(RTCSessionDescription desc)
    {
        var op = webrtcConnection.SetLocalDescription(ref desc);
        yield return op;

        if (!op.IsError)
        {
            Debug.Log("Set Local Description complete");
        }
        else
        {
            var error = op.Error;
            Debug.Log("ERROR Set Session Description: " + error);
        }

    }

    public void SendDirectMessage()
    {
        var message = "Message from Unity " + DateTime.Now;
        if (dataChannel != null && dataChannel.ReadyState == RTCDataChannelState.Open) dataChannel.Send(message);
        if (remoteDataChannel != null && remoteDataChannel.ReadyState == RTCDataChannelState.Open) remoteDataChannel.Send(message);
    }

    void OnIceConnectionChange(RTCPeerConnection pc, RTCIceConnectionState state)
    {
        switch (state)
        {
            case RTCIceConnectionState.New:
                Debug.Log("IceConnectionState: New");
                break;
            case RTCIceConnectionState.Checking:
                Debug.Log("IceConnectionState: Checking");
                break;
            case RTCIceConnectionState.Closed:
                Debug.Log("IceConnectionState: Closed");
                break;
            case RTCIceConnectionState.Completed:
                Debug.Log("IceConnectionState: Completed");
                break;
            case RTCIceConnectionState.Connected:
                Debug.Log("IceConnectionState: Connected");
                break;
            case RTCIceConnectionState.Disconnected:
                Debug.Log("IceConnectionState: Disconnected");
                break;
            case RTCIceConnectionState.Failed:
                Debug.Log("IceConnectionState: Failed");
                break;
            case RTCIceConnectionState.Max:
                Debug.Log("IceConnectionState: New");
                break;
            default:
                break;
        }
    }

    void OnIceCandidate(RTCPeerConnection webrtcConnection, RTCIceCandidate candidate)
    {
        webrtcConnection.AddIceCandidate(candidate);
        Debug.Log("ADDED local ICE candidate:" + candidate.Candidate);
        string remoteUuid = wscandidates.GetComponent<Dropdown>().captionText.text;
        var iceCand = new WSMessage<Ice>
        {
            userId = userId,
            type = "ICE",
            data = "",
            payload = new Ice(candidate.Candidate, (int)candidate.SdpMLineIndex, candidate.SdpMid, candidate.UserNameFragment),
            toUserId = remoteUuid
        };
        ws.Send(JsonUtility.ToJson(iceCand));
        Debug.Log("SEND ICE:" + JsonUtility.ToJson(iceCand));
    }

    void RemoteIceCandidate(RTCPeerConnection webrtcConnection, RTCIceCandidate candidate)
    {
        webrtcConnection.AddIceCandidate(candidate);
        Debug.Log("ADDED remote ICE candidate:" + candidate.Candidate);
    }

    public void ConnectWebsocket()
    {
        ws.Connect();
    }

    public void DisconnectWebsocket()
    {
        ws.Close();
    }

    public void LoginWebsocket()
    {
        var hello = new WSMessage<string>
        {
            userId = "",
            type = "LOGIN",
            data = "",
            payload = "",
            toUserId = ""
        };
        ws.Send(JsonUtility.ToJson(hello));
    }

    public void SendNewmember()
    {
        var newmember = new WSMessage<string>
        {
            userId = userId,
            type = "NEWMEMBER",
            data = "",
            payload = "",
            toUserId = ""
        };

        ws.Send(JsonUtility.ToJson(newmember));
    }

    void SetUserId(string userId)
    {
        uuid.GetComponent<UnityEngine.UI.Text>().text = userId;
    }

}

[Serializable]
public class WSMessage<T>
{
    public string userId;
    public string type;
    public string data;
    public T payload;
    public string toUserId;
}

[Serializable]
public class Offer
{
    public string type;
    public string sdp;

    public Offer(string sdp, string type)
    {
        this.type = type;
        this.sdp = sdp;
    }
}

[Serializable]
public class Answer
{
    public string type;
    public string sdp;
    public Answer(string sdp, string type)
    {
        this.type = type;
        this.sdp = sdp;
    }
}

[Serializable]
public class Ice
{
    public string candidate;
    public int sdpMLineIndex;
    public string sdpMid;
    public string usernameFragment;

    public Ice(string candidate, int sdpMLineIndex, string sdpMid, string usernameFragment)
    {
        this.candidate = candidate;
        this.sdpMLineIndex = sdpMLineIndex;
        this.sdpMid = sdpMid;
        this.usernameFragment = usernameFragment;
    }
}

Добавляем наши методы SendDirectMessage и CallWebRTC на созданные Button и можем приступить к проверке.

Проверка взаимодействия компонентов

Теперь можем провести проверку между браузером и Unity с двух сторон и между Unity и Python скриптом.

проверка:

Unity – Браузер (желательно в обе стороны)

Unity – Python:

Вуаля, мы видим, что сообщения успешно отправляются и принимаются, а это значит что у нас закончилась 3 часть.


Часть 4. Настройка WebRTC Media streaming

В предыдущих частях у нас получилось сделать datachannel между браузером-python-unity, теперь этот код пора обогатить медиаконтентом.

Серверный компонент

Добавим в созданную разметку main.html новые элементы для видео

main.html
...
<div class="row-md-6">
  <form class="form-inline">
    <button id="startStream" class="btn btn-primary" type="submit">Start media streaming</button>
  </form>
  </div>
  <div class="row-md-6">
    <video autoplay id="myvideo" width="320" height="240" controls></video>
    <video autoplay id="remoteVideo" width="320" height="240" controls></video>
</div>
...

Доработаем наш скрипт следующим образом:

robo.js
...
const constraints = {
  video: true, audio: true
};
var videoStream;
...

// Добавим в function initRTCPeerConnection()
// video handling
const remoteVideo = document.getElementById('remoteVideo');
peerConnection.addEventListener('track', async (event) => {
  const remoteStream = event.streams[0];
  remoteVideo.srcObject = remoteStream;
  console.log('Received remote stream');
});

// Добавим старт медиа стриминга:
function statrStream(){
  navigator.mediaDevices.getUserMedia(constraints)
  .then(stream => {
    videoStream = stream;
    stream.getTracks().forEach(track => {
      peerConnection.addTrack(track, videoStream);
      console.log("added track:" + track);
    });
      console.log("sucsess started media stream");
    recreateOffer();
  })
  .catch(function(err){
    console.log("errors when media stream");
  });
  attachVideoToScreen();
}

function recreateOffer(){
  peerConnection.createOffer(function(offer) {
    connection.send(JSON.stringify({'userId' : userId, 'type' : 'OFFER', 'payload' : offer , 'toUserId' : $("#offerTo").val()}));
    peerConnection.setLocalDescription(offer);
  },
  function(error) {
    console.log("error in offer creating:" + error);
  });
}

function attachVideoToScreen(){
  const localvideo = document.getElementById('myvideo');
  if (videoStream != null) {
    localvideo.srcObject = videoStream;
    localvideo.onloadedmetadata = () => {
      localvideo.play();
    }
    console.log("sucsess attached media stream");
  } else {
    setTimeout(attachVideoToScreen, 500);
  }
}

// Не забываем сделать кнопку активной:
...
$( "#startStream" ).click(function() { statrStream(); });
...

Теперь подключаем USB-камеру к компьютеру и можем сделать проверку работы видео/аудио между браузерами в обе стороны, я делаю между браузером компьютера и телефона и видим, что все идет успешно.

Исполнительный компонент(Python-скрипт на RPI)

При поступлении offer от Управляющего компонента нам нужно получить видео с USB-камеры и создать видеотрак, который добавить к webrtc-connection. В пакете aiortc для реализации передачи медиа есть специальные хелперы. Реализуем их в нашем Python скрипте:

part3.py
## добавим 2 метода:
def force_codec(pc, sender, forced_codec):
    kind = forced_codec.split("/")[0]
    codecs = RTCRtpSender.getCapabilities(kind).codecs
    transceiver = next(t for t in pc.getTransceivers() if t.sender == sender)
    transceiver.setCodecPreferences(
        [codec for codec in codecs if codec.mimeType == forced_codec]
    )


async def add_video(peer_conn) -> None:
    options = {"framerate": "30", "video_size": "640x480", "preset": "fast"}
    relay_v = MediaRelay()
    webcam = MediaPlayer("/dev/video0", format="v4l2", options=options)
    video_sender = peer_conn.addTrack(relay_v.subscribe(webcam.video, buffered=False))
    force_codec(peer_conn, video_sender, "video/H264")

## И в раздел хендлинга оффера добавим вызов добавления видео и отправку оффера с новым кандидатом.

            await add_video(peer_conn)
            
            offer = await peer_conn.createOffer()
            await peer_conn.setLocalDescription(offer)
            await websocket.send(json.dumps({"userId": local_user_id, "type": "OFFER", "data": "",
                                             "payload": {"sdp": offer.sdp, "type": offer.type},
                                             "toUserId": msg.get("userId")}))

Хелперы основаны на ffmpeg поэтому в параметры опций мы можем включать необходимые опции для ускорения ffmpeg, это может быть очень полезно для снижения задержек видео.

Теперь можем провести проверку работы между Python и браузером. Если вдруг медиа траки принимаются в браузере, но видео не воспроизводится - то перезапускаем хром, так же можем пользоваться инструментом - chrome://webrtc-internals/

проверка:

Управляющий компонент(Unity VR)

С этой частью все несколько сложнее, для начала мы сделаем panel для отображения видео, для этого просто продублируем созданный ранее Connection Panel, переименуем его в Robo Video Panel, удалим из него все объекты и поместим в него 2 новых объекта:

  • Raw Image - для отображения видео из видеотрека, ставим тэг «remoteimage»

  • Audiosource - для аттача аудио из аудиотрека webrtc, ставим тэг «receivedaudiosource»(это на будущее)

Вот так:

и добавим собственно само добавление видео/аудио из траков:

Connection.cs:
...
private RawImage remoteImage;
private DelegateOnTrack webrtcСonnOntrack;
private AudioSource receivedAudioSource;
...

// Аттачим объекты по тегам в Start методе и добавим делагат на событие добавления траков:
void Start()
….
remoteImage = GameObject.FindGameObjectWithTag("remoteimage").GetComponent<RawImage>();
receivedAudioSource = GameObject.FindGameObjectWithTag("receivedAudioSource").GetComponent<AudioSource>();
….
webrtcСonnOntrack = e =>
        {
            if (e != null)
            {
                Debug.Log("NEW TRACK Recived: " + e.Track.Id);
            }
            if (e.Track is VideoStreamTrack video)
            {
                video.OnVideoReceived += tex =>
                {
                    remoteImage.texture = tex;
                };
            }
            if (e.Track is AudioStreamTrack track)
            {
                receivedAudioSource.SetTrack(track);
                receivedAudioSource.loop = true;
                receivedAudioSource.Play();
            }
        };

// Теперь в хендлинге оффера добавим на OnTrack делегат webrtcСonnOntrack
IEnumerator HandleCall(RTCSessionDescription desc)
{
    webrtcConnection.OnTrack = webrtcСonnOntrack;
...
// И очень важный пункт после хендлинга оффера — провести WebRTC.Update().
...
StartCoroutine(WebRTC.Update());
}

PS: так же можно сделать и трансляцию с камеры из Unity в медиа стрим если хочется смотреть смотреть в браузере, что делается в Unity:
MediaStream videoStream = customCamera.CaptureStream(1280, 720);
MediaStreamTrack trackLocal = videoStream.GetVideoTracks().First();
webrtcConnection.AddTrack(track, videoStream);
Debug.Log("ADDED OUTGOING TRACK:" + trackLocal.Id);

Проверка взаимодействия компонентов

Теперь проверим взаимодействие компонентов, сначала Unity-браузер, потом Unity-Python, как видим обмен контекстом успешен и видео с камеры отображается в Unity.

проверка:

Unity – Python:

PS: я использовал видеокамеру, которая по идее должна выдавать медиа с разрешением 1280х720 30fps, однако по факту она настроена на 640х480, т.к. даже с оптимизированными настройками на ffmpeg мне не удалось найти режима с минимальной задержкой видео. Возможно дело в камере, возможно в кривых руках.

options = {"framerate": "25", "input_format": "mjpeg", "video_size": "1280x720", "preset": "ultrafast", "tune":"zerolatency"}

PS2: судя по инфо по aiortc с камеры так же можно получить аудиопоток, однако у меня это с помощью хелпера MediaPlayer() с alsa не получилось, поэтому я оставил этот вопрос.

Очередная часть закончена, пора приступить к реализации управления.

Продолжение в следующей статье – Управляем роботами из VR. Продолжение 2

Комментарии (0)