Задача хоть интересная, но по объему получилась не маленькая. Разбита на 9 частей, 6 из которых уже готовы и упакованы в 3 статьи. В каждой статье будет много принтскринов и кусков кода(все убрано под спойлеры). Если пропущу какие-то детали – заранее извиняюсь, пишите в личку или комментарии, некоторые особенности мог просто опустить как само собой разумеющееся или просто вылетело из головы.
Части и статусы на момент публикации:
-
Подготовительная (реализована).
-
Сигнализация Websocket (реализована).
-
Настройка WebRTC Connection + DataChannel (реализована).
-
Настройка WebRTC Media streaming (реализована).
-
Настройка управления камерой (реализована).
-
Настройка управления манипулятором (реализована).
-
Перенос на ROS (в процессе).
-
Работа через Интернет (в процессе).
-
Настройка передвижения (не взята).
А вот то, что мы получим к концу 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


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

Теперь нам нужно сделать минимальную сцену-окружение для дальнейших тестов. Добавляем простой Panel Ground. В него добавляем Teleportation Area, и простой материал с цветом на наш пол.
Окружение:
Создаем 2 Empty объекта контейнера для префабов левого и правого контроллера, которые разворачиваем на 180 по «Y», и в каждый из них добавляем префаб модели контроллера из Samples.
Подключаем Quest2 по USB к рабочей станции и подключаемся к Oculus Link. Теперь если запустить в Unity проект (Play) по идее вы попадаете в нашу дефолтную сцену, можете крутить головой в окулусе, телепортироваться по ground, видеть контроллеры с красными лазерами.

Если что-то не будет получаться с Unity XR, смотрим видео тут(ссылка).
Подготовка закончена, по окончании этого этапа у вас есть:
-
Собранный стенд с подвесом камеры.
-
Заготовка сервера сигнализации и управления из веб.
-
Заготовка для исполнительного компонента (RPI-хоста).
-
Заготовка для управляющего компонента (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, проверяем результат в консоли браузера и логе сервера:
проверяем:
Управляющий компонент(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
ссылка на оригинал статьи https://habr.com/ru/articles/730164/
Добавить комментарий