Final Project
Pockety (name subject to change): Pocket Productivity Device
Overivew
In current times we need our technological devices for most tasks like looking at our to-do’s, calender or tasks such as timing stuff. Though these are crucial tasks since now we have devices a myriad of functions turning them on for these may lead to hours of distraction particularly caused by social media apps directing the user away from their task. Hence, comes in Pockety a device which gives the user a medium to easily acsess this functionlity and not get distracted as a pocket sized e-ink device made for this. Intially planned as a simple utility device containing apps such as: to-do’s, calender, notes, timer as need rises further functionality will be implemented. Additionally, the final goal for the device, if time permits, is becoming a writer deck: a distractionless writing device.
Motivation
I personally believe that technology ia great though it must be used with intenitonally, meaning one shouldn’t drift from their main purpose when they’re using a device. Thus, I wanted to build a final project like pocekty to have a robust easy to carry device to navigate daily tasks thorugh a distractionless device to keep focus on the tasks at hand. Additionally, I am a person who desires to build his own devices since as long as I remember and thus want to work toward that goal with this final project.
Example / Similar projects
example 1: sample writerdeck

example 2: Xtenik X4

Sketch v 1.0.0

Sketch v 2.0.0 –> next step

Projected Project Timeline
| Weeks | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Preparation and Research | X | |||||||||||||||||
| Prototyping – Design | X | |||||||||||||||||
| Prototyping – Interface | X | X | ||||||||||||||||
| Prototyping – Electronics | X | |||||||||||||||||
| Implementation – PCB | X | X | X | X | ||||||||||||||
| Implementation – CAD | X | X | X | |||||||||||||||
| Production | X | X | X | |||||||||||||||
| Optimization | X | X | ||||||||||||||||
| Finalization & Project Completion | X | |||||||||||||||||
| Documentation | X | X | ||||||||||||||||
| Presentation Prep & Evaluation | X | X |
Weekly Tasks and Details
The blocks below follow the same phases as the timeline table above, written for Pockety (Pico 2 W, custom PCB, e-ink + OLED, MicroPython, web UI, and 3D-printed enclosure)—not a generic template.
Week 1–2: Preparation and Research
Tasks:
- Create project plan and schedule
- Conduct requirement analysis and research
Details:
- Define project scope and objectives (distraction-free productivity on a pocket e-ink device)
- Research technologies and tools: MCU options, e-ink modules, power (LiPo + charging), Wi-Fi needs, and fabrication (PCB, 3D printing)
Week 3–4: Design and Prototype
Tasks:
- Create initial sketches and drawings
- Complete prototype design (enclosure + interaction concept)
Details:
- Set dimensions and materials for the housing (print orientation, wall thickness, clearance for e-ink, encoder, and PCB)
- Validate basic UX early (what appears on e-ink vs OLED, encoder/button roles)
Week 5–8: Implementation
Tasks:
- Bring up Raspberry Pi Pico 2 W and firmware (MicroPython)
- Implement host/UI communication (HTTP/Web workflow toward the device)
- Design, manufacture, and assemble the custom PCB
- Integrate e-ink and OLED, encoder, button, and power path
Details:
- Install and configure firmware workflow (deploy, REPL, iterative testing)
- Exercise displays (partial/full refresh on e-ink, OLED status lines)
- Implement application logic (menus, timers, drawing pipeline toward e-ink where applicable)
- Wire TP4056 charging, LiPo, and regulated rails safely for on-board peripherals
- Iterate PCB layout, fabrication, soldering, and bench validation before mechanical integration
Week 9–10: Production
Tasks:
- Design and produce enclosure parts (CAD → print)
- Integrate and test the full system
Details:
- Prepare 3D-print files for body, top, screen cap, encoder shaft, and guides; iterate fit with magnets and screws
- Choose filament and print settings for stiffness and tolerance (encoder bore, snap fits)
- Assemble PCB, displays, battery module, and fasteners; run cable routing and strain relief
- Functional tests: boot, Wi-Fi, UI flows, display refresh, charging; tune firmware for reliability
Week 11–12: Documentation and Presentation
Tasks:
- Document the project
- Prepare and practice the presentation
Details:
- Write detailed documentation (architecture, BOM, assembly, failures, and fixes)
- Prepare presentation materials (story arc: motivation → hardware → firmware → demo → lessons learned)
Week 13: Completion and Evaluation
Tasks:
- Conduct final tests and evaluation
- Make final adjustments based on mentor feedback
Details:
- Run final system checks (battery life estimates, edge cases, mechanical robustness)
- Apply last tweaks from Fab Academy feedback and freeze a demo-ready build
BOM
Bill of materials aligned with the integrated hardware documented in Week 15: System Integration.
Compute and interfaces
| Item | Qty | Notes |
|---|---|---|
| Raspberry Pi Pico 2 W | 1 | Main MCU and Wi-Fi |
| Custom PCB (bare board) | 1 | Interconnect and breakout |
Displays and input
| Item | Qty | Notes |
|---|---|---|
| Waveshare 2.13 inch e-ink module | 1 | Primary display |
| 0.96 inch SSD1306 OLED | 1 | Secondary status / UI |
| ALPS EC11 magnetic encoder | 1 | With cable to PCB |
| IC184 red push button | 1 | Tactile input |
Power
| Item | Qty | Notes |
|---|---|---|
| 1S 3.7 V LiPo battery (350 mAh) | 1 | As used in integration |
| TP4056 LiPo charging module | 1 | Charging / protection (module as built) |
PCB population (SMD / through-hole on custom board)
| Part | Qty |
|---|---|
| LED 1206 (orange) | 2 |
| C 1206 0.1 µF | 2 |
| R 1206 100 Ω | 1 |
| C 1206 10 µF | 1 |
| Pin header 2.54 mm | 13 |
| 5-position vertical SMD socket | 8 |
| 4-position vertical SMD socket | 1 |
| 3-position vertical SMD socket | 1 |
| 2-position vertical SMD socket | 1 |
Mechanical and assembly
| Item | Qty | Notes |
|---|---|---|
| 3D-printed parts (body, top, screen cap, encoder shaft, guides, etc.) | 1 set | Design-specific enclosure |
| 3D printing filament | As needed | e.g. PLA or PETG |
| Magnets (~0.5 inch diameter) | Set | Magnetic retention for e-ink cap / top |
| M2 screws | Set | Top–body assembly |
| Brass threaded inserts | Set | Installed in top for screw retention |
CAD v1
Below is the sample cad I created in week 2 for my final project
hero shot
the version one of the design I did for week 2 pretty archaic

inside

Progress update (April 2026)
Version 2, slightly improved, still a bit unaesthetic, too big.

Progress update (May 2026)
Version 3, the one in system integration as well. Thinner form factor, better placement for the screen.

inside

PCB
main board
cad

3D

the physical
version one of the device: “the box”

version two of the device: “the pocket”

Programming section
Embedded
Role of each piece
- E-ink (primary UI): menus, reader text, and anything that should stay readable in daylight with low distraction—this is where “OS-level” navigation lives.
- OLED (secondary): quick status (battery, Wi-Fi, mode) or in-app shortcuts so the big screen stays clean.
- Inputs: magnetic encoder for scrolling / moving focus; push button for confirm / select (including in-app actions like bookmarks or jump-to-end when those flows exist).
- Software: There was no ready-made driver that matched how I wired the panel, so the bring-up path is datasheet + SPI command/data framing + busy polling, wrapped as a small MicroPython
EPDclass you can draw into withframebuf, then push with full or partial refresh.
Short clip of the device running firmware (e-ink + encoder interaction):
System diagram

(Same story as sysdiag.png above: framebuf feeds EPD; SPI goes to the e-ink controller RAM after _rotate_to_native inside display_*; I2C serves SSD1306; GPIO carries EC11 / IC184; HTTP stays /status /draw /clear over Wi-Fi.)
Reading the diagram against the code
| Diagram box | What it is in firmware |
|---|---|
| Week 14 canvas / Pack 250×122 | Browser UI → packed bitmap matching what /draw expects. |
| framebuf MONO_HLSB | Landscape internal buffer in EPD (self.fb) before _rotate_to_native(). |
| HTTP … /draw | MicroPython handlers (/status, /draw, /clear) + CYW43 stack. |
| EPD class SPI cmds | Custom driver: _cmd / _data, SoftSPI, busy wait, display_full / display_partial. |
| SPI / I2C / GPIO | Physical buses from Pico pins to PCB nets (SPI to panel, I2C to OLED, GPIO for encoder/button). |
| SSD1306 driver | OLED firmware path (I2C), separate from epd_driver.py. |
| EC11 decode / IC184 debounce | Input logic in your app (polling or ISR) riding on GPIO. |
| Waveshare 2.13 EPD / SSD1306 module | Modules mounted off headers / flex after SKT. |
Custom e-ink module (epd_driver.py)
The panel controller is spoken to over SPI (SoftSPI at 2 MHz) with busy, rst, dc, cs, sck, mosi as in EPD.__init__. Drawing uses a landscape framebuf (250×122, padded stride 256); the panel’s native memory layout is 122×250 portrait, so _rotate_to_native() remaps pixels before display_full() or display_partial(). _LUT_PARTIAL feeds the controller’s partial waveform; _prev_buffer tracks the last image for partial updates.
Raw file for copy-paste or tooling: epd_driver.py
Full MicroPython source (same as the download above — collapse with ▾)
from machine import Pin, SoftSPI
import framebuf
import utime
# Native panel resolution (portrait, as the controller sees it)
EPD_NATIVE_WIDTH = 122
EPD_NATIVE_HEIGHT = 250
_NATIVE_BUF_WIDTH = (EPD_NATIVE_WIDTH + 7) & ~7 # 128
# Logical resolution exposed to the user (landscape)
EPD_WIDTH = 250
EPD_HEIGHT = 122
_FB_WIDTH = (EPD_WIDTH + 7) & ~7 # 256
_LUT_PARTIAL = bytes([
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x28, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00,
])
class EPD:
# Public dimensions are the landscape ones
WIDTH = EPD_WIDTH
HEIGHT = EPD_HEIGHT
def __init__(self,
busy=6, rst=7, dc=8, cs=9,
sck=10, mosi=11, miso_placeholder=13):
self.busy = Pin(busy, Pin.IN)
self.rst = Pin(rst, Pin.OUT)
self.dc = Pin(dc, Pin.OUT)
self.cs = Pin(cs, Pin.OUT)
self.cs.value(1)
self.dc.value(0)
self.rst.value(1)
self.spi = SoftSPI(baudrate=2_000_000, polarity=0, phase=0,
sck=Pin(sck), mosi=Pin(mosi),
miso=Pin(miso_placeholder))
# --- Native (portrait) buffers actually sent to the panel ---
self._native_buf_w = _NATIVE_BUF_WIDTH # 128
self._native_stride = self._native_buf_w // 8 # 16
self.buffer = bytearray(self._native_buf_w * EPD_NATIVE_HEIGHT // 8)
for i in range(len(self.buffer)):
self.buffer[i] = 0xFF
self._prev_buffer = bytearray(self._native_buf_w * EPD_NATIVE_HEIGHT // 8)
for i in range(len(self._prev_buffer)):
self._prev_buffer[i] = 0xFF
# --- User-facing landscape framebuffer (drawn into directly) ---
self._fb_w = _FB_WIDTH # 256
self._fb_h = EPD_HEIGHT # 122
self._fb_stride = self._fb_w // 8 # 32
self._fb_buf = bytearray(self._fb_w * self._fb_h // 8)
self.fb = framebuf.FrameBuffer(self._fb_buf,
self._fb_w, self._fb_h,
framebuf.MONO_HLSB)
self.fb.fill(0xFF)
self._init_full()
# ---------------- low-level I/O ----------------
def _reset(self):
self.rst.value(1); utime.sleep_ms(50)
self.rst.value(0); utime.sleep_ms(5)
self.rst.value(1); utime.sleep_ms(50)
def _cmd(self, c):
self.dc.value(0); self.cs.value(0)
self.spi.write(bytes([c]))
self.cs.value(1)
def _data(self, d):
self.dc.value(1); self.cs.value(0)
if isinstance(d, int):
self.spi.write(bytes([d]))
else:
self.spi.write(d)
self.cs.value(1)
def _wait_busy(self, timeout_ms=10000):
t = 0
while self.busy.value() == 1 and t < timeout_ms:
utime.sleep_ms(10); t += 10
if t >= timeout_ms:
print("EPD BUSY timeout!")
def _set_window(self, x_start_byte, x_end_byte, y_start, y_end):
self._cmd(0x44)
self._data(x_start_byte & 0xFF)
self._data(x_end_byte & 0xFF)
self._cmd(0x45)
self._data(y_start & 0xFF); self._data((y_start >> 8) & 0xFF)
self._data(y_end & 0xFF); self._data((y_end >> 8) & 0xFF)
def _set_cursor(self, x_byte, y):
self._cmd(0x4E); self._data(x_byte & 0xFF)
self._cmd(0x4F); self._data(y & 0xFF); self._data((y >> 8) & 0xFF)
# ---------------- rotation ----------------
def _rotate_to_native(self):
"""
Rotate the landscape framebuffer into the native portrait buffer.
Native pixel (x_n, y_n) with 0 <= x_n < 122, 0 <= y_n < 250
maps to logical pixel (x_l, y_l) with:
x_l = y_n
y_l = (EPD_HEIGHT - 1) - x_n # 90° rotation, text reads correctly
"""
nat = self.buffer
log = self._fb_buf
nat_stride = self._native_stride
log_stride = self._fb_stride
h_minus_1 = EPD_HEIGHT - 1
# Clear native buffer to white
for i in range(len(nat)):
nat[i] = 0xFF
for y_n in range(EPD_NATIVE_HEIGHT): # 0..249
x_l = y_n
if x_l >= EPD_WIDTH: # guard if buf padded beyond 250
continue
log_byte_col = x_l >> 3
log_bit_shift = 7 - (x_l & 7)
for x_n in range(EPD_NATIVE_WIDTH): # 0..121
y_l = h_minus_1 - x_n
lb = log[y_l * log_stride + log_byte_col]
bit = (lb >> log_bit_shift) & 1
ni = y_n * nat_stride + (x_n >> 3)
mask = 1 << (7 - (x_n & 7))
if bit:
nat[ni] |= mask
else:
nat[ni] &= ~mask
# ---------------- panel init sequences ----------------
def _init_full(self):
self._reset()
self._wait_busy()
self._cmd(0x12)
self._wait_busy()
self._cmd(0x01)
self._data(0xF9); self._data(0x00); self._data(0x00)
self._cmd(0x11); self._data(0x03)
self._set_window(0, (self._native_buf_w - 1) >> 3, 0, EPD_NATIVE_HEIGHT - 1)
self._cmd(0x3C); self._data(0x05)
self._cmd(0x21); self._data(0x00); self._data(0x80)
self._cmd(0x18); self._data(0x80)
self._set_cursor(0, 0)
self._wait_busy()
def _init_partial(self):
self._cmd(0x32)
self._data(_LUT_PARTIAL)
self._cmd(0x3C); self._data(0x80)
self._set_window(0, (self._native_buf_w - 1) >> 3, 0, EPD_NATIVE_HEIGHT - 1)
self._set_cursor(0, 0)
# ---------------- public display ops ----------------
def display_full(self):
self._rotate_to_native()
self._init_full()
self._set_cursor(0, 0)
self._cmd(0x24)
self._data(self.buffer)
self._set_cursor(0, 0)
self._cmd(0x26)
self._data(self.buffer)
self._cmd(0x22); self._data(0xF7)
self._cmd(0x20)
self._wait_busy()
for i in range(len(self.buffer)):
self._prev_buffer[i] = self.buffer[i]
def display_partial(self):
self._rotate_to_native()
self._init_partial()
self._set_cursor(0, 0)
self._cmd(0x26)
self._data(self._prev_buffer)
self._set_cursor(0, 0)
self._cmd(0x24)
self._data(self.buffer)
self._cmd(0x22); self._data(0xFF)
self._cmd(0x20)
self._wait_busy()
for i in range(len(self.buffer)):
self._prev_buffer[i] = self.buffer[i]
def clear(self, color=0xFF):
self.fb.fill(color)
self.display_full()
def sleep(self):
self._cmd(0x10); self._data(0x01)
utime.sleep_ms(100)
Calendar UI (portrait 122×250)
This build targets the panel’s logical portrait framebuffer (122×250): _EW / _EH defaults match that; main() still sets _EW, _EH = epd.WIDTH, epd.HEIGHT so whatever your EPD reports wins. Layout is one column, top to bottom—no left/right split.
Vertical bands (_draw_calendar)
- Header — Centered
MMM D YYYY(shortened if wider thanmax_chars_w), then centered full weekday from_DOW_FULL, then a full-width horizontal rule. - Month grid —
M … Srow; grid centered horizontally (grid_x0 = (_EW - grid_w) // 2).cell_w ≈ 16 pxso day numerals are 1–2 digits without padded spacing. Selected day is solid invert; today gets a frame when not selected; days with JSON entries get a 2×2 marker dot. Cell height is computed from remaining vertical space between the grid and the reserved events + footer band (clamped between_LH+2and 16 px). - Second rule, then events for
_date_key(y, m, sel_d)—time+titlelines,+N moreif the list would run into the footer. - Footer — Rule above
Scroll: month/Scroll: day, orMode: M/Mode: Dif the long string would overflow.
Typography constants
_CW = 8,_LH = 10— monospacefb.textstride (matches your font assumptions)._EMG = 3— screen margin.
Refresh policy (_Refresher)
- Same as before: partial batches, forced full every
_PARTIALS_BEFORE_FULL(25) or whenforce_full(first paint usesfull=True).
Encoder mapping
| Action | Behaviour |
|---|---|
dial+ / dial- | _advance by month or day according to mode. |
dial_double | Flip _MODE_MONTH ↔ _MODE_DAY. |
dial | Refresh today from time.localtime() (chained unpack with y, m, d). |
dial_long | Leave loop → main() clears framebuffer, full refresh, sleep(). |
Data
calendar.jsonbeside the script; seedSAMPLE_EVENTSon first run if the file is missing.
Imports
import os— included as in your firmware tree (e.g. paths / SD layout later); not required for the logic shown.from epd2in13 import EPD— your on-device module name; align with the sameEPDAPI as the publishedepd_driver.py(.fb,display_full,display_partial,sleep).pico_ui_input.InputManager— encoder onclk=2, dt=3, sw=4withlong_pressanddouble_click.
Entrypoint
if __name__ == '__main__': main()pluselse: main()so the calendar still runs when the file is imported on your stack (matches your snippet).
Download: calendar_ui.py
Full calendar_ui.py source
import os
import json
import time
from epd2in13 import EPD
from pico_ui_input import InputManager
CAL_FILE = 'calendar.json'
SAMPLE_EVENTS = {
"2026-04-28": [
{"time": "09:00", "title": "Standup"},
{"time": "14:30", "title": "Dentist"},
{"time": "19:00", "title": "Dinner with M"}
],
"2026-04-30": [
{"time": "10:00", "title": "Project review"}
],
"2026-05-03": [
{"time": "All day", "title": "Trip to Bursa"}
]
}
_CW = 8
_LH = 10
_EMG = 3
_PARTIALS_BEFORE_FULL = 25
# Filled in from epd.WIDTH / epd.HEIGHT in main()
# Portrait: 122 wide x 250 tall
_EW = 122
_EH = 250
_MONTHS = ('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec')
_DOW = ('M', 'T', 'W', 'T', 'F', 'S', 'S')
_DOW_FULL = ('Monday', 'Tuesday', 'Wednesday', 'Thursday',
'Friday', 'Saturday', 'Sunday')
# Scroll modes
_MODE_MONTH = 0
_MODE_DAY = 1
class _Refresher:
def __init__(self, epd):
self.epd = epd
self.partials = 0
def push(self, force_full=False):
if force_full or self.partials >= _PARTIALS_BEFORE_FULL:
self.epd.display_full()
self.partials = 0
else:
if self.partials == 0:
self.epd.display_full()
else:
self.epd.display_partial()
self.partials += 1
def push_full(self):
self.epd.display_full()
self.partials = 0
def _load_events():
try:
with open(CAL_FILE) as f:
return json.load(f)
except OSError:
try:
with open(CAL_FILE, 'w') as f:
json.dump(SAMPLE_EVENTS, f)
except Exception as e:
print('write sample:', e)
return dict(SAMPLE_EVENTS)
except Exception as e:
print('load events:', e)
return {}
def _is_leap(y):
return (y % 4 == 0 and y % 100 != 0) or (y % 400 == 0)
def _days_in_month(y, m):
if m == 2:
return 29 if _is_leap(y) else 28
if m in (4, 6, 9, 11):
return 30
return 31
def _zeller_dow_monday0(y, m, d):
if m < 3:
m += 12
y -= 1
K = y % 100
J = y // 100
h = (d + (13 * (m + 1)) // 5 + K + K // 4 + J // 4 + 5 * J) % 7
return (h + 5) % 7
def _today():
try:
t = time.localtime()
return (t[0], t[1], t[2])
except:
return (2026, 4, 28)
def _date_key(y, m, d):
return '{:04d}-{:02d}-{:02d}'.format(y, m, d)
def _advance(y, m, d, mode, step):
"""Advance (y, m, d) by `step` units of `mode` (day or month)."""
if mode == _MODE_DAY:
s = 1 if step > 0 else -1
for _ in range(abs(step)):
d += s
if d > _days_in_month(y, m):
d = 1
m += 1
if m > 12:
m = 1
y += 1
elif d < 1:
m -= 1
if m < 1:
m = 12
y -= 1
d = _days_in_month(y, m)
return (y, m, d)
else:
m += step
while m > 12:
m -= 12
y += 1
while m < 1:
m += 12
y -= 1
dim = _days_in_month(y, m)
if d > dim:
d = dim
return (y, m, d)
def _draw_calendar(epd, refresher, y, m, sel_d, mode, events, full=False):
"""
Portrait view (122 x 250), top-to-bottom:
1. Header : "MMM D YYYY" + day-of-week subtitle
2. Grid : day-of-week row + month grid with selected day inverted
3. Events : list for the selected day
4. Footer : scroll mode indicator
"""
fb = epd.fb
fb.fill(1)
max_chars_w = (_EW - _EMG * 2) // _CW
# -------- 1. Header --------
hdr_y = _EMG
title = '{} {} {}'.format(_MONTHS[m - 1], sel_d, y)
if len(title) > max_chars_w:
title = '{} {}'.format(_MONTHS[m - 1], sel_d)
tx = (_EW - len(title) * _CW) // 2
if tx < 0:
tx = 0
fb.text(title, tx, hdr_y, 0)
sub_y = hdr_y + _LH + 1
dow_idx = _zeller_dow_monday0(y, m, sel_d)
sub = _DOW_FULL[dow_idx]
if len(sub) > max_chars_w:
sub = sub[:max_chars_w]
sx = (_EW - len(sub) * _CW) // 2
if sx < 0:
sx = 0
fb.text(sub, sx, sub_y, 0)
sep1_y = sub_y + _LH + 2
fb.hline(0, sep1_y, _EW, 0)
# -------- 2. Day-of-week row + month grid --------
grid_x0 = _EMG
grid_w_avail = _EW - _EMG * 2
cell_w = grid_w_avail // 7 # 116 // 7 = 16 px
grid_w = cell_w * 7 # 112 px
# Center the grid horizontally given integer cell_w
grid_x0 = (_EW - grid_w) // 2
dow_y = sep1_y + 3
for i in range(7):
x = grid_x0 + i * cell_w + (cell_w - _CW) // 2
fb.text(_DOW[i], x, dow_y, 0)
dow_line_y = dow_y + _LH
fb.hline(grid_x0, dow_line_y, grid_w, 0)
n_days = _days_in_month(y, m)
first_dow = _zeller_dow_monday0(y, m, 1)
n_rows = (first_dow + n_days + 6) // 7
# Reserve space at the bottom for events + footer
foot_h = _LH + 2
events_min_h = _LH * 3 + 6 # title + ~2 lines minimum
grid_top = dow_line_y + 2
grid_bottom_max = _EH - _EMG - foot_h - events_min_h - 2
avail_h = grid_bottom_max - grid_top
cell_h = avail_h // n_rows
if cell_h < _LH + 2:
cell_h = _LH + 2
if cell_h > 16:
cell_h = 16
grid_h = n_rows * cell_h
for r in range(n_rows + 1):
ly = grid_top - 1 + r * cell_h
fb.hline(grid_x0, ly, grid_w + 1, 0)
for c in range(8):
lx = grid_x0 + c * cell_w
fb.vline(lx, grid_top - 1, grid_h + 1, 0)
today_y, today_m, today_d = _today()
for d in range(1, n_days + 1):
idx = first_dow + (d - 1)
row = idx // 7
col = idx % 7
cx = grid_x0 + col * cell_w
cy = grid_top + row * cell_h
is_sel = (d == sel_d)
is_today = (y == today_y and m == today_m and d == today_d)
if is_sel:
fb.fill_rect(cx + 1, cy, cell_w - 1, cell_h - 1, 0)
text_color = 1
else:
text_color = 0
# cell_w is only ~16 px -> max 2 chars; print without leading space
ds = str(d)
tx2 = cx + (cell_w - len(ds) * _CW) // 2
fb.text(ds, tx2, cy + 1, text_color)
if is_today and not is_sel:
fb.rect(cx + 1, cy, cell_w - 1, cell_h - 1, 0)
if _date_key(y, m, d) in events:
mx = cx + cell_w - 3
my = cy + cell_h - 3
fb.fill_rect(mx, my, 2, 2, 1 if is_sel else 0)
sep2_y = grid_top + grid_h + 2
fb.hline(0, sep2_y, _EW, 0)
# -------- 3. Events --------
body_top = sep2_y + 3
foot_y = _EH - _EMG - _LH
body_bottom = foot_y - 3
key = _date_key(y, m, sel_d)
day_events = events.get(key, [])
if not day_events:
fb.text('No events', _EMG, body_top, 0)
else:
y_cursor = body_top
for i, ev in enumerate(day_events):
if y_cursor + _LH > body_bottom:
more = '+{} more'.format(len(day_events) - i)
if len(more) > max_chars_w:
more = more[:max_chars_w]
fb.text(more, _EMG, y_cursor, 0)
break
t = ev.get('time', '')
title_s = ev.get('title', '')
line = '{} {}'.format(t, title_s) if t else title_s
if len(line) > max_chars_w:
line = line[:max_chars_w]
fb.text(line, _EMG, y_cursor, 0)
y_cursor += _LH
# -------- 4. Footer (mode indicator) --------
fb.hline(0, foot_y - 2, _EW, 0)
mode_str = 'Scroll: month' if mode == _MODE_MONTH else 'Scroll: day'
if len(mode_str) > max_chars_w:
mode_str = 'Mode: M' if mode == _MODE_MONTH else 'Mode: D'
fb.text(mode_str, _EMG, foot_y, 0)
refresher.push(force_full=full)
def _calendar_loop(epd, ui, refresher, events):
today_y, today_m, today_d = _today()
y, m, d = today_y, today_m, today_d
mode = _MODE_MONTH
_draw_calendar(epd, refresher, y, m, d, mode, events, full=True)
while True:
a = ui.wait()
if a == 'dial+':
y, m, d = _advance(y, m, d, mode, +1)
_draw_calendar(epd, refresher, y, m, d, mode, events)
elif a == 'dial-':
y, m, d = _advance(y, m, d, mode, -1)
_draw_calendar(epd, refresher, y, m, d, mode, events)
elif a == 'dial_double':
mode = _MODE_DAY if mode == _MODE_MONTH else _MODE_MONTH
_draw_calendar(epd, refresher, y, m, d, mode, events)
elif a == 'dial':
y, m, d = today_y, today_m, today_d = _today()
_draw_calendar(epd, refresher, y, m, d, mode, events)
elif a == 'dial_long':
return
def main():
global _EW, _EH
epd = EPD()
_EW, _EH = epd.WIDTH, epd.HEIGHT
ui = InputManager()
ui.add_encoder('dial', clk=2, dt=3, sw=4,
long_press=True, double_click=True)
refresher = _Refresher(epd)
events = _load_events()
_calendar_loop(epd, ui, refresher, events)
epd.fb.fill(1)
epd.display_full()
epd.sleep()
if __name__ == '__main__':
main()
else:
main()
E-ink reader / library (portrait 122×250)
Same portrait framebuffer contract as the calendar: _EW / _EH default to 122×250; main() sets them from epd.WIDTH / epd.HEIGHT. The e-ink shows the library (sorted *.txt from /) and the reader (wrapped text, pagination, footer page N/M, progress bar, bookmark glyph in the title row when the current page is bookmarked). Files with saved bookmarks show a small dot in the library list.
Optional OLED (128×64) — I2C(0), sda=16, scl=17, SSD1306_I2C. If init fails, OLED_OK is false and menus fall back to “no OLED” behaviour where applicable. The OLED drives options (bookmark, skip/jump, exit), bookmark list, and short confirm messages so the big screen stays on reading.
Refresh (_Refresher) — Partials are batched; a full refresh runs when force_full, on first paint (partials == 0), or after _PARTIALS_BEFORE_FULL (15) partial updates (counter resets to 1 after each full). File-list scrolling can request push_partial for snappier navigation.
Bookmarks — Persisted in bookmarks.json (page indices per filename). Reopening a file resumes at the latest bookmarked page when any exist.
Encoder (InputManager) — Single encoder dial: clk=2, dt=3, sw=4, long_press=True only (no double_click in this app). Library: dial+ / dial- move selection, dial opens the file, dial_long exits to shutdown path. Reader: dial+ / dial- turn pages, dial opens the OLED options menu, dial_long returns to the library.
Download: eink_reader.py
Full eink_reader.py source
import os
import json
from machine import Pin, I2C
from epd2in13 import EPD
from pico_ui_input import InputManager
_CW = 8
_LH = 10
_EMG = 3
_PARTIALS_BEFORE_FULL = 15
# Portrait: 122 wide x 250 tall
_EW = 122
_EH = 250
OW, OH = 128, 64
BOOKMARKS_FILE = 'bookmarks.json'
try:
from ssd1306 import SSD1306_I2C
_i2c = I2C(0, sda=Pin(16), scl=Pin(17), freq=400_000)
oled = SSD1306_I2C(OW, OH, _i2c)
OLED_OK = True
except Exception as e:
print('OLED unavailable:', e)
oled = None
OLED_OK = False
class _Refresher:
def __init__(self, epd):
self.epd = epd
self.partials = 0
def push(self, force_full=False):
if force_full or self.partials >= _PARTIALS_BEFORE_FULL:
self.epd.display_full()
self.partials = 1
else:
if self.partials == 0:
self.epd.display_full()
self.partials = 1
else:
self.epd.display_partial()
self.partials += 1
def push_partial(self):
if self.partials == 0:
self.epd.display_full()
self.partials = 1
else:
self.epd.display_partial()
self.partials += 1
def push_full(self):
self.epd.display_full()
self.partials = 1
def _load_bookmarks():
try:
with open(BOOKMARKS_FILE) as f:
return json.load(f)
except:
return {}
def _save_bookmarks(bm):
try:
with open(BOOKMARKS_FILE, 'w') as f:
json.dump(bm, f)
except Exception as e:
print('bookmark save:', e)
def _list_txt_files():
out = []
try:
for name in os.listdir('/'):
if name.lower().endswith('.txt'):
out.append(name)
except Exception as e:
print('list_txt_files:', e)
out.sort()
return out
# ----------------- file list -----------------
def _draw_file_list(epd, refresher, files, sel, bookmarks,
full=False, force_partial=False):
fb = epd.fb
fb.fill(1)
# Header
fb.text('Library', _EMG, _EMG, 0)
n = len(files)
if n:
cnt = '{}/{}'.format(sel + 1, n)
cw = len(cnt) * _CW
fb.text(cnt, _EW - _EMG - cw, _EMG, 0)
sep_y = _EMG + _LH + 2
fb.hline(0, sep_y, _EW, 0)
if not files:
fb.text('No .txt files', _EMG, sep_y + 8, 0)
if force_partial:
refresher.push_partial()
else:
refresher.push(force_full=full)
return
list_top = sep_y + 4
list_bottom = _EH - _EMG
# Each row uses _LH px; leave a 2-char left gutter for the cursor + bookmark mark
left_gutter = _CW + 2 # 10 px: cursor (>) or space, then bookmark dot
text_x = _EMG + left_gutter
chars_per_line = max(1, (_EW - text_x - _EMG) // _CW)
rows_visible = max(1, (list_bottom - list_top) // _LH)
# Center the selection in the visible window where possible
if sel < rows_visible // 2:
first = 0
elif sel > n - rows_visible // 2 - 1:
first = max(0, n - rows_visible)
else:
first = sel - rows_visible // 2
last = min(n, first + rows_visible)
for i in range(first, last):
y = list_top + (i - first) * _LH
is_sel = (i == sel)
name = files[i]
if len(name) > chars_per_line:
name = name[:chars_per_line - 1] + '~'
if is_sel:
fb.text('>', _EMG, y, 0)
# bookmark indicator: solid dot before name if file has any bookmarks
if bookmarks.get(files[i]):
fb.fill_rect(_EMG + _CW, y + 3, 2, 2, 0)
fb.text(name, text_x, y, 0)
if force_partial:
refresher.push_partial()
else:
refresher.push(force_full=full)
def _file_list_loop(epd, ui, refresher, bookmarks):
files = _list_txt_files()
sel = 0
_draw_file_list(epd, refresher, files, sel, bookmarks, full=True)
while True:
a = ui.wait()
if a == 'dial+':
if files:
sel = (sel + 1) % len(files)
_draw_file_list(epd, refresher, files, sel, bookmarks,
force_partial=True)
elif a == 'dial-':
if files:
sel = (sel - 1) % len(files)
_draw_file_list(epd, refresher, files, sel, bookmarks,
force_partial=True)
elif a == 'dial':
if files:
return files[sel]
elif a == 'dial_long':
return None
# ----------------- text wrapping -----------------
def _wrap_paragraph(text, cols):
if not text:
return ['']
lines = []
cur = ''
for word in text.split(' '):
if not word:
continue
while len(word) > cols:
if cur:
lines.append(cur); cur = ''
lines.append(word[:cols])
word = word[cols:]
if not cur:
cur = word
elif len(cur) + 1 + len(word) <= cols:
cur = cur + ' ' + word
else:
lines.append(cur); cur = word
if cur:
lines.append(cur)
return lines
def _wrap_text(text, cols):
out = []
text = text.replace('\r\n', '\n').replace('\r', '\n')
for para in text.split('\n'):
if para == '':
out.append('')
else:
out.extend(_wrap_paragraph(para, cols))
return out
def _paginate(lines, rows_per_page):
if rows_per_page < 1:
rows_per_page = 1
pages = []
for i in range(0, len(lines), rows_per_page):
pages.append(lines[i:i + rows_per_page])
if not pages:
pages = [['']]
return pages
# ----------------- reader -----------------
# Layout constants for the reader chrome (top + bottom).
# Recomputed once and reused for both sizing and drawing so the math stays
# consistent.
_HDR_H = _EMG + _LH + 2 # title row + 2 px under separator
_BAR_H = 6 # progress bar height (a touch slimmer)
_FOOT_H = _BAR_H + 2 + _LH + 2 # bar + gap + page text + gap
def _draw_reader_page(epd, refresher, title, page_lines,
page_idx, n_pages, at_end=False, bookmarked=False,
full=False):
fb = epd.fb
fb.fill(1)
chars_per_line = max(1, (_EW - _EMG * 2) // _CW)
# ---- header ----
title_avail = chars_per_line - (2 if bookmarked else 0) # reserve space for icon
title_disp = title
if len(title_disp) > title_avail:
title_disp = title_disp[:title_avail - 1] + '~'
fb.text(title_disp, _EMG, _EMG, 0)
if bookmarked:
bx = _EW - _EMG - 6
by = _EMG - 1
# tiny bookmark glyph (filled rect with notched bottom)
fb.fill_rect(bx, by, 6, 9, 0)
fb.fill_rect(bx + 1, by + 8, 1, 2, 1)
fb.fill_rect(bx + 4, by + 8, 1, 2, 1)
sep_y = _EMG + _LH + 1
fb.hline(0, sep_y, _EW, 0)
# ---- body ----
body_top = sep_y + 3
body_bottom = _EH - _FOOT_H
rows_per_page = max(1, (body_bottom - body_top) // _LH)
for i, line in enumerate(page_lines):
if i >= rows_per_page:
break
if line:
fb.text(line, _EMG, body_top + i * _LH, 0)
# ---- footer: page text + progress bar ----
foot = 'END {}/{}'.format(page_idx + 1, n_pages) if at_end \
else '{}/{}'.format(page_idx + 1, n_pages)
if len(foot) * _CW > _EW - _EMG * 2:
foot = '{}/{}'.format(page_idx + 1, n_pages)
fw = len(foot) * _CW
foot_y = _EH - _BAR_H - 2 - _LH
fb.text(foot, _EW - _EMG - fw, foot_y, 0)
# Progress bar at bottom
bar_x0 = _EMG
bar_x1 = _EW - _EMG
bar_y = _EH - _BAR_H - 1
bar_w = bar_x1 - bar_x0
fb.rect(bar_x0, bar_y, bar_w, _BAR_H, 0)
if n_pages > 1:
fill_w = (bar_w - 4) * page_idx // (n_pages - 1)
else:
fill_w = bar_w - 4
if fill_w > 0:
fb.fill_rect(bar_x0 + 2, bar_y + 2, fill_w, _BAR_H - 4, 0)
refresher.push(force_full=full)
def _reader_rows_per_page():
body_top = _EMG + _LH + 1 + 3
body_bottom = _EH - _FOOT_H
return max(1, (body_bottom - body_top) // _LH)
# ----------------- OLED helpers (unchanged) -----------------
def _oled_clear():
if OLED_OK:
oled.fill(0); oled.show()
def _oled_menu(ui, title, items):
if not OLED_OK:
return None
sel = 0
while True:
oled.fill(0)
oled.fill_rect(0, 0, OW, 11, 1)
title_short = title
if len(title_short) > OW // 8 - 1:
title_short = title_short[:OW // 8 - 1]
oled.text(title_short, 2, 2, 0)
body_top = 14
row_h = 11
rows_visible = (OH - body_top - 2) // row_h
if sel < rows_visible // 2:
first = 0
elif sel > len(items) - rows_visible // 2 - 1:
first = max(0, len(items) - rows_visible)
else:
first = sel - rows_visible // 2
max_chars = OW // 8
for i in range(first, min(len(items), first + rows_visible)):
y = body_top + (i - first) * row_h
label = items[i]
if len(label) > max_chars:
label = label[:max_chars]
if i == sel:
oled.fill_rect(0, y - 1, OW, row_h - 1, 1)
oled.text(label, 2, y, 0)
else:
oled.text(label, 2, y, 1)
oled.show()
a = ui.wait()
if a == 'dial+':
sel = (sel + 1) % len(items)
elif a == 'dial-':
sel = (sel - 1) % len(items)
elif a == 'dial':
return sel
elif a == 'dial_long':
return None
def _oled_message(msg, sub=''):
if not OLED_OK:
return
oled.fill(0)
chars = OW // 8
m = msg if len(msg) <= chars else msg[:chars]
s = sub if len(sub) <= chars else sub[:chars]
oled.text(m, (OW - len(m) * 8) // 2, OH // 2 - 8, 1)
if s:
oled.text(s, (OW - len(s) * 8) // 2, OH // 2 + 4, 1)
oled.show()
def _show_bookmark_list(ui, page, marks):
if not marks:
_oled_message('No bookmarks')
ui.wait()
return None
items = []
sorted_marks = sorted(marks)
for p in sorted_marks:
marker = '*' if p == page else ' '
items.append('{} p{}'.format(marker, p + 1))
sel = _oled_menu(ui, 'Bookmarks', items)
if sel is None:
return None
return sorted_marks[sel]
def _nearest_bookmark(marks, page, direction):
if not marks:
return None
if direction > 0:
candidates = [p for p in marks if p > page]
if candidates:
return min(candidates)
return None
else:
candidates = [p for p in marks if p < page]
if candidates:
return max(candidates)
return None
def _options_menu(ui, page, n_pages, marks):
is_bookmarked = page in marks
items = []
actions = []
if marks and any(p > page for p in marks):
items.append('Skip >>')
actions.append('skip_fwd')
elif page < n_pages - 1:
items.append('Jump end')
actions.append('jump_end')
if marks and any(p < page for p in marks):
items.append('Skip <<')
actions.append('skip_back')
elif page > 0:
items.append('Jump start')
actions.append('jump_start')
if is_bookmarked:
items.append('Remove mark')
actions.append('unmark')
else:
items.append('Set mark')
actions.append('mark')
items.append('Bookmarks...')
actions.append('list')
items.append('Exit book')
actions.append('exit')
items.append('Cancel')
actions.append('cancel')
sel = _oled_menu(ui, 'Options', items)
if sel is None:
return 'cancel'
return actions[sel]
# ----------------- main reader loop -----------------
def _read_file_loop(epd, ui, refresher, filename, all_bookmarks):
try:
with open('/' + filename, 'r') as f:
text = f.read()
except Exception as e:
fb = epd.fb
fb.fill(1)
fb.text('Error reading:', _EMG, _EMG, 0)
fb.text(filename[:14], _EMG, _EMG + _LH * 2, 0)
fb.text(str(e)[:14], _EMG, _EMG + _LH * 4, 0)
refresher.push_full()
ui.wait()
return
chars_per_line = max(1, (_EW - _EMG * 2) // _CW)
rows_per_page = _reader_rows_per_page()
lines = _wrap_text(text, chars_per_line)
pages = _paginate(lines, rows_per_page)
n = len(pages)
title = filename.rsplit('.', 1)[0] if '.' in filename else filename
marks = list(all_bookmarks.get(filename, []))
marks = [p for p in marks if 0 <= p < n]
marks_set = set(marks)
page = 0
if marks:
page = max(marks)
def _redraw(full=False):
_draw_reader_page(epd, refresher, title, pages[page],
page, n, at_end=(page == n - 1),
bookmarked=(page in marks_set), full=full)
_redraw(full=True)
while True:
a = ui.wait()
if a == 'dial+':
if page < n - 1:
page += 1
_redraw()
elif a == 'dial-':
if page > 0:
page -= 1
_redraw()
elif a == 'dial_long':
_oled_clear()
return
elif a == 'dial':
choice = _options_menu(ui, page, n, marks_set)
_oled_clear()
if choice == 'cancel':
pass
elif choice == 'skip_fwd':
target = _nearest_bookmark(marks_set, page, +1)
if target is not None:
page = target
_redraw(full=True)
elif choice == 'skip_back':
target = _nearest_bookmark(marks_set, page, -1)
if target is not None:
page = target
_redraw(full=True)
elif choice == 'jump_end':
page = n - 1
_redraw(full=True)
elif choice == 'jump_start':
page = 0
_redraw(full=True)
elif choice == 'mark':
marks_set.add(page)
all_bookmarks[filename] = sorted(marks_set)
_save_bookmarks(all_bookmarks)
_oled_message('Bookmarked', 'p{}'.format(page + 1))
_redraw()
elif choice == 'unmark':
marks_set.discard(page)
if marks_set:
all_bookmarks[filename] = sorted(marks_set)
else:
all_bookmarks.pop(filename, None)
_save_bookmarks(all_bookmarks)
_oled_message('Removed', 'p{}'.format(page + 1))
_redraw()
elif choice == 'list':
target = _show_bookmark_list(ui, page, marks_set)
_oled_clear()
if target is not None:
page = target
_redraw(full=True)
elif choice == 'exit':
return
def main():
global _EW, _EH
epd = EPD()
_EW, _EH = epd.WIDTH, epd.HEIGHT
ui = InputManager()
ui.add_encoder('dial', clk=2, dt=3, sw=4, long_press=True)
refresher = _Refresher(epd)
bookmarks = _load_bookmarks()
while True:
chosen = _file_list_loop(epd, ui, refresher, bookmarks)
if chosen is None:
break
_read_file_loop(epd, ui, refresher, chosen, bookmarks)
# Bookmarks may have changed inside the reader; reload from memory.
epd.fb.fill(1)
epd.display_full()
epd.sleep()
_oled_clear()
if __name__ == '__main__':
main()
else:
main()
Pico flashcards / SRS (portrait 122×250)
Anki-style spaced repetition on the same stack: EPD portrait framebuffer (EW / EH from epd.WIDTH / epd.HEIGHT), optional SSD1306 on I2C(0) sda=16, scl=17, 128×64. flashcards.json is created on first run with a sample deck; schedule() implements a simplified SM-2 with four grades (Again / Hard / Good / Easy), updating ef, iv (interval days), reps, and due (epoch seconds).
Screens (e-ink) — Deck list: two lines per deck (name + due: N), scroll window, hold to quit in the footer. Review: question centered → click flips to Q | divider | A plus a four-row vertical grade bar; rotate moves highlight, click commits. Nothing due / Session done summary screens.
OLED — During review: deck title bar, cards left, last grade; deck list: short control hints. Cleared in main() finally.
Refresh (_Refresher) — push(force_full=…): forces full, or full every 5 partial updates (counter resets to 0 after full); first paint in the partial path uses full when partials == 0.
Encoder (InputManager) — dial: clk=2, dt=3, sw=4, long_press=True, double_click=True. Deck list: rotate selects, click starts review, long-press exits app. Review (question): click flips; long / double save and return to deck list (double acts as “bail out” to the list). Review (answer): rotate chooses grade, click grades (Again re-queues that card in the session); long / double save and exit review.
Download: flashcards_ui.py
Full flashcards_ui.py source
"""
Pico flashcards — Anki-style SRS on e-ink + OLED + rotary encoder.
Portrait (122x250) layout.
Storage: flashcards.json (auto-created with a sample deck on first run)
SRS: simplified SM-2 (Again/Hard/Good/Easy)
Controls:
rotate = scroll / select
click = confirm / show answer / grade
long-press = back / exit
double = jump to deck list
"""
from machine import Pin, I2C
import time, json, gc
from epd2in13 import EPD
from ssd1306 import SSD1306_I2C
from pico_ui_input import InputManager
# ---------- constants ----------
DB_FILE = 'flashcards.json'
DAY_S = 86400
MIN_EASE = 1.30
START_EASE = 2.50
GRADES = ('Again', 'Hard', 'Good', 'Easy')
DEFAULT_DB = {
'baseline': 0,
'decks': [
{
'name': 'Sample',
'cards': [
{'q': 'Capital of France?', 'a': 'Paris',
'iv': 0, 'ef': START_EASE, 'due': 0, 'reps': 0},
{'q': '2 + 2 = ?', 'a': '4',
'iv': 0, 'ef': START_EASE, 'due': 0, 'reps': 0},
{'q': 'H2O is...', 'a': 'water',
'iv': 0, 'ef': START_EASE, 'due': 0, 'reps': 0},
],
},
],
}
# ---------- DB load / save ----------
def load_db():
try:
with open(DB_FILE) as f:
db = json.load(f)
for deck in db.get('decks', []):
for c in deck.get('cards', []):
c.setdefault('iv', 0)
c.setdefault('ef', START_EASE)
c.setdefault('due', 0)
c.setdefault('reps', 0)
db.setdefault('baseline', 0)
return db
except (OSError, ValueError):
save_db(DEFAULT_DB)
return DEFAULT_DB
def save_db(db):
try:
with open(DB_FILE, 'w') as f:
json.dump(db, f)
except Exception as e:
print('save_db:', e)
def now_s():
return time.time()
# ---------- SRS core ----------
def schedule(card, grade_idx):
ef = card['ef']
iv = card['iv']
reps = card['reps']
if grade_idx == 0: # Again
ef = max(MIN_EASE, ef - 0.20)
iv = 1
reps = 0
elif grade_idx == 1: # Hard
ef = max(MIN_EASE, ef - 0.15)
iv = max(1, int(round(iv * 1.2))) if iv > 0 else 1
reps += 1
elif grade_idx == 2: # Good
if reps == 0:
iv = 1
elif reps == 1:
iv = 3
else:
iv = max(1, int(round(iv * ef)))
reps += 1
else: # Easy
ef = ef + 0.15
if reps == 0:
iv = 4
elif reps == 1:
iv = 7
else:
iv = max(1, int(round(iv * ef * 1.3)))
reps += 1
card['ef'] = round(ef, 3)
card['iv'] = iv
card['reps'] = reps
card['due'] = now_s() + iv * DAY_S
def due_count(deck, now=None):
if now is None:
now = now_s()
n = 0
for c in deck['cards']:
if c['due'] <= now:
n += 1
return n
def collect_due_cards(deck, now=None):
if now is None:
now = now_s()
return [i for i, c in enumerate(deck['cards']) if c['due'] <= now]
# ---------- hardware setup ----------
epd = EPD()
EW, EH = epd.WIDTH, epd.HEIGHT # 122 x 250 in portrait
EMG = 3
CW, LH = 8, 10
i2c0 = I2C(0, sda=Pin(16), scl=Pin(17), freq=400_000)
try:
oled = SSD1306_I2C(128, 64, i2c0)
OLED_OK = True
except Exception as e:
print('OLED init failed:', e)
oled = None
OLED_OK = False
OW, OH = 128, 64
class _Refresher:
def __init__(self, ep):
self.ep = ep
self.partials = 0
def push(self, force_full=False):
if force_full or self.partials >= 5:
self.ep.display_full()
self.partials = 0
else:
if self.partials == 0:
self.ep.display_full()
else:
self.ep.display_partial()
self.partials += 1
refresher = _Refresher(epd)
ui = InputManager()
ui.add_encoder('dial', clk=2, dt=3, sw=4,
long_press=True, double_click=True)
# ---------- text helpers ----------
def _fit(s, max_w):
n = max(0, max_w // CW)
return s if len(s) <= n else s[:n]
def _wrap_lines(s, max_chars):
if max_chars <= 0:
return []
out = []
for paragraph in s.split('\n'):
if not paragraph:
out.append('')
continue
words = paragraph.split(' ')
cur = ''
for w in words:
if len(w) > max_chars:
if cur:
out.append(cur); cur = ''
while len(w) > max_chars:
out.append(w[:max_chars])
w = w[max_chars:]
cur = w
continue
if not cur:
cur = w
elif len(cur) + 1 + len(w) <= max_chars:
cur = cur + ' ' + w
else:
out.append(cur); cur = w
if cur:
out.append(cur)
return out
# ---------- e-ink screens ----------
def render_deck_list(decks, sel):
"""
Vertical list of decks. Two rows per deck: name on row 1,
"due: N" on row 2 in muted style. Avoids name truncation on a 14-char screen.
"""
fb = epd.fb
fb.fill(1)
# Header
fb.text(_fit('Decks', EW - EMG * 2), EMG, EMG, 0)
cnt = '{}/{}'.format(sel + 1, len(decks)) if decks else '0/0'
cw = len(cnt) * CW
fb.text(cnt, EW - EMG - cw, EMG, 0)
sep_y = EMG + 8 + 2
fb.hline(0, sep_y, EW, 0)
# Footer
foot_h = 8
sep_yb = EH - EMG - foot_h - 2
fb.hline(0, sep_yb, EW, 0)
fb.text(_fit('hold to quit', EW - EMG * 2),
EMG, EH - EMG - foot_h, 0)
body_top = sep_y + 4
body_bot = sep_yb - 2
row_h = LH * 2 + 3 # name + count + small gap
max_rows = max(1, (body_bot - body_top) // row_h)
start = 0
if len(decks) > max_rows:
start = max(0, min(sel - max_rows // 2, len(decks) - max_rows))
end = min(len(decks), start + max_rows)
name_max = max(1, (EW - EMG * 2 - CW) // CW) # leave room for cursor
for vis_i, idx in enumerate(range(start, end)):
deck = decks[idx]
n = due_count(deck)
y = body_top + vis_i * row_h
is_sel = (idx == sel)
if is_sel:
# Cursor + bold-ish (we only have one weight so just cursor)
fb.text('>', EMG, y, 0)
name = deck['name'][:name_max]
fb.text(name, EMG + CW, y, 0)
sub = 'due: {}'.format(n)
fb.text(sub, EMG + CW, y + LH, 0)
refresher.push(force_full=True)
def render_card(deck_name, card, idx, total, show_answer, grade_sel,
full=False):
"""
Portrait card screen.
show_answer=False:
header | wrapped question (centered vertically) | hint
show_answer=True:
header | question (top, ~2 lines) | divider | answer (wrapped) |
vertical 4-row grade bar | hint
"""
fb = epd.fb
fb.fill(1)
chars = max(1, (EW - EMG * 2) // CW)
# ---- header ----
progress = '{}/{}'.format(idx + 1, total)
pw = len(progress) * CW
name_w = EW - EMG * 2 - pw - CW
name = _fit(deck_name, name_w)
fb.text(name, EMG, EMG, 0)
fb.text(progress, EW - EMG - pw, EMG, 0)
sep_y = EMG + 8 + 2
fb.hline(0, sep_y, EW, 0)
# ---- footer ----
foot_h = 8
sep_yb = EH - EMG - foot_h - 2
fb.hline(0, sep_yb, EW, 0)
body_top = sep_y + 3
body_bot = sep_yb - 2
body_h = body_bot - body_top
if not show_answer:
lines = _wrap_lines(card['q'], chars)
max_lines = body_h // LH
if len(lines) > max_lines:
lines = lines[:max_lines]
total_h = len(lines) * LH
ystart = body_top + max(0, (body_h - total_h) // 2)
for i, ln in enumerate(lines):
tx = (EW - len(ln) * CW) // 2
if tx < EMG:
tx = EMG
fb.text(ln, tx, ystart + i * LH, 0)
fb.text(_fit('click to flip', EW - EMG * 2),
EMG, EH - EMG - foot_h, 0)
else:
# Vertical grade bar at the bottom of the body area: 4 rows,
# each row 13 px tall (text + 3 px padding). Total = 52 px.
gb_row_h = 13
gb_h = gb_row_h * len(GRADES)
gb_y0 = body_bot - gb_h
# Question + divider + answer fit in the area above the grade bar.
qa_top = body_top
qa_bot = gb_y0 - 2
qa_h = qa_bot - qa_top
# Allow up to 2 lines for question, rest for answer.
q_lines = _wrap_lines(card['q'], chars)
if len(q_lines) > 2:
q_lines = q_lines[:2]
q_h = len(q_lines) * LH
for i, ln in enumerate(q_lines):
fb.text(ln, EMG, qa_top + i * LH, 0)
div_y = qa_top + q_h + 1
# dotted-ish divider: skip every other pixel
for x in range(EMG + 2, EW - EMG - 2, 2):
fb.pixel(x, div_y, 0)
a_top = div_y + 3
a_h = qa_bot - a_top
a_max_lines = max(1, a_h // LH)
a_lines = _wrap_lines(card['a'], chars)
if len(a_lines) > a_max_lines:
a_lines = a_lines[:a_max_lines - 1] + [a_lines[a_max_lines - 1][:chars - 1] + '~']
for i, ln in enumerate(a_lines):
fb.text(ln, EMG, a_top + i * LH, 0)
# Vertical grade bar
for gi, g in enumerate(GRADES):
cy = gb_y0 + gi * gb_row_h
label = g[:chars]
tx = EMG + max(0, (EW - EMG * 2 - len(label) * CW) // 2)
ty = cy + (gb_row_h - LH) // 2 + 1
if gi == grade_sel:
fb.fill_rect(EMG, cy, EW - EMG * 2, gb_row_h, 0)
fb.text(label, tx, ty, 1)
else:
fb.rect(EMG, cy, EW - EMG * 2, gb_row_h, 0)
fb.text(label, tx, ty, 0)
fb.text(_fit('turn pick click ok', EW - EMG * 2),
EMG, EH - EMG - foot_h, 0)
refresher.push(force_full=full)
def render_session_done(deck_name, reviewed):
fb = epd.fb
fb.fill(1)
title = _fit('Session done', EW - EMG * 2)
fb.text(title, (EW - len(title) * CW) // 2, EH // 2 - 20, 0)
sub = _fit('{} cards'.format(reviewed), EW - EMG * 2)
fb.text(sub, (EW - len(sub) * CW) // 2, EH // 2 - 4, 0)
nm = _fit(deck_name, EW - EMG * 2)
fb.text(nm, (EW - len(nm) * CW) // 2, EH // 2 + 10, 0)
hint = _fit('click=continue', EW - EMG * 2)
fb.text(hint, (EW - len(hint) * CW) // 2, EH - EMG - 8, 0)
refresher.push(force_full=True)
def render_empty_deck(deck_name):
fb = epd.fb
fb.fill(1)
msg = _fit('Nothing due', EW - EMG * 2)
fb.text(msg, (EW - len(msg) * CW) // 2, EH // 2 - 12, 0)
nm = _fit(deck_name, EW - EMG * 2)
fb.text(nm, (EW - len(nm) * CW) // 2, EH // 2, 0)
hint = _fit('click=back', EW - EMG * 2)
fb.text(hint, (EW - len(hint) * CW) // 2, EH - EMG - 8, 0)
refresher.push(force_full=True)
# ---------- OLED screens (unchanged — different display) ----------
def oled_status(deck_name, remaining, last_grade=None):
if not OLED_OK: return
oled.fill(0)
oled.fill_rect(0, 0, OW, 11, 1)
name = deck_name[:max(1, OW // 8 - 1)]
oled.text(name, 2, 2, 0)
oled.text('Left: {}'.format(remaining), 0, 18, 1)
lg = last_grade if last_grade is not None else '-'
oled.text('Last: {}'.format(lg), 0, 32, 1)
oled.text('hold=quit', 0, OH - 8, 1)
oled.show()
def oled_decks_hint(n_decks):
if not OLED_OK: return
oled.fill(0)
oled.fill_rect(0, 0, OW, 11, 1)
oled.text('Flashcards', 2, 2, 0)
oled.text('Decks: {}'.format(n_decks), 0, 18, 1)
oled.text('rotate=pick', 0, 32, 1)
oled.text('click=open', 0, 44, 1)
oled.text('hold=quit', 0, OH - 8, 1)
oled.show()
def oled_clear():
if OLED_OK:
oled.fill(0); oled.show()
# ---------- review session ----------
def review_deck(db, deck):
queue = collect_due_cards(deck)
if not queue:
render_empty_deck(deck['name'])
oled_status(deck['name'], 0, None)
while True:
a = ui.wait()
if a in ('dial', 'dial_long', 'dial_double'):
return 0
reviewed = 0
last_grade = None
pos = 0
while pos < len(queue):
card_idx = queue[pos]
card = deck['cards'][card_idx]
# Phase 1: question only
render_card(deck['name'], card, pos, len(queue),
show_answer=False, grade_sel=2, full=(pos == 0))
oled_status(deck['name'], len(queue) - pos, last_grade)
flipped = False
while not flipped:
a = ui.wait()
if a == 'dial':
flipped = True
elif a == 'dial_long':
save_db(db)
return reviewed
elif a == 'dial_double':
save_db(db)
return reviewed
# Phase 2: answer + grading
grade_sel = 2
render_card(deck['name'], card, pos, len(queue),
show_answer=True, grade_sel=grade_sel)
graded = False
while not graded:
a = ui.wait()
if a == 'dial+':
grade_sel = (grade_sel + 1) % len(GRADES)
render_card(deck['name'], card, pos, len(queue),
show_answer=True, grade_sel=grade_sel)
elif a == 'dial-':
grade_sel = (grade_sel - 1) % len(GRADES)
render_card(deck['name'], card, pos, len(queue),
show_answer=True, grade_sel=grade_sel)
elif a == 'dial':
schedule(card, grade_sel)
last_grade = GRADES[grade_sel]
reviewed += 1
save_db(db)
if grade_sel == 0:
queue.append(card_idx)
graded = True
elif a in ('dial_long', 'dial_double'):
save_db(db)
return reviewed
pos += 1
gc.collect()
render_session_done(deck['name'], reviewed)
oled_status(deck['name'], 0, last_grade)
while True:
a = ui.wait()
if a in ('dial', 'dial_long', 'dial_double'):
return reviewed
# ---------- deck list mode ----------
def deck_list_mode(db):
sel = 0
decks = db['decks']
if not decks:
decks = list(DEFAULT_DB['decks'])
db['decks'] = decks
save_db(db)
render_deck_list(decks, sel)
oled_decks_hint(len(decks))
while True:
a = ui.wait()
if a == 'dial+':
sel = (sel + 1) % len(decks)
render_deck_list(decks, sel)
elif a == 'dial-':
sel = (sel - 1) % len(decks)
render_deck_list(decks, sel)
elif a == 'dial':
review_deck(db, decks[sel])
render_deck_list(decks, sel)
oled_decks_hint(len(decks))
elif a == 'dial_long':
return
# ---------- main ----------
def main():
db = load_db()
if db.get('baseline', 0) == 0:
db['baseline'] = now_s()
save_db(db)
try:
deck_list_mode(db)
finally:
save_db(db)
oled_clear()
if __name__ == '__main__':
main()
else:
main()
Weather (portrait 122×250, Open-Meteo)
Network weather on the same hardware: EPD portrait (EW / EH from epd.WIDTH / HEIGHT), SSD1306 on I2C(0) sda=16, scl=17. The e-ink UI is laid out for a tall panel—header and footer rules bracket a body height derived from EH, so the current view stacks city + time, icon (size from remaining space), condition label, 2× scaled temperature, then feels / humidity / wind; forecast splits the body into up to four day rows (day, icon, hi/lo) with adaptive row height. render_current_page / render_forecast_page docstrings describe the vertical bands.
Data — Open-Meteo forecast JSON (current + 4 daily fields). weather.txt lists cities as Name,lat,lon (defaults seeded if missing); weather_settings.json stores °C/°F, wind km/h vs mph, and refresh interval (minutes).
Wi-Fi — Expect a device-local wifi_secrets.py with SSID and PASSWORD (not shipped in this repo). Startup retries on failure.
OLED — pico_anim.Animation: weather-kind–specific loops (sun rays, clouds, rain, etc.) plus a top HUD bar (city / temp). City picker and settings are OLED-only menus.
Refresh (_Refresher) — Same batching pattern as flashcards: full every 5 partials or when push_full / force_full; counter resets to 0 after full.
Encoder — dial: clk=2, dt=3, sw=4, long_press=True, double_click=True. Idle (animated OLED): dial → city picker, dial_double → settings, dial_long → force API refresh, dial+ / dial- → e-ink current ↔ forecast pages.
Python / network (client only) — This firmware does not run an HTTP server on the Pico (contrast canvas_server.py). It is an HTTP(S) client: wifi_connect() brings up network.WLAN(STA_IF) with wifi_secrets, then fetch_weather(lat, lon) calls urequests.get on Open-Meteo — GET https://api.open-meteo.com/v1/forecast with latitude / longitude, current=temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code,apparent_temperature, daily=temperature_2m_max,temperature_2m_min,weather_code,precipitation_probability_max, timezone=auto, forecast_days=4, wind_speed_unit=kmh. JSON is mapped into a single dict (temp, feels, humidity, wind, code, forecast arrays). fetch_for_current_city returns cached data while age_ms < refresh_min × 60 × 1000 unless force; on success it **cache_put**s with time.ticks_ms(). main() also refreshes on a timer when the cache is stale.
Core client code (excerpt from weather_ui.py — Wi‑Fi + Open‑Meteo + cache policy)
def wifi_connect(timeout_s=20):
if wlan.isconnected(): return True
wlan.active(True)
wlan.connect(SSID, PASSWORD)
deadline = time.ticks_add(time.ticks_ms(), timeout_s * 1000)
while time.ticks_diff(deadline, time.ticks_ms()) > 0:
if wlan.isconnected(): return True
time.sleep_ms(150)
return False
def fetch_weather(lat, lon):
url = (
'https://api.open-meteo.com/v1/forecast?'
'latitude={}&longitude={}'
'¤t=temperature_2m,relative_humidity_2m,wind_speed_10m,'
'weather_code,apparent_temperature'
'&daily=temperature_2m_max,temperature_2m_min,weather_code,'
'precipitation_probability_max'
'&timezone=auto&forecast_days=4&wind_speed_unit=kmh'
).format(lat, lon)
try:
r = urequests.get(url)
if r.status_code != 200:
r.close(); return None
data = r.json()
r.close()
gc.collect()
except Exception as e:
print('fetch err:', e)
return None
cur = data.get('current', {}); daily = data.get('daily', {})
return {
'temp': cur.get('temperature_2m'),
'feels': cur.get('apparent_temperature'),
'humidity': cur.get('relative_humidity_2m'),
'wind': cur.get('wind_speed_10m'),
'code': cur.get('weather_code'),
'fc_dates': daily.get('time', []),
'fc_max': daily.get('temperature_2m_max', []),
'fc_min': daily.get('temperature_2m_min', []),
'fc_code': daily.get('weather_code', []),
'fc_pop': daily.get('precipitation_probability_max', []),
}
def fetch_for_current_city(force=False):
name, lat, lon = CITIES[state['city_idx']]
cached = cache_get(name)
age_ms = (time.ticks_diff(time.ticks_ms(), cached['fetched_at'])
if cached else None)
if cached and age_ms < settings['refresh_min'] * 60 * 1000 and not force:
return cached['data']
if not wlan.isconnected():
if not wifi_connect():
return None
w = fetch_weather(lat, lon)
if w is not None:
cache_put(name, w)
return w
Download: weather_ui.py
Full weather_ui.py source
"""
Weather app — Open-Meteo on e-ink + animated OLED HUD.
Portrait (122×250): current page + forecast page; layout sized from EH.
Requires: wifi_secrets.py with SSID, PASSWORD
"""
from machine import Pin, I2C
import time, gc, math, json
import network
import urequests
from epd2in13 import EPD
from ssd1306 import SSD1306_I2C
from pico_ui_input import InputManager
from pico_anim import Animation
try:
from wifi_secrets import SSID, PASSWORD
except ImportError:
print("ERROR: create wifi_secrets.py with SSID and PASSWORD")
raise
CITIES_FILE = 'weather.txt'
SETTINGS_FILE = 'weather_settings.json'
DEFAULT_CITIES = [
('Istanbul', 41.0082, 28.9784),
('Ankara', 39.9334, 32.8597),
('Izmir', 38.4192, 27.1287),
('London', 51.5074, -0.1278),
('Tokyo', 35.6762, 139.6503),
]
DEFAULTS = {
'units_temp': 'C',
'units_wind': 'kmh',
'refresh_min': 10,
}
def _parse_city_line(line):
line = line.strip()
if not line or line.startswith('#'):
return None
parts = [p.strip() for p in line.split(',')]
if len(parts) < 3:
return None
try:
lon = float(parts[-1])
lat = float(parts[-2])
except (ValueError, TypeError):
return None
name = ', '.join(parts[:-2]).strip().strip(',').strip()
has_alnum = False
for c in name:
if ('a' <= c <= 'z') or ('A' <= c <= 'Z') or ('0' <= c <= '9'):
has_alnum = True
break
if not name or not has_alnum:
return None
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
return None
return (name, lat, lon)
def _write_cities_file(cities):
try:
with open(CITIES_FILE, 'w') as f:
f.write('# Cities for the weather app\n')
f.write('# Format: Name,latitude,longitude\n')
for name, lat, lon in cities:
f.write('{},{},{}\n'.format(name, lat, lon))
except Exception as e:
print('write cities:', e)
def load_cities():
parsed = []
try:
with open(CITIES_FILE) as f:
for line in f:
e = _parse_city_line(line)
if e:
parsed.append(e)
except OSError:
_write_cities_file(DEFAULT_CITIES)
return list(DEFAULT_CITIES)
if not parsed:
return list(DEFAULT_CITIES)
return parsed
CITIES = load_cities()
def _read_json(path, default):
try:
with open(path) as f:
return json.load(f)
except:
return default
def _write_json(path, data):
try:
with open(path, 'w') as f:
json.dump(data, f)
except:
pass
settings = dict(DEFAULTS)
settings.update(_read_json(SETTINGS_FILE, {}))
for k, v in DEFAULTS.items():
settings.setdefault(k, v)
def save_settings():
_write_json(SETTINGS_FILE, settings)
def disp_temp(c):
if c is None: return '--'
if settings['units_temp'] == 'F':
return '{}'.format(int(round(c * 9 / 5 + 32)))
return '{}'.format(int(round(c)))
def temp_unit():
return 'F' if settings['units_temp'] == 'F' else 'C'
def disp_wind(kmh):
if kmh is None: return '--'
if settings['units_wind'] == 'mph':
return '{}'.format(int(round(kmh * 0.621)))
return '{}'.format(int(round(kmh)))
def wind_unit():
return 'mph' if settings['units_wind'] == 'mph' else 'kmh'
epd = EPD()
EW, EH = epd.WIDTH, epd.HEIGHT
EMG = 4
CW, LH = 8, 10
i2c0 = I2C(0, sda=Pin(16), scl=Pin(17), freq=400_000)
try:
oled = SSD1306_I2C(128, 64, i2c0)
OLED_OK = True
except Exception as e:
print('OLED init failed:', e)
oled = None
OLED_OK = False
OW, OH = 128, 64
class _Refresher:
def __init__(self, ep):
self.ep = ep
self.partials = 0
def push(self, force_full=False):
if force_full or self.partials >= 5:
self.ep.display_full()
self.partials = 0
else:
if self.partials == 0:
self.ep.display_full()
else:
self.ep.display_partial()
self.partials += 1
def push_full(self):
self.ep.display_full()
self.partials = 0
refresher = _Refresher(epd)
ui = InputManager()
ui.add_encoder('dial', clk=2, dt=3, sw=4,
long_press=True, double_click=True)
WMO = {
0: ('Clear', 'sun'),
1: ('Mostly clear', 'sun'),
2: ('Partly cloud', 'pcloud'),
3: ('Overcast', 'cloud'),
45: ('Fog', 'fog'), 48: ('Rime fog', 'fog'),
51: ('Lt drizzle', 'rain'), 53: ('Drizzle', 'rain'),
55: ('Hv drizzle', 'rain'), 61: ('Lt rain', 'rain'),
63: ('Rain', 'rain'), 65: ('Hv rain', 'rain'),
71: ('Lt snow', 'snow'), 73: ('Snow', 'snow'),
75: ('Hv snow', 'snow'), 77: ('Snow grain','snow'),
80: ('Showers', 'rain'), 81: ('Showers', 'rain'),
82: ('Hv showers', 'rain'), 85: ('Snow shwr', 'snow'),
86: ('Snow shwr', 'snow'),
95: ('Thunder', 'storm'),
96: ('Thunder hail', 'storm'), 99: ('Thunder hail','storm'),
}
def wmo_label(c): return WMO.get(c, ('Unknown', 'cloud'))[0]
def wmo_kind(c): return WMO.get(c, ('Unknown', 'cloud'))[1]
wlan = network.WLAN(network.STA_IF)
def wifi_connect(timeout_s=20):
if wlan.isconnected(): return True
wlan.active(True)
wlan.connect(SSID, PASSWORD)
deadline = time.ticks_add(time.ticks_ms(), timeout_s * 1000)
while time.ticks_diff(deadline, time.ticks_ms()) > 0:
if wlan.isconnected(): return True
time.sleep_ms(150)
return False
def fetch_weather(lat, lon):
url = (
'https://api.open-meteo.com/v1/forecast?'
'latitude={}&longitude={}'
'¤t=temperature_2m,relative_humidity_2m,wind_speed_10m,'
'weather_code,apparent_temperature'
'&daily=temperature_2m_max,temperature_2m_min,weather_code,'
'precipitation_probability_max'
'&timezone=auto&forecast_days=4&wind_speed_unit=kmh'
).format(lat, lon)
try:
r = urequests.get(url)
if r.status_code != 200:
r.close(); return None
data = r.json()
r.close()
gc.collect()
except Exception as e:
print('fetch err:', e)
return None
cur = data.get('current', {}); daily = data.get('daily', {})
return {
'temp': cur.get('temperature_2m'),
'feels': cur.get('apparent_temperature'),
'humidity': cur.get('relative_humidity_2m'),
'wind': cur.get('wind_speed_10m'),
'code': cur.get('weather_code'),
'fc_dates': daily.get('time', []),
'fc_max': daily.get('temperature_2m_max', []),
'fc_min': daily.get('temperature_2m_min', []),
'fc_code': daily.get('weather_code', []),
'fc_pop': daily.get('precipitation_probability_max', []),
}
def weekday_name(date_str):
try:
y, m, d = (int(x) for x in date_str.split('-'))
except:
return '?'
if m < 3: m += 12; y -= 1
K = y % 100; J = y // 100
h = (d + (13 * (m + 1)) // 5 + K + K // 4 + J // 4 + 5 * J) % 7
return ('Sat', 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri')[h]
# ----- e-ink helpers -----
def _ep_filled_circle(fb, cx, cy, r, color):
for dy in range(-r, r + 1):
dx = int((r * r - dy * dy) ** 0.5)
fb.hline(cx - dx, cy + dy, 2 * dx + 1, color)
def _ep_circle_outline(fb, cx, cy, r, color):
for dy in range(-r, r + 1):
for dx in range(-r, r + 1):
d2 = dx * dx + dy * dy
if (r - 1) * (r - 1) <= d2 <= r * r:
fb.pixel(cx + dx, cy + dy, color)
def _ep_cloud_sm(fb, cx, cy, w, color):
r = max(3, w // 4)
_ep_filled_circle(fb, cx - w // 4, cy + 1, r, color)
_ep_filled_circle(fb, cx + w // 4, cy + 1, r, color)
_ep_filled_circle(fb, cx, cy - r // 2, r + 1, color)
fb.fill_rect(cx - w // 3, cy + 1, (2 * w) // 3, r, color)
def ep_icon(fb, cx, cy, kind, size, color=0):
s = size
if kind == 'sun':
_ep_filled_circle(fb, cx, cy, s // 4, color)
for ox, oy in [(0,-1),(1,-1),(1,0),(1,1),(0,1),(-1,1),(-1,0),(-1,-1)]:
x1 = cx + int(ox * (s // 3.2))
y1 = cy + int(oy * (s // 3.2))
x2 = cx + int(ox * (s // 2.2))
y2 = cy + int(oy * (s // 2.2))
fb.line(x1, y1, x2, y2, color)
elif kind == 'pcloud':
scx, scy = cx - s // 4, cy - s // 4
_ep_filled_circle(fb, scx, scy, max(2, s // 8), color)
for ox, oy in [(0,-1),(1,0),(-1,-1)]:
x1 = scx + int(ox * (s // 5))
y1 = scy + int(oy * (s // 5))
x2 = scx + int(ox * (s // 3.5))
y2 = scy + int(oy * (s // 3.5))
fb.line(x1, y1, x2, y2, color)
_ep_cloud_sm(fb, cx + s // 8, cy + s // 8, s, color)
elif kind == 'cloud':
_ep_cloud_sm(fb, cx, cy, s, color)
elif kind == 'rain':
_ep_cloud_sm(fb, cx, cy - s // 8, int(s * 0.85), color)
for dx in (-s // 4, 0, s // 4):
fb.line(cx + dx, cy + s // 4, cx + dx - s // 16,
cy + s // 2, color)
elif kind == 'snow':
_ep_cloud_sm(fb, cx, cy - s // 8, int(s * 0.85), color)
for dx in (-s // 4, 0, s // 4):
sx, sy = cx + dx, cy + s // 3
r = max(2, s // 12)
fb.line(sx - r, sy, sx + r, sy, color)
fb.line(sx, sy - r, sx, sy + r, color)
elif kind == 'fog':
thick = max(1, s // 16)
for i in range(3):
y = cy - s // 3 + i * (s // 4)
for t in range(thick):
fb.hline(cx - s // 2, y + t, s, color)
elif kind == 'storm':
_ep_cloud_sm(fb, cx, cy - s // 8, int(s * 0.85), color)
bx, by = cx, cy + s // 5
fb.line(bx + 1, by, bx - s // 8, by + s // 6, color)
fb.line(bx + 2, by, bx - s // 8 + 1, by + s // 6, color)
fb.line(bx - s // 8, by + s // 6, bx + 1, by + s // 6, color)
fb.line(bx + 1, by + s // 6, bx - s // 12, by + s // 3, color)
def _fit_text(s, max_w):
"""Truncate s to fit within max_w pixels at CW per char."""
chars = max(0, max_w // CW)
if len(s) > chars:
s = s[:chars]
return s
def _draw_big_text(fb, s, x, y, scale):
"""Draw text scaled up by integer factor without per-pixel reads."""
import framebuf
if not s:
return 0
pad_w = ((len(s) * 8 + 7) // 8) * 8
tmp = bytearray(pad_w) # 8 rows * pad_w cols / 8 = pad_w bytes
tfb = framebuf.FrameBuffer(tmp, pad_w, 8, framebuf.MONO_HLSB)
tfb.fill(1)
tfb.text(s, 0, 0, 0)
for ty in range(8):
for txp in range(len(s) * 8):
if tfb.pixel(txp, ty) == 0:
fb.fill_rect(x + txp * scale, y + ty * scale,
scale, scale, 0)
return len(s) * 8 * scale # width in px
# ----- e-ink screens -----
def render_loading(msg):
fb = epd.fb
fb.fill(1)
msg = _fit_text(msg, EW - EMG * 2)
fb.text(msg, (EW - len(msg) * CW) // 2, EH // 2 - 4, 0)
refresher.push_full()
def render_error(msg):
fb = epd.fb
fb.fill(1)
title = 'Error'
fb.text(title, (EW - len(title) * CW) // 2, EH // 2 - 30, 0)
msg = _fit_text(msg, EW - EMG * 2)
fb.text(msg, (EW - len(msg) * CW) // 2, EH // 2 - 4, 0)
hint = _fit_text('click to retry', EW - EMG * 2)
fb.text(hint, (EW - len(hint) * CW) // 2, EH // 2 + 30, 0)
refresher.push_full()
def render_current_page(city, w, full=False):
"""
Layout (top -> bottom):
[HEADER] name (left) ... HH:MM (right) 8 px
[HSEP] 1 px
[ICON] centered weather icon ~icon_h
[LABEL] condition text 8 px
[TEMP] big temperature, 2x scale 16 px
[STATS] feels / hum / wind (3 lines) 3*LH
[HSEP] 1 px
[FOOTER] 'current' ... '1/2' 8 px
Sizes are computed from EH so nothing overlaps even on small panels.
"""
fb = epd.fb
fb.fill(1)
# Header
ts = ''
try:
t = time.localtime()
ts = '{:02d}:{:02d}'.format(t[3], t[4])
except:
ts = ''
ts_w = len(ts) * CW
name_max_w = EW - EMG * 2 - (ts_w + CW if ts else 0)
name = _fit_text(city, name_max_w)
fb.text(name, EMG, EMG, 0)
if ts:
fb.text(ts, EW - EMG - ts_w, EMG, 0)
sep_y_top = EMG + 8 + 2
fb.hline(EMG, sep_y_top, EW - EMG * 2, 0)
# Footer
foot_h = 8
sep_y_bot = EH - EMG - foot_h - 2
fb.hline(EMG, sep_y_bot, EW - EMG * 2, 0)
fb.text('current', EMG, EH - EMG - foot_h, 0)
page_str = '1/2'
fb.text(page_str, EW - EMG - len(page_str) * CW,
EH - EMG - foot_h, 0)
# Body
body_top = sep_y_top + 2
body_bot = sep_y_bot - 2
body_h = body_bot - body_top
# Choose temp scale based on free space
temp_str = '{}{}'.format(disp_temp(w['temp']), temp_unit())
temp_scale = 2 # 16 px tall - safer than 3x (24 px)
label = wmo_label(w['code'])
label = _fit_text(label, EW - EMG * 2)
stats_h = 3 * LH
# Reserve: label (8) + gap (3) + temp (8*scale) + gap (4) + stats
reserved = 8 + 3 + 8 * temp_scale + 4 + stats_h
icon_h = max(20, body_h - reserved - 2)
if icon_h > 56:
icon_h = 56
icon_cx = EW // 2
icon_cy = body_top + icon_h // 2
ep_icon(fb, icon_cx, icon_cy, wmo_kind(w['code']), icon_h, 0)
label_y = body_top + icon_h + 1
fb.text(label, (EW - len(label) * CW) // 2, label_y, 0)
temp_y = label_y + 8 + 3
temp_w = len(temp_str) * 8 * temp_scale
temp_x = (EW - temp_w) // 2
if temp_x < EMG:
temp_x = EMG
_draw_big_text(fb, temp_str, temp_x, temp_y, temp_scale)
stats_y = temp_y + 8 * temp_scale + 4
feels = _fit_text(
'Feels {}{}'.format(disp_temp(w['feels']), temp_unit()),
EW - EMG * 2)
hum = _fit_text(
('Hum {}%'.format(w['humidity'])
if w['humidity'] is not None else 'Hum --'),
EW - EMG * 2)
wind = _fit_text(
'Wind {} {}'.format(disp_wind(w['wind']), wind_unit()),
EW - EMG * 2)
for i, line in enumerate((feels, hum, wind)):
ly = stats_y + i * LH
if ly + 8 > sep_y_bot - 1:
break # safety: don't draw into footer separator
fb.text(line, EMG, ly, 0)
refresher.push(force_full=full)
def render_forecast_page(city, w, full=False):
"""
Layout:
[HEADER] name 8 px
[HSEP] 1 px
[BODY] up to 4 forecast rows, evenly divided
[HSEP] 1 px
[FOOTER] 'forecast' ... '2/2' 8 px
Row contents (left -> right):
day-name (3 ch) icon hi-temp / lo-temp
Row height adapts to fit; no row spills into the footer.
"""
fb = epd.fb
fb.fill(1)
# Header
name = _fit_text(city, EW - EMG * 2)
fb.text(name, EMG, EMG, 0)
sep_y_top = EMG + 8 + 2
fb.hline(EMG, sep_y_top, EW - EMG * 2, 0)
# Footer
foot_h = 8
sep_y_bot = EH - EMG - foot_h - 2
fb.hline(EMG, sep_y_bot, EW - EMG * 2, 0)
fb.text('forecast', EMG, EH - EMG - foot_h, 0)
page_str = '2/2'
fb.text(page_str, EW - EMG - len(page_str) * CW,
EH - EMG - foot_h, 0)
# Body
body_top = sep_y_top + 2
body_bot = sep_y_bot - 2
body_h = body_bot - body_top
n = min(4, len(w.get('fc_dates', [])))
if n <= 0:
msg = _fit_text('No forecast', EW - EMG * 2)
fb.text(msg, (EW - len(msg) * CW) // 2,
body_top + body_h // 2 - 4, 0)
refresher.push(force_full=full)
return
row_h = body_h // n # adaptive, never overflows
icon_size = max(14, min(row_h - 4, 26))
day_w = 3 * CW + 2 # "Mon" + small gap
icon_cx = EMG + day_w + icon_size // 2
text_x = icon_cx + icon_size // 2 + 4
text_avail = EW - EMG - text_x
for i in range(n):
ry = body_top + i * row_h
rcy = ry + row_h // 2
day = weekday_name(w['fc_dates'][i])[:3]
fb.text(day, EMG, rcy - 4, 0)
kind = wmo_kind(w['fc_code'][i] if i < len(w['fc_code']) else 0)
ep_icon(fb, icon_cx, rcy, kind, icon_size, 0)
t_max = '{}{}'.format(
disp_temp(w['fc_max'][i] if i < len(w['fc_max']) else None),
temp_unit())
t_min = '{}{}'.format(
disp_temp(w['fc_min'][i] if i < len(w['fc_min']) else None),
temp_unit())
hi = _fit_text(t_max, text_avail)
lo = _fit_text(t_min, text_avail)
# Stack hi above lo, vertically centered around rcy
hi_y = rcy - 8 - 1
lo_y = rcy + 1
if hi_y < ry:
hi_y = ry
if lo_y + 8 > ry + row_h - 1:
lo_y = ry + row_h - 1 - 8
fb.text(hi, text_x, hi_y, 0)
fb.text(lo, text_x, lo_y, 0)
# Row separator (skip last; never touches footer line)
if i < n - 1:
sy = ry + row_h - 1
if sy < sep_y_bot - 1:
fb.hline(EMG + 2, sy, EW - (EMG + 2) * 2, 0)
refresher.push(force_full=full)
# ----- OLED helpers -----
def _oled_cloud(cx, cy, w):
if not OLED_OK: return
half = w // 2
if cx + half < 0 or cx - half > OW: return
r1 = w // 3
for dy in range(-r1, r1 + 1):
for dx in range(-r1, r1 + 1):
if dx*dx + dy*dy <= r1*r1:
px, py = cx + dx, cy + dy
if 0 <= px < OW and 0 <= py < OH:
oled.pixel(px, py, 1)
r2 = w // 4
for ox in (-w // 3, w // 3):
for dy in range(-r2, r2 + 1):
for dx in range(-r2, r2 + 1):
if dx*dx + dy*dy <= r2*r2:
px, py = cx + ox + dx, cy + dy + 2
if 0 <= px < OW and 0 <= py < OH:
oled.pixel(px, py, 1)
def _hud(ctx):
"""Top status bar: city (left), temp (right). Truncates city to fit."""
if not OLED_OK: return
city = ctx.get('city', '')
temp = ctx.get('temp', '')
if not city and not temp:
return
oled.fill_rect(0, 0, OW, 11, 1)
max_chars = OW // 8
temp_chars = len(temp)
# Reserve: 1 char left margin + temp + 1 char gap + 1 char right margin
city_room = max(0, max_chars - temp_chars - 2)
city_short = city[:city_room]
if city_short:
oled.text(city_short, 2, 2, 0)
if temp:
oled.text(temp, OW - len(temp) * 8 - 2, 2, 0)
def _draw_sun(idx, ctx):
if not OLED_OK: return
oled.fill(0)
cx, cy = OW // 2, OH // 2 + 6
for dy in range(-9, 10):
for dx in range(-9, 10):
if dx*dx + dy*dy <= 81:
oled.pixel(cx + dx, cy + dy, 1)
rot = idx * (2 * math.pi / 16)
for k in range(12):
ang = rot + k * (math.pi / 6)
x1 = cx + int(13 * math.cos(ang))
y1 = cy + int(13 * math.sin(ang))
x2 = cx + int(26 * math.cos(ang))
y2 = cy + int(26 * math.sin(ang))
oled.line(x1, y1, x2, y2, 1)
_hud(ctx)
oled.show()
def _draw_pcloud(idx, ctx):
if not OLED_OK: return
oled.fill(0)
cx, cy = 26, 28
for dy in range(-6, 7):
for dx in range(-6, 7):
if dx*dx + dy*dy <= 36:
oled.pixel(cx + dx, cy + dy, 1)
for ox, oy in [(0,-1),(1,0),(0,1),(-1,0),(1,-1),(1,1),(-1,1),(-1,-1)]:
oled.line(cx + ox*8, cy + oy*8, cx + ox*11, cy + oy*11, 1)
offset = (idx * 2) % (OW + 60) - 30
_oled_cloud(offset + 30, 48, 28)
_hud(ctx)
oled.show()
def _draw_cloud_anim(idx, ctx):
if not OLED_OK: return
oled.fill(0)
o1 = (idx * 2) % (OW + 80) - 40
o2 = (idx * 3 + 40) % (OW + 80) - 40
_oled_cloud(o1, 26, 32)
_oled_cloud(o2, 48, 26)
_hud(ctx)
oled.show()
def _draw_rain(idx, ctx):
if not OLED_OK: return
oled.fill(0)
_oled_cloud(OW // 2, 24, 40)
drops = [(20, 0), (35, 3), (50, 6), (65, 1), (80, 4),
(95, 7), (110, 2), (28, 5), (75, 0)]
for x, phase in drops:
y = 38 + ((idx * 2 + phase * 3) % 22)
oled.line(x, y, x - 1, y + 3, 1)
_hud(ctx)
oled.show()
def _draw_snow(idx, ctx):
if not OLED_OK: return
oled.fill(0)
_oled_cloud(OW // 2, 24, 40)
flakes = [(20, 0), (40, 3), (60, 6), (80, 1), (100, 4),
(30, 5), (70, 2), (90, 7)]
for x, phase in flakes:
y = 38 + ((idx * 2 + phase * 3) % 22)
wobble = int(math.sin((idx + phase) * 0.4) * 2)
fx = x + wobble
if 1 <= fx < OW - 1 and 1 <= y < OH - 1:
oled.pixel(fx, y, 1)
oled.pixel(fx - 1, y, 1)
oled.pixel(fx + 1, y, 1)
oled.pixel(fx, y - 1, 1)
oled.pixel(fx, y + 1, 1)
_hud(ctx)
oled.show()
def _draw_fog(idx, ctx):
if not OLED_OK: return
oled.fill(0)
for row, base_y in enumerate((22, 34, 46, 56)):
offset = (idx * (1 + row % 2) + row * 7) % 16 - 8
for x in range(0, OW, 4):
seg = (x + offset) % OW
oled.line(seg, base_y, min(seg + 2, OW - 1), base_y, 1)
if base_y + 1 < OH:
oled.line(seg, base_y + 1,
min(seg + 2, OW - 1), base_y + 1, 1)
_hud(ctx)
oled.show()
def _draw_storm(idx, ctx):
if not OLED_OK: return
oled.fill(0)
_oled_cloud(OW // 2, 24, 44)
flash = (idx % 6) < 2
if flash:
bx = OW // 2
oled.line(bx + 2, 36, bx - 6, 46, 1)
oled.line(bx + 3, 36, bx - 5, 46, 1)
oled.line(bx - 6, 46, bx + 2, 46, 1)
oled.line(bx + 2, 46, bx - 4, 58, 1)
oled.line(bx + 3, 46, bx - 3, 58, 1)
for x, phase in [(20, 0), (40, 3), (90, 6), (110, 2)]:
y = 40 + ((idx * 2 + phase * 4) % 20)
oled.line(x, y, x - 1, y + 3, 1)
_hud(ctx)
oled.show()
ANIM_CTX = {'city': '', 'temp': '', 'kind': 'cloud'}
ANIMS = {
'sun': Animation(n_frames=16, durations=120, draw_fn=_draw_sun, ctx=ANIM_CTX),
'pcloud': Animation(n_frames=24, durations=140, draw_fn=_draw_pcloud, ctx=ANIM_CTX),
'cloud': Animation(n_frames=24, durations=160, draw_fn=_draw_cloud_anim, ctx=ANIM_CTX),
'rain': Animation(n_frames=8, durations=120, draw_fn=_draw_rain, ctx=ANIM_CTX),
'snow': Animation(n_frames=16, durations=160, draw_fn=_draw_snow, ctx=ANIM_CTX),
'fog': Animation(n_frames=16, durations=180, draw_fn=_draw_fog, ctx=ANIM_CTX),
'storm': Animation(n_frames=12, durations=140, draw_fn=_draw_storm, ctx=ANIM_CTX),
}
def current_anim():
return ANIMS.get(ANIM_CTX['kind'], ANIMS['cloud'])
def oled_clear():
if OLED_OK:
oled.fill(0); oled.show()
def oled_render_city_nav(idx):
"""
Layout:
[BAR] 'Pick city' 0..10
[PICKER] '<' .... NAME .... '>' centered y=24..34
[COUNT] 'i/N' y=42..50
[HINT] 'click ok' ... 'hold x' y=56..63
The center name area is clipped between the arrows so it never overlaps.
"""
if not OLED_OK: return
oled.fill(0)
oled.fill_rect(0, 0, OW, 11, 1)
oled.text('Pick city', 2, 2, 0)
# Arrows at fixed columns
arrow_y = 26
oled.text('<', 0, arrow_y, 1)
oled.text('>', OW - 8, arrow_y, 1)
# Name window strictly between arrows: x in [12, OW-12)
name_left = 12
name_right = OW - 12
name_window = name_right - name_left # px
max_chars = max(1, name_window // 8)
name = CITIES[idx][0]
if len(name) > max_chars:
name = name[:max_chars - 1] + '.' if max_chars > 1 else name[0]
name_w = len(name) * 8
nx = name_left + max(0, (name_window - name_w) // 2)
oled.text(name, nx, arrow_y, 1)
cnt = '{}/{}'.format(idx + 1, len(CITIES))
oled.text(cnt, (OW - len(cnt) * 8) // 2, 42, 1)
# Hints — keep within row
left_hint = 'click ok'
right_hint = 'hold x'
oled.text(left_hint, 0, OH - 8, 1)
oled.text(right_hint, OW - len(right_hint) * 8, OH - 8, 1)
oled.show()
def oled_render_settings(items, sel):
"""
Layout:
[BAR] 'Settings' y=0..10
[ROWS] up to 3 rows of 'label value' y=14, 25, 36
[HINT] 'clk=set hold=x' y=56..63
Rows are computed so highlight bg never touches the bar or hint.
"""
if not OLED_OK: return
oled.fill(0)
oled.fill_rect(0, 0, OW, 11, 1)
oled.text('Settings', 2, 2, 0)
rows_top = 14
row_h = 11
hint_y = OH - 8
max_rows = (hint_y - 2 - rows_top) // row_h # ensure 2 px gap above hint
if max_rows < 1:
max_rows = 1
# Scroll window so selected item is visible
if sel < 0:
sel = 0
if sel >= len(items):
sel = len(items) - 1
start = 0
if len(items) > max_rows:
start = max(0, min(sel - max_rows // 2, len(items) - max_rows))
end = min(len(items), start + max_rows)
max_chars = OW // 8
for vis_i, idx in enumerate(range(start, end)):
label, val = items[idx]
v = str(val)
# Truncate label so 'label value' fits in max_chars with >=1 space
room_label = max(1, max_chars - len(v) - 1)
l = label[:room_label]
pad = max_chars - len(l) - len(v)
if pad < 1:
pad = 1
row = l + ' ' * pad + v
# Hard clip
if len(row) > max_chars:
row = row[:max_chars]
y = rows_top + vis_i * row_h
if idx == sel:
oled.fill_rect(0, y - 1, OW, row_h - 1, 1)
oled.text(row, 0, y, 0)
else:
oled.text(row, 0, y, 1)
# Footer hint, truncated to width
hint = 'clk=set hold=x'
hint = hint[:OW // 8]
oled.text(hint, 0, hint_y, 1)
oled.show()
# ----- state / app loop -----
state = {
'city_idx': 0,
'cache': {},
'last_refresh_ms': 0,
'page': 0,
}
def cache_get(name):
return state['cache'].get(name)
def cache_put(name, data):
state['cache'][name] = {'data': data, 'fetched_at': time.ticks_ms()}
def update_anim_for_current_city():
name = CITIES[state['city_idx']][0]
ANIM_CTX['city'] = name
cached = cache_get(name)
if cached:
w = cached['data']
ANIM_CTX['kind'] = wmo_kind(w['code'])
ANIM_CTX['temp'] = '{}{}'.format(disp_temp(w['temp']), temp_unit())
else:
ANIM_CTX['kind'] = 'cloud'
ANIM_CTX['temp'] = '...'
current_anim().force_redraw()
def fetch_for_current_city(force=False):
name, lat, lon = CITIES[state['city_idx']]
cached = cache_get(name)
age_ms = (time.ticks_diff(time.ticks_ms(), cached['fetched_at'])
if cached else None)
if cached and age_ms < settings['refresh_min'] * 60 * 1000 and not force:
return cached['data']
if not wlan.isconnected():
if not wifi_connect():
return None
w = fetch_weather(lat, lon)
if w is not None:
cache_put(name, w)
return w
def render_eink(full=False):
name = CITIES[state['city_idx']][0]
cached = cache_get(name)
if cached is None:
render_error('No data')
return
w = cached['data']
if state['page'] == 0:
render_current_page(name, w, full=full)
else:
render_forecast_page(name, w, full=full)
def mode_idle():
update_anim_for_current_city()
while True:
a = ui.wait_animated(current_anim().tick)
if a == 'dial':
return 'city_nav'
elif a == 'dial_double':
return 'settings'
elif a == 'dial_long':
return 'force_refresh'
elif a == 'dial+':
state['page'] = (state['page'] + 1) % 2
render_eink()
elif a == 'dial-':
state['page'] = (state['page'] - 1) % 2
render_eink()
def mode_city_nav():
sel = state['city_idx']
oled_render_city_nav(sel)
while True:
a = ui.wait()
if a == 'dial+':
sel = (sel + 1) % len(CITIES)
oled_render_city_nav(sel)
elif a == 'dial-':
sel = (sel - 1) % len(CITIES)
oled_render_city_nav(sel)
elif a == 'dial':
state['city_idx'] = sel
return 'load_and_show'
elif a in ('dial_long', 'dial_double'):
return 'idle'
SETTING_DEFS = [
('Temp', 'units_temp', ['C', 'F']),
('Wind', 'units_wind', ['kmh', 'mph']),
('Refresh', 'refresh_min', [5, 10, 15, 30, 60]),
]
def _cycle_setting(sel, direction):
label, key, options = SETTING_DEFS[sel]
cur = settings[key]
i = options.index(cur) if cur in options else 0
settings[key] = options[(i + direction) % len(options)]
def mode_settings():
sel = 0
while True:
items = []
for label, key, _ in SETTING_DEFS:
v = settings[key]
if key == 'refresh_min':
v = '{}m'.format(v)
items.append((label, str(v)))
oled_render_settings(items, sel)
a = ui.wait()
if a == 'dial+':
sel = (sel + 1) % len(SETTING_DEFS)
elif a == 'dial-':
sel = (sel - 1) % len(SETTING_DEFS)
elif a == 'dial':
_cycle_setting(sel, +1)
elif a in ('dial_long', 'dial_double'):
save_settings()
return 'idle'
def main():
render_loading('Connecting...')
if not wifi_connect():
render_error('WiFi failed')
if OLED_OK:
oled.fill(0)
oled.text('No WiFi', 32, 28, 1)
oled.text('check secrets', 12, 44, 1)
oled.show()
ui.wait()
return main()
render_loading('Fetching...')
w = fetch_for_current_city(force=True)
if w is None:
render_error('Could not fetch')
ui.wait()
return main()
state['last_refresh_ms'] = time.ticks_ms()
render_eink(full=True)
update_anim_for_current_city()
next_mode = 'idle'
while True:
if (time.ticks_diff(time.ticks_ms(), state['last_refresh_ms'])
>= settings['refresh_min'] * 60 * 1000):
fetch_for_current_city(force=True)
state['last_refresh_ms'] = time.ticks_ms()
render_eink()
update_anim_for_current_city()
if next_mode == 'idle':
next_mode = mode_idle()
elif next_mode == 'city_nav':
r = mode_city_nav()
if r == 'load_and_show':
ANIM_CTX['city'] = CITIES[state['city_idx']][0]
ANIM_CTX['temp'] = '...'
ANIM_CTX['kind'] = 'cloud'
current_anim().force_redraw()
fetch_for_current_city(force=False)
state['last_refresh_ms'] = time.ticks_ms()
state['page'] = 0
render_eink(full=True)
next_mode = 'idle'
elif next_mode == 'settings':
mode_settings()
render_eink(full=True)
next_mode = 'idle'
elif next_mode == 'force_refresh':
ANIM_CTX['temp'] = '...'
current_anim().force_redraw()
fetch_for_current_city(force=True)
state['last_refresh_ms'] = time.ticks_ms()
render_eink(full=True)
next_mode = 'idle'
if __name__ == '__main__':
main()
else:
main()
Browser canvas server (canvas_server.py, Pico W)
All-in-one MicroPython HTTP server for the Week 14 workflow: it serves a 250×122 1-bpp HTML/JS canvas (brush, shapes, fill, generators, pack to MONO_HLSB-style rows) from the same file as the firmware. On the board you only need epd2in13.py (your EPD driver) and canvas_server.py. Endpoints: GET /, GET /status (JSON with w, h, expected packed byte count), POST /draw (raw bitmap body, query mode=partial|full), POST /clear, plus CORS for OPTIONS. After POST /draw, the server blits into the landscape framebuf, then display_partial() or display_full(); it also forces a full refresh every 12 partial draws to limit ghosting. Wi-Fi uses a device-local wifi_secrets.py with SSID and PASSWORD (same pattern as the weather app—no credentials in the published file). run() shows a boot screen with the LAN IP; open http://<ip>/ in a browser on the same network.
Download: canvas_server.py
Full canvas_server.py source
"""
canvas_server.py -- All-in-one Pico W e-ink canvas server.
Files needed on the Pico:
epd2in13.py - your EPD driver
canvas_server.py - this file
Just run this file (Thonny green play, or `import canvas_server` in REPL).
The bottom of the file calls run() automatically.
"""
import gc
import socket
import time
import network
from epd2in13 import EPD, EPD_WIDTH, EPD_HEIGHT
try:
from wifi_secrets import SSID, PASSWORD
except ImportError:
SSID = ""
PASSWORD = ""
HTTP_PORT = 80
EXPECTED_BYTES = ((EPD_WIDTH + 7) // 8) * EPD_HEIGHT # 32 * 122 = 3904
# =============================================================================
# Embedded web app
# =============================================================================
HTML_PAGE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
<title>PICO E-INK CANVAS</title>
<style>
:root{--ink:#111;--paper:#f4f1ea;--rule:#111;--accent:#ff3b30;--mute:#7a7268;}
*{box-sizing:border-box}
html,body{margin:0;background:var(--paper);color:var(--ink);
font-family:"JetBrains Mono","IBM Plex Mono",ui-monospace,Menlo,monospace;}
body{min-height:100vh;padding:18px clamp(12px,3vw,32px);
background-image:
repeating-linear-gradient(0deg,rgba(0,0,0,.04) 0 1px,transparent 1px 24px),
repeating-linear-gradient(90deg,rgba(0,0,0,.04) 0 1px,transparent 1px 24px);}
header{display:flex;align-items:flex-end;justify-content:space-between;
border-bottom:2px solid var(--rule);padding-bottom:8px;margin-bottom:14px;flex-wrap:wrap;gap:8px;}
header h1{margin:0;font-size:clamp(20px,3.4vw,30px);letter-spacing:.04em;
text-transform:uppercase;font-weight:800;}
header h1 .dot{color:var(--accent)}
header .meta{font-size:12px;color:var(--mute);text-transform:uppercase;letter-spacing:.12em}
#status{font-weight:700;color:var(--ink)}
#status.bad{color:var(--accent)}
main{display:grid;grid-template-columns:minmax(0,1fr) 260px;gap:18px;align-items:start;}
@media (max-width:780px){main{grid-template-columns:1fr}}
.stage{background:#fff;border:2px solid var(--rule);box-shadow:6px 6px 0 var(--rule);
padding:14px;position:relative;overflow:hidden;}
.stage::before{content:"250 \\00D7 122 \\00B7 MONO";position:absolute;top:6px;right:10px;
font-size:10px;letter-spacing:.18em;color:var(--mute);}
.canvas-wrap{display:flex;justify-content:center;align-items:center;padding:8px 0 4px;}
#view{image-rendering:pixelated;image-rendering:crisp-edges;
width:min(100%, 750px);aspect-ratio:250 / 122;background:#fff;
border:1px solid var(--rule);cursor:crosshair;touch-action:none;display:block;}
.ruler{margin-top:8px;display:flex;justify-content:space-between;
font-size:10px;color:var(--mute);letter-spacing:.18em;}
aside{border:2px solid var(--rule);background:#fff;box-shadow:6px 6px 0 var(--rule);
padding:12px;display:flex;flex-direction:column;gap:14px;}
.group{display:flex;flex-direction:column;gap:6px}
.label{font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute);
border-bottom:1px dashed var(--rule);padding-bottom:3px;margin-bottom:2px;}
.row{display:flex;flex-wrap:wrap;gap:6px}
button{appearance:none;border:1.5px solid var(--rule);background:var(--paper);
color:var(--ink);font-family:inherit;font-size:12px;padding:7px 9px;cursor:pointer;
letter-spacing:.04em;text-transform:uppercase;font-weight:700;
transition:transform .04s ease, background .1s ease;}
button:hover{background:#fff}
button:active{transform:translate(1px,1px)}
button.on{background:var(--ink);color:var(--paper)}
button.send{background:var(--accent);color:#fff;border-color:var(--accent);
box-shadow:3px 3px 0 var(--rule);}
button.send:hover{background:#e62e23}
button.danger{border-color:var(--accent);color:var(--accent)}
input[type=range]{width:100%}
.size-readout{font-size:11px;color:var(--mute);text-align:right}
.swatches{display:flex;gap:6px}
.sw{width:32px;height:24px;border:1.5px solid var(--rule);cursor:pointer;
display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;}
.sw.black{background:#000;color:#fff}
.sw.white{background:#fff;color:#000}
.sw.on{outline:3px solid var(--accent);outline-offset:1px}
footer{margin-top:14px;border-top:2px solid var(--rule);padding-top:6px;
display:flex;justify-content:space-between;font-size:10px;color:var(--mute);
letter-spacing:.16em;text-transform:uppercase;flex-wrap:wrap;gap:6px;}
kbd{font-family:inherit;border:1px solid var(--rule);padding:1px 4px;
background:var(--paper);font-size:10px;}
</style>
</head>
<body>
<header>
<h1>PICO E-INK CANVAS<span class="dot">.</span></h1>
<div class="meta">STATUS: <span id="status">ready</span></div>
</header>
<main>
<section class="stage">
<div id="dbg" style="background:#fff7d6;border:1px solid #c8b663;padding:4px 8px;font-size:11px;margin-bottom:8px;font-family:ui-monospace,monospace;color:#000">init...</div>
<div class="canvas-wrap">
<canvas id="view" width="250" height="122"></canvas>
</div>
<div class="ruler"><span>0</span><span>125</span><span>250 PX</span></div>
</section>
<aside>
<div class="group">
<div class="label">Tool</div>
<div class="row" id="tools">
<button data-tool="brush" class="on">Brush</button>
<button data-tool="eraser">Eraser</button>
<button data-tool="line">Line</button>
<button data-tool="rect">Rect</button>
<button data-tool="circle">Circle</button>
<button data-tool="fill">Fill</button>
</div>
</div>
<div class="group">
<div class="label">Ink</div>
<div class="swatches" id="swatches">
<div class="sw black on" data-ink="0">B</div>
<div class="sw white" data-ink="1">W</div>
</div>
</div>
<div class="group">
<div class="label">Brush size</div>
<input id="size" type="range" min="1" max="16" value="2">
<div class="size-readout"><span id="sizeOut">2</span> px</div>
</div>
<div class="group">
<div class="label">Waves & flow</div>
<div class="row">
<button data-gen="scribble">Scribble</button>
<button data-gen="sinewave">Sine</button>
<button data-gen="interference">Ripples</button>
<button data-gen="rose">Rose</button>
<button data-gen="lissajous">Lissajous</button>
<button data-gen="flow">Flow field</button>
</div>
</div>
<div class="group">
<div class="label">Symmetry & tiling</div>
<div class="row">
<button data-gen="truchet">Truchet</button>
<button data-gen="kaleido">Kaleido</button>
<button data-gen="hex">Hex grid</button>
<button data-gen="herringbone">Herring</button>
<button data-gen="weave">Weave</button>
<button data-gen="moire">Moir\u00e9</button>
</div>
</div>
<div class="group">
<div class="label">Fractals</div>
<div class="row">
<button data-gen="sierpinski">Sierpinski</button>
<button data-gen="carpet">Carpet</button>
<button data-gen="dragon">Dragon</button>
<button data-gen="koch">Koch</button>
<button data-gen="hilbert">Hilbert</button>
<button data-gen="mandelbrot">Mandel</button>
</div>
</div>
<div class="group">
<div class="label">Geometric</div>
<div class="row">
<button data-gen="grad">Gradient</button>
<button data-gen="circles">Circles</button>
<button data-gen="sunburst">Sunburst</button>
<button data-gen="parabolic">Stitching</button>
<button data-gen="mystic">Mystic rose</button>
<button data-gen="spirograph">Spiro</button>
</div>
</div>
<div class="group">
<div class="label">Cellular & random</div>
<div class="row">
<button data-gen="stars">Stars</button>
<button data-gen="noise">Noise</button>
<button data-gen="maze">Maze</button>
<button data-gen="life">Life</button>
<button data-gen="voronoi">Voronoi</button>
<button data-gen="dla">DLA tree</button>
</div>
</div>
<div class="group">
<div class="label">Canvas</div>
<div class="row">
<button id="btn-undo">Undo</button>
<button id="btn-clear" class="danger">Clear</button>
<button id="btn-invert">Invert</button>
<button id="btn-png">Save PNG</button>
</div>
</div>
<div class="group">
<div class="label">Send to e-ink</div>
<div class="row">
<button id="btn-send" class="send">Send (partial)</button>
<button id="btn-sendfull">Send (full)</button>
</div>
<div class="row">
<button id="btn-wipe" class="danger">Blank screen</button>
</div>
</div>
</aside>
</main>
<footer>
<span>Pico W \\u00b7 250\\u00d7122 \\u00b7 1bpp</span>
<span><kbd>B</kbd> brush \\u00b7 <kbd>E</kbd> eraser \\u00b7 <kbd>X</kbd> swap \\u00b7 <kbd>Ctrl+Z</kbd> undo \\u00b7 <kbd>Enter</kbd> send</span>
</footer>
<script>
window.addEventListener('error', e => {
const d = document.getElementById('dbg');
if (d) d.textContent = 'JS ERROR: ' + e.message + ' @ ' + e.lineno;
});
(function(){
const W = 250, H = 122;
const view = document.getElementById('view');
const dbg = document.getElementById('dbg');
const setDbg = m => { if(dbg) dbg.textContent = m; };
if(!view){ setDbg('FATAL: canvas element missing'); return; }
const ctx = view.getContext('2d');
if(!ctx){ setDbg('FATAL: 2D context unavailable'); return; }
// pixel buffer: 1 byte per pixel, 0=black 1=white
let pixels = new Uint8Array(W*H);
pixels.fill(1);
// --- repaint via ImageData ---
const imgd = ctx.createImageData(W, H);
function repaint(){
const d = imgd.data;
for(let i=0;i<pixels.length;i++){
const v = pixels[i] ? 255 : 0;
const j = i*4;
d[j]=v; d[j+1]=v; d[j+2]=v; d[j+3]=255;
}
ctx.putImageData(imgd, 0, 0);
}
repaint();
setDbg('ready - tap or drag the canvas');
// --- drawing ops in pixel space ---
function setPx(x,y,ink){
if(x<0||y<0||x>=W||y>=H) return;
pixels[y*W + x] = ink;
}
function stamp(cx,cy,r,ink){
cx|=0; cy|=0;
if(r<=1){ setPx(cx,cy,ink); return; }
const h = Math.max(1, r/2)|0;
const r2 = h*h;
for(let y=Math.max(0,cy-h); y<=Math.min(H-1,cy+h); y++){
for(let x=Math.max(0,cx-h); x<=Math.min(W-1,cx+h); x++){
const dx=x-cx, dy=y-cy;
if(dx*dx+dy*dy <= r2) pixels[y*W+x]=ink;
}
}
}
function drawLine(x0,y0,x1,y1,r,ink){
x0|=0; y0|=0; x1|=0; y1|=0;
const dx=Math.abs(x1-x0), sx=x0<x1?1:-1;
const dy=-Math.abs(y1-y0), sy=y0<y1?1:-1;
let err=dx+dy, guard=0;
while(guard++<10000){
stamp(x0,y0,r,ink);
if(x0===x1 && y0===y1) break;
const e2=2*err;
if(e2>=dy){ err+=dy; x0+=sx; }
if(e2<=dx){ err+=dx; y0+=sy; }
}
}
function drawRect(x0,y0,x1,y1,r,ink){
drawLine(x0,y0,x1,y0,r,ink); drawLine(x1,y0,x1,y1,r,ink);
drawLine(x1,y1,x0,y1,r,ink); drawLine(x0,y1,x0,y0,r,ink);
}
function drawCircle(cx,cy,rad,r,ink){
if(rad<1){ stamp(cx,cy,r,ink); return; }
let x=rad,y=0,err=0,guard=0;
while(x>=y && guard++<5000){
stamp(cx+x,cy+y,r,ink); stamp(cx+y,cy+x,r,ink);
stamp(cx-y,cy+x,r,ink); stamp(cx-x,cy+y,r,ink);
stamp(cx-x,cy-y,r,ink); stamp(cx-y,cy-x,r,ink);
stamp(cx+y,cy-x,r,ink); stamp(cx+x,cy-y,r,ink);
y++; err+=1+2*y;
if(2*(err-x)+1>0){ x--; err+=1-2*x; }
}
}
function floodFill(sx,sy,ink){
const target=pixels[sy*W+sx];
if(target===undefined || target===ink) return;
const stack=[sx,sy];
while(stack.length){
const y=stack.pop(), x=stack.pop();
if(x<0||y<0||x>=W||y>=H) continue;
if(pixels[y*W+x]!==target) continue;
pixels[y*W+x]=ink;
stack.push(x+1,y, x-1,y, x,y+1, x,y-1);
}
}
// --- state ---
let tool='brush', ink=0, size=3;
let drawing=false, last=null, startPt=null, snap=null;
const undoStack=[];
function pushUndo(){
undoStack.push(new Uint8Array(pixels));
if(undoStack.length>20) undoStack.shift();
}
// --- pointer math: get pixel coords from any event ---
function getXY(clientX, clientY){
const r = view.getBoundingClientRect();
let x = ((clientX - r.left) / r.width) * W;
let y = ((clientY - r.top) / r.height) * H;
x = Math.max(0, Math.min(W-1, x|0));
y = Math.max(0, Math.min(H-1, y|0));
return [x,y];
}
function startStroke(clientX, clientY){
pushUndo();
drawing = true;
const [x,y] = getXY(clientX, clientY);
startPt = [x,y]; last = [x,y];
setDbg('down @ '+x+','+y+' tool='+tool);
if(tool==='brush' || tool==='eraser'){
stamp(x, y, size, tool==='eraser'?1:ink);
repaint();
} else if(tool==='fill'){
floodFill(x, y, ink);
repaint();
drawing = false;
} else {
snap = new Uint8Array(pixels);
}
}
function moveStroke(clientX, clientY){
if(!drawing) return;
const [x,y] = getXY(clientX, clientY);
if(tool==='brush' || tool==='eraser'){
drawLine(last[0], last[1], x, y, size, tool==='eraser'?1:ink);
last = [x,y];
repaint();
setDbg('draw @ '+x+','+y);
} else if(tool==='line' || tool==='rect' || tool==='circle'){
pixels.set(snap);
if(tool==='line') drawLine(startPt[0],startPt[1],x,y,size,ink);
else if(tool==='rect') drawRect(startPt[0],startPt[1],x,y,size,ink);
else {
const dx=x-startPt[0], dy=y-startPt[1];
drawCircle(startPt[0], startPt[1], Math.round(Math.sqrt(dx*dx+dy*dy)), size, ink);
}
repaint();
}
}
function endStroke(){
if(!drawing) return;
drawing=false; last=null; startPt=null; snap=null;
setDbg('stroke ended');
}
// --- attach pointer events with both pointer + mouse + touch fallbacks ---
if('PointerEvent' in window){
view.addEventListener('pointerdown', e => {
e.preventDefault();
try{ view.setPointerCapture(e.pointerId); }catch(_){}
startStroke(e.clientX, e.clientY);
});
view.addEventListener('pointermove', e => {
if(drawing){ e.preventDefault(); moveStroke(e.clientX, e.clientY); }
});
window.addEventListener('pointerup', endStroke);
window.addEventListener('pointercancel', endStroke);
setDbg('using PointerEvent');
} else {
view.addEventListener('mousedown', e => { e.preventDefault(); startStroke(e.clientX, e.clientY); });
window.addEventListener('mousemove', e => moveStroke(e.clientX, e.clientY));
window.addEventListener('mouseup', endStroke);
view.addEventListener('touchstart', e => {
if(!e.touches[0]) return;
e.preventDefault();
startStroke(e.touches[0].clientX, e.touches[0].clientY);
}, {passive:false});
view.addEventListener('touchmove', e => {
if(!e.touches[0]) return;
e.preventDefault();
moveStroke(e.touches[0].clientX, e.touches[0].clientY);
}, {passive:false});
view.addEventListener('touchend', endStroke);
view.addEventListener('touchcancel', endStroke);
setDbg('using mouse+touch fallback');
}
// --- toolbar ---
document.getElementById('tools').addEventListener('click', e => {
const b = e.target.closest('button'); if(!b) return;
tool = b.dataset.tool;
document.querySelectorAll('#tools button').forEach(x => x.classList.toggle('on', x===b));
setDbg('tool: '+tool);
});
document.getElementById('swatches').addEventListener('click', e => {
const s = e.target.closest('.sw'); if(!s) return;
ink = parseInt(s.dataset.ink, 10);
document.querySelectorAll('#swatches .sw').forEach(x => x.classList.toggle('on', x===s));
setDbg('ink: '+(ink?'white':'black'));
});
const sizeIn = document.getElementById('size');
const sizeOut = document.getElementById('sizeOut');
size = +sizeIn.value || 3;
sizeOut.textContent = size;
sizeIn.addEventListener('input', () => { size = +sizeIn.value; sizeOut.textContent = size; });
document.getElementById('btn-clear').addEventListener('click', () => { pushUndo(); pixels.fill(1); repaint(); });
document.getElementById('btn-invert').addEventListener('click', () => {
pushUndo(); for(let i=0;i<pixels.length;i++) pixels[i]^=1; repaint();
});
document.getElementById('btn-undo').addEventListener('click', () => {
if(!undoStack.length) return; pixels = undoStack.pop(); repaint();
});
document.getElementById('btn-png').addEventListener('click', () => {
const off = document.createElement('canvas'); off.width=W*4; off.height=H*4;
const oc = off.getContext('2d'); oc.imageSmoothingEnabled=false;
oc.drawImage(view, 0, 0, off.width, off.height);
off.toBlob(b => {
const a = document.createElement('a');
a.href = URL.createObjectURL(b);
a.download = 'pico-canvas.png'; a.click();
});
});
// --- generators ---
// --- generators (math / art patterns) ---
// Helpers used by several gens
function plotBresenham(x0,y0,x1,y1,ink){
drawLine(x0|0,y0|0,x1|0,y1|0,1,ink);
}
function fillRectPx(x0,y0,x1,y1,ink){
if(x0>x1){ const t=x0;x0=x1;x1=t; }
if(y0>y1){ const t=y0;y0=y1;y1=t; }
x0=Math.max(0,x0|0); y0=Math.max(0,y0|0);
x1=Math.min(W-1,x1|0); y1=Math.min(H-1,y1|0);
for(let y=y0;y<=y1;y++) for(let x=x0;x<=x1;x++) pixels[y*W+x]=ink;
}
const gens = {
scribble(){
pixels.fill(1); let x=W/2, y=H/2;
for(let i=0;i<600;i++){
const nx=x+(Math.random()-.5)*14, ny=y+(Math.random()-.5)*14;
drawLine(x|0, y|0, nx|0, ny|0, 1, 0);
x=Math.max(2,Math.min(W-3,nx)); y=Math.max(2,Math.min(H-3,ny));
}
},
sinewave(){
pixels.fill(1);
const layers = 5;
for(let L=0; L<layers; L++){
const amp = 8 + L*4;
const freq = 0.05 + L*0.015;
const phase = L*0.7;
const yc = H/2 + (L-layers/2)*4;
let py = (yc + Math.sin(phase)*amp)|0;
for(let x=0; x<W; x++){
const y = (yc + Math.sin(x*freq + phase)*amp)|0;
drawLine(x-1, py, x, y, 1, 0);
py = y;
}
}
},
interference(){
// Two wave sources; threshold the sum to make moire/ripple bands
pixels.fill(1);
const s1x=W*0.30, s1y=H*0.5;
const s2x=W*0.70, s2y=H*0.5;
const k = 0.55; // wave number
for(let y=0;y<H;y++){
for(let x=0;x<W;x++){
const d1 = Math.sqrt((x-s1x)**2+(y-s1y)**2);
const d2 = Math.sqrt((x-s2x)**2+(y-s2y)**2);
const v = Math.cos(d1*k) + Math.cos(d2*k);
pixels[y*W+x] = v>0 ? 0 : 1;
}
}
},
rose(){
// Rose curve r = a*cos(k*theta), k = n/d
pixels.fill(1);
const cx=W/2, cy=H/2;
const a = Math.min(W,H)*0.45;
const n = 5, d = 1; // 5-petal rose; tweak for variety
const steps = 4000;
let px=null, py=null;
for(let i=0; i<=steps; i++){
const th = (i/steps) * Math.PI * 2 * d;
const r = a * Math.cos(n/d * th);
const x = (cx + r*Math.cos(th))|0;
const y = (cy + r*Math.sin(th))|0;
if(px!==null) drawLine(px,py,x,y,1,0);
px=x; py=y;
}
},
lissajous(){
pixels.fill(1);
const cx=W/2, cy=H/2;
const A=W*0.46, B=H*0.46;
const a=3, b=2, delta=Math.PI/2;
const steps=3000;
let px=null, py=null;
for(let i=0;i<=steps;i++){
const t = (i/steps)*Math.PI*2;
const x = (cx + A*Math.sin(a*t + delta))|0;
const y = (cy + B*Math.sin(b*t))|0;
if(px!==null) drawLine(px,py,x,y,1,0);
px=x; py=y;
}
},
flow(){
// Pseudo-Perlin flow field via cheap value-noise hash
pixels.fill(1);
function hash(x,y){
let h = (x*374761393 + y*668265263) | 0;
h = (h ^ (h>>>13)) * 1274126177 | 0;
return ((h ^ (h>>>16)) >>> 0) / 4294967295;
}
function noise(x,y){
const xi=Math.floor(x), yi=Math.floor(y);
const xf=x-xi, yf=y-yi;
const u=xf*xf*(3-2*xf), v=yf*yf*(3-2*yf);
const n00=hash(xi,yi), n10=hash(xi+1,yi);
const n01=hash(xi,yi+1), n11=hash(xi+1,yi+1);
return (n00*(1-u)+n10*u)*(1-v) + (n01*(1-u)+n11*u)*v;
}
const seeds = 80, steps = 60;
for(let s=0; s<seeds; s++){
let x = Math.random()*W, y = Math.random()*H;
for(let i=0;i<steps;i++){
const ang = noise(x*0.04, y*0.04) * Math.PI * 4;
const nx = x + Math.cos(ang)*1.4;
const ny = y + Math.sin(ang)*1.4;
drawLine(x|0,y|0,nx|0,ny|0,1,0);
x=nx; y=ny;
if(x<0||x>=W||y<0||y>=H) break;
}
}
},
truchet(){
pixels.fill(1); const t=12;
for(let y=0;y<H;y+=t) for(let x=0;x<W;x+=t){
if(Math.random()<.5) drawLine(x,y,x+t,y+t,1,0);
else drawLine(x+t,y,x,y+t,1,0);
}
},
kaleido(){
// Draw random strokes in one wedge, then mirror across N-fold symmetry
pixels.fill(1);
const cx=W/2, cy=H/2;
const N = 6;
const segs = 30;
const pts = [];
for(let i=0;i<segs;i++){
const r1 = Math.random()*Math.min(W,H)*0.45;
const r2 = Math.random()*Math.min(W,H)*0.45;
const a1 = Math.random()*Math.PI*2/N;
const a2 = a1 + (Math.random()-.5)*0.6;
pts.push([r1,a1,r2,a2]);
}
for(let k=0;k<N;k++){
const base = k*Math.PI*2/N;
for(const [r1,a1,r2,a2] of pts){
const x1=cx+Math.cos(base+a1)*r1, y1=cy+Math.sin(base+a1)*r1;
const x2=cx+Math.cos(base+a2)*r2, y2=cy+Math.sin(base+a2)*r2;
drawLine(x1|0,y1|0,x2|0,y2|0,1,0);
// mirror within wedge
const x1m=cx+Math.cos(base-a1)*r1, y1m=cy+Math.sin(base-a1)*r1;
const x2m=cx+Math.cos(base-a2)*r2, y2m=cy+Math.sin(base-a2)*r2;
drawLine(x1m|0,y1m|0,x2m|0,y2m|0,1,0);
}
}
},
hex(){
pixels.fill(1);
const r = 8; // hex circumradius
const dx = r*Math.sqrt(3);
const dy = r*1.5;
for(let row=-1; row*dy<H+r; row++){
for(let col=-1; col*dx<W+r; col++){
const cx = col*dx + (row&1?dx/2:0);
const cy = row*dy;
// hex outline
let px=null, py=null;
for(let k=0;k<=6;k++){
const a = Math.PI/3*k - Math.PI/2;
const x = (cx+Math.cos(a)*r)|0;
const y = (cy+Math.sin(a)*r)|0;
if(px!==null) drawLine(px,py,x,y,1,0);
px=x; py=y;
}
}
}
},
herringbone(){
pixels.fill(1);
const bw=18, bh=6;
for(let y=0;y<H+bh;y+=bh){
for(let x=-bw;x<W;x+=bw){
const ox = ((y/bh)|0) % 2 === 0 ? 0 : bw/2;
// diagonal brick
drawLine(x+ox, y, x+ox+bw, y+bh, 1, 0);
}
}
},
weave(){
pixels.fill(1);
const t=6;
// horizontal stripes
for(let y=0;y<H;y+=t*2){
for(let x=0;x<W;x+=t*2){
fillRectPx(x, y, x+t-1, y+t-1, 0);
fillRectPx(x+t, y+t, x+2*t-1, y+2*t-1, 0);
}
}
},
moire(){
// Two rotated line gratings
pixels.fill(1);
const a1 = 0.1, a2 = -0.13;
const sp = 4;
for(let y=0;y<H;y++){
for(let x=0;x<W;x++){
const u = x*Math.cos(a1)+y*Math.sin(a1);
const v = x*Math.cos(a2)+y*Math.sin(a2);
const on = (Math.floor(u/sp)&1) ^ (Math.floor(v/sp)&1);
if(on) pixels[y*W+x]=0;
}
}
},
sierpinski(){
pixels.fill(1);
const ax=W/2, ay=4;
const bx=4, by=H-4;
const cx=W-4, cy=H-4;
let px=W/2, py=H/2;
const verts=[[ax,ay],[bx,by],[cx,cy]];
// Chaos game
for(let i=0;i<8000;i++){
const v = verts[(Math.random()*3)|0];
px = (px+v[0])/2;
py = (py+v[1])/2;
if(i>20) setPx(px|0, py|0, 0);
}
},
carpet(){
// Sierpinski carpet by recursive subdivision
pixels.fill(0); // start black, carve white holes
function carve(x,y,w,h,depth){
if(depth===0 || w<3 || h<3) return;
const w3=w/3, h3=h/3;
fillRectPx(x+w3, y+h3, x+2*w3-1, y+2*h3-1, 1);
for(let iy=0;iy<3;iy++) for(let ix=0;ix<3;ix++){
if(ix===1 && iy===1) continue;
carve(x+ix*w3, y+iy*h3, w3, h3, depth-1);
}
}
carve(0,0,W,H,4);
},
dragon(){
pixels.fill(1);
let path = [1];
for(let i=0;i<11;i++){
const rev = [];
for(let j=path.length-1;j>=0;j--) rev.push(path[j]^1);
path = path.concat([1]).concat(rev);
}
// walk
let x=W*0.35, y=H*0.55, dir=0;
const step=2;
for(const turn of path){
const nx = x + Math.cos(dir)*step;
const ny = y + Math.sin(dir)*step;
drawLine(x|0,y|0,nx|0,ny|0,1,0);
x=nx; y=ny;
dir += turn ? Math.PI/2 : -Math.PI/2;
}
},
koch(){
pixels.fill(1);
// Koch snowflake from equilateral triangle, 4 iterations
function koch(p1,p2,depth){
if(depth===0){
drawLine(p1[0]|0,p1[1]|0,p2[0]|0,p2[1]|0,1,0);
return;
}
const dx=(p2[0]-p1[0])/3, dy=(p2[1]-p1[1])/3;
const a=[p1[0]+dx, p1[1]+dy];
const b=[p1[0]+2*dx, p1[1]+2*dy];
const ang = Math.atan2(dy,dx) - Math.PI/3;
const len = Math.sqrt(dx*dx+dy*dy);
const peak=[a[0]+Math.cos(ang)*len, a[1]+Math.sin(ang)*len];
koch(p1,a,depth-1); koch(a,peak,depth-1);
koch(peak,b,depth-1); koch(b,p2,depth-1);
}
const cx=W/2, cy=H/2;
const r=Math.min(W,H)*0.42;
const v=[];
for(let i=0;i<3;i++){
const a = -Math.PI/2 + i*Math.PI*2/3;
v.push([cx+Math.cos(a)*r, cy+Math.sin(a)*r]);
}
koch(v[0],v[1],4); koch(v[1],v[2],4); koch(v[2],v[0],4);
},
hilbert(){
pixels.fill(1);
// Iterative Hilbert curve fitting in min(W,H) square
const order = 5; // 2^5 = 32 cells per side
const N = 1<<order;
const cell = Math.min(W,H)/N;
const ox = (W-N*cell)/2, oy = (H-N*cell)/2;
function d2xy(d){
let x=0,y=0,t=d;
for(let s=1;s<N;s<<=1){
const rx = 1 & (t>>1);
const ry = 1 & (t ^ rx);
if(ry===0){
if(rx===1){ x=s-1-x; y=s-1-y; }
const tmp=x; x=y; y=tmp;
}
x += s*rx; y += s*ry;
t >>= 2;
}
return [x,y];
}
let prev=null;
for(let i=0;i<N*N;i++){
const [gx,gy]=d2xy(i);
const x = (ox + gx*cell + cell/2)|0;
const y = (oy + gy*cell + cell/2)|0;
if(prev) drawLine(prev[0],prev[1],x,y,1,0);
prev=[x,y];
}
},
mandelbrot(){
pixels.fill(1);
const xmin=-2.1, xmax=0.7, ymin=-1.0, ymax=1.0;
const maxIter=24;
for(let py=0;py<H;py++){
const cy = ymin + (ymax-ymin)*py/H;
for(let px=0;px<W;px++){
const cx = xmin + (xmax-xmin)*px/W;
let zx=0, zy=0, i=0;
while(i<maxIter && zx*zx+zy*zy<4){
const t = zx*zx-zy*zy+cx;
zy = 2*zx*zy+cy;
zx = t;
i++;
}
// Threshold dither: high-iter -> dark, low-iter -> light
const v = i/maxIter;
const bayer = ((px+py*7)*0.137)%1;
pixels[py*W+px] = v < 0.95 && v*1.1 > bayer ? 0 : 1;
if(i===maxIter) pixels[py*W+px]=0;
}
}
},
grad(){
const bayer=[
[0,32,8,40,2,34,10,42],[48,16,56,24,50,18,58,26],
[12,44,4,36,14,46,6,38],[60,28,52,20,62,30,54,22],
[3,35,11,43,1,33,9,41],[51,19,59,27,49,17,57,25],
[15,47,7,39,13,45,5,37],[63,31,55,23,61,29,53,21]];
for(let y=0;y<H;y++) for(let x=0;x<W;x++){
pixels[y*W+x] = ((x/(W-1))*64 > bayer[y&7][x&7]) ? 1 : 0;
}
},
circles(){
pixels.fill(1);
const cx=W/2, cy=H/2;
for(let r=4; r<Math.max(W,H); r+=6){
drawCircle(cx,cy,r,1,0);
}
},
sunburst(){
pixels.fill(1);
const cx=W/2, cy=H/2;
const rays = 64;
const R = Math.max(W,H);
for(let i=0;i<rays;i++){
const a = i*Math.PI*2/rays;
const x = cx+Math.cos(a)*R;
const y = cy+Math.sin(a)*R;
drawLine(cx|0,cy|0,x|0,y|0,1,0);
}
},
parabolic(){
// Curve stitching: connect points across two perpendicular axes
pixels.fill(1);
const N = 24;
const x0=4, y0=4, x1=W-4, y1=H-4;
// top-left corner
for(let i=0;i<=N;i++){
const t = i/N;
drawLine(x0+(x1-x0)*t|0, y0|0, x0|0, y0+(y1-y0)*t|0, 1, 0);
}
// bottom-right corner
for(let i=0;i<=N;i++){
const t = i/N;
drawLine(x1-(x1-x0)*t|0, y1|0, x1|0, y1-(y1-y0)*t|0, 1, 0);
}
},
mystic(){
// Mystic rose: N points on a circle, every chord drawn
pixels.fill(1);
const cx=W/2, cy=H/2;
const r = Math.min(W,H)*0.45;
const N = 18;
const pts = [];
for(let i=0;i<N;i++){
const a = i*Math.PI*2/N - Math.PI/2;
pts.push([cx+Math.cos(a)*r, cy+Math.sin(a)*r]);
}
for(let i=0;i<N;i++) for(let j=i+1;j<N;j++){
drawLine(pts[i][0]|0, pts[i][1]|0, pts[j][0]|0, pts[j][1]|0, 1, 0);
}
},
spirograph(){
// Hypotrochoid: outer R, inner r, pen offset d
pixels.fill(1);
const cx=W/2, cy=H/2;
const R = Math.min(W,H)*0.42;
const r = R*0.31;
const d = R*0.55;
const steps = 4000;
let px=null, py=null;
for(let i=0;i<=steps;i++){
const t = (i/steps)*Math.PI*2*7;
const x = ((R-r)*Math.cos(t) + d*Math.cos((R-r)/r*t)) + cx;
const y = ((R-r)*Math.sin(t) - d*Math.sin((R-r)/r*t)) + cy;
if(px!==null) drawLine(px|0,py|0,x|0,y|0,1,0);
px=x; py=y;
}
},
stars(){
pixels.fill(0);
for(let i=0;i<180;i++){
stamp((Math.random()*W)|0, (Math.random()*H)|0,
Math.random()<.15?2:1, 1);
}
},
noise(){
for(let i=0;i<pixels.length;i++) pixels[i] = Math.random()<.5 ? 0 : 1;
},
maze(){
pixels.fill(1); const t=8;
for(let y=0;y<H;y+=t) for(let x=0;x<W;x+=t){
if(Math.random()<.5) drawLine(x,y,x+t,y,1,0);
else drawLine(x,y,x,y+t,1,0);
}
drawRect(0,0,W-1,H-1,1,0);
},
life(){
// Run Conway's Game of Life from random soup, snapshot at step N
let cur = new Uint8Array(W*H);
for(let i=0;i<cur.length;i++) cur[i] = Math.random()<.35 ? 1 : 0;
let nxt = new Uint8Array(W*H);
for(let gen=0; gen<30; gen++){
for(let y=0;y<H;y++){
for(let x=0;x<W;x++){
let n=0;
for(let dy=-1;dy<=1;dy++) for(let dx=-1;dx<=1;dx++){
if(dx===0&&dy===0) continue;
const xx=(x+dx+W)%W, yy=(y+dy+H)%H;
n += cur[yy*W+xx];
}
const c = cur[y*W+x];
nxt[y*W+x] = (c && (n===2||n===3)) || (!c && n===3) ? 1 : 0;
}
}
const t=cur; cur=nxt; nxt=t;
}
// alive=black on white paper
for(let i=0;i<pixels.length;i++) pixels[i] = cur[i] ? 0 : 1;
},
voronoi(){
// Voronoi cell edges: pixel is "edge" if its nearest seed differs from a neighbor's
const N = 22;
const sx=[], sy=[];
for(let i=0;i<N;i++){ sx.push(Math.random()*W); sy.push(Math.random()*H); }
const owner = new Int16Array(W*H);
for(let y=0;y<H;y++){
for(let x=0;x<W;x++){
let best=0, bd=1e9;
for(let i=0;i<N;i++){
const dx=x-sx[i], dy=y-sy[i];
const d=dx*dx+dy*dy;
if(d<bd){ bd=d; best=i; }
}
owner[y*W+x] = best;
}
}
pixels.fill(1);
for(let y=0;y<H;y++){
for(let x=0;x<W;x++){
const o = owner[y*W+x];
if(x+1<W && owner[y*W+x+1]!==o){ pixels[y*W+x]=0; }
else if(y+1<H && owner[(y+1)*W+x]!==o){ pixels[y*W+x]=0; }
}
}
},
dla(){
// Diffusion-limited aggregation: random walkers stick to growing tree
pixels.fill(1);
const grid = new Uint8Array(W*H);
const cx=W/2|0, cy=H/2|0;
grid[cy*W+cx]=1; pixels[cy*W+cx]=0;
const maxParticles = 800;
for(let p=0;p<maxParticles;p++){
// spawn on a circle of radius rmax
let ang = Math.random()*Math.PI*2;
let rad = Math.min(W,H)*0.45;
let x = cx + (Math.cos(ang)*rad)|0;
let y = cy + (Math.sin(ang)*rad)|0;
for(let step=0; step<2000; step++){
x += (Math.random()<.5?-1:1);
y += (Math.random()<.5?-1:1);
if(x<1||x>=W-1||y<1||y>=H-1){ break; }
// touch?
if(grid[(y-1)*W+x]||grid[(y+1)*W+x]||
grid[y*W+x-1]||grid[y*W+x+1]){
grid[y*W+x]=1; pixels[y*W+x]=0; break;
}
}
}
},
};
document.querySelectorAll('[data-gen]').forEach(btn => {
btn.addEventListener('click', () => {
const k = btn.dataset.gen;
const fn = gens[k];
if(!fn){ setDbg('unknown gen: '+k); return; }
pushUndo();
try { fn(); } catch(e){ setDbg('gen '+k+' err: '+e.message); }
repaint();
setDbg('generated: '+k);
});
});
// --- network ---
const statusEl = document.getElementById('status');
function setStatus(msg, bad){ statusEl.textContent = msg; statusEl.classList.toggle('bad', !!bad); }
function packBitmap(){
const stride = (W+7)>>3;
const out = new Uint8Array(stride * H);
for(let y=0;y<H;y++){
for(let xb=0;xb<stride;xb++){
let b = 0;
for(let bit=0;bit<8;bit++){
const x = xb*8 + bit;
const v = (x<W) ? pixels[y*W+x] : 1;
if(v) b |= (1 << (7-bit));
}
out[y*stride + xb] = b;
}
}
return out;
}
async function sendDraw(mode){
setStatus('sending...');
try{
const r = await fetch('/draw?mode='+mode, {
method: 'POST',
headers: {'Content-Type':'application/octet-stream'},
body: packBitmap()
});
if(!r.ok) throw new Error('HTTP '+r.status);
const j = await r.json();
setStatus('sent - '+j.mode+' - '+j.ms+'ms');
} catch(err){ setStatus('send failed: '+err.message, true); }
}
async function sendClear(){
setStatus('clearing...');
try{
const r = await fetch('/clear', {method:'POST'});
if(!r.ok) throw new Error('HTTP '+r.status);
setStatus('blanked');
} catch(err){ setStatus('clear failed: '+err.message, true); }
}
document.getElementById('btn-send').addEventListener('click', () => sendDraw('partial'));
document.getElementById('btn-sendfull').addEventListener('click', () => sendDraw('full'));
document.getElementById('btn-wipe').addEventListener('click', sendClear);
fetch('/status').then(r => r.json()).then(j => {
setStatus('connected '+j.w+'x'+j.h);
}).catch(() => setStatus('offline', true));
})();
</script>
</body>
</html>
"""
# =============================================================================
# WiFi
# =============================================================================
def _wifi_connect(ssid, password, timeout=60):
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
try:
wlan.config(pm=0xa11140) # disable power save BEFORE connecting
except Exception:
pass
if not wlan.isconnected():
print("Connecting to", repr(ssid))
if password:
wlan.connect(ssid, password)
else:
wlan.connect(ssid)
t0 = time.ticks_ms()
last_status = None
while not wlan.isconnected():
try:
st = wlan.status()
except Exception:
st = None
if st != last_status:
print(" wlan status:", st)
last_status = st
if time.ticks_diff(time.ticks_ms(), t0) > timeout * 1000:
raise RuntimeError(
"WiFi connect timeout (last status=%s). "
"Check: hotspot ON, 2.4 GHz / Maximize Compatibility ON, "
"WPA2 (not WPA3), correct password." % st
)
time.sleep(0.5)
ip = wlan.ifconfig()[0]
print("WiFi OK, IP =", ip)
return ip
# =============================================================================
# Bitmap unpack
# =============================================================================
def _blit_bitmap_to_fb(epd, payload):
src_stride = (EPD_WIDTH + 7) // 8
dst_stride = epd._fb_stride
h = EPD_HEIGHT
fb = epd._fb_buf
for i in range(len(fb)):
fb[i] = 0xFF
last_real_bits = EPD_WIDTH & 7
if last_real_bits:
keep = (0xFF << (8 - last_real_bits)) & 0xFF
pad = 0xFF ^ keep
else:
keep, pad = 0xFF, 0x00
n = len(payload)
for y in range(h):
so = y * src_stride
do = y * dst_stride
if so + src_stride > n:
break
for x in range(src_stride - 1):
fb[do + x] = payload[so + x]
last = payload[so + src_stride - 1]
if last_real_bits:
last = (last & keep) | pad
fb[do + src_stride - 1] = last
# =============================================================================
# HTTP helpers
# =============================================================================
def _sendall(conn, data):
"""Robust send -- MicroPython's socket.send() may not send everything,
and sendall() can fail on large buffers; chunk it explicitly."""
if isinstance(data, str):
data = data.encode("utf-8")
mv = memoryview(data)
total = len(mv)
sent = 0
chunk = 1024 # safe size for Pico W lwIP buffers
while sent < total:
end = sent + chunk
if end > total:
end = total
# Try send(); may return short
try:
n = conn.send(mv[sent:end])
except OSError as e:
# EAGAIN -> wait a bit and retry; otherwise give up
err = e.args[0] if e.args else 0
if err in (11, 35): # EAGAIN / EWOULDBLOCK
time.sleep_ms(5)
continue
raise
if n is None:
n = end - sent # some MP builds return None on success
if n <= 0:
time.sleep_ms(5)
continue
sent += n
def _send(conn, status, body, content_type="text/plain", extra=""):
if isinstance(body, str):
body = body.encode("utf-8")
head = (
"HTTP/1.1 {s}\r\n"
"Content-Type: {ct}\r\n"
"Content-Length: {n}\r\n"
"Access-Control-Allow-Origin: *\r\n"
"Connection: close\r\n"
"{x}"
"\r\n"
).format(s=status, ct=content_type, n=len(body), x=extra)
_sendall(conn, head)
_sendall(conn, body)
def _read_request(conn):
conn.settimeout(5.0)
buf = b""
while b"\r\n\r\n" not in buf:
chunk = conn.recv(1024)
if not chunk:
break
buf += chunk
if len(buf) > 8192:
break
head, _, rest = buf.partition(b"\r\n\r\n")
lines = head.split(b"\r\n")
if not lines:
return None, None, {}, b""
try:
method, path, _ = lines[0].decode("utf-8").split(" ", 2)
except ValueError:
return None, None, {}, b""
headers = {}
for line in lines[1:]:
if b":" in line:
k, _, v = line.partition(b":")
headers[k.decode("utf-8").strip().lower()] = v.decode("utf-8").strip()
return method, path, headers, rest
def _read_exact(conn, n, already=b""):
out = bytearray(already)
while len(out) < n:
chunk = conn.recv(min(1024, n - len(out)))
if not chunk:
break
out += chunk
return bytes(out)
def _show_boot_screen(epd, ip):
fb = epd.fb
fb.fill(0xFF)
fb.rect(0, 0, EPD_WIDTH, EPD_HEIGHT, 0x00)
fb.text("PICO E-INK CANVAS", 8, 10, 0x00)
fb.hline(8, 24, EPD_WIDTH - 16, 0x00)
fb.text("WiFi: connected", 8, 38, 0x00)
fb.text("IP:", 8, 56, 0x00)
fb.text(ip, 40, 56, 0x00)
fb.text("Open in browser:", 8, 80, 0x00)
fb.text("http://" + ip + "/", 8, 96, 0x00)
epd.display_full()
# =============================================================================
# Main entry
# =============================================================================
def run(ssid=None, password=None, port=HTTP_PORT):
if ssid is None:
ssid = SSID
if password is None:
password = PASSWORD
if not ssid:
print("ERROR: create wifi_secrets.py with SSID and PASSWORD (copy from weather app pattern)")
raise SystemExit
epd = EPD()
epd.clear()
ip = _wifi_connect(ssid, password)
_show_boot_screen(epd, ip)
addr = socket.getaddrinfo("0.0.0.0", port)[0][-1]
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(addr)
s.listen(2)
print("HTTP server listening on", addr)
print("Open http://%s/ in your browser." % ip)
full_refresh_counter = 0
try:
while True:
try:
conn, client = s.accept()
except OSError:
continue
try:
method, path, headers, leftover = _read_request(conn)
if method is None:
_send(conn, "400 Bad Request", "bad request")
continue
qpos = path.find("?")
query = ""
if qpos >= 0:
query = path[qpos + 1:]
path = path[:qpos]
print(method, path, "from", client)
if method == "GET" and path in ("/", "/index.html"):
_send(conn, "200 OK", HTML_PAGE,
content_type="text/html; charset=utf-8")
elif method == "GET" and path == "/status":
_send(conn, "200 OK",
'{"ok":true,"w":%d,"h":%d,"bytes":%d}' % (
EPD_WIDTH, EPD_HEIGHT, EXPECTED_BYTES),
content_type="application/json")
elif method == "OPTIONS":
_send(conn, "204 No Content", "",
extra=("Access-Control-Allow-Methods: GET,POST,OPTIONS\r\n"
"Access-Control-Allow-Headers: Content-Type\r\n"))
elif method == "POST" and path == "/clear":
epd.clear()
full_refresh_counter = 0
_send(conn, "200 OK", '{"ok":true}', content_type="application/json")
elif method == "POST" and path == "/draw":
try:
n = int(headers.get("content-length", "0"))
except ValueError:
n = 0
if n <= 0 or n > 16384:
_send(conn, "400 Bad Request", "bad length")
continue
body = _read_exact(conn, n, leftover)
if len(body) < n:
_send(conn, "400 Bad Request", "short body")
continue
_blit_bitmap_to_fb(epd, body)
mode = "partial"
if "mode=full" in query:
mode = "full"
full_refresh_counter += 1
if full_refresh_counter >= 12:
mode = "full"
full_refresh_counter = 0
t0 = time.ticks_ms()
if mode == "full":
epd.display_full()
else:
epd.display_partial()
dt = time.ticks_diff(time.ticks_ms(), t0)
print(" drew %d bytes, mode=%s, %dms" % (len(body), mode, dt))
_send(conn, "200 OK",
'{"ok":true,"mode":"%s","ms":%d}' % (mode, dt),
content_type="application/json")
else:
_send(conn, "404 Not Found", "no")
except Exception as e:
print("req error:", e)
try:
_send(conn, "500 Internal Server Error", str(e))
except Exception:
pass
finally:
try:
conn.close()
except Exception:
pass
gc.collect()
except KeyboardInterrupt:
print("stopped")
finally:
try:
s.close()
except Exception:
pass
if __name__ == '__main__':
run()
else:
run()Application and Interface
For Fab Academy Week 14 I built the browser drawing app → Pico W → e-ink path that Pockety relies on: a web canvas (250×122 monochrome), packed bitmap bytes over HTTP, and MicroPython endpoints (/status, /draw, /clear) with partial and full refresh on the module. That stack is the same interface direction as the final device—iterate sleep screens and bitmaps without a full phone OS.
Full write-up, code chunks, and testing notes: Week 14: Application and Interface Programming.
The browser UI (HTML, CSS, and client JS—including canvas drawing, generators, and packBitmap / fetch) is the HTML_PAGE string inside canvas_server.py; the same document is saved separately for easy reading and download.
Download markup only: canvas_app_interface.html
Full browser app interface source (canvas_app_interface.html — collapse with ▾)
<!-- Snapshot of HTML_PAGE from canvas_server.py — regenerate if the embedded UI changes. -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
<title>PICO E-INK CANVAS</title>
<style>
:root{--ink:#111;--paper:#f4f1ea;--rule:#111;--accent:#ff3b30;--mute:#7a7268;}
*{box-sizing:border-box}
html,body{margin:0;background:var(--paper);color:var(--ink);
font-family:"JetBrains Mono","IBM Plex Mono",ui-monospace,Menlo,monospace;}
body{min-height:100vh;padding:18px clamp(12px,3vw,32px);
background-image:
repeating-linear-gradient(0deg,rgba(0,0,0,.04) 0 1px,transparent 1px 24px),
repeating-linear-gradient(90deg,rgba(0,0,0,.04) 0 1px,transparent 1px 24px);}
header{display:flex;align-items:flex-end;justify-content:space-between;
border-bottom:2px solid var(--rule);padding-bottom:8px;margin-bottom:14px;flex-wrap:wrap;gap:8px;}
header h1{margin:0;font-size:clamp(20px,3.4vw,30px);letter-spacing:.04em;
text-transform:uppercase;font-weight:800;}
header h1 .dot{color:var(--accent)}
header .meta{font-size:12px;color:var(--mute);text-transform:uppercase;letter-spacing:.12em}
#status{font-weight:700;color:var(--ink)}
#status.bad{color:var(--accent)}
main{display:grid;grid-template-columns:minmax(0,1fr) 260px;gap:18px;align-items:start;}
@media (max-width:780px){main{grid-template-columns:1fr}}
.stage{background:#fff;border:2px solid var(--rule);box-shadow:6px 6px 0 var(--rule);
padding:14px;position:relative;overflow:hidden;}
.stage::before{content:"250 \\00D7 122 \\00B7 MONO";position:absolute;top:6px;right:10px;
font-size:10px;letter-spacing:.18em;color:var(--mute);}
.canvas-wrap{display:flex;justify-content:center;align-items:center;padding:8px 0 4px;}
#view{image-rendering:pixelated;image-rendering:crisp-edges;
width:min(100%, 750px);aspect-ratio:250 / 122;background:#fff;
border:1px solid var(--rule);cursor:crosshair;touch-action:none;display:block;}
.ruler{margin-top:8px;display:flex;justify-content:space-between;
font-size:10px;color:var(--mute);letter-spacing:.18em;}
aside{border:2px solid var(--rule);background:#fff;box-shadow:6px 6px 0 var(--rule);
padding:12px;display:flex;flex-direction:column;gap:14px;}
.group{display:flex;flex-direction:column;gap:6px}
.label{font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute);
border-bottom:1px dashed var(--rule);padding-bottom:3px;margin-bottom:2px;}
.row{display:flex;flex-wrap:wrap;gap:6px}
button{appearance:none;border:1.5px solid var(--rule);background:var(--paper);
color:var(--ink);font-family:inherit;font-size:12px;padding:7px 9px;cursor:pointer;
letter-spacing:.04em;text-transform:uppercase;font-weight:700;
transition:transform .04s ease, background .1s ease;}
button:hover{background:#fff}
button:active{transform:translate(1px,1px)}
button.on{background:var(--ink);color:var(--paper)}
button.send{background:var(--accent);color:#fff;border-color:var(--accent);
box-shadow:3px 3px 0 var(--rule);}
button.send:hover{background:#e62e23}
button.danger{border-color:var(--accent);color:var(--accent)}
input[type=range]{width:100%}
.size-readout{font-size:11px;color:var(--mute);text-align:right}
.swatches{display:flex;gap:6px}
.sw{width:32px;height:24px;border:1.5px solid var(--rule);cursor:pointer;
display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;}
.sw.black{background:#000;color:#fff}
.sw.white{background:#fff;color:#000}
.sw.on{outline:3px solid var(--accent);outline-offset:1px}
footer{margin-top:14px;border-top:2px solid var(--rule);padding-top:6px;
display:flex;justify-content:space-between;font-size:10px;color:var(--mute);
letter-spacing:.16em;text-transform:uppercase;flex-wrap:wrap;gap:6px;}
kbd{font-family:inherit;border:1px solid var(--rule);padding:1px 4px;
background:var(--paper);font-size:10px;}
</style>
</head>
<body>
<header>
<h1>PICO E-INK CANVAS<span class="dot">.</span></h1>
<div class="meta">STATUS: <span id="status">ready</span></div>
</header>
<main>
<section class="stage">
<div id="dbg" style="background:#fff7d6;border:1px solid #c8b663;padding:4px 8px;font-size:11px;margin-bottom:8px;font-family:ui-monospace,monospace;color:#000">init...</div>
<div class="canvas-wrap">
<canvas id="view" width="250" height="122"></canvas>
</div>
<div class="ruler"><span>0</span><span>125</span><span>250 PX</span></div>
</section>
<aside>
<div class="group">
<div class="label">Tool</div>
<div class="row" id="tools">
<button data-tool="brush" class="on">Brush</button>
<button data-tool="eraser">Eraser</button>
<button data-tool="line">Line</button>
<button data-tool="rect">Rect</button>
<button data-tool="circle">Circle</button>
<button data-tool="fill">Fill</button>
</div>
</div>
<div class="group">
<div class="label">Ink</div>
<div class="swatches" id="swatches">
<div class="sw black on" data-ink="0">B</div>
<div class="sw white" data-ink="1">W</div>
</div>
</div>
<div class="group">
<div class="label">Brush size</div>
<input id="size" type="range" min="1" max="16" value="2">
<div class="size-readout"><span id="sizeOut">2</span> px</div>
</div>
<div class="group">
<div class="label">Waves & flow</div>
<div class="row">
<button data-gen="scribble">Scribble</button>
<button data-gen="sinewave">Sine</button>
<button data-gen="interference">Ripples</button>
<button data-gen="rose">Rose</button>
<button data-gen="lissajous">Lissajous</button>
<button data-gen="flow">Flow field</button>
</div>
</div>
<div class="group">
<div class="label">Symmetry & tiling</div>
<div class="row">
<button data-gen="truchet">Truchet</button>
<button data-gen="kaleido">Kaleido</button>
<button data-gen="hex">Hex grid</button>
<button data-gen="herringbone">Herring</button>
<button data-gen="weave">Weave</button>
<button data-gen="moire">Moir\u00e9</button>
</div>
</div>
<div class="group">
<div class="label">Fractals</div>
<div class="row">
<button data-gen="sierpinski">Sierpinski</button>
<button data-gen="carpet">Carpet</button>
<button data-gen="dragon">Dragon</button>
<button data-gen="koch">Koch</button>
<button data-gen="hilbert">Hilbert</button>
<button data-gen="mandelbrot">Mandel</button>
</div>
</div>
<div class="group">
<div class="label">Geometric</div>
<div class="row">
<button data-gen="grad">Gradient</button>
<button data-gen="circles">Circles</button>
<button data-gen="sunburst">Sunburst</button>
<button data-gen="parabolic">Stitching</button>
<button data-gen="mystic">Mystic rose</button>
<button data-gen="spirograph">Spiro</button>
</div>
</div>
<div class="group">
<div class="label">Cellular & random</div>
<div class="row">
<button data-gen="stars">Stars</button>
<button data-gen="noise">Noise</button>
<button data-gen="maze">Maze</button>
<button data-gen="life">Life</button>
<button data-gen="voronoi">Voronoi</button>
<button data-gen="dla">DLA tree</button>
</div>
</div>
<div class="group">
<div class="label">Canvas</div>
<div class="row">
<button id="btn-undo">Undo</button>
<button id="btn-clear" class="danger">Clear</button>
<button id="btn-invert">Invert</button>
<button id="btn-png">Save PNG</button>
</div>
</div>
<div class="group">
<div class="label">Send to e-ink</div>
<div class="row">
<button id="btn-send" class="send">Send (partial)</button>
<button id="btn-sendfull">Send (full)</button>
</div>
<div class="row">
<button id="btn-wipe" class="danger">Blank screen</button>
</div>
</div>
</aside>
</main>
<footer>
<span>Pico W \\u00b7 250\\u00d7122 \\u00b7 1bpp</span>
<span><kbd>B</kbd> brush \\u00b7 <kbd>E</kbd> eraser \\u00b7 <kbd>X</kbd> swap \\u00b7 <kbd>Ctrl+Z</kbd> undo \\u00b7 <kbd>Enter</kbd> send</span>
</footer>
<script>
window.addEventListener('error', e => {
const d = document.getElementById('dbg');
if (d) d.textContent = 'JS ERROR: ' + e.message + ' @ ' + e.lineno;
});
(function(){
const W = 250, H = 122;
const view = document.getElementById('view');
const dbg = document.getElementById('dbg');
const setDbg = m => { if(dbg) dbg.textContent = m; };
if(!view){ setDbg('FATAL: canvas element missing'); return; }
const ctx = view.getContext('2d');
if(!ctx){ setDbg('FATAL: 2D context unavailable'); return; }
// pixel buffer: 1 byte per pixel, 0=black 1=white
let pixels = new Uint8Array(W*H);
pixels.fill(1);
// --- repaint via ImageData ---
const imgd = ctx.createImageData(W, H);
function repaint(){
const d = imgd.data;
for(let i=0;i<pixels.length;i++){
const v = pixels[i] ? 255 : 0;
const j = i*4;
d[j]=v; d[j+1]=v; d[j+2]=v; d[j+3]=255;
}
ctx.putImageData(imgd, 0, 0);
}
repaint();
setDbg('ready - tap or drag the canvas');
// --- drawing ops in pixel space ---
function setPx(x,y,ink){
if(x<0||y<0||x>=W||y>=H) return;
pixels[y*W + x] = ink;
}
function stamp(cx,cy,r,ink){
cx|=0; cy|=0;
if(r<=1){ setPx(cx,cy,ink); return; }
const h = Math.max(1, r/2)|0;
const r2 = h*h;
for(let y=Math.max(0,cy-h); y<=Math.min(H-1,cy+h); y++){
for(let x=Math.max(0,cx-h); x<=Math.min(W-1,cx+h); x++){
const dx=x-cx, dy=y-cy;
if(dx*dx+dy*dy <= r2) pixels[y*W+x]=ink;
}
}
}
function drawLine(x0,y0,x1,y1,r,ink){
x0|=0; y0|=0; x1|=0; y1|=0;
const dx=Math.abs(x1-x0), sx=x0<x1?1:-1;
const dy=-Math.abs(y1-y0), sy=y0<y1?1:-1;
let err=dx+dy, guard=0;
while(guard++<10000){
stamp(x0,y0,r,ink);
if(x0===x1 && y0===y1) break;
const e2=2*err;
if(e2>=dy){ err+=dy; x0+=sx; }
if(e2<=dx){ err+=dx; y0+=sy; }
}
}
function drawRect(x0,y0,x1,y1,r,ink){
drawLine(x0,y0,x1,y0,r,ink); drawLine(x1,y0,x1,y1,r,ink);
drawLine(x1,y1,x0,y1,r,ink); drawLine(x0,y1,x0,y0,r,ink);
}
function drawCircle(cx,cy,rad,r,ink){
if(rad<1){ stamp(cx,cy,r,ink); return; }
let x=rad,y=0,err=0,guard=0;
while(x>=y && guard++<5000){
stamp(cx+x,cy+y,r,ink); stamp(cx+y,cy+x,r,ink);
stamp(cx-y,cy+x,r,ink); stamp(cx-x,cy+y,r,ink);
stamp(cx-x,cy-y,r,ink); stamp(cx-y,cy-x,r,ink);
stamp(cx+y,cy-x,r,ink); stamp(cx+x,cy-y,r,ink);
y++; err+=1+2*y;
if(2*(err-x)+1>0){ x--; err+=1-2*x; }
}
}
function floodFill(sx,sy,ink){
const target=pixels[sy*W+sx];
if(target===undefined || target===ink) return;
const stack=[sx,sy];
while(stack.length){
const y=stack.pop(), x=stack.pop();
if(x<0||y<0||x>=W||y>=H) continue;
if(pixels[y*W+x]!==target) continue;
pixels[y*W+x]=ink;
stack.push(x+1,y, x-1,y, x,y+1, x,y-1);
}
}
// --- state ---
let tool='brush', ink=0, size=3;
let drawing=false, last=null, startPt=null, snap=null;
const undoStack=[];
function pushUndo(){
undoStack.push(new Uint8Array(pixels));
if(undoStack.length>20) undoStack.shift();
}
// --- pointer math: get pixel coords from any event ---
function getXY(clientX, clientY){
const r = view.getBoundingClientRect();
let x = ((clientX - r.left) / r.width) * W;
let y = ((clientY - r.top) / r.height) * H;
x = Math.max(0, Math.min(W-1, x|0));
y = Math.max(0, Math.min(H-1, y|0));
return [x,y];
}
function startStroke(clientX, clientY){
pushUndo();
drawing = true;
const [x,y] = getXY(clientX, clientY);
startPt = [x,y]; last = [x,y];
setDbg('down @ '+x+','+y+' tool='+tool);
if(tool==='brush' || tool==='eraser'){
stamp(x, y, size, tool==='eraser'?1:ink);
repaint();
} else if(tool==='fill'){
floodFill(x, y, ink);
repaint();
drawing = false;
} else {
snap = new Uint8Array(pixels);
}
}
function moveStroke(clientX, clientY){
if(!drawing) return;
const [x,y] = getXY(clientX, clientY);
if(tool==='brush' || tool==='eraser'){
drawLine(last[0], last[1], x, y, size, tool==='eraser'?1:ink);
last = [x,y];
repaint();
setDbg('draw @ '+x+','+y);
} else if(tool==='line' || tool==='rect' || tool==='circle'){
pixels.set(snap);
if(tool==='line') drawLine(startPt[0],startPt[1],x,y,size,ink);
else if(tool==='rect') drawRect(startPt[0],startPt[1],x,y,size,ink);
else {
const dx=x-startPt[0], dy=y-startPt[1];
drawCircle(startPt[0], startPt[1], Math.round(Math.sqrt(dx*dx+dy*dy)), size, ink);
}
repaint();
}
}
function endStroke(){
if(!drawing) return;
drawing=false; last=null; startPt=null; snap=null;
setDbg('stroke ended');
}
// --- attach pointer events with both pointer + mouse + touch fallbacks ---
if('PointerEvent' in window){
view.addEventListener('pointerdown', e => {
e.preventDefault();
try{ view.setPointerCapture(e.pointerId); }catch(_){}
startStroke(e.clientX, e.clientY);
});
view.addEventListener('pointermove', e => {
if(drawing){ e.preventDefault(); moveStroke(e.clientX, e.clientY); }
});
window.addEventListener('pointerup', endStroke);
window.addEventListener('pointercancel', endStroke);
setDbg('using PointerEvent');
} else {
view.addEventListener('mousedown', e => { e.preventDefault(); startStroke(e.clientX, e.clientY); });
window.addEventListener('mousemove', e => moveStroke(e.clientX, e.clientY));
window.addEventListener('mouseup', endStroke);
view.addEventListener('touchstart', e => {
if(!e.touches[0]) return;
e.preventDefault();
startStroke(e.touches[0].clientX, e.touches[0].clientY);
}, {passive:false});
view.addEventListener('touchmove', e => {
if(!e.touches[0]) return;
e.preventDefault();
moveStroke(e.touches[0].clientX, e.touches[0].clientY);
}, {passive:false});
view.addEventListener('touchend', endStroke);
view.addEventListener('touchcancel', endStroke);
setDbg('using mouse+touch fallback');
}
// --- toolbar ---
document.getElementById('tools').addEventListener('click', e => {
const b = e.target.closest('button'); if(!b) return;
tool = b.dataset.tool;
document.querySelectorAll('#tools button').forEach(x => x.classList.toggle('on', x===b));
setDbg('tool: '+tool);
});
document.getElementById('swatches').addEventListener('click', e => {
const s = e.target.closest('.sw'); if(!s) return;
ink = parseInt(s.dataset.ink, 10);
document.querySelectorAll('#swatches .sw').forEach(x => x.classList.toggle('on', x===s));
setDbg('ink: '+(ink?'white':'black'));
});
const sizeIn = document.getElementById('size');
const sizeOut = document.getElementById('sizeOut');
size = +sizeIn.value || 3;
sizeOut.textContent = size;
sizeIn.addEventListener('input', () => { size = +sizeIn.value; sizeOut.textContent = size; });
document.getElementById('btn-clear').addEventListener('click', () => { pushUndo(); pixels.fill(1); repaint(); });
document.getElementById('btn-invert').addEventListener('click', () => {
pushUndo(); for(let i=0;i<pixels.length;i++) pixels[i]^=1; repaint();
});
document.getElementById('btn-undo').addEventListener('click', () => {
if(!undoStack.length) return; pixels = undoStack.pop(); repaint();
});
document.getElementById('btn-png').addEventListener('click', () => {
const off = document.createElement('canvas'); off.width=W*4; off.height=H*4;
const oc = off.getContext('2d'); oc.imageSmoothingEnabled=false;
oc.drawImage(view, 0, 0, off.width, off.height);
off.toBlob(b => {
const a = document.createElement('a');
a.href = URL.createObjectURL(b);
a.download = 'pico-canvas.png'; a.click();
});
});
// --- generators ---
// --- generators (math / art patterns) ---
// Helpers used by several gens
function plotBresenham(x0,y0,x1,y1,ink){
drawLine(x0|0,y0|0,x1|0,y1|0,1,ink);
}
function fillRectPx(x0,y0,x1,y1,ink){
if(x0>x1){ const t=x0;x0=x1;x1=t; }
if(y0>y1){ const t=y0;y0=y1;y1=t; }
x0=Math.max(0,x0|0); y0=Math.max(0,y0|0);
x1=Math.min(W-1,x1|0); y1=Math.min(H-1,y1|0);
for(let y=y0;y<=y1;y++) for(let x=x0;x<=x1;x++) pixels[y*W+x]=ink;
}
const gens = {
scribble(){
pixels.fill(1); let x=W/2, y=H/2;
for(let i=0;i<600;i++){
const nx=x+(Math.random()-.5)*14, ny=y+(Math.random()-.5)*14;
drawLine(x|0, y|0, nx|0, ny|0, 1, 0);
x=Math.max(2,Math.min(W-3,nx)); y=Math.max(2,Math.min(H-3,ny));
}
},
sinewave(){
pixels.fill(1);
const layers = 5;
for(let L=0; L<layers; L++){
const amp = 8 + L*4;
const freq = 0.05 + L*0.015;
const phase = L*0.7;
const yc = H/2 + (L-layers/2)*4;
let py = (yc + Math.sin(phase)*amp)|0;
for(let x=0; x<W; x++){
const y = (yc + Math.sin(x*freq + phase)*amp)|0;
drawLine(x-1, py, x, y, 1, 0);
py = y;
}
}
},
interference(){
// Two wave sources; threshold the sum to make moire/ripple bands
pixels.fill(1);
const s1x=W*0.30, s1y=H*0.5;
const s2x=W*0.70, s2y=H*0.5;
const k = 0.55; // wave number
for(let y=0;y<H;y++){
for(let x=0;x<W;x++){
const d1 = Math.sqrt((x-s1x)**2+(y-s1y)**2);
const d2 = Math.sqrt((x-s2x)**2+(y-s2y)**2);
const v = Math.cos(d1*k) + Math.cos(d2*k);
pixels[y*W+x] = v>0 ? 0 : 1;
}
}
},
rose(){
// Rose curve r = a*cos(k*theta), k = n/d
pixels.fill(1);
const cx=W/2, cy=H/2;
const a = Math.min(W,H)*0.45;
const n = 5, d = 1; // 5-petal rose; tweak for variety
const steps = 4000;
let px=null, py=null;
for(let i=0; i<=steps; i++){
const th = (i/steps) * Math.PI * 2 * d;
const r = a * Math.cos(n/d * th);
const x = (cx + r*Math.cos(th))|0;
const y = (cy + r*Math.sin(th))|0;
if(px!==null) drawLine(px,py,x,y,1,0);
px=x; py=y;
}
},
lissajous(){
pixels.fill(1);
const cx=W/2, cy=H/2;
const A=W*0.46, B=H*0.46;
const a=3, b=2, delta=Math.PI/2;
const steps=3000;
let px=null, py=null;
for(let i=0;i<=steps;i++){
const t = (i/steps)*Math.PI*2;
const x = (cx + A*Math.sin(a*t + delta))|0;
const y = (cy + B*Math.sin(b*t))|0;
if(px!==null) drawLine(px,py,x,y,1,0);
px=x; py=y;
}
},
flow(){
// Pseudo-Perlin flow field via cheap value-noise hash
pixels.fill(1);
function hash(x,y){
let h = (x*374761393 + y*668265263) | 0;
h = (h ^ (h>>>13)) * 1274126177 | 0;
return ((h ^ (h>>>16)) >>> 0) / 4294967295;
}
function noise(x,y){
const xi=Math.floor(x), yi=Math.floor(y);
const xf=x-xi, yf=y-yi;
const u=xf*xf*(3-2*xf), v=yf*yf*(3-2*yf);
const n00=hash(xi,yi), n10=hash(xi+1,yi);
const n01=hash(xi,yi+1), n11=hash(xi+1,yi+1);
return (n00*(1-u)+n10*u)*(1-v) + (n01*(1-u)+n11*u)*v;
}
const seeds = 80, steps = 60;
for(let s=0; s<seeds; s++){
let x = Math.random()*W, y = Math.random()*H;
for(let i=0;i<steps;i++){
const ang = noise(x*0.04, y*0.04) * Math.PI * 4;
const nx = x + Math.cos(ang)*1.4;
const ny = y + Math.sin(ang)*1.4;
drawLine(x|0,y|0,nx|0,ny|0,1,0);
x=nx; y=ny;
if(x<0||x>=W||y<0||y>=H) break;
}
}
},
truchet(){
pixels.fill(1); const t=12;
for(let y=0;y<H;y+=t) for(let x=0;x<W;x+=t){
if(Math.random()<.5) drawLine(x,y,x+t,y+t,1,0);
else drawLine(x+t,y,x,y+t,1,0);
}
},
kaleido(){
// Draw random strokes in one wedge, then mirror across N-fold symmetry
pixels.fill(1);
const cx=W/2, cy=H/2;
const N = 6;
const segs = 30;
const pts = [];
for(let i=0;i<segs;i++){
const r1 = Math.random()*Math.min(W,H)*0.45;
const r2 = Math.random()*Math.min(W,H)*0.45;
const a1 = Math.random()*Math.PI*2/N;
const a2 = a1 + (Math.random()-.5)*0.6;
pts.push([r1,a1,r2,a2]);
}
for(let k=0;k<N;k++){
const base = k*Math.PI*2/N;
for(const [r1,a1,r2,a2] of pts){
const x1=cx+Math.cos(base+a1)*r1, y1=cy+Math.sin(base+a1)*r1;
const x2=cx+Math.cos(base+a2)*r2, y2=cy+Math.sin(base+a2)*r2;
drawLine(x1|0,y1|0,x2|0,y2|0,1,0);
// mirror within wedge
const x1m=cx+Math.cos(base-a1)*r1, y1m=cy+Math.sin(base-a1)*r1;
const x2m=cx+Math.cos(base-a2)*r2, y2m=cy+Math.sin(base-a2)*r2;
drawLine(x1m|0,y1m|0,x2m|0,y2m|0,1,0);
}
}
},
hex(){
pixels.fill(1);
const r = 8; // hex circumradius
const dx = r*Math.sqrt(3);
const dy = r*1.5;
for(let row=-1; row*dy<H+r; row++){
for(let col=-1; col*dx<W+r; col++){
const cx = col*dx + (row&1?dx/2:0);
const cy = row*dy;
// hex outline
let px=null, py=null;
for(let k=0;k<=6;k++){
const a = Math.PI/3*k - Math.PI/2;
const x = (cx+Math.cos(a)*r)|0;
const y = (cy+Math.sin(a)*r)|0;
if(px!==null) drawLine(px,py,x,y,1,0);
px=x; py=y;
}
}
}
},
herringbone(){
pixels.fill(1);
const bw=18, bh=6;
for(let y=0;y<H+bh;y+=bh){
for(let x=-bw;x<W;x+=bw){
const ox = ((y/bh)|0) % 2 === 0 ? 0 : bw/2;
// diagonal brick
drawLine(x+ox, y, x+ox+bw, y+bh, 1, 0);
}
}
},
weave(){
pixels.fill(1);
const t=6;
// horizontal stripes
for(let y=0;y<H;y+=t*2){
for(let x=0;x<W;x+=t*2){
fillRectPx(x, y, x+t-1, y+t-1, 0);
fillRectPx(x+t, y+t, x+2*t-1, y+2*t-1, 0);
}
}
},
moire(){
// Two rotated line gratings
pixels.fill(1);
const a1 = 0.1, a2 = -0.13;
const sp = 4;
for(let y=0;y<H;y++){
for(let x=0;x<W;x++){
const u = x*Math.cos(a1)+y*Math.sin(a1);
const v = x*Math.cos(a2)+y*Math.sin(a2);
const on = (Math.floor(u/sp)&1) ^ (Math.floor(v/sp)&1);
if(on) pixels[y*W+x]=0;
}
}
},
sierpinski(){
pixels.fill(1);
const ax=W/2, ay=4;
const bx=4, by=H-4;
const cx=W-4, cy=H-4;
let px=W/2, py=H/2;
const verts=[[ax,ay],[bx,by],[cx,cy]];
// Chaos game
for(let i=0;i<8000;i++){
const v = verts[(Math.random()*3)|0];
px = (px+v[0])/2;
py = (py+v[1])/2;
if(i>20) setPx(px|0, py|0, 0);
}
},
carpet(){
// Sierpinski carpet by recursive subdivision
pixels.fill(0); // start black, carve white holes
function carve(x,y,w,h,depth){
if(depth===0 || w<3 || h<3) return;
const w3=w/3, h3=h/3;
fillRectPx(x+w3, y+h3, x+2*w3-1, y+2*h3-1, 1);
for(let iy=0;iy<3;iy++) for(let ix=0;ix<3;ix++){
if(ix===1 && iy===1) continue;
carve(x+ix*w3, y+iy*h3, w3, h3, depth-1);
}
}
carve(0,0,W,H,4);
},
dragon(){
pixels.fill(1);
let path = [1];
for(let i=0;i<11;i++){
const rev = [];
for(let j=path.length-1;j>=0;j--) rev.push(path[j]^1);
path = path.concat([1]).concat(rev);
}
// walk
let x=W*0.35, y=H*0.55, dir=0;
const step=2;
for(const turn of path){
const nx = x + Math.cos(dir)*step;
const ny = y + Math.sin(dir)*step;
drawLine(x|0,y|0,nx|0,ny|0,1,0);
x=nx; y=ny;
dir += turn ? Math.PI/2 : -Math.PI/2;
}
},
koch(){
pixels.fill(1);
// Koch snowflake from equilateral triangle, 4 iterations
function koch(p1,p2,depth){
if(depth===0){
drawLine(p1[0]|0,p1[1]|0,p2[0]|0,p2[1]|0,1,0);
return;
}
const dx=(p2[0]-p1[0])/3, dy=(p2[1]-p1[1])/3;
const a=[p1[0]+dx, p1[1]+dy];
const b=[p1[0]+2*dx, p1[1]+2*dy];
const ang = Math.atan2(dy,dx) - Math.PI/3;
const len = Math.sqrt(dx*dx+dy*dy);
const peak=[a[0]+Math.cos(ang)*len, a[1]+Math.sin(ang)*len];
koch(p1,a,depth-1); koch(a,peak,depth-1);
koch(peak,b,depth-1); koch(b,p2,depth-1);
}
const cx=W/2, cy=H/2;
const r=Math.min(W,H)*0.42;
const v=[];
for(let i=0;i<3;i++){
const a = -Math.PI/2 + i*Math.PI*2/3;
v.push([cx+Math.cos(a)*r, cy+Math.sin(a)*r]);
}
koch(v[0],v[1],4); koch(v[1],v[2],4); koch(v[2],v[0],4);
},
hilbert(){
pixels.fill(1);
// Iterative Hilbert curve fitting in min(W,H) square
const order = 5; // 2^5 = 32 cells per side
const N = 1<<order;
const cell = Math.min(W,H)/N;
const ox = (W-N*cell)/2, oy = (H-N*cell)/2;
function d2xy(d){
let x=0,y=0,t=d;
for(let s=1;s<N;s<<=1){
const rx = 1 & (t>>1);
const ry = 1 & (t ^ rx);
if(ry===0){
if(rx===1){ x=s-1-x; y=s-1-y; }
const tmp=x; x=y; y=tmp;
}
x += s*rx; y += s*ry;
t >>= 2;
}
return [x,y];
}
let prev=null;
for(let i=0;i<N*N;i++){
const [gx,gy]=d2xy(i);
const x = (ox + gx*cell + cell/2)|0;
const y = (oy + gy*cell + cell/2)|0;
if(prev) drawLine(prev[0],prev[1],x,y,1,0);
prev=[x,y];
}
},
mandelbrot(){
pixels.fill(1);
const xmin=-2.1, xmax=0.7, ymin=-1.0, ymax=1.0;
const maxIter=24;
for(let py=0;py<H;py++){
const cy = ymin + (ymax-ymin)*py/H;
for(let px=0;px<W;px++){
const cx = xmin + (xmax-xmin)*px/W;
let zx=0, zy=0, i=0;
while(i<maxIter && zx*zx+zy*zy<4){
const t = zx*zx-zy*zy+cx;
zy = 2*zx*zy+cy;
zx = t;
i++;
}
// Threshold dither: high-iter -> dark, low-iter -> light
const v = i/maxIter;
const bayer = ((px+py*7)*0.137)%1;
pixels[py*W+px] = v < 0.95 && v*1.1 > bayer ? 0 : 1;
if(i===maxIter) pixels[py*W+px]=0;
}
}
},
grad(){
const bayer=[
[0,32,8,40,2,34,10,42],[48,16,56,24,50,18,58,26],
[12,44,4,36,14,46,6,38],[60,28,52,20,62,30,54,22],
[3,35,11,43,1,33,9,41],[51,19,59,27,49,17,57,25],
[15,47,7,39,13,45,5,37],[63,31,55,23,61,29,53,21]];
for(let y=0;y<H;y++) for(let x=0;x<W;x++){
pixels[y*W+x] = ((x/(W-1))*64 > bayer[y&7][x&7]) ? 1 : 0;
}
},
circles(){
pixels.fill(1);
const cx=W/2, cy=H/2;
for(let r=4; r<Math.max(W,H); r+=6){
drawCircle(cx,cy,r,1,0);
}
},
sunburst(){
pixels.fill(1);
const cx=W/2, cy=H/2;
const rays = 64;
const R = Math.max(W,H);
for(let i=0;i<rays;i++){
const a = i*Math.PI*2/rays;
const x = cx+Math.cos(a)*R;
const y = cy+Math.sin(a)*R;
drawLine(cx|0,cy|0,x|0,y|0,1,0);
}
},
parabolic(){
// Curve stitching: connect points across two perpendicular axes
pixels.fill(1);
const N = 24;
const x0=4, y0=4, x1=W-4, y1=H-4;
// top-left corner
for(let i=0;i<=N;i++){
const t = i/N;
drawLine(x0+(x1-x0)*t|0, y0|0, x0|0, y0+(y1-y0)*t|0, 1, 0);
}
// bottom-right corner
for(let i=0;i<=N;i++){
const t = i/N;
drawLine(x1-(x1-x0)*t|0, y1|0, x1|0, y1-(y1-y0)*t|0, 1, 0);
}
},
mystic(){
// Mystic rose: N points on a circle, every chord drawn
pixels.fill(1);
const cx=W/2, cy=H/2;
const r = Math.min(W,H)*0.45;
const N = 18;
const pts = [];
for(let i=0;i<N;i++){
const a = i*Math.PI*2/N - Math.PI/2;
pts.push([cx+Math.cos(a)*r, cy+Math.sin(a)*r]);
}
for(let i=0;i<N;i++) for(let j=i+1;j<N;j++){
drawLine(pts[i][0]|0, pts[i][1]|0, pts[j][0]|0, pts[j][1]|0, 1, 0);
}
},
spirograph(){
// Hypotrochoid: outer R, inner r, pen offset d
pixels.fill(1);
const cx=W/2, cy=H/2;
const R = Math.min(W,H)*0.42;
const r = R*0.31;
const d = R*0.55;
const steps = 4000;
let px=null, py=null;
for(let i=0;i<=steps;i++){
const t = (i/steps)*Math.PI*2*7;
const x = ((R-r)*Math.cos(t) + d*Math.cos((R-r)/r*t)) + cx;
const y = ((R-r)*Math.sin(t) - d*Math.sin((R-r)/r*t)) + cy;
if(px!==null) drawLine(px|0,py|0,x|0,y|0,1,0);
px=x; py=y;
}
},
stars(){
pixels.fill(0);
for(let i=0;i<180;i++){
stamp((Math.random()*W)|0, (Math.random()*H)|0,
Math.random()<.15?2:1, 1);
}
},
noise(){
for(let i=0;i<pixels.length;i++) pixels[i] = Math.random()<.5 ? 0 : 1;
},
maze(){
pixels.fill(1); const t=8;
for(let y=0;y<H;y+=t) for(let x=0;x<W;x+=t){
if(Math.random()<.5) drawLine(x,y,x+t,y,1,0);
else drawLine(x,y,x,y+t,1,0);
}
drawRect(0,0,W-1,H-1,1,0);
},
life(){
// Run Conway's Game of Life from random soup, snapshot at step N
let cur = new Uint8Array(W*H);
for(let i=0;i<cur.length;i++) cur[i] = Math.random()<.35 ? 1 : 0;
let nxt = new Uint8Array(W*H);
for(let gen=0; gen<30; gen++){
for(let y=0;y<H;y++){
for(let x=0;x<W;x++){
let n=0;
for(let dy=-1;dy<=1;dy++) for(let dx=-1;dx<=1;dx++){
if(dx===0&&dy===0) continue;
const xx=(x+dx+W)%W, yy=(y+dy+H)%H;
n += cur[yy*W+xx];
}
const c = cur[y*W+x];
nxt[y*W+x] = (c && (n===2||n===3)) || (!c && n===3) ? 1 : 0;
}
}
const t=cur; cur=nxt; nxt=t;
}
// alive=black on white paper
for(let i=0;i<pixels.length;i++) pixels[i] = cur[i] ? 0 : 1;
},
voronoi(){
// Voronoi cell edges: pixel is "edge" if its nearest seed differs from a neighbor's
const N = 22;
const sx=[], sy=[];
for(let i=0;i<N;i++){ sx.push(Math.random()*W); sy.push(Math.random()*H); }
const owner = new Int16Array(W*H);
for(let y=0;y<H;y++){
for(let x=0;x<W;x++){
let best=0, bd=1e9;
for(let i=0;i<N;i++){
const dx=x-sx[i], dy=y-sy[i];
const d=dx*dx+dy*dy;
if(d<bd){ bd=d; best=i; }
}
owner[y*W+x] = best;
}
}
pixels.fill(1);
for(let y=0;y<H;y++){
for(let x=0;x<W;x++){
const o = owner[y*W+x];
if(x+1<W && owner[y*W+x+1]!==o){ pixels[y*W+x]=0; }
else if(y+1<H && owner[(y+1)*W+x]!==o){ pixels[y*W+x]=0; }
}
}
},
dla(){
// Diffusion-limited aggregation: random walkers stick to growing tree
pixels.fill(1);
const grid = new Uint8Array(W*H);
const cx=W/2|0, cy=H/2|0;
grid[cy*W+cx]=1; pixels[cy*W+cx]=0;
const maxParticles = 800;
for(let p=0;p<maxParticles;p++){
// spawn on a circle of radius rmax
let ang = Math.random()*Math.PI*2;
let rad = Math.min(W,H)*0.45;
let x = cx + (Math.cos(ang)*rad)|0;
let y = cy + (Math.sin(ang)*rad)|0;
for(let step=0; step<2000; step++){
x += (Math.random()<.5?-1:1);
y += (Math.random()<.5?-1:1);
if(x<1||x>=W-1||y<1||y>=H-1){ break; }
// touch?
if(grid[(y-1)*W+x]||grid[(y+1)*W+x]||
grid[y*W+x-1]||grid[y*W+x+1]){
grid[y*W+x]=1; pixels[y*W+x]=0; break;
}
}
}
},
};
document.querySelectorAll('[data-gen]').forEach(btn => {
btn.addEventListener('click', () => {
const k = btn.dataset.gen;
const fn = gens[k];
if(!fn){ setDbg('unknown gen: '+k); return; }
pushUndo();
try { fn(); } catch(e){ setDbg('gen '+k+' err: '+e.message); }
repaint();
setDbg('generated: '+k);
});
});
// --- network ---
const statusEl = document.getElementById('status');
function setStatus(msg, bad){ statusEl.textContent = msg; statusEl.classList.toggle('bad', !!bad); }
function packBitmap(){
const stride = (W+7)>>3;
const out = new Uint8Array(stride * H);
for(let y=0;y<H;y++){
for(let xb=0;xb<stride;xb++){
let b = 0;
for(let bit=0;bit<8;bit++){
const x = xb*8 + bit;
const v = (x<W) ? pixels[y*W+x] : 1;
if(v) b |= (1 << (7-bit));
}
out[y*stride + xb] = b;
}
}
return out;
}
async function sendDraw(mode){
setStatus('sending...');
try{
const r = await fetch('/draw?mode='+mode, {
method: 'POST',
headers: {'Content-Type':'application/octet-stream'},
body: packBitmap()
});
if(!r.ok) throw new Error('HTTP '+r.status);
const j = await r.json();
setStatus('sent - '+j.mode+' - '+j.ms+'ms');
} catch(err){ setStatus('send failed: '+err.message, true); }
}
async function sendClear(){
setStatus('clearing...');
try{
const r = await fetch('/clear', {method:'POST'});
if(!r.ok) throw new Error('HTTP '+r.status);
setStatus('blanked');
} catch(err){ setStatus('clear failed: '+err.message, true); }
}
document.getElementById('btn-send').addEventListener('click', () => sendDraw('partial'));
document.getElementById('btn-sendfull').addEventListener('click', () => sendDraw('full'));
document.getElementById('btn-wipe').addEventListener('click', sendClear);
fetch('/status').then(r => r.json()).then(j => {
setStatus('connected '+j.w+'x'+j.h);
}).catch(() => setStatus('offline', true));
})();
</script>
</body>
</html>
UI


sample interface
