Sunton/MaTouch ESP32-S3 7 Zoll Displays mit LVGL und ESP-IDF nutzen

Parallele RGB LCD-Displays bieten eine kostengünstige und effiziente Lösung für das IoT, insbesondere in Verbindung mit dem leistungsfähigen ESP32-S3 Mikrocontrollern. Durch ihre einfache Schnittstelle und breite Verfügbarkeit eignen sich diese Displays optimal für die Anzeige von Informationen in IoT-Geräten. Dieser Artikel untersucht am Beispiel der Makerfabs MaTouch und Sunton 7 Zoll Displays die Funktionsweise der parallelen RGB LCD-Displays und die Integration mit dem ESP-IDF Framework sowie der LVGL Grafikbibliothek.

Die Funktionsweise von parallelen LCD Displays

Parallele LCD-Displays nutzen eine Reihe von Leitungen, um die nötigen Bildinformationen zu übertragen. Die Datenleitungen spielen dabei eine Schlüsselrolle, indem sie die Farbinformationen für jedes Pixel weitergeben. Die Displays von Makerfabs und Sunton verwenden das RGB565-Format, also 5 Bit für Rot, 6 Bit für Grün und 5 Bit für Blau. Für die Daten werden somit insgesamt 16 Bit benötigt. 

PWM Brightness
PWM Brightness
Microcontroller
Microcontroller
Display
Display
HSYNC
HSYNC
VSYNC
VSYNC
DATA ENABLE
DATA ENABLE
CLOCK
CLOCK
RGB565 Data
RGB565 Data
I2C Touch
I2C Touch
3
3
16
16

Die HSYNC und VSYNC Signale markieren den Beginn neuer Zeilen bzw. Bilder, während die CLOCK Leitung die Datenübertragung synchronisiert und die DATA ENABLE Leitung angibt, wann die Daten gültig sind. Das folgende Diagramm verdeutlicht den zeitlichen Ablauf:

VSYNC
VSYNC
HSYNC
HSYNC
Horizontal
Back Porch
Horizontal...
Vertical
Back Porch
Vertical...
Vertical
Front Porch
Vertical...
Horizontal
Front Porch
Horizontal...
LCD Lines
LCD Lines
LCD Columns
LCD Columns
CLOCK
CLOCK
DATA
ENABLE
DATA...
RGB565
DATA
RGB565...

Back Porch und Front Porch stammen noch aus den Zeiten der Röhrenmonitore. Sie wurden für die Synchronisierung und Stabilisierung des Bildes auf dem Display benötigt, indem sie sicherstellen, dass der Elektronenstrahl des Bildschirms ordnungsgemäß positioniert wird. Auch in modernen LCD-Displays werden diese Pausen verwendet, um die Datenübertragung zu stabilisieren und den Bildaufbau zu synchronisieren. Die richtigen Werte bekommt man entweder aus dem Datenblatt zum jeweiligen Display oder aus Beispielprogrammen vom Hersteller.

Zusätzlich sind die Displays mit einem GT911 Touch Controller ausgestattet. Dieser Controller arbeitet völlig unabhängig vom Display und ist über eine I²C Schnittstelle angebunden. Eine weitere Leitung steuert mittels Pulsweitenmodulation die Helligkeit des Displays.

Anders als bei anderen Displaytypen werden diese Leitungen bei parallelen LCD-Displays nicht durch einen speziellen LCD-Controller gesteuert, sondern durch einen Mikrocontroller. Das spart nicht nur Kosten, sondern ermöglicht auch eine höhere Flexibilität bei der Anpassung an spezifische Anforderungen. Bei den MaTouch und Sunton Displays übernimmt der ESP32-S3 diese Aufgabe. Der Preis dafür ist jedoch sehr hoch. Insgesamt werden 24 GPIO Ports des ESP32-S3 für die Steuerung der Displays benötigt.

ESP32-S3 und parallele LCD Panels

Die Mikrocontroller von Espressif bieten schon seit dem ersten ESP32 Hardwareunterstützung für Displays an, allerdings nur für LCDs mit eigenem Controller, die über Schnittstellen wie I2C oder SPI angesprochen werden. Nur der ESP32-S3 unterstützt zusätzlich parallele RGB Displays. Die Bildschirmdaten werden dabei vom LCD-Interface per DMA vom Framebuffer an das Display übertragen, ohne die beiden CPUs zu belasten. Das ist auch der Grund, warum alle größeren Displays mit einem Espressif Mikrocontroller den ESP32-S3 verwenden.

Die Konfiguration des RGB-Interface LCD-Treibers benötigt nur wenige Schritte. Zunächst müssen in der Struktur esp_lcd_rgb_panel_config_t sämtliche Paramater wie Clock-Source, Anzahl der Framebuffer (Bildschirmspeicher), Datenbreite, GPIO-Pins oder Timings vorgenommen werden. Anschließend wird mit den Funktionen esp_lcd_new_rgb_panel, esp_lcd_panel_reset und esp_lcd_panel_init das Display initialisiert. 

Der LCD-Treiber bietet die Möglichkeit, den Framebuffer auf unterschiedliche Weise zu konfigurieren:

  • Interner Speicher: Einfachste Option, verbraucht aber den sehr begrenzten internen Speicher.
  • PSRAM: Diese Option nutzt das PSRAM, um den internen Speicher zu entlasten. Dabei werden die Bildschirmdaten direkt per DMA aus dem PSRAM gelesen. Dies kann zu Bandbreitenproblemen führen, insbesondere wenn CPU und DMA gleichzeitig auf das PSRAM zugreifen.
  • Doppelter PSRAM: Verwendet zwei PSRAM Framebuffer, um Tearing-Effekte zu vermeiden, erfordert aber eine Synchronisation der Framebuffer.
  • Bounce-Buffer mit einem PSRAM: Verwendet internen Speicher für einen Bounce-Puffer und PSRAM für einen Framebuffer. Bietet höhere Pixeltaktfrequenzen, erhöht aber die CPU-Auslastung.
  • Nur Bounce-Buffer: Der Treiber weist keinen PSRAM Framebuffer zu. Mit einer Callback-Funktion wird  der Bounce-Buffer im laufenden Betrieb befüllt.

Da die verwendeten Displays über 8 MB PSRAM verfügen, verwende ich die Variante mit zwei PSRAM Framebuffern. Die Funktion zu Initialisierung des LCD-Treibers sieht dementsprechend wie folgt aus:

void init_lcd(esp_lcd_panel_handle_t *panel_handle) {
    
    ESP_LOGI(TAG, "Install RGB LCD panel driver");

    sem_vsync_end = xSemaphoreCreateBinary();
    sem_gui_ready = xSemaphoreCreateBinary();

    esp_lcd_rgb_panel_config_t panel_config = {
        .data_width = 16, // RGB565 in parallel mode, thus 16bit in width
        .psram_trans_align = 64,
        .num_fbs = 2,
        .clk_src = LCD_CLK_SRC_DEFAULT,
        .disp_gpio_num = PIN_NUM_DISP_EN,
        .pclk_gpio_num = PIN_NUM_PCLK,
        .vsync_gpio_num = PIN_NUM_VSYNC,
        .hsync_gpio_num = PIN_NUM_HSYNC,
        .de_gpio_num = PIN_NUM_DE,
        .data_gpio_nums = {
            PIN_NUM_DATA0,
            PIN_NUM_DATA1,
            PIN_NUM_DATA2,
            PIN_NUM_DATA3,
            PIN_NUM_DATA4,
            PIN_NUM_DATA5,
            PIN_NUM_DATA6,
            PIN_NUM_DATA7,
            PIN_NUM_DATA8,
            PIN_NUM_DATA9,
            PIN_NUM_DATA10,
            PIN_NUM_DATA11,
            PIN_NUM_DATA12,
            PIN_NUM_DATA13,
            PIN_NUM_DATA14,
            PIN_NUM_DATA15,
        },
        .timings = {
            .pclk_hz = LCD_PIXEL_CLOCK_HZ,
            .h_res = LCD_H_RES,
            .v_res = LCD_V_RES,
            .hsync_back_porch = HSYNC_BACK_PORCH,
            .hsync_front_porch = HSYNC_FRONT_PORCH,
            .hsync_pulse_width = HSYNC_PULSE_WIDTH,
            .vsync_back_porch = VSYNC_BACK_PORCH,
            .vsync_front_porch = VSYNC_FRONT_PORCH,
            .vsync_pulse_width = VSYNC_PULSE_WIDTH,
        },
        .flags.fb_in_psram = true, // allocate frame buffer in PSRAM
    };
    ESP_LOGI(TAG, "Create RGB LCD panel");
    esp_lcd_new_rgb_panel(&panel_config, panel_handle);

    ESP_LOGI(TAG, "Register event callbacks");
    esp_lcd_rgb_panel_event_callbacks_t cbs = {
        .on_vsync = on_vsync_event,
    };
    esp_lcd_rgb_panel_register_event_callbacks(*panel_handle, &cbs, NULL);


    ESP_LOGI(TAG, "Initialize RGB LCD panel");
    esp_lcd_panel_reset(*panel_handle);
    esp_lcd_panel_init(*panel_handle);
}


Die Konstanten sind in separaten Header-Dateien definiert, wobei für das Sunton-Display und die beiden MaTouch-Displays jeweils eine eigene Header-Datei existiert.

In dieser Funktion wird auch ein VSYNC-Callback definiert. Dieser Callback wird bei jedem VSYNC-Signal aufgerufen, also genau dann, wenn ein neues Bild aufgebaut wird:

static bool on_vsync_event(esp_lcd_panel_handle_t panel, const esp_lcd_rgb_panel_event_data_t *event_data, void *user_data)
{
    BaseType_t high_task_awoken = pdFALSE;

    // Wait until LVGL has finished 
    if (xSemaphoreTakeFromISR(sem_gui_ready, &high_task_awoken) == pdTRUE) {
        // Indicate that the VSYNC event has ended, and it's safe to proceed with flushing the buffer.
        xSemaphoreGiveFromISR(sem_vsync_end, &high_task_awoken);
    }

    return high_task_awoken == pdTRUE;
}


 Weiter unten erkläre ich noch den Hintergrund dieser Funktion und die Bedeutung der Semaphore.

GT911 Touch Controller

In diesem Beispiel verwende ich die GT911 Komponente von Espressif. Allerdings hatte das MaTouch Display mit der 1024x600 Pixel Auflösung das Problem, dass die gelieferten Touch-Koordinaten nicht zum Display gepasst haben. Vertikal gingen die Werte von 0 bis 768. Auf dem Makerfabs Wiki wird empfohlen, die Koordinaten mit einer map-Funktion zu korrigieren. Meiner Ansicht nach ist es aber besser, direkt den GT911 richtig zu konfigurieren. Da der Espressif Treiber keine Funktion zum Schreiben der entsprechenden Register bietet, habe ich dazu eine neue Funktion implementiert. Damit werden die korrekten Werte in die Register 0x8048 und 0x804A geschrieben:

static esp_err_t touch_gt911_write_resolution(esp_lcd_touch_handle_t tp, uint16_t x_max, uint16_t y_max)
{
    esp_err_t err;

    uint8_t len = 0x8100 - ESP_LCD_TOUCH_GT911_CONFIG_REG + 1;
    uint8_t config[len];

    ESP_LOGI(TAG, "Write resolution");

    ESP_RETURN_ON_ERROR(touch_gt911_i2c_read(tp, ESP_LCD_TOUCH_GT911_CONFIG_REG, (uint8_t *)&config[0], len), TAG, "GT911 read error!");

    config[1] = (x_max & 0xff);
    config[2] = (x_max >> 8);
    config[3] = (y_max & 0xff);
    config[4] = (y_max >> 8);

    uint8_t checksum = calcChecksum(config, len - 2);
    ESP_LOGI(TAG, "Checksum:%u", checksum);

    config[len - 2] = calcChecksum(config, len - 2);
    config[len - 1] = 1;

    err = esp_lcd_panel_io_tx_param(tp->io, ESP_LCD_TOUCH_GT911_MAX_X, (uint8_t *)&config[1], 2);
    ESP_RETURN_ON_ERROR(err, TAG, "I2C write error!");
    err = esp_lcd_panel_io_tx_param(tp->io, ESP_LCD_TOUCH_GT911_MAX_Y, (uint8_t *)&config[3], 2);
    ESP_RETURN_ON_ERROR(err, TAG, "I2C write error!");

    err = esp_lcd_panel_io_tx_param(tp->io, ESP_LCD_TOUCH_GT911_CHKSUM, (uint8_t *)&config[len - 2], 1);
    ESP_RETURN_ON_ERROR(err, TAG, "I2C write error!");
    err = esp_lcd_panel_io_tx_param(tp->io, ESP_LCD_TOUCH_GT911_FRESH, (uint8_t *)&config[len - 1], 1);
    ESP_RETURN_ON_ERROR(err, TAG, "I2C write error!");

    return ESP_OK;
}


Diese Funktion muss nur einmal in esp_lcd_touch_new_i2c_gt911 aufgerufen werden. Danach stimmen die Touch-Koordinaten genau mit dem Display-Koordinaten überein.

Eine weitere Funktion ist für das Auslesen der Touch-Koordinaten verantwortlich:

static void touchpad_read(lv_indev_drv_t *indev_driver, lv_indev_data_t *data)
{

    esp_lcd_touch_handle_t touch_handle = (esp_lcd_touch_handle_t)indev_driver->user_data;

    uint16_t touchpad_x;
    uint16_t touchpad_y;
    uint16_t touch_strength;
    uint8_t touch_cnt = 0;

    data->state = LV_INDEV_STATE_REL;

    esp_lcd_touch_read_data(touch_handle);
    bool touchpad_pressed = esp_lcd_touch_get_coordinates(touch_handle, &touchpad_x, &touchpad_y, &touch_strength, &touch_cnt, 1);
    if (touchpad_pressed) {

        data->state = LV_INDEV_STATE_PR;

        /*Set the coordinates*/
        data->point.x = touchpad_x;
        data->point.y = touchpad_y;
    }
}


Diese Funktion wird bei der LVGL-Initialisierung als Callback-Funktion für den Touch-Treiber zugewiesen.

LVGL mit ESP-IDF

Die meisten LVGL-Beispiele für den ESP32 integrieren auch eine Grafikbibliothek wie LovyanGFX oder Arduino_GFX. Diese sind jedoch nicht unbedingt erforderlich. LVGL kann auch direkt mit dem ESP-IDF-Framework verwendet werden. 

Dazu wird zunächst die LVGL Grafikbibliohek mit der Funktion lv_init initialisiert. Anschließend werden die beiden vom ESP-IDF Framework erzeugten Framebuffer mit den Funktionen esp_lcd_rgb_panel_get_frame_buffer und lv_disp_draw_buf_init zugewiesen. Danach müssen nur noch der Display-Treiber und der Touch-Treiber initialisiert werden. Diese Funktion sieht folgendermaßen aus:

void init_lvgl(esp_lcd_panel_handle_t panel_handle, esp_lcd_touch_handle_t touch_handle) {

    ESP_LOGI(TAG, "Initialize LVGL library");

    lvgl_mux = xSemaphoreCreateRecursiveMutex();

    static lv_disp_draw_buf_t disp_buf; // contains internal graphic buffer(s) called draw buffer(s)
    static lv_disp_drv_t disp_drv;      // contains callback functions
    
    lv_init();

    ESP_LOGI(TAG, "Use PSRAM framebuffers");
    void *buf1 = NULL;
    void *buf2 = NULL;
    esp_lcd_rgb_panel_get_frame_buffer(panel_handle, 2, &buf1, &buf2);
    lv_disp_draw_buf_init(&disp_buf, buf1, buf2, LCD_H_RES * LCD_V_RES);


    ESP_LOGI(TAG, "Register display driver to LVGL");
    lv_disp_drv_init(&disp_drv);
    disp_drv.hor_res = LCD_H_RES;
    disp_drv.ver_res = LCD_V_RES;
    disp_drv.flush_cb = lvgl_flush_cb;
    disp_drv.draw_buf = &disp_buf;
    disp_drv.user_data = panel_handle;
    disp_drv.full_refresh = true;
    lv_disp_drv_register(&disp_drv);

    ESP_LOGI(TAG, "Register input device driver to LVGL");
    static lv_indev_drv_t indev_drv;
    lv_indev_drv_init(&indev_drv);
    indev_drv.type = LV_INDEV_TYPE_POINTER;
    indev_drv.read_cb = touchpad_read;
    indev_drv.user_data = touch_handle;
    lv_indev_drv_register(&indev_drv);

    ESP_LOGI(TAG, "Start lv_timer_handler task");

    xTaskCreate(lvgl_port_task, "LVGL", LVGL_TASK_STACK_SIZE, NULL, LVGL_TASK_PRIORITY, NULL);
}


Die Einstellung disp_drv.full_refresh = true; sorgt dafür, dass LVGL immer den gesamten Bildschirminhalt in den Framebuffer schreibt. Dadurch muss beim flush_cb Callback nur zwischen den beiden Framebuffern umgeschaltet werden.

Der flush_cb Callback sieht folgendermaßen aus:

static void lvgl_flush_cb(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_map)
{
    esp_lcd_panel_handle_t panel_handle = (esp_lcd_panel_handle_t) drv->user_data;

    int offsetx1 = area->x1;
    int offsetx2 = area->x2;
    int offsety1 = area->y1;
    int offsety2 = area->y2;

    // LVGL has finished
    xSemaphoreGive(sem_gui_ready);
    // Now wait for the VSYNC event. 
    xSemaphoreTake(sem_vsync_end, portMAX_DELAY);

    // pass the draw buffer to the driver
    esp_lcd_panel_draw_bitmap(panel_handle, offsetx1, offsety1, offsetx2 + 1, offsety2 + 1, color_map);
    lv_disp_flush_ready(drv);
}


Hier kommen wieder die beiden Semaphore sem_gui_ready und sem_vsync_end vor, die schon im VSYNC-Callback verwendet wurden. Diese beiden Semaphore sorgen dafür, dass die Daten immer genau zu Beginn des Bildschirmaufbaus an das Display geschickt werden. Das verhindert sogenannte Tearing-Effekte, bei denen Teile von mehreren Frames gleichzeitig während der Aktualisierung der Anzeige sichtbar sind.

Eine weiterer Semaphor wird benötigt, da LVGL nicht thread-safe ist. Das bedeutet, dass nur ein Prozess gleichzeitig LVGL-Funktionen aufrufen darf. Einzige Ausnahme sind Events und Timer. Ansonsten müssen LVGL Aufrufe immer mit xSemaphoreTakeRecursive und xSemaphoreGiveRecursive abgesichert werden. 

Bildschirmhelligkeit steuern

Auch die Bildschirmhelligkeit muss bei diesen Displays vom Mikrocontroller per Pulsweitenmodulation gesteuert werden. Dazu wird die LED-Steuerung (LEDC) des ESP32-S3 verwendet, die entsprechend konfiguriert wird. 

void init_backlight(void) {
    ledc_timer_config_t ledc_timer = {
        .duty_resolution = PWM_RESOLUTION,
        .freq_hz = PWM_FREQ,
        .speed_mode = LEDC_LOW_SPEED_MODE,
        .timer_num = LEDC_TIMER,
        .clk_cfg = LEDC_AUTO_CLK
    };
    ledc_channel_config_t ledc_channel = {
        .channel = LEDC_CHANNEL,
        .duty = 0,
        .hpoint = 0,
        .intr_type = LEDC_INTR_DISABLE,
        .gpio_num = LEDC_PIN_NUM_BK_LIGHT,
        .speed_mode = LEDC_LOW_SPEED_MODE,
        .timer_sel = LEDC_TIMER,
        .flags.output_invert = LEDC_OUTPUT_INVERT
    };

    ESP_LOGI(TAG, "Initializing LCD backlight");
    ledc_timer_config(&ledc_timer);
    ledc_channel_config(&ledc_channel);
}


Die Helligkeit kann mit dieser Funktion eingestellt werden, die lediglich den Duty Cycle (Tastverhältnis) des LEDC Timers ändert:

void set_backlight_brightness(uint8_t brightness) {
    ESP_LOGI(TAG, "Setting LCD backlight brightness to %d", brightness);

    ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL, brightness);
    ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL);
}


Die PWM Frequenz beträgt 200 Hz. Das MaTouch Board verwendet für die Hintergrundbeleuchtung den RY3730 Step-Up Konverter. Die Angaben im Datenblatt für die Frequenz sind hier etwas seltsam:

...in order to let the dimming control perform correctly for preventing the flicker issue, the suqqested PWM frequency is ≥1kHz or ≤200Hz

Ich habe das Gefühl, dass hier etwas vertauscht wurde und der Bereich eher zwischen 200 Hz und 1 kHz sein sollte. Aber mit 200 Hz bin ich auf jedem Fall auf der sicheren Seite.

Demo-Projekt

Um zu demonstrieren, wie alle Komponenten zusammenarbeiten, habe ich eine kleine Applikation entwickelt. Der Code ist in Visual Studio Code mit dem PlatformIO Plugin entwickelt. Bevor das Projekt gestartet wird, ist es erforderlich, eine Konfigurationsdatei für das MaTouch Display mit dem folgenden Inhalt zu erstellen:

{
  "build": {
    "arduino": {
      "ldscript": "esp32s3_out.ld",
      "partitions": "default_16MB.csv",
      "memory_type": "qio_opi"
    },
    "core": "esp32",
    "extra_flags": [
      "-DARDUINO_ESP32S3_DEV",
      "-DBOARD_HAS_PSRAM",
      "-DARDUINO_USB_MODE=1",
      "-DARDUINO_RUNNING_CORE=1",
      "-DARDUINO_EVENT_RUNNING_CORE=1",
      "-DARDUINO_USB_CDC_ON_BOOT=0"
    ],
    "f_cpu": "240000000L",
    "f_flash": "80000000L",
    "flash_mode": "qio",
    "hwids": [
      [
        "0x303A",
        "0x1001"
      ]
    ],
    "mcu": "esp32s3",
    "variant": "esp32s3"
  },
  "connectivity": [
    "wifi"
  ],
  "debug": {
    "openocd_target": "esp32s3.cfg"
  },
  "frameworks": [
    "arduino",
    "espidf"
  ],
  "name": "MaTouch ESP32-S3 7 Inch",
  "upload": {
    "flash_size": "16MB",
    "maximum_ram_size": 327680,
    "maximum_size": 16777216,    
    "use_1200bps_touch": true,
    "wait_for_upload_port": true,
    "require_upload_port": true,
    "speed": 460800
  },
  "url": "https://www.makerfabs.com/esp32-s3-parallel-tft-with-touch-7-inch.html",
  "vendor": "Makerfabs"
}


Diese Datei wird unter dem Namen matouch_s3.json im lokalen Benutzerverzeichnis in das Verzeichnis .platformio\platforms\espressif32\boards\ gepeichert. Der Inhalt ist grundsätzlich der gleiche wie in dem Artikel über das Sunton Display

Für die Demo habe ich ein minimalistisches Userinterface mit SquareLine Studio erstellt. Mit einem Slider kann man die Helligkeit des Displays ändern und im Hintergrund zählt ein Task einen Counter nach oben. Wenn die Applikation richtig startet, dann sollte das Bild etwa so aussehen:

demo.png

In dieser Demo sind zwei gängige Szenarien enthalten, und zwar aus einem Task heraus das GUI ändern und auf LVGL Events reagieren und entsprechend im GUI anzeigen. Die Struktur des Projekts ist wie folgt:

CMakeLists.txt
platformio.ini                           Konfiguration des Projekts
sdkconfig.defaults                       Spezielle ESP-IDF Einstellungen
components
components\esp_lcd_touch                 Allgemeiner Touch Treiber Code
components\esp_lcd_touch_gt911           Angepasster GT911 Treiber
src
src\CMakeLists.txt
src\main.c                               Hauptprogramm
src\lv_conf.h                            LVGL Konfiguration 
src\display
src\display\esp32_s3.c                   Display und LVGL Initialisierung
src\display\esp32_s3.h
src\display\matouch_7inch_1024x600.h     Header File für MaTouch IPS 1024x600 Pixel 
src\display\matouch_7inch_800x480.h      Header File für MaTouch TN 800xs480 Pixel
src\display\sunton_7inch_800x480.h       Header File für Sunton TN 800x480 Pixel
src\gui                                 
src\gui\gui.c                            Funktionen für Änderungen im GUI
src\gui\gui.h
src\task
src\task\counter_task.c                  Einfacher Task mit einem Zähler
src\task\counter_task.h
src\ui                                   Dieser Ordner enthält den Squareline Studio Export

Der Code der Demo Applikation liegt auf GitHub. Das Squareline Studio Projekt findet ihr hier. Bei Fragen/Anregungen/Verbesserungen könnt ihr mir gerne eine Nachricht hinterlassen. 

Konversation wird geladen