Задача хоть интересная, но по объему получилась не маленькая. Разбита на 9 частей, 6 из которых уже готовы и упакованы в 3 статьи. В каждой статье будет много принтскринов и кусков кода(все убрано под спойлеры). Если пропущу какие-то детали – заранее извиняюсь, пишите в личку или комментарии, некоторые особенности мог просто опустить как само собой разумеющееся или просто вылетело из головы.

Части и статусы на момент публикации:

  1. Подготовительная (реализована).

  2. Сигнализация Websocket (реализована).

  3. Настройка WebRTC Connection + DataChannel (реализована).

  4. Настройка WebRTC Media streaming (реализована).

  5. Настройка управления камерой (реализована).

  6. Настройка управления манипулятором (реализована).

  7. Перенос на ROS (в процессе).

  8. Работа через Интернет (в процессе).

  9. Настройка передвижения (не взята).

А вот то, что мы получим к концу 6 части:

Disclaimer 1.

Некоторые участки кода и решения в этой серии статей могут вызвать неоднозначные чувства у читателей, прошу отнестись к этим особенностям с пониманием, так как:

1. Основная цель – это демонстрация, реализация Proof of Concept, некоторые элементы заведомо не реализовывались, дабы сократить объем работы и материала для публикации.

2. Unity/C#/Python у меня на начальном уровне, а с некоторыми вещами, как с корутинами в python и c#, вообще столкнулся впервые на этапе приготовления этого блюда.

3. Переключаться между 4-мя ЯП достаточно тяжко для моей скороварки, мне и самому плакать хотелось от того, что я наделал, но с какого-то момента я уже просто не мог остановиться, простите.


Часть 1. Подготовительная

Проведя быстрый аудит компонентов, которые у меня были в наличии, я остановился на решении с роботизированной рукой-манипулятором подключенной к RPI3B+, и решил выстроить весь процесс вокруг идеи управления этим манипулятором из VR-приложения, с возможностью быстро доработать это решение управлением не только из локальной сети, но и через Интернет.

Это можно сделать реализовав клиент-серверную архитектуру для взаимодействия компонентов, но тогда получается минус в использовании сервера как единой точки консолидации трафика и будет дополнительная задержка между VR и Манипулятором, поэтому решено использовать WebRTC в качестве решения для P2P трафика, и Websocket для сигнализации WebRTC, еще один плюс WebRTC – элегантный механизм прохода за NAT с помощью stun/turn-серверов в будущем.

В WebRTC для обмена данными существует DataChannel, который можно использовать для передачи данных управления на сервоприводы и MediaStreams для передачи видео/аудио контента, что мы и будем использовать для трансляции видео из USB-камеры в приложение.

Websocket отлично подходит для реализации сигнализации между WebRTC-пирами, можно, конечно, использовать и REST для обмена сигнализацией, но минус REST – периодичность опроса конечной точки при инициации подключения. Т.е. когда мы захотим инициировать новое подключение к манипулятору, нам потребуется ждать очередного периода опроса для обмена контекстом WebRTC.

Таким образом у нас получается комплект из 3-х основных компонентов – Сервер сигнализации для обмена контекстом WebRTC, Исполнительный компонент (RPI-хост) который будет принимать управляющие сигналы для сервоприводов и транслировать видеопоток, и Управляющий компонент(VR-приложение), в котором мы принимаем видеопоток, и из которого мы будем управлять манипулятором отправляя сообщения на RPI-хост. Так же, мы реализуем дублирующий управляющий компонент с помощью HTML/JS, он нам поможет в поэтапной реализации и отладке.

Сервер сигнализации реализуем с помощью SpringBoot, на стороне исполнительного компонента на RPI будем использовать приложение на Python, управляющий компонент сделаем с помощью Unity XR, так же на стороне сервера сигнализации продублируем управляющий компонент с помощью стандартными средствами веб – html-bootstrap-js-jquery.

Disclaimer 2

Вообще, все что касается робототехники нужно делать с использованием ROS на стороне исполнительного компонента, но мне было интересно посмотреть на альтернативу попроще, а уже потом перенести на ROS. Как говорится – все познается в сравнении.

Используемые компоненты:

  • VR-гарнитура с контроллерами, я буду использовать Oculus Quest 2.

  • Кабель USB Type-C 1-2м, который будем использовать для подключения VR-гарнитуры к Unity для тестирования и отладки.

  • Raspberry Pi 3B+ с БП.

  • Плата PCA9685, это плата расширения, она соединяется I2C интерфейсом с RPI и позволяет управлять 16-ю ШИМ сервоприводами.

  • Кронштейн-подвес для SG90 и 2 сервопривода SG90, для вращения подвеса камеры.

  • USB-веб-камера, будет использоваться для трансляции видео в VR.

  • Манипулятор(6Dof Arm MG996R/YF-6125MG у меня такой), собственно тот манипулятор, которым мы будем управлять из VR.

  • Комплект соединительных проводов Мама-Папа(40см.). У моих сервоприводов стоковые кабели коротки и их не дотянуть до платы PCA9685.

  • Блок питания 220V – 5V. Для запитывания серво.

  • Основание для фиксации компонентов (манипулятор, RPI, БП, подвес для камеры), у меня это лишняя часть пластикового стеллажа, куда смонтированы все компоненты.

  • (желательно) Роутер на OWRT, если будете проводить тесты по RTT/Jitter.

  • (желательно) Более-менее прямые руки, но и с "не очень" получится с нескольких попыток.

Требуемая экспертиза:

  • Начальные знания по Linux.

  • Начальные знания по Python.

  • Начальные знания по Java и SpringBoot.

  • Начальные знания по Unity и C#.

  • Начальные знания по HTML/Bootstrap/JS/JQuery.

  • Начальные знания по Websocket и WebRTC.

Основные операции будут проводиться на рабочей станции под Win10, периодически переключаясь на Ubuntu WSL для работы с RPI. На Win10 установлены:

  • Eclipse IDE, для SpringBoot.

  • Unity + MS Visual Studio, для VR части.

  • Oculus Client, нужен для Oculus Link.(Возможно потребуются драйверы ADB для Oculus, но у меня они уже были установлены ранее).

Исполнительный компонент

Для начала собираем минимальный исполнительный комплект – подвес камеры с SG90 и соединяем его с платой PCA9685(я разобрал свою USB-камеру, т.к. корпус был большой и неудобный).

В плату PCA9685 сервы SG90 устанавливаем в порты 0 и 1. Они будут отвечать за ротацию камеры по осям(поворот головы в VR-гарнитуре). Далее делаем установку камеры на кронштейн на основе за местом, где планируется манипулятор. Кабели от разъемов привода удлиняем с помощью дополнительных кабелей «Папа-Мама».

подвес:

Делаем соединение платы PCA9685 и RPI3B+:

 GND - Pin-6
 SCL - GPIO-3(Pin5)
 SDA - GPIO-2(Pin3)
 VCC - Pin1
 V+  - Pin4

Питание для серво должно идти через отдельную клемму на PCA9685, но до 6 части мы будем использовать только 2 серво SG90 и брать питание с RPI можем напрямую.

Так же рекомендую использовать обычный светодиод подключенный к RPI для проведения маленького наглядного тестирования работы RPI и скриптов (Pin 39-40) (как же можно обойтись без мигания светодиодом).

Далее, подготавливаем RPI, заливаем ОС с помощью RPI Imager(RPI OS Light x64). После успешной заливки делаем стандартные апдейт/апгрейд apt и личные предпочтения по настройке системы(ssh-ключи, samba и т.д.).

Для работы с интерфейсом I2C в RPI нам нужен пакет пакет с i2c-tools:

sudo apt install i2c-tools -y

Заходим в raspi конфиг и включаем поддержку I2C:

sudo raspi-config

Interface Options → <Enable I2C>

reboot

Проверяем I2C:

i2cdetect -y 1

Должны получить такое:
0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f
00:                         -- -- -- -- -- -- -- --
10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
30: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
40: 40 -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
50: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
60: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
70: 70 -- -- -- -- -- -- –

Это значит все норм и плата обнаружена.

Далее, для работы с серво через PCA9685 из Python нам понадобится pip3-пакеты, поэтому ставим pip3:

sudo apt-get install python3-pip

И ставим сами пакеты:

pip3 install adafruit-circuitpython-pca9685

pip3 install adafruit-circuitpython-motorkit

Установка пакетов глобально это конечно плохой тон, но тут мы срежем углы, т. к. все равно все будет на ROS.

Теперь можем проверить работу маленьким скриптом:

nano /home/pi3/shared/testpi3b/part0.py

part0.py
import RPi.GPIO as GPIO
import time
import busio
from board import SCL, SDA
from adafruit_motor import servo
from adafruit_pca9685 import PCA9685

GPIO.setmode(GPIO.BCM)
GPIO.setup(21, GPIO.OUT)
i2c = busio.I2C(SCL, SDA)
pca = PCA9685(i2c)
pca.frequency = 50
servoX = servo.Servo(pca.channels[0], min_pulse=500, max_pulse=2400)
    
print("Simple testing")

try:
    for i in range(3):
        print("Blink")
        GPIO.output(21,True)
        servoX.angle = 0
        time.sleep(1)
        GPIO.output(21,False)
        servoX.angle = 180
        time.sleep(1)
except KeyboardInterrupt:
    GPIO.cleanup()

print("Test done")
GPIO.cleanup()

PS: Работа с серво по ШИМ имеет свои особенности, поэтому рекомендую сначала потратить часик на чтение материала по теме.

Если все сделали правильно - то светодиод моргнет 3 раза и сервомотор в 0-порту PCA9685 3 раза сделает поворот 0-180 градусов. По деталям работы из Python с PCA9685 можно почитать тут, с Rpi.GPIO тут. На этом подготовительная часть с RPI-хостом закончена.

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

Следующим пунктом будет подготовка и установка SSL сертификатов для работы websocket-over-ssl (можно конечно обойтись без SSL, но мне хотелось сразу решить этот вопрос и получить возможность тестировать видео в браузере).

Генерируем новый самоподписанный сертификат(я это делаю из под WSL Ubuntu 20):

sudo openssl req -x509 -nodes -days 1825 -newkey rsa:2048 -keyout ./ssl/ssl-selfsigned.key -out ./ssl/ssl-selfsigned.crt

... отвечаем на все вопросы, указываем IP-хоста на котором будет крутиться сервер. И сразу же переводим его в формат PKS#12:

openssl pkcs12 -export -in ./ssl/ssl-selfsigned.crt -inkey ./ssl/ssl-selfsigned.key -out ./ssl/ssl-selfsigned.p12

Файлы сертификатов копируем на Win10, проводим установку сертификата на Win10.

Теперь сделаем заготовку для сервера сигнализации. Делаем новый SpringBoot проект через Spring Initializr для сервера сигнализации, нам потребуются зависимости:

Зависимости:
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.webjars:webjars-locator-core'
implementation 'org.webjars:sockjs-client:1.0.2'
implementation 'org.webjars:bootstrap:3.3.7'
implementation 'org.webjars:jquery:3.1.1-1'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'

Делаем базовую настройку работы на порту 9000 с нашим сертификатом:

application.yml:
server:
  port: 9000
  ssl:
    key-store: classpath:ssl-selfsigned.p12
    key-store-password:
    keyStoreType: PKCS12

Подавляем идентификацию/аутентификацию с использованием SecurityFilterChain:

SecurityConfiguration.java
@Configuration
public class SecurityConfiguration{
	@Bean
	SecurityFilterChain securityFilterChain(HttpSecurity http) 
    throws Exception {
    	http
    	.authorizeHttpRequests((requests) -> requests
    		.requestMatchers("/", "/**", "/main", "/main/**")
            .permitAll()
    	);
	return http.build();
	}
}

Делаем простейший контроллер, страницу для HTML и JS-скрипт:

SimpleController.java
@Controller
public class SimpleController {
	@GetMapping("/main")
	public String roboPage() {
		return "main";
	}
}

main.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Robo WS+RTC:</title>
<!-- Head bootstrap as a fragment -->
<div th:insert="~{fragments :: bootstraphead}"></div>
<script src="/robo.js"></script>
</head>
  <body>
    <div id="main-content" class="container">
      <div class="row-md-6">
      <p>Part 0</p>
      </div>
    </div>
  </body>
</html>

fragments.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<!-- Bootstraphead CSS -->
  <div th:fragment="bootstraphead"> 
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
  
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.4.1/dist/css/bootstrap.min.css" 
      integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
    <script src="https://code.jquery.com/jquery-3.4.1.slim.min.js" 
      integrity="sha384-J6qa4849blE2+poT4WnyKhv5vZF5SrPo0iEjwBvKU7imGFAV0wwj1yYfoRSJoZ+n" crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js" 
      integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.6/dist/umd/popper.min.js" 
      integrity="sha384-oBqDVmMz9ATKxIep9tiCxS/Z9fNfEXiDAYTujMAeBAsjFuCZSmKbSSUnQlmh/jp3" crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.min.js" 
      integrity="sha384-cuYeSxntonz0PPNlHhBs68uyIAVpIIOZZ5JqeqvYYIcEL727kskC66kF92t6Xl2V" crossorigin="anonymous"></script>
  </div>
</body>
</html>

Файл robo.js - пока оставим пустой, он нам потребуется дальше.

Структура проекта:

Такая настройка позволит работать через HTTPS и не требует ввода логина/пароля при соединении(нам это пока не требуется). Теперь запускаем в BootDashboard и проверяем в браузере(https://<ip>:9000/main), что страница открывается успешно через https.

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

И последний подготовительный этап - сделаем базовое VR-приложение. Создаем новый проект Unity. Для начала у нас должен быть установлен Unity Hub, Oculus Client, и IDE для C#. Через Unity Hub устанавливаем Editor (2021.3.19f1). Создаем новый проект (3D Core). После загрузки проекта установим XR Plugin Managment и сделаем стартовые настройки для XR:

настройки(много принтскринов):

Давайте дальше добавим еще один компонент - XR Interaction Toolkit, который отвечает за взаимодействие с элементами сцены с помощью шлема и контроллеров.

XR Interaction Toolkit
Window -> Package Manager -> Unity Registry -> XR Interaction Toolkit -> Install
Window -> Package Manager -> Unity Registry -> XR Interaction Toolkit -> Install

После установки импортируем Starter Assets, этот ассет поможет в освоении базового взаимодействия с элементами сцены, там есть хорошие префабы и DemoScene для экспериментов.

Теперь добавим готовые пресеты из XR Interaction Toolkit в наш проект, для этого зайдем в директорию Samples → XR Interaction Toolkit → 2.2.0 → Starter Assets и увидим там 8 файлов пресетов для разного типа взаимодействия Scene, добавим их все.

Пресеты:

Теперь нам нужно сделать минимальную сцену-окружение для дальнейших тестов. Добавляем простой Panel Ground. В него добавляем Teleportation Area, и простой материал с цветом на наш пол.

Окружение:
Добавляем XR Interaction Manager → Add Input Action Manager → XRI Default Input Actions:
Добавляем XR Interaction Manager → Add Input Action Manager → XRI Default Input Actions:
Добавляем XR Origin:
Добавляем XR Origin:

Создаем 2 Empty объекта контейнера для префабов левого и правого контроллера, которые разворачиваем на 180 по «Y», и в каждый из них добавляем префаб модели контроллера из Samples.

Каждому контроллеру добавляем наши созданные контейнеры:
Каждому контроллеру добавляем наши созданные контейнеры:

Подключаем Quest2 по USB к рабочей станции и подключаемся к Oculus Link. Теперь если запустить в Unity проект (Play) по идее вы попадаете в нашу дефолтную сцену, можете крутить головой в окулусе, телепортироваться по ground, видеть контроллеры с красными лазерами.

Если что-то не будет получаться с Unity XR, смотрим видео тут(ссылка).

Подготовка закончена, по окончании этого этапа у вас есть:

  1. Собранный стенд с подвесом камеры.

  2. Заготовка сервера сигнализации и управления из веб.

  3. Заготовка для исполнительного компонента (RPI-хоста).

  4. Заготовка для управляющего компонента (VR-приложения).


Часть 2. Сигнализация Websocket

Теперь можем приступить к связыванию компонентов с помощью сервера сигнализации.

Основные сущности для сигнализации:

  • userId/toUserId – идентификаторы пользователей(RPI-хост, VR-клиент, браузерный клиент).

  • тип сообщения исходя из описания WebRTC→ OFFER, ANSWER, ICE

  • наши типы процесса установления соединения → LOGIN, NEWMEMBER

  • data – доп. поле для служебной информации установления UUID.

  • payload – собственно сами offer/answer/ice из WebRTC в виде json.

Cервер сигнализации

Для работы по Websocket с WebRTC нам нужно реализовать модель сообщения:

SignalData.java
@Data
public class SignalData {
  private String userId;
  private SignalType type;
  private String data;
  private JsonNode payload;
  private String toUserId;
}

SignalType.java
public enum SignalType {
  LOGIN,
  USERID,
  OFFER,
  ANSWER,
  ICE,
  NEWMEMBER
}

сделать хендлер конфигурации:

SignalingConfiguration.java
@Configuration
@EnableWebSocket
public class SignalingConfiguration implements WebSocketConfigurer{
  @Override
  public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
    registry.addHandler(new SignalingHandler(), "/robopi_webrtc");
  }
}

и хендлер самих сообщений сигнализации:

SignalingHandler.java
@Slf4j
public class SignalingHandler extends TextWebSocketHandler {
  final ObjectMapper mapper = new ObjectMapper();
  List<WebSocketSession> sessions = new LinkedList<WebSocketSession>();
  ConcurrentHashMap<String,WebSocketSession> sessionMap = new ConcurrentHashMap<String,WebSocketSession>();
  
  @Override
  protected void handleTextMessage(WebSocketSession session, 
                                  TextMessage message) throws Exception {
    mapper.configure(JsonParser.Feature.ALLOW_UNQUOTED_FIELD_NAMES, true);
    final String msg = message.getPayload();
    SignalData sigData = mapper.readValue(msg, SignalData.class);
    
    if(sigData.getType().equals(SignalType.LOGIN)){
      var sigResp = new SignalData();
      var userId = UUID.randomUUID().toString();
      sigResp.setUserId("SIGNALLING_SERVER");
      sigResp.setType(SignalType.USERID);
      sigResp.setData(userId);
      sessionMap.put(userId, session);
      session.sendMessage(new TextMessage(mapper.writeValueAsString(sigResp)));
      return ;
    }
    else if(sigData.getType().equals(SignalType.NEWMEMBER)) {
      sessionMap.values().forEach(a -> {
        var sigResp =new SignalData();
        sigResp.setUserId(sigData.getUserId());
        sigResp.setType(SignalType.NEWMEMBER); 
          try {
            if(a.isOpen()) a.sendMessage(
              new TextMessage(mapper.writeValueAsString(sigResp))
            );
          }
          catch(Exception e) {
            log.info("Error Sending message:", e);
          }
      });
      return ;
    }
    else if(sigData.getType().equals(SignalType.OFFER)) {
      var sigResp = new SignalData();
      sigResp.setUserId(sigData.getUserId());
      sigResp.setType(SignalType.OFFER);
      sigResp.setData(sigData.getData());
      sigResp.setPayload(sigData.getPayload());
      sigResp.setToUserId(sigData.getToUserId());
      sessionMap.get(sigData.getToUserId()).sendMessage(
        new TextMessage(mapper.writeValueAsString(sigResp))
      );
    }
    else if(sigData.getType().equals(SignalType.ANSWER)) {
      var sigResp = new SignalData();
      sigResp.setUserId(sigData.getUserId());
      sigResp.setType(SignalType.ANSWER);
      sigResp.setData(sigData.getData());
      sigResp.setPayload(sigData.getPayload());
      sigResp.setToUserId(sigData.getToUserId());
      sessionMap.get(sigData.getToUserId()).sendMessage(
        new TextMessage(mapper.writeValueAsString(sigResp))
      );
    }
    else if(sigData.getType().equals(SignalType.ICE)) {
      var sigResp = new SignalData();
      sigResp.setUserId(sigData.getUserId());
      sigResp.setType(SignalType.ICE);
      sigResp.setData(sigData.getData());
      sigResp.setPayload(sigData.getPayload());
      sigResp.setToUserId(sigData.getToUserId());
      sessionMap.get(sigData.getToUserId()).sendMessage(
        new TextMessage(mapper.writeValueAsString(sigResp))
      );
      }
  }
  
  @Override
  public void afterConnectionEstablished(WebSocketSession session) 
    throws Exception {
    sessions.add(session);
    super.afterConnectionEstablished(session);
  }
  
  @Override
  public void afterConnectionClosed(WebSocketSession session, CloseStatus closeStatus) 
    throws Exception {
    sessions.remove(session);
    super.afterConnectionClosed(session, closeStatus);
    }
  }

PS – в хендлер сообщений сигнализации лучше добавить логирование всех сообщений в websocket, это упростит трейс обмена сообщениями.

Для браузерной части – дополняем наш main.html новыми элементами: соединение с websocket-сервером, отправка запроса на UUID, и NEWMEMBER Info:

main.html
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>Robo WS+RTC:</title>
  <link rel="icon" href="data:;base64,iVBORw0KGgo=">
  <!-- Head bootstrap as a fragment -->
  <div th:insert="~{fragments :: bootstraphead}"></div>
  <script src="/robo.js"></script>
</head>
<body>
  <div id="main-content" class="container">
    <div class="row-md-6">
      <label for="username">User id:</label>
      <span id="username">unknown</span>
    </div>
    <div class="row-md-6">
      <form class="form-inline">
        <div class="form-group">
          <label for="connect">WebSocket connection:</label>
          <button id="connect" class="btn btn-primary" type="submit">Connect</button>
          <button id="disconnect" class="btn btn-secondary" type="submit">Connect</button>
        </div>
      </form>
    </div>
    <div class="row-md-6">
      <form class="form-inline">
        <button id="login" class="btn btn-primary" type="submit">Login</button>
      </form>
    </div>
    <div class="row-md-6">
      <form class="form-inline">
        <button id="newmember" class="btn btn-primary" type="submit">New member info</button>
      </form>
    </div>
  </div>
</body>
</html>

Формируем скрипт robo.js для обработки соединения:

robo.js
var connection;
var userId = 'unknown';

function connect(){
  connection = new WebSocket('wss://' + window.location.host + '/robopi_webrtc');
  console.log("Connsection sucsess");
  
  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(resp);
      }
    }
    if(resp.type == 'OFFER'){
      if(userId != resp.userId){
        console.log(resp);
      }
    }
    if(resp.type == 'ICE'){
      if(userId != resp.userId){
        console.log(resp);
      }
    }
    if(resp.type == 'ANSWER'){
      if(userId != resp.userId){
        console.log(resp);
      }
    }
  }
}

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

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

$(function () {
  $("form").on('submit', function (e) {
    e.preventDefault();
  });

$( "#connect" ).click(function() { connect(); });
$( "#login" ).click(function() { login(); });
$( "#newmember" ).click(function() { newmember(); });
});

В итоге, мы можем зайти на страницу https://<ip>:9000/main c разных браузеров и проверить:

должно получится так:

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

В Python для работы с вебсокетами используем библиотеку websockets,

pip3 install websockets

а так же нам понадобиться asynco, ssl и json для работы с сообщениями:

part1.py
import asyncio
import websockets
import json
import ssl
from websockets import WebSocketClientProtocol


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 = ""
    
    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")))
 
        if msg.get("type") == 'ICE' and local_user_id == msg.get("toUserId"):
            print("ICE INCOMING")

        if msg.get("type") == 'ANSWER' and local_user_id == msg.get("toUserId"):
            print("ANSWER INCOMING")


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

Теперь мы можем попробовать подсоединиться и из браузера и из RPI и оправить NEWMEMBER инфо между браузером и Python, проверяем результат в консоли браузера и логе сервера:

проверяем:
RPI сторона
RPI сторона
консоль браузера
консоль браузера
лог на сервере
лог на сервере

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

Для работы с вебсокетом из Unity используем библиотеку WebSocketSharp, для этого сначала поставим NuGetForUnity, а потом оттуда делаем установку WebSocketSharp.

установка:

Далее реализуем небольшой UI: cоздаем Empty Object с названием Connection Panel, становим его параметры Rect Transform и наполняем его компонентами:

параметры Rect Transform и компоненты:

После этого размещаем в Connection Panel несколько элементов:

элементы:
Text   → "WS Header"
Text   → "UUID" (на элементе указываем тэг - «uuid»)
Button → "Connect WS"
Button → "Disonnect WS"
Button → "Login"
Button → "Newmember"
Так же создаем Empty объект хендлер для скрипта → "Connection handler"

Теперь создаем новый C# скрипт, который будет ядром наших соединений websocket и webrtc:

Connection.cs
using System;
using System.Collections.Concurrent;
using UnityEngine;
using WebSocketSharp;

public class Connection : MonoBehaviour
{

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

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

    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))
            {

            }
            else if (answer.type.Equals("OFFER") && !answer.userId.Equals(userId))
            {

            }
            else if (answer.type.Equals("ICE") && !answer.userId.Equals(userId))
            {

            }
            else if (answer.type.Equals("ANSWER") && !answer.userId.Equals(userId))
            {

            }
        }
    }

    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;
}

Готовый скрипт аттачим на объект Connection handler, а его в свою очередь на каждый Button, и выбираем соответствующий метод для этой кнопки:

так:

По нажатию на кнопку вызывается метод из нашего скрипта, происходит соединение, отправка/прием сообщений(+ смотрим на Debug console в Unity)!

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

Тестируем Unity сначала с браузером, потом и с Python скриптом.

тесты c браузером:

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

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

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