# main.py - e-ink dashboard dla Raspberry Pi Pico W + Waveshare Pico-ePaper-2.13.
#
# Wyświetlacz to moduł typu HAT, więc poniższe piny SPI są połączone na stałe na płytce
# - nie zmieniaj ich.
#
# Działanie: urządzenie śpi wyświetlając kota. Stuknięcie je wybudza, rysuje
# dashboard i pozostaje aktywne przez AWAKE_MS. Stuknięcia w stanie wybudzenia przełączają
# strony i odnawiają odliczanie timera. Gdy timer minie, ponowanie rysuje kota
# i przechodzi w stan niskiego poboru mocy. Na fizycznym sprzęcie puknięcie to przerwanie
# podwójnego puknięcia z LIS3DH; w Wokwi jest to przycisk GP15, który z punktu widzenia
# kodu wygląda identycznie - jeden impuls na jedno puknięcie.
#
# Wokwi nie posiada modułu 2.13", więc diagram.json używa board-epaper-2in9 podłączonego
# do pinów rzeczywistego HAT-a. Ten sam sterownik, ta sama 4000-bajtowa ramka; panel 2.9" jest po prostu
# fizycznie większy, więc zobaczysz dashboard z pustym marginesem obok.
import json
import machine
import time
import config
import epaper
import gfx
import pages
# --------------------------------------------------------------- konfiguracja
TAP_PIN = 15 # LIS3DH INT1 na sprzęcie; przycisk w Wokwi
AWAKE_MS = 2 * 60 * 1000 # jak długo dashboard pozostaje wybudzony po puknięciu
DEBOUNCE_MS = 250
TRIPLE_TAP_MS = 1800 # trzy puknięcia w tym okienku czasowym otwierają konfigurację
SLEEP_CHUNK_MS = 60 * 1000 # lightsleep w porcjach, nie na zawsze
DATA_FILE = "data.json"
WEATHER_FILE = "weather.json"
# Poza domem nie ma się z czym połączyć, więc nie ma co tracić energii. Skanowanie
# weryfikuje każdą próbę; te zmienne określają jedynie jak często w ogóle próbujemy.
SYNC_MIN_S = 20 * 60 # nie pobieraj ponownie częściej niż tak
BACKOFF_S = (0, 300, 900, 3600) # po 1, 2, 3+ kolejnych niepowodzeniach
USE_NETWORK = True # NTP + pogoda na żywo i przypomnienia przez netsync.py
USE_PORTAL = True # portal konfiguracyjny AP na urządzeniu (patrz portal.py)
USE_LIGHTSLEEP = True # przechodzi automatycznie w tryb awaryjny, jeśli port tego odmówi
UNDERCLOCK = None # np. 48000000 podczas snu. Najpierw przeczytaj README:
# poniżej ~48 MHz konsola szeregowa USB przestaje działać.
FLIP = False # True, jeśli obraz pojawia się do góry nogami
# Geometria RAM panelu (szerokość, wysokość) w pionie. Układy się dostosowują,
# więc możesz tworzyć kod dla dowolnego z nich.
# (122, 250) Waveshare Pico-ePaper-2.13 - rzeczywisty sprzęt
# (128, 296) symulowany 2.9" w Wokwi - wypełnia symulowany ekran, ale
# to co widzisz jest wtedy o 46px szersze niż panel, który posiadasz
PANEL = (128, 296)
DAYS = ("PON", "WTO", "ŚRO", "CZW", "PIĄ", "SOB", "NIE")
DAY_NAMES = ("PONIEDZIAŁEK", "WTOREK", "ŚRODA", "CZWARTEK", "PIĄTEK", "SOBOTA", "NIEDZIELA")
MONTHS = ("STY", "LUT", "MAR", "KWI", "MAJ", "CZE", "LIP", "SIE", "WRZ", "PAŹ", "LIS", "GRU")
# --------------------------------------------------------------- dane
# Element to {"at": "13:30", "text": "..."}. "at" jest opcjonalne - pomiń dla
# zadań bez określonej godziny, które trafią na sam dół. "done" też jest opcjonalne:
# bez niego wszystko, czego czas już minął, uznaje się za wykonane, dzięki czemu płytka
# sama dba o aktualność bez ingerencji użytkownika.
#
# Celowo puste. Przypomnienia pochodzą ze strony konfiguracji (potrójne puknięcie) lub z
# kanału informacyjnego; wpisanie tu czegokolwiek stworzyłoby na ekranie fikcję wyglądającą
# dokładnie jak prawdziwe dane.
DEFAULT_AGENDA = []
def _stamp(items):
"""Prosty hash wbudowanej listy, dzięki czemu edycja DEFAULT_AGENDA przynosi efekt
nawet wtedy, gdy plik pamięci podręcznej znajduje się już w systemie plików."""
h = 0
for it in items:
for ch in (it.get("at") or "") + it.get("text", ""):
h = (h * 31 + ord(ch)) & 0xFFFFFFF
return h
def load_weather():
try:
with open(WEATHER_FILE) as f:
return json.load(f)
except Exception:
return None
def save_weather(rec):
try:
with open(WEATHER_FILE, "w") as f:
json.dump(rec, f)
except Exception as e:
print("nie można zapisać %s: %s" % (WEATHER_FILE, e))
def load_agenda():
"""Zapisane w pamięci flash, dzięki czemu przetrwa ponowne uruchomienie bez sieci.
Pamięć podręczna pochodząca z kanału informacyjnego zawsze wygrywa;
ta z DEFAULT_AGENDA jest odrzucana, gdy tylko edytujesz DEFAULT_AGENDA."""
try:
with open(DATA_FILE) as f:
blob = json.load(f)
if blob.get("from_feed") or blob.get("stamp") == _stamp(DEFAULT_AGENDA):
return blob["agenda"]
print("wbudowana agenda uległa zmianie, używam jej")
except Exception:
pass
save_agenda(DEFAULT_AGENDA)
return [dict(i) for i in DEFAULT_AGENDA]
def save_agenda(agenda, from_feed=False):
try:
with open(DATA_FILE, "w") as f:
json.dump({"agenda": agenda, "from_feed": from_feed, "stamp": _stamp(DEFAULT_AGENDA)}, f)
except Exception as e:
print("nie można zapisać %s: %s" % (DATA_FILE, e))
def get_time():
y, mo, d, wd, h, mi, s, _ = machine.RTC().datetime()
return {
"mins": h * 60 + mi,
"hour": h,
"hhmm": "%02d:%02d" % (h, mi),
"date": "%s %02d %s" % (DAYS[wd], d, MONTHS[mo - 1]),
"dow": DAYS[wd],
"day": d,
"month": MONTHS[mo - 1],
"weekday": DAY_NAMES[wd],
"year": y,
}
def prepare_agenda(items, now_mins):
# Przetwórz godziny, ustal co już minęło, posortuj. Elementy z określoną godziną pierwsze według zegara, elementy bez godziny po nich
out = []
for it in items:
mins = None
at = it.get("at")
if at:
try:
hh, mm = at.split(":")
mins = int(hh) * 60 + int(mm)
except Exception:
at = None
done = it.get("done")
if done is None:
done = mins is not None and mins < now_mins
out.append({"at": at, "text": it.get("text", ""), "mins": mins, "done": done})
out.sort(key=lambda i: (i["mins"] is None, i["mins"] or 0))
return out
# --------------------------------------------------------------- puknięcie
_tapped = False
_last_tap = 0
_run = 0 # puknięcia wykryte w krótkim odstępie czasu
_run_start = 0
def _on_tap(pin):
global _tapped, _last_tap, _run, _run_start
now = time.ticks_ms()
if time.ticks_diff(now, _last_tap) < DEBOUNCE_MS:
return
_last_tap = now
_tapped = True
if _run and time.ticks_diff(now, _run_start) < TRIPLE_TAP_MS:
_run += 1
else:
_run = 1
_run_start = now
def take_tap():
global _tapped
if _tapped:
_tapped = False
return True
return False
def triple_tapped():
"""Trzy puknięcia wewnątrz okienka TRIPLE_TAP_MS. Działa tak samo dla przycisku jak i czujnika puknięć,
przy czym długie przytrzymanie nie działa - LIS3DH zgłasza puknięcia jako impulsy, więc nie ma czego trzymać."""
global _run
if _run >= 3 and time.ticks_diff(time.ticks_ms(), _run_start) < TRIPLE_TAP_MS:
_run = 0
return True
return False
# --------------------------------------------------------------- zasilanie
def radio_off():
if not USE_NETWORK:
return
try:
import network
except ImportError:
return # to nie jest płytka W, nie ma czego wyłączać
try:
wlan = network.WLAN(network.STA_IF)
wlan.disconnect()
wlan.active(False)
except Exception as e:
print("wyłączenie radia nie powiodło się:", e)
def low_power():
# Panel znajduje się już we własnym głębokim śnie trzymając kota, więc jedyne co zostało to radio i taktowanie zegara
radio_off()
if UNDERCLOCK:
try:
machine.freq(UNDERCLOCK)
except Exception as e:
print("obniżenie taktowania nie powiodło się:", e)
def full_power():
if UNDERCLOCK:
try:
machine.freq(125000000)
except Exception as e:
print("przywrócenie taktowania nie powiodło się:", e)
_can_lightsleep = USE_LIGHTSLEEP
def wait_for_tap():
"""Blokuj, dopóki ktoś nie puknie. Przy zasilaniu z baterii to tutaj mija prawie cały czas rzeczywisty,
więc jest to jedyna część warta optymalizacji - i jedyny powód, by woleć lightsleep od pętli odpytującej (polling loop)."""
global _can_lightsleep
while not _tapped:
if _can_lightsleep:
try:
machine.lightsleep(SLEEP_CHUNK_MS) # przerwanie IRQ wybudza go wcześniej
continue
except Exception as e:
# Obsługa tego w Wokwi jest niezawodna, a port który tego odmówi nie powinien pozostawiać płytki bez odpowiedzi.
print("lightsleep niedostępny (%s), odpytywanie w zastępstwie" % e)
_can_lightsleep = False
time.sleep_ms(200)
# --------------------------------------------------------------- main
def main():
spi = machine.SPI(1, baudrate=4000000, polarity=0, phase=0, sck=machine.Pin(10), mosi=machine.Pin(11))
epd = epaper.EPD(spi, machine.Pin(9), machine.Pin(8), machine.Pin(12), machine.Pin(13), width=PANEL[0], height=PANEL[1])
gfx.configure(PANEL[1], PANEL[0]) # poziom: najpierw długa oś
canvas = gfx.Canvas(flip=config.get("flip", FLIP))
tap = machine.Pin(TAP_PIN, machine.Pin.IN, machine.Pin.PULL_DOWN)
tap.irq(trigger=machine.Pin.IRQ_RISING, handler=_on_tap)
# Zegar rezerwowy. NTP nadpisze go, jeśli radio się uruchomi.
machine.RTC().datetime((2026, 9, 19, 5, 13, 5, 0, 0))
import netsync
raw_agenda = load_agenda()
record = load_weather() or {}
# Płytka pozostaje podłączona do zasilania, więc RTC przetrwa uśpienie. Nie przetrwa zresetowania,
# a błędny zegar jest gorszy niż oczywisty brak zegara - więc jeśli wygląda na nieustawiony, pożycz czas z ostatniej synchronizacji i zgłoś to
if machine.RTC().datetime()[0] < 2025 and record:
t = time.localtime(int(record["fetched"]) + record.get("tz", 0))
machine.RTC().datetime((t[0], t[1], t[2], t[6], t[3], t[4], t[5], 0))
data_time_ok = False
print("zegar nie był ustawiony, przywrócono z ostatniej synchronizacji")
else:
data_time_ok = True
data = {
"time": get_time(),
"weather": netsync.project(record),
"agenda": [],
"online": False if USE_NETWORK else None,
"highlight": config.get("highlight") or None,
"time_ok": data_time_ok,
}
def push():
epd.init()
epd.display_frame(canvas.to_panel())
epd.sleep()
def show(page):
data["time"] = get_time()
# przeliczaj przy każdym rysowaniu, żeby zadania wygasały, a wykres opadów deszczu
# przesuwał się w ciągu dnia, nawet jeśli nic nie zostało pobrane od rana w domu
data["agenda"] = prepare_agenda(raw_agenda, data["time"]["mins"])
data["weather"] = netsync.project(record)
pages.draw(canvas, page, data)
push()
print("strona %d o %s (online=%s)" % (page, data["time"]["hhmm"], data["online"]))
def show_sleep():
pages.page_sleep(canvas, data)
push()
print("przechodzenie w stan uśpienia")
def setup():
"""Uruchom AP konfiguracyjny i utrzymuj go, aż zostanie zapisany lub minie czas.
Zwraca True, jeśli konfiguracja uległa zmianie."""
if not USE_PORTAL:
return False
try:
import portal
except ImportError:
return False
def on_screen(ssid, addr, ap_mode=True):
pages.page_setup(canvas, ssid, addr, portal.QR_DATA, portal.QR_SIZE, ap_mode)
push()
def _set_agenda(items):
raw_agenda[:] = items
save_agenda(items, from_feed=True)
try:
saved = portal.run(on_screen, get_agenda=lambda: raw_agenda, set_agenda=_set_agenda)
except Exception as e:
# brak radia, odmowa AP, gniazdo w użyciu - nic z tego nie powinno
# wyłożyć całego dashboardu
print("konfiguracja niedostępna:", e)
return False
if saved:
data["highlight"] = config.get("highlight") or None
canvas.flip = config.get("flip", FLIP)
return saved
def resync():
"""True, jeśli cokolwiek na ekranie powinno się zmienić. Uzależnione od tego, czy sieć domowa jest w zasięgu,
więc dzień poza domem kosztuje jedno skanowanie na wybudzenie zamiast dziesięciosekundowego limitu czasu na puknięcie."""
if not USE_NETWORK:
return False
now = time.time()
if state["fails"] and now - state["last_try"] < BACKOFF_S[min(state["fails"], len(BACKOFF_S) - 1)]:
return False
if state["last_ok"] and now - state["last_ok"] < SYNC_MIN_S:
return False
if not netsync.home_in_range():
was = data["online"]
data["online"] = False
state["last_try"] = now
return was is not False
state["last_try"] = now
was = data["online"]
data["online"] = netsync.connect(timeout_ms=10000)
if not data["online"]:
state["fails"] += 1
return was is not False
state["fails"] = 0
rec = netsync.fetch_weather()
if rec:
record.clear()
record.update(rec)
save_weather(rec)
netsync.sync_clock(rec.get("tz", 0))
data["time_ok"] = True
state["last_ok"] = time.time()
feed, remote_cfg = netsync.fetch_agenda()
if feed is not None:
raw_agenda[:] = feed
save_agenda(feed, from_feed=True)
if config.merge_remote(remote_cfg):
data["highlight"] = config.get("highlight") or None
canvas.flip = config.get("flip", FLIP)
return True
state = {"fails": 0, "last_try": 0, "last_ok": 0}
# Nic jeszcze nie zapisano. Najpierw spróbuj połączyć się z siecią - w Wokwi
# wirtualny punkt dostępowy jest otwarty i zawsze w zasięgu, więc symulator
# nigdy nie pokazuje ekranu konfiguracji. Dopiero gdy to się nie powiedzie, prosimy o dane dostępowe.
# Sieć, która po prostu przestaje działać, NIE trafia tutaj: niestabilny router
# powinien zostawić Cię ze nieaktualnymi danymi, a nie formularzem konfiguracji.
if USE_NETWORK and USE_PORTAL and not config.configured():
import netsync
if not netsync.connect(timeout_ms=10000):
print("brak skonfigurowanego wifi, otwieranie konfiguracji")
setup()
while True:
# ---------------------------------------------------------- stan aktywności
full_power()
take_tap() # przechwyć puknięcie, które nas wybudziło
page = 0 # puknięcie powinno zawsze przenosić na stronę główną
show(page)
# Wybudzenia są wywoływane puknięciem i rzadkie, więc próbuj przy każdym - resync() jest
# tani, kiedy nie ma sieci domowej do znalezienia.
if USE_NETWORK and resync():
show(page)
awake_ms = config.get("awake_s", AWAKE_MS // 1000) * 1000
idle_since = time.ticks_ms()
while time.ticks_diff(time.ticks_ms(), idle_since) < awake_ms:
if triple_tapped():
if setup():
show(page)
idle_since = time.ticks_ms()
elif take_tap():
page = (page + 1) % pages.N_PAGES
show(page)
idle_since = time.ticks_ms() # każde puknięcie wydłuża czas aktywności
else:
time.sleep_ms(50)
# ---------------------------------------------------------- stan uśpienia
show_sleep()
low_power()
wait_for_tap()
main()Loading
epaper-2in9
epaper-2in9