Über Web, Tech, Games, Art,
Code & Design

17. Februar 2024

DIY: Tisch-Uhr mit e-Ink-Display (Waveshare Pico e-Paper 3.7) und Raspberry Pi Pico

In diesem Artikel beschreibe ich, wie ich eine Tisch-Uhr mit e-Ink-Display programmiert & gebaut habe. Neben der Uhrzeit wird auf dem Display auch noch die aktuelle Außentemperatur, der Tageshöchstwert, die aktuelle Leistung meines Balkonkraftwerks und der Tagesertrag des BKW angezeigt.

Material

  • Raspberry Pi Pico WH (ca. 8 € – z.B. von Berrybase)
  • 3,7 Zoll E-Paper-/E-Ink- Display von Waveshare (ca. 25 € – z.B. von Berrybase)
  • ein Gehäuse aus Holz (ca. 3 €), Pappe oder aus dem 3D-Drucker
  • eine Holzleiste aus dem Baumarkt (ca. 2 €)
  • ein Micro-USB-Kabel + Netzteil
  • Cuttermesser, (Säge,) Heißkleber, Sprühlack

Ich habe mich bei diesem Projekt für das 3,7 Zoll E-Paper-/E-Ink- Display von Waveshare entschieden, da es dieses Display in einer speziellen Ausführung für den Raspberry Pi Pico WH gibt. Da der Pico WH bereits über eine eingelötete Stiftleiste verfügt, kann er direkt auf das Display aufgesteckt werden. Löten ist somit zu keiner Zeit erforderlich.

Ursprünglich wollte ich die Uhr mit 3 oder 4 AA-Batterien betreiben. Die Kabel des Batteriefachs kann man wahlweise am Pico anlöten oder man benutzt ein Batteriefach mit USB-Ausgang und betreibt das Gerät per USB. Warum ich mich gegen beide Lösungen entschieden habe, werde ich später noch erläutern.

Als Gehäuse habe ich mich für ein kleines Holzkästchen aus dem „TEDI“ entschieden. Das Kästchen hatte zufällig genau die gewünschten Maße (10 x 15 cm) und das weiche Kieferholz ließ sich leicht mit einem Cuttermesser schneiden. Doch zunächst zur Programmierung:

Programmierung

Die Programmierung hatte ich mir vor dem Start dieses Projektes etwas einfacher vorgestellt. In der Annahme, dass der Pico und die Waveshare-Displays in der „Maker-Szene“ weit verbreitet sind, dachte ich, dass es bestimmt viele Tutorials und Beispiele gibt, wie das Display zu bespielen ist. Dem ist allerdings nicht so. Und auch Chat GPT konnte micht bei meinem Einstieg in MicroPython in vielen Fällen nur sehr bedingt unterstützen.

Ganz entscheidend weitergeholfen, hat mir dieses Modul auf GitHub, das auch Teil meines Codes ist: https://github.com/phoreglad/pico-epaper

Basierend auf den Beipielcodes habe ich folgenden Code entwickelt:

from Pico_ePaper import Eink
import framebuf
from writer import Writer
import utime
from utime import sleep
from machine import RTC
import gc
import time
from fonts import inter_bold_44
import machine
import network
import urequests
import network
import sys
import time
import usocket as socket
import ustruct as struct

import img.n0 as n0
import img.n1 as n1
import img.n2 as n2
import img.n3 as n3
import img.n4 as n4
import img.n5 as n5
import img.n6 as n6
import img.n7 as n7
import img.n8 as n8
import img.n9 as n9
import img.space as space

import img.line1 as line1
import img.line2 as line2

ssid = 'YOUR_SSID'
password = 'YOUR_PASSWORD'

class DummyDevice(framebuf.FrameBuffer):
    def __init__(self, width, height, buf_format):
        self.width = width
        self.height = height
        self._buf = bytearray(self.width * self.height // 8)
        super().__init__(self._buf, self.width, self.height, buf_format)
        self.fill(1)

def display_digit(image, x, y):
    img_tmp = framebuf.FrameBuffer(image.img_bw, image.width, image.height, framebuf.MONO_HLSB)
    epd.blit(img_tmp, x, y)

def fetch_weather_data():
    url = "https://api.open-meteo.com/v1/forecast?latitude=52.028953201377355&longitude=8.533312390444614&current=temperature_2m&daily=temperature_2m_max&timezone=Europe%2FBerlin&forecast_days=1"
    response = urequests.get(url)

    if response.status_code == 200:
        weather_data = response.json()
        response.close()
        return weather_data
    else:
        print("Fehler bei der API-Anfrage. Status-Code:", response.status_code)
        response.close()
        return None

def fetch_power_data():
    url = "YOUR_API_URL"
    response = urequests.get(url)

    if response.status_code == 200:
        power_data = response.json()
        response.close()
        return power_data
    else:
        print("Fehler bei der API-Anfrage. Status-Code:", response.status_code)
        response.close()
        return None    

def display_text(writer, text, x, y):
    Writer.set_textpos(dummy, x, y)
    writer.set_clip(row_clip=True, col_clip=True, wrap=True)
    writer.printstring(text, invert=True)

# Winterzeit / Sommerzeit
GMT_OFFSET = 3600 * 1 # 3600 = 1 h (Winterzeit)
#GMT_OFFSET = 3600 * 2 # 3600 = 1 h (Sommerzeit)

# NTP-Host
NTP_HOST = 'pool.ntp.org'

# Funktion: WLAN-Verbindung
def wlanConnect():
    wlan = network.WLAN(network.STA_IF)
    if not wlan.isconnected():
        print('WLAN-Verbindung herstellen')
        wlan.active(True)
        wlan.connect(ssid, password)
        for i in range(10):
            if wlan.status() < 0 or wlan.status() >= 3:
                break
            print('.')
            time.sleep(1)
    if wlan.isconnected():
        print('WLAN-Verbindung hergestellt / WLAN-Status:', wlan.status())
    else:
        print('Keine WLAN-Verbindung')
        print('WLAN-Status:', wlan.status())

# Funktion: Zeit per NTP holen
def getTimeNTP():
    NTP_DELTA = 2208988800
    NTP_QUERY = bytearray(48)
    NTP_QUERY[0] = 0x1B
    addr = socket.getaddrinfo(NTP_HOST, 123)[0][-1]
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    try:
        s.settimeout(1)
        res = s.sendto(NTP_QUERY, addr)
        msg = s.recv(48)
    finally:
        s.close()
    ntp_time = struct.unpack("!I", msg[40:44])[0]
    return time.gmtime(ntp_time - NTP_DELTA + GMT_OFFSET)

# Funktion: RTC-Zeit setzen
def setTimeRTC():
    # NTP-Zeit holen
    tm = getTimeNTP()
    machine.RTC().datetime((tm[0], tm[1], tm[2], tm[6] + 1, tm[3], tm[4], tm[5], 0))

# WLAN-Verbindung herstellen
wlanConnect()

# Zeit setzen
setTimeRTC()

epd = Eink(rotation=180)

# Zeitstempel für die Aktualisierung
last_update = 0

while True:
    
    rtc = RTC()
    datetime = rtc.datetime()
    
    gc.collect()
    gc.mem_free()

    # Überprüfen, ob seit der letzten Aktualisierung 5 Minuten vergangen sind
    current_time = utime.time()
    
    if current_time - last_update >= 300:
        
        img_tmp = framebuf.FrameBuffer(space.img_bw, space.width, space.height, framebuf.MONO_HLSB)
        epd.blit(img_tmp, 30, 340)
        epd.blit(img_tmp, 143, 340)
        
        # WLAN-Verbindung herstellen
        wlanConnect()

        # Wetterdaten abrufen
        weather_data = fetch_weather_data()

        if weather_data:
            
            current_temperature_2m = weather_data["current"]["temperature_2m"]
            daily_temperature_2m_max = weather_data["daily"]["temperature_2m_max"][0]

            print("Aktuelle Temperatur_2m:", current_temperature_2m, "°C")
            print("Tägliche Temperatur_2m_max:", daily_temperature_2m_max, "°C")
            
            gc.collect()
            gc.mem_free()
            print("Speicherverbrauch nach manueller GC:", gc.mem_alloc() / 1024, "KiB")

            dummy = DummyDevice(epd.width, epd.height, framebuf.MONO_HLSB)
            wri = Writer(dummy, inter_bold_44)

            display_text(wri, f'{current_temperature_2m}', 346, 35)
            display_text(wri, f'{daily_temperature_2m_max}', 346, 141)

            epd.blit(dummy, 0, 0, key=1, ram=epd.RAM_BW)
               
            gc.collect()
            gc.mem_free()
            print("Speicherverbrauch nach manueller GC:", gc.mem_alloc() / 1024, "KiB")

            # Zeitstempel für die letzte Aktualisierung aktualisieren
            last_update = current_time

        '''
        # Stromdaten abrufen
        power_data = fetch_power_data()
        
        if power_data:
            
            now = power_data["now"]
            total_sun_day = power_data["total_sun_day"]
            
            gc.collect()
            gc.mem_free()
            print("Speicherverbrauch nach manueller GC:", gc.mem_alloc() / 1024, "KiB")

            dummy = DummyDevice(epd.width, epd.height, framebuf.MONO_HLSB)
            wri = Writer(dummy, inter_bold_44)

            display_text(wri, f'{now}', 406, 35)
            display_text(wri, f'{total_sun_day}', 406, 141)
            
            epd.blit(dummy, 0, 0, key=1, ram=epd.RAM_BW)
                
            gc.collect()
            gc.mem_free()
            print("Speicherverbrauch nach manueller GC:", gc.mem_alloc() / 1024, "KiB")

        '''       
  
    hour_first_digit = datetime[4] // 10
    hour_second_digit = datetime[4] % 10
    display_digit(eval(f'n{hour_first_digit}'), 30, 50)
    display_digit(eval(f'n{hour_second_digit}'), 30 + eval(f'n{hour_first_digit}').width, 50)
    display_digit(space, 30 + eval(f'n{hour_first_digit}').width + eval(f'n{hour_second_digit}').width, 50)

    minute_first_digit = datetime[5] // 10
    minute_second_digit = datetime[5] % 10
    display_digit(eval(f'n{minute_first_digit}'), 30, 50 + eval(f'n{hour_first_digit}').height)
    display_digit(eval(f'n{minute_second_digit}'), 30 + eval(f'n{minute_first_digit}').width, 50 + eval(f'n{hour_first_digit}').height)
    display_digit(space, 30 + eval(f'n{minute_first_digit}').width + eval(f'n{minute_second_digit}').width, 50 + eval(f'n{hour_first_digit}').height)

    img_tmp = framebuf.FrameBuffer(line1.img_bw, line1.width, line1.height, framebuf.MONO_HLSB)
    epd.blit(img_tmp, 10, 340)
    
    '''
    img_tmp = framebuf.FrameBuffer(line2.img_bw, line2.width, line2.height, framebuf.MONO_HLSB)
    epd.blit(img_tmp, 10, 400)
    '''

    gc.collect()
    gc.mem_free()
    print("Speicherverbrauch nach manueller GC:", gc.mem_alloc() / 1024, "KiB")

    epd.show()
    epd.sleep()
    sleep(60)
    epd.reinit()


Aufklappen

Hierzu einige Erläuterungen:

Uhrzeit
Während der Entwicklung am USB-Port des Rechners wird der Pico vom Rechner mit der aktuellen Uhrzeit versorgt. Da der Pico keine interne Batterie hat, muss die Uhrzeit im Standalone-Betrieb von einem Zeitserver abgerufen werden (s. Z. 80-125)

Bilder
Die großen Ziffern und die Labels unten habe ich als Grafik eingebaut. Da das Display normale Bilddaten nicht verarbeiten kann, müssen alle Bilder vorher in Bytearrays konvertiert werden. Hierfür habe ich das Skript „convertimage.py“ geschrieben, das lokal im Terminal mit folgendem Aufruf ausgeführt werden kann

python3 convertimage.py

Fonts
Die kleinen Ziffern unten sind Text, der mit einem Custom-Font (inter-bold.ttf) dargestellt wird. Da das Display keine TrueType-Fonts darstellen kann, müssen diese ebenfalls vorher konvertiert werden. Hierfür gibt es das Skript „font_to_py.py“, das lokal im Terminal mit folgendem Aufruf ausgeführt werden kann:

python3 font_to_py.py Inter-Bold.ttf 23 inter_bold_23.py -x

Wetterdaten
Die Wetterdaten beziehe ich von der kostenlosen Wetter-Api von Open Meteo. Die Konfiguration sollte selbsterklärend sein.

Daten des Balkonkraftwerks
Für die Daten des Balkonkraftwerks habe ich eine eigene API geschrieben. Weitere Erläuterungen dazu findest du hier: www.fbnfrtg.de/so-kannst-die-lokale-api-des-apsystems-ez-1-auch-mit-php-ohne-python-nutzen/

Raspberry Pi Pico „Autostart“
Wenn das Programm automatisch auf dem Pico starten soll, musst du die Datei in „main.py“ umbenennen.

Probleme bei der Entwicklung

Beim Entwickeln bin auf einige Probleme gestoßen, mit denen ich vorher so nicht gerechnet habe.

Zu wenig Strom
Mein ursprünglicher Plan war, die Uhr mit 4 AA-Batterien zu betreiben. Offenbar benötigt der WLAN-Chip des Pico aber beim Verbindungsaufbau mehr Strom, als die Batterien bereit stellen können. Das funktioniert also nicht. Demnach läuft die Uhr nur via USB-Netzteil.

Zu wenig RAM
Bei meinen ersten Versuchen ist der Pico regelmäßig abgestürzt. Durch den Blick in die Konsole in Thonny war schnell zu erkennen, dass der Speicher (RAM) „voll läuft“. Angeblich soll es in MicroPython nicht notwendig sein, den Speicher manuell freizugeben, in der Praxis habe ich aber andere Erfahrungen gemacht. Durch den Einsatz des „Garbage Collectors“ an mehreren Stellen im Code („gc.collect() gc.mem_free()“), konnte dieses Problem auf jeden Fall gelöst werden.

Das Gehäuse

Als Gehäuse habe ich mich für ein kleines Holzkästchen entschieden. In das weiche Kiefernholz habe ich mit einem Cuttermesser eine 5 x 8 cm große Öffnung für das Display geschnitten. Die Kanten habe ich mit Schleifpapier etwas geglättet. Anschließend habe ich das Kästchen mit Sprühlack lackiert.

Den Pico habe ich mit zwei auf Maß geschnittenen Holzleisten fixiert. Die Holzleisten können mit etwas Heißkleber fixiert werden.

Bei den Maßen des Gehäuses ist zu Bedenken, dass der USB-Stecker recht viel Platz in Anspruch nimmt.

Hier geht´s zum Download des kompletten Codes. Bitte beachte, dass der Code an einigen Stellen noch angepasst werden muss (WLAN-Zugangsdaten, Wetter-API, ggf. BKW-API). Und: Nicht vergessen, die Datei in „main.py“ umzubenennen.

Download

Viel Spaß beim Nachbauen. 🙂


Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert