Project development

Planted May 26, 2026

Planning, research, sketches, timeline, and prototype builds — the stages before the integrated May pocket on the main final project page.

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.

Inspiration

WriterDeck

WriterDeck — compact e-ink writer with physical keyboard, used as a distraction-free writing reference

Xtenik X4

Xtenik X4 — pocket Linux handheld with keyboard and small display, another form-factor reference

Early sketches

Sketch v 1.0.0

First paper sketch — pocket e-ink layout with encoder and button placement

Sketch v 2.0.0 → next step

Sketch v2 — refined proportions, submodule slots, and internal volume estimate

Physical sketch

Cardboard mockup cut from the v2 sketch to check grip and thickness in hand

Projected timeline

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

Weekly tasks

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

Week 1–2: Preparation and Research

Tasks: Create project plan and schedule; conduct requirement analysis and research.

Details: Define project scope (distraction-free pocket e-ink). Research MCU options, e-ink modules, LiPo/charging, Wi-Fi, PCB, and 3D printing.

Week 3–4: Design and Prototype

Tasks: Initial sketches and enclosure concept.

Details: Wall thickness, clearances, early UX (e-ink vs OLED, encoder/button roles).

Week 5–8: Implementation

Tasks: Pico 2 W bring-up, HTTP/UI workflow, custom PCB, display and power integration.

Week 9–10: Production

Tasks: CAD → print enclosure; full system assembly and functional tests.

Week 11–13: Documentation, presentation, evaluation

Tasks: Architecture docs, mentor feedback, demo-ready freeze.

Enclosure design

CAD v1 (Week 2)

First enclosure CAD — archaic compared to the final pocket.

ExteriorInterior
CAD v1 — exterior shell render with screen cutout and encoder pocketCAD v1 — internal view showing PCB bay, battery space, and cable routing

April 2026 — Version 2 iterations

Slightly improved, still unaesthetic and too big. Blue vs yellow top/body prints explored encoder shaft height and clearance.

Version 2 enclosure — assembled yellow body with e-ink module fitted

Printed top and body variants — blue vs yellow encoder clearance experiments

3D-printed fit check for the wooden enclosure shell before CNC milling

Prototype builds

Yellow-enclosure iterations before the white May integration build on the main page.

Version 1 — “the box”

First integrated enclosure — tall box form factor.

Version 1 “box” prototype — tall rectangular enclosure with e-ink and encoder on the bench

Full walkaround

Short clip — same build

Encoder scroll and e-ink UI

Power-on and display bring-up

Version 2 — early pocket (yellow)

Slimmer yellow body before the May integration build.

Version 2 early pocket — slimmer yellow body, closer to the final form factor

Exterior and encoder feel

Thickness and pocketability

App switching and e-ink refresh

PCB & electronics

Main interconnect layout, milled copper boards, and early on-device UI reference.

Early on-device UI reference — menu layout and typography before final firmware polish

KiCad layoutMilled copper
Main interconnect PCB — Pico socket, e-ink header, and encoder padsMilled copper prototypes — main board and submodule breakout after CNC routing

ChordBoard submodule — PCB & assembly

ChordBoard copper PCB — five touch pads, dual switches, and XIAO footprint

Soldering at the JBC station (320 °C)

Mechanical assembly

Yellow-enclosure integration — wiring, cable guides, and test fits before the white final shell.

Yellow top opened — encoder, PCB, and wiring through internal cutouts

Bench assembly — PCB stack, displays, and submodule headers wired for test

E-ink module seated in the yellow top — pre-close-up test fit

Opened build — cable guides on the enclosure wall, e-ink flex kept clear

Modeling

CNC steps before copper boards and silicone molds — from computer-controlled machining and molding and casting weeks.

PCB milling — isolating copper traces on the interconnect board (Roland SRM-20).

Mold prep — machining a wax blank for the Feyncorder top master mold.

Feyncorder top molding

The Feyncorder submodule needs a small top plate with cutouts for the encoder knob, button, and window. All three photos below are one mold → cast sequence from Molding and Casting week — pour silicone into the master, demold the flexible negative, then cast the final top in resin.

StepWhat happens
1. Pour siliconeEcoflex into the rigid master — rectangular window, round encoder pocket, and pad wells. Masking tape dams overflow on the green mold block.
2. Silicone moldCured flexible negative (teal) ready on the bench; raised islands will become holes and button cups in the part.
3. Cast the topResin (orange) poured into that silicone mold; green sidewalls box the cavity for a clean demold.

Step 1 — pour ecoflex into the Feyncorder master mold

Pouring teal ecoflex into the master mold — window and encoder cavities filling, tape masking the edges

Step 2 — cured silicone mold (negative)

Feyncorder top silicone mold — window, pad recess, and encoder pocket with alignment bosses

Step 3 — resin cast into the silicone mold

Pouring orange casting resin into the Feyncorder top silicone mold

Populated encoder board and on-device demo on the main page: Final product tests →.

Customization & branding

On-screen identity, vinyl labels, and humour stickers on the yellow prototype body.

Boot & submodule screens

Pockety splashChordBoard brand
Custom boot splash — “Pockety” title on e-ink at power-onChordBoard branding — EngelBart submodule identity screen

Vinyl labels

Roland vinyl cutter — cutting sticker outlines

“Sedat approves” sticker

“Not fragile — toss at will” sticker

Accessory storage CAD

Fusion 360 — modular box with PCB, wood, and accessory compartments

Additional interfaces

Prototype and submodule interfaces beyond the main pockety-interface data editor on the final page.

Week 14 drawing canvas (canvas_server.py)

Fab Academy Week 14 browser drawing app → Pico W → e-ink path: a web canvas (250×122 monochrome), packed bitmap bytes over HTTP, and MicroPython endpoints (/status, /draw, /clear) with partial and full refresh.

Full write-up: Week 14: Application and Interface Programming.

Drawing app UI — brush, shapes, and generators

Send controls — pack bitmap and push partial or full refresh

Download: canvas_server.py · canvas_app_interface.html

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()
Full browser canvas markup (canvas_app_interface.html)
<!-- 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>

ESP32-C3 audio BLE gadget (esp32_audio_ble.ino)

Handheld record / play / send submodule built on an ESP32-C3 with I2S mic + amp, SSD1306 OLED, and one button. It scans for the Pico advertising as PICO2W, connects over Nordic UART BLE, and exchanges 16-bit mono 16 kHz clips using a simple framed protocol:

ByteTypePayload
H (0x48)startuint32 LE total audio byte count
D (0x44)dataraw int16 LE samples
E (0x45)end

Button (active-low on GP2):

ClicksAction
1Record a 3 s clip into RAM
2Play the last clip on the local speaker
3Stream the last clip to the Pico over BLE

When the Pico pushes a clip back, the ESP32 reassembles it into recBuf and plays through the amp. While idle and unlinked, the OLED shows a pair of eyes glancing left and right; a filled dot top-right means BLE is connected.

Download: esp32_audio_ble.ino

Full esp32_audio_ble.ino source
/*
 * record / send / clear  --  ESP32-C3 audio gadget that pairs with the Pico.
 *
 * Button (active-low on GP2):
 *   1 click  = record a 3 s clip into RAM
 *   2 clicks = play the last clip locally
 *   3 clicks = stream the last clip to the Pico over BLE
 *
 * The Pico pushes clips back to us: when one arrives we reassemble it into
 * recBuf and play it through the amp.
 *
 * While idle / waiting for the link, the OLED shows a pair of eyes glancing
 * left and right.
 *
 * BLE: stock ESP32 (Bluedroid) library, central role, modelled on the
 * motion-pen sketch -- a scan *callback* (onResult) finds "PICO2W" and we
 * connect from loop(). That pattern ignores BLEScan::start()'s return value,
 * so it builds the same on arduino-esp32 2.x and 3.x. The scan is fired in
 * its non-blocking (completion-callback) form so the eyes keep animating
 * while we hunt for the Pico.
 *
 * Nordic-UART service. Wire framing (same both directions):
 *   byte 0 = type  'H' (0x48) start -> + uint32 LE total audio-byte count
 *                  'D' (0x44) data  -> + raw int16 LE mono @ 16 kHz
 *                  'E' (0x45) end
 */

#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include "driver/i2s.h"

#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEScan.h>
#include <BLEClient.h>
#include <BLEAdvertisedDevice.h>

// ---------- Pins (ESP32-C3) ----------
#define I2S_BCLK   8
#define I2S_WS     9
#define I2S_DOUT  10
#define I2S_DIN   20
#define OLED_SDA   6
#define OLED_SCL   7
#define BTN_PIN    2     // button between GP2 and GND (active-low)

#define SAMPLE_RATE    16000
#define BITS           I2S_BITS_PER_SAMPLE_32BIT
#define BUF_SAMPLES    256
#define RECORD_SECONDS 3
#define TOTAL_SAMPLES  (SAMPLE_RATE * RECORD_SECONDS)

#define MULTI_CLICK_MS 350    // window to gather extra clicks
#define SCAN_SECS      6      // length of each (non-blocking) scan window

#define SCREEN_WIDTH  128
#define SCREEN_HEIGHT 64
#define OLED_ADDR     0x3C
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);

// ---------- BLE (Nordic UART) ----------
static BLEUUID NUS_SVC("6E400001-B5A3-F393-E0A9-E50E24DCCA9E");
static BLEUUID NUS_RX ("6E400002-B5A3-F393-E0A9-E50E24DCCA9E"); // we write
static BLEUUID NUS_TX ("6E400003-B5A3-F393-E0A9-E50E24DCCA9E"); // we get notified

BLEScan                  *scan       = nullptr;
BLEAddress               *targetAddr = nullptr;
BLEClient                *pClient    = nullptr;
BLERemoteCharacteristic  *rxChar     = nullptr;   // central -> peripheral
BLERemoteCharacteristic  *txChar     = nullptr;   // peripheral -> central

volatile bool bleConnected = false;
volatile bool doConnect    = false;
volatile bool scanning     = false;

// We request a 247-byte MTU; 180 audio bytes/frame stays well inside that.
// If writes ever fail (MTU stuck at the 23-byte default), drop this to ~18.
#define WRITE_AUDIO_CHUNK 180

// ---------- frame types ----------
#define T_START 0x48
#define T_DATA  0x44
#define T_END   0x45

// ---------- audio buffers ----------
int32_t  ioBuf[BUF_SAMPLES];
int16_t *recBuf = nullptr;
bool     hasRecording = false;
uint32_t playSamples  = 0;

// ---------- incoming-clip reassembly (filled in the BLE notify callback) ----------
volatile bool     rxActive    = false;
volatile uint32_t rxTotal     = 0;
volatile uint32_t rxPos       = 0;
volatile bool     playRequest = false;

// 'busy' guards recBuf while record/play touch it.
volatile bool busy = false;

// ---------- OLED idle state ----------
bool     oledCleared = false;     // set by double-click; suppresses the eyes
uint32_t lastEyeMs   = 0;

enum ClickType { CLICK_NONE, CLICK_SINGLE, CLICK_DOUBLE, CLICK_TRIPLE };

void startScan();
void cleanupAndRescan();


// =====================================================================
//  I2S
// =====================================================================
void i2sInit() {
  i2s_config_t cfg = {
    .mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_TX | I2S_MODE_RX),
    .sample_rate = SAMPLE_RATE,
    .bits_per_sample = BITS,
    .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,
    .communication_format = I2S_COMM_FORMAT_STAND_I2S,
    .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
    .dma_buf_count = 8,
    .dma_buf_len = 128,
    .use_apll = false,
    .tx_desc_auto_clear = true,
    .fixed_mclk = 0
  };
  i2s_pin_config_t pins = {
    .bck_io_num = I2S_BCLK,
    .ws_io_num  = I2S_WS,
    .data_out_num = I2S_DOUT,
    .data_in_num  = I2S_DIN
  };
  Serial.printf("i2s install: %s\n", esp_err_to_name(i2s_driver_install(I2S_NUM_0, &cfg, 0, NULL)));
  Serial.printf("i2s set_pin: %s\n", esp_err_to_name(i2s_set_pin(I2S_NUM_0, &pins)));
}


// =====================================================================
//  OLED
// =====================================================================
void showStatus(const char *line1, const char *line2, int bar) {
  oledCleared = false;
  display.clearDisplay();
  display.setTextSize(1);
  display.setCursor(0, 0);
  display.println(line1);
  if (line2) { display.setCursor(0, 12); display.println(line2); }
  if (bar >= 0) {
    if (bar > SCREEN_WIDTH) bar = SCREEN_WIDTH;
    display.drawRect(0, 30, SCREEN_WIDTH, 16, SSD1306_WHITE);
    display.fillRect(0, 30, bar, 16, SSD1306_WHITE);
  }
  display.display();
}

// One eye: white rounded "sclera" with a black pupil offset horizontally.
void drawEye(int cx, int cy, int off) {
  display.fillRoundRect(cx - 18, cy - 14, 36, 28, 8, SSD1306_WHITE);
  display.fillCircle(cx + off, cy, 7, SSD1306_BLACK);
}

// Idle animation: both pupils glide left<->right together.
void drawEyes() {
  if (millis() - lastEyeMs < 33) return;          // ~30 fps cap
  lastEyeMs = millis();

  float t = (millis() % 2400) / 2400.0f;          // 0..1 over 2.4 s
  int off = (int)(sinf(t * 2.0f * 3.14159265f) * 10.0f);   // -10..+10 px

  display.clearDisplay();
  drawEye(40, 32, off);
  drawEye(88, 32, off);
  // link indicator (top-right): filled = linked, outline = still waiting
  if (bleConnected) display.fillCircle(123, 5, 3, SSD1306_WHITE);
  else              display.drawCircle(123, 5, 3, SSD1306_WHITE);
  display.display();
}

void clearOled() {
  oledCleared = true;
  display.clearDisplay();
  display.display();
}


// =====================================================================
//  Button: debounce + multi-click resolver
// =====================================================================
bool rawPress() {
  static bool lastStable = HIGH;
  static bool lastRead = HIGH;
  static uint32_t lastChange = 0;
  bool reading = digitalRead(BTN_PIN);
  if (reading != lastRead) { lastRead = reading; lastChange = millis(); }
  if (millis() - lastChange > 30) {
    if (reading != lastStable) {
      lastStable = reading;
      if (lastStable == LOW) return true;
    }
  }
  return false;
}

ClickType getClick() {
  if (!rawPress()) return CLICK_NONE;
  int count = 1;
  uint32_t last = millis();
  while (millis() - last < MULTI_CLICK_MS) {
    if (rawPress()) {
      count++;
      last = millis();
      if (count >= 3) break;
    }
  }
  if (count >= 3) return CLICK_TRIPLE;
  if (count == 2) return CLICK_DOUBLE;
  return CLICK_SINGLE;
}


// =====================================================================
//  Record / Play
// =====================================================================
void recordClip() {
  busy = true;
  oledCleared = false;
  Serial.println("RECORDING...");
  int captured = 0;
  uint32_t lastUI = 0;
  while (captured < TOTAL_SAMPLES) {
    size_t bytesRead = 0;
    i2s_read(I2S_NUM_0, ioBuf, sizeof(ioBuf), &bytesRead, portMAX_DELAY);
    int n = bytesRead / sizeof(int32_t);
    uint32_t peak = 0;
    for (int i = 0; i < n && captured < TOTAL_SAMPLES; i++) {
      int32_t s = ioBuf[i] >> 11;
      if (s >  32767) s =  32767;
      if (s < -32768) s = -32768;
      recBuf[captured++] = (int16_t)s;
      uint32_t a = (s < 0) ? -s : s;
      if (a > peak) peak = a;
    }
    if (millis() - lastUI >= 120) {
      lastUI = millis();
      int bar = (int)((int64_t)captured * SCREEN_WIDTH / TOTAL_SAMPLES);
      char l2[24];
      snprintf(l2, sizeof(l2), "peak=%lu", (unsigned long)peak);
      showStatus("REC...", l2, bar);
    }
  }
  hasRecording = true;
  playSamples  = TOTAL_SAMPLES;
  busy = false;
  Serial.println("record done");
}

void playClip() {
  if (!hasRecording || playSamples == 0) {
    showStatus("Nothing to play", nullptr, -1);
    delay(800);
    return;
  }
  busy = true;
  oledCleared = false;
  Serial.println("PLAYBACK...");
  uint32_t played = 0;
  uint32_t lastUI = 0;
  while (played < playSamples) {
    int n = 0;
    while (n < BUF_SAMPLES && played < playSamples) {
      int32_t s = recBuf[played++];      // stored 16-bit sample
      s = s * 4;                         // playback gain (raise/lower this)
      if (s >  32767) s =  32767;
      if (s < -32768) s = -32768;
      ioBuf[n++] = s << 13;
    }
    size_t bytesWritten = 0;
    i2s_write(I2S_NUM_0, ioBuf, n * sizeof(int32_t), &bytesWritten, portMAX_DELAY);
    if (millis() - lastUI >= 120) {
      lastUI = millis();
      int bar = (int)((int64_t)played * SCREEN_WIDTH / playSamples);
      showStatus("PLAY...", nullptr, bar);
    }
  }
  busy = false;
  Serial.println("playback done");
}


// =====================================================================
//  BLE: incoming notifications  (Pico -> ESP32 : a clip to play)
// =====================================================================
void onNotify(BLERemoteCharacteristic *c, uint8_t *data, size_t len, bool isNotify) {
  if (len < 1) return;
  uint8_t type = data[0];

  if (type == T_START) {
    if (busy) return;                    // don't stomp recBuf mid record/play
    uint32_t total = 0;
    if (len >= 5) {
      total = (uint32_t)data[1] | ((uint32_t)data[2] << 8) |
              ((uint32_t)data[3] << 16) | ((uint32_t)data[4] << 24);
    }
    uint32_t cap = (uint32_t)TOTAL_SAMPLES * sizeof(int16_t);
    if (total == 0 || total > cap) { rxActive = false; return; }
    rxTotal  = total;
    rxPos    = 0;
    rxActive = true;
  }
  else if (type == T_DATA) {
    if (!rxActive) return;
    uint32_t n = len - 1;
    if (rxPos + n > rxTotal) n = rxTotal - rxPos;
    memcpy((uint8_t *)recBuf + rxPos, data + 1, n);
    rxPos += n;
  }
  else if (type == T_END) {
    if (!rxActive) return;
    rxActive     = false;
    playSamples  = rxPos / sizeof(int16_t);
    hasRecording = (playSamples > 0);
    playRequest  = true;                 // loop() will play it
  }
}


// =====================================================================
//  BLE: outgoing  (ESP32 -> Pico : send the current clip)
// =====================================================================
void sendClipToPico() {
  oledCleared = false;
  if (!bleConnected || rxChar == nullptr) {
    showStatus("No BLE link", "can't send", -1);
    delay(800);
    return;
  }
  if (!hasRecording || playSamples == 0) {
    showStatus("Nothing to send", "record first", -1);
    delay(800);
    return;
  }
  busy = true;

  uint32_t total = playSamples * sizeof(int16_t);
  int audio = WRITE_AUDIO_CHUNK;

  uint8_t hdr[5] = { T_START,
                     (uint8_t)(total & 0xff), (uint8_t)((total >> 8) & 0xff),
                     (uint8_t)((total >> 16) & 0xff), (uint8_t)((total >> 24) & 0xff) };
  rxChar->writeValue(hdr, 5, true);      // write-with-response = flow control

  static uint8_t frame[1 + WRITE_AUDIO_CHUNK];
  uint8_t *src = (uint8_t *)recBuf;
  uint32_t sent = 0;
  uint32_t lastUI = 0;
  while (sent < total) {
    uint32_t n = total - sent;
    if (n > (uint32_t)audio) n = audio;
    frame[0] = T_DATA;
    memcpy(frame + 1, src + sent, n);
    rxChar->writeValue(frame, n + 1, true);
    sent += n;
    if (millis() - lastUI >= 120) {
      lastUI = millis();
      int bar = (int)((int64_t)sent * SCREEN_WIDTH / total);
      showStatus("SENDING...", nullptr, bar);
    }
  }

  uint8_t endf = T_END;
  rxChar->writeValue(&endf, 1, true);

  busy = false;
  showStatus("Sent to Pico", nullptr, -1);
  delay(600);
}


// =====================================================================
//  BLE: scan + connect  (pen-style: onResult callback, connect in loop)
// =====================================================================
class ScanCB : public BLEAdvertisedDeviceCallbacks {
  void onResult(BLEAdvertisedDevice device) {
    if (device.haveName() && device.getName() == "PICO2W") {
      Serial.println("found PICO2W");
      if (targetAddr != nullptr) delete targetAddr;
      targetAddr = new BLEAddress(device.getAddress());
      scanning  = false;
      doConnect = true;
      scan->stop();
    }
  }
};

class ClientCB : public BLEClientCallbacks {
  void onConnect(BLEClient *c)    override { bleConnected = true; }
  void onDisconnect(BLEClient *c) override { bleConnected = false; rxActive = false; }
};
ClientCB clientCB;

// Fired when a scan window ends without finding the Pico; loop() re-arms.
// NOTE (arduino-esp32 3.x): if the compiler complains about this argument
// type, change "BLEScanResults" to "BLEScanResults*" -- the body ignores it.
void scanComplete(BLEScanResults results) {
  scanning = false;
}

void startScan() {
  if (scanning || bleConnected || doConnect) return;
  scanning = true;
  scan->clearResults();
  scan->start(SCAN_SECS, scanComplete, false);    // non-blocking
}

void cleanupAndRescan() {
  if (pClient != nullptr) {
    if (pClient->isConnected()) pClient->disconnect();
    pClient = nullptr;                   // Bluedroid frees the client itself
  }
  rxChar = nullptr;
  txChar = nullptr;
  bleConnected = false;
  doConnect    = false;
  if (targetAddr != nullptr) { delete targetAddr; targetAddr = nullptr; }
  scanning = false;                      // loop() will re-arm the scan
  Serial.println("rescanning");
}

void doConnectNow() {
  doConnect = false;
  Serial.println("connecting...");
  pClient = BLEDevice::createClient();
  pClient->setClientCallbacks(&clientCB);   // 3.x: single-arg signature
  if (!pClient->connect(*targetAddr)) {
    Serial.println("connect failed");
    cleanupAndRescan();
    return;
  }
  pClient->setMTU(247);

  BLERemoteService *svc = pClient->getService(NUS_SVC);
  if (svc == nullptr) { Serial.println("no service"); cleanupAndRescan(); return; }
  rxChar = svc->getCharacteristic(NUS_RX);
  txChar = svc->getCharacteristic(NUS_TX);
  if (rxChar == nullptr || txChar == nullptr) {
    Serial.println("no char"); cleanupAndRescan(); return;
  }
  if (txChar->canNotify()) txChar->registerForNotify(onNotify);

  bleConnected = true;
  oledCleared  = false;
  Serial.println("BLE linked");
}


// =====================================================================
//  setup / loop
// =====================================================================
void setup() {
  Serial.begin(115200);
  delay(300);
  Serial.println("\n=== record(1) / play(2) / send(3) ===");

  pinMode(BTN_PIN, INPUT_PULLUP);

  Wire.begin(OLED_SDA, OLED_SCL);
  if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR))
    Serial.println("!! OLED not found");

  size_t bytes = (size_t)TOTAL_SAMPLES * sizeof(int16_t);
  recBuf = (int16_t *)malloc(bytes);
  Serial.printf("recBuf alloc %u bytes (%ds): %s\n",
                (unsigned)bytes, RECORD_SECONDS, recBuf ? "OK" : "FAILED");
  if (!recBuf) {
    showStatus("ALLOC FAILED", "lower RECORD_SECONDS", -1);
    while (true) delay(1000);
  }

  i2sInit();

  BLEDevice::init("esp-audio");
  scan = BLEDevice::getScan();
  scan->setAdvertisedDeviceCallbacks(new ScanCB());
  scan->setActiveScan(true);
  startScan();

  Serial.println("Ready.");
}

void loop() {
  // A clip the Pico pushed to us -> play it through the amp.
  if (playRequest) {
    playRequest = false;
    playClip();
  }

  // Connect once the scan callback has flagged a target.
  if (doConnect && targetAddr != nullptr) {
    doConnectNow();
  }

  // Drop detection -> rescan.
  if (bleConnected && pClient != nullptr && !pClient->isConnected()) {
    Serial.println("link lost");
    cleanupAndRescan();
  }

  // Keep looking while unlinked.
  if (!bleConnected && !doConnect) {
    startScan();
  }

  // Button.
  ClickType c = getClick();
  if (c == CLICK_SINGLE) {
    delay(150);
    recordClip();
  } else if (c == CLICK_DOUBLE) {
    playClip();                  // play the last clip locally
  } else if (c == CLICK_TRIPLE) {
    sendClipToPico();
  }

  // Idle visual: eyes glancing around while we wait (unless cleared/busy).
  if (!busy && !oledCleared) {
    drawEyes();
  }
}

MicroPython sources for every on-device app — firmware section below. Main browser UI: Pockety main page.

Firmware & apps

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.

Encoder demo (e-ink menu navigation and partial refresh): Final product tests on the main page →.

System diagram

Firmware system diagram — framebuf, EPD driver, SPI/I2C/GPIO buses, and HTTP canvas path

(Same story as ../sysdiag.jpg on the main page: 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()