Working with the Cayenne Dashboard - LoRa to MQTT Gateway (Part 3)

After several failed attempts to build a universal LoRa gateway with the ESP32, the following proprietary gateway was created, which allows LoRa-based IoT devices to connect to the Cayenne dashboard via MQTT. In an advanced expansion stage, the gateway will also manage IoT devices based on the ESP-Now protocol.

For the gateway, we only need one ESP32 with LoRa and OLED Displayno further components are required. Power can be supplied with any USB power supply.

Description:

The gateway can manage 32 devices with up to 8 channels each. Data is transmitted asynchronously. When the gateway receives a LoRa data packet, the first six bytes are interpreted as the device ID (typical mac address). The gateway then checks a device list to see if this device is already registered. If this is not the case, the device id is saved and displayed via the web interface for registration. 

If the device is already registered, the data is read and stored per channel in a message buffer. The channel number is determined from the device number (index in the device list) * 8 + channel number of the received data. Device 0 thus has channels 0 to 7, device 1 channels 8 to 15 etc. 

A record starts with one byte for the channel number followed by one byte of the type, followed by 1 to 6 data bytes, depending on the type. After all data has been saved to the message buffer and marked as new, the response packet is compiled to the Lora device. It starts again with the six bytes device id. The message buffer then checks the channels for this device to see if there is an output data packet from Cayenne to the device that is marked new. If such a data packet is found, it is attached to the response packet, again using the relative channel number 0 to 7. As a final step, the response packet is transferred to the LoRa device.

The Cayenne.loop(1) function communicates with the IoT server. In the callback function CAYENNE_OUT_DEFAULT() all channels that are marked with new and contain an input data packet for Cayenne are searched in the message buffer. These packets are now converted and sent to the IoT server, depending on the type. After successful transfer, the New flag is reset.

A second callback function CAYENNE_IN_DEFAULT() is called whenever Cayenne has data for an action channel. The data from the IoT server is converted depending on the type and stored in the message buffer. They are marked with New so that they are sent to the device the next time LoRa communicates with the response packet.

Display:

In addition to the name, the display shows the current date and time. This includes the IP address.
The line MQTT: shows the number of successful transfers to the IoT server, LoRa the successful transfers to LoRa devices and NOW the number of successful transfers to ESP-Now devices. The latter is currently always 0 because this function is not yet implemented.

Device list and registration:

The device list is stored as a CSV file in the Flash File System (SPIFFS) so that it is preserved even if power is missing. This list can be maintained via the web server built into the gateway. Devices can be deleted and new devices can be registered.

Packets:

For the type, however, the codes are subtracted according to the IPSO Alliance Smart Objects Guidelines, but 3200 is subtracted.

Type Ipso TypeNo. Bytes Resolution
Digital input 3200 0 1 1
Digital output 3201 1 1 1
Analog entrance 3202 2 2 0.01 with sign
Analog output 3203 3 2 0.01 with sign
Lighting sensor 3301 101 2 1 Lux
Presence sensor 3302 102 1 1
Temperature Sensor 3303 103 2 0.1°C with sign
Humidity sensor 3304 104 1 0.5%
Acceleration sensor 3313 113 6 0.001G with sign
axis X, Y and Z
Pressure Sensor 3315 115 2 0.1 hPa
Gyrometer 3334 134 6 0.01%/s sign
axis X, Y and Z
GPS Position 3336 136 9 Latitude 0.0001° 
with sign
Longitude 0.0001°
with sign
Height 0.01m 
with sign

 

Sketch:

The gateway registration with Cayenne is done in the same way as described in Part 1 of this blog series. The access data must then be entered into the sketch (yellow marking) as well as the access data for logging in to the local WLAN. The Cayenne Dashboard will not display any widgets because no device has been connected to the gateway yet.

Board for Arduino IDE = TTGO LoRa32-OLED V1

/* The MQTT Gateway forms an interface between LoRa devices or ESP Nowe devices 
 * and Cayenne MQTT dashboards. It runs on ESP32 with LoRa and OLED display
 * The configuration is done by the browser
 */
#include <Spi.H>
#include <Lora.H>
#include "SSD1306.h"
#include<Arduino.H>
#include <CayenneMQTTESP32.H>
#include <CayenneLPP.H>
#include <Wifi.H>
#include <Web.H>
#include <Time.H>
#include "FS.h"
#include "SPIFFS.h"


We get the data for this setting from the Cayenne Dashboard
#define MQTT_USER ""
#define MQTT_PASSWORD ""
#define MQTT_CLIENTID ""

Access data for local Wi-Fi
#define WIFI_SSID ""
#define WIFI_PASSWORD ""

NTP Server for time synchronization
#define NTP_SERVER "de.pool.ntp.org"
#define GMT_OFFSET_SEC 3600
#define DAYLIGHT_OFFSET_SEC 0

Pins for the LoRa Chip
#define Ss      18
#define Rst     14
#define DI0     26
Frequency for the LoRa chip
#define Band    433175000

//
#define MAXCHANNELS 256 maximum number of channels managed
#define MAXDEVICE 32 maximum number of managed devices MAXCHANNELS/MAXDEVICE = 8 results in the maximum number of channels per device

Format Flash Filesystem if not already done
#define FORMAT_SPIFFS_IF_FAILED True

#define Debug 1

Building blocks for the web server
Const Char HTML_HEADER[] =
"<! DOCTYPE HTML>"
"<html>"
"<head>"
"<meta name = "viewport" content = "width = device-width, initial-scale = 1.0, maximum-scale = 1.0, user-scalable=0>">"
"<meta http-equiv="content-type" content="text/html; charset=UTF-8">"
"<title>MQTT Gateway</title>"
"<script language="javascript">"
"function reload()
"document.location="http://%s";"
"</script>"
"<style>"
"body - background-color: #d2f3eb; font-family: Arial, Helvetica, Sans-Serif; Color: #000000;font-size:12pt; }"
"th background-color: #b6c0db; color: #050ed2;font-weight:lighter;font-size:10pt;
"table, th, td "border: 1px solid black;"
".title .font-size:18pt;font-weight:bold;text-align:center;
"</style>"
"</head>"
"<body><div style='margin-left:30px;' >";
Const Char HTML_END[] =
"</div><script language="javascript">setTimeout(reload, 10000);</script></body>"
"</html>";
Const Char HTML_TAB_GERAETE[] =
"<table style=""width:100%"><tr><th style="width:20%"">ID</th><th style="width:10%">No.</th>"
"<th style=""width:20%">Channels</th><th style="width:20%">Name</th>"
"<th style=""width:20%">Recent Data</th><th style="width:10%">Action</th></tr>";
Const Char HTML_TAB_END[] =
"</table>";
Const Char HTML_NEWDEVICE[] =
"<div style=""margin-top:20px;">%s Name: <input type="text" style="width:200px" name="devname"" maxlength="10" value=""> <button name="register" value="%s">Register</button></div>";
Const Char HTML_TAB_ZEILE[] =
"<tr><td>%s</td><td>%i</td><td>%i to %i</td><td>%s<//td><td>%s</td><td><button name="delete" value="%i">Delete</button></td></tr>";

Structures
News Buffer
Struct MSG_BUF {   uint8_t Type;   uint8_t New;   uint8_t Data[10];
};

Device definition
Struct Device {   uint8_t Active;   uint8_t Service; 0=LoRa, 1=ESP-Now   uint8_t Id[6];   String Name;   String Last;
};

Global variable
Web server instance
Web Server(80);

OLED Display
SSD1306  Display(0x3c, 4, 15);

Buffer for caching messages per channel
MSG_BUF messages[MAXCHANNELS];

List of defined devices
Device Devices[MAXDEVICE];

Id of an unregistered device
uint8_t Unknown[6];
Flag always true when a new device is detected
Boolean newGeraet = False;
Type of new device 0=LöRa 1 =ESPNow
uint8_t newGeraetType = 0;

Counters and activities Status for the display
uint32_t loraCnt = 0; Number of LoRa messages received
String loraLast = ""; Date and time of last received LoRa message
uint32_t nowCnt = 0; Number of ESP Now messages received
String nowLast = ""; Date and time of last received LoRa message
uint32_t cayCnt = 0; Number of MQTT messages sent
String cayLast = ""; Date and time of last sent MQTT message


Function returns date and time in the format yyyy-mm-dd hh:mm:ss as a string
String getLocalTime()
{   Char sttime[20] = "";   Struct Tm timeinfo;   If(!getLocalTime(&timeinfo)){     Serial.println("Failed to obtain time");     Return sttime;   }   Strftime(sttime, Sizeof(sttime), "%Y-%m-%d %H:%M:%S", &timeinfo);   Return sttime;
}

Funktion liefert eine 6-Byte Geräte-Id im format xx:xx:xx:xx:xx:xx als String
String Getid(uint8_t Id[6])
{   String stid;   Char Tmp[4];   Sprintf(Tmp,"%02x",Id[0]);   stid=Tmp;   for (uint8_t J = 1; J<6; J++) {     Sprintf(Tmp,":%02x",Id[J]);     stid = stid += Tmp ;   }   Return stid;
}

prepares the message buffer
sets all messages on done
Void initMessageBuffer() {   for (Int  = 0;<MAXCHANNELS;++) messages[].New = 0;
}

Function to save the configuration
Void writeConfiguration(Const Char *Fn) {   File Q = SPIFFS.Open(Fn, FILE_WRITE);   If (!Q) {     Serial.println(Q("ERROR: SPIFFS Can't Save Configuration"));     Return;   }   for (uint8_t  = 0; <MAXDEVICE; ++) {     Q.Print(Devices[].Active);Q.Print(",");     Q.Print(Devices[].Service);Q.Print(",");     Q.Print(Getid(Devices[].Id));Q.Print(",");     Q.Print(Devices[].Name);Q.Print(",");     Q.println(Devices[].Last);   }
}

Function to register a new device
Void geraetRegister() {   uint8_t  = 0;   search free entry   while ((<MAXDEVICE) && Devices[].Active) ++;   there is no new entry we do nothing   If ( < MAXDEVICE) {     otherwise register geraet name = entered name      or unknown if none has been entered     If (Server.hasArg("devname")) {       Devices[].Name = Server.Bad("devname");     } else {       Devices[].Name = "unknown";     }     for (uint8_t J = 0; J<6; J++) Devices[].Id[J]=Unknown[J];     Devices[].Active = 1;     Devices[].Service= newGeraetType;     Devices[].Last = "";     writeConfiguration("/configuration.csv");     newGeraet = False;   }
}

Service function of the web server
Void handleRoot() {   Char htmlbuf[1024];   Char tmp1[20];   Char tmp2[20];   Char tmp3[20];   Int Index;   was the delete button clicked ?   If (Server.hasArg("delete")) {     Index = Server.Bad("delete").toInt();
#ifdef DEGUG     Serial.Printf("Delete device %i = ",Index);     Serial.println(Devices[Index].Name);
#endif     Devices[Index].Active=0;     writeConfiguration("/configuration.csv");   }   has the Register button been clicked ?   If (Server.hasArg("register")) {     geraetRegister();   }   Send current HTML page to browser   Server.setContentLength(CONTENT_LENGTH_UNKNOWN);   Header   Wifi.localIP().Tostring().Tochararray(tmp1,20);   Sprintf(htmlbuf,HTML_HEADER,tmp1);   Server.send(200, "text/html",htmlbuf);   Form Beginning   Server.sendContent("<div class="title">MQTT - Gateway</div><form>");   Table of active devices   Server.sendContent(HTML_TAB_GERAETE);   for (uint8_t  = 0; <MAXDEVICE; ++) {      If (Devices[].Active == 1) {        Getid(Devices[].Id).Tochararray(tmp1,20);       Devices[].Name.Tochararray(tmp2,20);       Devices[].Last.Tochararray(tmp3,20);       Sprintf(htmlbuf,HTML_TAB_ZEILE,tmp1,,*8,*8+7,tmp2,tmp3,);       Server.sendContent(htmlbuf);     }   }   Server.sendContent(HTML_TAB_END);   If a new device is found, its ID and an input field for the name of the   and a button to register the new device is displayed   If (newGeraet) {     Getid(Unknown).Tochararray(tmp1,20);     Sprintf(htmlbuf,HTML_NEWDEVICE,tmp1,tmp1);     Server.sendContent(htmlbuf);   }   Server.sendContent(HTML_END);
}

Function to find a device in the device list
Return index of device or -1 if it was not found
Int findDevice(uint8_t Dev[6]) {   uint8_t J;   uint8_t  = 0;   Boolean Found = False;   Thu {     J = 0;     If (Devices[].Active == 0) {       ++;     } else {       while ((J < 6) && (Dev[J] == Devices[].Id[J])) {J++;}       Found = (J == 6);       If (!Found) ++;      }    } while ((<MAXDEVICE) && (!Found));   If (Found) {Return ;} else {Return -1;}
}

Function to display the status on the OLED display
Void Display() {   Display.Clear();   Display.Drawstring(0,0,"MQTT Gateway");   Display.Drawstring(0,10,getLocalTime());   Display.Drawstring(0,20,Wifi.localIP().Tostring());   Display.Drawstring(0,34,"MQTT: ");   Display.Drawstring(60,34,String(cayCnt));   Display.Drawstring(0,44,"LoRa: ");   Display.Drawstring(60,44,String(loraCnt));   Display.Drawstring(0,54,"NOW: ");   Display.Drawstring(60,54,String(nowCnt));   Display.Display();
}


Process a message from a LoRa client
Void readLoRa() {   Int devnr;   uint8_t Daniel[6];   uint8_t Channel;   uint8_t Type;   uint8_t Len;   uint8_t Dat;   Boolean Output;   Get data if available   Int packetSize = Lora.parsePacket();   have we received data ?   If (packetSize > 5) {
#ifdef Debug         Serial.println(getLocalTime());     Serial.Print(" RX ");     Serial.Print(packetSize);     Serial.println(" Bytes");     Serial.Print("Device ID");
#endif      first read the device id        for (uint8_t =0; <6;++){       Daniel[]=Lora.Read();
#ifdef Debug       Serial.Printf("-%02x",Daniel[]);
#endif     }
#ifdef Debug     Serial.println();
#endif     Calculate residual package     packetSize -= 6;     check if the device is registered     devnr = findDevice(Daniel);     If (devnr >= 0)  {       if yes, we set the time stamp for the last message and       read the data       Devices[devnr].Last = getLocalTime();       writeConfiguration("/configuration.csv");       while (packetSize > 0) {         Channel number = device number * 16 + device channel         Channel = Lora.Read() + devnr*16;
#ifdef Debug         Serial.Printf("Channel: %02x",Channel);
#endif         type of channel         Type = Lora.Read();
#ifdef Debug         Serial.Printf("Type: %02x",Type);
#endif         determine the length of the data packet and whether the channel is an actuator         Output = False;         Switch(Type) {           Case LPP_DIGITAL_INPUT : Len = LPP_DIGITAL_INPUT_SIZE - 2; Break;           Case LPP_DIGITAL_OUTPUT : Len = LPP_DIGITAL_OUTPUT_SIZE - 2; Output = True; Break;           Case LPP_ANALOG_INPUT : Len = LPP_ANALOG_INPUT_SIZE - 2; Break;           Case LPP_ANALOG_OUTPUT : Len = LPP_ANALOG_OUTPUT_SIZE - 2; Output = True; Break;           Case LPP_LUMINOSITY : Len = LPP_LUMINOSITY_SIZE - 2; Break;           Case LPP_PRESENCE : Len = LPP_PRESENCE_SIZE - 2; Break;           Case LPP_TEMPERATURE : Len = LPP_TEMPERATURE_SIZE - 2; Break;           Case LPP_RELATIVE_HUMIDITY : Len = LPP_RELATIVE_HUMIDITY_SIZE - 2; Break;           Case LPP_ACCELEROMETER : Len = LPP_ACCELEROMETER_SIZE - 2; Break;           Case LPP_BAROMETRIC_PRESSURE : Len = LPP_BAROMETRIC_PRESSURE_SIZE - 2; Break;           Case LPP_GYROMETER : Len = LPP_GYROMETER_SIZE - 2; Break;           Case LPP_GPS : Len = LPP_GPS_SIZE - 2; Break;           Default: Len =  0;         }         if the channel is not an actuator, we reset the message buffer to 1         so that the data is sent to the MQTT server at the next opportunity         If (!Output) messages[Channel].New =1;         messages[Channel].Type = Type;         Remaining package = 2 less because channel and type were read         packetSize -= 2;
#ifdef Debug         Serial.Print("Data:");
#endif         now we read the received data with the determined length         for (uint8_t =0; <Len; ++) {           Dat = Lora.Read();           for actuators, we don't remember any data           If (! Output) messages[Channel].Data[] = Dat;
#ifdef Debug           Serial.Printf("-%02x",Dat);
#endif           Reduce the remaining package by one           packetSize --;         }
#ifdef Debug         Serial.println();
#endif       }       Update status       loraCnt++;       loraLast = getLocalTime();       Display();     } else {       The device is not registered        we remember the device id to display it for registration       for (uint8_t  = 0; <6; ++) Unknown[] = Daniel[];       newGeraet = True;       newGeraetType = 0; LoRa Device     }     Part two Send response to LoRa device     Delay(100);     Lora.beginPacket();     at the beginning, the device id     Lora.Write(Daniel,6);     we check if we have output data for the current LoRa device     Int devbase = devnr*16;     for (Int  = devbase; <devbase+8; ++) {       depending on the type of digital or analog data       Switch (messages[].Type) {           Case LPP_DIGITAL_OUTPUT : Lora.Write(-devbase);             Lora.Write(messages[].Type);             Lora.Write(messages[].Data,1);
#ifdef Debug             Serial.println("Digital Output");
#endif             Break;           Case LPP_ANALOG_OUTPUT :  Lora.Write(-devbase);             Lora.Write(messages[].Type);             Lora.Write(messages[].Data,2);
#ifdef Debug             Serial.println("Analog Output");
#endif             Break;       }     }          Int lstatus = Lora.endPacket();
#ifdef Debug     Serial.Print("Send Status = ");     Serial.println(lstatus);
#endif   }
}

Function to read the configuration
Void readConfiguration(Const Char *Fn) {   uint8_t  = 0;   String Tmp;   Char Hex[3];   If (!SPIFFS.Exists(Fn)) {     does not yet exist then generate     writeConfiguration(Fn);     Return;   }   File Q = SPIFFS.Open(Fn, "r");   If (!Q) {     Serial.println(Q("ERROR:: SPIFFS Can't open configuration"));     Return;   }   while (Q.available() && (<MAXDEVICE)) {     Tmp = Q.readStringUntil(',');     Devices[].Active = (Tmp == "1");     Tmp = Q.readStringUntil(',');     Devices[].Service = Tmp.toInt();     Tmp = Q.readStringUntil(',');     for (uint8_t J=0; J<6; J++){       Hex[0]=Tmp[J*3];       Hex[1]=Tmp[J*3+1];       Hex[2]=0;       Devices[].Id[J]= (Byte) strtol(Hex,Null,16);     }     Tmp = Q.readStringUntil(',');     Devices[].Name = Tmp;     Tmp = Q.readStringUntil(',');     Devices[].Last = Tmp;     ++;   }    }

Void Setup() {   Initialize device storage   for (uint8_t  =0; <MAXDEVICE; ++) Devices[].Active = 0;   OLED Display Initialize   pinMode(16,Output);   digitalWrite(16, Low);   Delay(50);    digitalWrite(16, High);   Display.Init();   Display.flipScreenVertically();   Display.setFont(ArialMT_Plain_10);   Display.setTextAlignment(TEXT_ALIGN_LEFT);   Start serial interface   Serial.Begin(115200);   while (!Serial);    Serial.println("Start");   Flash File syastem   If (SPIFFS.Begin(FORMAT_SPIFFS_IF_FAILED)) Serial.println(Q("SPIFFS Loaded"));   Read in the configuration   readConfiguration("/configuration.csv");     initMessageBuffer();   Initialize SPI and LoRa   Spi.Begin(5,19,27,18);   Lora.setPins(Ss,Rst,DI0);   Serial.println("LoRa TRX");   If (!Lora.Begin(Band)) {     Serial.println("Starting LoRa failed!");     while (1);   }   Lora.enableCrc();   Serial.println("LoRa Initial OK!");   Delay(2000);   Connect to the Wi-Fi and MQTT Server   Serial.println("Connect Wi-Fi");   Cayenne.Begin(MQTT_USER, MQTT_PASSWORD, MQTT_CLIENTID, WIFI_SSID, WIFI_PASSWORD);   Serial.Print("IP address: ");   Serial.println(Wifi.localIP());   Initialize Web Server   Server.On("/", handleRoot);   Server.Begin();   Synchronize clock with time server   configTime(GMT_OFFSET_SEC, DAYLIGHT_OFFSET_SEC, NTP_SERVER);   Output current time   Serial.println(getLocalTime());


}


Void Loop() {   Display();   Check LoRa Interface for data   readLoRa();   communicate with Cayenne MQTT Server   Cayenne.Loop(1);   Serving Web Server   Server.handleClient();

}

Send data from the message buffer to the MQTT server
CAYENNE_OUT_DEFAULT()
{   Boolean Output = False;   Boolean sentData = False;
#ifdef Debug   Serial.println(getLocalTime());   Serial.println("Cayenne send");
#endif   for (Int  = 0; <MAXCHANNELS; ++) {     send only new messages     If (messages[].New == 1) {
#ifdef Debug       Serial.Printf("Send MQTT Type %i'n",messages[].Type);
#endif       send data depending on type       Switch (messages[].Type) {           Case LPP_DIGITAL_INPUT : Cayenne.digitalSensorWrite(,messages[].Data[0]); Break;           Case LPP_DIGITAL_OUTPUT : Output = True; Break;           case LPP_ANALOG_INPUT : Cayenne.virtualWrite(i,(messages[i].daten[0]*256 + messages[i].data[1])/100,"analog_sensor",UNIT_UNDEFINED); break; break;           Case LPP_ANALOG_OUTPUT : Output = True; Break;           Case LPP_LUMINOSITY : Cayenne.luxWrite(,messages[].Data[0]*256 + messages[].Data[1]); Break;           Case LPP_PRESENCE : Cayenne.digitalSensorWrite(,messages[].Data[0]); Break;           Case LPP_TEMPERATURE : Cayenne.celsiusWrite(,(messages[].Data[0]*256 + messages[].Data[1])/10); Break;           Case LPP_RELATIVE_HUMIDITY : Cayenne.virtualWrite(,messages[].Data[0]/2,TYPE_RELATIVE_HUMIDITY,UNIT_PERCENT); Break;           Case LPP_ACCELEROMETER : Cayenne.virtualWrite(,(messages[].Data[0]*256 + messages[].Data[1])/1000,"gx","g"); Break;           Case LPP_BAROMETRIC_PRESSURE : Cayenne.hectoPascalWrite(,(messages[].Data[0]*256 + messages[].Data[1])/10); Break;           case LPP_GYROMETER : len = LPP_GYROMETER_SIZE - 2; break;           case LPP_GPS : len = LPP_GPS_SIZE - 2; break;       }       If (!Output) {         messages[].New = 0;         sentData = True;       }            }   }   If (sentData) {     Update status     cayCnt++;     cayLast = getLocalTime();     Display();   }

}

CAYENNE_IN_DEFAULT()
{   uint8_t * Pdata;   Int Val;   Int Ch = request.Channel;
#ifdef Debug   Serial.println("Cayenne recive");   Serial.Printf("MQTT Data for Channel %i = %s"n",Ch,Getvalue.asString());
#endif   Switch (messages[Ch].Type) {       Case LPP_DIGITAL_OUTPUT : messages[Ch].Data[0] = Getvalue.asInt();         messages[Ch].New = 1;         Break;       Case LPP_ANALOG_OUTPUT :  Val = round(Getvalue.asDouble()*100);         messages[Ch].Data[0] = Val / 256;         messages[Ch].Data[1] = Val % 256;         messages[Ch].New = 1;         Break;   }    }

 

 

DisplaysEsp-32Projekte für fortgeschrittene

3 comments

moi

moi

für was genau ist der cayenne server zuständig?

kann ich auch einfach eine lokale ip eines mqtt servers eingeben und die pakete werden an diesen weiter geleitet?

Gerald Lechner

Gerald Lechner

Hallo Marco
Ja du brauchst dafür einen zweiter ESP32 mit LoRa und im nächsten Teil folgt der notwendige Code.
Gruß Gerald

Marco

Marco

Hallo,
toller Artikel der Lust aufs Ausprobieren macht. Noch eine Frage. Ihr beschreibt hier das Gateway. Das “spricht” auf der einen Seite LoRa und auf der anderen Seite (via WLan) mit einem Netzwerk.
Ich bräuchte dann für die Kommunikation mittels LoRa dann noch einen zweiten ESP als Client, richtig? Könnt Ihr darüber vielleicht einen 4. Teil machen und erklären wie man dann damit ein Ende-Ende Szenario aufbaut?

Viele Grüße
Marco

Leave a comment

All comments are moderated before being published

Recommended blog posts

  1. Install ESP32 now from the board manager
  2. Lüftersteuerung Raspberry Pi
  3. Arduino IDE - Programmieren für Einsteiger - Teil 1
  4. ESP32 - das Multitalent
  5. OTA - Over the Air - ESP programming via WLAN