Lernprojekt "Smarte Lichterkettensteuerung mit ESP32"

Das Ziel

Ich beschäftige mich gerade mit ESP32 Programmierung und Firmwareerstellung mittels Espressif-IDF in der VS Code IDE.

Das meiste ginge vermutlich auch mit der Arduino IDE, aber mir gefällt der Ansatz von Espressif ein FreeRTOS als Basis zu verwenden und das man wirklich die volle Kontrolle über den Chips hat und sich nicht mit dem begnügen muss was Arduino so davon umsetzt… obs am Ende wirklich mehr bringt weiss ich noch nicht.

Hier geht es also mehr um Software als um Hardware :wink: Dennoch spielt die HW eine wichtige Rolle. Ich habe so div. ESP32s hier zum experimentieren. Mein Ansatz wäre jetzt mit möglichst wenig Hardware und Platzbedarf eine gute Alternative zu den allseits bekannten, batteriebetriebenen LED Lichterketten mit Timer zu bauen, damit das alles einen gewissen Sinn macht. Mich nervt deren Steuerung nämlich kollosal weil die nie dann leuchten wenn ich es für sinnvoll halte und durch den 6h Timer den praktisch alle diese Geräte haben, viel zu früh wieder erlischen. Auch hätte ich gern eine kraftvollere Energiequelle wie einen Li-Ion Akku.

Mir kommt es also vor allem auf die Energieeffizienz an, das also der Akku möglichst lange hält. Dazu sollte die Steuerung, vor allem im AUS-Modus möglichst wenig bis keinen Strom verbrauchen. Die LED-Lichtstränge werden entweder mit parallel geschalteten LEDs oder zwei parallelen, gegenpoligen Strängen betrieben. Mir reicht die einpolige Variante, also kürzere Ketten bis so ca. 30 LEDs. So ein Strang verträgt gut 100 mA, leuchtet aber auch mit 50 mA noch ordentlich, immerhin dann bei doppelte Laufzeit.

Lassen wir erstmal das Batteriemanagement (Laden, entladen, überwachung) beiseite und beschäftigen uns mit der Basis.

Gesteuert werden soll alles über einen Taster, da nehm ich gleich den BOOT-Taster der schon auf den meisten Boards drauf ist. Als Board würde ich eines wählen was möglichst wenig Komponenten mit drauf hat, auch aus Platzgründen, z.b. nen ESP32-H2 Super-Mini.

Für den ersten Wurf benötige ich viele der ESP Peripherie nicht, die sollte man also deaktivieren. Aber schaun mer mal…

1 „Gefällt mir“

Die Funktion (aka “Fachliche Spezifikation”)

Zunächst braucht es mal eine fachliche Anforderung die es zu erfüllen gilt und da fange ich mal mir der Steuerlogik an. Die stelle ich mir so vor:

  • Taste kurz drücken → Moduswechsel von AUS nach TIMER
  • Ohne weitere Taste wird der Timer abgespielt, wie programmiert, ansonsten bei kurzem Tastendruck wechsel in den AUS Modus. Zur visuellen Kontrolle soll die LED dazu runtergedimmt werden bevor sie aus geht (fade-off).
  • Taste doppelt drücken im TIMER Modus bei ausgeschalteter LED programmiert die Startzeit. Diese wird im TIMER alle 24h wiederholt, die LEDs gehen an. Als Quittierung der Programmierung blinken die LEDs 3x kurz.
  • Taste doppelt drücken im TIMER bei eingeschalteter LED (=Startzeit wurde programmiert) programmiert die Ausschaltzeit. Auch diese wiederholt sich alle 24h und wird mit 3x blinken quittiert.
  • Es können zwei (mehrere) Ein/Aus-Zeiten programmiert werden, z.b. für morgens und abends indem die o.g. Logik wiederholt wird.
  • Drückt man die Taste bei eingeschalteter LED länger als 2 Sekunden, startet der DIMMER Modus. In diesem fährt die Helligkeit rauf oder runter, je nach aktueller Richtung, bis zum Endpunkt. Das erreichen des Endpunktes wird durch 1x blinken angezeigt. Nach weiteren 2 Sekunden kehrt sich dann die Dimm-Richtung um. Ein Durchlauf (hell-dunkel oder dunkel-hell) soll 5 Sekunden dauern. Durch loslassen der Taste wird der Helligkeitswert für den jeweiligen Timer gespeichert.
  • Durch drücken der Taste im AUS Modus für länger als 5 Sekunden werden alle Timer gelöscht. Das wird durch 5x blinken der LEDs signalisiert.

Ich hatte auch noch eine andere Idee, die Schaltzeiten durch den Taster einzugeben, also 8x drücken für 8 Uhr, dann die Zehner und Einer Minuten. Aber das wurde dann doch zu kompliziert.

Ein Building-Block ist also definitiv die Tastensteuerung und vermutlich sowas wie eine einfache State-Machine Logik.

1 „Gefällt mir“

Willst du nur eine “lineare LED Kette” oder eine WS2812 er Kette ansprechen?

Die LED Ansteuerung

Ein weiterer Building-Block ist die Lichtsteuerung. Hier möchte ich PWM einsetzen um die Helligkeit zu steuern. Dazu gibt es im ESP32 entsprechende Kanäle/Controller

Dort steht das man zuerst den Timer und damit die Taktfrequenz für den PWM mit der SDK Funktion

ledc_timer_config()

einstellt und dann damit den LED Kanal beschickt

ledc_channel_config()

Am Channel stellt man dann den Duty-Cycle ein und damit die Helligkeit.

Relevant ist hier die Kombination von Taktquelle und ggf. späterem Sleep-Modus, da dieser ja verschiedene Komponenten anschaltet. Im Deep-Sleep ist quasi alles aus und somit würde auch keine PWM mehr erzeugt werden und die LED ging aus. Da müsste man also einen externen PWM Controller verwenden, was wieder Zusatzkosten verursacht und die Komplexität erhöht.

Um nun das Puls/Pausenverhältnis (Duty-Cycle) und damit den Helligkeitswert der LEDs zu ändern setzt man zuerst den neuen Wert mit

ledc_set_duty()

und aktiviert diese Änderung dann anschließend mittels:

ledc_update_duty()

Der Duty-Wert hängt von der gewählten Timer-Auflsösung ab. Die kleinste ist wohl 8-Bit und kann somit Werte von 0-255 aufnehmen. Bei 10 oder 13 Bit entsprechend höhere Werte.

Der LEDC verfügt hardwareseitig noch über Fader, die könnten für Ein-Ausschalteffekte mal interessant werden, aktuell lasse ich sie aber außen vor.

Um die Funktionen im eigenen Programm zu importieren muss man natürlich die Header-Datei (driver/ledc.h) in der main.c einbinden:

#include <stdio.h>
#include "driver/ledc.h"

void app_main(void)
{

}

Sowie die Abhängigkeit zu “esp_driver_ledc” in der main/CMakeList.txt hinterlegen:

idf_component_register(
    SRCS
        "main.c"
    INCLUDE_DIRS
        "."
   REQUIRES 
        esp_driver_ledc
)

Basteln wir uns eine Initialisierungsroutine, die zunächst den Timer einrichtet, siehe auch:

  • ledc_timer_config_t

Und anschließend den LEDC Kanal definiert:

  • ledc_channel_config_t
static void led_init(void);

...

static void led_init(void) {

    // configure timer for LEDC
    ledc_timer_config_t ledc_timer = {
        .speed_mode = LEDC_LOW_SPEED_MODE,
        .timer_num = LEDC_TIMER_0,
        .duty_resolution = LEDC_TIMER_8_BIT,
        .freq_hz = 1000,
        .clk_cfg = LEDC_USE_RC_FAST_CLK  // RC_FAST_CLK läuft im Light Sleep weiter!
    };
    ledc_timer_config(&ledc_timer);
    
    // configure channel for LEDC
    ledc_channel_config_t ledc_channel = {
        .speed_mode = LEDC_LOW_SPEED_MODE,
        .channel = LEDC_CHANNEL_0,
        .timer_sel = LEDC_TIMER_0,
        .intr_type = LEDC_INTR_DISABLE,
        .gpio_num = GPIO_NUM_13,
        .duty = 0,
        .hpoint = 0
    };
    ledc_channel_config(&ledc_channel);
}
  • speed_mode muss immer LEDC_LOW_SPEED_MODE sein
  • timer_num => hier nutze ich einfach den ersten, TIMER_0
  • clk_cfg => hier nutze ich als Taktquelle den RTC damit auch im Light-Sleep das PWM weiter läuft
  • duty_resolution => aufgrund der gewählten clk_cfg vom RTC (~7 MHz) ist die Auflösung begrenzt. Ich nutze 8 Bit, das reicht aus meiner Sicht völlig.
  • freq_hz => 1 kHz ist ok für diese Kombi das nichts flackert
  • gpio_num => das ist der IO-Port. Ich nutze zu Demo-Zwecken einfach die blaue On-Board LED
  • duty => das ist der initiale Wert der Helligkeit. Die LED soll anfangs erstmal aus sein

Nun brauchen wir eine Sub die uns die gewünschte Helligkeit einstellt. Dazu mache ich einfach folgendes:

static void led_set_brightness(uint8_t brightness);

...

static void led_set_brightness(uint8_t brightness) {
    // Set duty cycle (0-255 for 8-bit resolution)
    uint32_t duty = (brightness * 255) / 255; // Scale brightness to duty cycle
    ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, duty);
    ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0);
}

und kann nun irgendwo im Code die Helligkeit auf einen gewünschten Wert einstellen:

led_set_brightness(50); // 50% Helligkeit

Diese Zeile in der main_app() eingebaut bewirkt schon einen Effekt an der blauen LED.

Beachten muss ich dabei natürlich die Auflösung meines Timers die ja den Regelbereich/Wertebereich des Duty-Cycle bestimmt. Um die Helligkeit in Prozent einstellen zu können habe ich einfach den angegebenen Prozentwert in einen Duty-Cycle Wert umgerechnet. So ist es wesentlich einfacher und universeller die Zielhelligkeit zu bestimmen, auch wenn sich z.B. die Auflösung des Timers mal ändern sollte. Das sollte man später alles mal relativ zum gewählten Timer machen.

Um die LED blinken zu lassen (Quittierung div. Funktionen) könnte man einfach die Helligkeit abwechselnd auf 0% und 100% einstellen. Wenn man aber den Blink-Effekt mit dem eingestellten Helligkeitswert wünscht muss man diesen kennen. Aktuell speichere ich den noch nirgendwo, also wird es Zeit eine Variable dafür zu definieren:

uint8_t led_brightness = 0; // Helligkeit von 0 bis 255

Diese wird dann von der Funktion led_set_brightness() bedient:

static void led_set_brightness(uint8_t brightness) {
    ...
    led_brightness = brightness;
}

In diesem Zuge möchte ich auch weitere Hilfsfunktion hinzufügen, wie:

  • “led_blink()” => welche die Anzahl, sowie die Ein- und Ausschaltdauer der LED in ms und auch die gewünschte AN-Helligkeit erhalten kann.
  • “led_fade_off()” / “led_fade_on()” => Soll die LED innerhalb der angegebenen Zeit langsam aus- bzw. angehen lassen
  • “led_pulse()” => das gleiche wie led_blink() jedoch nicht mit hartem ein/aus sondern fadeoff/fadein

Zur Realisierung benötige ich eine delay-Funktion, was aber dazu führen wird das der Code nichts anderes zulässt während er läuft. Für den Anfang reicht das

vTaskDelay(pdMS_TO_TICKS( <DELAY_IN_MILLISECONDS_UINT32> ));

Das ist aber nicht gerade RTOS-Like… ich muss mich also auch noch mit dem Multitasking von FreeRTOS beschäftigen um herauszufinden wie ich so einen Task im “Hintergrund” starte und ggf. auch beenden kann wenn ein anderer Task die Kontrolle übernehmen soll.

1 „Gefällt mir“

Normale LEDs von klassischen Lichterketten.

Projektumgebung einrichten

Ich starte VS Code, gehe auf das ESP-IDF Plugin, wähle “New project”, wähle das IDF-Framework:

image

dann die Projektdaten eingeben (Zielort, Prozessortyp, Board und COM-Port:

Nach Klick auf “Choose Template” bleibe ich auf “Extension” (nicht “ESP-IDF”) und wähle “template-app”:

Anschließend erstellt mir VS Code ein leeres Projektverzeichnis mit den notwendigsten Dateien:

├── CMakeLists.txt
├── main
│   ├── CMakeLists.txt
│   └── main.c
└── README.md

Zuletzt möchte VS Code noch wissen welchen Compiler es nutzen kann, ich lass ihn das selbst suchen:

Er macht anschließend einen Compile, wohl um zu prüfen ob alles passt, aber halt mit einer leeren app_main() aus main/main.c (die Datei bezieht er aus dem idf_component_register() SRCS aus main/CMakeList.txt)

Nun kann es losgehen!

P.S.: Ja, ich weiß das es massig Beispiele im Netz und auch in der ESP-IDF (Templates) gibt, die lohnt es sich auf jeden Fall anzusehen. Aber ich persönlich komme besser klar ganz unten, ganz unverschnörkelt anzufangen um zu verstehen wozu was gebraucht wird.

Ich publiziere den Code in seinen Ständen auf Github damit alle was davon haben:

1 „Gefällt mir“

Die Tastensteuerung

Ich versuche alles mit einer Taste zu steuern, somit muss ich bestimmte “Gesten” erkennen, also

  1. kurze Tastendrücke
  2. lange Tastendrücke
  3. mehrfache Tastendrücke hintereinander

Natürlich muss die Taste selbst auch noch entprellt werden. Hier muss man sich also etwas überlegen was man universell nutzen kann, denn man weiß ja nie wo man welches Verhalten wie abfragen will und vor allem weiß man nicht was der User so macht.

Um den Fall 3. vom 1. zu unterscheiden hilft eigentlich nur die Zeit zwischen zwei Tastendrücken zu definieren, wann diese als Einzel und wann als Mehrfachdruck erkannt werden sollen. Die Schwierigkeit wird sein das man dann eben schnelle Einzeldrücke nicht mehr als solche erkennen kann.

Im Programm wird es so sein das unterschiedliche Routinen zu unterschiedlichen Zeitpunkten auf Tasten warten oder mit ihnen interagieren. Der Taster ist also wohl immer mit einem Modus bzw. einer Funktion darin “verbunden”, also im AUS-Modus oder im TIMER-Modus, als Dimmer oder Schaltzeitprogrammierer.

Das Batteriemanagement

Ein Li-Ion Akku, wie ich ihn verwenden würde, hat besondere Eigenschaften und Anforderungen um zum einen möglichst lang Energie zu liefern, zum anderen aber auch eine lange Gesamtlebensdauer aufzuweisen.

Einen Li-Ion Akku lädt man z.B. nie voll auf, auch Tiefendladung mag dieser gar nicht. Beides geht sehr zu lasten der Lebensdauer, ebenso wie zu schnelles laden. Das alles muss also in der Balance gehalten werden. Auch die Energieentnahme will kontrolliert sein, spielt in unserem Fall aber keine Rolle weil wir in Summe definitiv unter 100 mA liegen werden.

Nach oben hin soll der Akku am besten bei erreichen von 80% Gesamtladung (0.8 C) mit dem Ladevorgang stoppen. Bei Energieentnahme sollte man bei erreichen von 20% Restladung aufhören und wieder laden. In der Praxis dürften 10%/90% auch noch ok sein. Aber wie erreicht man das?

Ladelogik

Da die Lichterkette ja batteriebetrieben ist hat man logischerweise keine Steckdose in der Nähe, somit fällt ein dauerhaftes, selbstständiges laden flach, macht keinen Sinn. Also lädt man die Akkus ausserhalb der Batteriebox. Dazu sollte der Akku leicht wechselbar sein. Der Gehäusetyp 18650 bietet so eine Möglichkeit. Ansonsten gibt es noch Akku-Packs die am Ende einen JST Stecker haben. Auch sowas ist auswechselbar. Alternativ könnte man auch die Box mit einem Zwischenstecker zur Lichterkette ausstatten (habe ich sowieso gemacht, weil es die Installation erleichtert) und die Box dann abnehmen und an ein Ladenetzteil anschließen. Oder man wechselt zuvor geladene Akkus aus. Das wäre mein Weg, weil ich so die geringste Downtime habe und die Akkus schöööön langsam laden kann, was ihnen wiederum ein längeres Leben ermöglicht. Auch das ich dazu ein gutes Ladegerät verwende und kein Steckernetzteil dürfte für die Akkus wie Wellness sein :wink:

Entladelogik

Bei der Energieentnahme wird es schon etwas komplizierter. Hier hilft eigentlich nur die Restkapazität der Batterie durch Messverfahren zu bestimmen. Selbst wenn man einen Energiemesser hätte ist nicht gesichert das die Batterie immer gleiche Energie liefert. Die Aufgabe ist also den unteren Totpunkt zu messen. Zum einen sollte die Batteriespannung niemals unter 2,5 V gehen, aber schon unter 3,0 V sollte man aufpassen. Die Spannungskurve eines Li-Ion Akkus ist aber über weite Teile sehr stabil und sackt dann schnell ab.

Also nur über Spannungsmessung wird es vermutlich ungenau, aber hier hätte ich gern Eure Meinungen zum Thema! Vielleicht habt ihr ja gute Ideen dazu?

Das Energiemanagement

Grundlagen und Logiken um den Stromverbrauch der Steuerung auf ein Minimum zu reduzieren. Das beginnt mit der Frage welche Komponenten des ESP32-H2 bei meinem Projekt überhaupt eine Rolle spielen, bzw. abgeschaltet werden können um Energie zu sparen. Auch das bypassen oder entlöten von Bauteilen könnte dazu beitragen, wie z.B. Spannungsregler, Pull-Up Widerstände, etc. Das ist ein bisschen wie bei der Apollo-13 Mission :wink:

Fangen wir damit an was der Chip und dann was das Board so alles hat. Laut Datenblatt ist der ESP32-H2 in folgende Bereiche unterteilt:

Der Funktionsblock “Wireless Digital Circuits” würde aus meiner Sicht erstmal wegfallen. Vom “RF” benötige ich aber wenigstens den PLL und Fast RC Oscillator, ggf. auch den XTAL.

Der “Security”-Teil ist für mich nicht relevant und bei den “Periphals” benötige ich auch nur bestimmte Dinge, weiß aber nicht ob ich die einzeln zu/abschalten kann. Diese Frage wird im Datenblatt unter “Power Management Unit” beantwortet. Dort sind zunächst die Low-Power Modes beschrieben:

  • Active mode => alles an (24 - 140 mA)
  • Modem-sleep mode => CPU mit verminderter Taktfrequenz, Funkverbindungen werden periodisch aus/an geschaltet um eine Verbindung zu erhalten (3 - 17 mA)
  • Light-sleep mode => Die CPU wird angehalten und einige Periphals werden abgeschaltet. Der Wakeup kann über Modem-Aktivität, RTC Timer oder Hardware-Interrupts erfolgen. In diesem Modus kann eine Funkverbindung aktiv bleiben. (0,025 - 0,085 mA)
  • Deep-sleep mode => alles aus, nur das “LP System” läuft noch (0,007 mA)
  • Power off => CHIP_EN ist auf LOW (0,001 mA)

Die Aufstellungen in “5.5 Current Consumption” des Datenblattes zeigen das der Stromverbrauch natürlich auch von der CPU-Taktfrequenz abhängig ist. Aufpassen muss man natürlich auch, das man nichts abschaltet bzw. abgeschaltet wird, was benötigt wird zur Funktion.

Aus dem Datenblatt weiß ich bereits das ich nicht unter den Light-Sleep mode komme, da ich wenigstens das PWM benötige. Aufwecken kann in diesem Modus per Tastendruck (HW-Interupt) oder Timer (Zeit bis zum nächsten programmierten Schaltzeitpunkt) erfolgen. Ich kann also maximal bis runter auf 85 µA.

Das Super-Mini Board welches ich einsetze hat ganz wenig “nutzlose” Peripherie. Leider gibt es kein offizielles Schaltplan-Dokument dazu.

Das Coding

Programmierung hat für mich auch viel mit Ästhetik zu tun, es ist schon eine Kunst die gewünschten Funktionen so in Code zu packen das sie zum einen verständlich und gut lesbar bleiben, aber auch effizient sind. Ich bin kein Softwareentwickler und habe das auch nicht gelernt, verstehe aber ein paar Sprachen und habe kleinere Projekte damit schon umgesetzt.

Es gibt immer wieder interessante Tricks und Kniffe zu beachten, daher dachte ich das ich dem ein eigenes Kapitel schenke um auch hierzu den Dialog mit Euch zu suchen und zu lernen.

Gerade das Thema RTOS ist für mich Neuland, bislang war ich eher im linearen programmieren zuhause. Die Funktionen am Ende sind linear, aber man muss die Parallelität berücksichtigen und das wirkt sogar in meinem kleinen Mini-Projekt bereits.

Hier möchte ich auf die Dinge eingehen die ggf. speziell von der Espressif-IDF bereitgestellt werden um das Programm sicher, universell und effizient zu machen.

Verzögerungen (delays)

Hier bietet das ESP-IDF über FreeRTOS (als Teil der FreeRTOS Task Control) das “vTaskDelay()” an. Im Headerfile “task.h” wird auch ein Macro “pdMS_TO_TICKS” bereitgestellt um die “Ticks” ganz einfach in Millisekunden umrechnen zu können:

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

vTaskDelay(pdMS_TO_TICKS(<MILLISEKUNDEN>));

In jedem Fall wäre der vTaskDelay() allen anderen delay-Varianten vorzuziehen, denn diese benutzen in der Regel CPU-blockierende Prozeduren und somit ist der ESP während einer manchmal längeren Wartezeit nicht in der Lage auf externe Ereignisse zu reagieren (z.B. Tastendrücke) und würde diese auch “verschlucken”. Das allein ist schon ein Riesen Vorteil eines RTOS.

Logmeldungen

Beim entwickeln von Software sind Logmeldungen unerlässlich um den Programmfluss zu verstehen und das Problemfeld einzukreisen. ESP-IDF bietet hier die Möglichkeit sehr einfach Logmeldungen über USB an ein Terminalprogramm auszugeben. Dabei werden die typischen Loglevel unterstützt.

// setze Loglevel im Build prozess (WICHTIG: VOR dem include der header!)
#define LOG_LOCAL_LEVEL ESP_LOG_INFO

...

#include "esp_log.h"

...

// name des Moduls (kann auch "*" sein)
static const char* TAG = "MyModule";

// setze loglevel zur Laufzeit
void app_main(void)
{
    esp_log_level_set(TAG, ESP_LOG_ERROR);
    ...
}

Verwendung im Code dann einfach mit:

ESP_LOGI(TAG, "Wrong parameter value found: %.1f%%", value); // info level
ESP_LOGW(TAG, ...); // warning level
ESP_LOGE(TAG, ...); // error level
ESP_LOGV(TAG, ...); // verbose level
ESP_LOGD(TAG, ...); // debug level

Fehlerbehandlung (Exceptions)

Beim entwickeln der Software hat man oft nur die gewünschte Funktionsweise im Blick, aber der Code wird erst stabil und sicher wenn man auch mit allen möglichen Fehlern rechnet. Diese können extern (z.B. mangelnde Stromversorgung) als auch intern (z.B. nicht behandelte Kondition oder Funktionsergebnis) sein. Lasst uns mal einen Blick darauf werfen das ESP-IDF hier bietet.

Die State-Machine

Meine Steuerung soll ja verschiedene Betriebsmodus haben, wie:

  1. OFF
  2. TIMER RUN
  3. TIMER PROGRAM ON
  4. TIMER PROGRAM OFF
  5. LED DIM UP
  6. LED DIM DOWN
  7. LED FADE UP
  8. LED FADE DOWN
  9. STORE BRIGHTNESS

Man könnte das noch weiter runterbrechen, aber grob wäre es das. Diese Modus sollen mit bestimmten Tasten-”Gesten” erreicht werden, ich brauche also so eine Art State-Machine um immer zu wissen wo ich gerade bin im Code und was als nächstes passieren kann oder soll.

… statt “Gesten”-Steuerung würde ich das eher mit Morse-Code angehen :upside_down_face:

1 „Gefällt mir“

Die Persistenz (Bootfeste Parameter)

Ein ganz wichtiger Punkt ist natürlich auch das man bei einem Batteriewechsel die ganzen Einstellungen (Helligkeits-/Zeitwerte) nicht verliert. Dazu gibt es ja den Flash-Speicher oder auch NVS (Non-Volatile-Storage). In der ESP-IDF wirden in der NVS-API verschiedene Funktionen dafür bereitgestellt (nvs_open(), nvs_set_*(), nvs_get_*(), nvs_commit(), …). Das ESP-IDF verwendet den Flash-Speicher wie ein Filesystem, man muss also zunächst eine Parameterdatei öffnen oder erstellen und kann dann die Werte darin verarbeiten oder ändern. Um die Änderung auf die “Disk” zu bekommen, muss man die Änderungen committen.

Um diese nutzen zu können müssen wir zunächst die Komponente über “main/CMakeLists.txt” anfordern:

idf_component_register(
   ...
   REQUIRES 
        ...
        nvs_flash
)

Danach können wir die Header-Files includen:

#include "nvs_flash.h"
#include "nvs.h"

Nun kann man z.B. den zuletzt eingestellten Helligkeitswert der LED im Flash speichern. Hierzu initialisiert man NVS und erstellt ein File-Handle:

    nvs_flash_init();
    nvs_handle_t handle;
    nvs_open("storage", NVS_READWRITE, &handle);

Nun kann man nach Herzenslust Werte dort hineinschreiben oder zurücklesen:

// *_u8() weil led_brightness in meinem Fall ein uint8_t ist
    nvs_get_u8(handle, "led_brightness", &led_brightness);
// wert setzen
    nvs_set_u8(handle, "led_brightness", led_brightness);

All das geschieht noch im Speicher, man muss also zum wegschreiben final noch den commit aufrufen:

nvs_commit(handle);

Soweit, so einfach. Das Konzept wirft nur einige Probleme auf. Zum einen sollte man die Flash-Speicher nicht unnötig oft überschreiben da Flash nur eine recht begrenzte Anzahl von Schreibzyklen kann. Zum anderen dauert der Schreibvorgang und der Code wartet in dieser Zeit. Und zu guter Letzt kostet das schreiben von Flash auch verhältnismäßig viel Energie (Strom).

Man muss also bei der Verwendung von NVS selbst darauf achten das man die Werte die im Flash stehen nur dann zurückschreibt wenn sich diese geändert haben und stabil sind und nicht bei jedem Zwischenwert (z.B. beim dimmen nicht jeden, sondern nur den Endwert nach Beedingung des Vorgangs) und evtl. auch nur dann wenn sich der neue Wert vom bereits gespeicherten unterscheidet.

Das schreit alles nach einer Routine die im Hintergrund läuft, als RTOS Task und die Änderung dieser Variablen überwacht. Andernfalls muss man im Code immer wieder darauf achten.

Leider kann ich den Ursprungsbeitrag nicht mehr editieren, diese Option geht nach einem Tag oder so verloren. Daher muss ich hier im Thread anknüpfen was die Sache etwas unleserlicher machen wird. Tut mir leid, evtl. kann/möchte der Mod etwas dagegen tun?

Auf jeden Fall möchte ich für die Tastensteuerung, entgegen meiner sonstigen Vorgehensweise nun mal was neues ins Spiel bringen: Eine IDF-Komponente. Das ist anders als ein Include/Require aber irgendwie doch ähnlich, ich weiß noch nicht warum es beides gibt…

Für Buttons gibt es die “espressif/button” (IoT Button) Komponente. Um die einbinden zu können muss man sie erst runterladen/installieren. Dazu erstellt man die Datei main/idf_component.yml und gibt in ihr an welche Zusatzkomponenten das eigene Projekt benötigt. Diese sollen nicht Bestandteil des Git-Repositories werden, sondern vom Nutzer des das Repo auschecked beim compilieren runterladen werden. In der YAML gibt man die Abhängigkeit so an:

dependencies:
  espressif/button:
    version: "^3.3.0"

Wenn man nun den Compile neu laufen lässt (“Schraubenschlüssel” Icon im VS Code) lädt er diese Komponente runter und fügt sie im Unterverzeichnis managed_components in das Projekt ein. Man sollte unbedingt die .gitignore Datei entsprechend anpassen:

managed_components/
build/
sdkconfig
sdkconfig.old

Um die Component im Code zu nutzen muss man wie gehabt die entsprechende Headerdatei includen:

#include "iot_button.h"

Die iot_button Komponente wird über den Datentyp button_config_t konfiguriert:

button_config_t btn_cfg = {
    .type = BUTTON_TYPE_GPIO,
    .long_press_time = 800,      // 800ms für langen Druck
    .short_press_time = 180,     // Schwellenwert für kurzen Druck
    .gpio_button_config = {
        .gpio_num = GPIO_NUM_9,  // BOOT-button
        .active_level = 0,       // 0 = Active LOW (GPIO mit Pull-Up)
    },
};

button_handle_t btn = iot_button_create(&btn_cfg);

Das zurückgelieferte Handle dient anschließend im Code dazu die Routinen zu definieren die auf eine oder mehrere der folgenden Events reagieren soll:

BUTTON_PRESS_DOWN - Button wird gedrückt
BUTTON_PRESS_UP - Button wird losgelassen
BUTTON_SINGLE_CLICK - Einfacher Klick
BUTTON_DOUBLE_CLICK - Doppelklick
BUTTON_MULTIPLE_CLICK - Mehrfachklick (5 oder mehr!)
BUTTON_PRESS_REPEAT - Button wird mehrfach gedrückt (Event wird pro Klick ausgelöst)
BUTTON_PRESS_REPEAT_DONE - Wird am Ende einer Mehrfachauslösung aufgerufen

Zusätzlich noch Events für den aktuellen Zustand des Buttons:

BUTTON_LONG_PRESS_START - Langer Druck beginnt
BUTTON_LONG_PRESS_HOLD - Langer Druck wird gehalten
BUTTON_LONG_PRESS_UP - Langer Druck endet

Hier ein Beispiel für Single- und Double-Klick:

static void on_single_click(void *arg, void *usr_data)
{
    ESP_LOGI(TAG, "EINFACH-KLICK erkannt!");
}
static void on_double_click(void *arg, void *usr_data)
{
    ESP_LOGI(TAG, "DOPPEL-KLICK erkannt!");
}

iot_button_register_cb(btn, BUTTON_SINGLE_CLICK, on_single_click, NULL);
iot_button_register_cb(btn, BUTTON_DOUBLE_CLICK, on_double_click, NULL);

Im Prinzip wirklich einfach. Das schöne an der Component ist auch, das als Button nicht nur ein GPIO (üblicher Taster) sondern auch andere Eingabegeräte wie z.B. ein Touchsensor oder ein ADC dienen kann. Der eigene Code ändert sich dadurch nicht.

Eine kleine Schwachstelle hat die Component jedoch. Möchte man neben Doppelklicks auch 3fach oder 4fach Klicks erkennen, kommt man so erstmal nicht weiter. Der Event BUTTON_MULTIPLE_CLICK wird erst ab 5 oder mehr Klicks ausgelöst, es bleibt also eine Lücke für 3er und 4er Klicks offen.

Um die zu schließen könnte man mit BUTTON_PRESS_REPEAT arbeiten, aber hierbei bekommt man bei jedem Tastendruck eine Auslösung. Drückt man also 3x hintereinander wird der ISR 3x aufgerufen, jeweils mit der bisherigen Anzahl als zusammenhängend (Timing-Abhängig) erkannter Clicks.

Die Lösung hierbei ist den Event BUTTON_PRESS_REPEAT_DONE zu nutzen. Dieser wird nur einmal am Ende einer Klick-Serie aufgerufen.

Das ganze läuft übrigens asynchron im Hintergrund, was für meine Anwendung schonmal goldrichtig ist. Damit wäre ein weiteres Puzzlestück gefunden!

Soo, heute widme ich mich mal der State Machine. Die besteht ja aus definierten Zuständen (status) und Übergängen (transitions), sowie trigger die einen Übergang auslösen. Der Action Code wird dann in Übergangshandlern ausgeführt. Soweit die Theorie. Ich muss also die Zustände definieren und den aktuellen Status in eine Variable packen. Dazu definiere ich einen Enum-Typ mit den möglichen Zuständen und erzeuge eine Variable von diesem Typ mit dem Initialzustand:

typedef enum {
    STATE_OFF = 0,
    STATE_TIMER_RUN,
    STATE_TIMER_PROGRAM_ON,
    STATE_TIMER_PROGRAM_OFF,
    STATE_TIMER_ERASE
} state_machine_state_t;

static state_machine_state_t current_state = STATE_OFF;

Der enum-Typ ist im Kern ein Integer dessen Werte benamt sind. Es genügt den ersten mit einem Wert “0” zu deklarieren, dann folgen die anderen aufsteigend. Im Code weist man, fragt man die Zustände dann über ihre Namen ab, was den Code sehr gut lesbar macht.

Die Ereignisse die die SM triggern definiere ich genauso mit einem enum:

typedef enum {
    BTN_EVENT_SINGLE_CLICK,
    BTN_EVENT_DOUBLE_CLICK,
    BTN_EVENT_LONG_CLICK,
    BTN_EVENT_BUTTON_HOLD,
    BTN_EVENT_BUTTON_RELEASED
} state_machine_button_event_t;

Da ich später die Timer im NVS sichere werde ich auch den aktuellen Zustand dort ablegen um ihn nach einem Batteriewechsel wieder einzunehmen, das soll hier aber erstmal keine Rolle spielen.

Zunächst will ich den Zustand der Statemachine über Tastendrücke wechseln, später kommen noch weitere Events wie Timer hinzu. Hierzu erstelle ich einen Event-Callback für Tastendrücke welche die SM verarbeiten soll:

void state_machine_handle_button_event(state_machine_button_event_t event) {
    ...
}

Bevor ich dort Code einsetze zunächst wie diese Routine genutzt wird. Dies geschieht durch die Button-Callback-Handler, welche bei bestimmten Tastendrücken vom iot_button Code aufgerufen werden:

static void on_press_repeat_done(void *arg, void *usr_data)
{
    button_handle_t btn = (button_handle_t)arg;

    int click_count = iot_button_get_repeat(btn);
    
    switch (click_count) {
        case 1:
            state_machine_handle_button_event(BTN_EVENT_SINGLE_CLICK);
            break;
            
        case 2:
            state_machine_handle_button_event(BTN_EVENT_DOUBLE_CLICK);
            break;
        ...
    }
    ...
}

Ich glaube man kann gut erkennen wie das funktioniert? Der iot_button erkennt die Tastendruckart und ruft dafür den entsprechenden Handler-Code auf. Dieser wiederum leitet das Ereignis an den State-Machine Button-Handler weiter. In dieser, bereits oben angerissener Funktion wird dann entsprechend dem aktuellen Zustand und dem Button-Event ein neuer Zustand eingenommen:

void state_machine_handle_button_event(state_machine_button_event_t event)
{
    switch(current_state)
    {
        case STATE_OFF:
            if (event == BTN_EVENT_SINGLE_CLICK) {
                state_machine_transition(STATE_TIMER_RUN);
            }
            else if (event == BTN_EVENT_LONG_CLICK) {
                state_machine_transition(STATE_TIMER_ERASE);
            }
            break;
            
        case STATE_TIMER_RUN:
            if (event == BTN_EVENT_SINGLE_CLICK) {
                state_machine_transition(STATE_OFF);
            }
            else if (event == BTN_EVENT_DOUBLE_CLICK) {
                if (led_brightness == 0) {
                    state_machine_transition(STATE_TIMER_PROGRAM_ON);
                }
            }
            break;

        default:
            break;
    }
}

Ich denke man erkennt gut wie die Zustandslogik arbeitet. Die SM ist in einem bestimmten Zustand, z.B. STATE_OFF. Ein Tastendruck führt wieder zu diesem Eventhandler und löst dann die Funktion state_machine_transition(STATE_TIMER_RUN) aus. Der Übergangshandler sorgt dann dafür das der gewünschte Zielzustand erreicht wird. Im Handler kann das aber auch misslingen weil evtl. Randbedingungen nicht passen, dann verbleibt die SM in ihrem Zustand. Der Code dafür schaut dann so aus:

void state_machine_transition(state_machine_state_t new_state)
{
    if (new_state >= STATE_MAX) {
        ESP_LOGE(TAG, "Invalid state: %d", new_state);
        return;
    }
    
    state_machine_state_t old_state = current_state;
    current_state = new_state;
    ESP_LOGI(TAG, "State transition: %s -> %s", state_names[old_state], state_names[new_state]);
    
    switch(new_state) {
        case STATE_OFF:
            on_state_enter_off(old_state);
            break;
        case STATE_TIMER_RUN:
            on_state_enter_timer_run(old_state);
            break;
        case STATE_TIMER_PROGRAM_ON:
            on_state_enter_timer_program_on(old_state);
            break;
        case STATE_TIMER_PROGRAM_OFF:
            on_state_enter_timer_program_off(old_state);
            break;
        case STATE_TIMER_ERASE:
            on_state_enter_timer_erase(old_state);
            break;

        // ... weitere Cases

        default:
            ESP_LOGE(TAG, "Unhandled state transistion: %d -> %d", old_state, new_state);
            return;
    }
}

Hier gibt es also für jeden definierten Zustandsübergang (von X, nach Y) einen Handler. Diese Handler müssen natürlich erstellt werden und enthalten dann den Nutzcode:

static void on_state_enter_off(state_machine_state_t from_state) {
    if (from_state == STATE_TIMER_PROGRAM_ON) {
        ESP_LOGI(TAG, "Timer programming ABORTED!");
        led_blink(5, 100);
        led_set_brightness(0);
    }
    else {
        ESP_LOGI(TAG, "Entering OFF state - turning off LED");
        led_set_brightness(0);
    }
}

static void on_state_enter_timer_run(state_machine_state_t from_state) {
    if (from_state == STATE_TIMER_PROGRAM_ON) {
        ESP_LOGI(TAG, "Timer programming COMPLETED!");
        led_blink(3, 100);
    }
    else {
        ESP_LOGI(TAG, "Entering TIMER_RUN state");
    }
}

static void on_state_enter_timer_program_on(state_machine_state_t from_state) {
    ESP_LOGI(TAG, "Program TIMER START TIME");
    led_fade_on(2000, 100);
    led_blink(3, 250);
}

static void on_state_enter_timer_program_off(state_machine_state_t from_state) {
    ESP_LOGI(TAG, "Program TIMER STOP TIME");
    led_fade_off(2000);
}

static void on_state_enter_timer_erase(state_machine_state_t from_state) {
    ESP_LOGI(TAG, "All timers erased!");
    led_blink(5, 250);
}

In den Handlern kann man auf Spezialfälle die keinen eigenen Zustand haben eingehen, indem man der vorherigen Zustand mitgeliefert bekommt. In der STATE_OFF Behandlung kann ich so zwischen einem regulären Wechsel in diesen Zustand und einem Timer-Programmier-Abbruch unterscheiden.

Mehr ist es nicht und der Automat läuft! “Please insert coin” :wink:

Jetzt fehlt nicht mehr viel bis zum fertigen Produkt, ich fasse kurz zusammen was ich nun alles gelernt habe:

  1. Ein VS Code Projekt erstellen, Libraryfunktionen einbetten (includes), Zusatzkomponenten hinzufügen (idf_components)
  2. Logmeldungen auf die Console ausgeben
  3. FreeRTOS nutzen
  4. LEDs per PWM steuern
  5. NVS Flash-Speicher für bootfeste Parameter nutzen
  6. Button-Klicks erkennen
  7. Businesslogic mit einer State-Machine implementieren

Als nächstes benötige ich den Timer der die LED zu den programmierten Zeiten ein- und aus-schaltet (im Zustand TIMER_RUN”), dann wäre die Grundfunktionalität bereits erreicht.

Der Timer

Mit dem Timer werden die programmierten Ein/Aus Zeiten der LED umgesetzt. Dazu benötige ich eine Datenstruktur welche in der Lage ist mehrere Schaltzeiten zu enthalten (Array), sowie eine Funktion die die Timer nach einem Neustart reaktiviert. Da sich die Schaltzeiten nicht überlappen können ist der Ablauf der Timer linear, d.h. TIMER1_AN … TIMER1_AUS, TIMER2_AN … TIMER2_AUS, usw. Es gibt also immer einen “Abstand” zwischen AN und AUS sowie zum nächsten AN.

Das könnte man ganz einfachen mit Sekundenabständen realisieren. Das Problem ist wohl der Reboot, denn zwischen dem poweroff und poweron des Gerätes kann eine beliebige Zeit verstreichen die der Code nicht mitbekommt. Um aber stets die richtigen Zeiten einzuhalten müsste ich also über eine Echtzeit verfügen. Leider hat der ESP32-H2 den ich einsetzen möchte keine eingebaute Realtime-Clock (RTC), daher müsste ich für diese Komfortfunktion eine externe hinzufügen, sowas wie ein DS3231 mit CR2023 Batteriepuffer:

Ja, das wirkt etwas “Overkill”, aber bei einem Netzunabhängigen Gerät wäre das das Mittel der Wahl für mich. Die Alternative wäre sich über das Funkmodem des H2 per “Thread” an einen Router zu wenden und von dort die Zeit zu holen. Ein C2 oder C6 könnte das auch per WLAN. Aber nur für die Uhrzeit fände ich das genauso Overkill. Also kommt ein RTC mit in meinen Kasten :slight_smile:

Neben der Integration der RTC muss ich mir Gedanken über die Datenablage der Timer machen. Grundsätzlich benötige ich eine Funktion um einen neuen Timer an das Array einzufügen, jeweils mit Start- und Endzeit:

timer_add(uint8_t hour_on, uint8_t minute_on, uint8_t hour_off, uint8_t minute_off)

Weiterhin Funktionen um einen bzw. alle Timer zu löschen:

timer_delete(int index)

Zur Ausführung brauche ich dann noch etwas was sicher “durch” die Timer arbeitet, also die Schaltzeiten nacheinander ausführt. Dabei denke ich schon daran das ich ja zwischen den Schaltzeiten den ESP schlafen legen will um Strom zu sparen. Ich denke also das ich eine Funktion brauche die mir die Sekunden bis zum nächsten Schaltzeitpunkt liefert:

get_seconds_to_next_event()

und ich mit diesem dann einen Light-Sleep(seconds) auslösen kann. Da ich aber ja auch eine Hardware-Uhr habe könnte ich das auch darüber tun, indem ich dort einen “Wecker” stelle, auf Echtzeit-Basis. Ich lege dann also den ESP schlafen auf unbestimmte Zeit und verlasse mich drauf das mich die RTC aufweckt über einen Hardware-Interrupt auf einen GPIO-Pin. Mit so einer Funktion:

enter_light_sleep_until_next_event()

Würde ich dann einfach den nächsten Schaltzeitpunkt ermitteln und in den ext. RTC schreiben. Das genügt. Ein weiterer Wakeup wäre natürlich ein Tastendruck. Und es sollte auch vorgesehen sein das der ESP nach einer gewissen Zeit ohne Tastenbedienung in den Sleep geht, aber das wäre wieder Teil der Energiesparfunktionen. Jetzt besorge ich mir erstmal so eine RTC und schaue wie ich die anschließe (I2C) und im Code einbinde.

Für “große” State-Machine Implementationen findet man neben den von mir bislang genutzten Handlern die Code beim Übergang in einen neuen Status ausführen, auch welche die nach Eintritts- und Austrittshandler trennen.

Aufgefallen ist mir das Problem als ich bemerkte das ich beim Eintritt in den Zustand “STATE_OFF” unterschiedlich agieren will wenn ich aus “STATE_TIMER_RUN” komme oder aus “STATE_TIMER_PROGRAM_ON”. In diesem Szenario nutzt mir ein generischer Eintrittshandler für STATE_OFF, welcher dann die LED

Um das sauber zu halten müsste ich eigentlich folgende Handler haben:

on_state_transistion_FROM_state_timer_program_on_TO_state_off()
on_state_transistion_FROM_state_timer_run_TO_state_off()

Also quasi das Produkt aller möglichen Zustandsübergänge.

Und dann habe ich noch das Problem wie ich mit Zuständen umgehen soll die eigentlich keine sind? Beispiel: Wenn ich im TIMER_RUN einen Doppelklick mache starte, bei ausgeschalteter LED, will ich damit ja eine Timer-Programmierung starten und direkt die aktuelle Zeit als Startzeit festlegen (TIMER_PROGRAM_ON). Mache ich in diesem Zustand wieder einen Doppelklick will ich damit bekunden das nun die Ausschaltzeit gekommen ist und START und END Zeit als sich wiederholender Timer abgelegt werden soll.

Ich könnte nun also aus einem Status wie beispielsweise STATE_TIMER_PROGRAM_BEGIN per Doppelklick nach STATE_TIMER_PROGRAM_FINISH wechseln um dort im Handlercode den Timer zu speichern. In diesem Zustand angekommen soll der Automat aber nicht verbleiben, denn ich würde danach gern direkt zum STATE_TIMER_RUN zurückwechseln. Dazu müsste ich sowas irres machen das ich im Status-Handler einen Trigger für einen Statuswechsel auslöse. Hmm… das wirkt irgendwie seltsam, oder?

Oder gibt es diesen letzten Status einfach nicht und ich kehre nach STATE_TIMER_PROGRAM_BEGIN einfach direkt zu STATE_TIMER_RUN zurück? Dann aber habe ich das Problem wie oben, ich müsste Handler haben die nicht nur auf den Statuseintritt reagieren, sondern auch die Statusherkunft berücksichtigen.

Irgendwo mach ich da noch nen Denkfehler…

Mein Denkfehler war die Vermischung von Zuständen und Übergängen. Abbruch und Fertigstellung der Timer-Programmierung sind eben keine eigenen Zustände sondern nur unterschiedliche Transitionen zum selben Zustand, TIMER_RUN. Die unterschiedliche Auswirkung kann man nir mittels Kenntnis des auslösenden Events steuern.

Daher habe ich den Code so geändert das ich zum einen Exit- und Enter-Funktionen für die Zustände nutze und zum anderen den Button-Event bis in diese Funktionen durchreiche. So kann ich die Fallunterscheidung ABORT im Exit-Timer-Program anhand des Klick-Events vornehmen.

Der Code wird dadurch noch strukturierer und sortenreiner, was die Wartung und Erweiterung erleichtert.

Business Logic

Die Programmlogik habe ich nochmal etwas nachgearbeitet. Am Ende muss der User mit den ganzen Tasten- und LED-Feedback Morsecodes noch etwas anfangen können.

Anlegen einer Betriebsspannung

Gerät startet mit letzten Einstellungen/Status oder bei Erstinbetriebnahme im OFF Modus.

OFF

On Enter => LED erlischt aus der aktuellen Helligkeit innerhalb 2 Sekunden (Fade). War die LED aus wird von voller Helligkeit runtergefahren.

Singe Click => Wechsel in den TIMER Status.

Long Click => Wechsel in RESET Status.

RESET

Long Click Hold => LED blinkt schnell. Wird die Taste länger als 5 Sekunden gehalten wird das NVS auf Default zurückgesetzt und mit 2 Sekunden dauerlicht quitiert. Rückkehr in den OFF Status.

Long Click Release => Rückkehr in den OFF Status.

TIMER

On Enter => Sind keine Timer programmiert blinkt die LED 10x schnell als Hinweis. Sind Timer programmiert blinkt sie 2x langsam.

Single Click => Wechsel zum OFF Status.

Double Click => Wechsel zum PROGRAM Status.

Long Click => Weschek zum TIMER_DIM Status.

TIMER_DIM

Long Click Hold => Helligkeit der LED wird abwechselnd innerhalb von 3 Sekunden jeweils ganz rauf und fast ganz runter (10%) gefahren.

Long Click Release => Helligkeit wird für den aktuellen Timer aktualisiert. Rückkehr in den TIMER Status.

PROGRAM

On Enter => LED blinkt 2x kurz und fadet dann innerhalb 2 Sekunden auf 100% Helligkeit hoch.

Long Click => Wechsel in den PROGRAM_DIM Status.

Double Click => Timerzeiten mit Helligkeitswert programmieren und speichern. Rückkehr zum TIMER Modus.

Triple Click => Abbruch der Programmierung und Rückkehr zum TIMER Modus.

PROGRAM_DIM

Long Click Hold => Helligkeit der LED wird abwechselnd innerhalb von 3 Sekunden jeweils ganz rauf und fast ganz runter (10%) gefahren.

Long Click Release => Helligkeit wird für den Timer hinterlegt. Rückkehr in den PROGRAM Status.

Die Idee:

  • Warnungen/Fehler/Störungen erzeugen schnelle Blinksignale (250 ms Periode)
  • Einschalten erzeugt einen Fade-On Effekt
  • Ausschalten erzeugt einen Fade-Off Effekt
  • Programmierungen erzeugen langsame Blinksignale (500 ms Periode)

Die Realtime-Clock (DS3231)

Ich möchte eine DS3231 anbinden, was grundsätzlich per I2C Schnittstelle/Protokoll geschieht:

Technische Daten

  • RTC-Chip: DS3231 (I2C Adresse 0x68) zählt Sekunden, Minuten, Stunden, Tage, Wochentage, Monate, Jahre (einschließlich Schaltjahr Funktion)
  • I2C-EEPROM: AT24CS32 (Adresse 0x57), 4 KB Speicherplatz
  • Spannungsversorgung: 3,3 über VCC Pin oder 3,0 Volt per Batterie
  • Batterie: Lithium “CR2032” or Akku “LIR2032” (integrierte Ladefunktion vorhanden)
  • Stromverbrauch im VCC Betrieb: ca. 0,3 mA ohne LED (die löte ich eh runter)
  • Stromverbrauch im Batteriebetrieb: ca. 2.0 µA
  • Zwei programmierbare Alarme mit Interrupt Funktion (am SQW Pin)
  • Integrierte Pullup-Widerstände für I2C

Verwenden einer CR2023 Lithium Puffer-Batterie

Der DS3231 kann mit einer Puffer-Batterie (“Knopfzelle”) ausgestattet werden, welche die RTC auch ohne externe Stromversorgung weiter laufen lässt. Das macht aus meiner Sicht durchaus Sinn, denn dann saugt das nicht auch noch am System-Akku und es macht das Modul als ganzes stabil und unabhängig von der Systemversorgung.

WICHTIG das Board wird für den Akkubetrieb ausgeliefert, legt man in diesem Zustand eine Lithium-Batterie ein führt das zur Zerstörung der Batterie! Um es mit einer Lithium-Batterie zu betreiben sollten diesen beiden Bauteile (Ladeschaltung für Akku) entfernt werden:

Das Board hat neben dem DS3231 auch noch ein EEPROM draufgelötet. Ein mögliche Anwendung dafür wäre die Speicherung der Timer. Das würde es erlauben dieses Modul extern zu programmieren und in eine Lichterketten-Box einzustecken oder zwischen diesen zu wechseln. So könnte man mit einem einfachen USB-Interface die Schaltzeiten bequem am PC zu planen und zu hinterlegen und dann in die Box zu stecken. Ich sehe also auf meiner Basisplatine eine 6-Poligen Buchsenleiste dafür vor.

Anschluß am ESP-H2

Der ESP32-H2 verfügt intern über zwei interne Hardware I2C-Controller die jeweils als Master oder Slave arbeiten können. Die IO-Ports die der Kontroller nutzt sind quasi frei definierbar, daher verbinde ich meinen ESP32-H2 wie folgt mit dem Modul:

  • SCL → GPIO 11
  • SDA → GPIO 12
  • GND → GND

Damit ich später mit dem RTC-Alarm Pin “SQW” den ESP aufwecken kann verbinde ich zudem:

  • SQW → GPIO 14

Stromversorgung des DS3231

Um mit dem RTC (oder dem EEPROM) über I2C zu kommunizieren wird die 3,3 V Betriebsspannung benötigt. Liegt diese nicht an, läuft der RTC auf Batterie mit extrem niedrigem Stromverbrauch von von nur ca. 2 µA, was einer theoretischen Lebensdauer der Batterie von gut 4 Jahren entspricht.

Die LED löte ich ohnehin vom Board runter, die frisst nur unnötig Strom. Es bleibt also nur die Versorgung des RTC und des EEPROMs, wozu selbst im Peak während der Temperaturkompensation nur max. 1,5 mA benötigt werden, ansonsten nur ca. 0,3 mA. Ein GPIO-Port des ESP32 kann typischerweise bis zu 40 mA liefern, ich kann also den RTC problemlos direkt darüber mit Strom versorgen, also verbinde ich wie folgt:

  • VCC → GPIO 10

Im Code muss ich den GPIO also im Default LOW halten und wenn ich per I2C kommunizieren will auf HIGH setzen. Danach sollte ich 100 ms warten um den Chips Zeit zum aufwecken zu geben, meine Infos mit ihnen austauschen und den GPIO wieder auf LOW setzen.

Verwendung von I2C und DS3231 im Programm

Um die Hardware nun zu nutzen können wir wieder auf eine fertige Component zurückgreifen

Dazu fügen wir die einfach in der main/idf_component.yml mit ein:

dependencies:
  ...
  esp-idf-lib/ds3231:
    version: "^1.1.7"

Beim nächsten kompilieren wird so die Komponente im Projekt hinterlegt:

managed_components/esp-idf-lib__ds3231

Aufgrund der Abhängig zur I2C Library entstehen auch noch diese:

managed_components/esp-idf-lib__i2cdev
managed_components/esp-idf-lib__esp_idf_lib_helpers

Jetzt wie üblich zuerst die Header hinzufügen:

#include "i2cdev.h" // Generic I2C
#include "ds3231.h" // DS3231 RTC (I2C realtime clock)

Den zu verwendenden I2C Controller, Typ und GPIO-Pins definieren:

#define I2C_MASTER_NUM I2C_NUM_0
#define I2C_MASTER_SCL_IO GPIO_NUM_2
#define I2C_MASTER_SDA_IO GPIO_NUM_1

Zur Steuerung der Stromversorgung des RTC verwende ich diese Funktionen:

// GPIO für DS3231 VCC (Power-Control)
#define DS3231_VCC_PIN GPIO_NUM_10

// GPIO für Power-Control initialisieren
static void ds3231_init_power_gpio(void)
{
    gpio_config_t io_conf = {
        .intr_type = GPIO_INTR_DISABLE,
        .mode = GPIO_MODE_OUTPUT,
        .pin_bit_mask = (1ULL << DS3231_VCC_PIN),
        .pull_down_en = GPIO_PULLDOWN_DISABLE,
        .pull_up_en = GPIO_PULLUP_DISABLE
    };
    gpio_config(&io_conf);
    gpio_set_level(DS3231_VCC_PIN, 0); // defaults to POWER OFF
}

// DS3231 VCC einschalten (+ 100 ms Sleep)
static void ds3231_power_on(void)
{
    gpio_set_level(DS3231_VCC_PIN, 1);
    vTaskDelay(pdMS_TO_TICKS(100)); // Warte auf Stabilisierung
    ESP_LOGI(TAG, "DS3231 power ON (run on VCC)");
}

// DS3231 VCC ausschalten (läuft dann auf Batterie)
static void ds3231_power_off(void)
{
    gpio_set_level(DS3231_VCC_PIN, 0);
    ESP_LOGI(TAG, "DS3231 power OFF (run on VBAT)");
}

What time is it?

Damit will ich erstmal experimentieren bevor ich an die Sleep-Wakeup-Funktionen gehe. Ein erster Test wäre ja einfach mal die aktuelle Uhrzeit aus dem RTC auszulesen. Dazu gibt es die Funktion ds3231_get_time() welche in das als Referenz übergebene Struct vom Typ “tm” die Zeitwerte vom RTC zurückliefert:

struct tm rtctime;

ds3231_get_time(&dev, &rtctime);

ESP_LOGI(TAG, "Zeit: %02d.%02d.%04d %02d:%02d:%02d",
               rtctime.tm_mday,         // wochentag (1=So, 2=Mo, ..)
               rtctime.tm_mon + 1,      // tm_mon ist 0-11
               rtctime.tm_year + 1900,  // tm_year ist Jahre seit 1900
               rtctime.tm_hour,
               rtctime.tm_min,
               rtctime.tm_sec);

Um aber überhaupt mit dem RTC reden zu können muss zunächst der I2C Bus am ESP mit i2cdev_init() initialisiert und mit ds3231_init_desc() ein Handle (Device Descriptor) für den Zugriff auf den Bus erzeugt werden. Die Routine, angereichert mit einer Stromsteuerfunktionalität, schaut dann so aus:

app_main()
{
    ...

    // Power-GPIO initialisieren
    ds3231_init_power_gpio();

    // I2C Bus des ESP initialisieren
    i2cdev_init();

    // DS3231 initialisieren und device-handler erzeugen
    i2c_dev_t dev;
    memset(&dev, 0, sizeof(i2c_dev_t));
    ds3231_init_desc(&dev, I2C_MASTER_NUM, I2C_MASTER_SDA_IO, I2C_MASTER_SCL_IO);

    struct tm rtctime;

    while(1)
    {
        ds3231_power_on();
        ds3231_get_time(&dev, &rtctime);
        ds3231_power_off();

        ESP_LOGI(TAG, "Zeit: %02d.%02d.%04d %02d:%02d:%02d",
                 rtctime.tm_mday, 
                 rtctime.tm_mon + 1,      // tm_mon ist 0-11
                 rtctime.tm_year + 1900,  // tm_year ist Jahre seit 1900
                 rtctime.tm_hour,
                 rtctime.tm_min,
                 rtctime.tm_sec
        );

        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

Im Ergebnis hat man eine schöne Uhr, die aber natürlich noch falsch geht. Der Grund liegt darin das sie nicht gestellt wurde und bei Erstinbetriebnahme einfach bei 01.01.2000 00:00:00 Uhr startet:

I (410) LED_STRIP_CTRL: DS3231 power ON (run on VCC)
I (410) i2cdev: [Port 0] First initialization. Configuring bus with SDA=12, SCL=11 (Pullups SCL:0 SDA:0)
W (410) i2c.master: Please check pull-up resistances whether be connected properly. Otherwise unexpected behavior would happen. For more detailed information, please read docs
I (420) i2cdev: [Port 0] Successfully installed I2C master bus (Handle: 0x4084e304).
I (430) i2cdev: [0x68 at 0] Device added successfully (Device Handle: 0x4084e7e0, Speed: 400000 Hz).
I (440) LED_STRIP_CTRL: DS3231 power OFF (run on VBAT)
I (440) LED_STRIP_CTRL: Zeit: 01.01.2000 00:00:26
I (1550) LED_STRIP_CTRL: DS3231 power ON (run on VCC)
I (1550) LED_STRIP_CTRL: DS3231 power OFF (run on VBAT)
I (1550) LED_STRIP_CTRL: Zeit: 01.01.2000 00:00:27
I (2650) LED_STRIP_CTRL: DS3231 power ON (run on VCC)
I (2650) LED_STRIP_CTRL: DS3231 power OFF (run on VBAT)
I (2650) LED_STRIP_CTRL: Zeit: 01.01.2000 00:00:28

Korrekte Uhrzeit einstellen

Im Grunde kann uns die Echte Uhrzeit ja egal sein weil sich die gesetzten Timer relativ zueinander ja richtig verhalten würden. Aus Debugging-Gründen und wenn man drüber nachdenkt die Timer später mal über eine Console am PC einstellen zu wollen, wäre es natürlich schick die Uhr einmal richtig zu stellen. Das ginge mit folgendem Code:

struct tm time = {
    .tm_year = 2025 - 1900,  // Jahre seit 1900
    .tm_mon = 11,             // Monate 0-11 (Dezember = 11)
    .tm_mday = 11,            // Tag des Monats
    .tm_hour = 14,
    .tm_min = 30,
    .tm_sec = 0
};
ds3231_set_time(&dev, &time);
ESP_LOGI(TAG, "Zeit gesetzt auf: %02d.%02d.%04d %02d:%02d:%02d",
         time.tm_mday, time.tm_mon + 1, time.tm_year + 1900,
         time.tm_hour, time.tm_min, time.tm_sec);

Wake me up before you go go…

In diesem Zustand nutzt mir der RTC aktuell noch nichts denn ich baue ja keine Uhr :wink: Also schaue ich mir jetzt mal an wie man beim DS3231 den “Wecker” stellt um damit meinen ESP aufzuwecken sobald der nächste Schaltzeitpunkt erreicht ist.
Man kann zwei unabhängige Alarmzeiten definieren. Dazu gibt es noch verschiedene Arbeitsmodus und Aktionen bei erreichen der Alarmzeit. Ich möchte das dabei der SQW auf LOW geht um damit später den ESP aufwachen zu lassen. Das macht man mit ds3231_set_alarm() der man neben dem I2C-Handle jeweils den Modus und einen tm Record für beide Alarmzeiten übergibt (bzw. NULL wenn einer der Alarme nicht gesetzt werden soll):

struct tm alarm_time = {
    .tm_hour = 15,
    .tm_min = 30
};

// WICHTIG: Alarm-Flags vorher löschen!
ds3231_clear_alarm_flags(&dev, DS3231_ALARM_1);

// Alarmzeit und Modus einstellen
ds3231_set_alarm(&dev, DS3231_ALARM_1,
    &alarm_time, DS3231_ALARM2_MATCH_MINHOUR,
    NULL, 0));

// Alarm aktivieren
ds3231_enable_alarm_ints(&dev, DS3231_ALARM_1);

Der Modus DS3231_ALARM1_MATCH_MINHOUR stellt einen Wecker nur mit Stunde und Minute, welcher sich dann automatisch täglich wiederholt. Daher ist es nicht notwendig in der tm struct mehr als diese beiden Werte einzustellen. Sobald der Alarm “scharf” ist (enable) wird er bei erreichen den SQW Pin auf LOW nehmen. Der SQW-Pin hat auf dem DS-Board einen Pull-Up Widerstand. Dieser ist aber vergleichsweise niederohmig weshalb ich ihn aus Stromspargründen entlöten werde. Daher muss der ESP-Eingang auf internen Pullup gestellt werden:

    // GPIO14 als Eingang konfigurieren (pull-up)
    gpio_config_t io_conf = {
        .intr_type = GPIO_INTR_DISABLE,
        .mode = GPIO_MODE_INPUT,
        .pin_bit_mask = (1ULL << DS3231_ALARM_INT_PIN),
        .pull_up_en = GPIO_PULLUP_ENABLE,
        .pull_down_en = GPIO_PULLDOWN_DISABLE
    };
    gpio_config(&io_conf);

Per Default wird am SQW Pin des DS3231 im Batterybetrieb (also ohne Stromversorgung) ebenfalls abgeschaltet (auf LOW gelegt). Damit das Signal seine Funktion auch da erhält muss das BBSQW Bit (Bit 6 des Control-Registers) gesetzt sein. Dafür gibt es in der ESP-Lib scheinbar keine direkte Funktion, im Gegenteil, die Funktion ds3231_enable_alarm_ints() setzt dieses Bit auf 0 und verhindert somit den Ausgang im Batteriebetrieb!

Daher muss man es unbedingt NACH Aufruf von enable-alarm so einstellen:

// Optional: BBSQW für Batteriebetrieb
uint8_t ctrl;
i2c_dev_read_reg(&dev, 0x0E, &ctrl, 1);
ctrl |= (1 << 6);  // BBSQW
i2c_dev_write_reg(&dev, 0x0E, &ctrl, 1);
1 „Gefällt mir“