Jedním z nejnovějších buzzwordů průmyslu 4.0, digitalizace a internetizace prakticky všeho je digital twin – digitální dvojče.
Tedy počítačová reprezentace nějakého skutečného předmětu z fyzického světa, která se používá pro jeho snadnou simulaci třeba ve virtuálním prostředí. Dnes si to vyzkoušíme v praxi na ikonickém letounu Antonov An-225 Mrija.
Propojené digitální dvojče
Mrija se stala jednou z prvních obětí ruské invaze na Ukrajinu, díky své výjimečnosti ji ale kutilové už před lety a v mnoha variantách přetvarovali do počítačového 3D modelu. Jeden z nich je k dispozici zdarma na webu Sketchfab a poslouží nám jako digitální dvojče, které bude poletovat nad virtuálním Brnem.
Digitální Mrija od MaTtea0 ze SketchFabu a vytištěná Mrija z Thingiverse. Dnes oba dva světy propojíme bezdrátovou komunikací
Fyzikou Mrijou bude další model z Thingiverse, který vyrobíme na běžné domácí 3D tiskárně. Bude to macek s rozpětím křídel 44 centimetrů, ale nebojte se, tak velkou tiskárnu nepotřebujete, model se totiž skládá z téměř dvacítky jednotlivých dílů, které složíte dohromady.
Podívejte se na video, jak ovládáme digitální Mriju v PC pomocí té vytištěné, která má vlastní počítač, Wi-Fi a gyroskop:
Do hry zapojíme čip gyroskopu
Takže máme jeden digitální a jeden „skutečný“ Antonov An-225, oba světy ale ještě musíme propojit, aby to celé mělo nějaký smysl. Součástí Mriji z umělé hmoty tak bude box místo podvozku, ve kterém bude schovaná česká prototypovací deska ESP32-LPkit s Wi-Fi mikrokontrolerem ESP32 a konektorem pro připojení jednočlánkového lithiového akumulátoru, destička totiž disponuje i nabíjecím obvodem.
Schéma zapojení dnešního projektu
Do schránky ještě zabudujeme jednoduchou šestiosou IMU jednotku MPU-6050 od InvenSense TDK a drobný monochromatický 0,91“ OLED s rozlišením 132×32 pixelů a řadičem SSD1306. Obě součástky komunikují na sběrnici I²C, zapojení tedy bude naprosto jednoduché a znázorňuje jej schéma na obrázku.
Klonění, klopení a bočení
Když schránku zacvakneme do spodní části letounu Mrija a ten čumákem namíříme na monitor počítače, náš firmware po spuštění provede kalibraci gyroskopu a připojí se k předdefinované síti Wi-Fi.
Letecké úhly v praxi
Když poté na počítači spustíme skript v prostředí Processing, se kterým jsme pracovali i v předchozím dílu našeho seriálu o programování elektroniky, program se skrze lokální síť spojí s mikrokontrolerem ESP32 a skrze webový komunikační protokol WebSocket začne číst textová data o aktuálním natočení našeho fyzického letounu v prostor, jak jej změřil gyroskop MPU-6050.
- Natočení okolo podélné osy X říkáme klonění – roll
- Natočení okolo příčné osy Y říkáme klopení – pitch
- Natočení okolo svislé osy Z říkáme bočení – yaw
Když nakloníme plastovou Mriju, natočí se i ta digitální v počítači
Podle zjištěných úhlů Processing analogicky nakloní digitální model Mriji ve virtuálním prostředí oblohy pár set metrů nad Brnem. Z vytištěného letounu tedy uděláme jakýsi bezdrátový letecký joystick, který může přenášet data kamkoliv na internet.
Potřebné knihovny pro Arduino
Celý program pro čip ESP32 v Arduinu se nám vejde i s komentářem na necelé dvě stovky řádků, o vše se totiž postará trojice klíčových knihoven:
- arduinoWebSockets pro práci se stejnojmenným protokolem zejména na čipech ESP8266/ESP32
- MPU6050_tockn pro práci s kombinovaným gyroskopem MPU-6050 a zjištění úhlu natočení od základní roviny získané úvodní kalibrací
- Adafruit_SSD1306 pro práci s maličkým OLED displejem
Sériový výstup našeho programu, pokud bude deska připojená k PC
Potřebné knihovny pro Processing
Na straně Processingu, což je vlastně takové Arduino pro desktopy, pak použijeme tyto dvě knihovny:
Přenos náklonu z fyzické Mriji na tu digitální v prostředí Processing
Zatímco zvukovou knihovnu doinstalujete vyhledáním z nabídky Sketch – Import Library – Manage Libraries, weboscketovou knihovnu stáhnete z GitHubu a rozbalíte do adresáře s knihovnami pro Processing. Na Windows je to zpravidla složka Processing\libraries v adresáři pro dokumenty uživatele.
Zdrojové kódy
Zdrojové kódy dnešního projektu jsou poměrně jednoduché a pečlivě okomentované, namísto slovní vaty se proto podívejte přímo na ně.
Zdrojový kód pro Arduino
/*
Firmware pro Mriju, Arduino a mikrokontroler ESP32
Po pripojeni napajeni se cip:
1) Spoji s 128x32 OLED displejem s I2C radicem SSD1306
2) Spoji s I2C gyroskopem MPU6050
3) Pripoji se k preddefinovane Wi-Fi
4) Na TCP portu 80 nastartuje websocketovy server
Ve smycce bude cekat na pripojeni klienta
Pokud k tomu dojde, zacne mu posilat uhly natoceni gyroskopu
jako prosty text ve formatu X.XXX,Y.YYY,Z.ZZZ
Pouzite externi knihovny pro Arduino:
1) https://github.com/tockn/MPU6050_tockn
2) https://github.com/Links2004/arduinoWebSockets
3) https://github.com/adafruit/Adafruit_SSD1306
*/
#include <WiFi.h> // Wi-Fi komunikace
#include <ESPmDNS.h> // LAN MDNS
#include <esp_wifi.h> // Vynuceni plneho vykonu Wi-Fi
#include <WebSocketsServer.h> // Websockets
#include <Wire.h> // I2C
#include <MPU6050_tockn.h> // Gyroskop MPU6050
#include <Adafruit_SSD1306.h> // OLED displej
// SSID a heslo Wi-Fi (2GHz),
// ke ktere se bude pripojovat fyzicka Mrija
// Pro jednoduchost natvrdo soucasti firmwaru
const char *ssid = "nazev-wifi-site";
const char *password = "heslo-k-wifi";
// Trida gyroskopu
MPU6050 mpu6050(Wire);
// Trida websocketoveho serveru na TCP portu 80
WebSocketsServer websocket = WebSocketsServer(80);
// Trida SSD1306 I2C OLED displeje 128x32 px
Adafruit_SSD1306 display(128, 32, &Wire, -1);
// Pomocne promenne pro aktualizaci dat z gyroskopu
// a zasilani skrze websocket kazdych x milisekund
uint32_t casOdeslanychDat = 0;
uint32_t prodlevaMeziOdeslanim = 100; // ms
// Funkce setup se spusti na zacatku programu
void setup() {
// Nastartovani seriove linky
Serial.begin(115200);
delay(1000);
// Nastartuj sbernici I2C
Wire.begin();
// Nastartuj displej
Serial.print("Startuji displej na I2C adrese 0x3C... ");
if (display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
Serial.println("OK");
}
else Serial.println("CHYBA");
// Nastartuj gyroskop
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.clearDisplay();
display.setCursor(0, 0);
display.print("Gyroskop... ");
display.display();
Serial.print("Startuji gyroskop MPU6050... ");
mpu6050.begin(); // Knihovna pri startu nevraci zadnou hodnotu a nelze overit, jestli spojeni funguje
Serial.println("OK");
display.println("OK");
display.display();
delay(2000);
// Nastartuj Wi-Fi
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.clearDisplay();
display.setCursor(0, 0);
display.print("Wifi...");
display.display();
Serial.print("Startuji Wi-Fi...");
WiFi.mode(WIFI_STA);
// Vynuceni vysokeho vykonu WI-FI, ale tim padem i vyssi spotreby
// https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/network/esp_wifi.html#_CPPv415esp_wifi_set_ps14wifi_ps_type_t
esp_wifi_set_ps(WIFI_PS_NONE);
WiFi.begin(ssid, password);
// Konfigurace MDNS
// Na podporovanych platformach bude
// Mrija dostupna i na LAN domene mrija.local
MDNS.begin("mrija");
MDNS.addService("http", "tcp", 80);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println(" OK");
Serial.printf("IP: %s\r\n", WiFi.localIP().toString().c_str());
display.println(" OK");
display.display();
delay(2000);
// Namir Mriju cumakem k displeji
// Provedu kalibraci gyroskopu
// Natoceni cumakem k displeji je klicove,
// jinak bude Mrija na monitoru natocena
// pod spatnym uhlem v rovine (osa Z/boceni)
display.clearDisplay();
display.setCursor(0, 0);
display.println("Namir Mriju");
display.println("k displeji");
display.display();
Serial.println("Namir zarizeni v rovine na displej");
delay(5000);
display.clearDisplay();
display.setCursor(0, 0);
display.print("Kalibruji... ");
display.display();
Serial.println("Kalibruji gyroskop... ");
mpu6050.calcGyroOffsets(true);
display.print("OK");
display.display();
Serial.println("");
delay(2000);
// Nastartuj websocketovy server na stanovenem TCP portu
// Pri udalosti zavola funkci websocketUdalost
websocket.begin();
websocket.onEvent(websocketUdalost);
// Konfigurace je hotova,
// a tak vypiseme an displej IP adresu Mriji
// Pote se uz spusti smycka loop
display.clearDisplay();
display.setCursor(0, 0);
display.println("IP adresa letadla:");
display.println(WiFi.localIP().toString());
display.display();
}
// Funkce loop se opakuje stale dokola
void loop() {
// Zpracuj ulohy websocketoveho serveru
websocket.loop();
// Pokud se pripojil nejaky websocketovy klient
// a zaroven uplynulo 100 ms od posledniho odeslani dat,
// ziskej nove hodnoty z gyroskopu a odesli je vsem
// pripojenym klientum a pro kontrolu vypis do seriove linky
if (websocket.connectedClients(false) > 0 && millis() - casOdeslanychDat > prodlevaMeziOdeslanim) {
casOdeslanychDat = millis();
mpu6050.update();
char zprava[100];
sprintf(zprava, "%f,%f,%f", mpu6050.getAngleX(), mpu6050.getAngleY(), mpu6050.getAngleZ());
websocket.broadcastTXT(zprava, strlen(zprava));
Serial.println(zprava);
}
}
// Tuto funkci zavola websocketovy server, pokud se neco stane
// Registruji udalost pripojeni/odpojeni klientu a prijem textovych dat
void websocketUdalost(uint8_t id, WStype_t typ, uint8_t *data, size_t length) {
IPAddress ip = websocket.remoteIP(id);
switch (typ) {
// Klient se odpojil
case WStype_DISCONNECTED:
Serial.printf("Klient %u (%s) se odpojil\r\n", id, ip.toString().c_str());
break;
// Klient se pripojil, vypisu jeho IP adresu
case WStype_CONNECTED:
Serial.printf("Klient %u se pripojil z IP adresy %s\r\n", id, ip.toString().c_str());
websocket.sendTXT(id, "#Mrija Websocket Server te vita!");
break;
// Textova ASCII data od klienta
// Jen je vypisu do seriove linky, mohli bychom je ale pouzit
// treba pro konfiguraci
case WStype_TEXT:
Serial.printf("Zprava od klienta %u (%s): %s\n", id, ip.toString().c_str(), data);
break;
}
}
Zdrojový kód pro Processing
// https://processing.org/reference/libraries/sound/index.html
import processing.sound.*;
// https://github.com/alexandrainst/processing_websockets
import websockets.*;
// IP adresa a TCP port našeho letadla, tedy Wi-Fi čipu ESP32
String ipAdresaLetadla = "172.17.16.131";
int tcpPortLetadla = 80;
// Proměnné pro souřadncie kamery, obrázku pozadí a 3D modelu letadla
float x,y,z;
PImage pozadi;
PShape letadlo;
// Proměnné s úhly z gyroskopu
float xGyro = 0;
float yGyro = 0;
float zGyro = 0;
// Souřadncie letadla ve scéně
float pX = 0.0;
float pY = 4300;
// Soubor se zvukovou nahrávkou letadla
SoundFile file;
// Pomocné proměnné pro stav linky a její statistiku
boolean dataOk = false;
boolean spojeniOk = false;
int dataPocet = 0;
// Objekt websocketového klientu
WebsocketClient websocket;
// Otáčením kolečka myši přiblížíme, nebo vzdálíme kameru
// a tedy i model letadla
void mouseWheel(MouseEvent event) {
z += event.getCount();
}
// Funkce setup se zpracuje hned na začátku
// Analogie funkce setup ze světa Arduino
void setup() {
surface.setTitle("Mrija Simulátor 2022");
surface.setResizable(false);
// Vytvoříme 3D scénu v okně s rozměry 1280x720 pixelů
size(1280, 720, P3D);
// Souřadnice kamery
x = width/2;
y = height/2;
// Ve výchozím stavu bude kamera hluboko ve scéně
// Na začátku ji postupně posuneme dozadu,
// čímž vytvoříme animaci přilétající Mriji
z = 600;
// Nastavíme obrázek pozadi okna,
// nahrajeme 3D model Mriji a začneme přehrávat zvuk motorů
pozadi = loadImage("brno.jpg");
letadlo = loadShape("mrija.obj");
file = new SoundFile(this, "motory.mp3");
file.play();
// Otevřeme spojení s websocketovým serverem
// na stanovené IP adrese a TCP portu
try{
websocket = new WebsocketClient(this, "ws://" + ipAdresaLetadla + ":" + tcpPortLetadla);
}
catch(Exception e){
dataOk = false;
println("Nemohu dekodovat uhly X, Y, Z ");
}
}
// Funkce draw stále dokola překresluje okno
// Je to tedy analogie funkce loop ze světa Arduino
void draw() {
// Aktualizujeme nadpis okna, ve kterém vypisujeme FPS
surface.setTitle("Mrija Simulátor 2022");
// Pokud nehraje stopa s hlukem motorů, začni ji přehrávat
if(!file.isPlaying()) file.play();
// Jako pozadí okna nastav fotografii Brna
background(pozadi);
// Nastav pohled kamery na souřadnice X, Y, Z
translate(x,y,z);
// Pokud je hloubka (z) modelu větší než 0,
// je model skrytý vpředu mimo záběr, a tak
// jej začnu vysunovat do záběru. Vznikne animace
// úvodního příletu letadla do středu obrazovky
if(z > 0){
z--;
}
// Natoč scénu podle aktuální hodnoty X, Y, Z
// z gyroskopu. Stupně přervedeme na radiány
rotateX(radians(xGyro));
rotateY(radians(zGyro));
rotateZ(radians(yGyro));
// Tvorba komplexního modrého bodového světla
// Osvit modelu zhruba ze zadního pravého rohu
// podobně jako na podkladové fotografii
pointLight(3, 177, 252, 0, -500, 600);
pointLight(3, 177, 252, 0, 500, 600);
pointLight(3, 177, 252, 1000, 0, 0);
// Měřítko modelu
scale(0.02);
// vykreslení modelu na souřadnice X, Y
shape(letadlo, pX, pY);
// 2D vrstva s textovými poisky
camera();
hint(DISABLE_DEPTH_TEST);
noLights();
textMode(MODEL);
textSize(20);
text("Leť bezpečně, jsi nad Brnem!", 10, 30);
textSize(15);
text("Naklonění okolo osy Y (klopení, pitch): " + (xGyro) + "°" , 10, 60);
text("Naklonění okolo osy X (klonění, roll): " + (yGyro) + "°" , 10, 80);
text("Otáčení okolo osy Z (bočení, yaw): " + (zGyro) + "°" , 10, 100);
text("Letadlo X: " + (pX), 10, 120);
text("Letadlo Y: " + (pY), 10, 140);
text("Kamera X: " + (x), 10, 160);
text("Kamera Y: " + (y), 10, 180);
text("Kamera Z: " + (z), 10, 200);
// Barevné indikátory v pravém horním rohu
// Websocket spojení zelená/červená
stroke(0,0,0);
if(spojeniOk)fill(0,255,0);
else fill(255,0,0);
rect(width-140, 20, 15, 15);
fill(0,0,0);
text(ipAdresaLetadla, width-110, 32);
// Korektní data čevená/zelená
//Celkový počet přijatých úhlů X, Y, Z
if(dataOk)fill(0,255,0);
else fill(255,0,0);
rect(width-140, 45, 15, 15);
fill(0,0,0);
text(dataPocet, width-110, 57);
hint(ENABLE_DEPTH_TEST);
}
// Tato funkce se bude volat, jakmile z WebSocketu dorazí nějaká textová data
void webSocketEvent(String data){
if(data != null){
// Zprávy se znakem # na začátku neobsahují souřadnice
if(data.charAt(0) == '#'){
data = data.substring(1);
println(data);
// Touto zprávou websocketový server přivítá každého nového klienta
// Jedná se tedy o ověření, že spojení funguje
if(data.equals("Mrija Websocket Server te vita!")) spojeniOk = true;
}
// Pokud zpráva neobsahuje na začátku znak #,
// musí to být trojice úhlů X, Y a Z ve formátu:
// XX.XXXX,YY.YYYY,Z.ZZZZ
// Dekódujeme tyto hodnoty a uložíme do proměnných
else{
String[] casti = split(data, ',');
try{
xGyro = -1 * Float.parseFloat(casti[0]);
yGyro = Float.parseFloat(casti[1]);
zGyro = Float.parseFloat(casti[2]);
dataOk = true;
dataPocet++;
}
catch(Exception e){
dataOk = false;
println("Nemohu dekódovat úhly X, Y, Z ");
}
}
}
}