Categories
ESP32 Mast

SkyCam mit ESP32

Link zum aktuellen Kamerabild: SkyCam bei Heiligenberg in JN47qu

Mittlerweile sind in meinem Gartenhaus allerlei Sensoren installiert. Was allerdings noch fehlt ist eine Art Webcam. Zunächst ist die Frage was man auf einer Webcam sehen möchte. Mich interessiert, wenn ich nicht daheim bin, wie das Wetter zuhause ist. Ob es bewölkt ist, regnet oder der Himmel frei von Wolken ist. Was für mich nicht in Frage kam, ist eine Webcam, mit der man meinen Garten sieht. Warum also nicht eine Webcam, die den Himmel ablichtet. So entstand die Idee eine SkyCam zu bauen. Dafür sollte die Kamera zudem Weitwinkel können, sonst ist das Sichtfeld in den Himmel doch etwas eingeschränkt.

Für mich wichtig war, dass die Kamera günstig und zuverlässig ist. Nicht wichtig waren mir nächtliche Aufnahmen des Sternenhimmels. Ich wollte kein Betriebssystem (wie bei einem RaspberryPi) mit SD-Karte, die kaputt gehen kann, pflegen und ggf. tauschen.

Somit entschloss ich mich ein ESP32 Kameramodul mit Weitwinkel zu nehmen.

Kamera

Als Kamera habe ich einen ES32 Cam verwendet. Das Modul ist relativ günstig und auch in Weitwinkel zu bekommen. Ohne Weitwinkel sieht ein Modul in etwa so aus:

ESP32 CAM mit Standardobjektiv

Platine

Da ich die Kamera auf einem Mast installieren wollte und nicht 5V bis zum Mast hochführen wollte, habe ich mir eine Platine gestaltet, auf dem ein Step-Down Wandler sitzt, der alles zwischen 6 V und ~25V akzeptiert.

Auf der Platine sind die Anschlüsse zum Flashen des ESP32 zudem extrahiert.

https://aisler.net/p/NCICYTNB (Werbelink)

Bestückung:

  • C1, C2, C3 : 10nF
  • L1 : 10 uH
  • DC-Wandler : RECOM R-78E50-05

Mit dem Brücken der Kontakte Flash-EN kann der ESP32 geflasht werden. Mit dem Brücken der Kontakte Step-Down-EN wird der Ausgang des Step-Down Wandlers mit dem Versorgungsspannunsgeingang des ESP32 verbunden. Es ist in jedem Fall zu vermeiden Step-Down-EN gebrückt zu haben und den ESP anderweitig z.B. via Flash-Kabel mit einer weiteren Spannungsquelle, wie beispielsweise einem Computer verbunden zu haben. Dies kann zur Beschädigung führen.

Gehäuse

Danach musste ich mir überlegen in was für ein Gehäuse ich für die Kamera nehmen möchte. Um den Himmel zu beobachten bietet sich ein “Dome” einer Überwachungskamera an. Diese können aus diversen Quellen bezogen werden. Meiner ist aus Acrylglas. Der Innendurchmesser beträgt ~76 mm, der Außendurchmesser ~85mm. Den Unterbau, in dem sich die Kamera mit dem ESP befindet habe ich selbst gestaltet, 3D gedruckt und ganz wichtig; angeschliffen und mit Farbe besprüht, um eine gewisse UV Festigkeit zu erreichen. Als Material habe ich ABS verwendet. Das gedruckte Gehäuse besteht aus zwei Teilen. Einem Oberteil, in das der “Dome” eingepasst wird, in den die Kamera schaut, sowie einem unteren Teil, der auf den Mast gesteckt und fest mit dem oberen Teil verbunden wird.

SkyCam gedrucktes Oberteil Foto 1 – die beiden Öffnungen links und rechts der Kameraöffnung sind für Silikagel damit es trocken bleibt.
SkyCam gedrucktes Oberteil Foto 2 – die Kamera wird mitsamt der Platine von unten in das Oberteil eingepasst.
SkyCam gedrucktes Unterteil Foto 1 – diese Seite wird auf dem Mast gesteckt.
SkyCam gedrucktes Unteritel Foto 2 – Diese Seite wird am Oberteil befestigt.

Im Unterteil habe ich eine Sechskantöffnung verwendet, da ich dort eine Wasserdichte Kabeldurchführung für das Stromversorgungskabel verwende.

Der “Dome” wird mit Fugmasse am Oberteil eingelassen, die beiden Fächer im Oberteil sind für Silikagel Kugeln, damit der “Dome” nicht beschlägt.

Endmontage

Fertig am Mast installierte SykCam
Oberteil mit Silikagel und Tape mit “Löchern” damit die Kugeln nicht bei der Montage am Mast durch die Kuppel fliegen. – Das gesamte Oberteil ist lackiert, der Teil in der Kuppel jedoch NICHT mit Klarlack versehen, um reflexionen zu vermeiden.
Lackiertes Unterteil mit Kabeldurchführung. – Gut zu erkennen ist, dass der Druck ursprünglich blau war und schwarz lackiert wurde.
Montiert auf dem Mast, noch mit Schutzfolie.
Auch die Unterseite (an der das Unterteil mit Schrauben am Oberteil befestigt ist) wurde abgedichtet.

Software

Die Kamera stellt einen HTTP-Server zur Verfügung. Das Kamerabild muss von einem Server im Heimnetzwerk aktiv abgeholt werden. Ich empfehle die Kamera niemals von außerhalb des Heimnetzwerks erreichbar zu machen.

#include <WebServer.h>
#include <TaskScheduler.h>

#define CAMERA_MODEL_AI_THINKER
#include "camera_pins.h"
#include "WiFi.h"
#include "esp_camera.h"
#include "esp_timer.h"
#include "img_converters.h"
#include "Arduino.h"
#include "soc/soc.h"           // Disable brownour problems
#include "soc/rtc_cntl_reg.h"  // Disable brownour problems
#include "driver/rtc_io.h"

#include <FS.h>

// --------- WIFI -----------

#define STASSID    "SSID"
#define STAPSK     "PWD"

#define DEVICENAME "ESP32-SkyCamName"

unsigned long previousMillis = 0;
unsigned long interval = 30000;

// --------- END WIFI -------

// --------- INITS -------

const char* ssid = STASSID;
const char* password = STAPSK;
const char* deviceName = DEVICENAME;

WebServer server(80);
StaticJsonDocument<1024> jsonDocument;
char jsonBuffer[1024];

// --------- END INITS -------

// --------- SCHEDULER BEGIN -------

void checkFreeRam();
Task scheduleCheckFreeRam(21*1000, TASK_FOREVER, &checkFreeRam);

void wifiReconnectCheck();
Task scheduleWifiReconnectCheck(8*60*1000, TASK_FOREVER, &wifiReconnectCheck);

Scheduler runner;

// --------- SCHEDULER END ---------

// ------- PINS ----------
static int morsePin = 2;
// ------- END PINS ----------

// --------- Variables ---------
int freeHeap = 0;
// --------- END Variables ---------

void setup()
{
  initSerial();
  initWifi();
  initCamera();
  initPinModes();
  initTimeClient();
  initServer();
  initSchedules();
  checkFreeRam();
}

void loop()
{
  server.handleClient();
  runner.execute();
}

void initSerial()
{
  Serial.begin(115200); 
  while(!Serial){} // Waiting for serial connection
  Serial.println();
}

void initWifi()
{
  Serial.println("WiFi init");
  Serial.print("Wifi: ");
  Serial.println(ssid);
  //Serial.print("WifiPW: ");
  //Serial.println(password);
  
  Serial.println("turn wifi off...");
  WiFi.mode(WIFI_OFF);
  delay(10);
  //WiFi.forceSleepBegin();
  delay(200);
  //WiFi.forceSleepWake();
  WiFi.mode(WIFI_STA);
  delay(250);
  
  WiFi.config(INADDR_NONE, INADDR_NONE, INADDR_NONE);    
  delay(200); 
  //WiFi.mode(WIFI_STA);
  Serial.println("setting hostname");
  WiFi.setHostname(deviceName);
  delay(200); 
  Serial.println("Connecting to WiFi..");
  WiFi.begin(ssid, password);
  delay(200); 

  int iCounter = 0;
  int iMax = 30;
  while (WiFi.status() != WL_CONNECTED && iCounter < iMax)
  {
    digitalWrite(morsePin, LOW);
    delay(500);
    digitalWrite(morsePin, HIGH);
    delay(500);
    Serial.print(".");
    iCounter++;
  }
  
  Serial.println(WiFi.localIP()); 
}

void initPinModes()
{
  Serial.println("PIN MODES init");

  pinMode(morsePin, OUTPUT);
  digitalWrite(morsePin, LOW);
}

void initSchedules()
{
  Serial.println("SCHEDULES init");
  
  runner.init();

  runner.addTask(scheduleCheckFreeRam);
  scheduleCheckFreeRam.enable();

  runner.addTask(scheduleWifiReconnectCheck);
  scheduleWifiReconnectCheck.enable();
}

void initServer()
{
  server.on("/", sendPhoto);
  server.begin();
  Serial.println("HTTP server started");
}

void wifiReconnectCheck()
{
  unsigned long currentMillis = millis();
  // if WiFi is down, try reconnecting every CHECK_WIFI_TIME seconds
  if ((WiFi.status() != WL_CONNECTED) && (currentMillis - previousMillis >=interval))
  {
    Serial.print(millis());
    Serial.println("Reconnecting to WiFi...");
    WiFi.disconnect();
    WiFi.reconnect();
    previousMillis = currentMillis;
  }
}

void checkFreeRam()
{
  freeHeap = ESP.getFreeHeap();

  if (ESP.getFreeHeap() < 60000)
  {
    ESP.restart();
  }
}

void initCamera()
{
  Serial.println("Init Camera");
  
  WRITE_PERI_REG(RTC_CNTL_BROWN_OUT_REG, 0); //disable brownout detector

  camera_config_t config;
  config.ledc_channel = LEDC_CHANNEL_0;
  config.ledc_timer = LEDC_TIMER_0;
  config.pin_d0 = Y2_GPIO_NUM;
  config.pin_d1 = Y3_GPIO_NUM;
  config.pin_d2 = Y4_GPIO_NUM;
  config.pin_d3 = Y5_GPIO_NUM;
  config.pin_d4 = Y6_GPIO_NUM;
  config.pin_d5 = Y7_GPIO_NUM;
  config.pin_d6 = Y8_GPIO_NUM;
  config.pin_d7 = Y9_GPIO_NUM;
  config.pin_xclk = XCLK_GPIO_NUM;
  config.pin_pclk = PCLK_GPIO_NUM;
  config.pin_vsync = VSYNC_GPIO_NUM;
  config.pin_href = HREF_GPIO_NUM;
  config.pin_sscb_sda = SIOD_GPIO_NUM;
  config.pin_sscb_scl = SIOC_GPIO_NUM;
  config.pin_pwdn = PWDN_GPIO_NUM;
  config.pin_reset = RESET_GPIO_NUM;
  //config.xclk_freq_hz = 20000000;
  config.xclk_freq_hz = 10000000;
  config.pixel_format = PIXFORMAT_JPEG; 

  config.frame_size = FRAMESIZE_UXGA;
  config.jpeg_quality = 10;
  config.fb_count = 1;

  // camera init
  esp_err_t err = esp_camera_init(&config);
  if (err != ESP_OK) 
  {
    Serial.printf("Camera init failed with error 0x%x", err);
    return;
  }
}

void sendPhoto()
{
  capturePhotoAndSend();
}

// Photo BEGIN
void capturePhotoAndSend() 
{
  camera_fb_t * fb = NULL; // pointer
  bool ok = 0; // Boolean indicating if the picture has been taken correctly

  size_t _jpg_buf_len;
  uint8_t * _jpg_buf;

  Serial.println("Taking a photo...");

  do
  {
    // Take a photo with the camera
    //Serial.println("Taking a photo...");

    fb = esp_camera_fb_get();
    if (!fb)
    {
      Serial.println("Camera capture failed");
      server.send(500, "text/html", "Error 500"); 
      return;
    }

    /*if (fb->len > 0)
    {
      server.send_P(200, "image/jpg", (const char *)fb->buf, fb->len);
      Serial.println("Length: " + (String)fb->len);
      ok = true;
    }*/

    if(fb->format == PIXFORMAT_JPEG)
    {
      //frame2jpg(fb, 100, &_jpg_buf, &_jpg_buf_len);
      server.send_P(200, "image/jpg", (const char *)fb->buf, fb->len);
      ok = true;
    }
    else
    {
      //frame2jpg_cb(fb, 80, jpg_encode_stream, &jchunk)?ESP_OK:ESP_FAIL;
      frame2jpg(fb, 100, &_jpg_buf, &_jpg_buf_len);
      server.send_P(200, "image/jpg", (const char *)_jpg_buf, _jpg_buf_len);
      //server.send_P(req, NULL, 0);
      //fb_len = jchunk.len;
      ok = true;
    }

    esp_camera_fb_return(fb);
  }

  while(!ok);
}

// Photo END

Hinweis

Dieser Artikel dokumentiert lediglich meinen Aufbau. Für den Nachbau, die Nutzung einzelner Komponenten, die Platinen und den gesamten Inhalt wird die Haftung in jeglicher Form ausgeschlossen.

Categories
ESP32 Home Assistant Mast

Windgeschwindigkeit und Windrichtung mit ESP32 erfassen

Nachdem ich in meinem Gartenhaus bereits Temperatur, Luftfeuchtigkeit, Luftdruck, Feinstaub und einige andere Werte erfasse ( Weather Station with Dust Sensor based on ESP8266 ) , fehlen mir eigentlich nur noch Niederschlag und Windrichtung, sowie Windgeschwindigkeit.

Der Erfassung der Daten wird über einen ESP32 Mikrocontroller umgesetzt, der diese als JSON bereitstellt, damit ich sie mit HomeAssistant abgreifen und zur Verfügung stellen kann.

Im Gartenhaus sind bereits 12V DC, sowie 5V DC Leitungen verlegt, die Stromversorgung ist damit sichergestellt.

Windrichtung

Für die Erfassung der Windrichtung gibt es verschiedene Sensoren, ich habe mich für einen entschieden, der je nach Richtung verschiedene Spannungen ausgibt. Versorgt wird er mit 12V. Meiner gibt unterteilt in sieben Schritten die Spannungen 0V, 0.8V, 1.4V, 2.1V, 3V, 3,6V, 3,9V aus.

Wichtig zu erwähnen ist, dass man die Gradzahlen, also von-bis welche Spannung ausgegeben wird ausmessen sollte, da die Winkel für die Spannungen nicht gleichgroß sind, bei mir ist beispielsweise der Teil mit 3,9V zirka 90° groß, jedoch der Winkel für 0V nur 40° groß. Für die spätere Richtungsberechnung nehme ich die Mitte als Gradzahl zur Hand.

Da der ESP Spannungen über 3.3V nicht bevorzugt, habe ich mit einem 50/50 Spannungsteiler (2x 5.6kO) die Spannung halbiert, die am ESP32 ankommt.

Windgeschwindigkeit

Bei der Windgeschwindigkeitsmessung habe ich mich für ein Schalenanemometer entschieden. Es gibt sie in verschiedenen Ausführungen und verschiedenen Maxima bei der Messung der Windgeschwindigkeit. Das ist unter anderem auf verschiedene Messtechnologien zurückzuführen. Mein Anemometer kann maximal 70 m/s -> 252 km/h, die hoffentlich nie eintreten werden.

Mein Schalenanemometer wird mit 12V versorgt und generiert über ein NPN-Transistor Low-Hi Signale, die ich mit dem ESP32 auswerte. Bei meinem Anemometer war angegeben, dass eine Umdrehung 20 “Ticks” sind. Es müssen die “Ticks” pro Sekunde gemessen werden und mit dem Auflösungswert multipliziert werden. Meins hat eine Auflösung von 0.0875m/s pro “Tick” das bedeutet; erhalte ich zwei “Ticks” pro Sekunde, liegt eine Windgeschwindigkeit von 0.175 m/s vor.

Hardware

Zunächst stellt sich die Frage, wie die Montage aussehen soll. Windsensoren sollten relativ hoch und frei aufgestellt werden. Da ich am Gartenhaus bereits einen Mast habe, war dieser dafür prädestiniert. Also habe ich mir ein Rohr besorgt, dass sich horizontal zum vertikalen Mast an diesem montierten lässt. Im Rohr habe ich Kerben mit der Flex gemacht und dann 2mm stake Bleche angeschweißt. In der Mitte habe ich ein Loch für die Kabel gebohrt. Das Rohr mit den Stahlblechen habe ich mit Alu-Zink Spray als Rostschutz eingesprüht.

Rohr mit angeschweißten Platten für die Sensormontage. In der Mitte je ein Loch für die Kabel.

Danach habe ich die beiden Sensoren montiert. Als Unterlegscheiben habe ich Teflon Unterlegscheiben verwendet. Wichtig ist, dass der Richtungssensor so montiert ist, dass es bei der Montage am Mast leicht fällt diesen so aus zu richten, dass der Sensor die richtige Richtung detektiert. Da man direkt im Rohr keine Muttern befestigen kann, weil man dort sehr schlecht bis gar nicht hin kommen würde, habe ich Gewinde in das Stahlblech geschnitten, die die Schrauben halten.

Montierter Sensor im Detail, Links und rechts durch geschnittene Gewinde im Blech gehalten, vorne und hinten durch Muttern auf der Unterseite befestigt
Beide Sensoren montiert

Nachdem die Sensoren montiert sind, muss die Technik verstaut werden. Als Mikrocontroller wird ein ESP32 mit eigens gestalteter Platine verwendet. (Den Link zur Platine, sowie dessen Bestückung werden später in diesem Artikel erläutert.) Dazu habe ich ein wasserdichtes Gehäuse mit Kabeldurchführungen erworben, sowie eine Masthalterung, an der das Gehäuse befestigt wird. Zunächst wird das Gehäuse mit Bohrungen, sowie Platinen-Halterung vorbereitet. Die Halterung ist 3D gedruckt und steht zum Download bereit.

3D Modell Screenshot
Montierte Platinen-Halterung mit Kabeldurchführung, unten für das CAT.7 Kabel zum Gartenhaus für die Stromversorgung, links für die beiden Sensoren
Montierte Masthalterung für die Box mit dem Mikrocontroller. Die Löcher für die kleinen Schrauben musste ich selbst in die Masthalterung bohren, danach habe ich wieder Zink-Alu Spray als Rostschutz für die Bohrungen verwendet.

Nachdem die Befestigungen und Boxen für die Technik soweit vorbereitet sind, können wir uns der Platine widmen.

Schaltplan

Die 12V DC vom Gartenhaus werden über einen DC-Wandler zu 5V umgewandelt und dann wird der ESP32 über VIN versorgt. Diese Schaltung klammere ich hier explizit aus.

Der Windrichtungssensor liefert je nach Richtung zwischen 0-3.9V als Spannungen zurück. Über einen Spannungsteiler halbiere ich die Spannung und erfasse sie dann mit dem ESP32 als Analogsignal. Wichtig ist, dass man die ADC2 Kanäle nicht nutzen kann, wenn WLAN aktiviert ist, was der Fall ist. Deshalb müssen wir ADC1 PINs nutzen. Die M-Kontakte im Bild sind Messpunkte auf der Platine, die zur Diagnose mit dem Oszilloskop verwendet werden können.

Spannungsteiler für die Windrichtung mit Kondensator für die Störungsreduktion

Der Windgeschwindigkeitssensor liefert via NPN entsprechende Low-High Rechteck Signale. Da der ESP32 auf das Abfallen oder Ansteigen eines Digital-PINs via Interrupt reagieren kann, wir jedoch keine 12V am ESP32 verarbeiten können oder eben nur einmal mit einer Rauchfahne, wandle ich das 12V Low-High Signal mit einer einfachen Transistorschaltung in ein 3.3V Low-High Signal um.

Einfache Transistorschaltung zur Wandlung des 12V Low-High Signal des Windgeschwindigkeit Messers in ein 3.3V Signal für den ESP32

Wie sich die Geschwindigkeit anhand der Low-High Signale berechnet habe ich bereits am Anfang des Artikels erläutert.

Platine

Hier die Platine dazu (Afillate): https://aisler.net/p/PDEJYOOO

  • ESP32 mit 30 Pins
  • EN-DC -> Brücke um den ESP über den DC-Wandler zu versorgen (im Normalbetrieb) oder eben über USB, wenn nicht gebrückt wurde, interessant für Debug-Zwecke
  • DC-Wandler : RECOM R-78E50-05
  • M1 : Eingangsspannung
  • M2 : Ausgangsspannung DC-Wandler
  • M3 : Windrichtungssensor Ausgang
  • M4 : Windgeschwindigkeitssensor Ausgang
  • M5 : Masse
  • C1, C2, C3, C4 : 10nF
  • L1 : 10µH
  • R1, R2, R3, R4 : 5.6kO
  • T1 : BC547C
Bestückte und montierte Platine in der Halterung im Gehäuse

Endmontage

Sensoren am Mast montiert
Sensoren am Mast montiert – Detailansicht
Links die Box mit dem ESP32 und der Platine, sowie rechts oben das Rohr zu sehen auf dem die Sensoren montiert sind

Home Assistant

Der ESP32 ist so programmiert, dass er einmal pro Minute die Wind-Werte der vergangenen Minute zu einer Aussage zusammenfasst, dazu später. Diese Werte fragen wir alle 30 Sekunden im Home Assistant via REST ab, sowohl in m/s, wie auch in km/h. Der ESP32 liefert bei der Geschwindigkeit das Minimum, Maximum, sowie den Durchschnitt. Bei der Windrichtung ist die Berechnung etwas kompexer, hier wird ein berechneter Wert, sowie der Text, also beispielsweise O (für Ost), SW (für Südwest) etc. geliefert.

rest:
  - scan_interval: 30
    resource: http://<ESP32-IP>/jsondoaction
    sensor:
     - name: "ESP32 Wind - Speed AVG - m/s"
       unit_of_measurement: "m/s"
       value_template: "{{ value_json.windSpeedMinuteAvgMS | float | round(2) }}"
       
     - name: "ESP32 Wind - Speed Min - m/s"
       unit_of_measurement: "m/s"
       value_template: "{{ value_json.windSpeedMinuteMinMS | float | round(2) }}"
       
     - name: "ESP32 Wind - Speed Max - m/s"
       unit_of_measurement: "m/s"
       value_template: "{{ value_json.windSpeedMinuteMaxMS | float | round(2) }}"
       
     - name: "ESP32 Wind - Speed AVG - km/h"
       unit_of_measurement: "km/h"
       value_template: "{{ value_json.windSpeedMinuteAvgKMH | float | round(2) }}"
       
     - name: "ESP32 Wind - Speed Min - km/h"
       unit_of_measurement: "km/h"
       value_template: "{{ value_json.windSpeedMinuteMinKMH | float | round(2) }}"
       
     - name: "ESP32 Wind - Speed Max - km/h"
       unit_of_measurement: "km/h"
       value_template: "{{ value_json.windSpeedMinuteMaxKMH | float | round(2) }}"
       
     - name: "ESP32 Wind - Direction - Degree"
       unit_of_measurement: "°"
       value_template: "{{ value_json.windDirectionCalculatedDegree | int }}"
       
     - name: "ESP32 Wind - Direction - Text"
       value_template: "{{ value_json.windDirectionCalculatedText }}"
       
     - name: "ESP32 Radiation - Free RAM"
       unit_of_measurement: "bytes"
       value_template: "{{ value_json.freeram | int }}"
Home Assistant Screenshot der Live Werte
Home Assistant Screenshot der Histogramme

Software

Windgeschwindigkeit

Beginnen wir mit der Bestimmung der Windgeschwindigkeit; Hier müssen wie die Low-Hi Signale pro Sekunde zählen. Zunächst definieren wir eine statische Variable mit der Auflösung des Geschwindigkeitsmessers, sowie die entsprechenden Arrays:

static double speedResolution = 0.0875; // m/s per tick per second

int oneSecondWindTicks[60];
int oneSecondWindTicksLastMinute[60];

int minuteTicksSum;
double minuteWindSpeedAvgMS; // m/s
double minuteWindSpeedMinMS; // m/s
double minuteWindSpeedMaxMS; // m/s

Dafür registrieren wir ein Interrupt, dass in ein Array für die jeweilige Sekunde die “Ticks” hochzählt:

void IRAM_ATTR eventWindSpeedTick()
{
  int actualSecond = timeClient.getSeconds();
  if (actualSecond > -1 && actualSecond < 60)
  {
    oneSecondWindTicks[actualSecond]++;
  }
}

In der initPinModes() Funktion registrieren wir das Event auf das Fallen des PINs:

pinMode(windSpeedPin, INPUT);
attachInterrupt(digitalPinToInterrupt(windSpeedPin), eventWindSpeedTick, FALLING);

In der changeMinute() Funktion, die über den Scheduler jede Sekunde aufgerufen wird und prüft, ob sich die Minute geändert hat, wird das oneSecondWindTicks in ein genau gleichgroßes Array für die vergangene Minute kopiert, um darauf Berechnungen durch zu führen. Danach wird das aktuelle Array “genullt”.

memcpy(oneSecondWindTicksLastMinute, oneSecondWindTicks, sizeof(oneSecondWindTicks));
for (int i=0; i<60; i++)
{
    oneSecondWindTicks[i] = 0;
}

Um dann in der buildLastMinuteVals() Funktion Minimum, Maximum und Durchschnitt zu ermitteln. Dort wird auch die “Auflösung” des Windgeschwindigkeitssensor relevant, denn diese sagt aus welche Windgeschwindigkeit pro Tick gilt. Die Umrechnung der m/s in km/h wird beim Abruf des JSON getätigt.

// wind speed
int minTickVal = oneSecondWindTicksLastMinute[0];
int maxTickVal = 0;
minuteTicksSum = 0;

for (int i=0; i<60; i++)
{
int workVal = oneSecondWindTicksLastMinute[i];

if (workVal < 1000)
{   
  if (workVal < minTickVal) // set min
  {
	minTickVal = workVal;
  }
  if (workVal > maxTickVal) // set max
  {
	maxTickVal = workVal;
  }

  minuteTicksSum += workVal; // add to sum
}    
}

double avgTickVal = (double)minuteTicksSum / (double)60;

// convert to m/s
minuteWindSpeedAvgMS = avgTickVal * speedResolution;
minuteWindSpeedMinMS = (double)minTickVal * speedResolution;
minuteWindSpeedMaxMS = (double)maxTickVal * speedResolution;

Die Berechnung der Windgeschwindigkeit ist relativ einfach, komplizierter wird es bei der Berechnung der Windrichtung.

Windrichtung

Um auf die Aussage vom Anfang des Artikels zurück zu kommen, hat nicht jeder Teil des Richtungssensors die gleiche Größe (in Grad). Zunächst habe ich den Richtungssensor auf ein Blatt Papier gestellt, markiert wo die Schrauben und das Label sind, dann den Sensor angeschlossen und gemessen von wo bis wo welche Spannung anliegt. Danach habe ich diese Kompassrose (https://de.m.wikipedia.org/wiki/Datei:Kompassrose.svg) aufgeklebt und die Mitte der Bereiche in Grad dazu geschrieben, die als Basis für eine Berechnung dienen. Es sind Augenmaß Werte, kann also durchaus etwas ungenauer aussehen. Hier der Scan meines Papiers (nicht schön aber selten):

Blau eingerahmt die anliegende Spannung, orange markiert die per Augenmaß ermittelte Gradzahl in der Mitte des Bereichs

Danach habe ich am ESP32 die Analogwerte ermittelt, die je Bereich ausgegeben werden, zwischen zwei Analogwerten die Mitte ermittelt und diese als Basis für die Ermittlung der Richtung genommen (größer-gleich). Die Richtung frage ich einmal pro Sekunde hab und schreibe sie an die entsprechende Position im Array die Richtung.

static int diectionAnalogVal[] = {  -1, 128, 463, 880,  1302, 1724, 2143}; // min analog value
// Middle Vals                       0  256  670  1091  1514  1934  2352
static int diectionVals[] =      {  60, 100,  140, 190,  240, 280, 350}; // directions in degree
int directionCountLastMinute[7];
int directionFirst = 0;
int directionSecond = 0;
int directionMostCalculated = 0;

Einmal pro Sekunde rufe ich die Funktion checkDirection() via Task Scheduler auf, die dann das Array entsprechend beschreibt:

void checkDirection()
{
  int actualSecond = timeClient.getSeconds();
  if (actualSecond > -1 && actualSecond < 60)
  {
    int analogVal = analogRead(windDirectionPin);
    int direction = 0;
    for (int i=0; i<7 && (analogVal > diectionAnalogVal[i]); i++)
    {
      direction = diectionVals[i];
    }

    oneSecondWindDirection[actualSecond] = direction;

    Serial.println("WindDirectionAnalogVal: " + (String)analogVal);
    Serial.println("WindDirectionDegreeVal: " + (String)direction);
  }
}

Auch hier wird in der Funktion changeMinute() das aktuelle Array mit den Werten in das Array der vorigen Minuten kopiert, um dann Berechnungen durch zu führen.

memcpy(oneSecondWindDirectionLastMinute, oneSecondWindDirection, sizeof(oneSecondWindDirection));
for (int i=0; i<60; i++)
{
  oneSecondWindDirection[i] = 0;
}

Komplexer wird die Berechnung der gemittelten Windrichtung. Zunächst ermittle ich die beiden am meisten auftretenden Richtungen. Danach ermittle ich welcher Weg (im oder gegen den Uhrzeigersinn) der kürzere ist. Die “Distanz” zwischen den beiden Gradzahlen wird dann ermittelt und durch die Summe der beiden am häufigsten auftretenden Windrichtungen geteilt und dann entsprechend mit einer der beiden multipliziert und dazu addiert bzw. subtrahiert. Ein einfaches Beispiel: 20x 0° und 30x 100° -> 100-0 -> 100 / (20+30) -> 2° je Messung -> 20 x 2° -> 40° -> 0° + 40° -> 40° -> Nord-Ost. Das passiert in der Funktion buildLastMinuteVals() in den Kommentaren hinter den Berechnungen finden sich die Beispiele mit denen man die entsprechenden Bereiche durchläuft:

// wind direction

// prepararion
for(int i=0; i<7; i++)
{
  directionCountLastMinute[i] = 0;
}

directionFirst = -1;
directionSecond = -1;
directionMostCalculated = 0;    

for (int i=0; i<60; i++)
{
  int workVal = oneSecondWindDirectionLastMinute[i];
  
  for (int j=0; j<7; j++)
  {
    if (workVal == diectionVals[j])
    {
      directionCountLastMinute[j]++;
    }
  }
}

// check which is most used
for (int i=0; i<7; i++)
{
  if (directionCountLastMinute[i] > directionCountLastMinute[directionFirst] || directionFirst == -1)
  {
    directionFirst = i;
  }
}

// check which is second most used  
for (int i=0; i<7; i++)
{
  if (i != directionFirst)
  {
    if (directionCountLastMinute[i] > directionCountLastMinute[directionSecond] || directionSecond == -1 )
    {
      directionSecond = i;
    }
  }
}

if (directionFirst == -1)
{
  directionFirst = 0;
}

if (directionSecond == -1)
{
  directionSecond = directionFirst;
}

Serial.println("First direction: " + (String)directionFirst + " / Count: " + (String)directionCountLastMinute[directionFirst]);
Serial.println("Senco direction: " + (String)directionSecond + " / Count: " + (String)directionCountLastMinute[directionSecond]);

if (directionCountLastMinute[directionFirst] > 0 || directionCountLastMinute[directionSecond] > 0) // otherwise we have zero division
{

  int workVal = 0;
  double degreePerCount;

  if (diectionVals[directionFirst] < diectionVals[directionSecond]) // 10 / 100 | 10 / 200 | 10 / 350
  {
    if (diectionVals[directionFirst] + 180 >= diectionVals[directionSecond]) // 10 / 100
    {
      workVal = diectionVals[directionSecond] - diectionVals[directionFirst];
      degreePerCount = (double)workVal / (directionCountLastMinute[directionFirst] + directionCountLastMinute[directionSecond]);
      directionMostCalculated = diectionVals[directionFirst] + (degreePerCount * directionCountLastMinute[directionSecond]);
    }
    else if (diectionVals[directionFirst] + 180 < diectionVals[directionSecond]) // 10 / 200
    {
      workVal = 360 - diectionVals[directionSecond] + diectionVals[directionFirst];
      degreePerCount = (double)workVal / (directionCountLastMinute[directionFirst] + directionCountLastMinute[directionSecond]);
      directionMostCalculated = diectionVals[directionSecond] + (degreePerCount * directionCountLastMinute[directionFirst]);
    }
    else // 10 / 350
    {
      workVal = 180 - diectionVals[directionSecond] + diectionVals[directionFirst];
      degreePerCount = (double)workVal / (directionCountLastMinute[directionFirst] + directionCountLastMinute[directionSecond]);
      directionMostCalculated = diectionVals[directionSecond] + (degreePerCount * directionCountLastMinute[directionFirst]);
    }

  }
  else if (diectionVals[directionFirst] > diectionVals[directionSecond]) // 100 / 10 | 200 / 10 | 350 / 10
  {
    if (diectionVals[directionSecond] + 180 >= diectionVals[directionSecond]) // 100 / 10
    {
      workVal = diectionVals[directionFirst] - diectionVals[directionSecond];
      degreePerCount = (double)workVal / (directionCountLastMinute[directionFirst] + directionCountLastMinute[directionSecond]);
      directionMostCalculated = diectionVals[directionSecond] + (degreePerCount * directionCountLastMinute[directionFirst]);
    }
    else if (diectionVals[directionSecond] + 180 < diectionVals[directionSecond]) // 200 / 10
    {
      workVal = 360 - diectionVals[directionFirst] + diectionVals[directionSecond];
      degreePerCount = (double)workVal / (directionCountLastMinute[directionFirst] + directionCountLastMinute[directionSecond]);
      directionMostCalculated = diectionVals[directionFirst] + (degreePerCount * directionCountLastMinute[directionSecond]);
    }
    else // 350 / 10
    {
      workVal = 180 - diectionVals[directionFirst] + diectionVals[directionSecond];
      degreePerCount = (double)workVal / (directionCountLastMinute[directionFirst] + directionCountLastMinute[directionSecond]);
      directionMostCalculated = diectionVals[directionFirst] + (degreePerCount * directionCountLastMinute[directionSecond]);
    }
  }

  while (directionMostCalculated > 360)
  {
    directionMostCalculated -= 360;
  }
}
else
{
  directionMostCalculated = 0;
}

Die Ermittlung der Texte zu den Gradzahlen ist relativ einfach:

String getWindDirectionDescriptionFromDegree(int degreeIn)
{
  String retStr = "";
  if (degreeIn > -1) // 22.5 / 11.25 / round all
  {
    if (degreeIn >= 349 || degreeIn < 12) // 348.75 / 11.5
    {
      retStr = "N";
    }
    else if (degreeIn >= 12 && degreeIn < 34) // 11.5 / 34
    {
      retStr = "NNO";
    }
    else if (degreeIn >= 34 && degreeIn < 57) // 34 / 56.5
    {
      retStr = "NO";
    }
    else if (degreeIn >= 57 && degreeIn < 79) // 56.5 / 79
    {
      retStr = "ONO";
    }
    else if (degreeIn >= 79 && degreeIn < 102) // 79 / 101.5
    {
      retStr = "O";
    }
    else if (degreeIn >= 102 && degreeIn < 124) // 101.5 / 124
    {
      retStr = "OSO";
    }
    else if (degreeIn >= 124 && degreeIn < 147) // 124 / 146.5
    {
      retStr = "SO";
    }
    else if (degreeIn >= 147 && degreeIn < 169) // 146.5 / 169
    {
      retStr = "SSO";
    }
    else if (degreeIn >= 169 && degreeIn < 192) // 169 / 191.5
    {
      retStr = "S";
    }
    else if (degreeIn >= 192 && degreeIn < 214) // 191.5 / 214
    {
      retStr = "SSW";
    }
    else if (degreeIn >= 214 && degreeIn < 237) // 214 / 236.5
    {
      retStr = "SW";
    }
    else if (degreeIn >= 237 && degreeIn < 259) // 236.5 / 259
    {
      retStr = "WSW";
    }
    else if (degreeIn >= 259 && degreeIn < 282) // 259 / 281.5
    {
      retStr = "W";
    }
    else if (degreeIn >= 282 && degreeIn < 304) // 281.5 / 304
    {
      retStr = "WNW";
    }
    else if (degreeIn >= 304 && degreeIn < 327) // 304 / 326.5
    {
      retStr = "NW";
    }
    else if (degreeIn >= 327 && degreeIn < 349) // 326.5 / 349
    {
      retStr = "NNW";
    }
  }

  return retStr;
}

Die Initialisierung des Servers und Rückgabe der Werte als JSON erwähne ich an dieser Stelle nicht explizit.

Code

#include <ArduinoJson.h>

#include <WebServer.h>

#include <TaskScheduler.h>

#include <NTPClient.h>
#include <WiFiUdp.h>

// --------- WIFI -----------
#define STASSID    "" // wifi name
#define STAPSK     "" // wifi pw

#define DEVICENAME "ESP32-Wind";

unsigned long previousMillis = 0;
unsigned long interval = 2000;

// --------- END WIFI -------

// --------- INITS -------

const char* ssid = STASSID;
const char* password = STAPSK;
const char* deviceName = DEVICENAME;
WiFiUDP ntpUDP;
NTPClient timeClient(ntpUDP, "de.pool.ntp.org", 0, 0);
WebServer server(80);
StaticJsonDocument<1024> jsonDocument;
char jsonBuffer[1024];

// --------- END INITS -------

// --------- SCHEDULER BEGIN -------

void checkFreeRam();
Task scheduleCheckFreeRam(21*1000, TASK_FOREVER, &checkFreeRam);

void wifiReconnectCheck();
Task scheduleWifiReconnectCheck(5*1000, TASK_FOREVER, &wifiReconnectCheck);

void refreshTime();
Task scheduleRefreshTime(10*60*1000, TASK_FOREVER, &refreshTime);

void changeMinute();
Task scheduleChangeMinute(1000, TASK_FOREVER, &changeMinute);

void checkDirection();
Task scheduleCheckDirection(1000, TASK_FOREVER, &checkDirection);

Scheduler runner;

// --------- SCHEDULER END ---------

// ------- DEFINITIONS ----------

static String linkColorNormal = "#2321B0";
static String linkColorVisited = "#2321B0";
static String activeMarkerBegin = "<b>&raquo;";
static String activeMarkerEnd = "&laquo;</b>";

// ------- END DEFINITIONS ----------

// ------- PINS ----------
static int morsePin = 2;
static int i2cSdaPin = 21;
static int i2cSclPin = 22;
static int windSpeedPin = 26;
static int windDirectionPin = 34;
// ------- END PINS ----------

// --------- Variables ---------

int freeHeap = 0;

int actualMin = 0;
int actualTenSecond = 0;

static double speedResolution = 0.0875; // m/s per tick per second

int oneSecondWindTicks[60];
int oneSecondWindTicksLastMinute[60];

int minuteTicksSum;
double minuteWindSpeedAvgMS; // m/s
double minuteWindSpeedMinMS; // m/s
double minuteWindSpeedMaxMS; // m/s

static int diectionAnalogVal[] = {  -1, 128, 463, 880,  1302, 1724, 2143}; // min analog value
// Middle Vals                       0  256  670  1091  1514  1934  2352
static int diectionVals[] =      {  60, 100,  140, 190,  240, 280, 350}; // directions in degree
int directionCountLastMinute[7];
int directionFirst = 0;
int directionSecond = 0;
int directionMostCalculated = 0;

int oneSecondWindDirection[60];
int oneSecondWindDirectionLastMinute[60];

// --------- END Variables ---------

// --------- Interrupt Functions -----------

void IRAM_ATTR eventWindSpeedTick()
{
  int actualSecond = timeClient.getSeconds();
  if (actualSecond > -1 && actualSecond < 60)
  {
    oneSecondWindTicks[actualSecond]++;
  }
}

// --------- END Interrupt Functions -----------

void setup() 
{
  initSerial();
  initWifi();
  initSchedules();
  initPinModes();
  initTimeClient();
  initServer();
  checkFreeRam();
}

void loop() 
{
  // put your main code here, to run repeatedly:
  server.handleClient();
  runner.execute();
}

void initSerial()
{
  Serial.begin(115200); 
  while(!Serial){} // Waiting for serial connection
  Serial.println();
}

void initWifi()
{
  Serial.println("WiFi init");
  Serial.print("Wifi: ");
  Serial.println(ssid);
  //Serial.print("WifiPW: ");
  //Serial.println(password);
  
  Serial.println("turn wifi off...");
  WiFi.mode(WIFI_OFF);
  delay(10);
  //WiFi.forceSleepBegin();
  delay(200);
  //WiFi.forceSleepWake();
  WiFi.mode(WIFI_STA);
  delay(250);
  
  WiFi.config(INADDR_NONE, INADDR_NONE, INADDR_NONE);    
  delay(200); 
  //WiFi.mode(WIFI_STA);
  Serial.println("setting hostname");
  WiFi.setHostname(deviceName);
  delay(200); 
  Serial.println("Connecting to WiFi..");
  WiFi.begin(ssid, password);
  delay(200); 

  int iCounter = 0;
  int iMax = 30;
  while (WiFi.status() != WL_CONNECTED && iCounter < iMax)
  {
    digitalWrite(morsePin, LOW);
    delay(500);
    digitalWrite(morsePin, HIGH);
    delay(500);
    Serial.print(".");
    iCounter++;
  }
  
  Serial.println(WiFi.localIP()); 
}

void initPinModes()
{
  Serial.println("PIN MODES init");

  pinMode(morsePin, OUTPUT);
  digitalWrite(morsePin, LOW);

  pinMode(windSpeedPin, INPUT);
  attachInterrupt(digitalPinToInterrupt(windSpeedPin), eventWindSpeedTick, FALLING);

  analogRead(windDirectionPin);

}

void initSchedules()
{
  Serial.println("SCHEDULES init");
  
  runner.init();

  runner.addTask(scheduleCheckFreeRam);
  scheduleCheckFreeRam.enable();

  runner.addTask(scheduleWifiReconnectCheck);
  scheduleWifiReconnectCheck.enable();

  runner.addTask(scheduleRefreshTime);
  scheduleRefreshTime.enable();

  runner.addTask(scheduleChangeMinute);
  scheduleChangeMinute.enable();

  runner.addTask(scheduleCheckDirection);
  scheduleCheckDirection.enable();
}

void initTimeClient()
{
  timeClient.begin();
  delay(1000);
  timeClient.update();
}

void initServer()
{
  server.on("/", handleConnect);
  server.on("/jsondoaction", jsonDoAct);
  server.on("/jsondoaction", HTTP_POST, jsonDoAct);  
  server.onNotFound(handleConnect);
  server.begin();
  Serial.println("HTTP server started");
}

void wifiReconnectCheck()
{
  unsigned long currentMillis = millis();
  // if WiFi is down, try reconnecting every CHECK_WIFI_TIME seconds
  if ((WiFi.status() != WL_CONNECTED) && (currentMillis - previousMillis >=interval))
  {
    Serial.print(millis());
    Serial.println("Reconnecting to WiFi...");
    WiFi.disconnect();
    WiFi.reconnect();
    previousMillis = currentMillis;
  }
}

void refreshTime()
{
  timeClient.update();
}

void changeMinute()
{
  if (timeClient.getMinutes() != actualMin)
  {
    Serial.println("Changing Minute...");    
    actualMin = timeClient.getMinutes();
    
    memcpy(oneSecondWindTicksLastMinute, oneSecondWindTicks, sizeof(oneSecondWindTicks));

    memcpy(oneSecondWindDirectionLastMinute, oneSecondWindDirection, sizeof(oneSecondWindDirection));

    //Serial.print("Arrays old/new: ");

    for (int i=0; i<60; i++)
    {
      oneSecondWindTicks[i] = 0;
      //Serial.print(" " + (String)oneSecondWindTicksLastMinute[i] + "/" + (String)oneSecondWindTicks[i]);
    }

    for (int i=0; i<60; i++)
    {
      oneSecondWindDirection[i] = 0;
      //Serial.print(" " + (String)oneSecondWindDirectionLastMinute[i] + "/" + (String)oneSecondWindDirection[i]);
    }

    //Serial.println();

    buildLastMinuteVals();
    
  }
}

void buildLastMinuteVals()
{
  // wind speed
  
  int minTickVal = oneSecondWindTicksLastMinute[0];
  int maxTickVal = 0;
  minuteTicksSum = 0;

  for (int i=0; i<60; i++)
  {
    int workVal = oneSecondWindTicksLastMinute[i];

    if (workVal < 1000)
    {   
      if (workVal < minTickVal) // set min
      {
        minTickVal = workVal;
      }
      if (workVal > maxTickVal) // set max
      {
        maxTickVal = workVal;
      }

      minuteTicksSum += workVal; // add to sum
    }    
  }

  double avgTickVal = (double)minuteTicksSum / (double)60;

  // convert to m/s
  minuteWindSpeedAvgMS = avgTickVal * speedResolution;
  minuteWindSpeedMinMS = (double)minTickVal * speedResolution;
  minuteWindSpeedMaxMS = (double)maxTickVal * speedResolution;


  // wind direction

  // prepararion
  for(int i=0; i<7; i++)
  {
    directionCountLastMinute[i] = 0;
  }
  
  directionFirst = -1;
  directionSecond = -1;
  directionMostCalculated = 0;    

  for (int i=0; i<60; i++)
  {
    int workVal = oneSecondWindDirectionLastMinute[i];
    
    for (int j=0; j<7; j++)
    {
      if (workVal == diectionVals[j])
      {
        directionCountLastMinute[j]++;
      }
    }
  }

  // check which is most used
  for (int i=0; i<7; i++)
  {
    if (directionCountLastMinute[i] > directionCountLastMinute[directionFirst] || directionFirst == -1)
    {
      directionFirst = i;
    }
  }

  // check which is second most used  
  for (int i=0; i<7; i++)
  {
    if (i != directionFirst)
    {
      if (directionCountLastMinute[i] > directionCountLastMinute[directionSecond] || directionSecond == -1 )
      {
        directionSecond = i;
      }
    }
  }

  if (directionFirst == -1)
  {
    directionFirst = 0;
  }

  if (directionSecond == -1)
  {
    directionSecond = directionFirst;
  }

  Serial.println("First direction: " + (String)directionFirst + " / Count: " + (String)directionCountLastMinute[directionFirst]);
  Serial.println("Senco direction: " + (String)directionSecond + " / Count: " + (String)directionCountLastMinute[directionSecond]);

  if (directionCountLastMinute[directionFirst] > 0 || directionCountLastMinute[directionSecond] > 0) // otherwise we have zero division
  {

    int workVal = 0;
    double degreePerCount;

    if (diectionVals[directionFirst] < diectionVals[directionSecond]) // 10 / 100 | 10 / 200 | 10 / 350
    {
      if (diectionVals[directionFirst] + 180 >= diectionVals[directionSecond]) // 10 / 100
      {
        workVal = diectionVals[directionSecond] - diectionVals[directionFirst];
        degreePerCount = (double)workVal / (directionCountLastMinute[directionFirst] + directionCountLastMinute[directionSecond]);
        directionMostCalculated = diectionVals[directionFirst] + (degreePerCount * directionCountLastMinute[directionSecond]);
      }
      else if (diectionVals[directionFirst] + 180 < diectionVals[directionSecond]) // 10 / 200
      {
        workVal = 360 - diectionVals[directionSecond] + diectionVals[directionFirst];
        degreePerCount = (double)workVal / (directionCountLastMinute[directionFirst] + directionCountLastMinute[directionSecond]);
        directionMostCalculated = diectionVals[directionSecond] + (degreePerCount * directionCountLastMinute[directionFirst]);
      }
      else // 10 / 350
      {
        workVal = 180 - diectionVals[directionSecond] + diectionVals[directionFirst];
        degreePerCount = (double)workVal / (directionCountLastMinute[directionFirst] + directionCountLastMinute[directionSecond]);
        directionMostCalculated = diectionVals[directionSecond] + (degreePerCount * directionCountLastMinute[directionFirst]);
      }

    }
    else if (diectionVals[directionFirst] > diectionVals[directionSecond]) // 100 / 10 | 200 / 10 | 350 / 10
    {
      if (diectionVals[directionSecond] + 180 >= diectionVals[directionSecond]) // 100 / 10
      {
        workVal = diectionVals[directionFirst] - diectionVals[directionSecond];
        degreePerCount = (double)workVal / (directionCountLastMinute[directionFirst] + directionCountLastMinute[directionSecond]);
        directionMostCalculated = diectionVals[directionSecond] + (degreePerCount * directionCountLastMinute[directionFirst]);
      }
      else if (diectionVals[directionSecond] + 180 < diectionVals[directionSecond]) // 200 / 10
      {
        workVal = 360 - diectionVals[directionFirst] + diectionVals[directionSecond];
        degreePerCount = (double)workVal / (directionCountLastMinute[directionFirst] + directionCountLastMinute[directionSecond]);
        directionMostCalculated = diectionVals[directionFirst] + (degreePerCount * directionCountLastMinute[directionSecond]);
      }
      else // 350 / 10
      {
        workVal = 180 - diectionVals[directionFirst] + diectionVals[directionSecond];
        degreePerCount = (double)workVal / (directionCountLastMinute[directionFirst] + directionCountLastMinute[directionSecond]);
        directionMostCalculated = diectionVals[directionFirst] + (degreePerCount * directionCountLastMinute[directionSecond]);
      }
    }

    while (directionMostCalculated > 360)
    {
      directionMostCalculated -= 360;
    }
  }
  else
  {
    directionMostCalculated = 0;
  }

}

String getWindDirectionDescriptionFromDegree(int degreeIn)
{
  String retStr = "";
  if (degreeIn > -1) // 22.5 / 11.25 / round all
  {
    if (degreeIn >= 349 || degreeIn < 12) // 348.75 / 11.5
    {
      retStr = "N";
    }
    else if (degreeIn >= 12 && degreeIn < 34) // 11.5 / 34
    {
      retStr = "NNO";
    }
    else if (degreeIn >= 34 && degreeIn < 57) // 34 / 56.5
    {
      retStr = "NO";
    }
    else if (degreeIn >= 57 && degreeIn < 79) // 56.5 / 79
    {
      retStr = "ONO";
    }
    else if (degreeIn >= 79 && degreeIn < 102) // 79 / 101.5
    {
      retStr = "O";
    }
    else if (degreeIn >= 102 && degreeIn < 124) // 101.5 / 124
    {
      retStr = "OSO";
    }
    else if (degreeIn >= 124 && degreeIn < 147) // 124 / 146.5
    {
      retStr = "SO";
    }
    else if (degreeIn >= 147 && degreeIn < 169) // 146.5 / 169
    {
      retStr = "SSO";
    }
    else if (degreeIn >= 169 && degreeIn < 192) // 169 / 191.5
    {
      retStr = "S";
    }
    else if (degreeIn >= 192 && degreeIn < 214) // 191.5 / 214
    {
      retStr = "SSW";
    }
    else if (degreeIn >= 214 && degreeIn < 237) // 214 / 236.5
    {
      retStr = "SW";
    }
    else if (degreeIn >= 237 && degreeIn < 259) // 236.5 / 259
    {
      retStr = "WSW";
    }
    else if (degreeIn >= 259 && degreeIn < 282) // 259 / 281.5
    {
      retStr = "W";
    }
    else if (degreeIn >= 282 && degreeIn < 304) // 281.5 / 304
    {
      retStr = "WNW";
    }
    else if (degreeIn >= 304 && degreeIn < 327) // 304 / 326.5
    {
      retStr = "NW";
    }
    else if (degreeIn >= 327 && degreeIn < 349) // 326.5 / 349
    {
      retStr = "NNW";
    }
  }

  return retStr;
}

double convertMsToKmh(double msValIn)
{
  double tmpVal = msValIn * (double)3.6;
  return tmpVal;
}

void checkDirection()
{
  int actualSecond = timeClient.getSeconds();
  if (actualSecond > -1 && actualSecond < 60)
  {
    int analogVal = analogRead(windDirectionPin);
    int direction = 0;
    for (int i=0; i<7 && (analogVal > diectionAnalogVal[i]); i++)
    {
      direction = diectionVals[i];
    }

    oneSecondWindDirection[actualSecond] = direction;

    Serial.println("WindDirectionAnalogVal: " + (String)analogVal);
    Serial.println("WindDirectionDegreeVal: " + (String)direction);
  }
}

void checkFreeRam()
{
  freeHeap = ESP.getFreeHeap();

  if (ESP.getFreeHeap() < 60000)
  {
    ESP.restart();
  }

  //Serial.println("Free RAM: " + String(ESP.getFreeHeap()));
}

void handleConnect()
{
  Serial.println("Connect");
  server.send(200, "text/html", SendHTML("")); 
}

String SendHTML(String context)
{
  String ptr = "<!DOCTYPE html> <html>\n";
  ptr +="<head><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, user-scalable=no\">\n";
  ptr +="<title>ESP32 Garagentor TEST</title>\n";
  ptr +="<style>html { font-family: Arial; display: inline-block; margin: 0px auto; text-align: center;}\n";
  ptr +="body{margin-top: 50px;} h1 {color: #444444;margin: 50px auto 30px;} h3 {color: #444444;margin-bottom: 50px;}\n";
  ptr +=".button {display: block;width: 80px;background-color: #3498db;border: none;color: white;padding: 13px 30px;text-decoration: none;font-size: 25px;margin: 0px auto 35px;cursor: pointer;border-radius: 4px;}\n";
  ptr +="a, a:active, { color: " + linkColorNormal + "; text-decoration: underline; }\n";
  ptr +="a:visited { color: " + linkColorVisited + "; text-decoration: underline; }\n";
  ptr +="p {font-size: 14px;color: #888;margin-bottom: 10px;}\n";
  ptr +="</style>\n";
  ptr +="</head>\n";
  ptr +="<body>\n";
  ptr +="<h1>ESP32 Garagentor TEST</h1>\n";

  String lineBreak = "<br><br>";

  ptr += "";

  ptr +="</body>\n";
  ptr +="</html>\n";
  return ptr;
}

void jsonDoAct()
{
  Serial.println("JSON Act");

  String status = "OK";

  if (server.hasArg("plain") == false) 
  {
    //handle error here
  }
  
  String body = server.arg("plain");
  Serial.println(body);
  deserializeJson(jsonDocument, body);
  
  createStatusJson(status);

  server.send(200, "application/json", jsonBuffer);
}

// -------------------- JSON HELPER --------------------------

void createStatusJson(String statusIn) 
{
  if (statusIn == "")
  {
    statusIn = "OK";
  }
  
  jsonDocument.clear();  
  jsonDocument["state"] = statusIn;
  
  jsonDocument["windSpeedMinuteTicksSum"] = minuteTicksSum;
  jsonDocument["windSpeedMinuteAvgMS"] = minuteWindSpeedAvgMS;
  jsonDocument["windSpeedMinuteMinMS"] = minuteWindSpeedMinMS;
  jsonDocument["windSpeedMinuteMaxMS"] = minuteWindSpeedMaxMS;

  jsonDocument["windSpeedMinuteAvgKMH"] = convertMsToKmh(minuteWindSpeedAvgMS);
  jsonDocument["windSpeedMinuteMinKMH"] = convertMsToKmh(minuteWindSpeedMinMS);
  jsonDocument["windSpeedMinuteMaxKMH"] = convertMsToKmh(minuteWindSpeedMaxMS);

  jsonDocument["windDirectionDirectionFirstDegree"] = diectionVals[directionFirst];
  jsonDocument["windDirectionDirectionFirstCount"] = directionCountLastMinute[directionFirst];
  jsonDocument["windDirectionDirectionFirstText"] = getWindDirectionDescriptionFromDegree(diectionVals[directionFirst]);
  
  jsonDocument["windDirectionDirectionSecondDegree"] = diectionVals[directionSecond];
  jsonDocument["windDirectionDirectionSecondCount"] = directionCountLastMinute[directionSecond];
  jsonDocument["windDirectionDirectionSecondText"] = getWindDirectionDescriptionFromDegree(diectionVals[directionSecond]);

  jsonDocument["windDirectionCalculatedDegree"] = directionMostCalculated;
  jsonDocument["windDirectionCalculatedText"] = getWindDirectionDescriptionFromDegree(directionMostCalculated);

  jsonDocument["freeram"] = freeHeap;

  serializeJson(jsonDocument, jsonBuffer);
}

Hinweis

Dieser Artikel dokumentiert lediglich meinen Aufbau. Für den Nachbau, die Nutzung einzelner Komponenten, die Platinen und den gesamten Inhalt wird die Haftung in jeglicher Form ausgeschlossen.

Categories
Raspberry Pi

DL0FIS – Raspberry Pi – Kameras mit Sensoren – Revision

Link zu den Webcams und Wetterdaten hier

Link zum A18, auf dessen Clubstation (DL0FIS), die Kameras installiert sind hier

Einleitung

Nach einigen Jahren haben die Kameras mit Sensoren bei DL0FIS immer unzuverlässiger gearbeitet, bis hin zum Totalausfall beider Raspberry Pis.

Daher mussten die Kameras abgebaut und revisioniert werden. Meistens ist es “nur” die SD-Karte, sollte man meinen. Doch nach einigen Jahren der Witterung ausgesetzt, war es nicht nur die SD Karte, die defekt war.

Bestandsaufnahme

Der DHT22 Sensor genauso wie der BME280 Sensor sind durch die Witterungseinflüsse komplett “abgegammelt”.

Defekter DHT22 Sensor – Im Hintergrund zu sehen: der damals noch schlecht gedruckte “Wetterschutz” für den Sensor.
Defekter DHT22 Sensor – Innenleben

Bei einer der Kameras hat sich der Kleber der Scheibe gelöst, somit konnte Wasser ins Gehäuse eindringen – Den Raspberry Pi hat das nicht zerstört, da das Wasser im Gehäuse nie hoch stand.

Vorderseite der Kamera, bei der sich der Kleber des Frontglases gelöst hat (ohne Glas) – Links erkennt man gut die Moosbildung durch eindringende Feuchtigkeit.

Bei einer der Kameras habe ich Pappe statt einer 3D gedruckten Kamerarückseite verwendet, auch diese hat sich sehr verzogen. Davon habe ich kein Foto gemacht, hier nochmal das Original bzw. wie es beim Einbau aussah:

Die eingebauten Step-Down Wandler (damals noch mit schlecht gedruckten Gehäuse) funktionieren weiterhin zuverlässig.

Step-Down Wandler in der Gehäuserückseite einer Kamera.

Die SD-Karte ist auch defekt und muss ersetzt werden.

Hardware

Bisher war die eine Kamera mit einem DHT22 Sensor und die andere mit einem BME280 Sensor für die Messung der Umgebung ausgestattet. Sowie eine der Kameras mit Sensoren für die Messung der Innentemperatur.

Im Zuge der Revision habe ich mich dazu entschieden die Innensensoren zu entfernen, sowie nur noch eine der Kameras mit einem Außensensor (BME280) auszustatten.

Neuer Außensensor – BME280

Die Schutzhülle des Sensors habe ich neu gestaltet, sodass der Schutz für den Sensor verbessert wird. Den Schutz habe ich im Anschluss lackiert, um den ABS Kunststoff etwas gegen die UV Strahlung zu schützen.

BME280 Sensor Schutz lackiert inklusive Bohrungen für die Luftzirkulation.
3D Modell – BME280 Sensor Schutz – Der mehrwandige Schutz soll verhindern, dass Wasser bei Wind zum Sensor gelangt.

Die Front mit der Glasscheibe habe ich auch repariert bzw. die Scheibe mit etwas Acryl befestigt.

Befestigte Scheibe in der Kamerafront

Als Kamerarückseite habe ich eine 3D gedrucktes Teil eingesetzt, dass ich mit schwarzer Farbe lackiert habe, da ABS Kunststoff dazu neigen kann bei Sonneneinstrahlung aus zu bleichen. Die Kamerarückseite ist deshalb montiert, damit es auf der Scheibe keine Reflexionen gibt.

Kamerarückseite ohne eingesetzte Kamera, aufgeraut, damit der Lack besser hält.
Lackierte Kamerarückseite ohne eingesetzte Kamera.

Das Kameragehäuse hat eine Art “Schlitten” integriert, auf dem der Raspberry Pi installiert war und wieder installiert wird.

Der Kamerahalter ist separat gedruckt und wird mittels Aceton an die Kamerarückseite “geklebt”. Darin wird die Kamera mit zwei kleinen Klebetropfen nochmals fixiert. Alle Teile sind aus ABS gedruckt.

Montierte Kamera von der Rückseite

Bisher hatte der Raspberry Pi ein Gehäuse, dass auch oben zu war. Das neue “Gehäuse” ist ausschließlich eine Halterung, die oben offen ist, damit die Abwärme des Raspberry Pis besser in das Gehäuse abgegeben werden kann.

Raspberry Pi in der Halterung
3D Modell – Raspberry Pi 2 – Halterung

Die Stromversorgung bleibt wie bisher auch über den Step Down Wandler bestehen. Allerdings war es bisher so, dass ein langes LAN-Kabel von unten bis in die Kamera geführt wurde. Das ist sehr wartungsunfreundlich, deshalb wurde das geändert, dazu später. Da ich keine flexiblen LAN-Kabel für den Außeneinsatz gefunden habe, habe ich ein flexibles Kabel für innen mit einem für den Außeneinsatz geeigneten, selbst verschweißenden Band umwickelt und durch eine Kabeldurchführung ins Gehäuse geführt.

Schwarz mit Band eingewickelt das Netzwerkkabel.

Wie bereits erwähnt, werden kurze Netzwerkkabel verwendet, um die Kameras mit dem Netzwerk zu verbinden.

Zum Mast hoch geht nur noch ein Cat7 Kabel, das für den Außeneinsatz geeignet ist. Am Mast befindet sich ein Verteiler, an dem das achtdrähtige Cat7 Kabel über ein Patchpanel im inneren der Box in zwei LAN-Verbindungen aufgesplittet wird, die dann jeweils zu den Kameras gehen. Das Gehäuse wird mittels einer Masthalterung am Mast befestigt.

Offenes Gehäuse für die LAN-Anschlüsse, links und in der Mitte die beiden schwarzen Anschlüsse, an die die Kameras angeschlossen werden, ganz rechts die Kabeldurchführung für das Cat7 Außenkabel.

Nachdem sich Erfahrungsgemäß zum Teil hohe Temperaturen im Gehäuse bilden können, vornehmlich im Sommer, habe ich eine entsprechende Durchlüftung im Gehäuse eingebaut. Die Belüftung muss so beschaffen sein, dass kein Regen in der Gehäuse eindringen kann, auch wenn hohe Windgeschwindigkeiten vorliegen. Das habe ich so gelöst, dass ich im Inneren des Gehäuses “Türme” eingebaut habe, die zwar Luftzirkulation zulassen, nicht jedoch das eindringen von Wasser. Genauso gibt es in der Mitte unter dem Raspberry Pi eine mit einem Kunststoffplättchen teilüberdeckte Öffnung, durch die ggf. eindringendes Wasser entweichen kann (nicht zu sehen).

In weiß zu sehen die Belüftungsöffnungen

Und so sieht die fertige Kamera offen aus:

Fertige Kamera offen von oben

Nachdem der Innenausbau fertig ist, habe ich noch das Vorderteil und Hinterteil abgeschliffen und lackiert, da diese von der Witterung angegriffen waren. Zudem habe ich das “Dach” aus Aluminium abgeschliffen und lackiert (weiß + Klarlack), da es nicht mehr schön aussah.

Fertige Kamera von der Seite
Fertige Kamera von vorne

Am Mast montiert sehen die Kameras mit der LAN-Box wie folgt aus:

Foto: DF1GT – Kameras am Mast montiert.

Software

Auf dem Raspberry Pi läuft Raspbian.

Ich könnte hier einiges über die eingesetzten Technologien und Software schreiben. Das wirklich wichtige ist, dass häufig wechselnde Daten nicht auf die SD Karte geschrieben werden sollten, sondern in eine RAM-Disk. So sind viele meiner Logs, sowie das Webcambild konfiguriert.

Beispielauszug für die Einrichtung eines Webcambildes auf einer RAM-Disk:

#!/bin/sh


mkdir /mnt/RAMDisk

echo "" >> /etc/fstab
echo "tmpfs /mnt/RAMDisk tmpfs nodev,nosuid,size=5M 0 0" >> /etc/fstab
echo "" >> /etc/fstab

mount -a

df

Danach könnte das Ergebnis wie folgt aussehen, was der Befehl “df” auswirft:

Filesystem     1K-blocks    Used Available Use% Mounted on
/dev/root       15019440 2223292  12151476  16% /
devtmpfs          413172       0    413172   0% /dev
tmpfs             446452       0    446452   0% /dev/shm
tmpfs             178584     572    178012   1% /run
tmpfs               5120       4      5116   1% /run/lock
tmpfs               5120     448      4672   9% /mnt/RAMDisk
/dev/mmcblk0p1    258095   50413    207682  20% /boot
tmpfs              89288       0     89288   0% /run/user/0

Das “/mnt/RAMDisk” mit dem Filesystem “tmpfs” ist jetzt unsere RAMDisk. Da man aber nicht immer den Pfad der “Dateien” wechseln möchte bieten sich symbolische Verknüfungen an, so möchte ich mein Webcambild im Ordner “/home/pi/htdocs” vorfinden. Dafür muss ich zunächst die bereits angelegten Dateien entfernen und dann einen symbolischen Link zur RAMDisk setzen.

mkdir /home/pi/htdocs
rm /home/pi/htdocs/current.jpg
rm /home/pi/htdocs/current.jpg~
ln -s /mnt/RAMDisk/current.jpg /home/pi/htdocs/current.jpg

Das Bild kann ich dann unter dem Link “/home/pi/htdocs/current.jpg” aufrufen, beispielsweise über den Webserver auf dem Raspberry Pi.

Über “ln -s /mnt/RAMDisk/[…] […]” lassen sich weitere Verknüpfungen für Dateien anlegen, die in der RAMDisk abgelegt werden sollen.

Danach muss ich nur noch dem “raspistill” Tool mitteilen, dass es das Webcambild zukünftig in der RAMDisk ablegen soll. Dafür habe ich mein stündlich laufendes Script entsprechend modifiziert.

#!/bin/sh

killall raspivid
killall raspistill

sleep 2

nohup raspistill -o /mnt/RAMDisk/current.jpg -h 720 -w 1280 -tl 2000 -t 4000000 -q 80 -vf -hf -ex auto -n > /dev/null

Fertig. – Zur Erklärung: Ich starte über “nohup” das Tool “raspistill” im Hintergrund, sodass das Script in Gänze durchlaufen kann. Zudem muss ich die Parameter “-vf”, sowie “-hf” anhängen, um das Bild so zu drehen, dass es nicht auf dem Kopf steht, da das Kameramodul kopfstehend eingebaut wurde. Das Script wird stündlich über einen Cronjob gestartet.

Hinweis

Dieser Artikel dokumentiert lediglich meinen Aufbau. Für den Nachbau, die Nutzung einzelner Komponenten, die Platinen und den gesamten Inhalt wird die Haftung in jeglicher Form ausgeschlossen.

Categories
ESP32 Home Assistant

Smarte Türklingel optimiert

Vor einiger Zeit habe ich meine Türklingel smart gemacht, hier der Link.

Relativ schnell hat sich herausgestellt, dass öfter ein Klingeln detektiert wird, wenn man das Licht an- bzw. ausschaltet. Da das Kabel, mit dem die Klingel angesteuert wird nicht geschirmt ist, ist das nicht weiter verwunderlich.

Wir erinnern uns: Der ESP32 reagiert auf das Abfallen des Signals mit einem Interrupt und detektiert ein Klingeln.

Also habe ich mir die Störung auf dem Oszilloskop genauer angeschaut.

Oszilloskop, Störung am ESP32 Eingang

Die Störung ist zirka 6ms Sekunden lang und beginnt sehr “unsauber”.

Danach habe ich mir ein echtes so kurz wie mögliches Klingeln angeschaut.

Oszilloskop, Klingeln am ESP32 Eingang

Was sofort ins Auge fällt ist, dass ein “echtes” Klingeln deutlich länger als die Störung ist. Betrachtet man die Zeit Skala des Oszilloskop Bildes, fällt auf, dass wir nicht mehr 1ms/Einheit sondern 20ms/Einheit haben. Eigentlich müsste man also nur messen, wie lang das Signal “low” ist, um dann entscheiden zu können, ob man eine Klingelbenachrichtigung auslöst. – Zumindest theoretisch. – Am ESP32 ist das nicht so einfach, da man im “Interrupt” nicht so einfach die Möglichkeit hat, ab zu fragen, wie lang der Eingang low/high ist. – Man könnte den Interrupt auch auf “on-low”, sowie auf “on-high” registrieren und messen, wie lang der Abstand zwischen diesen beiden Interrupts ist. Das könnte funktionieren.

Dann habe ich mir das Klingelsignal etwas genauer angeschaut, sowohl den Anfang, wie auch etwa in der Mitte.

Oszilloskop, Klingeln am ESP32 Eingang, Zoom: Anfang
Oszilloskop, Klingeln am ESP32 Eingang, Zoom: Mitte

Dabei habe ich festgestellt, dass das einfache Messen der Differenz zwischen “on-low” und “on-high” nicht zielführend ist. Wenn man allerdings die Differenz zwischen dem ersten “on-low” und dem letzten “on-high” misst, diese mindestens 40ms betragen muss, sodass ein Klingeln ausgelöst wird, kann man die durch Störungen ausgelösten Klingelbenachrichtigungen bis auf wenige Ausnahmen komplett eliminieren. Der Detektorzeitraum wird nach vier Sekunden zurückgesetzt, damit nicht zwei Störungen zu einer Benachrichtigung führen. – Warum werden nur “fast” alle falschen Klingelbenachrichtigungen eliminiert? – Es kann sein man schaltet das Licht an und innerhalb von vier Sekunden wieder aus und es kommt der Umstand dazu, dass bei beiden Schaltvorgängen Störungen ausgelöst werden, dann erhält man dennoch eine Klingelbenachrichtigung. Das kommt bei mir innerhalb eines Monats maximal einmal vor.

Software

Der ESP32 reagiert jetzt auf “on-low” sowie “on-high” und misst die Differenz zwischen dem ersten “on-low” und dem letzten “on-high”. Beträgt dieser mindestens 40ms, wird klingeln ausgelöst. Die beiden Zeitstempel werden nach vier Sekunden zurückgesetzt, um eine Fehlauslösung durch Störungen zu verhindern.

#include "WiFi.h"
#include <WebServer.h>
#include <ArduinoJson.h>
#include <HTTPClient.h>

#include <WiFiClientSecure.h>

#include <TaskScheduler.h>

#include <NTPClient.h>
#include <WiFiUdp.h>

// --------- WIFI -----------

[...]

unsigned long previousMillis = 0;
unsigned long interval = 30000;

// --------- END WIFI -------

// --------- INITS -------

const char* ssid = STASSID;
const char* password = STAPSK;
const char* deviceName = DEVICENAME;
WiFiUDP ntpUDP;
NTPClient timeClient(ntpUDP, "de.pool.ntp.org", 0, 0);
WebServer server(80);
StaticJsonDocument<512> jsonDocument;
char jsonBuffer[512];

const String HomeAssistantBearerName = "Authorization";
const String HomeAssistantBearerContent = "Bearer HomeAssistantBearer";

const String SendApiIotUrl = "HOME-Assistant-URL";

// --------- END INITS -------

// --------- SCHEDULER BEGIN -------

void resetRing();
Task scheduleResetRing(120*1000, TASK_FOREVER, &resetRing);

void checkFreeRam();
Task scheduleCheckFreeRam(60*1000, TASK_FOREVER, &checkFreeRam);

Scheduler runner;

// --------- SCHEDULER END ---------

// ------- DEFINITIONS ----------
static String linkColorNormal = "#2321B0";
static String linkColorVisited = "#2321B0";
static String activeMarkerBegin = "<b>&raquo;";
static String activeMarkerEnd = "&laquo;</b>";
// ------- END DEFINITIONS ----------

// --------- Pins -----------

static int morsePin = 2;
const int detectLed = 4; // D4
const int testButton = 18; // D18
const int dorbellPin = 34; // D34

// --------- END Pins ----------

// --------- Variables ---------

volatile bool ringActive = false;
volatile bool sentActive = false;
volatile bool sentInactive = false;
volatile unsigned long ringMillis = -1;
volatile unsigned long rangMillis = -1;

int freeHeap = 0;

String logText = "";

// --------- END Variables ---------

// --------- Interrupt Functions -----------

void IRAM_ATTR eventDorbell()
{

  int bellVal = digitalRead(dorbellPin);
  if (bellVal == 1)
  {
    rang();
  }
  else
  {
    ring();
  }
}

// --------- END Interrupt Functions -----------

void setup() 
{
  initSerial();

  delay(2000);

  initWifi();
  
  initPinModes();

  initSchedules();

  initTimeClient();

  initServer();
}

void initSerial()
{
  Serial.begin(115200); 
  while(!Serial){} // Waiting for serial connection
  Serial.println();
}

void initWifi()
{
  Serial.println("WiFi init");
  Serial.print("Wifi: ");
  Serial.println(ssid);

  Serial.println("turn wifi off...");
  WiFi.mode(WIFI_OFF);
  delay(10);

  delay(200);

  WiFi.mode(WIFI_STA);
  delay(250);
  
  WiFi.config(INADDR_NONE, INADDR_NONE, INADDR_NONE);    
  delay(200); 

  Serial.println("setting hostname");
  WiFi.setHostname(deviceName);
  delay(200); 
  Serial.println("Connecting to WiFi..");
  WiFi.begin(ssid, password);
  delay(200); 

  int iCounter = 0;
  int iMax = 30;
  while (WiFi.status() != WL_CONNECTED && iCounter < iMax)
  {
    digitalWrite(morsePin, LOW);
    delay(500);
    digitalWrite(morsePin, HIGH);
    delay(500);
    Serial.print(".");
    iCounter++;
  }

  digitalWrite(morsePin, LOW);

  Serial.println(WiFi.localIP()); 
}

void initPinModes()
{
  Serial.println("PIN inits");
  
  pinMode(morsePin, OUTPUT);
  pinMode(detectLed, OUTPUT);
  pinMode(testButton, INPUT);
  pinMode(dorbellPin, INPUT);
  attachInterrupt(digitalPinToInterrupt(dorbellPin), eventDorbell, CHANGE);
}

void initSchedules()
{
  Serial.println("SCHEDULES init");
  
  runner.init();

  runner.addTask(scheduleCheckFreeRam);
  scheduleCheckFreeRam.enable();

  runner.addTask(scheduleResetRing);
  scheduleResetRing.enable();
}

void initTimeClient()
{
  timeClient.begin();
  timeClient.update();
}

void initServer()
{
  server.on("/", handleConnect);
  server.on("/jsondoaction", jsonDoAct);
  server.onNotFound(handleConnect);
  server.begin();
  Serial.println("HTTP server started");
}

void checkFreeRam()
{
  freeHeap = ESP.getFreeHeap();

  if (ESP.getFreeHeap() < 60000)
  {
    ESP.restart();
  }
}

void resetRing()
{
  sendRingStatus();
}

void loop() 
{
  wifiReconnectCheck();

  handleButtons();

  checkRing();

  handleRing();

  server.handleClient();
  runner.execute();

}

void checkRing()
{
  if (ringMillis != -1 && 
      rangMillis != -1)
  {

    if (rangMillis < ringMillis)
    {
      return;
    }
    if ((rangMillis - ringMillis) < 40)
    {
      return;
    }

    if ((rangMillis - ringMillis) >= 40)
    {
      ringActive = true;
      digitalWrite(detectLed, HIGH);
    }

    ringMillis = -1;
    rangMillis = -1;
  }

  // if too long
  if (ringMillis != -1)
  {
    if (millis() < ringMillis ||
        (millis() - ringMillis) > 4000)
    {
      ringActive = false;
      digitalWrite(detectLed, LOW);
      ringMillis = -1;
    }
  }

  if (rangMillis != -1)
  {
    if (millis() < rangMillis ||
        (millis() - rangMillis) > 4000)
    {
      ringActive = false;
      digitalWrite(detectLed, LOW);
      rangMillis = -1;
    }
  }
}

void wifiReconnectCheck()
{
  unsigned long currentMillis = millis();
  // if WiFi is down, try reconnecting every CHECK_WIFI_TIME seconds
  if ((WiFi.status() != WL_CONNECTED) && (currentMillis - previousMillis >=interval))
  {
    Serial.print(millis());
    Serial.println("Reconnecting to WiFi...");
    WiFi.disconnect();
    WiFi.reconnect();
    previousMillis = currentMillis;
  }
}

void handleButtons()
{
  int testBtn = digitalRead(testButton);

  if (testBtn == 1)
  {
    digitalWrite(morsePin, HIGH);
    delay(100);
    testBtn = digitalRead(testButton);
    digitalWrite(morsePin, LOW);
    if (testBtn == 1)
    {
      ringActive = true;
      digitalWrite(detectLed, HIGH);
    }
    delay(2000);
  }
}

void handleRing()
{
  if (ringActive == true && sentActive == false)
  {
    sendRingStatus();
  }
  else if (ringActive == false && sentInactive == false)
  {
    sendRingStatus();
  }
  else if (ringActive == true && sentInactive == true)
  {
    sendRingStatus();
  }
}

void ring()
{
  if (ringMillis == -1)
  {
    ringMillis = millis();
  }
}

void rang()
{
  rangMillis = millis();
}

void unring()
{
  ringActive = false;
  digitalWrite(detectLed, LOW);
}

String sendRingStatus()
{
  String retStr;

  HTTPClient *https = new HTTPClient();
  https->setReuse(false);

  Serial.print("[HTTP] begin...\n");
  Serial.println("[HTTP] URL: " + SendApiIotUrl);
  
  if (https->begin(SendApiIotUrl))
  {
    Serial.print("[HTTP] POST...\n");
    https->setTimeout(30000);
    // start connection and send HTTP header
    https->addHeader("Content-Type", "application/json; charset=UTF-8");
    https->addHeader(HomeAssistantBearerName, HomeAssistantBearerContent); // auth

    // status Json
    bool initialRingActive = ringActive;
    String sendContent = "{\"state\": \"off\" }";
    if (initialRingActive == true)
    {
      sendContent = "{\"state\": \"on\" }";
    }

    Serial.println("Content: " + sendContent);
    
    int httpCode = https->POST(sendContent);

    // httpCode will be negative on error
    if (httpCode > 0)
    {
      // HTTP header has been send and Server response header has been handled
      Serial.printf("[HTTP] POST... code: %d\n", httpCode);

      // file found at server
      if (httpCode == HTTP_CODE_OK || httpCode == HTTP_CODE_MOVED_PERMANENTLY)
      {
        retStr = https->getString();

        if (initialRingActive == true)
        {
          sentActive = true;
          sentInactive = false;
          delay(3000);
          unring();
        }
        else if (initialRingActive == false)
        {
          sentActive = false;
          sentInactive = true;
        }
        
      }
    }
    else
    {
      Serial.printf("[HTTP] GET... failed, error: %s\n", https->errorToString(httpCode).c_str());
      retStr = "error: ";
      retStr += https->errorToString(httpCode).c_str();
    }

    https->end();
  }
  else
  {
    Serial.printf("[HTTP] Unable to connect\n");
    retStr = "ConErr";
  }

  delete https;
  https = NULL;

  delay(2000);

  return retStr;
}

// HTTP Server

void handleConnect()
{
  Serial.println("Connect");
  server.send(200, "text/html", SendHTML("")); 
}

String SendHTML(String context)
{
  String ptr = "<!DOCTYPE html> <html>\n";
  ptr +="<head><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, user-scalable=no\">\n";
  ptr +="<title>ESP32 Doorbell</title>\n";
  ptr +="<style>html { font-family: Arial; display: inline-block; margin: 0px auto; text-align: center;}\n";
  ptr +="body{margin-top: 50px;} h1 {color: #444444;margin: 50px auto 30px;} h3 {color: #444444;margin-bottom: 50px;}\n";
  ptr +=".button {display: block;width: 80px;background-color: #3498db;border: none;color: white;padding: 13px 30px;text-decoration: none;font-size: 25px;margin: 0px auto 35px;cursor: pointer;border-radius: 4px;}\n";
  ptr +="a, a:active, { color: " + linkColorNormal + "; text-decoration: underline; }\n";
  ptr +="a:visited { color: " + linkColorVisited + "; text-decoration: underline; }\n";
  ptr +="p {font-size: 14px;color: #888;margin-bottom: 10px;}\n";
  ptr +="</style>\n";
  ptr +="</head>\n";
  ptr +="<body>\n";
  ptr +="<h1>ESP32 Doorbell</h1>\n";

  String lineBreak = "<br><br>";

  ptr += logText;

  ptr +="</body>\n";
  ptr +="</html>\n";
  return ptr;
}

void jsonDoAct()
{
  Serial.println("JSON Act");

  String status = "OK";
  
  if (server.hasArg("plain") == false) 
  {
    //handle error here
  }
  
  String body = server.arg("plain");
  Serial.println(body);
  deserializeJson(jsonDocument, body);

  // continue
  status = "OK";

  createStatusJson(status);

  server.send(200, "application/json", jsonBuffer);
}

// -------------------- JSON HELPER --------------------------

void createStatusJson(String statusIn) 
{
  if (statusIn == "")
  {
    statusIn = "OK";
  }
  
  jsonDocument.clear();  
  jsonDocument["state"] = statusIn;
  jsonDocument["millis"] = millis();
  jsonDocument["ringActive"] = ringActive;
  jsonDocument["sentActive"] = sentActive;
  jsonDocument["sentInactive"] = sentInactive;

  jsonDocument["freeram"] = freeHeap;

  serializeJson(jsonDocument, jsonBuffer);
}

// logger

void putlog(String strIn)
{
  //logText += strIn + "<br>";
}

Hinweis

Dieser Artikel dokumentiert lediglich meinen Aufbau. Für den Nachbau, die Nutzung einzelner Komponenten, die Platinen und den gesamten Inhalt wird die Haftung in jeglicher Form ausgeschlossen.

Categories
ESP32 Home Assistant

Radioaktivitätsmessung mit ESP32 und Geiger-Müller-Zählrohr

Vor zirka ein bis zwei Jahren habe ich mir aus einer Laune heraus ein Geiger Müller Zählrohr gekauft. Wie genau die Messung erfolgt und was man beachten muss, sowie wie man die Daten abgreifen kann, wusste ich damals nicht, aber ich wollte das Teil haben.

Ich hatte fast vergessen, dass ich das Zählrohr noch habe, als ich ihn durch Zufall zwischen anderen Platinen wiedergefunden habe.

Warum sollte man selbst ein Geiger-Müller-Zählrohr aufstellen und dessen Werte abgreifen? – Da ich bereits eine Wetterstation mit Feinstaubsensor betreibe, ist es naheliegend diese um weitere Umweltsensoren jeglicher Art zu ergänzen. Weitere Sensoren werden folgen, welche das sein werden, werden wir sehen, es bleibt spannend.

In diesem Artikel werde ich “Geiger-Müller Zählrohr” der Einfachheit halber mit “Geiger-Zähler” abkürzen.

Funktionsweise

Die Funktionsweise eines Geiger-Müller Zählrohrs werde ich nicht erläutern, diese kann in diversen Quellen im Internet nachgelesen werden.

Wichtig ist eher; Wie kann ich die Daten mit dem ESP32 abgreifen. Dafür habe ich mir zunächst das Audio Ausgangssignal des Geiger-Zählers mit dem Oszilloskop angeschaut. Folgendes Signal kommt dabei raus:

Geiger-Zähler: Audio Ausgangssignal

Wir erkennen unschwer, dass es sich um ein erwartungsgemäß schwaches und kein sauberes Hi-/Low-Signal handelt. Selbstverständlich kann man solche Signale verstärken und aufbereiten. Behalten wir im Hinterkopf, dass der ESP32 die Möglichkeit bietet, dass man auf “on Low” sowie “on High” reagieren kann, muss man zugeben, dass selbst wenn eine Aufbereitung gelungen ist die Signalerkennung zwar möglich aber nicht zwingend trivial wird.

Da sich auf der Geiger-Zähler Platine auch eine LED befindet, die immer bei einem “Tick” aufleuchtet, habe ich mir überlegt; Vielleicht kann man dort ein sauberes Hi-Low Signal abgreifen. Meine Vermutung wurde bestätigt, hier liegt ein sauberes ~5V Hi-, sowie Low- Signal an, wie im folgenden zu sehen:

Geiger-Zähler: LED Signal

Da wir jetzt nur noch auf 3.3V runter müssen, ist es sehr einfach dies mithilfe einer Transistorschaltung um zu setzen.

Schaltung 5V -> 3.3V

Bei dieser Schaltung ist es so, dass, wenn der ESP32 Pin von 1 auf 0 geht, entspricht das einem “Tick” des Geiger-Zählers. C1, sowie R3 sind im oberen Schaubild dazu da, dass Störungen beispielsweise durch HF oder das Betätigen von Lichtschaltern reduziert werden. Das bedeutet auch, dass der ESP32 auf “on Low” reagieren bzw. Zählen muss, dazu später.

Berechnung

Nachdem wir das Signal bzw. die “Ticks” des Zählrohrs sauber abgreifen können, stellt sich die Frage, wie man von der Anzahl der Ticks auf die detektierte Dosis kommt. Hier liefert der Hersteller der Platine einen “conversion index”, der in meinem Fall bei 151 liegt. Dieser sagt aus, dass 151 “Ticks” (bzw. CPM = Counts per Minute) innerhalb einer Minute bedeuten, dass 1µSv/h als Dosis anliegen.

Rechenbeispiel: 30 CPM / 150 => 0.1987 µSv/h

Laut meinen Recherchen ist eine Dosis, die in der Umgebung vorherrscht je nach Höhe und Lokalität zwischen ~0.8 und ~1.2 mSv/Jahr eine normale Dosis, die der Mensch durch die gegebene Umgebungsstrahlung pro Jahr aufnimmt. Es ist wohl auch so, dass der Mensch durch die Nahrungsaufnahme, Flugreisen etc. weiterer Strahlung ausgesetzt ist, das wird hier nicht betrachtet, es geht einzig und allein um die Umgebungsstrahlung. Also müssen wir das Ergebnis auf das Jahr hochrechnen. Wichtig hierbei ist, dass man nicht nur mit 365 Tagen sondern 365.25 Tagen rechnet, Thema Schaltjahr.

Rechenbeispiel: [ ( 30 CPM / 150 ) * 24h * 365.25 Tage ] / 1000 => 1,7532 mSv/Jahr

Software

Für die Zählung der Ticks pro Minute verwende ich die TaskScheduler Bibliothek, die für Arduino zur Verfügung steht. Das bedeutet, dass ich so lange einen Zähler über die Interrupts hochzähle, bis eine Minute vorbei ist und dann den Zähler auf einen “vorherige Minute” Wert schreibe, der dann als JSON Objekt im Webinterface zur Verfügung steht. Genauso basieren die berechneten Dosiswerte, die im Webinterface ausgegeben werden, auf den “Ticks” der vorigen Minute.

Zunächst müssen wir den Handler für das “on Low” Interrupt definieren.

void IRAM_ATTR eventTick()
{
  actualMinTickVal++;
}

Um dann im eigentlichen Programm diesen Interrupt auf “FALLING” zu aktivieren:

void setup() 
{
  [...]
  initPinModes();

  [...]
}

void initPinModes()
{
  Serial.println("PIN MODES init");

  [...]
  pinMode(pinInTick, INPUT);
  attachInterrupt(pinInTick, eventTick, FALLING);
  [...]
}

Das “umschreiben” des Zählerwertes erfolgt über den Scheduler, der jede Sekunde prüft, ob sich die Minute der Uhrzeit geändert hat:

void changeMinute();
Task scheduleChangeMinute(1000, TASK_FOREVER, &changeMinute);

void setup() 
{
  [...]
  initSchedules();
  [...]
}

void loop() 
{
  server.handleClient();
  runner.execute();
  [...]
}

void initSchedules()
{
  Serial.println("SCHEDULES init");
  
  runner.init();

  [...]
  runner.addTask(scheduleChangeMinute);
  scheduleChangeMinute.enable();
  [...]
}

void changeMinute()
{
  if (timeClient.getMinutes() != actualMin)
  {
    Serial.println("Changing Minute...");    
    actualMin = timeClient.getMinutes();
    lastMinTickVal = actualMinTickVal;
    actualMinTickVal = 0;
    if (lastMinTickVal > 0)
    {
      lastMinValid = true;
    }
  }
}

Die Uhrzeit wird über die “NTPClient” Bibliothek ermittelt. Wie man diese Bibliothek verwendet und einbindet ist im Internet zu genüge dokumentiert, deshalb gehe ich hier nicht darauf ein.

Platine

Hier die Platine dazu (Afillate): https://aisler.net/p/ASGRFSHK

Bauteile:

  • C1, C2, C3, C4 : 10nF
  • L1 : 10µH
  • R1, R2, R4 : 5.6kO
  • R3 : 1kO
  • T1 : Taster (um die OLED Anzeige zu aktivieren)
  • T2 : Transistor BC547C
  • Display : SSD1306 (I²C)
  • DC-Wandler : RECOM R-78E50-05
  • Mikrocontroller: ESP32 mit 30 PINs

Messpunkte:

  • M1 : Tick-Signal
  • M2 : ESP32 Tick Eingangssignal
  • M3 : Tick Signal an der Basis des Transistors
  • M4 : 5V Spannung

Ich versorge die Platine mit 12V DC, der DC Wandler reicht aus, um den ESP32, sowie meine Geiger-Zähler Platine mit Strom (5V) zu versorgen.

Gehäuse

Das Gehäuse habe ich 3D gedruckt. Auf der einen Seite sitzt die Geiger-Zähler Platine, auf der anderen Seite die ESP32 Platine. Wichtig ist: Das 3D Modell besteht aus zwei teilen, die ich zusammengefügt habe. Da ich das Gehäuse aus ABS gedruckt habe, habe ich zum verkleben der beiden Hälften Aceton verwendet. Die Aussparungen für die Kabel sind ausgesägt.

Fotos

Unterseite bzw. Wandseite mit Geiger-Zähler
Fertig montiert mit aktiviertem Display

Home Assistant

Die Einbindung in Home Assistant ist relativ simpel, da hier nur ein JSON abgefragt werden muss.

rest:
  - scan_interval: 60
    resource: http://<Geiger-Zähler-IP>/jsondoaction
    sensor:
     - name: "ESP32 Radiation - Ticks"
       value_template: "{{ value_json.lastminticks | int }}"
       
     - name: "ESP32 Radiation - Mikrosievert per Hour"
       unit_of_measurement: "μSv/h"
       value_template: "{{ value_json.lastminusivert | float | round(4) }}"
       
     - name: "ESP32 Radiation - Millisievert per Year"
       unit_of_measurement: "mSv/y"
       value_template: "{{ value_json.lastminmsivert | float | round(4) }}"
       
     - name: "ESP32 Radiation - Free RAM"
       unit_of_measurement: "bytes"
       value_template: "{{ value_json.freeram | int }}"

Software

#include <ArduinoJson.h>
#include <WebServer.h>
#include <TaskScheduler.h>

#include <NTPClient.h>
#include <WiFiUdp.h>

#include <ESP_Adafruit_SSD1306.h>
#define OLED_RESET 4

// --------- WIFI -----------
#define STASSID    "x"
#define STAPSK     "x"

#define DEVICENAME "ESP32-Radiation";

unsigned long previousMillis = 0;
unsigned long interval = 30000;


// --------- END WIFI -------

// --------- INITS -------

const char* ssid = STASSID;
const char* password = STAPSK;
const char* deviceName = DEVICENAME;
WiFiUDP ntpUDP;
NTPClient timeClient(ntpUDP, "<time-server>", 0, 0);
WebServer server(80);
StaticJsonDocument<1024> jsonDocument;
char jsonBuffer[1024];

Adafruit_SSD1306 display(OLED_RESET);

const String Name1 = "DK1TEO";
const String Name2 = "Radiation";
const String uSvh =  "uSv/h";
const String mSvy =  "mSv/y";

// --------- END INITS -------

// --------- SCHEDULER BEGIN -------

void checkFreeRam();
Task scheduleCheckFreeRam(21*1000, TASK_FOREVER, &checkFreeRam);

void wifiReconnectCheck();
Task scheduleWifiReconnectCheck(8*60*1000, TASK_FOREVER, &wifiReconnectCheck);

void refreshTime();
Task scheduleRefreshTime(10*60*1000, TASK_FOREVER, &refreshTime);

void changeMinute();
Task scheduleChangeMinute(1000, TASK_FOREVER, &changeMinute);

void displayTimeout();
Task scheduleDisplayTimeout(3*1000, TASK_FOREVER, &displayTimeout);

Scheduler runner;

// --------- SCHEDULER END ---------

// ------- DEFINITIONS ----------
static int selfCheckPinDuration = 500;
static String linkColorNormal = "#2321B0";
static String linkColorVisited = "#2321B0";
static String activeMarkerBegin = "<b>&raquo;";
static String activeMarkerEnd = "&laquo;</b>";
// ------- END DEFINITIONS ----------

// ------- PINS ----------
static int morsePin = 2;
static int i2cSdaPin = 21;
static int i2cSclPin = 22;

static int pinInTick = 32;
static int pinInInfo = 19;

// ------- END PINS ----------

// --------- Variables ---------

unsigned long btnInfoPressedMillis = 0;

int freeHeap = 0;

int actualMin = 0;

bool lastMinValid = false;

int actualMinTickValOldSerial = 0;
int actualMinTickVal = 0;

int lastMinTickVal = 0;

// --------- END Variables ---------

// --------- Interrupt Functions -----------

void IRAM_ATTR eventTick()
{
  actualMinTickVal++;
}

void setup() 
{
  initSerial();
  initDisplay();
  initWifi();
  initPinModes();
  initTimeClient();
  initServer();
  initSchedules();
  checkFreeRam();
}

void loop() 
{
  server.handleClient();
  runner.execute();

  tickChangedSerial();
  infoPressed();
}

void initSerial()
{
  Serial.begin(115200); 
  while(!Serial){} // Waiting for serial connection
  Serial.println();
}

void initDisplay()
{
  display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
  display.setTextColor(WHITE);
  display.clearDisplay();
  display.display();
}

void initWifi()
{
  Serial.println("WiFi init");
  Serial.print("Wifi: ");
  Serial.println(ssid);
  //Serial.print("WifiPW: ");
  //Serial.println(password);
  
  Serial.println("turn wifi off...");
  WiFi.mode(WIFI_OFF);
  delay(10);
  //WiFi.forceSleepBegin();
  delay(200);
  //WiFi.forceSleepWake();
  WiFi.mode(WIFI_STA);
  delay(250);
  
  WiFi.config(INADDR_NONE, INADDR_NONE, INADDR_NONE);    
  delay(200); 
  //WiFi.mode(WIFI_STA);
  Serial.println("setting hostname");
  WiFi.setHostname(deviceName);
  delay(200); 
  Serial.println("Connecting to WiFi..");
  WiFi.begin(ssid, password);
  delay(200); 

  int iCounter = 0;
  int iMax = 30;
  while (WiFi.status() != WL_CONNECTED && iCounter < iMax)
  {
    digitalWrite(morsePin, LOW);
    delay(500);
    digitalWrite(morsePin, HIGH);
    delay(500);
    Serial.print(".");
    iCounter++;
  }
  
  Serial.println(WiFi.localIP()); 
}

void initPinModes()
{
  Serial.println("PIN MODES init");

  pinMode(morsePin, OUTPUT);
  digitalWrite(morsePin, LOW);

  pinMode(pinInTick, INPUT);
  attachInterrupt(pinInTick, eventTick, FALLING);

  pinMode(pinInInfo, INPUT);
}

void initSchedules()
{
  Serial.println("SCHEDULES init");
  
  runner.init();

  runner.addTask(scheduleCheckFreeRam);
  scheduleCheckFreeRam.enable();

  runner.addTask(scheduleWifiReconnectCheck);
  scheduleWifiReconnectCheck.enable();

  runner.addTask(scheduleRefreshTime);
  scheduleRefreshTime.enable();

  runner.addTask(scheduleChangeMinute);
  scheduleChangeMinute.enable();

  runner.addTask(scheduleDisplayTimeout);
  scheduleDisplayTimeout.enable();
}

void initTimeClient()
{
  timeClient.begin();
  delay(1000);
  timeClient.update();
}

void initServer()
{
  server.on("/", handleConnect);
  server.on("/jsondoaction", jsonDoAct);
  server.onNotFound(handleConnect);
  server.begin();
  Serial.println("HTTP server started");
}

void wifiReconnectCheck()
{
  unsigned long currentMillis = millis();
  // if WiFi is down, try reconnecting every CHECK_WIFI_TIME seconds
  if ((WiFi.status() != WL_CONNECTED) && (currentMillis - previousMillis >=interval))
  {
    Serial.print(millis());
    Serial.println("Reconnecting to WiFi...");
    WiFi.disconnect();
    WiFi.reconnect();
    previousMillis = currentMillis;
  }
}

void refreshTime()
{
  timeClient.update();
}

void changeMinute()
{
  if (timeClient.getMinutes() != actualMin)
  {
    Serial.println("Changing Minute...");    
    actualMin = timeClient.getMinutes();
    lastMinTickVal = actualMinTickVal;
    actualMinTickVal = 0;
    if (lastMinTickVal > 0)
    {
      lastMinValid = true;
    }
    
  }
}

void tickChangedSerial()
{
  if (actualMinTickValOldSerial != actualMinTickVal)
  {
    actualMinTickValOldSerial = actualMinTickVal;
    Serial.println(actualMinTickVal);
  }
}

void displayTimeout()
{
  if (btnInfoPressedMillis == -1)
  {
    return;
  }

  if (millis() < btnInfoPressedMillis ||
      millis() > (btnInfoPressedMillis + (20*1000)))
  {
    Serial.println("Display Timeout");
    display.clearDisplay();
    display.display();
    btnInfoPressedMillis = -1;
  }
}

void checkFreeRam()
{
  freeHeap = ESP.getFreeHeap();

  if (ESP.getFreeHeap() < 60000)
  {
    ESP.restart();
  }
}

// Helper

float getUSivert(int ticksMinIn)
{
  float retVal = 0;

  retVal = (float)ticksMinIn/(float)151;

  return retVal;
}

float getMSivert(int ticksMinIn)
{
  float retVal = getUSivert(ticksMinIn);

  retVal *= ((float)24 * (float)365.25) / (float)1000;

  return retVal;
}

void infoPressed()
{
  int btnVal = digitalRead(pinInInfo);
  if (btnVal == 1)
  {
    delay(50);
    btnVal = digitalRead(pinInInfo);

    if (btnVal == 1)
    {
      btnInfoPressedMillis = millis();

      String displayString = "";

      display.clearDisplay();

      display.setTextColor(WHITE);
      display.setTextSize(1);
    
      display.setCursor(0, 0);
      display.println(Name1);
    
      display.setCursor(0, 10);
      display.println(Name2);

      displayString = legthCorrector((float)lastMinTickVal) + (String)lastMinTickVal + "    Ticks/min";
      display.setCursor(0, 25);
      display.println(displayString);

      displayString = legthCorrector((float)getUSivert(lastMinTickVal)) + (String)getUSivert(lastMinTickVal) + " " + uSvh;
      display.setCursor(0, 35);
      display.println(displayString);

      displayString = legthCorrector((float)getMSivert(lastMinTickVal)) + (String)getMSivert(lastMinTickVal) + " " + mSvy;
      display.setCursor(0, 45);
      display.println(displayString);

      display.display();
    }

  }
}

String legthCorrector(float valIn)
{
  String retStr = "";

  if (valIn < 100000)
  {
    retStr += " ";
  }
  if (valIn < 10000)
  {
    retStr += " ";
  }
  if (valIn < 1000)
  {
    retStr += " ";
  }
  if (valIn < 100)
  {
    retStr += " ";
  }
  if (valIn < 10)
  {
    retStr += " ";
  }

  return retStr;
}

// HTTP Server

void handleConnect()
{
  Serial.println("Connect");
  server.send(200, "text/html", SendHTML("")); 
}

String SendHTML(String context)
{
  String ptr = "<!DOCTYPE html> <html>\n";
  ptr +="<head><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, user-scalable=no\">\n";
  ptr +="<title>ESP32 Radiation</title>\n";
  ptr +="<style>html { font-family: Arial; display: inline-block; margin: 0px auto; text-align: center;}\n";
  ptr +="body{margin-top: 50px;} h1 {color: #444444;margin: 50px auto 30px;} h3 {color: #444444;margin-bottom: 50px;}\n";
  ptr +=".button {display: block;width: 80px;background-color: #3498db;border: none;color: white;padding: 13px 30px;text-decoration: none;font-size: 25px;margin: 0px auto 35px;cursor: pointer;border-radius: 4px;}\n";
  ptr +="a, a:active, { color: " + linkColorNormal + "; text-decoration: underline; }\n";
  ptr +="a:visited { color: " + linkColorVisited + "; text-decoration: underline; }\n";
  ptr +="p {font-size: 14px;color: #888;margin-bottom: 10px;}\n";
  ptr +="</style>\n";
  ptr +="</head>\n";
  ptr +="<body>\n";
  ptr +="<h1>ESP32 Radiation</h1>\n";

  String lineBreak = "<br><br>";

  ptr += "";

  ptr += "Actual Minute Ticks: ";
  ptr += "<b>"+ (String)actualMinTickVal +"</b><br>";

  ptr += "Actual Minute &mu;Sv/h: ";
  ptr += "<b>"+ (String)getUSivert(actualMinTickVal) +"</b><br>";

  ptr += "Actual Minute mSv/y: ";
  ptr += "<b>"+ (String)getMSivert(actualMinTickVal) +"</b><br>";

  ptr += lineBreak;

  ptr += "Last Minute Ticks: ";
  ptr += "<b>"+ (String)lastMinTickVal +"</b><br>";

  ptr += "Last Minute &mu;Sv/h: ";
  ptr += "<b>"+ (String)getUSivert(lastMinTickVal) +"</b><br>";

  ptr += "Actual Minute mSv/y: ";
  ptr += "<b>"+ (String)getMSivert(lastMinTickVal) +"</b><br>";

  ptr +="</body>\n";
  ptr +="</html>\n";
  return ptr;
}

void jsonDoAct()
{
  Serial.println("JSON Act");

  String status = "OK";

  if (server.hasArg("plain") == false) 
  {
    //handle error here
  }
  
  String body = server.arg("plain");
  Serial.println(body);

  // continue
  status = "OK";

  createStatusJson(status);

  Serial.println(status);

  server.send(200, "application/json", jsonBuffer);
}

// -------------------- JSON HELPER --------------------------

void createStatusJson(String statusIn) 
{
  if (statusIn == "")
  {
    statusIn = "OK";
  }
  
  jsonDocument.clear();
  jsonDocument["state"] = statusIn;

  jsonDocument["actualticks"] = actualMinTickVal;
  jsonDocument["actialusivert"] = getUSivert(actualMinTickVal);
  jsonDocument["actialmsivert"] = getMSivert(actualMinTickVal);

  jsonDocument["lastminticks"] = lastMinTickVal;
  jsonDocument["lastminusivert"] = getUSivert(lastMinTickVal);
  jsonDocument["lastminmsivert"] = getMSivert(lastMinTickVal);

  jsonDocument["freeram"] = freeHeap;

  serializeJson(jsonDocument, jsonBuffer);
}

Hinweis

Dieser Artikel dokumentiert lediglich meinen Aufbau. Für den Nachbau, die Nutzung einzelner Komponenten, die Platinen und den gesamten Inhalt wird die Haftung in jeglicher Form ausgeschlossen.

Categories
ESP32 Home Assistant

ESP32 LED-Steuerung

Dezente Beleuchtung ist eine schöne Sache. Vor allem, wenn sie sich einfach anpassen und steuern lässt.

Nachdem ich einige kommerzielle Lösungen angeschaut habe, habe ich mich dazu entschieden eine eigene auf Basis eines ESP32 zu entwickeln, die ich in den Home Assistant integrieren kann. Auch weil ich die Steuerung von Farbe, An- sowie Ausschaltzeitpunkt automatisieren wollte.

Funktionsweise

Zunächst muss man sich mit der Steuerung von LED-Bändern beschäftigen, um eine Steuerung entwickeln zu können. LED Bänder bestehen in der Regel aus vielen RGB (Rot-Grün-Blau) LEDs. Je nach Helligkeit der einzelnen Farben, ändert sich die wahrgenommene Farbe. Sind beispielsweise nur Rot und Grün zu je 100% an, ergibt das die Farbe gelb. Kommt blau zu 100% dazu, ergibt sich weiß. Je nach dem zu wie viel Prozent jede Farbe an ist, entsteht eine Mischfarbe. Wichtig ist das Verhältnis der Werte zueinander. Um die LEDs zu dimmen, werden die Verhältnisse der Farben zu einander gleich gehalten, jedoch alle LEDs insgesamt in der Helligkeit reduziert.

Im Gegensatz zu klassischen Glühbirnen, werden LEDs nicht durch das reduzieren der Spannung verdunkelt, sondern durch das “Takten” und die damit verbundene Helligkeitsreduzierung. Nehmen wir eine Sekunde; In dieser Sekunde ist die eine Farbe der LED die gesamte Zeit an, eine andere nur zu 50%. Als Betrachter nimmt man die andere Farbe somit um 50% weniger Hell wahr. Man schaltet jedoch nicht die andere Farbe eine halbe Sekunde ab und diese dann wieder eine halbe Sekunde an, sondern man wählt eine weitaus schnellere, für das Auge nicht wahrnehmbare Frequenz, in der die zweite Farbe an- und ausgeschaltet wird. Die Folge wäre sonst ein unangenehmer und ständiger Farbwechsel. Im Ergebnis ist; Die andere LED zwar in Summe nur 50% der Zeit an, aber der Wechsel zwischen AN und AUS ist so schnell, dass dieser vom menschlichen Auge nicht wahrgenommen wird. Was das menschliche Auge wahrnimmt ist eine geringere Helligkeit der anderen Farbe und damit bildet sich eine entsprechende Mischfarbe.

Ich habe 15m LED Band im Einsatz. Ein Test hat gezeigt, dass wie zu erwarten das in Reihe schalten der drei Bänder dazu führt, dass das letzte LED Band fast nicht mehr leuchtet. Die Bänder müssen also parallel an die Platine angeschlossen werden. Im Parallelbetrieb gemessen, habe ich bei der Farbe weiß, die den höchsten Stromfluss aufweist, zirka 4.5-5 Ampere bei einer Betriebsspannung von 12V gemessen. Das entspricht einer Leistung von zirka 60W. Das heißt der Pluspol über den der Maximalstrom von 5 Ampere fließt und die zugehörigen Komponenten müssen entsprechend dimensioniert werden. Wichtig an dieser Stelle ist: Man kann keine pauschale Aussage über die Stromaufnahme eines 15m LED Bandes treffen, das hängt von vielen Faktoren ab, beispielsweise wie viele LEDs pro Meter installiert sind.

Zudem habe ich festgestellt, dass die LEDs immer unter Spannung stehen. Das heißt es wird nur der Stromabfluss gesteuert, nicht der Zufluss. Das empfinde ich als großes Manko dieses Bandes, da ich eigentlich nicht möchte, dass dauerhaft 12V am Band anliegen.

Hardware

Zum Einsatz kommen ein ESP32, sowie eine selbst entwickelte Platine.

Das LED-Band besteht aus RGB 5050 LEDs.

Ein ESP32 hat den Vorteil, dass er das sogenannte PWM auf vielen PINs unterstützt. Es können zwar nur zehn verschiedene PWM Kanäle genutzt werden, was aber für ein RGB Band völlig ausreichend ist.

PWM bzw. ausgeschrieben Pulse-width modulation ermöglicht es, PINs des ESP32 in einer bestimmten Frequenz unter Bestimmung der Pulsbreite, also wie breit ist der Teil, in dem der PIN an und wie breit ist der Teil, in dem der PIN aus ist, zu setzen.

Das LED Band wird mit einer Spannung von 12V betrieben und benötigt hohe Ströme. Beides kann der ESP32 nicht leisten. Aus diesem Grund habe ich mich dafür entschieden für das Schalten des LED Bandes MOSFETs zu nutzen. MOSFETs haben den großen Vorteil, dass sie im Gegensatz zu Transistoren einen sehr geringen Innenwiderstand bzw. Spannungsabfall aufweisen. Aus diesem Grund fällt sehr wenig Leistung ab und die Wärmeentwicklung hält sich in Grenzen. Wichtig bei der Verwendung von MOSFETs ist, dass sie immer sauber durchgeschaltet oder gesperrt werden, sonst erhöht sich hier Innenwiderstand signifikant, was zwangsläufig zur Zerstörung des MOSFET führt. Je nach Typ sollte an einem MOSFET am Gate entweder 0V oder die Betriebsspannung (der Source) anliegen. Ein Blick auf das Datenblatt des ESP32 enthüllt; Der Mikrocontroller kann maximal 3.3V und Ströme im Milliampere Bereich leisten. Für ein sauberes Sperren bzw. Durchschalten des MOSFET nicht ausreichend. Aus diesem Grund verwende ich ich einen BC547C Transistor als Schalter, um die MOSFETs an zu steuern.

Um den “Abfluss” der Ströme der einzelnen Farben zu steuern verwende ich N-MOSFETs. Um den “Zufluss” bei deaktiviertem Band respektive einer Helligkeit von null ab zu schalten, verwende ich einen P-MOSFET. Warum man nicht eine Art MOSFET für beide Arten der Steuerung verwenden kann, lässt sich in entsprechender Fachliteratur oder im Internet nachlesen.

Da mein verwendeter P-MOSFETs einen signifikant höheren Innenwiderstand (~0,06 Ohm) aufweist, als die verwendeten N-MOSFETS (~0,006 Ohm) und sich daher eine entsprechende Wärmeentwicklung einstellt, habe ich den P-MOSFET mit einem Kühlköper, sowie das Gehäuse mit einem Lüfter ausgestattet. Mit ein Grund für den Lüfter ist, dass es im Inneren des Gartenhauses gerade im Sommer etwas wärmer werden kann und ich vermeiden wollte, dass sich im Gehäuse hohe Temperaturen bei einer sowieso schon hohen Außentemperatur bilden.

Platine

Hier die Platine dazu (Afillate): https://aisler.net/p/ZSWOKSID

Bauteile:

  • Mikrocontroller: ESP32 mit 30 PINs
  • C1, C2, C3 : 10nF
  • LED1, LED2, LED3, LED4 : Standard LED
    (dienen zur optischen Kontrolle)
  • R1, R3, R5, R7 : 1kO
  • R2, R4, R6 : 2.2kO
  • R8 : 2.2kO
  • R9, R10, R11, R12 : 3.3kO
  • R13, R14, R15, R16 : 5.6kO
  • L1 : 10uH
  • T1, T2, T3, T4 : Transistor BC547C
  • 3x MOSFET IRL1004
  • 1x MOSFET IRF5210
  • 1x DC Wandler RECOM R-78E50-05
  • 1x TO-220 Kühlkörper V FI356
  • 2x 7.62mm PCB Screw Terminal Block (3-Pin)

Messpunkte:

  • M1 : Blau
  • M2 : Rot
  • M3 : Grün
  • M4 : Eingangsspannung
  • M5 : On/Off Spannung
  • M6 : 5V DC
  • M7 : Gate Blau
  • M8 : Gate Rot
  • M9 : Gate Grün
  • M10 : Gate On/Off
  • M11 : GND
  • M12 : On/Off Spannung (wie M4)
Verlötete Platine (oben)
Verlötete Platine (seite)

Die Anschlüsse unten auf der Platine 3V3, GND, sowie SCL und SDA (I²C) dienen dazu, damit sich spätere Erweiterungen, wie beispielsweise ein Temperatursensor einfach nachrüsten lassen, falls man das möchte.

Gehäuse

Das Gehäuse ist 3D gedruckt und mit einem 40mm 12V Lüfter versehen. Der Lüfter wird an GND und Vout angeschlossen, damit dieser nur dann läuft, wenn das LED Band angeschaltet ist. Zudem habe ich vor Vin eine 10A Schmelzsicherung installiert, falls ein Kurzschluss z.B. in einem LED-Band auftritt und die Schutzschaltung im Netzteil nicht greifen sollte.

Die seitlichen Löcher im Gehäuse habe ich gebohrt. Der Lüfter bringt Luft ins Gehäuse, die über die Löcher austritt.

Gehäuse offen
Installierte LED-Steuerung, links zu sehen die Verteilung auf die LED-Bänder

Fotos

Home Assistant

Die Schwierigkeit mit Home Assistant liegt darin, dass die LED Farbe von RGB nach Hue und Saturation, sowie umgekehrt umgerechnet werden muss.

rest:
  - scan_interval: 60
    resource: http://<ip>/jsondoaction
    sensor:
     - name: "ESP32 Gartenhaus LED - Hue-Color"
       value_template: "{{ value_json.hue | int }}"
       
     - name: "ESP32 Gartenhaus LED - Saturation-Color"
       value_template: "{{ value_json.saturation | int }}"
       
     - name: "ESP32 Gartenhaus LED - Brightness"
       value_template: "{{ value_json.brightness | int }}"
rest_command:
  gartenhaus_led_set_color:
    url: "http://<ip>/jsondoaction"
    method: POST
    headers:
      accept: "application/json, text/html"
    payload: '{ "hue": {{ h }}, "saturation": {{ s }} }'
    content_type:  'application/json; charset=utf-8'
    
  gartenhaus_led_on:
    url: "http://<ip>/jsondoaction"
    method: POST
    headers:
      accept: "application/json, text/html"
    payload: '{ "ison": "1"}'
    content_type:  'application/json; charset=utf-8'
    
  gartenhaus_led_off:
    url: "http://<ip>/jsondoaction"
    method: POST
    headers:
      accept: "application/json, text/html"
    payload: '{ "ison": "0"}'
    content_type:  'application/json; charset=utf-8'
    
  gartenhaus_led_set_level:
    url: "http://<ip>/jsondoaction"
    method: POST
    headers:
      accept: "application/json, text/html"
    payload: '{ "brightness": {{ brightness }} }'
    content_type:  'application/json; charset=utf-8'
switch:
  - platform: rest
    name: "ESP32 Gartenhaus LED - Enabled"
    resource: http://<ip>/jsondoaction
    method: post
    body_on: "{'ison': '1' }"
    body_off: "{'ison': '0' }"
    is_on_template: "{{ value_json.ison }}"
    headers:
      Content-Type: application/json

Das Farbpicker Control wird hiermit erstellt und steht dann im Oberflächendesigner zur Verfügung:

light:
  - platform: template
    lights:
      gartenhaus_led_light:
        friendly_name: "Gartenhaus Colorpicker"
        color_template: "({{states('sensor.esp32_gartenhaus_led_hue_color') | int}}, {{states('sensor.esp32_gartenhaus_led_saturation_color') | int}})"
        level_template: "{{ states('sensor.esp32_gartenhaus_led_brightness') | int }}"
        set_color:
        - service: rest_command.gartenhaus_led_set_color
          data:
            h: "{{ h }}"
            s: "{{ s }}"
        set_level:
        - service: rest_command.gartenhaus_led_set_level
          data:
            brightness: "{{ brightness }}"
        turn_on:
        - service: rest_command.gartenhaus_led_on
        turn_off:
        - service: rest_command.gartenhaus_led_off
Home Assistant Steuerelement für Farbe und Helligkeit

Software

Der ESP32 meldet proaktiv Änderungen an den Home Assistant. Dafür ist ein sogenanntes Token oder Bearer genannt notwendig, das im Home Assistant generiert werden kann.

#include <ArduinoJson.h>
#include <HTTPClient.h>
#include <WebServer.h>

#include <WiFiClientSecure.h>

#include <TaskScheduler.h>

#include <NTPClient.h>
#include <WiFiUdp.h>

#include <ESP_Color.h>


const String LedEnabledSwitchName = "switch.esp32_gartenhaus_led_enabled";
const String LedHueIntName = "sensor.esp32_gartenhaus_led_hue_color";
const String LedSaturationIntName = "sensor.esp32_gartenhaus_led_saturation_color";
const String LedBrightnessIntName = "sensor.esp32_gartenhaus_led_brightness";

// --------- WIFI -----------

[...]

unsigned long previousMillis = 0;
unsigned long interval = 30000;

// --------- END WIFI -------

// --------- INITS -------

const char* ssid = STASSID;
const char* password = STAPSK;
const char* deviceName = DEVICENAME;
WiFiUDP ntpUDP;
NTPClient timeClient(ntpUDP, "de.pool.ntp.org", 0, 0);
WebServer server(80);
StaticJsonDocument<1024> jsonDocument;
char jsonBuffer[1024];

const String HomeAssistantBearerName = "Authorization";
const String HomeAssistantBearerContent = "Bearer xxx";

const String SendApiIotUrl = "http://<homeAssistantIp>:8123/api/states/";



// --------- END INITS -------

// --------- SCHEDULER BEGIN -------

void checkFreeRam();
Task scheduleCheckFreeRam(21*1000, TASK_FOREVER, &checkFreeRam);

void wifiReconnectCheck();
Task scheduleWifiReconnectCheck(8*60*1000, TASK_FOREVER, &wifiReconnectCheck);

void refreshTime();
Task scheduleRefreshTime(10*60*1000, TASK_FOREVER, &refreshTime);

Scheduler runner;

// --------- SCHEDULER END ---------

// ------- DEFINITIONS ----------
static int selfCheckPinDuration = 500;
static String linkColorNormal = "#2321B0";
static String linkColorVisited = "#2321B0";
static String activeMarkerBegin = "<b>&raquo;";
static String activeMarkerEnd = "&laquo;</b>";
// ------- END DEFINITIONS ----------

// ------- PINS ----------
static int morsePin = 2;
static int i2cSdaPin = 21;
static int i2cSclPin = 22;

static int pinOutBlue = 32;
static int pinOutRed = 33;
static int pinOutGreen = 27;
static int pinOutAll = 18;

const int freq = 5000;

const int blueChannel = 2;
const int redChannel = 0;
const int greenChannel = 1;

const int resolution = 8; // 256
// ------- END PINS ----------

// --------- Variables ---------

int freeHeap = 0;

bool isOn = false;
int blueVal = 255;
int redVal = 255;
int greenVal = 255;

int hueVal = 0;
int saturationVal = 0;
int brightnessVal = 100;

// --------- END Variables ---------

void setup() 
{
  // put your setup code here, to run once:
  initSerial();
  initWifi();
  initSchedules();
  initPinModes();
  initRgbPwm();
  buildFromRgb();
  updateLed();
  initTimeClient();
  initServer();
  checkFreeRam();  
}

void loop() 
{
  server.handleClient();
  runner.execute();
}

void initSerial()
{
  Serial.begin(115200); 
  while(!Serial){} // Waiting for serial connection
  Serial.println();
}


void initWifi()
{
  Serial.println("WiFi init");
  Serial.print("Wifi: ");
  Serial.println(ssid);
  //Serial.print("WifiPW: ");
  //Serial.println(password);
  
  Serial.println("turn wifi off...");
  WiFi.mode(WIFI_OFF);
  delay(10);
  //WiFi.forceSleepBegin();
  delay(200);
  //WiFi.forceSleepWake();
  WiFi.mode(WIFI_STA);
  delay(250);
  
  WiFi.config(INADDR_NONE, INADDR_NONE, INADDR_NONE);    
  delay(200); 
  //WiFi.mode(WIFI_STA);
  Serial.println("setting hostname");
  WiFi.setHostname(deviceName);
  delay(200); 
  Serial.println("Connecting to WiFi..");
  WiFi.begin(ssid, password);
  delay(200); 

  int iCounter = 0;
  int iMax = 30;
  while (WiFi.status() != WL_CONNECTED && iCounter < iMax)
  {
    digitalWrite(morsePin, LOW);
    delay(500);
    digitalWrite(morsePin, HIGH);
    delay(500);
    Serial.print(".");
    iCounter++;
  }
  
  Serial.println(WiFi.localIP()); 
}

void initPinModes()
{
  Serial.println("PIN MODES init");

  pinMode(morsePin, OUTPUT);
  digitalWrite(morsePin, LOW);

  pinMode(pinOutAll, OUTPUT);
  digitalWrite(pinOutAll, LOW);
}

void initSchedules()
{
  Serial.println("SCHEDULES init");
  
  runner.init();

  runner.addTask(scheduleCheckFreeRam);
  scheduleCheckFreeRam.enable();

  runner.addTask(scheduleWifiReconnectCheck);
  scheduleWifiReconnectCheck.enable();

  runner.addTask(scheduleRefreshTime);
  scheduleRefreshTime.enable();
}

void initTimeClient()
{
  timeClient.begin();
  delay(1000);
  timeClient.update();
}

void initServer()
{
  server.on("/", handleConnect);
  server.on("/jsondoaction", jsonDoAct);
  server.on("/jsondoaction", HTTP_POST, jsonDoAct);  
  server.onNotFound(handleConnect);
  server.begin();
  Serial.println("HTTP server started");
}

void wifiReconnectCheck()
{
  unsigned long currentMillis = millis();
  // if WiFi is down, try reconnecting every CHECK_WIFI_TIME seconds
  if ((WiFi.status() != WL_CONNECTED) && (currentMillis - previousMillis >=interval))
  {
    Serial.print(millis());
    Serial.println("Reconnecting to WiFi...");
    WiFi.disconnect();
    WiFi.reconnect();
    previousMillis = currentMillis;
  }
}

void refreshTime()
{
  timeClient.update();
}

void checkFreeRam()
{
  freeHeap = ESP.getFreeHeap();

  if (ESP.getFreeHeap() < 60000)
  {
    ESP.restart();
  }
}

// RGB functions

void initRgbPwm()
{
  ledcSetup(blueChannel, freq, resolution);
  ledcSetup(redChannel, freq, resolution);
  ledcSetup(greenChannel, freq, resolution);
  
  // attach the channel to the GPIO to be controlled
  ledcAttachPin(pinOutBlue, blueChannel);
  ledcAttachPin(pinOutRed, redChannel);
  ledcAttachPin(pinOutGreen, greenChannel);
}

void enableFetAll(bool bIn)
{
  if (bIn == true)
  {
    digitalWrite(pinOutAll, HIGH);
  }
  else
  {
    digitalWrite(pinOutAll, LOW);
  }

  isOn = bIn;
  sendStatusChangeSwitch(LedEnabledSwitchName, bIn);

}

void setLedValue(int iChannel, int iVal)
{

  int valToSet = (int) ( ( (float)brightnessVal / (float)255 ) * (float)iVal );

  if (valToSet >= 256)
  {
    valToSet = 255;
  }
  else if (valToSet < 0)
  {
    valToSet = 0;
  }

  ledcWrite(iChannel, 255-valToSet);
}

String convertToHexColor()
{
  String retStr = "";

  byte R = redVal;
  byte G = greenVal;
  byte B = blueVal;

  char hex[7] = {0};
  sprintf(hex,"%02X%02X%02X",R,G,B);

  retStr += String(hex);

  return retStr;
}

void buildFromRgb()
{
  float tmpRed = (float)redVal/(float)255;
  float tmpGreen = (float)greenVal/(float)255;
  float tmpBlue = (float)blueVal/(float)255;

  ESP_Color::Color colTmp = ESP_Color::Color(tmpRed, tmpGreen, tmpBlue);
  auto hsv = colTmp.ToHsv(); 

  float tmpHue = (float)360 * (float)hsv.H;
  float tmpSaturation = (float)100 * (float)hsv.S;

  hueVal = (int)tmpHue;
  saturationVal = (int)tmpSaturation;
}

void buildFromHsv()
{
  ESP_Color::Color colTmp = ESP_Color::Color::FromHsv((float)hueVal/(float)360, (float)saturationVal/(float)100, 1.00f);

  float tmpRed = ((float)255) * (float)colTmp.R;
  float tmpGreen = ((float)255) * (float)colTmp.G;
  float tmpBlue = ((float)255) * (float)colTmp.B;

  redVal = (int) tmpRed;
  greenVal = (int) tmpGreen;
  blueVal = (int) tmpBlue;
}

void updateLed()
{
  setLedValue(blueChannel, blueVal);
  setLedValue(greenChannel, greenVal);
  setLedValue(redChannel, redVal);
}

// HTTP Server

void handleConnect()
{
  Serial.println("Connect");
  server.send(200, "text/html", SendHTML("")); 
}

String SendHTML(String context)
{
  String ptr = "<!DOCTYPE html> <html>\n";
  ptr +="<head><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, user-scalable=no\">\n";
  ptr +="<title>ESP32 Gartenhaus LED</title>\n";
  ptr +="<style>html { font-family: Arial; display: inline-block; margin: 0px auto; text-align: center;}\n";
  ptr +="body{margin-top: 50px;} h1 {color: #444444;margin: 50px auto 30px;} h3 {color: #444444;margin-bottom: 50px;}\n";
  ptr +=".button {display: block;width: 80px;background-color: #3498db;border: none;color: white;padding: 13px 30px;text-decoration: none;font-size: 25px;margin: 0px auto 35px;cursor: pointer;border-radius: 4px;}\n";
  ptr +="a, a:active, { color: " + linkColorNormal + "; text-decoration: underline; }\n";
  ptr +="a:visited { color: " + linkColorVisited + "; text-decoration: underline; }\n";
  ptr +="p {font-size: 14px;color: #888;margin-bottom: 10px;}\n";
  ptr +="</style>\n";
  ptr +="</head>\n";
  ptr +="<body>\n";
  ptr +="<h1>ESP32 Gartenhaus LED</h1>\n";

  // TODO Webinterface

  String lineBreak = "<br><br>";

  ptr += "";

  ptr +="</body>\n";
  ptr +="</html>\n";
  return ptr;
}

void jsonDoAct()
{
  Serial.println("JSON Act");

  String status = "OK";
  int tmpInput = 0;
  bool tmpOnOff = false;
  bool tmpBuildFromRgb = false;
  bool tmpBuildFromHsv = false;
  bool tmpRefreshBrightness = false;

  if (server.hasArg("plain") == false) 
  {
    //handle error here
  }
  
  String body = server.arg("plain");
  Serial.println(body);
  deserializeJson(jsonDocument, body);

  // continue
  status = "OK";

  // ON OFF
  if (jsonDocument.containsKey("ison") == true)
  {
    tmpInput = jsonDocument["ison"];
    if (tmpInput == 1)
    {
      brightnessVal = 255;
      enableFetAll(true);
    }
    else
    {
      brightnessVal = 0;
      enableFetAll(false);
    }

    tmpOnOff = true;
  }

  // RGB
  if (jsonDocument.containsKey("blue") == true)
  {
    tmpInput = jsonDocument["blue"];
    if (tmpInput >= 0 && tmpInput <= 255)
    {
      blueVal = tmpInput;
      tmpBuildFromRgb = true;
    }
  }
  if (jsonDocument.containsKey("green") == true)
  {
    tmpInput = jsonDocument["green"];
    if (tmpInput >= 0 && tmpInput <= 255)
    {
      greenVal = tmpInput;
      tmpBuildFromRgb = true;
    }
  }
  if (jsonDocument.containsKey("red") == true)
  {
    tmpInput = jsonDocument["red"];
    if (tmpInput >= 0 && tmpInput <= 255)
    {
      redVal = tmpInput;
      tmpBuildFromRgb = true;
    }
  }

  // HSV
  if (jsonDocument.containsKey("hue") == true)
  {
    tmpInput = jsonDocument["hue"];
    if (tmpInput >= 0 && tmpInput <= 360)
    {
      hueVal = tmpInput;
      tmpBuildFromHsv = true;
    }
  }
  if (jsonDocument.containsKey("saturation") == true)
  {
    tmpInput = jsonDocument["saturation"];
    if (tmpInput >= 0 && tmpInput <= 100)
    {
      saturationVal = tmpInput;
      tmpBuildFromHsv = true;
    }
  }

  if (jsonDocument.containsKey("brightness") == true)
  {
    tmpInput = jsonDocument["brightness"];
    if (tmpInput >= 0 && tmpInput <= 255)
    {
      brightnessVal = tmpInput;
      if (brightnessVal == 0)
      {
        enableFetAll(false);
      }
      else
      {
        enableFetAll(true);
      }
      
      tmpRefreshBrightness = true;
      tmpOnOff = true;
    }
  }

  // build
  if (tmpBuildFromRgb == true || tmpBuildFromHsv == true || tmpRefreshBrightness == true || tmpOnOff == true)
  {
    if (tmpBuildFromRgb == true)
    {
      buildFromRgb();
    }
    
    if (tmpBuildFromHsv == true)
    {
      buildFromHsv();
    }

    updateLed();

    updateColorData();
  }

  createStatusJson(status);

  Serial.println(status);

  server.send(200, "application/json", jsonBuffer);
}

// -------------------- JSON HELPER --------------------------

void createStatusJson(String statusIn) 
{
  if (statusIn == "")
  {
    statusIn = "OK";
  }
  
  jsonDocument.clear();  
  jsonDocument["state"] = statusIn;

  jsonDocument["ison"] = isOn;

  jsonDocument["blue"] = blueVal;
  jsonDocument["green"] = greenVal;
  jsonDocument["red"] = redVal;

  jsonDocument["hexcolor"] = convertToHexColor();

  jsonDocument["hue"] = hueVal;
  jsonDocument["saturation"] = saturationVal;
  jsonDocument["brightness"] = brightnessVal;

  jsonDocument["freeram"] = freeHeap;

  serializeJson(jsonDocument, jsonBuffer);
}

// -------------------- HTTP HELPER --------------------------

String boolToSwitch(bool inVal)
{
  if (inVal == true)
  {
    return "on";
  }
  return "off";
}

void sendStatusChangeInt(String attributeStringIn, int valueIn)
{
  Serial.println(sendInputStatusHttp(attributeStringIn, String(valueIn), false));
}

void sendStatusChangeSwitch(String attributeStringIn, bool valueIn)
{
  Serial.println(sendInputStatusHttp(attributeStringIn, boolToSwitch(valueIn), true));
}

void updateColorData()
{
  sendStatusChangeInt(LedHueIntName, hueVal);
  sendStatusChangeInt(LedSaturationIntName, saturationVal);
  sendStatusChangeInt(LedBrightnessIntName, brightnessVal);
}

String sendInputStatusHttp(String attributeStringIn, String valueIn, bool isString)
{
  String retStr;

  HTTPClient *http = new HTTPClient();
  http->setReuse(false);

  Serial.print("[HTTP] begin...\n");
  Serial.println("[HTTP] URL: " + SendApiIotUrl + attributeStringIn);
  
  if (http->begin(SendApiIotUrl + attributeStringIn))
  {
    Serial.print("[HTTP] POST...\n");
    http->setTimeout(5000);
    // start connection and send HTTP header
    http->addHeader("Content-Type", "application/json; charset=UTF-8");
    http->addHeader(HomeAssistantBearerName, HomeAssistantBearerContent); // auth

    // status Json
    String sendContent = "";
    if (isString == true)
    {
      sendContent = "{\"state\": \""+valueIn+"\" }";
    }
    else
    {
      sendContent = "{\"state\": "+valueIn+" }";
    }

    Serial.println("Content: " + sendContent);
    
    int httpCode = http->POST(sendContent);

    // httpCode will be negative on error
    if (httpCode > 0)
    {
      // HTTP header has been send and Server response header has been handled
      Serial.printf("[HTTP] POST... code: %d\n", httpCode);

      // file found at server
      if (httpCode == HTTP_CODE_OK || httpCode == HTTP_CODE_MOVED_PERMANENTLY)
      {
        //String payload = http->getString();
        //Serial.println(payload);
        retStr = http->getString();
      }

      //String payload = http->getString();
      //Serial.println(payload);
    }
    else
    {
      Serial.printf("[HTTP] GET... failed, error: %s\n", http->errorToString(httpCode).c_str());
      retStr = "error: ";
      retStr += http->errorToString(httpCode).c_str();
    }

    http->end();
  }
  else
  {
    Serial.printf("[HTTP] Unable to connect\n");
    retStr = "ConErr";
  }

  delete http;
  http = NULL;

  //delay(200);

  return retStr;
}

Hinweis

Dieser Artikel dokumentiert lediglich meinen Aufbau. Für den Nachbau, die Nutzung einzelner Komponenten, die Platinen und den gesamten Inhalt wird die Haftung in jeglicher Form ausgeschlossen.

Categories
ESP32 Home Assistant

Klassische Türklingel smart gemacht

Einige haben sie noch, viele kennen sie noch, manche haben sie schon ersetzt. Die Rede ist von der klassischen Türklingel mit Wechselstrom, die entweder schrillt oder metallern “ding-dong” von sich gibt, wenn an der Tür jemand den Taster mit der Glocke betätigt.

Das Problem ist, wenn man sich gerade im Keller oder draußen befindet, hört man die Klingel nicht immer.

Klassische Klingel, außen Ansicht
Klassische Klingel, innen Ansicht

Hier wird durch das “Klingeln” der Bolzen in der Mitte nach links geschlagen, das ist das “ding”, lässt man den Taster los, schlängt der Bolzen wieder nach rechts, das ist das “dong”. An den beiden Kontakten liegt beim Klingeln Wechselspannung an, meist ~12V.

Ich habe mir überlegt, dass man den zum Teil kurzen Wechselspannungsimpuls durch einen ESP32 detektieren müsste, um ihn dann dem Home Assistant für eine Benachrichtigung zu nutzen.

Da es sich um Wechselspannung handelt, könnte es durchaus sein, dass der ESP32 aufgrund des schnellen Wechsels nichts detektiert oder mehrfach detektiert. Ein ESP32 bietet die Funktion auf den Wechsel eines Eingangs von Hi nach Lo, sowie umgekehrt zu reagieren, unabhängig ob der Eingang gerade im Code abgefragt wird, dazu später.

Schaltplan

Aber eins nach dem anderen. Zuerst werden die ~12V mit Dioden gleichgerichtet. Richtet man Wechselspannung gleich, haben wir viele “kleine Berge”. Für das oben genannte “Dektieren” wäre das nicht von Vorteil, entweder ist der ESP32 zu langsam, oder zu schnell und es wird bei langem klingeln, mehrfach klingeln gemeldet. Was wir zum Gleichrichter also noch benötigen ist etwas, was die Spannung nach dem Gleichrichter stabilisiert, dazu verwende ich einen 22µF Kondensator. Um zu verhindern, dass sich hier ungewollt eine Spannung aufbaut, sowie der Kondensator nach dem Klingeln wieder zügig entlädt, habe ich zwischen dem Ausgang des Gleichrichters und Masse einen Widerstand mit 1kO verbaut.

Nachdem das Signal gleichgerichtet ist, müssen wir dafür sorgen, dass der Eingang des ESP32 maximal 3,3V erhält und keine Spannung darüber, dazu verwende ich einen BC547C Transistor als Schalter. Der Kollektor wird hierbei über einen Widerstand auf Hi gezogen, während am Kollektor der Eingang des ESP32 angeschlossen ist. Wird jetzt die Klingel aktiviert, liegt an der Basis des Transistors eine Spannung an, der Transistor wird leitend und der Eingang des ESP32 bzw. der Kollektor wird gegen Masse “gezogen”.

Schaltplan – ~1 sowie ~2 sind die Anschlüsse für den Klingeldraht – Schaltplan erstellt mit sPlan

Platine

Hier die Platine dazu (Afillate):  https://aisler.net/p/RGOCICIL

Bauteile:

  • C1 : 22 µF
  • C2, C3, C4 : 10nF
  • D1, D2, D3, D4 : Diode Typ 1N 4148
  • IC1 : RECOM R-78E50-05
  • L1 : 10 µH
  • LED1 : Standard LED
  • R1 : 2.2kO
  • R2, R3 : 5.6kO
  • R4 : 22kO
  • R5 : 1kO
  • T1 : BC547C
  • Test : Dip-Taster

Hardware

Nach einigen Test mit dem Oszilloskop auf dem Breadboard und zusammenlöten der Platine, habe ich ein Gehäuse für den Aufbau erstellt.

Gehäuse für die Platine – Löcher für Kabel müssen gebohrt werden
Deckel für das Gehäuse mit Lüftungsschlitzen

Die erste Montage sieht wie folgt aus:

Montiert, ohne Deckel

Nach positivem Verlauf der Tests, habe ich die mittlerweile vergilbte Klingelabdeckung angeschliffen und mit Sprühlack lackiert, inklusive Klarlack.

Frisch lackiertes Klingelgehäuse
Fertig montierte Klingel mit Klingel Detector

Home Assistant

Wichtig ist, dass die Klingel proaktiv dem Home Assistant meldet, dass geklingelt wurde. Dafür muss im Home Assistant ein Template Sensor angelegt werden.

binary_sensor:
  - platform: template
    sensors:
      door_bell:
        friendly_name: "Türklingel"
        value_template: "{{ state_attr('binary_sensor.eg_front_door_bell', 'ring') }}"

Auf die Statusänderung des Binary Sensor lässt sich dann ein Automatismus registrieren, der z.B. per Benachrichtigung an mobile Geräte das Klingeln meldet.

Software

Sobald die Türklingel gedrückt wurde und der GPIO Port des ESP32 auf Low geschaltet wird, soll dies an Home Assistant zurückgemeldet werden. Somit muss ein Handler auf das “to-low” bzw FALLING des GPIO Ports registriert werden, ein sogenannter Interrupt. Im Anschluss muss dann der Klingelstatus an den Home Assistant übermittelt werden, genauso wie einige Zeit später das Klingel Signal durch den ESP32 wieder resettet werden muss. Der Bearer ist ein Langzeittoken, dass als Admin erstellt werden muss.

#include <ArduinoJson.h>
#include <HTTPClient.h>

#include <WiFiClientSecure.h>

#include <TaskScheduler.h>

#include <NTPClient.h>
#include <WiFiUdp.h>

// --------- WIFI -----------

[...]

unsigned long previousMillis = 0;
unsigned long interval = 30000;

// --------- END WIFI -------

// --------- INITS -------

const char* ssid = STASSID;
const char* password = STAPSK;
const char* deviceName = DEVICENAME;
WiFiUDP ntpUDP;
NTPClient timeClient(ntpUDP, "de.pool.ntp.org", 0, 6 * 3600 * 1000);
StaticJsonDocument<2048> jsonDocument;

const String HomeAssistantBearerName = "Authorization";
const String HomeAssistantBearerContent = "Bearer XXX";

const String SendApiIotUrl = "http://homeAssistantIp:8123/api/states/binary_sensor.door_bell";

// --------- END INITS -------

// --------- SCHEDULER BEGIN -------

void resetRing();
Task scheduleResetRing(120*1000, TASK_FOREVER, &resetRing);

void checkFreeRam();
Task scheduleCheckFreeRam(60*1000, TASK_FOREVER, &checkFreeRam);

Scheduler runner;

// --------- SCHEDULER END ---------

// --------- Pins -----------

static int morsePin = 2;
const int detectLed = 4; // D4
const int testButton = 18; // D18
const int dorbellPin = 34; // D34

// --------- END Pins ----------

// --------- Variables ---------

bool ringActive = false;
bool sentActive = false;
bool sentInactive = false;
unsigned long ringMillis = -1;

// --------- END Variables ---------

// --------- Interrupt Functions -----------

void IRAM_ATTR eventDorbell()
{
  detachInterrupt(dorbellPin);

  ring();
}

// --------- END Interrupt Functions -----------


void setup() 
{
  initSerial();

  delay(2000);

  initWifi();
  
  initPinModes();

  initSchedules();

  initTimeClient();
}

void initSerial()
{
  Serial.begin(115200); 
  while(!Serial){} // Waiting for serial connection
  Serial.println();
}

void initWifi()
{ 
  [...]
}

void initPinModes()
{
  Serial.println("PIN inits");
  
  pinMode(morsePin, OUTPUT);
  pinMode(detectLed, OUTPUT);
  pinMode(testButton, INPUT);
  pinMode(dorbellPin, INPUT);
  attachInterrupt(dorbellPin, eventDorbell, FALLING);
}

void initSchedules()
{
  Serial.println("SCHEDULES init");
  
  runner.init();

  runner.addTask(scheduleCheckFreeRam);
  scheduleCheckFreeRam.enable();

  runner.addTask(scheduleResetRing);
  scheduleResetRing.enable();
}

void initTimeClient()
{
  timeClient.begin();
  timeClient.update();
}

void checkFreeRam()
{
  if (ESP.getFreeHeap() < 60000)
  {
    ESP.restart();
  }

  //Serial.println("Free RAM: " + String(ESP.getFreeHeap()));
}

void resetRing()
{
  sendRingStatus();
}

void loop() 
{
  wifiReconnectCheck();

  handleButtons();

  bool beforeRingVal = ringActive;

  handleRing();

  runner.execute();

}

void ringResetter()
{
  if (ringMillis != -1)
  {
    unsigned long currentMillis = millis();

    if (ringMillis > currentMillis)
    {
      ringMillis = currentMillis;
    }
    
    if (currentMillis - ringMillis >= 30000)
    {
      bool doorbellState = digitalRead(dorbellPin);
      if (doorbellState == true)
      {
        unring();
      }
    }
  }
}

void wifiReconnectCheck()
{
  unsigned long currentMillis = millis();
  // if WiFi is down, try reconnecting every CHECK_WIFI_TIME seconds
  if ((WiFi.status() != WL_CONNECTED) && (currentMillis - previousMillis >=interval))
  {
    Serial.print(millis());
    Serial.println("Reconnecting to WiFi...");
    WiFi.disconnect();
    WiFi.reconnect();
    previousMillis = currentMillis;
  }
}

void handleButtons()
{
  int testBtn = digitalRead(testButton);

  if (testBtn == 1)
  {
    digitalWrite(morsePin, HIGH);
    delay(100);
    testBtn = digitalRead(testButton);
    digitalWrite(morsePin, LOW);
    if (testBtn == 1)
    {
      ring();
    }
    delay(2000);
  }
}

void handleRing()
{
  if (ringActive == true && sentActive == false)
  {
    sendRingStatus();
  }
  else if (ringActive == false && sentInactive == false)
  {
    sendRingStatus();
  }
  else if (ringActive == true && sentInactive == true)
  {
    sendRingStatus();
  }
}

void ring()
{
  ringActive = true;
  ringMillis = millis();
  digitalWrite(detectLed, HIGH);
}

void unring()
{
  ringActive = false;
  digitalWrite(detectLed, LOW);
  attachInterrupt(dorbellPin, eventDorbell, FALLING);
}

String sendRingStatus()
{
  String retStr;

  HTTPClient *https = new HTTPClient();
  https->setReuse(false);

  Serial.print("[HTTP] begin...\n");
  Serial.println("[HTTP] URL: " + SendApiIotUrl);
  
  if (https->begin(SendApiIotUrl))
  {
    Serial.print("[HTTP] POST...\n");
    https->setTimeout(30000);
    // start connection and send HTTP header
    https->addHeader("Content-Type", "application/json; charset=UTF-8");
    https->addHeader(HomeAssistantBearerName, HomeAssistantBearerContent); // auth

    // status Json
    bool initialRingActive = ringActive;
    String sendContent = "{\"state\": \"off\" }";
    if (initialRingActive == true)
    {
      sendContent = "{\"state\": \"on\" }";
    }

    Serial.println("Content: " + sendContent);
    
    int httpCode = https->POST(sendContent);

    // httpCode will be negative on error
    if (httpCode > 0)
    {
      // HTTP header has been send and Server response header has been handled
      Serial.printf("[HTTP] POST... code: %d\n", httpCode);

      // file found at server
      if (httpCode == HTTP_CODE_OK || httpCode == HTTP_CODE_MOVED_PERMANENTLY)
      {
        //String payload = https->getString();
        //Serial.println(payload);
        retStr = https->getString();

        if (initialRingActive == true)
        {
          sentActive = true;
          sentInactive = false;
          delay(3000);
          unring();
        }
        else if (initialRingActive == false)
        {
          sentActive = false;
          sentInactive = true;
        }
        
      }

      //String payload = https->getString();
      //Serial.println(payload);
    }
    else
    {
      Serial.printf("[HTTP] GET... failed, error: %s\n", https->errorToString(httpCode).c_str());
      retStr = "error: ";
      retStr += https->errorToString(httpCode).c_str();
    }

    https->end();
  }
  else
  {
    Serial.printf("[HTTP] Unable to connect\n");
    retStr = "ConErr";
  }

  delete https;
  https = NULL;

  delay(2000);

  return retStr;
}

Probleme

Klingeldrähte sind meist ohne Abschirmung verlegt, das heißt wenn ein Elektromagnetischer Impuls durch z.B. das Abschalten einer LED Lampe oder schalten eines Relais in der Nähe der Klingelleitung vollzogen wird, meldet die Klingel ab und an (wenige Male pro Monat) fälschlicherweise klingeln. Wahrscheinlich würde sich das Problem durch das Ersetzen der Klingelleitung durch eine abgeschirmte Leitung, beispielsweise CAT7 lösen lassen.

Noch ein Hinweis: Wenn die Klingel gedrückt ist, der Trafo im Schaltschrank z.B. ~12V auf der Sekundärseite nominal aufweist, kann es sein, dass bei angeschlossener Klingel weniger als 12V direkt vor der Klingel messbar sind, das ist normal. Das kommt dadurch, weil die Klingel Strom braucht und der Trafo oft nur eine Begrenze menge Strom liefern kann.

Hinweis

Dieser Artikel dokumentiert lediglich meinen Aufbau. Für den Nachbau, die Nutzung einzelner Komponenten, die Platinen und den gesamten Inhalt wird die Haftung in jeglicher Form ausgeschlossen.

Categories
ESP32 Home Assistant

Smarte Garagentor Steuerung

Ein Garagentor lässt sich in der Regel über eine Fernbedienung, sowie, falls montiert, über einen Taster im Inneren der Garage bedienen. Ist innen kein Schalter montiert, die Fernbedienung leer oder man möchte spontan von außen in die Garage, muss man oft lange Laufwege in Kauf nehmen.

Aus diesem Grund habe ich mich dazu entschlossen das Garagentor etwas smarter zu gestalten.

Hardware

Das Garagentor ist eine SupraMatic 2 von Hörmann. Dazu habe ich mir die passende Hörmann Universaladapterplatine UAP 1 Erweiterungsplatine besorgt.

ACHTUNG! Nicht jeder Garagentorantrieb von Hörmann und nicht jede Version der SupraMatic 2 bietet die Möglichkeit des Anschlusses einer Universaladapterplatine! Hierzu bitte den Hersteller oder entsprechende Fachbetriebe kontaktieren! Je nach Garagentorantrieb können anderen Platinen notwendig sein!

Wenn ich das richtig gelesen habe, dient das UAP 1 Modul dazu, das Garagentor von extern über eigene Komponenten zu schalten und entsprechende Status aus zu lesen oder andere Komponenten zu schalten. Dort sind drei Relais, die je nach dem geschlossen sind, wenn das Tor; Geschlossen, Offen ist, sowie das Licht aktiviert ist.

Um das Tor zu steuern, bedient der ESP32 die Eingangs-Kontakte, in dem er sie gegen Masse kurz schließt. Das lässt sich beispielsweise mit BC547C Transistoren realisieren.

Die Platine liefert 24V, sowie maximal 100mA. Damit lässt sich ein ESP32 mit einem effizienten, vorgeschalteten 5V StepDown Wandler versorgen.

Als Microcontroller habe ich mich für einen ESP32 mit 38 Pins entschieden.

Um die Temperatur, Luftfeuchtigkeit, sowie Luftdruck zu erfassen, verwende ich einen BME280 Sensor.

Damit das Garagentor nicht nur über das Webinterface schaltbar ist, gibt es auf der Platine weitere Pins, an die sich externe Schalter anschließen lassen.

Gehäuse

Praktischerweise besitzt der Garagentorantrieb oberhalb ein Blech, mit Loch, an dem ich das Gehäuse befestigt habe. Leider ist das Blech etwas innenliegend, somit muss das Gehäuse dort, wo es verschraubt wird, etwas dicker sein. Um es besser drucken zu können, habe ich das Gehäuse und den Abstandshalter separat gedruckt.

Hauptmodell
Abstandshalter

Einen Deckel habe ich noch nicht gedruckt, das werde ich hier ergänzen, sobald ich ihn gedruckt habe.

Zudem habe ich mir eine Handschaltbox an der Wand montiert, damit keine Fernbedienung zum Öffnen des Garagentors von innen benötigt wird.

Handschaltbox mit montierten 12mm Tastern und Beschriftung.
Montierter Aufbau am Garagentorantrieb, das Graue oben ist die Steuerleitung, die zur Handschaltbox führt.

Elektronik

Die UAP 1 liefert 24V, diese speisen den ESP32 inklusive der Komponenten, die auf der Platine montiert sind. Die Transistoren werden vom ESP32 entsprechend geschalten.

Hier die Platine dazu (Afillate): https://aisler.net/p/TITHFGRG

Bestückte Platine

Links im Rahmen “Output” befinden sich die PINs, die beim Schalten des Garagentores durch den ESP32 entsprechend gegen Masse gezogen werden.

Die “Switch” Pins triggern das Schalten z.B. durch einen externen Schalter. Diese sind entsprechend entprellt durch das warten in der Software, sowie entsprechende Pull-Up Widerstände.

“Input State” sind die Anschlüsse für die Relais, mit denen das Garagentor den Status zurückmeldet.

Links oben befindet sich der I²C Anschluss, an diesen kann beispielsweise der BME280 Sensor angeschlossen werden.

Über V+ werden die Platine und der ESP32 bzw. zunächst der DC-Wandler mit Strom versorgt.

ACHTUNG! Wenn der Garagentorantrieb den Fehler zeigt, dass die Schlupftür im Garagentor noch geöffnet ist, muss man sicherstellen, dass alle Masseverbindungen der Eingänge an der UAP 1 entsprechend verbunden sind.

Bauteile

  • C1, C2, C3 : 10nF
  • L1 : 10 µH
  • R1, R3, R5, R7 : 1kO
  • R2, R4, R6, R8 : 2.2kO
  • R9, R10, R11, R12, R13, R14, R15 : 5.6kO
  • T1, T2, T3, T4 : BC547C
  • DC-Wandler: RECOM R-78E50-05
  • J-DC – Jumper; Ist dieser geschlossen, wird der ESP32 über den DC-Wandler versorgt, ist der Jumper nicht geschlossen, kann er beispielsweise zu Entwicklungszwecken über den PC versorgt werden.

Die Taster auf der rechten Seite der Platine sind optional.

Software

Der ESP32 besitzt eine JSON Schnittstelle mit dessen Hilfe er sich schalten lässt.

Um das Maß an Sicherheit zu erhöhen, wird OTP, also OneTimePassword, verwendet, ähnlich wie man es von der zwei Faktor Authentifizierung anderer Dienste kennt. Das soll verhindern, dass bei einem ungewollten Zugriff auf das Netzwerk, das Garagentor bedient werden kann.

Der ESP32 meldet aktiv den Status des Garagentors bei einer Änderung an den Home Assistant zurück. Ansonsten würde der Status erst dann aktualisiert werden, wenn Home Assistant die Daten via REST selbst abfragt. Dafür muss als Admin ein Dauer-Token erstellt werden und dieser der HTTP Anfrage im Header als “Bearer” beigefügt werden.

Über Disable Inputs lassen sich die Handschalter in der Garage deaktivieren.

Mit Disable Commands werden die Handschalter in der Garage und jegliche per JSON an das Gerät geschickte Commands deaktiviert.

Home Assistant

Das Garagentor habe ich in den Home Assistant integriert. Home Assistant unterstützt out-of-the-box die OTP Integration, auch für REST Kommandos.

Garagentor Steuerung-Schalter:

# Garagentor
switch:
  - platform: rest
    name: "ESP32 Garagentor - Open"
    resource: http://TorEspIp/jsondoaction
    method: post
    body_on: "{'doopen': '1', 'code': {{ states('sensor.garagentor_totp') }} }"
    body_off: "{'doopen': '0', 'code': {{ states('sensor.garagentor_totp') }} }"
    is_on_template: "{{ value_json.doopen }}"
    headers:
      Content-Type: application/json
      
  - platform: rest
    name: "ESP32 Garagentor - Close"
    resource: http://TorEspIp/jsondoaction
    method: post
    body_on: "{'doclose': '1', 'code': {{ states('sensor.garagentor_totp') }} }"
    body_off: "{'doclose': '0', 'code': {{ states('sensor.garagentor_totp') }} }"
    is_on_template: "{{ value_json.doclose }}"
    headers:
      Content-Type: application/json
      
  - platform: rest
    name: "ESP32 Garagentor - Middle"
    resource: http://TorEspIp/jsondoaction
    method: post
    body_on: "{'domiddle': '1', 'code': {{ states('sensor.garagentor_totp') }} }"
    body_off: "{'domiddle': '0', 'code': {{ states('sensor.garagentor_totp') }} }"
    is_on_template: "{{ value_json.domiddle }}"
    headers:
      Content-Type: application/json
      
  - platform: rest
    name: "ESP32 Garagentor - Light"
    resource: http://TorEspIp/jsondoaction
    method: post
    body_on: "{'dolight': '1', 'code': {{ states('sensor.garagentor_totp') }} }"
    body_off: "{'dolight': '0', 'code': {{ states('sensor.garagentor_totp') }} }"
    is_on_template: "{{ value_json.dolight }}"
    headers:
      Content-Type: application/json
      
  - platform: rest
    name: "ESP32 Garagentor - Disable Inputs"
    resource: http://TorEspIp/jsondoaction
    method: post
    body_on: "{'blockinputs': '1', 'code': {{ states('sensor.garagentor_totp') }} }"
    body_off: "{'blockinputs': '0', 'code': {{ states('sensor.garagentor_totp') }} }"
    is_on_template: "{{ value_json.blockinputs }}"
    headers:
      Content-Type: application/json
      
  - platform: rest
    name: "ESP32 Garagentor - Disable Commands"
    resource: http://TorEspIp/jsondoaction
    method: post
    body_on: "{'blockcommands': '1', 'code': {{ states('sensor.garagentor_totp') }} }"
    body_off: "{'blockcommands': '0', 'code': {{ states('sensor.garagentor_totp') }} }"
    is_on_template: "{{ value_json.blockcommands }}"
    headers:
      Content-Type: application/json

Garagentor Sensoren:

# Garagentor
rest:
  - scan_interval: 60
    resource: http://TorEspIp/jsondoaction
    sensor:
     - name: "ESP32 Garagentor - Request state"
       value_template: "{{ value_json.state }}"
       
     - name: "ESP32 Garagentor - Light state"
       value_template: "{{ value_json.light }}"
       
     - name: "ESP32 Garagentor - Close state"
       value_template: "{{ value_json.close }}"
       
     - name: "ESP32 Garagentor - Open state"
       value_template: "{{ value_json.open }}"
       
     - name: "ESP32 Garagentor - Doorstate"
       value_template: "{{ value_json.doorstate | int }}"
       
     - name: "ESP32 Garagentor - Doorstate text"
       value_template: "{{ value_json.doorstatetext }}"
       
     - name: "ESP32 Garagentor - Temperature"
       unit_of_measurement: "°C"
       value_template: "{{ value_json.bmeTemp | float }}"
       
     - name: "ESP32 Garagentor - Humidity"
       unit_of_measurement: "%"
       value_template: "{{ value_json.bmeHum | float }}"
       
     - name: "ESP32 Garagentor - Pressure"
       unit_of_measurement: "hPa"
       value_template: "{{ value_json.bmePress | float }}"
       
     - name: "ESP32 Garagentor - Free RAM"
       unit_of_measurement: "bytes"
       value_template: "{{ value_json.freeram | int }}"

OTP Generierung:

sensor:
  - platform: otp
    name: "Garagentor TOTP"
    token: YOURTOKENBASE32

Beim Token ist zu beachten, dass das “Secret” für das Hinterlegen im ESP32, sowie im Home Assistant BASE32 encodiert sein muss. Solche Konvertier gibt es unter anderem online. Wichtig ist, dass man von de Länge ein entsprechendes Secret wählt, dass am Ende keine Gleichzeiten im BASE32 stehen, da dies sonst zu Problemen führt.

Im ESP32 wird das BASE32 encodierte “Secret”, das für die OTP-Generierung benötigt wird als HEX hinterlegt.

Code

#include <ArduinoJson.h>
#include <HTTPClient.h>
#include <WebServer.h>

#include <WiFiClientSecure.h>

#include <TaskScheduler.h>

#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>

#include <NTPClient.h>
#include <WiFiUdp.h>

#include <TOTP.h>

// --------- WIFI -----------
[...]
unsigned long previousMillis = 0;
unsigned long interval = 30000;

// --------- END WIFI -------

// --------- INITS -------

const char* ssid = STASSID;
const char* password = STAPSK;
const char* deviceName = DEVICENAME;
WiFiUDP ntpUDP;
NTPClient timeClient(ntpUDP, "de.pool.ntp.org", 0, 0);
WebServer server(80);
StaticJsonDocument<1024> jsonDocument;
char jsonBuffer[1024];

const String HomeAssistantBearerName = "Authorization";
const String HomeAssistantBearerContent = "Bearer XXXX";

const String SendApiIotUrl = "http://homeAssistantIp:8123/api/states/";


const String DoorStateIntName = "sensor.esp32_garagentor_doorstate";
const String DoorStateTextName = "sensor.esp32_garagentor_doorstate_text";
const String CloseStateBoolName = "sensor.esp32_garagentor_close_state";
const String OpenStateBoolName = "sensor.esp32_garagentor_open_state";
const String LighStateBoolName = "sensor.esp32_garagentor_light_state";

const String SwitchCloseStateName = "switch.esp32_garagentor_close";
const String SwitchMiddleStateName = "switch.esp32_garagentor_middle";
const String SwitchOpenStateName = "switch.esp32_garagentor_open";
const String SwitchLightStateName = "switch.esp32_garagentor_light";


Adafruit_BME280 bme; // I2C

// --------- END INITS -------

// --------- SCHEDULER BEGIN -------

void checkFreeRam();
Task scheduleCheckFreeRam(21*1000, TASK_FOREVER, &checkFreeRam);

void wifiReconnectCheck();
Task scheduleWifiReconnectCheck(8*60*1000, TASK_FOREVER, &wifiReconnectCheck);

void refreshTime();
Task scheduleRefreshTime(10*60*1000, TASK_FOREVER, &refreshTime);

void refreshTotp();
Task scheduleRefreshTotp(1*1000, TASK_FOREVER, &refreshTotp);

void refreshBmeVals();
Task scheduleRefreshBmeVals(20*1000, TASK_FOREVER, &refreshBmeVals);

Scheduler runner;

// --------- SCHEDULER END ---------

// ------- DEFINITIONS ----------
static int selfCheckPinDuration = 500;
static String linkColorNormal = "#2321B0";
static String linkColorVisited = "#2321B0";
static String activeMarkerBegin = "<b>&raquo;";
static String activeMarkerEnd = "&laquo;</b>";

static String textState[] = {"unknown", "closed", "moving / half-open", "open"};
// ------- END DEFINITIONS ----------

// ------- PINS ----------
static int morsePin = 2;
static int i2cSdaPin = 21;
static int i2cSclPin = 22;

static int pinOutLight = 32;
static int pinOutClose = 25;
static int pinOutOpen = 26;
static int pinOutMiddle = 27;

static int pinInStateOpened = 23;
static int pinInStateClosed = 33;
static int pinInStateLight = 16;

static int pinInSwitchLight = 19;
static int pinInSwitchClose = 18;
static int pinInSwitchOpen = 17;
static int pinInSwitchMiddle = 15;
// ------- END PINS ----------

// --------- Variables ---------

int freeHeap = 0;

bool blockInputs = false;
bool blockCommands = false;

String bmeTemp = String("");
String bmeHum = String("");
String bmePress = String("");

// 0 unknown
// 1 closed
// 2 moving / in between
// 3 open
int doorState = 0;

bool openState = false;
bool closeState = false;
bool lightState = false;

bool lightButton = false;
bool closeButton = false;
bool openButton = false;
bool middleButton = false;

// otp
String totpOldCode = String("");
String totpActualCode = String("");

uint8_t hmacKey[] = { 0x00, [...]};
TOTP totp = TOTP(hmacKey, 20);

// --------- END Variables ---------

void setup() 
{
  // put your setup code here, to run once:
  initSerial();
  initWifi();
  initSchedules();
  initPinModes();
  initBme();
  initTimeClient();
  initServer();
  refreshTotp();
  checkFreeRam();
}

void loop() 
{

  handleInputs();

  server.handleClient();

  resetPendingCommands();
  handleActions();
  
  runner.execute();
}

void initSerial()
{
  Serial.begin(115200); 
  while(!Serial){} // Waiting for serial connection
  Serial.println();
}

void initWifi()
{
  [...]

  Serial.println(WiFi.localIP()); 
}

void initPinModes()
{
  Serial.println("PIN MODES init");

  pinMode(morsePin, OUTPUT);
  digitalWrite(morsePin, LOW);


  pinMode(pinOutLight, OUTPUT);
  digitalWrite(pinOutLight, LOW);

  pinMode(pinOutClose, OUTPUT);
  digitalWrite(pinOutClose, LOW);

  pinMode(pinOutOpen, OUTPUT);
  digitalWrite(pinOutOpen, LOW);

  pinMode(pinOutMiddle, OUTPUT);
  digitalWrite(pinOutMiddle, LOW);

  
  pinMode(pinInStateOpened, INPUT);
  pinMode(pinInStateClosed, INPUT);
  pinMode(pinInStateLight, INPUT);

  pinMode(pinInSwitchLight, INPUT);
  pinMode(pinInSwitchClose, INPUT);
  pinMode(pinInSwitchOpen, INPUT);
  pinMode(pinInSwitchMiddle, INPUT);
}

void initBme()
{
  // BME 280
  for(int i = 0; i < 10; i++)
  {
    Serial.println("detecting BME280...");
    delay(500);
    
    if (bme.begin(0x76))
    {
      i = 10;
    }
  }
}

void initSchedules()
{
  Serial.println("SCHEDULES init");
  
  runner.init();

  runner.addTask(scheduleCheckFreeRam);
  scheduleCheckFreeRam.enable();

  runner.addTask(scheduleWifiReconnectCheck);
  scheduleWifiReconnectCheck.enable();

  runner.addTask(scheduleRefreshTime);
  scheduleRefreshTime.enable();

  runner.addTask(scheduleRefreshTotp);
  scheduleRefreshTotp.enable();

  runner.addTask(scheduleRefreshBmeVals);
  scheduleRefreshBmeVals.enable();
  
}

void initTimeClient()
{
  timeClient.begin();
  delay(1000);
  timeClient.update();
}

void initServer()
{
  server.on("/", handleConnect);
  server.on("/jsondoaction", jsonDoAct);
  server.on("/jsondoaction", HTTP_POST, jsonDoAct);  
  server.onNotFound(handleConnect);
  server.begin();
  Serial.println("HTTP server started");
}

void refreshTotp()
{
  String newCode = String(totp.getCode(timeClient.getEpochTime()));
  if(totpActualCode != newCode)
  {
    totpOldCode = totpActualCode;
    totpActualCode = String(newCode);
    Serial.print("TOTP code: ");
    Serial.println(newCode);
    Serial.println(timeClient.getEpochTime());
  }
}

void refreshBmeVals()
{
  Serial.println("Refresh BME Vals");
  String zBmeTemp = String(bme.readTemperature());
  String zBmeHum = String(bme.readHumidity());
  String zBmePress = String(bme.readPressure() / 100.0F);

  bmeTemp = zBmeTemp;
  bmeHum = zBmeHum;
  bmePress = zBmePress;
  Serial.println("END - Refresh BME Vals");
}

void wifiReconnectCheck()
{
  unsigned long currentMillis = millis();
  // if WiFi is down, try reconnecting every CHECK_WIFI_TIME seconds
  if ((WiFi.status() != WL_CONNECTED) && (currentMillis - previousMillis >=interval))
  {
    Serial.print(millis());
    Serial.println("Reconnecting to WiFi...");
    WiFi.disconnect();
    WiFi.reconnect();
    previousMillis = currentMillis;
  }
}

void refreshTime()
{
  timeClient.update();
}

void checkFreeRam()
{
  freeHeap = ESP.getFreeHeap();

  if (ESP.getFreeHeap() < 60000)
  {
    ESP.restart();
  }
}

bool totpValid(String codeToCheck)
{
  if (codeToCheck == totpOldCode ||
      codeToCheck == totpActualCode)
  {
    return true;
  }
  
  return false;
}

void handleInputs()
{
  int tmpState = 0;
  bool changedStateRelais = false;

  // open state
  tmpState = digitalRead(pinInStateOpened);
  if (tmpState == 1)
  {
    if (changedBool(openState, true))
    {
      sendStatusChangeBool(OpenStateBoolName, true);
      changedStateRelais = true;
    }

    openState = true;
  }
  else
  {
    if (changedBool(openState, false))
    {
      sendStatusChangeBool(OpenStateBoolName, false);
      changedStateRelais = true;
    }

    openState = false;
  }

  // close state
  tmpState = digitalRead(pinInStateClosed);
  if (tmpState == 1)
  {
    if (changedBool(closeState, true))
    {
      sendStatusChangeBool(CloseStateBoolName, true);
      changedStateRelais = true;
    }

    closeState = true;
  }
  else
  {
    if (changedBool(closeState, false))
    {
      sendStatusChangeBool(CloseStateBoolName, false);
      changedStateRelais = true;
    }

    closeState = false;
  }

  // handle door status
  if (changedStateRelais == true)
  {
    if (openState == false &&
        closeState == false)
    {
      doorState = 2;
    }
    else if (openState == true &&
             closeState == true)
    {
      doorState = 0;
    }
    else if (openState == true)
    {
      doorState = 3;      
    }
    else if (closeState == true)
    {
      doorState = 1;      
    }

    sendStatusChangeInt(DoorStateIntName, doorState);
    sendInputStatusHttp(DoorStateTextName, getStatusText(), true);
  }

  // light state
  tmpState = digitalRead(pinInStateLight);
  if (tmpState == 1)
  {
    if (changedBool(lightState, true))
    {
      sendStatusChangeBool(LighStateBoolName, true);
    }

    lightState = true;
  }
  else
  {
    if (changedBool(lightState, false))
    {
      sendStatusChangeBool(LighStateBoolName, false);
    }
    
    lightState = false;
  }


  if (blockInputs == false)
  {
    // BUTTONS / SWITCHES
    if (blockInputs == false)
    {
      tmpState = buttonPressed(pinInSwitchLight);
      if (tmpState == 1)
      {
        if (changedBool(lightButton, true))
        {
          sendStatusChangeSwitch(SwitchLightStateName, true);
        }

        lightButton = true;
      }
      else
      {
        if (changedBool(lightButton, false))
        {
          sendStatusChangeSwitch(SwitchLightStateName, false);
        }

        lightButton = false;
      }

      tmpState = buttonPressed(pinInSwitchClose);
      if (tmpState == 1)
      {
        if (changedBool(closeButton, true))
        {
          sendStatusChangeSwitch(SwitchCloseStateName, true);
        }

        closeButton = true;
      }
      else
      {
        if (changedBool(closeButton, false))
        {
          sendStatusChangeSwitch(SwitchCloseStateName, false);
        }

        closeButton = false;
      }

      tmpState = buttonPressed(pinInSwitchOpen);
      if (tmpState == 1)
      {
        if (changedBool(openButton, true))
        {
          sendStatusChangeSwitch(SwitchOpenStateName, true);
        }

        openButton = true;
      }
      else
      {
        if (changedBool(openButton, false))
        {
          sendStatusChangeSwitch(SwitchOpenStateName, false);
        }

        openButton = false;
      }

      tmpState = buttonPressed(pinInSwitchMiddle);
      if (tmpState == 1)
      {
        if (changedBool(middleButton, true))
        {
          sendStatusChangeSwitch(SwitchMiddleStateName, true);
        }

        middleButton = true;
      }
      else
      {
        if (changedBool(middleButton, false))
        {
          sendStatusChangeSwitch(SwitchMiddleStateName, false);
        }

        middleButton = false;
      }
    }
  }
  
}

int buttonPressed(int buttonNumber)
{
  int tmpState = 0;

  tmpState = digitalRead(buttonNumber);
  if (tmpState == 0)
  {
    digitalWrite(morsePin, HIGH);
    delay(20);
    tmpState = digitalRead(buttonNumber);
    delay(100);
    digitalWrite(morsePin, LOW);
    if (tmpState == 0)
    {
      Serial.println("Button Pressed: "  + String(buttonNumber));
      return 1;
    }
  }

  return 0;
}

bool changedBool(bool before, bool after)
{
  if(before != after)
  {
    return true;
  }

  return false;
}

bool changedInt(int before, int after)
{
  if (before != after)
  {
    return true;
  }

  return false;  
}

void sendStatusChangeBool(String attributeStringIn, bool valueIn)
{
  if (valueIn == true)
  {
    Serial.println(sendInputStatusHttp(attributeStringIn, "true", false));
  }
  else
  {
    Serial.println(sendInputStatusHttp(attributeStringIn, "false", false));
  }
}

void sendStatusChangeInt(String attributeStringIn, int valueIn)
{
  Serial.println(sendInputStatusHttp(attributeStringIn, String(valueIn), false));
}

void sendStatusChangeSwitch(String attributeStringIn, bool valueIn)
{
  Serial.println(sendInputStatusHttp(attributeStringIn, boolToSwitch(valueIn), true));
}

void resetPendingCommands()
{
  if (blockInputs == true &&
      blockCommands == true)
  {
    lightButton = false;
    closeButton = false;
    openButton = false;
    middleButton = false;
  }
}

void handleActions()
{
  int hiTimer = 800; // ms

  if (lightButton == true)
  {
    digitalWrite(morsePin, HIGH);
    digitalWrite(pinOutLight, HIGH);
    delay(hiTimer);
    digitalWrite(pinOutLight, LOW);
    digitalWrite(morsePin, LOW);


    if (changedBool(lightButton, false))
    {
      sendStatusChangeSwitch(SwitchLightStateName, false);
    }

    lightButton = false;

  }
  else if (closeButton == true)
  {
    digitalWrite(morsePin, HIGH);
    digitalWrite(pinOutClose, HIGH);
    delay(hiTimer);
    digitalWrite(pinOutClose, LOW);
    digitalWrite(morsePin, LOW);

    if (changedBool(closeButton, false))
    {
      sendStatusChangeSwitch(SwitchCloseStateName, false);
    }

    closeButton = false;
  }
  else if (openButton == true)
  {
    digitalWrite(morsePin, HIGH);
    digitalWrite(pinOutOpen, HIGH);
    delay(hiTimer);
    digitalWrite(pinOutOpen, LOW);
    digitalWrite(morsePin, LOW);


    if (changedBool(openButton, false))
    {
      sendStatusChangeSwitch(SwitchOpenStateName, false);
    }

    openButton = false;
  } 
  else if (middleButton == true)
  {
    digitalWrite(morsePin, HIGH);
    digitalWrite(pinOutMiddle, HIGH);
    delay(hiTimer);
    digitalWrite(pinOutMiddle, LOW);
    digitalWrite(morsePin, LOW);


    if (changedBool(middleButton, false))
    {
      sendStatusChangeSwitch(SwitchMiddleStateName, false);
    }

    middleButton = false;
  }

}

String getStatusText()
{
  return textState[doorState];
}

// HTTP Server

void handleConnect()
{
  Serial.println("Connect");
  server.send(200, "text/html", SendHTML("")); 
}

String SendHTML(String context)
{
  String ptr = "<!DOCTYPE html> <html>\n";
  ptr +="<head><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, user-scalable=no\">\n";
  ptr +="<title>ESP32 Garagentor</title>\n";
  ptr +="<style>html { font-family: Arial; display: inline-block; margin: 0px auto; text-align: center;}\n";
  ptr +="body{margin-top: 50px;} h1 {color: #444444;margin: 50px auto 30px;} h3 {color: #444444;margin-bottom: 50px;}\n";
  ptr +=".button {display: block;width: 80px;background-color: #3498db;border: none;color: white;padding: 13px 30px;text-decoration: none;font-size: 25px;margin: 0px auto 35px;cursor: pointer;border-radius: 4px;}\n";
  ptr +="a, a:active, { color: " + linkColorNormal + "; text-decoration: underline; }\n";
  ptr +="a:visited { color: " + linkColorVisited + "; text-decoration: underline; }\n";
  ptr +="p {font-size: 14px;color: #888;margin-bottom: 10px;}\n";
  ptr +="</style>\n";
  ptr +="</head>\n";
  ptr +="<body>\n";
  ptr +="<h1>ESP32 Garagentor</h1>\n";

  String lineBreak = "<br><br>";

  ptr += ""; // TODO

  ptr +="</body>\n";
  ptr +="</html>\n";
  return ptr;
}

void jsonDoAct()
{
  Serial.println("JSON Act");

  String status = "IDLE";
  int tmpInput = 0;
  bool waitAfterResponse = false;

  if (server.hasArg("plain") == false) 
  {
    //handle error here
  }
  
  String body = server.arg("plain");
  Serial.println(body);
  deserializeJson(jsonDocument, body);

  if (jsonDocument.containsKey("code") == true)
  {
    String code = jsonDocument["code"];
    if (totpValid(code) == true)
    {
      // continue
      status = "OK";

      if (jsonDocument.containsKey("blockinputs") == true)
      {
        tmpInput = jsonDocument["blockinputs"];
        if (tmpInput == 1)
        {
          blockInputs = true;
        }
        else
        {
          blockInputs = false;
        }
      }
      else if (jsonDocument.containsKey("blockcommands") == true)
      {
        tmpInput = jsonDocument["blockcommands"];
        if (tmpInput == 1)
        {
          blockCommands = true;
        }
        else
        {
          blockCommands = false;
        }
      }
      else if (jsonDocument.containsKey("dolight") == true &&
               blockCommands == false)
      {
        tmpInput = jsonDocument["dolight"];
        if (tmpInput == 1)
        {
          lightButton = true;
          waitAfterResponse = true;
        }
        else
        {
          lightButton = false;
        }
      }

      else if (jsonDocument.containsKey("doclose") == true &&
               blockCommands == false)
      {
        tmpInput = jsonDocument["doclose"];
        if (tmpInput == 1)
        {
          closeButton = true;
          waitAfterResponse = true;
        }
        else
        {
          closeButton = false;
        }
      }
      else if (jsonDocument.containsKey("doopen") == true &&
               blockCommands == false)
      {
        tmpInput = jsonDocument["doopen"];
        if (tmpInput == 1)
        {
          openButton = true;
          waitAfterResponse = true;
        }
        else
        {
          openButton = false;
        }
      }
      else if (jsonDocument.containsKey("domiddle") == true &&
               blockCommands == false)
      {
        tmpInput = jsonDocument["domiddle"];
        if (tmpInput == 1)
        {
          middleButton = true;
          waitAfterResponse = true;
        }
        else
        {
          middleButton = false;
        }
      }


    }
    else
    {
      status = "ERROR: WRONG CODE";
    }
    
    Serial.println(status);
  }

  resetPendingCommands();
  createStatusJson(status);

  server.send(200, "application/json", jsonBuffer);

  if (waitAfterResponse == true)
  {
    //delay(500);
  }
}

// -------------------- JSON HELPER --------------------------

void createStatusJson(String statusIn) 
{
  if (statusIn == "")
  {
    statusIn = "OK";
  }
  
  jsonDocument.clear();  
  jsonDocument["state"] = statusIn;

  jsonDocument["dolight"] = lightButton;
  jsonDocument["doclose"] = closeButton;
  jsonDocument["doopen"] = openButton;
  jsonDocument["domiddle"] = middleButton;

  jsonDocument["blockinputs"] = blockInputs;
  jsonDocument["blockcommands"] = blockCommands;
  jsonDocument["light"] = lightState;
  jsonDocument["close"] = closeState;
  jsonDocument["open"] = openState;
 
  jsonDocument["doorstate"] = doorState;  
  jsonDocument["doorstatetext"] = getStatusText();

  if (bmePress != String("") && 
      bmePress.toFloat() > 0)
  {
    jsonDocument["bmeTemp"] = bmeTemp;
    jsonDocument["bmeHum"] = bmeHum;
    jsonDocument["bmePress"] = bmePress;
  }

  jsonDocument["freeram"] = freeHeap;

  serializeJson(jsonDocument, jsonBuffer);
}

String boolToSwitch(bool inVal)
{
  if (inVal == true)
  {
    return "on";
  }
  return "off";
}

// -------------------- HTTP HELPER --------------------------

String sendInputStatusHttp(String attributeStringIn, String valueIn, bool isString)
{
  String retStr;

  HTTPClient *http = new HTTPClient();
  http->setReuse(false);

  Serial.print("[HTTP] begin...\n");
  Serial.println("[HTTP] URL: " + SendApiIotUrl + attributeStringIn);
  
  if (http->begin(SendApiIotUrl + attributeStringIn))
  {
    Serial.print("[HTTP] POST...\n");
    http->setTimeout(5000);
    // start connection and send HTTP header
    http->addHeader("Content-Type", "application/json; charset=UTF-8");
    http->addHeader(HomeAssistantBearerName, HomeAssistantBearerContent); // auth

    // status Json
    String sendContent = "";
    if (isString == true)
    {
      sendContent = "{\"state\": \""+valueIn+"\" }";
    }
    else
    {
      sendContent = "{\"state\": "+valueIn+" }";
    }

    Serial.println("Content: " + sendContent);
    
    int httpCode = http->POST(sendContent);

    // httpCode will be negative on error
    if (httpCode > 0)
    {
      // HTTP header has been send and Server response header has been handled
      Serial.printf("[HTTP] POST... code: %d\n", httpCode);

      // file found at server
      if (httpCode == HTTP_CODE_OK || httpCode == HTTP_CODE_MOVED_PERMANENTLY)
      {
        //String payload = http->getString();
        //Serial.println(payload);
        retStr = http->getString();
      }

      //String payload = http->getString();
      //Serial.println(payload);
    }
    else
    {
      Serial.printf("[HTTP] GET... failed, error: %s\n", http->errorToString(httpCode).c_str());
      retStr = "error: ";
      retStr += http->errorToString(httpCode).c_str();
    }

    http->end();
  }
  else
  {
    Serial.printf("[HTTP] Unable to connect\n");
    retStr = "ConErr";
  }

  delete http;
  http = NULL;

  return retStr;
}

Hinweis

Dieser Artikel dokumentiert lediglich meinen Aufbau. Für den Nachbau, die Nutzung einzelner Komponenten, die Platinen und den gesamten Inhalt wird die Haftung in jeglicher Form ausgeschlossen.

Categories
ESP32

Automatische Gartenhaus Belüftung

Nachdem es im Sommer im Gartenhaus etwas warm wird, habe ich mich dazu entschlossen eine automatische Belüftung zu entwickeln. Hier beschreibe ich dieses Projekt.

Problem

Im Gartenhaus wird es im Sommer immer etwas warm. Wenn man dort Pflanzensamen oder ähnliches lagert, ist Hitze diesen nicht besonders zuträglich. Ein Fenster dauerhaft offen lassen, wollte ich nicht. Es musste eine flexible Lösung sein. Zudem sollte die Lösung nicht teuer sein.

Auch wollte ich keine manuelle Lösung, sondern eine, die sich zwar Manuell schalten lässt, aber im Normalbetrieb automatisch abhängig von Innen und Außentemperatur schaltet. Es ist nicht zielführend, wenn es innen bereits kalt ist und durch die Lüftung noch kälter wird oder im umgekehrten Fall draußen wärmer als im Gartenhaus ist und der Lüfter die Wärme ins Gebäude zieht.

Lösung

Da ich noch einige ältere PC-Lüfter in einer Kiste hatte, war die Idee mit diesen eine Belüftung zu bauen. Zudem sollte eine Elektronik Innen- sowie Außentemperatur erfassen und anhand dessen entscheiden, ob der Lüfter aktiv sein soll.

Hardware

Zur Erfassung der Temperatur habe ich mich für wasserdichte DS18B20 Sensoren entschieden, die je einmal innen und einmal außen montiert werden.

Kontrolliert wird das ganze von einem ESP32 mit 30 PINs, der mit dem WLAN verbunden ist und damit aus dem Netzwerk geschaltet, sowie die Temperatur und Zustandswerte ausgelesen werden können.

Zur Lüftung werden zwei unterschiedlich große PC Lüfter hintereinander verwendet, um einen stärkeren Luftstrom zu gewährleisten. Die Luft wird mit der Entlüftung an der Decke nach draußen befördert, nachströmen kann die Luft über die Spalten z.B. in der Tür oder zwischen den Planken.

Lüfterkonstruktion

Die Lüfter müssen in einem Kanal hintereinander gebracht werden. Zudem soll ein “Rückschlag”, wenn der Winddruck bei aktivierter Lüftung zu groß wird, sowie das unbeabsichtigte Lüften bei deaktivierten Lüftern verhindert werden. Auch ein “Stehenbleiben” der Lüfter durch zu viel Gegendruck sollte vermieten werden. Da PC-Lüfter in der Regel keinen Starken Luftdruck erzeugen, müssen die Rückschlagklappen sehr leicht sein.

Grundkörper ineinander gesteckt und mit Draht fixiert: Links wird ein 120mm Lüfter montiert, rechts ein 80mm Lüfter, beim Belüften wird die Luft von rechts nach Links gesaugt.

Da es durch das Hintereinanderschalten von Lüftern zu Verwirbelungen in einem Luftkanal kommt habe ich nachträglich eine Art Blatt oder Flügen eingebaut, um dies zu verhindern.

Grundkörper mit montiertem Flügel, sowie einer “Abdichtung” mit Acryl.
Blick ins Innere des Grundkörpers, in Schwarz der Flügel. Die Lüfter sind hier bereits montiert.

Um auf das DN50 Rohr, welches nach Außen führt, zu kommen, sowie Platz für die Rückschlagklappen zu schaffen, habe ich einen weiteren Körper inklusive einer Art Überwurf gedruckt, damit man den Grundkörper mit dem weiteren Körper verbinden kann.

Zulaufender Körper mit Platz für die Rückschlagklappen.

Die Rückschlagklappen habe ich sehr dünn gedruckt und jeweils an einem Faden mit Sekundenkleber angeklebt.

Innere des zusätzlichen Körpers, in dem die Rückschlagklappen montiert sind. Die Rückschlagklappen sind schwarz, im Bild geschlossen.

Nach einigen Test habe ich festgestellt, dass die Klappen nicht mehr zu fallen, wenn sie senkrecht stehen.

Hier gut zu sehen die linke Klappe steht senkrecht.

Also musste verhindert werden, dass die Klappen ganz auf gehen.

Ein umgedrehtes U verhindert ein 90° aufstellen der Rückschlagklappen.

Hier der ganze nach Montage des Rohrstücks und der Durchführung durch die Gartenhauswand. Das graue Rohrstück ist “im Wasser” der Rest des Aufbaus wird “fallend” montiert, damit die Rückschlagklappen von selbst zu fallen.

Ganzer Aufbau mit Zwischen-Rohrstück, sowie Wand Anschlussstück.

Mit einer gedruckten Halterung an der Wand befestigt, habe ich den Aufbau dann im Gartenhaus montiert.

Innen im Gartenhaus: Vorne zu sehen das Schutzgitter in rot, um den Lüfter etwas zu schützen. Hier noch mit Fixierung zu sehen, da das Schutzgitter eingeklebt ist.
Außenansicht, hinter der Abdeckung befindet sich ein ~90° DN50 Rohrstück.

Die Abdeckung ist genau wie der Rest aus ABS gedruckt und lackiert, um der Witterung besser Stand zu halten.

Außen, sowie Innensensor sind mit etwas Abstand zur Wand montiert.

Innensensor rechts oben
Außensensor auf der Nordseite unter der Dachkante

Elektronik

Die PC-Lüfter benötigen 12V, ein Microcontroller des Type ESP32 kann jedoch nur 3.3V, sowie nur kleine Ströme liefern. Aus diesem Grund habe ich mich dazu entschieden die Lüfter mit dem ESP32 über einen P-MOSFET zu schalten. Da der MOSFET im Idealfall für Sperren/Schalten entsprechend die Versorgungsspannung von 12V benötigt, wird er MOSFET über einen am ESP32 angeschlossenen Transistor geschaltet.

Hier die Platine dazu (Afillate): https://aisler.net/p/TAHNLBDW

Bauteile

  • C1, C2, C3 : 10nF
  • IC1 : RECOM R-78E50-05
  • L1 : 10 µH
  • LED1, LED2 : Standard LEDs
  • R1, R2, R3, R8, R9 : 5.6kO
  • R5 : 1kO
  • R6, R7 : 10kO
  • T1 : BC547C
  • MOSFET : IRF5210

Software

Teile des Codes, den ich verwende werde ich hier veröffentlichen.

Für das regelmäßige Auslesen der Sensoren, des freien RAMs und das prüfen der benötigten Belüftung verwende ich den Task Scheduler.

Als Interface verwende ich einerseits eine Web-Darstellung, sowie eine JSON Schnittstelle.

Als optimale Innentemperatur habe ich 18°C angegeben, nur wenn die Außentemperatur mindestens 1.5°C näher an der Zieltemperatur liegt, wird die Belüftung aktiviert.

Man kann die Belüftung sowohl über Taster, wie auch über das Webinterface in den manuellen Modus umstellen.

Außerdem verbindet sich der ESP32 nach getrennter WLAN Verbindung erneut automatisch.

Der ESP32 gibt über die onboard LED entsprechend Rückmeldung. Beispielsweise, wenn Sensoren nicht gefunden werden bzw. diese ungültige Werte liefern.

#include <OneWire.h>
#include <DallasTemperature.h>

#include <TaskScheduler.h>
#include <ArduinoJson.h>

#include "WiFi.h"
#include <WebServer.h>

#include <NTPClient.h>
#include <WiFiUdp.h>

[...]


WebServer server(80);
StaticJsonDocument<1024> jsonDocument;

// --------- SCHEDULER BEGIN -------
void readTemperatures();
Task scheduleRead(5000, TASK_FOREVER, &readTemperatures);

void controlAir();
Task scheduleControl(30000, TASK_FOREVER, &controlAir);

void checkFreeRam();
Task scheduleCheckFreeRam(60000, TASK_FOREVER, &checkFreeRam);

Scheduler runner;

// --------- SCHEDULER END ---------

// --------- PIN BEGIN -------------
const int morsePin = 2;
const int innerSensorPin = 32;  
const int outerSensorPin = 33;    
const int airControlPin = 25;
const int manualControlPin = 26;
const int manualControlSwitchPin = 27;
const int manualOnOffSwitchPin = 13;
// --------- PIN END ---------------

// --------- PIN BEGIN -------------
const int morsePin = 2;
const int innerSensorPin = 32;  
const int outerSensorPin = 33;    
const int airControlPin = 25;
const int manualControlPin = 26;
const int manualControlSwitchPin = 27;
const int manualOnOffSwitchPin = 13;
// --------- PIN END ---------------

// ------- DEFINITIONS BEGIN -------
static String linkColorNormal = "#2321B0";
static String linkColorVisited = "#2321B0";
static String activeMarkerBegin = "<b>&raquo;";
static String activeMarkerEnd = "&laquo;</b>";
static float tempInnerOpti = 18;
static float tempDiffToChange = 1.5;
// ------- DEFINITIONS END ----------

// --------- VARS BEGIN ------------
bool airControlState = false;
bool airControlManual = false;
// --------- VARS END --------------

// Setup a oneWire instance to communicate with any OneWire devices
OneWire oneWireInner(innerSensorPin);
OneWire oneWireOuter(outerSensorPin);

// Pass our oneWire reference to Dallas Temperature sensor 
DallasTemperature innerSensor(&oneWireInner);
DallasTemperature outerSensor(&oneWireOuter);

float innerTemperature = 0;
float outerTemperature = 0;

void setup() 
{

  [...]

  // ---------- OUTPUT PIN BEGIN ----------
  pinMode(airControlPin, OUTPUT);
  digitalWrite(airControlPin, LOW);
  
  pinMode(manualControlPin, OUTPUT);
  digitalWrite(manualControlPin, LOW);

  pinMode(morsePin, OUTPUT);
  digitalWrite(morsePin, HIGH);
  // ---------- OUTPUT PIN END ------------

  // Start the DS18B20 sensor
  innerSensor.begin();
  outerSensor.begin();

  delay(100);
  // ---------- WEBSERVER BEGIN -----
  server.on("/", handleConnect);
  server.on("/auto", handleAuto);
  server.on("/manual", handleManual);
  server.on("/on", handleOn);
  server.on("/off", handleOff);
  server.on("/jsonstatus", sendJsonStatus);
  server.on("/jsondoaction", jsonDoAct);
  server.on("/jsondoaction", HTTP_POST, jsonDoAct);
  server.begin();
  Serial.println("HTTP server started");
  // ---------- WEBSERVER END -------

  delay(100);
  runner.init();
  
  runner.addTask(scheduleRead);
  scheduleRead.enable();

  runner.addTask(scheduleControl);
  scheduleControl.enable();

  runner.addTask(scheduleCheckFreeRam);
  scheduleCheckFreeRam.enable();



  digitalWrite(morsePin, LOW);
  
}


void loop() 
{
  unsigned long currentMillis = millis();
  // if WiFi is down, try reconnecting every CHECK_WIFI_TIME seconds
  if ((WiFi.status() != WL_CONNECTED) && (currentMillis - previousMillis >=interval))
    {
    Serial.print(millis());
    Serial.println("Reconnecting to WiFi...");
    WiFi.disconnect();
    WiFi.reconnect();
    previousMillis = currentMillis;
  }
  
  server.handleClient();
  runner.execute();
  handleButtons();
  //delay(5000);

}

void checkFreeRam()
{
  if (ESP.getFreeHeap() < 60000)
  {
    ESP.restart();
  }

  Serial.println("Free RAM: " + String(ESP.getFreeHeap()));
}

void handleConnect()
{
  Serial.println("WebServer: /");
  server.send(200, "text/html", SendHTML("")); 
}

void handleButtons()
{
  int manualControlSwitchSelect = digitalRead(manualControlSwitchPin);
  int manualOnOffSwitchSelect = digitalRead(manualOnOffSwitchPin);
  
  if (manualControlSwitchSelect == 1 || manualOnOffSwitchSelect == 1)
  {
    delay(20);
    manualControlSwitchSelect = digitalRead(manualControlSwitchPin);
    manualOnOffSwitchSelect = digitalRead(manualOnOffSwitchPin);
    delay(20);

    if (manualControlSwitchSelect == 1)
    {
      digitalWrite(morsePin, HIGH);
      if (airControlManual == true)
      {
        disableManual();
      }
      else
      {
        enableManual();
      }
      delay(200);
      digitalWrite(morsePin, LOW);
    }
    else if (manualOnOffSwitchSelect == 1)
    {
      if (airControlManual == true)
      {
        digitalWrite(morsePin, HIGH);
        if (airControlState == true)
        {
          disableAir();
        }
        else
        {
          enableAir();
        }
        delay(200);
        digitalWrite(morsePin, LOW);
      }
      else
      {
        digitalWrite(morsePin, HIGH);
        delay(20);
        digitalWrite(morsePin, LOW);
        delay(20);
        digitalWrite(morsePin, HIGH);
        delay(20);
        digitalWrite(morsePin, LOW);
        delay(20);
        digitalWrite(morsePin, HIGH);
        delay(20);
        digitalWrite(morsePin, LOW);
        delay(20);
        digitalWrite(morsePin, HIGH);
        delay(20);
        digitalWrite(morsePin, LOW);
        delay(20);
        digitalWrite(morsePin, HIGH);
        delay(20);
        digitalWrite(morsePin, LOW);
        delay(20);
      }
    }

    delay(400);
  }

}

void handleAuto()
{
  Serial.println("WebServer: /auto");

  disableManual();

  server.sendHeader("Location", String("/"), true);
  server.send (302, "text/plain", "");
}

void handleManual()
{
  Serial.println("WebServer: /manual");

  enableManual();

  server.sendHeader("Location", String("/"), true);
  server.send (302, "text/plain", "");
}

void handleOn()
{
  Serial.println("WebServer: /on");

  enableAir();

  server.sendHeader("Location", String("/"), true);
  server.send (302, "text/plain", "");
}

void handleOff()
{
  Serial.println("WebServer: /off");

  disableAir();

  server.sendHeader("Location", String("/"), true);
  server.send (302, "text/plain", "");
}

void sendJsonStatus()
{
  Serial.println("JSON Status");

  String jsonBuffer = createStatusJson("");

  server.send(200, "application/json", jsonBuffer); 
}

void jsonDoAct()
{
  Serial.println("JSON Act");

  if (server.hasArg("plain") == false) 
  {
    //handle error here
  }
  
  String body = server.arg("plain");
  Serial.println(body);
  deserializeJson(jsonDocument, body);

  if (jsonDocument.containsKey("manual") == true)
  {
    int manual = jsonDocument["manual"];
    Serial.println("JSON Act - Manual Set to " + (String)manual);
    if (manual == 1)
    {
      enableManual();
    }
    else if (manual == 0)
    {
      disableManual();
    }
  }

  if (airControlManual == true)
  {
    if (jsonDocument.containsKey("fanenabled") == true)
    {
      int fanenabled = jsonDocument["fanenabled"];
      Serial.println("JSON Act - fanenabled Set to " + (String)fanenabled);
      if (fanenabled == 1)
      {
        enableAir();
      }
      else if (fanenabled == 0)
      {
        disableAir();
      }
    }
  }

  controlAir();

  String jsonBuffer = createStatusJson("");

  server.send(200, "application/json", jsonBuffer); 
}

String createStatusJson(String statusIn) 
{
  char jsonBuffer[1024];
  
  if (statusIn == "")
  {
    statusIn = "OK";
  }
  
  jsonDocument.clear();  
  jsonDocument["state"] = statusIn;
  jsonDocument["innertemp"] = innerTemperature;
  jsonDocument["outertemp"] = outerTemperature;

  if (airControlState == true)
  {
    jsonDocument["fanenabled"] = true;
  }
  else
  {
    jsonDocument["fanenabled"] = false;
  }

  if (airControlManual == true)
  {
    jsonDocument["manual"] = true;
  }
  else
  {
    jsonDocument["manual"] = false;
  }

  int freeram = ESP.getFreeHeap();
  jsonDocument["freeram"] = freeram;

  serializeJson(jsonDocument, jsonBuffer);

  return String(jsonBuffer);
}


String SendHTML(String context){
  String ptr = "<!DOCTYPE html> <html>\n";
  ptr +="<head><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, user-scalable=no\">\n";
  ptr +="<title>ESP32 Air Control</title>\n";
  ptr +="<style>html { font-family: Arial; display: inline-block; margin: 0px auto; text-align: center;}\n";
  ptr +="body{margin-top: 50px;} h1 {color: #444444;margin: 50px auto 30px;} h3 {color: #444444;margin-bottom: 50px;}\n";
  ptr +=".button {display: block;width: 80px;background-color: #3498db;border: none;color: white;padding: 13px 30px;text-decoration: none;font-size: 25px;margin: 0px auto 35px;cursor: pointer;border-radius: 4px;}\n";
  ptr +="a, a:active, { color: " + linkColorNormal + "; text-decoration: underline; }\n";
  ptr +="a:visited { color: " + linkColorVisited + "; text-decoration: underline; }\n";
  ptr +="p {font-size: 14px;color: #888;margin-bottom: 10px;}\n";
  ptr +="</style>\n";
  ptr +="</head>\n";
  ptr +="<body>\n";
  ptr +="<h1>ESP32 Air Control</h1>\n";

  if (airControlManual == true)
  {
    ptr +="<h2>Manual Control - ";
  }
  else
  {
    ptr +="<h2>Auto Control - ";
  }

  if (airControlState == true)
  {
    ptr +="Air: On";
  }
  else
  {
    ptr +="Air: Off";
  }
  
  ptr += "</h2>";
  

  ptr +="<p>Inner Temp: " + String(innerTemperature) + " &deg;C</p>";
  ptr +="<p>Outer Temp: " + String(outerTemperature) + " &deg;C</p>";
  
  ptr +="<br><br>";

  if (airControlManual == true)
  {
    ptr +="<a href=\"/auto\">Enable Auto Control</a><br><br>";

    String textToAdd = "<a href=\"/on\">Enable Air</a>";
    String lineBreak = "<br><br>";
    
    if (airControlState == true)
    {
      ptr += activeMarkerBegin + textToAdd + activeMarkerEnd + lineBreak;
    }
    else
    {
      ptr += textToAdd + lineBreak;
    }

    textToAdd = "<a href=\"/off\">Disable Air</a>";
    if (airControlState == false)
    {
      ptr += activeMarkerBegin + textToAdd + activeMarkerEnd + lineBreak;
    }
    else
    {
      ptr += textToAdd + lineBreak;
    }
  }
  else
  {
    ptr +="<a href=\"/manual\">Disable Auto Control</a><br><br>";
  }

  ptr +="</body>\n";
  ptr +="</html>\n";
  return ptr;
}

void controlAir()
{
  if (innerTemperature == -127 || outerTemperature == -127)
  {
    errorBlink();
  }
  else
  {
    if (airControlManual == false)
    {
      if (innerTemperature <= tempInnerOpti && outerTemperature >= (innerTemperature+tempDiffToChange)) // innen zu kalt
      {
        enableAir();
      }
      else if (innerTemperature >= tempInnerOpti && (outerTemperature+tempDiffToChange) <= innerTemperature) // innen zu heiß
      {
        enableAir();
      }
      else
      {
        disableAir();
      }
    }
  }
}



void enableManual()
{
  digitalWrite(manualControlPin, HIGH);
  airControlManual = true;
}

void disableManual()
{
  digitalWrite(manualControlPin, LOW);
  airControlManual = false;
}

void enableAir()
{
  Serial.println("Enable Air!");
  digitalWrite(airControlPin, HIGH);
  airControlState = true;
}

void disableAir()
{
  Serial.println("Disable Air!");
  digitalWrite(airControlPin, LOW);
  airControlState = false;
}

void errorBlink()
{
  digitalWrite(morsePin, HIGH);
  delay(200);
  digitalWrite(morsePin, LOW);
  delay(200);
  digitalWrite(morsePin, HIGH);
  delay(200);
  digitalWrite(morsePin, LOW);
  delay(200);
  digitalWrite(morsePin, HIGH);
  delay(200);
  digitalWrite(morsePin, LOW);
}

void readTemperatures()
{
  innerSensor.requestTemperatures(); 
  innerTemperature = innerSensor.getTempCByIndex(0);
  Serial.print("InnerTemp: ");
  Serial.print(innerTemperature);
  Serial.println("ºC");
  
  outerSensor.requestTemperatures(); 
  outerTemperature = outerSensor.getTempCByIndex(0);
  Serial.print("OuterTemp: ");
  Serial.print(outerTemperature);
  Serial.println("ºC");
}

Hinweis

Dieser Artikel dokumentiert lediglich meinen Aufbau. Für den Nachbau, die Nutzung einzelner Komponenten, die Platinen und den gesamten Inhalt wird die Haftung in jeglicher Form ausgeschlossen.

Categories
ESP32 Mast Raspberry Pi

APRS iGate und WEB-SDR mit Antennen Trennrelais + Gewitterwarner

Seit längerem betreibe ich ein iGate, um APRS-Nachrichten ins Internet ein zu speisen. Ein Bekannter brachte mich auf die Idee auch ein WEBSDR ins Leben zu rufen. Ich habe dann mein iGate verbessert, sowie ein WEBSDR mit automatischen Antennen Trennrelais mit Gewitterwarner gebaut. Dieses Projekt möchte ich hier beschreiben.

WEBSDR

Empfangene APRS Nachrichten

Empfänger

Für mein APRS iGate nutze ich ein NESDR SMART Stick von NooElec. Da die Geräte relativ heiß werden, habe ich mir Gedanken gemacht, wie man diese kühlen könnte. Nach der Demontage des Sticks stellte sich heraus, dass dort noch etwas Platz ist, um eine Schraube zu platzieren, somit habe ich mich entschieden einen Kühlkörper mit Wärmeleitpaste zu befestigen.

SDR Stick mit Kühlkörper

Der Stick ist dann zwar etwas kühler, aber immer noch etwas zu warm für meinen Geschmack. Zudem wollte ich den dazugehörigen Raspberry Pi 3 noch kühlen.

Also entschied ich mich dazu ein Gehäuse mit Lüfter für das WEBSDR, sowie das iGate zu drucken. Als Material habe ich ABS gewählt.

3D Druck Video – https://youtu.be/mmZAZ56P3KI
3D Druck Video – https://youtu.be/5ix60GqjWIw

Im Gehäuse ist Platz für den Raspberry Pi, den Lüfter, sowie den SDR Stick. Der Lüfter, sowie der Raspberry Pi werden mit Schrauben befestigt, der Stick ist nur eingesteckt.

Offenes Gehäuse von oben
Offenes Gehäuse von vorne

Der Raspberry Pi wird über das Mainboard mit Strom versorgt, da ich dort, wo ich das iGate, sowie den WEBSDR aufbauen möchte, bereits eine 5V Spannungsversorgung vorhanden ist. Der Lüfter wird vom Mainboard des Raspberry Pi mit Strom versorgt. Mit Deckel bleiben Raspberry Pi, sowie der SDR Stick relativ kühl. Hier ist allerdings Vorsicht geboten, dass man einen Lüfter wählt, der nicht zu viel Strom verbraucht. Die GPIO PINS des Raspberry Pi dürfen nicht überlastet werden. Der Lüfter ist an +5V sowie GND angeschlossen.

Gewitterwarner

Als Gewitterwarner habe ich den GW1 von ELV gewählt, da dieser über Ausgänge verfügt, die je nach Warn-Lage auf GND gezogen werden. Wenn also Entwarnung aktiv ist, ist der Entwarnungs-PIN des Warners auf 0V und der Rest nicht. Deshalb benötigen wir den Pull UP der Eingänge auf der ESP32 Platine.

Für den Gewitterwarner habe ich wieder ein 3D Druck Gehäuse gezeichnet, aus dem man den Gewitterwarner für dass Ändern der Einstellungen relativ einfach entnehmen kann.

3D Druck Video – https://youtu.be/571t1ngLhKA
Gewitterwarner GW1 mit Halterung

Mast

Für die Antennen habe ich einen Mast montiert und diesen separat geerdet, um Störungen aus dem Haus etwas zu minimieren.

Staberder
Erdung Wandmontage
Mast Wandhalterung
Montierter Mast mit X-50 für das APRS iGate, sowie Big Wheel für das WEBSDR

Trennrelais

Damit ich nicht jedes Mal manuell den Antenneneingang des iGate bzw. WEBSDR entfernen muss, habe ich mich dazu entschlossen mithilfe zweier Relais, dem Gewitterwarner GW1 von ELV, sowie einem ESP32 (mit 30 Pins) eine Abschaltelektronik zu entwickeln.

Die Relais sind so angeschlossen, dass sie bei fehlender Spannung die Antenne auf Masse schließen. So ist auch bei Stromausfall sichergestellt, dass die Antenneneingänge nicht geschaltet sind.

Die Platine mit den Relais sind in einem Aluminiumgehäuse eingebaut. Die Antennenkabel werden direkt auf der Platine aufgelötet, um unnötige Übergangswiderstände zu vermeiden. Die Platine mit dem ESP32 ist außen am Alugehäuse angebracht. Im Aluminiumgehäuse habe ich eine kleine 3D gedruckte Plattform eingeschraubt, auf der die Platine aufgeschraubt wird, um Kurzschlüsse mit dem Aluminiumgehäuse zu vermeiden. Das Aluminiumgehäuse hat die Maße: 100 x 160 x 81 mm, ein kleines hätte mit Sicherheit auch gut funktioniert, in größeren Gehäusen lässt es sich besser arbeiten.

3D Druck Video – https://youtu.be/Imv8_j-KqeY
3D Druck Video – https://youtu.be/MYv9ufbvdCk
Aluminiumgehäuse mit Platinen Halter, sowie ESP32 Gehäuse

Die Schaltung wird mit 12V versorgt. Die Platinen habe ich zur Verfügung gestellt, sie können direkt bei Aisler bestellt werden:

Relais Platine (Afillate): https://aisler.net/p/OUAABZDY

ESP32 Platine (Affilate): https://aisler.net/p/FCIBCYRQ

Bauteile – Relais Platine

  • Relais Sockel: OMRON P2R-087P
  • Relais1, Relais 2: OMRON G2R-2-S-DC12(S)
  • D1, D2: 1N 4007 Gleichrichterdiode

Bauteile – ESP32 Platine

Platine mit aufgelöteten Bauteilen
  • C1, C2, C3: 10nF
  • D1, D2: LED Rot
  • F1, F2: IRF5210 MOSFET
  • L1: 10µH
  • R1, R2: 1kO
  • R3, R4, R5, R6, R7, R9, R10, R11: 5.6kO
  • R8: 2.2kO
  • R12, R13: 10kO
  • T1, T2, T3 Transistoren: BC547C
  • DC-Wandler: RECOM R-78E50-05
  • Bei Verwendung des GW1 Gewitterwarners muss bei PULL die 3V3 Verbindung gebrückt werden. NIEMALS; GND-Brücke und 3V3-Brücke gleichzeitig brücken!

Montage

Als Coax-Kabel habe ich Aircell 7 verwendet. Montiert sieht das offene Gehäuse mit den Relais wie folgt aus.

Montierte Abschaltelektronik ohne Gewitterwarner
Komplette Elektronik mit APRS iGate und WEBSDR
Detailbild der Installation

Software

Der ESP32 ist so programmiert, dass er bei Warnung die Relais abschaltet, damit wird die Antenne mit Masse verbunden und die Verbindung zum SDR/iGate unterbrochen. Sobald der Gewitterwarner Entwarnung gibt, werden die Relais wieder geschaltet, sodass der Kontakt zur Antenne gegeben ist.

Außerdem ist es möglich vor Ort über die Taster in den manuellen Modus um zu schalten und dann über die beiden anderen Taster die Relais an- und auszuschalten. Damit wird die Automatik, die bei Gewitterwarnung die Relais abschaltet temporär deaktiviert. Diese Funktion steht auch auf einem Webinterface zur Verfügung, da sich der ESP32 ins lokale WLAN einklinkt.

Durch das JSON Interface der Software, lässt sich dieser auch in Smart Home Lösungen integrieren.

Der Code befindet sich unten auf der Seite.

Probleme

Zu Anfang hat Das APRS-iGate kaum Signale empfangen.

Um dem Problem auf die Spur zu kommen, habe ich verschiedene Einstellungen für GAIN bei pymultimonaprs probiert, die jedoch nicht zielführend waren. Also habe ich mir das APRS Signal als Ton auf meinen Rechner gestreamt.

Der Stream kann nach der Installation einiger Komponenten wie folgt geöffnet werden. Möchte man den Stream als root ausführen muss der Erste Befehl einmalig ausgeführt werden:

sed -i 's/geteuid/getppid/' /usr/bin/vlc

rtl_fm -g80 -f 144.8M -M fm -s 22050 - | sox -traw -r22050 -es -b16 -c1 -V1 - -t flac - | cvlc - --sout "#standard{access=http,mux=ogg,dst=RASPBERRYPI-IP-ADRESSE:8080/audio.ogg}"

Der Stream lässt sich beispielsweise über den VLC Player anhören.

Dort habe ich festgestellt, dass ein sehr hoher Rauschanteil vorliegt.

Mit Klappferriten an den LAN- und Strom-Leitungen ließ sich das Problem relativ schnell und gut beheben.

Code

#include "WiFi.h"
#include <WebServer.h>
#include <ArduinoJson.h>


// --------- WIFI -----------
#define STASSID    "WLAN-NAME"
#define STAPSK     "WLAN-PWD"
#define DEVICENAME "ESP32-NETZWERK-NAME";
unsigned long previousMillis = 0;
unsigned long interval = 30000;
// --------- END WIFI -------

const char* ssid = STASSID;
const char* password = STAPSK;
const char* deviceName = DEVICENAME;

WebServer server(80);
StaticJsonDocument<250> jsonDocument;
char jsonBuffer[250];

// ------- PINS ----------
static int relais01 = 32; // D32
static int relais02 = 33; // D33
static int manualLed = 26; // D26

static int warnungPin = 34; // D34
static int blitzPin = 35; // D35
static int entwarnungPin = 23; // D23

static int manualSwitch = 19; // D19
static int onOffSwitch1 = 18; // D18
static int onOffSwitch2 = 4; // D4

static int i2cSdaPin = 21;
static int i2cSclPin = 22;
// ------- END PINS ----------

// ------- DEFINITIONS ----------
static int morsePin = 2;
static int selfCheckPinDuration = 500;
static String relais1name = "Relais Antenna 1";
static String relaisConnected = "Connected";
static String relais2name = "Relais Antenna 2";
static String linkColorNormal = "#2321B0";
static String linkColorVisited = "#2321B0";
// ------- END DEFINITIONS ----------

// ------- VARS ----------
int warnungActive = 0;
int entwarnungActive = 1;
int manualEnabled = 0;
int relais01state = 0;
int relais02state = 0;
int blitzCount = 0;
// ------- END VARS ----------

// Blitz Counter
void IRAM_ATTR eventBlitz()
{
  detachInterrupt(blitzPin);
  blitzCount++;
  if (blitzCount > 100000)
  {
    blitzCount = 0;
  }
  delay(100);
  attachInterrupt(blitzPin, eventBlitz, FALLING);
}

void setup() 
{
  Serial.begin(115200); 
  while(!Serial){} // Waiting for serial connection
  Serial.println();

  delay(2000);

  // WIFI
  Serial.print("Wifi: ");
  Serial.println(ssid);
  
  Serial.println("turn wifi off...");
  WiFi.mode(WIFI_OFF);

  delay(200);
  WiFi.mode(WIFI_STA);
  delay(250);
  
  WiFi.config(INADDR_NONE, INADDR_NONE, INADDR_NONE);    
  delay(200); 
  Serial.println("setting hostname");
  WiFi.setHostname(deviceName);
  delay(200); 
  Serial.println("Connecting to WiFi..");
  WiFi.begin(ssid, password);
  delay(200); 

  Serial.println("Makeing Morse PIN inits");
  pinMode(morsePin, OUTPUT);
  digitalWrite(morsePin, HIGH);
  
  int iCounter = 0;
  int iMax = 30;
  while (WiFi.status() != WL_CONNECTED && iCounter < iMax)
  {
    digitalWrite(morsePin, LOW);
    delay(500);
    digitalWrite(morsePin, HIGH);
    delay(500);
    Serial.print(".");
    iCounter++;
  }
  
  Serial.println("");
  Serial.println(WiFi.localIP()); 
  delay(1000);

  Serial.println("Makeing Output PIN inits");
  pinMode(relais01, OUTPUT);
  digitalWrite(relais01, LOW);
  pinMode(relais02, OUTPUT);
  digitalWrite(relais02, LOW);

  pinMode(manualLed, OUTPUT);

  Serial.println("Makeing Input PIN inits");
  pinMode(manualSwitch, INPUT);
  pinMode(onOffSwitch1, INPUT);
  pinMode(onOffSwitch2, INPUT);
  pinMode(warnungPin, INPUT);
  pinMode(blitzPin, INPUT);
  attachInterrupt(blitzPin, eventBlitz, FALLING);
  pinMode(entwarnungPin, INPUT);

  Serial.println("Selfcheck...");
  
  Serial.println("relais01...");
  setSwitch(1, 1);
  delay(selfCheckPinDuration);
  setSwitch(1, 0);

  Serial.println("relais02...");
  setSwitch(2, 1);
  delay(selfCheckPinDuration);
  setSwitch(2, 0);

  digitalWrite(morsePin, LOW);

  delay(selfCheckPinDuration);
  digitalWrite(morsePin, HIGH);
  delay(selfCheckPinDuration);
  digitalWrite(morsePin, LOW);
 
  delay(selfCheckPinDuration);

  server.on("/", handleConnect);
  server.on("/alloff", handleAllOff);
  server.on("/allon", handleAllOn);
  server.on("/switch1on", handleSwitch1on);
  server.on("/switch1off", handleSwitch1off);
  server.on("/switch2on", handleSwitch2on);
  server.on("/switch2off", handleSwitch2off);
  server.on("/manual", handleSwitchManual);
  server.on("/auto", handleSwitchAuto);
  server.on("/jsonstatus", sendJsonStatus);
  server.on("/jsondoaction", jsonDoAct);
  server.on("/jsondoaction", HTTP_POST, jsonDoAct);  
  server.onNotFound(handleConnect);
  server.begin();
  Serial.println("HTTP server started");
}

void loop() 
{
  server.handleClient();
  handleButtons();
  handleWarner();

  unsigned long currentMillis = millis();
  // WLAN reconnect, falls Verbindung verloren wurde
  if ((WiFi.status() != WL_CONNECTED) && (currentMillis - previousMillis >=interval))
  {
    Serial.print(millis());
    Serial.println("Reconnecting to WiFi...");
    WiFi.disconnect();
    WiFi.reconnect();
    previousMillis = currentMillis;
  }
}

// Allgemeiner Button Handler
void handleButtons()
{
  handleButtonManual();
  if (manualEnabled == 1)
  {
    handleButtonSwitch1();
    handleButtonSwitch2();
  }
}

// Manual/Auto Button Handler
void handleButtonManual()
{
  int manualSelect = digitalRead(manualSwitch);

  if (manualSelect == 1)
  {
    digitalWrite(morsePin, HIGH);
    delay(20);
    manualSelect = digitalRead(manualSwitch);
    delay(100);

    if (manualSelect == 1)
    {
      if (manualEnabled == 1)
      {
        setManual(0);
      }
      else if (manualEnabled == 0)
      {
        setManual(1);
      }
      delay(20);
    }

    digitalWrite(morsePin, LOW);

    delay(200);
  }
}

// Relais 1 Button Handler (wird nur geprüft, wenn manueller Modus aktiviert ist)
void handleButtonSwitch1()
{
  int manualSelect = digitalRead(onOffSwitch1);

  if (manualSelect == 1)
  {
    digitalWrite(morsePin, HIGH);
    delay(20);
    manualSelect = digitalRead(onOffSwitch1);
    delay(100);

    if (manualSelect == 1)
    {
      if (manualEnabled == 1)
      {
        if (relais01state == 0)
        {
          setSwitch(1, 1);
        }
        else if (relais01state == 1)
        {
          setSwitch(1, 0);
        }
      }
      delay(20);
    }

    digitalWrite(morsePin, LOW);

    delay(200);
  }
}

// Relais 2 Button Handler (wird nur geprüft, wenn manueller Modus aktiviert ist)
void handleButtonSwitch2()
{
  int manualSelect = digitalRead(onOffSwitch2);

  if (manualSelect == 1)
  {
    digitalWrite(morsePin, HIGH);
    delay(20);
    manualSelect = digitalRead(onOffSwitch2);
    delay(100);

    if (manualSelect == 1)
    {
      if (manualEnabled == 1)
      {
        if (relais02state == 0)
        {
          setSwitch(2, 1);
        }
        else if (relais02state == 1)
        {
          setSwitch(2, 0);
        }
      }
      delay(20);
    }

    digitalWrite(morsePin, LOW);

    delay(200);
  }
}

// HTTP Seiten Handler
void handleConnect()
{
  Serial.println("Connect");
  server.send(200, "text/html", SendHTML("")); 
}

// HTTP Seiten Handler - Not AUS
void handleAllOff()
{
  Serial.println("Connect");
  Serial.println("EMERGENCY ALL OFF");
  
  setManual(1);
  setSwitch(1, 0);
  setSwitch(2, 0);

  server.sendHeader("Location", String("/"), true);
  server.send (302, "text/plain", "");
  //server.send(200, "text/html", SendHTML("")); 
}

// HTTP Seiten Handler - Not AN
void handleAllOn()
{
  Serial.println("Connect");
  Serial.println("EMERGENCY ALL ON");

  setManual(1);
  setSwitch(1, 1);
  setSwitch(2, 1);

  server.sendHeader("Location", String("/"), true);
  server.send (302, "text/plain", "");
  //server.send(200, "text/html", SendHTML("")); 
}

// HTTP Seiten Handler - Relais 1 AN
void handleSwitch1on()
{
  Serial.println("Connect");
  Serial.println("Switch 1 ON");

  if (manualEnabled == 1)
  {
    setSwitch(1, 1);
  }
  
  server.sendHeader("Location", String("/"), true);
  server.send (302, "text/plain", "");
  //server.send(200, "text/html", SendHTML("")); 
}

// HTTP Seiten Handler - Relais 1 AUS
void handleSwitch1off()
{
  Serial.println("Connect");
  Serial.println("Switch 1 OFF");

  if (manualEnabled == 1)
  {
    setSwitch(1, 0);
  }
  
  server.sendHeader("Location", String("/"), true);
  server.send (302, "text/plain", "");
  //server.send(200, "text/html", SendHTML("")); 
}

// HTTP Seiten Handler - Relais 2 AN
void handleSwitch2on()
{
  Serial.println("Connect");
  Serial.println("Switch 1 ON");
  
  if (manualEnabled == 1)
  {
    setSwitch(2, 1);
  }

  server.sendHeader("Location", String("/"), true);
  server.send (302, "text/plain", "");
  //server.send(200, "text/html", SendHTML("")); 
}

// HTTP Seiten Handler - Relais 2 AUS
void handleSwitch2off()
{
  Serial.println("Connect");
  Serial.println("Switch 1 OFF");

  if (manualEnabled == 1)
  {
    setSwitch(2, 0);
  }
  
  server.sendHeader("Location", String("/"), true);
  server.send (302, "text/plain", "");
  //server.send(200, "text/html", SendHTML("")); 
}

// HTTP Seiten Handler - Manuell AN
void handleSwitchManual()
{
  Serial.println("Connect");
  Serial.println("Switch MANUAL");

  setManual(1);
  
  server.sendHeader("Location", String("/"), true);
  server.send (302, "text/plain", "");
  //server.send(200, "text/html", SendHTML("")); 
}

// HTTP Seiten Handler - Manuell AUS - Automatik Modus AN
void handleSwitchAuto()
{
  Serial.println("Connect");
  Serial.println("Switch AUTO");

  setManual(0);

  server.sendHeader("Location", String("/"), true);
  server.send (302, "text/plain", "");
  //server.send(200, "text/html", SendHTML("")); 
}

// Gewitterwarner Input PINS verarbeiten und entsprechende Maßnahmen im Automatik Modus ergreifen
void handleWarner()
{
  int warnPinVal = digitalRead(warnungPin);
  int entwarnungPinVal = digitalRead(entwarnungPin);
  
  if (warnPinVal == 0) // warnung aktiv
  {
    warnungActive = 1;
  }
  else if (warnPinVal == 1) // warnung INaktiv
  {
    warnungActive = 0;
  }

  if (entwarnungPinVal == 0) // entwarnung aktiv
  {
    entwarnungActive = 1;
  }
  else if (entwarnungPinVal == 1) // entwarnung INaktiv
  {
    entwarnungActive = 0;
  }

  if (manualEnabled == 0)
  {
    if (warnungActive == 1)
    {
        setSwitch(1, 0);
        setSwitch(2, 0);
    }
    else if (entwarnungActive == 1)
    {
        setSwitch(1, 1);
        setSwitch(2, 1);
    }
  }

  if (entwarnungActive == 1 && warnungActive == 0)
  {
    blitzCount = 0;
  }
}

// Setzt den Manuell bzw Automatik Modus
void setManual(int iMan)
{
  if (iMan == 0)
  {
    digitalWrite(manualLed, LOW);
    manualEnabled = iMan;
  }
  else if (iMan == 1)
  {
    digitalWrite(manualLed, HIGH);
    manualEnabled = iMan;
  }
}

// Setzt relais Zustand (es erfolgt keine Prüfung auf Manuell
void setSwitch(int iNum, int iState)
{
  if (iNum == 1)
  {
    if (iState == 0)
    {
      digitalWrite(relais01, LOW);
      relais01state = iState;
    }
    else if (iState == 1)
    {
      digitalWrite(relais01, HIGH);
      relais01state = iState;
    }
  }
  else if (iNum == 2)
  {
    if (iState == 0)
    {
      digitalWrite(relais02, LOW);
      relais02state = iState;
    }
    else if (iState == 1)
    {
      digitalWrite(relais02, HIGH);
      relais02state = iState;
    }
  }
}

// HTML Seite senden
String SendHTML(String context)
{
  String ptr = "<!DOCTYPE html> <html>\n";
  ptr +="<head><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, user-scalable=no\">\n";
  ptr +="<title>ESP32 RPI Protect</title>\n";
  ptr +="<style>html { font-family: Arial; display: inline-block; margin: 0px auto; text-align: center;}\n";
  ptr +="body{margin-top: 50px;} h1 {color: #444444;margin: 50px auto 30px;} h3 {color: #444444;margin-bottom: 50px;}\n";
  ptr +=".button {display: block;width: 80px;background-color: #3498db;border: none;color: white;padding: 13px 30px;text-decoration: none;font-size: 25px;margin: 0px auto 35px;cursor: pointer;border-radius: 4px;}\n";
  ptr +="a, a:active, { color: " + linkColorNormal + "; text-decoration: underline; }\n";
  ptr +="a:visited { color: " + linkColorVisited + "; text-decoration: underline; }\n";
  ptr +="p {font-size: 14px;color: #888;margin-bottom: 10px;}\n";
  ptr +="</style>\n";
  ptr +="</head>\n";
  ptr +="<body>\n";
  ptr +="<h1>ESP32 RPI Protect</h1>\n";

  String lineBreak = "<br><br>";

  ptr += "Warning: ";
  if (warnungActive == 1)
  {
    ptr += "<b>ON</b><br>";
  }
  else if (warnungActive == 0)
  {
    ptr += "<b>OFF</b><br>";
  }

  ptr += "All-Clear: ";
  if (entwarnungActive == 1)
  {
    ptr += "<b>ON</b><br>";
  }
  else if (entwarnungActive == 0)
  {
    ptr += "<b>OFF</b><br>";
  }

  ptr += "Lightning Count: <b>" + String(blitzCount) + "</b><br>";
  
  ptr += lineBreak;

  ptr += "Mode: ";
  if (manualEnabled == 0)
  {
    ptr += "<b><a href=\"/manual\">AUTO</a></b>" + lineBreak;
  }
  else
  {
    ptr += "<b><a href=\"/auto\">MANUAL</a></b>" + lineBreak;
  }

  ptr += "Relais 1: ";
  if (manualEnabled == 1)
  {
    if (relais01state == 0)
    {
      ptr += "<b><a href=\"/switch1on\">OFF</a></b>" + lineBreak;
    }
    else if (relais01state == 1)
    {
      ptr += "<b><a href=\"/switch1off\">ON</a></b>" + lineBreak;
    }
  }
  else
  {
    if (relais01state == 0)
    {
      ptr += "OFF" + lineBreak;
    }
    else if (relais01state == 1)
    {
      ptr += "ON" + lineBreak;
    }
  }

  ptr += "Relais 2: ";
  if (manualEnabled == 1)
  {
    if (relais02state == 0)
    {
      ptr += "<b><a href=\"/switch2on\">OFF</a></b>" + lineBreak;
    }
    else if (relais02state == 1)
    {
      ptr += "<b><a href=\"/switch2off\">ON</a></b>" + lineBreak;
    }
  }
  else
  {
    if (relais02state == 0)
    {
      ptr += "OFF" + lineBreak;
    }
    else if (relais02state == 1)
    {
      ptr += "ON" + lineBreak;
    }
  }

  ptr += "EMERGENCY <a href=\"/allon\"><b>ALL ON</b></a>" + lineBreak;
  ptr += "EMERGENCY <a href=\"/alloff\"><b>ALL OFF</b></a>" + lineBreak;

  ptr +="</body>\n";
  ptr +="</html>\n";
  return ptr;
}

// -------------------- JSON API --------------------------
// Beispielsweise für Home Assitant oder andere Komponenten

void sendJsonStatus()
{
  Serial.println("JSON Status");

  createStatusJson("");

  server.send(200, "application/json", jsonBuffer); 
}

void jsonDoAct()
{
  Serial.println("JSON Act");

  if (server.hasArg("plain") == false) 
  {
    //handle error here
  }
  
  String body = server.arg("plain");
  Serial.println(body);
  deserializeJson(jsonDocument, body);

  if (jsonDocument.containsKey("manual") == true)
  {
    int manual = jsonDocument["manual"];
    Serial.println("JSON Act - Manual Set to " + (String)manual);
    if (manual == 1)
    {
      setManual(1);
    }
    else if (manual == 0)
    {
      setManual(0);
    }
  }

  if (manualEnabled == 1)
  {
    if (jsonDocument.containsKey("relais1") == true)
    {
      int relais1 = jsonDocument["relais1"];
      Serial.println("JSON Act - relais1 Set to " + (String)relais1);
      if (relais1 == 1)
      {
        setSwitch(1, 1);
      }
      else if (relais1 == 0)
      {
        setSwitch(1, 0);
      }
    }
    
    if (jsonDocument.containsKey("relais2") == true)
    {
      int relais2 = jsonDocument["relais2"];
      Serial.println("JSON Act - relais2 Set to " + (String)relais2);
      if (relais2 == 1)
      {
        setSwitch(2, 1);
      }
      else if (relais2 == 0)
      {
        setSwitch(2, 0);
      }
    }
  }

  createStatusJson("");

  server.send(200, "application/json", jsonBuffer); 
}

// -------------------- JSON HELPER --------------------------

void createStatusJson(String statusIn) 
{
  if (statusIn == "")
  {
    statusIn = "OK";
  }
  jsonDocument.clear();  
  jsonDocument["state"] = statusIn;

  if (relais01state == 1)
  {
    jsonDocument["relais1"] = true;
  }
  else
  {
    jsonDocument["relais1"] = false;
  }

  if (relais02state == 1)
  {
    jsonDocument["relais2"] = true;
  }
  else
  {
    jsonDocument["relais2"] = false;
  }

  if (manualEnabled == true)
  {
    jsonDocument["manual"] = true;
  }
  else
  {
    jsonDocument["manual"] = false;
  }

  if (warnungActive == 1)
  {
    jsonDocument["warnung"] = true;
  }
  else
  {
    jsonDocument["warnung"] = false;
  }

  if (entwarnungActive == 1)
  {
    jsonDocument["entwarnung"] = true;
  }
  else
  {
    jsonDocument["entwarnung"] = false;
  }

  jsonDocument["blitzcount"] = blitzCount;
  serializeJson(jsonDocument, jsonBuffer);
}

Hinweis

Dieser Artikel dokumentiert lediglich meinen Aufbau. Für den Nachbau, die Nutzung einzelner Komponenten, die Platinen und den gesamten Inhalt wird die Haftung in jeglicher Form ausgeschlossen.