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

Weeks123456789101112131415161718
Preparation and ResearchX
Prototyping – DesignX
Prototyping – InterfaceXX
Prototyping – ElectronicsX
Implementation – PCBXXXX
Implementation – CADXXX
ProductionXXX
OptimizationXX
Finalization & Project CompletionX
DocumentationXX
Presentation Prep & EvaluationXX

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

ItemQtyNotes
Raspberry Pi Pico 2 W1Main MCU and Wi-Fi
Custom PCB (bare board)1Interconnect and breakout

Displays and input

ItemQtyNotes
Waveshare 2.13 inch e-ink module1Primary display
0.96 inch SSD1306 OLED1Secondary status / UI
ALPS EC11 magnetic encoder1With cable to PCB
IC184 red push button1Tactile input

Power

ItemQtyNotes
1S 3.7 V LiPo battery (350 mAh)1As used in integration
TP4056 LiPo charging module1Charging / protection (module as built)

PCB population (SMD / through-hole on custom board)

PartQty
LED 1206 (orange)2
C 1206 0.1 µF2
R 1206 100 Ω1
C 1206 10 µF1
Pin header 2.54 mm13
5-position vertical SMD socket8
4-position vertical SMD socket1
3-position vertical SMD socket1
2-position vertical SMD socket1

Mechanical and assembly

ItemQtyNotes
3D-printed parts (body, top, screen cap, encoder shaft, guides, etc.)1 setDesign-specific enclosure
3D printing filamentAs needede.g. PLA or PETG
Magnets (~0.5 inch diameter)SetMagnetic retention for e-ink cap / top
M2 screwsSetTop–body assembly
Brass threaded insertsSetInstalled 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 EPD class you can draw into with framebuf, 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 boxWhat it is in firmware
Week 14 canvas / Pack 250×122Browser UI → packed bitmap matching what /draw expects.
framebuf MONO_HLSBLandscape internal buffer in EPD (self.fb) before _rotate_to_native().
HTTP … /drawMicroPython handlers (/status, /draw, /clear) + CYW43 stack.
EPD class SPI cmdsCustom driver: _cmd / _data, SoftSPI, busy wait, display_full / display_partial.
SPI / I2C / GPIOPhysical buses from Pico pins to PCB nets (SPI to panel, I2C to OLED, GPIO for encoder/button).
SSD1306 driverOLED firmware path (I2C), separate from epd_driver.py.
EC11 decode / IC184 debounceInput logic in your app (polling or ISR) riding on GPIO.
Waveshare 2.13 EPD / SSD1306 moduleModules 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)

  1. Header — Centered MMM D YYYY (shortened if wider than max_chars_w), then centered full weekday from _DOW_FULL, then a full-width horizontal rule.
  2. Month gridM … S row; grid centered horizontally (grid_x0 = (_EW - grid_w) // 2). cell_w ≈ 16 px so 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+2 and 16 px).
  3. Second rule, then events for _date_key(y, m, sel_d)time + title lines, +N more if the list would run into the footer.
  4. Footer — Rule above Scroll: month / Scroll: day, or Mode: M / Mode: D if the long string would overflow.

Typography constants

  • _CW = 8, _LH = 10 — monospace fb.text stride (matches your font assumptions).
  • _EMG = 3 — screen margin.

Refresh policy (_Refresher)

  • Same as before: partial batches, forced full every _PARTIALS_BEFORE_FULL (25) or when force_full (first paint uses full=True).

Encoder mapping

ActionBehaviour
dial+ / dial-_advance by month or day according to mode.
dial_doubleFlip _MODE_MONTH_MODE_DAY.
dialRefresh today from time.localtime() (chained unpack with y, m, d).
dial_longLeave loop → main() clears framebuffer, full refresh, sleep().

Data

  • calendar.json beside the script; seed SAMPLE_EVENTS on 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 same EPD API as the published epd_driver.py (.fb, display_full, display_partial, sleep).
  • pico_ui_input.InputManager — encoder on clk=2, dt=3, sw=4 with long_press and double_click.

Entrypoint

  • if __name__ == '__main__': main() plus else: 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.

DataOpen-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.

OLEDpico_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.

Encoderdial: 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-MeteoGET 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={}'
        '&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'
    ).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={}'
        '&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'
    ).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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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

Drawing app UI

Send controls

sample interface

Hardware test: workflow