Задача хоть интересная, но по объему получилась не маленькая. Разбита на 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