Kiedyś tych wyświetlaczy opisywanych jako wyświetlacz od Nokii 3310/5110 (co niezupełnie jest prawdą, bo te w blaszanej obudowie są z 5110 i może kilku innych modeli, a w 3310 były jednak nieco inne, gdzie był plastik zamiast blachy), było przez wiele lat od zatrzęsienia na Ali czy w dowolnym sklepie z częściami elektronicznymi dla hobbystów.
(a nie są produkowane minimum od kilkunastu lat, jeśli nie znacznie dłużej),
Teraz nie ma ich niemal wcale, co pozwala mi wierzyć, że faktycznie te zamontowane na płytkach dedykowanych do DIY realnie pochodziły z recyklingu starych telefonów… (szczególnie, że kiedyś kupiłem trójpak i pierwszy wyjęty z opakowania wyświetlacz wyglądał jak psu z gardła wyjęty - blacha jakby młotkiem prostowana, a szkło ma kilka poważnych odprysków… na szczęście pozostałe 2 egzemplarze wyglądają lepiej).
By nie pisać po próżnicy sprawdziłem dostępność i łudząco podobne można nadal kupić na amazonii, pojedynczo i w wielopakach.
foto nokia
Tak w ogóle zacząłem to skrobać, bo kiedyś obiecałem kilka słów o testowaniu wyświetlaczy i obiecałem posta, a biorąc pod uwagę stan tego odrapańca oczywiście pierwsze co zrobiłem to kilka testów (no i… jest sprawny i na pierwszy rzut oka wygląda w miarę OK, ale zdjęcia okrutnie eksponują każdą wadę…), a generalnie to ten wyświetlacz nie jest najprzyjaźniejszy w skonfigurowaniu, ale da się z niego wycisnąć ostatnie soki ;D u mnie ma służyć do samoróbki czujników jakości powietrza, ale kusiło mnie by nie był totalnie nudny i dorobiłem też zegarek analogowy do wyświetlania (co w sumie nie było szczególnie łatwe, bo zachciało mi się wyświetlania okrągłej tarczy, ale o tym później)
1. konfiguracja platformy wyświetlacza i kilku innych rzeczy, które wykorzystamy później
ten YAML jest tylko fragmentarycznym wykopiowaniem z większej całości, więc nie gwarantuję, że czegoś nie przeoczyłem, doświadczeni chyba sobie poradzą, a dla zielonych listków wrzucę gotowy kompletny YAML i dodatkowe pliki na repozytorium w późniejszym terminie (bo projekt miernika eVOC i eCO2 mam wciąż rozgrzebany)
Uwaga omijam też bazową konfigurację płytki MCU - dzięki temu, że wyświetlacz ma małą rozdzielczość nie ma wielkich wymagań odnośnie zasobów MCU, więc to się daje uruchomić zarówno na ESP8266 jak i RPi Pico W jak i oczywiście na ESP32 oraz ESP32-xx (powinno być OK na S2, C3, S3, a może i innych modelach), nie wiem jak to wygląda na platformach LibreTiny.
time:
- platform: sntp
id: sntp_time # czas polski dla zegarka
timezone: Europe/Warsaw
servers:
- 0.pl.pool.ntp.org
- 1.pl.pool.ntp.org
- 2.pl.pool.ntp.org
interval:
- interval: 10s
then:
- display.page.show_next: nokia # wyświetlacz Nokia (co 10s)
light:
- platform: monochromatic
id: back_light # podświetlenie ekranu
output: backlight_pwm
name: "Podświetlenie LCD"
restore_mode: RESTORE_DEFAULT_ON
output:
- platform: rp2040_pwm # UWAGA uruchamiałem to na RPi Pico W, a w zależności od MCU należy użyć esp8266_pwm czy ledc dla ESP32xx
pin: GPIO11 # musi być zgodny z rzeczywistością
id: backlight_pwm
inverted: true
sensor:
- platform: wifi_signal
id: wifi_sgn # na wyświetlaczu będzie też prosty wskaźnik WiFi
name: "Siła sygnału WiFi"
update_interval: 60s
font:
- file: "fonts/Lato-Regular.ttf"
id: fnt_lto_10
size: 10
bpp: 1 # dla ekranów monochromatycznych
glyphs:
- " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~°…©®™€"
- "ąćęłńóśźżĄĆĘŁŃÓŚŹŻ" # ręczne dodanie polskich znaków, ale jeśli nie są wymagane linijka jest zbędna
qr_code:
- id: homepage_qr
value: !secret qr_wifi
spi:
clk_pin: GPIO14 # musi być zgodny z rzeczywistością, to jest przykładowy dobór akurat dla RPi Pico W
mosi_pin: GPIO15 # musi być zgodny z rzeczywistością
display:
- platform: pcd8544 # pobór <1mA bez podświetlenia - robiłem sobie bilans energetyczny podzespołów, bo użyte w projekcie czujniki są energochłonne…
data_rate: 4MHz
reset_pin: GPIO16 # musi być zgodny z rzeczywistością
cs_pin: GPIO13 # musi być zgodny z rzeczywistością
dc_pin: GPIO17 # musi być zgodny z rzeczywistością
rotation: 180° # możliwe opcje: 0, 90, 180, 270, moim zdaniem montaż tylko jako landscape, zastosowałem odwrócenie "do góry nogami", bo wolę wielki kawał blachy na dole, a nie wielki margines jak w telefonie u góry
contrast: 0x3f
update_interval: 1000ms
id: nokia
pages:
# no i tutaj wrzucamy strony by było co wyświetlać
Użyty wyżej font można pobrać tam
https://www.latofonts.com/download/
albo
https://fonts.google.com/specimen/Lato?preview.script=Latn&preview.lang=pl_Latn
ewentualnie w innych miejscach w internecie.
Wybrane pliki po rozpakowaniu wrzucone do podkatalogu fonts w katalogu konfiguracyjnym esphome (dla wyświetlacza PCD8544 wystarcza Lato-Regular.ttf ale dla wyświetlaczy z wyższą rozdzielczością można wybrać też inne wagi, czyli grubości).
Czemu akurat padł wybór na ten font - otóż zero renderuje się bez tzw. skreślenia, co jest kluczowe dla czytelności (nie myli się z 8) przy tak drastycznie małej rozdzielczości jaką oferuje PCD8544; jest darmowy, a licencja zezwala na dość dowolny użytek (więc udostępniając nie mam dylematów moralnych czy nie łamię licencji, której dla wielu innych fontów dostępnych w internecie nie znalazłem).
2. obiecane testy
UWAGA
dla łatwego wklejania (by zachować wcięcia!) wrzuciłem w kawałki kodu YAMLowe komentarze zaczynające się od 1 kolumny krzyżykiem (hash), ale po udanym wklejeniu należy je wywalić (jeśli wymyślę jak we wstawki z kodem wklejać lambdy by się łatwo wklejały to może to jakoś zmienię)
a) absolutnie najbardziej podstawowy test - zapalenie i zgaszenie wszystkich pikseli w obszarze całego aktywnego obszaru wyświetlacza, dzięki temu zobaczymy czy nie ma wadliwych pikseli
# "czarno" i "biało", UWAGA po wkelejeniu usuń tę linię!
- id: page1
lambda: |-
it.fill(COLOR_ON);
- id: page2
lambda: |-
it.fill(COLOR_OFF);
foto
b) tzw. koperta, czyli ramka obrysowująca brzegi aktywnego obszaru wyświetlacza + linie po przekątnych, to się głównie nadaje do spasowania wyświetlacza z obudową podczas ostatecznego montażu
# "koperta", skrajne współrzędne są pobierane z komponentu, więc to się nadaje do wyświetlacza o dowolnych rozmiarach, po ewentualnym uzupełnieniu o kolor
# ale jest kluczowe jego rozdzielczość była właściwe zdefiniowana, komponent pcd8544 definiuje to jako 84x48pix
- id: page3
lambda: |-
it.rectangle(0, 0, it.get_width(), it.get_height());
it.line(0, 0, it.get_width(), it.get_height());
it.line(0, it.get_height(), it.get_width(), 0);
no cóż ten test mnie trochę zawiódł, bo w tak prostej wersji nie widać dobrze, że piksele wcale nie są kwadratowe…
foto
ale można go nieco zmodyfikować dorysowując kółko…
# koperta z kółkiem o promieniu nieco mniejszym od połowy wysokości
- id: page4
lambda: |-
it.rectangle(0, 0, it.get_width(), it.get_height());
it.line(0, 0, it.get_width(), it.get_height());
it.line(0, it.get_height(), it.get_width(), 0);
it.circle(it.get_width()/2, it.get_height()/2, (it.get_height()/2)-2);
i tu dochodzimy do clou - ono jest… jajowate
foto
ta jajowatość wynika z faktu, że piksele nie są kwadratowe, lecz prostokątne, a tymczasem rendering engine w ESPHome zakłada stosunek szerokości do wysokości piksela 1:1 (jaki jest powszechnie spotykany w całkiem współczesnych wyświetlaczach).
Ze specyfikacji wyświetlacza Goldentek Display System GG0804A1FSN6G (bo to najprawdopodobniej ten model) znanego też jako LPH7366 wynika, że piksele wraz z marginesem je rozgraniczającym mają rozmiar 0.345x0.405mm czyli mają stosunek boków 69 do 81 który sobie zaokrągliłem do 1:1.1739 (wstępny eksperyment z oszacowaną na oko proporcją 4:5 → 1:1.25 też był całkiem udany)
dodatkowo ten test świetnie wizualizuje skutki zaokrągleń do wartości całkowitych
c) szachownica - też świetnie obrazuje zniekształcenia (pola równej szerokości po 4pix)
# a właściwie szachownice
- id: page5
lambda: |-
int size = 4; // Rozmiar boku kwadratu, który pokazuje, że nie jest kwadratem…
for (int y = 0; y < it.get_height(); y += size) {
for (int x = 0; x < it.get_width(); x += size) {
// Jeśli suma indeksów pól (x/size + y/size) jest parzysta, rysuj kwadrat
if (((x / size) + (y / size)) % 2 == 1) {
it.filled_rectangle(x, y, size, size);
}
}
}
- id: page6
lambda: |-
int size = 4; // jak wyżej, tym razem negatyw…
for (int y = 0; y < it.get_height(); y += size) {
for (int x = 0; x < it.get_width(); x += size) {
// Jeśli suma indeksów pól (x/size + y/size) jest parzysta, rysuj kwadrat
if (((x / size) + (y / size)) % 2 == 0) {
it.filled_rectangle(x, y, size, size);
}
}
}
- id: page7
lambda: |-
// a teraz coś co wizualnie wygląda jak kwadraty
int w = 5; // Szerokość w pikselach
int h = 4; // Wysokość w pikselach (mniej, bo prostokątne piksele mają mniej więcej proporcje 4:5)
for (int y = 0; y < it.get_height(); y += h) {
for (int x = 0; x < it.get_width(); x += w) {
// Logika szachownicy: suma indeksów kolumny i wiersza
if (((x / w) + (y / h)) % 2 == 0) {
it.filled_rectangle(x, y, w, h);
}
}
}
- id: page8
lambda: |-
int w = 5; // a teraz negatyw orzedniego obrazka
int h = 4;
for (int y = 0; y < it.get_height(); y += h) {
for (int x = 0; x < it.get_width(); x += w) {
// Logika szachownicy: suma indeksów kolumny i wiersza
if (((x / w) + (y / h)) % 2 == 1) {
it.filled_rectangle(x, y, w, h);
}
}
}
można nieco migać tymi szachownicami (tutaj co sekundę), to świetnie obrazuje czas potrzebny na przerysowanie wyświetlacza, zajmuje mu to coś koło ćwierci sekundy, większość współczesnych telefonów oferuję nagrania w zwolnionym tempie - warto to sobie nagrać
- id: page23
lambda: |-
int w = 5;
int h = 4;
int flash = (millis() / 1000) % 2;
for (int y = 0; y < it.get_height(); y += h) {
for (int x = 0; x < it.get_width(); x += w) {
// Dodajemy 'flash' do warunku, co odwraca szachownicę w każdym cyklu
if (((x / w) + (y / h) + flash) % 2 == 0) {
it.filled_rectangle(x, y, w, h);
}
}
}
3. tytułowy zegarek
# no i właśnie tu zapomniałem tej linijki i bez niej wcięcia się rozjechały (skasuj to)
- id: page1
lambda: |-
// Zegar analogowy
auto time = id(sntp_time).now();
int x_c = 29;
int y_c = 24;
//float aspect = 1.25;
float aspect = 1.1739;
int r = 23;
// Okrągła ramka cyferblatu (trzy warstwy dla pełnego wypełnienia)
// próbowałem to optymalizować dla świętego spokoju, by było mniej obliczeń, jakkolwiek RP2040 daje z tym radę
// (można zwiększyć krok z 1 do 2 stopni, ale traci na wyglądzie, przy 3 nie do zaakceptowania)
for (int i = 0; i < 360; i += 1) {
float rad = i * M_PI / 180.0;
for (float dr : {0.0f, 0.5f, 1.0f}) {
it.draw_pixel_at(x_c + round((r - dr) * sin(rad) * aspect), y_c - round((r - dr) * cos(rad)));
}
}
// Znaczniki godzin (główne 12=0, 3, 6, 9 - grube 3-pikselowe, skośne 2-pikselowe)
for (int i = 0; i < 12; i++) {
float angle = i * 30 * M_PI / 180.0;
bool is_main = (i % 3 == 0); // 12, 3, 6, 9
if (is_main) {
// główne godziny 3 linie (środek + 2 boki)
int len = 6;
float off = 2.0 * M_PI / 180.0;
// Środkowa
it.line(x_c + round((r - 1) * sin(angle) * aspect), y_c - round((r - 1) * cos(angle)),
x_c + round((r - 1 - len) * sin(angle) * aspect), y_c - round((r - 1 - len) * cos(angle)));
// Prawa (+off)
it.line(x_c + round((r - 1) * sin(angle + off) * aspect), y_c - round((r - 1) * cos(angle + off)),
x_c + round((r - 1 - len) * sin(angle + off) * aspect), y_c - round((r - 1 - len) * cos(angle + off)));
// Lewa (-off)
it.line(x_c + round((r - 1) * sin(angle - off) * aspect), y_c - round((r - 1) * cos(angle - off)),
x_c + round((r - 1 - len) * sin(angle - off) * aspect), y_c - round((r - 1 - len) * cos(angle - off)));
} else {
// pozostałe odziny skosy ~45°/135° skrócone do 2 pikseli
int len_diag = 2; // Skrócono o 1 piksel
int x_start = x_c + round((r - 1) * sin(angle) * aspect);
int y_start = y_c - round((r - 1) * cos(angle));
// Dobór kierunku skosu do wnętrza tarczy
int dx = (x_start > x_c) ? -len_diag : len_diag;
int dy = (y_start > y_c) ? -len_diag : len_diag;
it.line(x_start, y_start, x_start + dx, y_start + dy);
}
}
// Wskazówka godzinowa najgrubsza
float h_ang = (time.hour % 12 * 30 + time.minute * 0.5) * M_PI / 180.0;
int h_len = 11;
for(float a_off : {-0.04f, 0.0f, 0.04f}) {
it.line(x_c, y_c, x_c + round(h_len * sin(h_ang + a_off) * aspect), y_c - round(h_len * cos(h_ang + a_off)));
}
// Wskazówka minutowa średnia
float m_ang = (time.minute * 6) * M_PI / 180.0;
int m_len = 17;
for(float a_off : {0.0f, 0.03f}) {
it.line(x_c, y_c, x_c + round(m_len * sin(m_ang + a_off) * aspect), y_c - round(m_len * cos(m_ang + a_off)));
}
// Sekundnik wąski 1px "lewitujący"
float s_ang = time.second * 6 * M_PI / 180.0;
it.line(x_c + round(8 * sin(s_ang) * aspect), y_c - round(8 * cos(s_ang)),
x_c + round(20 * sin(s_ang) * aspect), y_c - round(20 * cos(s_ang)));
// Wypełnienie środka (łączenia wskazówek)
it.filled_circle(x_c, y_c, 2);
// Panel boczny - część tekstowa zegarka (font Lato 10)
// Polskie skróty nazw dni tygodnia i miesięcy
// (to taka moja wariacja wynikająca z ograniczonej ilości miejsca, stąd też braki interpunkcyjne)
static const char* dni[] = {"Ndz.", "Pon.", "Wt.", "Śr.", "Czw.", "Piąt.", "Sob."};
static const char* mce[] = {"Sty", "Lut", "Mar", "Kwi", "Maj", "Cze", "Lip", "Sie", "Wrz", "Paź", "Lis", "Gru"};
int right_edge = 84;
// Flaga migania (true co drugą sekundę)
bool blink = (time.second % 2 == 0);
// Godzina + minuta (dwukropek miga - zamiana na spację w parzystych sekundach)
it.printf(right_edge, 0, id(fnt_lto_10), TextAlign::TOP_RIGHT,
blink ? "%02d:%02d" : "%02d %02d", time.hour, time.minute);
// Sekundy (dwukropek przed sekundami miga synchronicznie)
it.printf(right_edge, 10, id(fnt_lto_10), TextAlign::TOP_RIGHT,
blink ? ":%02d" : " %02d", time.second);
// Dzień tygodnia
it.printf(right_edge, 26, id(fnt_lto_10), TextAlign::TOP_RIGHT, "%s", dni[time.day_of_week - 1]);
// Data (dzień + miesiąc)
it.printf(right_edge, 37, id(fnt_lto_10), TextAlign::TOP_RIGHT, "%d%s", time.day_of_month, mce[time.month - 1]);
// Separator: kropka, dwie przerwy (2pix), kropka... ten mi się podoba
for (int x = 62; x <= 84; x += 3) {
it.draw_pixel_at(x, 24);
}
// Opcjonalnie separator: linia
//it.line(62, 24, 84, 24);
// albo separator: kropka, przerwa, kropka...
//for (int x = 62; x <= 84; x += 2) {
// it.draw_pixel_at(x, 24);
//}
// Wskaźnik sygnału WiFi (x=0, y=0, h=10)
// normalnie rysuję go na końcu kodu każdej strony - to taki trik umożliwiający rysowanie go tam gdzie już jest np. jakaś ikona
// (nawet jeśli grafika jest biała, to rysowana w kodzie później zakryłaby wskaźnik)
float dbm = id(wifi_sgn).state;
// Mapowanie dBm na poziom 0.0 - 1.0 (zakres -100 do -30)
float level = (dbm + 100.0) / 70.0;
//float level = (-80 + 100.0) / 70.0; // to jest pozostałość testu - symulowałem niski sygnał
if (level < 0 || isnan(dbm)) level = 0.0;
if (level > 1.0) level = 1.0;
int wx = 0; // lewa krawędź
int wy = 0; // sama góra
int ww = 5; // szerokość (bezpieczna i dla zegara i dla ikon)
int wh = 10; // wysokość całkowita
// czyścimy trójkątny obszar, by wskaźnik mógł nakryć jakąś grafikę gdyby wyszła w tym rogu
// tak trochę zrobione na kolanie, bo z góry założyłem, że proporcje wskaźnika będą mniej więcej 1:2
it.filled_triangle(wx, wy, wx, wh + 2, ww + 1, wy, COLOR_OFF);
// mapujemy poziom sygnału na liczbę kresek (0 do 5)
int lines_to_draw = (int)(level * 5);
for (int i = 0; i <= lines_to_draw; i++) {
int current_y = wy + wh - (i * 2); // od dołu (wh) w górę
int lw = (i * ww) / 5; // szerokość rośnie wraz z wysokością kreski
it.line(wx, current_y, wx + lw, current_y);
}
Tak wygląda wersja w proporcjach 4 na 5 (jak teraz oglądam te zdjęcia, to chyba trzeba znaleźć jakąś wartość pośrednią, bo ani “na oko” nie było idealnie, ani ścisła matematyka się do końca nie sprawdza)
a tak wyglądała jedna z wstępnych wersji
może warto wrócić do grubszych wskazówek?
Robiem po drodze tyle zmian, że tylko w kometarzach zostały pogrubienia…
A zdjęcia to moglem dać w mniejszej rozdzielczości - przypomnę piksel ma rozmiary ułamka milimetra.
4. Kilka linków z ciekawymi lub istotnymi informacjami
oficjalna dokumentacja ESPHome dla kontrolera PCD8544
podstrona projektu serdisplib (sprzed ponad 20 lat) do sterowania różnymi wyświetlaczami z portu równoległego dziś archaicznych komputerów (zawiera unikalną dokumentację i ciekawe linki)
adafruit - kiedyś mieli takie wyświetlacze w ofercie, ale jest nadal dobra dokumentacja
sparkfun - też latami mieli takie wyświetlacze w ofercie i też jest trochę dokumentacji
to będzie uzupełniane na miarę wolnego czasu (niektóre materiały jak choćby zdjęcia wymagają obróbki), więc możecie nie komentować chwilowej pustki…



