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

Xtenik X4

Early sketches
Sketch v 1.0.0

Sketch v 2.0.0 → next step

Physical sketch

Projected timeline
| Weeks | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Preparation and Research | X | |||||||||||||||||
| Prototyping – Design | X | |||||||||||||||||
| Prototyping – Interface | X | X | ||||||||||||||||
| Prototyping – Electronics | X | |||||||||||||||||
| Implementation – PCB | X | X | X | X | ||||||||||||||
| Implementation – CAD | X | X | X | |||||||||||||||
| Production | X | X | X | |||||||||||||||
| Optimization | X | X | ||||||||||||||||
| Finalization & Completion | X | |||||||||||||||||
| Documentation | X | X | ||||||||||||||||
| Presentation & Evaluation | X | X |
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.
| Exterior | Interior |
|---|---|
![]() | ![]() |
April 2026 — Version 2 iterations
Slightly improved, still unaesthetic and too big. Blue vs yellow top/body prints explored encoder shaft height and clearance.



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.

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.

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.

| KiCad layout | Milled copper |
|---|---|
![]() | ![]() |
ChordBoard submodule — PCB & assembly

Soldering at the JBC station (320 °C)
Mechanical assembly
Yellow-enclosure integration — wiring, cable guides, and test fits before the white final shell.




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.
| Step | What happens |
|---|---|
| 1. Pour silicone | Ecoflex into the rigid master — rectangular window, round encoder pocket, and pad wells. Masking tape dams overflow on the green mold block. |
| 2. Silicone mold | Cured flexible negative (teal) ready on the bench; raised islands will become holes and button cups in the part. |
| 3. Cast the top | Resin (orange) poured into that silicone mold; green sidewalls box the cavity for a clean demold. |
Step 1 — pour ecoflex into the Feyncorder master mold

Step 2 — cured silicone mold (negative)

Step 3 — resin cast into the 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 splash | ChordBoard brand |
|---|---|
![]() | ![]() |
Vinyl labels
Roland vinyl cutter — cutting sticker outlines


Accessory storage CAD

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.


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 & flow</div>
<div class="row">
<button data-gen="scribble">Scribble</button>
<button data-gen="sinewave">Sine</button>
<button data-gen="interference">Ripples</button>
<button data-gen="rose">Rose</button>
<button data-gen="lissajous">Lissajous</button>
<button data-gen="flow">Flow field</button>
</div>
</div>
<div class="group">
<div class="label">Symmetry & tiling</div>
<div class="row">
<button data-gen="truchet">Truchet</button>
<button data-gen="kaleido">Kaleido</button>
<button data-gen="hex">Hex grid</button>
<button data-gen="herringbone">Herring</button>
<button data-gen="weave">Weave</button>
<button data-gen="moire">Moir\u00e9</button>
</div>
</div>
<div class="group">
<div class="label">Fractals</div>
<div class="row">
<button data-gen="sierpinski">Sierpinski</button>
<button data-gen="carpet">Carpet</button>
<button data-gen="dragon">Dragon</button>
<button data-gen="koch">Koch</button>
<button data-gen="hilbert">Hilbert</button>
<button data-gen="mandelbrot">Mandel</button>
</div>
</div>
<div class="group">
<div class="label">Geometric</div>
<div class="row">
<button data-gen="grad">Gradient</button>
<button data-gen="circles">Circles</button>
<button data-gen="sunburst">Sunburst</button>
<button data-gen="parabolic">Stitching</button>
<button data-gen="mystic">Mystic rose</button>
<button data-gen="spirograph">Spiro</button>
</div>
</div>
<div class="group">
<div class="label">Cellular & random</div>
<div class="row">
<button data-gen="stars">Stars</button>
<button data-gen="noise">Noise</button>
<button data-gen="maze">Maze</button>
<button data-gen="life">Life</button>
<button data-gen="voronoi">Voronoi</button>
<button data-gen="dla">DLA tree</button>
</div>
</div>
<div class="group">
<div class="label">Canvas</div>
<div class="row">
<button id="btn-undo">Undo</button>
<button id="btn-clear" class="danger">Clear</button>
<button id="btn-invert">Invert</button>
<button id="btn-png">Save PNG</button>
</div>
</div>
<div class="group">
<div class="label">Send to e-ink</div>
<div class="row">
<button id="btn-send" class="send">Send (partial)</button>
<button id="btn-sendfull">Send (full)</button>
</div>
<div class="row">
<button id="btn-wipe" class="danger">Blank screen</button>
</div>
</div>
</aside>
</main>
<footer>
<span>Pico W \\u00b7 250\\u00d7122 \\u00b7 1bpp</span>
<span><kbd>B</kbd> brush \\u00b7 <kbd>E</kbd> eraser \\u00b7 <kbd>X</kbd> swap \\u00b7 <kbd>Ctrl+Z</kbd> undo \\u00b7 <kbd>Enter</kbd> send</span>
</footer>
<script>
window.addEventListener('error', e => {
const d = document.getElementById('dbg');
if (d) d.textContent = 'JS ERROR: ' + e.message + ' @ ' + e.lineno;
});
(function(){
const W = 250, H = 122;
const view = document.getElementById('view');
const dbg = document.getElementById('dbg');
const setDbg = m => { if(dbg) dbg.textContent = m; };
if(!view){ setDbg('FATAL: canvas element missing'); return; }
const ctx = view.getContext('2d');
if(!ctx){ setDbg('FATAL: 2D context unavailable'); return; }
// pixel buffer: 1 byte per pixel, 0=black 1=white
let pixels = new Uint8Array(W*H);
pixels.fill(1);
// --- repaint via ImageData ---
const imgd = ctx.createImageData(W, H);
function repaint(){
const d = imgd.data;
for(let i=0;i<pixels.length;i++){
const v = pixels[i] ? 255 : 0;
const j = i*4;
d[j]=v; d[j+1]=v; d[j+2]=v; d[j+3]=255;
}
ctx.putImageData(imgd, 0, 0);
}
repaint();
setDbg('ready - tap or drag the canvas');
// --- drawing ops in pixel space ---
function setPx(x,y,ink){
if(x<0||y<0||x>=W||y>=H) return;
pixels[y*W + x] = ink;
}
function stamp(cx,cy,r,ink){
cx|=0; cy|=0;
if(r<=1){ setPx(cx,cy,ink); return; }
const h = Math.max(1, r/2)|0;
const r2 = h*h;
for(let y=Math.max(0,cy-h); y<=Math.min(H-1,cy+h); y++){
for(let x=Math.max(0,cx-h); x<=Math.min(W-1,cx+h); x++){
const dx=x-cx, dy=y-cy;
if(dx*dx+dy*dy <= r2) pixels[y*W+x]=ink;
}
}
}
function drawLine(x0,y0,x1,y1,r,ink){
x0|=0; y0|=0; x1|=0; y1|=0;
const dx=Math.abs(x1-x0), sx=x0<x1?1:-1;
const dy=-Math.abs(y1-y0), sy=y0<y1?1:-1;
let err=dx+dy, guard=0;
while(guard++<10000){
stamp(x0,y0,r,ink);
if(x0===x1 && y0===y1) break;
const e2=2*err;
if(e2>=dy){ err+=dy; x0+=sx; }
if(e2<=dx){ err+=dx; y0+=sy; }
}
}
function drawRect(x0,y0,x1,y1,r,ink){
drawLine(x0,y0,x1,y0,r,ink); drawLine(x1,y0,x1,y1,r,ink);
drawLine(x1,y1,x0,y1,r,ink); drawLine(x0,y1,x0,y0,r,ink);
}
function drawCircle(cx,cy,rad,r,ink){
if(rad<1){ stamp(cx,cy,r,ink); return; }
let x=rad,y=0,err=0,guard=0;
while(x>=y && guard++<5000){
stamp(cx+x,cy+y,r,ink); stamp(cx+y,cy+x,r,ink);
stamp(cx-y,cy+x,r,ink); stamp(cx-x,cy+y,r,ink);
stamp(cx-x,cy-y,r,ink); stamp(cx-y,cy-x,r,ink);
stamp(cx+y,cy-x,r,ink); stamp(cx+x,cy-y,r,ink);
y++; err+=1+2*y;
if(2*(err-x)+1>0){ x--; err+=1-2*x; }
}
}
function floodFill(sx,sy,ink){
const target=pixels[sy*W+sx];
if(target===undefined || target===ink) return;
const stack=[sx,sy];
while(stack.length){
const y=stack.pop(), x=stack.pop();
if(x<0||y<0||x>=W||y>=H) continue;
if(pixels[y*W+x]!==target) continue;
pixels[y*W+x]=ink;
stack.push(x+1,y, x-1,y, x,y+1, x,y-1);
}
}
// --- state ---
let tool='brush', ink=0, size=3;
let drawing=false, last=null, startPt=null, snap=null;
const undoStack=[];
function pushUndo(){
undoStack.push(new Uint8Array(pixels));
if(undoStack.length>20) undoStack.shift();
}
// --- pointer math: get pixel coords from any event ---
function getXY(clientX, clientY){
const r = view.getBoundingClientRect();
let x = ((clientX - r.left) / r.width) * W;
let y = ((clientY - r.top) / r.height) * H;
x = Math.max(0, Math.min(W-1, x|0));
y = Math.max(0, Math.min(H-1, y|0));
return [x,y];
}
function startStroke(clientX, clientY){
pushUndo();
drawing = true;
const [x,y] = getXY(clientX, clientY);
startPt = [x,y]; last = [x,y];
setDbg('down @ '+x+','+y+' tool='+tool);
if(tool==='brush' || tool==='eraser'){
stamp(x, y, size, tool==='eraser'?1:ink);
repaint();
} else if(tool==='fill'){
floodFill(x, y, ink);
repaint();
drawing = false;
} else {
snap = new Uint8Array(pixels);
}
}
function moveStroke(clientX, clientY){
if(!drawing) return;
const [x,y] = getXY(clientX, clientY);
if(tool==='brush' || tool==='eraser'){
drawLine(last[0], last[1], x, y, size, tool==='eraser'?1:ink);
last = [x,y];
repaint();
setDbg('draw @ '+x+','+y);
} else if(tool==='line' || tool==='rect' || tool==='circle'){
pixels.set(snap);
if(tool==='line') drawLine(startPt[0],startPt[1],x,y,size,ink);
else if(tool==='rect') drawRect(startPt[0],startPt[1],x,y,size,ink);
else {
const dx=x-startPt[0], dy=y-startPt[1];
drawCircle(startPt[0], startPt[1], Math.round(Math.sqrt(dx*dx+dy*dy)), size, ink);
}
repaint();
}
}
function endStroke(){
if(!drawing) return;
drawing=false; last=null; startPt=null; snap=null;
setDbg('stroke ended');
}
// --- attach pointer events with both pointer + mouse + touch fallbacks ---
if('PointerEvent' in window){
view.addEventListener('pointerdown', e => {
e.preventDefault();
try{ view.setPointerCapture(e.pointerId); }catch(_){}
startStroke(e.clientX, e.clientY);
});
view.addEventListener('pointermove', e => {
if(drawing){ e.preventDefault(); moveStroke(e.clientX, e.clientY); }
});
window.addEventListener('pointerup', endStroke);
window.addEventListener('pointercancel', endStroke);
setDbg('using PointerEvent');
} else {
view.addEventListener('mousedown', e => { e.preventDefault(); startStroke(e.clientX, e.clientY); });
window.addEventListener('mousemove', e => moveStroke(e.clientX, e.clientY));
window.addEventListener('mouseup', endStroke);
view.addEventListener('touchstart', e => {
if(!e.touches[0]) return;
e.preventDefault();
startStroke(e.touches[0].clientX, e.touches[0].clientY);
}, {passive:false});
view.addEventListener('touchmove', e => {
if(!e.touches[0]) return;
e.preventDefault();
moveStroke(e.touches[0].clientX, e.touches[0].clientY);
}, {passive:false});
view.addEventListener('touchend', endStroke);
view.addEventListener('touchcancel', endStroke);
setDbg('using mouse+touch fallback');
}
// --- toolbar ---
document.getElementById('tools').addEventListener('click', e => {
const b = e.target.closest('button'); if(!b) return;
tool = b.dataset.tool;
document.querySelectorAll('#tools button').forEach(x => x.classList.toggle('on', x===b));
setDbg('tool: '+tool);
});
document.getElementById('swatches').addEventListener('click', e => {
const s = e.target.closest('.sw'); if(!s) return;
ink = parseInt(s.dataset.ink, 10);
document.querySelectorAll('#swatches .sw').forEach(x => x.classList.toggle('on', x===s));
setDbg('ink: '+(ink?'white':'black'));
});
const sizeIn = document.getElementById('size');
const sizeOut = document.getElementById('sizeOut');
size = +sizeIn.value || 3;
sizeOut.textContent = size;
sizeIn.addEventListener('input', () => { size = +sizeIn.value; sizeOut.textContent = size; });
document.getElementById('btn-clear').addEventListener('click', () => { pushUndo(); pixels.fill(1); repaint(); });
document.getElementById('btn-invert').addEventListener('click', () => {
pushUndo(); for(let i=0;i<pixels.length;i++) pixels[i]^=1; repaint();
});
document.getElementById('btn-undo').addEventListener('click', () => {
if(!undoStack.length) return; pixels = undoStack.pop(); repaint();
});
document.getElementById('btn-png').addEventListener('click', () => {
const off = document.createElement('canvas'); off.width=W*4; off.height=H*4;
const oc = off.getContext('2d'); oc.imageSmoothingEnabled=false;
oc.drawImage(view, 0, 0, off.width, off.height);
off.toBlob(b => {
const a = document.createElement('a');
a.href = URL.createObjectURL(b);
a.download = 'pico-canvas.png'; a.click();
});
});
// --- generators ---
// --- generators (math / art patterns) ---
// Helpers used by several gens
function plotBresenham(x0,y0,x1,y1,ink){
drawLine(x0|0,y0|0,x1|0,y1|0,1,ink);
}
function fillRectPx(x0,y0,x1,y1,ink){
if(x0>x1){ const t=x0;x0=x1;x1=t; }
if(y0>y1){ const t=y0;y0=y1;y1=t; }
x0=Math.max(0,x0|0); y0=Math.max(0,y0|0);
x1=Math.min(W-1,x1|0); y1=Math.min(H-1,y1|0);
for(let y=y0;y<=y1;y++) for(let x=x0;x<=x1;x++) pixels[y*W+x]=ink;
}
const gens = {
scribble(){
pixels.fill(1); let x=W/2, y=H/2;
for(let i=0;i<600;i++){
const nx=x+(Math.random()-.5)*14, ny=y+(Math.random()-.5)*14;
drawLine(x|0, y|0, nx|0, ny|0, 1, 0);
x=Math.max(2,Math.min(W-3,nx)); y=Math.max(2,Math.min(H-3,ny));
}
},
sinewave(){
pixels.fill(1);
const layers = 5;
for(let L=0; L<layers; L++){
const amp = 8 + L*4;
const freq = 0.05 + L*0.015;
const phase = L*0.7;
const yc = H/2 + (L-layers/2)*4;
let py = (yc + Math.sin(phase)*amp)|0;
for(let x=0; x<W; x++){
const y = (yc + Math.sin(x*freq + phase)*amp)|0;
drawLine(x-1, py, x, y, 1, 0);
py = y;
}
}
},
interference(){
// Two wave sources; threshold the sum to make moire/ripple bands
pixels.fill(1);
const s1x=W*0.30, s1y=H*0.5;
const s2x=W*0.70, s2y=H*0.5;
const k = 0.55; // wave number
for(let y=0;y<H;y++){
for(let x=0;x<W;x++){
const d1 = Math.sqrt((x-s1x)**2+(y-s1y)**2);
const d2 = Math.sqrt((x-s2x)**2+(y-s2y)**2);
const v = Math.cos(d1*k) + Math.cos(d2*k);
pixels[y*W+x] = v>0 ? 0 : 1;
}
}
},
rose(){
// Rose curve r = a*cos(k*theta), k = n/d
pixels.fill(1);
const cx=W/2, cy=H/2;
const a = Math.min(W,H)*0.45;
const n = 5, d = 1; // 5-petal rose; tweak for variety
const steps = 4000;
let px=null, py=null;
for(let i=0; i<=steps; i++){
const th = (i/steps) * Math.PI * 2 * d;
const r = a * Math.cos(n/d * th);
const x = (cx + r*Math.cos(th))|0;
const y = (cy + r*Math.sin(th))|0;
if(px!==null) drawLine(px,py,x,y,1,0);
px=x; py=y;
}
},
lissajous(){
pixels.fill(1);
const cx=W/2, cy=H/2;
const A=W*0.46, B=H*0.46;
const a=3, b=2, delta=Math.PI/2;
const steps=3000;
let px=null, py=null;
for(let i=0;i<=steps;i++){
const t = (i/steps)*Math.PI*2;
const x = (cx + A*Math.sin(a*t + delta))|0;
const y = (cy + B*Math.sin(b*t))|0;
if(px!==null) drawLine(px,py,x,y,1,0);
px=x; py=y;
}
},
flow(){
// Pseudo-Perlin flow field via cheap value-noise hash
pixels.fill(1);
function hash(x,y){
let h = (x*374761393 + y*668265263) | 0;
h = (h ^ (h>>>13)) * 1274126177 | 0;
return ((h ^ (h>>>16)) >>> 0) / 4294967295;
}
function noise(x,y){
const xi=Math.floor(x), yi=Math.floor(y);
const xf=x-xi, yf=y-yi;
const u=xf*xf*(3-2*xf), v=yf*yf*(3-2*yf);
const n00=hash(xi,yi), n10=hash(xi+1,yi);
const n01=hash(xi,yi+1), n11=hash(xi+1,yi+1);
return (n00*(1-u)+n10*u)*(1-v) + (n01*(1-u)+n11*u)*v;
}
const seeds = 80, steps = 60;
for(let s=0; s<seeds; s++){
let x = Math.random()*W, y = Math.random()*H;
for(let i=0;i<steps;i++){
const ang = noise(x*0.04, y*0.04) * Math.PI * 4;
const nx = x + Math.cos(ang)*1.4;
const ny = y + Math.sin(ang)*1.4;
drawLine(x|0,y|0,nx|0,ny|0,1,0);
x=nx; y=ny;
if(x<0||x>=W||y<0||y>=H) break;
}
}
},
truchet(){
pixels.fill(1); const t=12;
for(let y=0;y<H;y+=t) for(let x=0;x<W;x+=t){
if(Math.random()<.5) drawLine(x,y,x+t,y+t,1,0);
else drawLine(x+t,y,x,y+t,1,0);
}
},
kaleido(){
// Draw random strokes in one wedge, then mirror across N-fold symmetry
pixels.fill(1);
const cx=W/2, cy=H/2;
const N = 6;
const segs = 30;
const pts = [];
for(let i=0;i<segs;i++){
const r1 = Math.random()*Math.min(W,H)*0.45;
const r2 = Math.random()*Math.min(W,H)*0.45;
const a1 = Math.random()*Math.PI*2/N;
const a2 = a1 + (Math.random()-.5)*0.6;
pts.push([r1,a1,r2,a2]);
}
for(let k=0;k<N;k++){
const base = k*Math.PI*2/N;
for(const [r1,a1,r2,a2] of pts){
const x1=cx+Math.cos(base+a1)*r1, y1=cy+Math.sin(base+a1)*r1;
const x2=cx+Math.cos(base+a2)*r2, y2=cy+Math.sin(base+a2)*r2;
drawLine(x1|0,y1|0,x2|0,y2|0,1,0);
// mirror within wedge
const x1m=cx+Math.cos(base-a1)*r1, y1m=cy+Math.sin(base-a1)*r1;
const x2m=cx+Math.cos(base-a2)*r2, y2m=cy+Math.sin(base-a2)*r2;
drawLine(x1m|0,y1m|0,x2m|0,y2m|0,1,0);
}
}
},
hex(){
pixels.fill(1);
const r = 8; // hex circumradius
const dx = r*Math.sqrt(3);
const dy = r*1.5;
for(let row=-1; row*dy<H+r; row++){
for(let col=-1; col*dx<W+r; col++){
const cx = col*dx + (row&1?dx/2:0);
const cy = row*dy;
// hex outline
let px=null, py=null;
for(let k=0;k<=6;k++){
const a = Math.PI/3*k - Math.PI/2;
const x = (cx+Math.cos(a)*r)|0;
const y = (cy+Math.sin(a)*r)|0;
if(px!==null) drawLine(px,py,x,y,1,0);
px=x; py=y;
}
}
}
},
herringbone(){
pixels.fill(1);
const bw=18, bh=6;
for(let y=0;y<H+bh;y+=bh){
for(let x=-bw;x<W;x+=bw){
const ox = ((y/bh)|0) % 2 === 0 ? 0 : bw/2;
// diagonal brick
drawLine(x+ox, y, x+ox+bw, y+bh, 1, 0);
}
}
},
weave(){
pixels.fill(1);
const t=6;
// horizontal stripes
for(let y=0;y<H;y+=t*2){
for(let x=0;x<W;x+=t*2){
fillRectPx(x, y, x+t-1, y+t-1, 0);
fillRectPx(x+t, y+t, x+2*t-1, y+2*t-1, 0);
}
}
},
moire(){
// Two rotated line gratings
pixels.fill(1);
const a1 = 0.1, a2 = -0.13;
const sp = 4;
for(let y=0;y<H;y++){
for(let x=0;x<W;x++){
const u = x*Math.cos(a1)+y*Math.sin(a1);
const v = x*Math.cos(a2)+y*Math.sin(a2);
const on = (Math.floor(u/sp)&1) ^ (Math.floor(v/sp)&1);
if(on) pixels[y*W+x]=0;
}
}
},
sierpinski(){
pixels.fill(1);
const ax=W/2, ay=4;
const bx=4, by=H-4;
const cx=W-4, cy=H-4;
let px=W/2, py=H/2;
const verts=[[ax,ay],[bx,by],[cx,cy]];
// Chaos game
for(let i=0;i<8000;i++){
const v = verts[(Math.random()*3)|0];
px = (px+v[0])/2;
py = (py+v[1])/2;
if(i>20) setPx(px|0, py|0, 0);
}
},
carpet(){
// Sierpinski carpet by recursive subdivision
pixels.fill(0); // start black, carve white holes
function carve(x,y,w,h,depth){
if(depth===0 || w<3 || h<3) return;
const w3=w/3, h3=h/3;
fillRectPx(x+w3, y+h3, x+2*w3-1, y+2*h3-1, 1);
for(let iy=0;iy<3;iy++) for(let ix=0;ix<3;ix++){
if(ix===1 && iy===1) continue;
carve(x+ix*w3, y+iy*h3, w3, h3, depth-1);
}
}
carve(0,0,W,H,4);
},
dragon(){
pixels.fill(1);
let path = [1];
for(let i=0;i<11;i++){
const rev = [];
for(let j=path.length-1;j>=0;j--) rev.push(path[j]^1);
path = path.concat([1]).concat(rev);
}
// walk
let x=W*0.35, y=H*0.55, dir=0;
const step=2;
for(const turn of path){
const nx = x + Math.cos(dir)*step;
const ny = y + Math.sin(dir)*step;
drawLine(x|0,y|0,nx|0,ny|0,1,0);
x=nx; y=ny;
dir += turn ? Math.PI/2 : -Math.PI/2;
}
},
koch(){
pixels.fill(1);
// Koch snowflake from equilateral triangle, 4 iterations
function koch(p1,p2,depth){
if(depth===0){
drawLine(p1[0]|0,p1[1]|0,p2[0]|0,p2[1]|0,1,0);
return;
}
const dx=(p2[0]-p1[0])/3, dy=(p2[1]-p1[1])/3;
const a=[p1[0]+dx, p1[1]+dy];
const b=[p1[0]+2*dx, p1[1]+2*dy];
const ang = Math.atan2(dy,dx) - Math.PI/3;
const len = Math.sqrt(dx*dx+dy*dy);
const peak=[a[0]+Math.cos(ang)*len, a[1]+Math.sin(ang)*len];
koch(p1,a,depth-1); koch(a,peak,depth-1);
koch(peak,b,depth-1); koch(b,p2,depth-1);
}
const cx=W/2, cy=H/2;
const r=Math.min(W,H)*0.42;
const v=[];
for(let i=0;i<3;i++){
const a = -Math.PI/2 + i*Math.PI*2/3;
v.push([cx+Math.cos(a)*r, cy+Math.sin(a)*r]);
}
koch(v[0],v[1],4); koch(v[1],v[2],4); koch(v[2],v[0],4);
},
hilbert(){
pixels.fill(1);
// Iterative Hilbert curve fitting in min(W,H) square
const order = 5; // 2^5 = 32 cells per side
const N = 1<<order;
const cell = Math.min(W,H)/N;
const ox = (W-N*cell)/2, oy = (H-N*cell)/2;
function d2xy(d){
let x=0,y=0,t=d;
for(let s=1;s<N;s<<=1){
const rx = 1 & (t>>1);
const ry = 1 & (t ^ rx);
if(ry===0){
if(rx===1){ x=s-1-x; y=s-1-y; }
const tmp=x; x=y; y=tmp;
}
x += s*rx; y += s*ry;
t >>= 2;
}
return [x,y];
}
let prev=null;
for(let i=0;i<N*N;i++){
const [gx,gy]=d2xy(i);
const x = (ox + gx*cell + cell/2)|0;
const y = (oy + gy*cell + cell/2)|0;
if(prev) drawLine(prev[0],prev[1],x,y,1,0);
prev=[x,y];
}
},
mandelbrot(){
pixels.fill(1);
const xmin=-2.1, xmax=0.7, ymin=-1.0, ymax=1.0;
const maxIter=24;
for(let py=0;py<H;py++){
const cy = ymin + (ymax-ymin)*py/H;
for(let px=0;px<W;px++){
const cx = xmin + (xmax-xmin)*px/W;
let zx=0, zy=0, i=0;
while(i<maxIter && zx*zx+zy*zy<4){
const t = zx*zx-zy*zy+cx;
zy = 2*zx*zy+cy;
zx = t;
i++;
}
// Threshold dither: high-iter -> dark, low-iter -> light
const v = i/maxIter;
const bayer = ((px+py*7)*0.137)%1;
pixels[py*W+px] = v < 0.95 && v*1.1 > bayer ? 0 : 1;
if(i===maxIter) pixels[py*W+px]=0;
}
}
},
grad(){
const bayer=[
[0,32,8,40,2,34,10,42],[48,16,56,24,50,18,58,26],
[12,44,4,36,14,46,6,38],[60,28,52,20,62,30,54,22],
[3,35,11,43,1,33,9,41],[51,19,59,27,49,17,57,25],
[15,47,7,39,13,45,5,37],[63,31,55,23,61,29,53,21]];
for(let y=0;y<H;y++) for(let x=0;x<W;x++){
pixels[y*W+x] = ((x/(W-1))*64 > bayer[y&7][x&7]) ? 1 : 0;
}
},
circles(){
pixels.fill(1);
const cx=W/2, cy=H/2;
for(let r=4; r<Math.max(W,H); r+=6){
drawCircle(cx,cy,r,1,0);
}
},
sunburst(){
pixels.fill(1);
const cx=W/2, cy=H/2;
const rays = 64;
const R = Math.max(W,H);
for(let i=0;i<rays;i++){
const a = i*Math.PI*2/rays;
const x = cx+Math.cos(a)*R;
const y = cy+Math.sin(a)*R;
drawLine(cx|0,cy|0,x|0,y|0,1,0);
}
},
parabolic(){
// Curve stitching: connect points across two perpendicular axes
pixels.fill(1);
const N = 24;
const x0=4, y0=4, x1=W-4, y1=H-4;
// top-left corner
for(let i=0;i<=N;i++){
const t = i/N;
drawLine(x0+(x1-x0)*t|0, y0|0, x0|0, y0+(y1-y0)*t|0, 1, 0);
}
// bottom-right corner
for(let i=0;i<=N;i++){
const t = i/N;
drawLine(x1-(x1-x0)*t|0, y1|0, x1|0, y1-(y1-y0)*t|0, 1, 0);
}
},
mystic(){
// Mystic rose: N points on a circle, every chord drawn
pixels.fill(1);
const cx=W/2, cy=H/2;
const r = Math.min(W,H)*0.45;
const N = 18;
const pts = [];
for(let i=0;i<N;i++){
const a = i*Math.PI*2/N - Math.PI/2;
pts.push([cx+Math.cos(a)*r, cy+Math.sin(a)*r]);
}
for(let i=0;i<N;i++) for(let j=i+1;j<N;j++){
drawLine(pts[i][0]|0, pts[i][1]|0, pts[j][0]|0, pts[j][1]|0, 1, 0);
}
},
spirograph(){
// Hypotrochoid: outer R, inner r, pen offset d
pixels.fill(1);
const cx=W/2, cy=H/2;
const R = Math.min(W,H)*0.42;
const r = R*0.31;
const d = R*0.55;
const steps = 4000;
let px=null, py=null;
for(let i=0;i<=steps;i++){
const t = (i/steps)*Math.PI*2*7;
const x = ((R-r)*Math.cos(t) + d*Math.cos((R-r)/r*t)) + cx;
const y = ((R-r)*Math.sin(t) - d*Math.sin((R-r)/r*t)) + cy;
if(px!==null) drawLine(px|0,py|0,x|0,y|0,1,0);
px=x; py=y;
}
},
stars(){
pixels.fill(0);
for(let i=0;i<180;i++){
stamp((Math.random()*W)|0, (Math.random()*H)|0,
Math.random()<.15?2:1, 1);
}
},
noise(){
for(let i=0;i<pixels.length;i++) pixels[i] = Math.random()<.5 ? 0 : 1;
},
maze(){
pixels.fill(1); const t=8;
for(let y=0;y<H;y+=t) for(let x=0;x<W;x+=t){
if(Math.random()<.5) drawLine(x,y,x+t,y,1,0);
else drawLine(x,y,x,y+t,1,0);
}
drawRect(0,0,W-1,H-1,1,0);
},
life(){
// Run Conway's Game of Life from random soup, snapshot at step N
let cur = new Uint8Array(W*H);
for(let i=0;i<cur.length;i++) cur[i] = Math.random()<.35 ? 1 : 0;
let nxt = new Uint8Array(W*H);
for(let gen=0; gen<30; gen++){
for(let y=0;y<H;y++){
for(let x=0;x<W;x++){
let n=0;
for(let dy=-1;dy<=1;dy++) for(let dx=-1;dx<=1;dx++){
if(dx===0&&dy===0) continue;
const xx=(x+dx+W)%W, yy=(y+dy+H)%H;
n += cur[yy*W+xx];
}
const c = cur[y*W+x];
nxt[y*W+x] = (c && (n===2||n===3)) || (!c && n===3) ? 1 : 0;
}
}
const t=cur; cur=nxt; nxt=t;
}
// alive=black on white paper
for(let i=0;i<pixels.length;i++) pixels[i] = cur[i] ? 0 : 1;
},
voronoi(){
// Voronoi cell edges: pixel is "edge" if its nearest seed differs from a neighbor's
const N = 22;
const sx=[], sy=[];
for(let i=0;i<N;i++){ sx.push(Math.random()*W); sy.push(Math.random()*H); }
const owner = new Int16Array(W*H);
for(let y=0;y<H;y++){
for(let x=0;x<W;x++){
let best=0, bd=1e9;
for(let i=0;i<N;i++){
const dx=x-sx[i], dy=y-sy[i];
const d=dx*dx+dy*dy;
if(d<bd){ bd=d; best=i; }
}
owner[y*W+x] = best;
}
}
pixels.fill(1);
for(let y=0;y<H;y++){
for(let x=0;x<W;x++){
const o = owner[y*W+x];
if(x+1<W && owner[y*W+x+1]!==o){ pixels[y*W+x]=0; }
else if(y+1<H && owner[(y+1)*W+x]!==o){ pixels[y*W+x]=0; }
}
}
},
dla(){
// Diffusion-limited aggregation: random walkers stick to growing tree
pixels.fill(1);
const grid = new Uint8Array(W*H);
const cx=W/2|0, cy=H/2|0;
grid[cy*W+cx]=1; pixels[cy*W+cx]=0;
const maxParticles = 800;
for(let p=0;p<maxParticles;p++){
// spawn on a circle of radius rmax
let ang = Math.random()*Math.PI*2;
let rad = Math.min(W,H)*0.45;
let x = cx + (Math.cos(ang)*rad)|0;
let y = cy + (Math.sin(ang)*rad)|0;
for(let step=0; step<2000; step++){
x += (Math.random()<.5?-1:1);
y += (Math.random()<.5?-1:1);
if(x<1||x>=W-1||y<1||y>=H-1){ break; }
// touch?
if(grid[(y-1)*W+x]||grid[(y+1)*W+x]||
grid[y*W+x-1]||grid[y*W+x+1]){
grid[y*W+x]=1; pixels[y*W+x]=0; break;
}
}
}
},
};
document.querySelectorAll('[data-gen]').forEach(btn => {
btn.addEventListener('click', () => {
const k = btn.dataset.gen;
const fn = gens[k];
if(!fn){ setDbg('unknown gen: '+k); return; }
pushUndo();
try { fn(); } catch(e){ setDbg('gen '+k+' err: '+e.message); }
repaint();
setDbg('generated: '+k);
});
});
// --- network ---
const statusEl = document.getElementById('status');
function setStatus(msg, bad){ statusEl.textContent = msg; statusEl.classList.toggle('bad', !!bad); }
function packBitmap(){
const stride = (W+7)>>3;
const out = new Uint8Array(stride * H);
for(let y=0;y<H;y++){
for(let xb=0;xb<stride;xb++){
let b = 0;
for(let bit=0;bit<8;bit++){
const x = xb*8 + bit;
const v = (x<W) ? pixels[y*W+x] : 1;
if(v) b |= (1 << (7-bit));
}
out[y*stride + xb] = b;
}
}
return out;
}
async function sendDraw(mode){
setStatus('sending...');
try{
const r = await fetch('/draw?mode='+mode, {
method: 'POST',
headers: {'Content-Type':'application/octet-stream'},
body: packBitmap()
});
if(!r.ok) throw new Error('HTTP '+r.status);
const j = await r.json();
setStatus('sent - '+j.mode+' - '+j.ms+'ms');
} catch(err){ setStatus('send failed: '+err.message, true); }
}
async function sendClear(){
setStatus('clearing...');
try{
const r = await fetch('/clear', {method:'POST'});
if(!r.ok) throw new Error('HTTP '+r.status);
setStatus('blanked');
} catch(err){ setStatus('clear failed: '+err.message, true); }
}
document.getElementById('btn-send').addEventListener('click', () => sendDraw('partial'));
document.getElementById('btn-sendfull').addEventListener('click', () => sendDraw('full'));
document.getElementById('btn-wipe').addEventListener('click', sendClear);
fetch('/status').then(r => r.json()).then(j => {
setStatus('connected '+j.w+'x'+j.h);
}).catch(() => setStatus('offline', true));
})();
</script>
</body>
</html>
"""
# =============================================================================
# WiFi
# =============================================================================
def _wifi_connect(ssid, password, timeout=60):
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
try:
wlan.config(pm=0xa11140) # disable power save BEFORE connecting
except Exception:
pass
if not wlan.isconnected():
print("Connecting to", repr(ssid))
if password:
wlan.connect(ssid, password)
else:
wlan.connect(ssid)
t0 = time.ticks_ms()
last_status = None
while not wlan.isconnected():
try:
st = wlan.status()
except Exception:
st = None
if st != last_status:
print(" wlan status:", st)
last_status = st
if time.ticks_diff(time.ticks_ms(), t0) > timeout * 1000:
raise RuntimeError(
"WiFi connect timeout (last status=%s). "
"Check: hotspot ON, 2.4 GHz / Maximize Compatibility ON, "
"WPA2 (not WPA3), correct password." % st
)
time.sleep(0.5)
ip = wlan.ifconfig()[0]
print("WiFi OK, IP =", ip)
return ip
# =============================================================================
# Bitmap unpack
# =============================================================================
def _blit_bitmap_to_fb(epd, payload):
src_stride = (EPD_WIDTH + 7) // 8
dst_stride = epd._fb_stride
h = EPD_HEIGHT
fb = epd._fb_buf
for i in range(len(fb)):
fb[i] = 0xFF
last_real_bits = EPD_WIDTH & 7
if last_real_bits:
keep = (0xFF << (8 - last_real_bits)) & 0xFF
pad = 0xFF ^ keep
else:
keep, pad = 0xFF, 0x00
n = len(payload)
for y in range(h):
so = y * src_stride
do = y * dst_stride
if so + src_stride > n:
break
for x in range(src_stride - 1):
fb[do + x] = payload[so + x]
last = payload[so + src_stride - 1]
if last_real_bits:
last = (last & keep) | pad
fb[do + src_stride - 1] = last
# =============================================================================
# HTTP helpers
# =============================================================================
def _sendall(conn, data):
"""Robust send -- MicroPython's socket.send() may not send everything,
and sendall() can fail on large buffers; chunk it explicitly."""
if isinstance(data, str):
data = data.encode("utf-8")
mv = memoryview(data)
total = len(mv)
sent = 0
chunk = 1024 # safe size for Pico W lwIP buffers
while sent < total:
end = sent + chunk
if end > total:
end = total
# Try send(); may return short
try:
n = conn.send(mv[sent:end])
except OSError as e:
# EAGAIN -> wait a bit and retry; otherwise give up
err = e.args[0] if e.args else 0
if err in (11, 35): # EAGAIN / EWOULDBLOCK
time.sleep_ms(5)
continue
raise
if n is None:
n = end - sent # some MP builds return None on success
if n <= 0:
time.sleep_ms(5)
continue
sent += n
def _send(conn, status, body, content_type="text/plain", extra=""):
if isinstance(body, str):
body = body.encode("utf-8")
head = (
"HTTP/1.1 {s}\r\n"
"Content-Type: {ct}\r\n"
"Content-Length: {n}\r\n"
"Access-Control-Allow-Origin: *\r\n"
"Connection: close\r\n"
"{x}"
"\r\n"
).format(s=status, ct=content_type, n=len(body), x=extra)
_sendall(conn, head)
_sendall(conn, body)
def _read_request(conn):
conn.settimeout(5.0)
buf = b""
while b"\r\n\r\n" not in buf:
chunk = conn.recv(1024)
if not chunk:
break
buf += chunk
if len(buf) > 8192:
break
head, _, rest = buf.partition(b"\r\n\r\n")
lines = head.split(b"\r\n")
if not lines:
return None, None, {}, b""
try:
method, path, _ = lines[0].decode("utf-8").split(" ", 2)
except ValueError:
return None, None, {}, b""
headers = {}
for line in lines[1:]:
if b":" in line:
k, _, v = line.partition(b":")
headers[k.decode("utf-8").strip().lower()] = v.decode("utf-8").strip()
return method, path, headers, rest
def _read_exact(conn, n, already=b""):
out = bytearray(already)
while len(out) < n:
chunk = conn.recv(min(1024, n - len(out)))
if not chunk:
break
out += chunk
return bytes(out)
def _show_boot_screen(epd, ip):
fb = epd.fb
fb.fill(0xFF)
fb.rect(0, 0, EPD_WIDTH, EPD_HEIGHT, 0x00)
fb.text("PICO E-INK CANVAS", 8, 10, 0x00)
fb.hline(8, 24, EPD_WIDTH - 16, 0x00)
fb.text("WiFi: connected", 8, 38, 0x00)
fb.text("IP:", 8, 56, 0x00)
fb.text(ip, 40, 56, 0x00)
fb.text("Open in browser:", 8, 80, 0x00)
fb.text("http://" + ip + "/", 8, 96, 0x00)
epd.display_full()
# =============================================================================
# Main entry
# =============================================================================
def run(ssid=None, password=None, port=HTTP_PORT):
if ssid is None:
ssid = SSID
if password is None:
password = PASSWORD
if not ssid:
print("ERROR: create wifi_secrets.py with SSID and PASSWORD (copy from weather app pattern)")
raise SystemExit
epd = EPD()
epd.clear()
ip = _wifi_connect(ssid, password)
_show_boot_screen(epd, ip)
addr = socket.getaddrinfo("0.0.0.0", port)[0][-1]
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(addr)
s.listen(2)
print("HTTP server listening on", addr)
print("Open http://%s/ in your browser." % ip)
full_refresh_counter = 0
try:
while True:
try:
conn, client = s.accept()
except OSError:
continue
try:
method, path, headers, leftover = _read_request(conn)
if method is None:
_send(conn, "400 Bad Request", "bad request")
continue
qpos = path.find("?")
query = ""
if qpos >= 0:
query = path[qpos + 1:]
path = path[:qpos]
print(method, path, "from", client)
if method == "GET" and path in ("/", "/index.html"):
_send(conn, "200 OK", HTML_PAGE,
content_type="text/html; charset=utf-8")
elif method == "GET" and path == "/status":
_send(conn, "200 OK",
'{"ok":true,"w":%d,"h":%d,"bytes":%d}' % (
EPD_WIDTH, EPD_HEIGHT, EXPECTED_BYTES),
content_type="application/json")
elif method == "OPTIONS":
_send(conn, "204 No Content", "",
extra=("Access-Control-Allow-Methods: GET,POST,OPTIONS\r\n"
"Access-Control-Allow-Headers: Content-Type\r\n"))
elif method == "POST" and path == "/clear":
epd.clear()
full_refresh_counter = 0
_send(conn, "200 OK", '{"ok":true}', content_type="application/json")
elif method == "POST" and path == "/draw":
try:
n = int(headers.get("content-length", "0"))
except ValueError:
n = 0
if n <= 0 or n > 16384:
_send(conn, "400 Bad Request", "bad length")
continue
body = _read_exact(conn, n, leftover)
if len(body) < n:
_send(conn, "400 Bad Request", "short body")
continue
_blit_bitmap_to_fb(epd, body)
mode = "partial"
if "mode=full" in query:
mode = "full"
full_refresh_counter += 1
if full_refresh_counter >= 12:
mode = "full"
full_refresh_counter = 0
t0 = time.ticks_ms()
if mode == "full":
epd.display_full()
else:
epd.display_partial()
dt = time.ticks_diff(time.ticks_ms(), t0)
print(" drew %d bytes, mode=%s, %dms" % (len(body), mode, dt))
_send(conn, "200 OK",
'{"ok":true,"mode":"%s","ms":%d}' % (mode, dt),
content_type="application/json")
else:
_send(conn, "404 Not Found", "no")
except Exception as e:
print("req error:", e)
try:
_send(conn, "500 Internal Server Error", str(e))
except Exception:
pass
finally:
try:
conn.close()
except Exception:
pass
gc.collect()
except KeyboardInterrupt:
print("stopped")
finally:
try:
s.close()
except Exception:
pass
if __name__ == '__main__':
run()
else:
run()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 & flow</div>
<div class="row">
<button data-gen="scribble">Scribble</button>
<button data-gen="sinewave">Sine</button>
<button data-gen="interference">Ripples</button>
<button data-gen="rose">Rose</button>
<button data-gen="lissajous">Lissajous</button>
<button data-gen="flow">Flow field</button>
</div>
</div>
<div class="group">
<div class="label">Symmetry & tiling</div>
<div class="row">
<button data-gen="truchet">Truchet</button>
<button data-gen="kaleido">Kaleido</button>
<button data-gen="hex">Hex grid</button>
<button data-gen="herringbone">Herring</button>
<button data-gen="weave">Weave</button>
<button data-gen="moire">Moir\u00e9</button>
</div>
</div>
<div class="group">
<div class="label">Fractals</div>
<div class="row">
<button data-gen="sierpinski">Sierpinski</button>
<button data-gen="carpet">Carpet</button>
<button data-gen="dragon">Dragon</button>
<button data-gen="koch">Koch</button>
<button data-gen="hilbert">Hilbert</button>
<button data-gen="mandelbrot">Mandel</button>
</div>
</div>
<div class="group">
<div class="label">Geometric</div>
<div class="row">
<button data-gen="grad">Gradient</button>
<button data-gen="circles">Circles</button>
<button data-gen="sunburst">Sunburst</button>
<button data-gen="parabolic">Stitching</button>
<button data-gen="mystic">Mystic rose</button>
<button data-gen="spirograph">Spiro</button>
</div>
</div>
<div class="group">
<div class="label">Cellular & random</div>
<div class="row">
<button data-gen="stars">Stars</button>
<button data-gen="noise">Noise</button>
<button data-gen="maze">Maze</button>
<button data-gen="life">Life</button>
<button data-gen="voronoi">Voronoi</button>
<button data-gen="dla">DLA tree</button>
</div>
</div>
<div class="group">
<div class="label">Canvas</div>
<div class="row">
<button id="btn-undo">Undo</button>
<button id="btn-clear" class="danger">Clear</button>
<button id="btn-invert">Invert</button>
<button id="btn-png">Save PNG</button>
</div>
</div>
<div class="group">
<div class="label">Send to e-ink</div>
<div class="row">
<button id="btn-send" class="send">Send (partial)</button>
<button id="btn-sendfull">Send (full)</button>
</div>
<div class="row">
<button id="btn-wipe" class="danger">Blank screen</button>
</div>
</div>
</aside>
</main>
<footer>
<span>Pico W \\u00b7 250\\u00d7122 \\u00b7 1bpp</span>
<span><kbd>B</kbd> brush \\u00b7 <kbd>E</kbd> eraser \\u00b7 <kbd>X</kbd> swap \\u00b7 <kbd>Ctrl+Z</kbd> undo \\u00b7 <kbd>Enter</kbd> send</span>
</footer>
<script>
window.addEventListener('error', e => {
const d = document.getElementById('dbg');
if (d) d.textContent = 'JS ERROR: ' + e.message + ' @ ' + e.lineno;
});
(function(){
const W = 250, H = 122;
const view = document.getElementById('view');
const dbg = document.getElementById('dbg');
const setDbg = m => { if(dbg) dbg.textContent = m; };
if(!view){ setDbg('FATAL: canvas element missing'); return; }
const ctx = view.getContext('2d');
if(!ctx){ setDbg('FATAL: 2D context unavailable'); return; }
// pixel buffer: 1 byte per pixel, 0=black 1=white
let pixels = new Uint8Array(W*H);
pixels.fill(1);
// --- repaint via ImageData ---
const imgd = ctx.createImageData(W, H);
function repaint(){
const d = imgd.data;
for(let i=0;i<pixels.length;i++){
const v = pixels[i] ? 255 : 0;
const j = i*4;
d[j]=v; d[j+1]=v; d[j+2]=v; d[j+3]=255;
}
ctx.putImageData(imgd, 0, 0);
}
repaint();
setDbg('ready - tap or drag the canvas');
// --- drawing ops in pixel space ---
function setPx(x,y,ink){
if(x<0||y<0||x>=W||y>=H) return;
pixels[y*W + x] = ink;
}
function stamp(cx,cy,r,ink){
cx|=0; cy|=0;
if(r<=1){ setPx(cx,cy,ink); return; }
const h = Math.max(1, r/2)|0;
const r2 = h*h;
for(let y=Math.max(0,cy-h); y<=Math.min(H-1,cy+h); y++){
for(let x=Math.max(0,cx-h); x<=Math.min(W-1,cx+h); x++){
const dx=x-cx, dy=y-cy;
if(dx*dx+dy*dy <= r2) pixels[y*W+x]=ink;
}
}
}
function drawLine(x0,y0,x1,y1,r,ink){
x0|=0; y0|=0; x1|=0; y1|=0;
const dx=Math.abs(x1-x0), sx=x0<x1?1:-1;
const dy=-Math.abs(y1-y0), sy=y0<y1?1:-1;
let err=dx+dy, guard=0;
while(guard++<10000){
stamp(x0,y0,r,ink);
if(x0===x1 && y0===y1) break;
const e2=2*err;
if(e2>=dy){ err+=dy; x0+=sx; }
if(e2<=dx){ err+=dx; y0+=sy; }
}
}
function drawRect(x0,y0,x1,y1,r,ink){
drawLine(x0,y0,x1,y0,r,ink); drawLine(x1,y0,x1,y1,r,ink);
drawLine(x1,y1,x0,y1,r,ink); drawLine(x0,y1,x0,y0,r,ink);
}
function drawCircle(cx,cy,rad,r,ink){
if(rad<1){ stamp(cx,cy,r,ink); return; }
let x=rad,y=0,err=0,guard=0;
while(x>=y && guard++<5000){
stamp(cx+x,cy+y,r,ink); stamp(cx+y,cy+x,r,ink);
stamp(cx-y,cy+x,r,ink); stamp(cx-x,cy+y,r,ink);
stamp(cx-x,cy-y,r,ink); stamp(cx-y,cy-x,r,ink);
stamp(cx+y,cy-x,r,ink); stamp(cx+x,cy-y,r,ink);
y++; err+=1+2*y;
if(2*(err-x)+1>0){ x--; err+=1-2*x; }
}
}
function floodFill(sx,sy,ink){
const target=pixels[sy*W+sx];
if(target===undefined || target===ink) return;
const stack=[sx,sy];
while(stack.length){
const y=stack.pop(), x=stack.pop();
if(x<0||y<0||x>=W||y>=H) continue;
if(pixels[y*W+x]!==target) continue;
pixels[y*W+x]=ink;
stack.push(x+1,y, x-1,y, x,y+1, x,y-1);
}
}
// --- state ---
let tool='brush', ink=0, size=3;
let drawing=false, last=null, startPt=null, snap=null;
const undoStack=[];
function pushUndo(){
undoStack.push(new Uint8Array(pixels));
if(undoStack.length>20) undoStack.shift();
}
// --- pointer math: get pixel coords from any event ---
function getXY(clientX, clientY){
const r = view.getBoundingClientRect();
let x = ((clientX - r.left) / r.width) * W;
let y = ((clientY - r.top) / r.height) * H;
x = Math.max(0, Math.min(W-1, x|0));
y = Math.max(0, Math.min(H-1, y|0));
return [x,y];
}
function startStroke(clientX, clientY){
pushUndo();
drawing = true;
const [x,y] = getXY(clientX, clientY);
startPt = [x,y]; last = [x,y];
setDbg('down @ '+x+','+y+' tool='+tool);
if(tool==='brush' || tool==='eraser'){
stamp(x, y, size, tool==='eraser'?1:ink);
repaint();
} else if(tool==='fill'){
floodFill(x, y, ink);
repaint();
drawing = false;
} else {
snap = new Uint8Array(pixels);
}
}
function moveStroke(clientX, clientY){
if(!drawing) return;
const [x,y] = getXY(clientX, clientY);
if(tool==='brush' || tool==='eraser'){
drawLine(last[0], last[1], x, y, size, tool==='eraser'?1:ink);
last = [x,y];
repaint();
setDbg('draw @ '+x+','+y);
} else if(tool==='line' || tool==='rect' || tool==='circle'){
pixels.set(snap);
if(tool==='line') drawLine(startPt[0],startPt[1],x,y,size,ink);
else if(tool==='rect') drawRect(startPt[0],startPt[1],x,y,size,ink);
else {
const dx=x-startPt[0], dy=y-startPt[1];
drawCircle(startPt[0], startPt[1], Math.round(Math.sqrt(dx*dx+dy*dy)), size, ink);
}
repaint();
}
}
function endStroke(){
if(!drawing) return;
drawing=false; last=null; startPt=null; snap=null;
setDbg('stroke ended');
}
// --- attach pointer events with both pointer + mouse + touch fallbacks ---
if('PointerEvent' in window){
view.addEventListener('pointerdown', e => {
e.preventDefault();
try{ view.setPointerCapture(e.pointerId); }catch(_){}
startStroke(e.clientX, e.clientY);
});
view.addEventListener('pointermove', e => {
if(drawing){ e.preventDefault(); moveStroke(e.clientX, e.clientY); }
});
window.addEventListener('pointerup', endStroke);
window.addEventListener('pointercancel', endStroke);
setDbg('using PointerEvent');
} else {
view.addEventListener('mousedown', e => { e.preventDefault(); startStroke(e.clientX, e.clientY); });
window.addEventListener('mousemove', e => moveStroke(e.clientX, e.clientY));
window.addEventListener('mouseup', endStroke);
view.addEventListener('touchstart', e => {
if(!e.touches[0]) return;
e.preventDefault();
startStroke(e.touches[0].clientX, e.touches[0].clientY);
}, {passive:false});
view.addEventListener('touchmove', e => {
if(!e.touches[0]) return;
e.preventDefault();
moveStroke(e.touches[0].clientX, e.touches[0].clientY);
}, {passive:false});
view.addEventListener('touchend', endStroke);
view.addEventListener('touchcancel', endStroke);
setDbg('using mouse+touch fallback');
}
// --- toolbar ---
document.getElementById('tools').addEventListener('click', e => {
const b = e.target.closest('button'); if(!b) return;
tool = b.dataset.tool;
document.querySelectorAll('#tools button').forEach(x => x.classList.toggle('on', x===b));
setDbg('tool: '+tool);
});
document.getElementById('swatches').addEventListener('click', e => {
const s = e.target.closest('.sw'); if(!s) return;
ink = parseInt(s.dataset.ink, 10);
document.querySelectorAll('#swatches .sw').forEach(x => x.classList.toggle('on', x===s));
setDbg('ink: '+(ink?'white':'black'));
});
const sizeIn = document.getElementById('size');
const sizeOut = document.getElementById('sizeOut');
size = +sizeIn.value || 3;
sizeOut.textContent = size;
sizeIn.addEventListener('input', () => { size = +sizeIn.value; sizeOut.textContent = size; });
document.getElementById('btn-clear').addEventListener('click', () => { pushUndo(); pixels.fill(1); repaint(); });
document.getElementById('btn-invert').addEventListener('click', () => {
pushUndo(); for(let i=0;i<pixels.length;i++) pixels[i]^=1; repaint();
});
document.getElementById('btn-undo').addEventListener('click', () => {
if(!undoStack.length) return; pixels = undoStack.pop(); repaint();
});
document.getElementById('btn-png').addEventListener('click', () => {
const off = document.createElement('canvas'); off.width=W*4; off.height=H*4;
const oc = off.getContext('2d'); oc.imageSmoothingEnabled=false;
oc.drawImage(view, 0, 0, off.width, off.height);
off.toBlob(b => {
const a = document.createElement('a');
a.href = URL.createObjectURL(b);
a.download = 'pico-canvas.png'; a.click();
});
});
// --- generators ---
// --- generators (math / art patterns) ---
// Helpers used by several gens
function plotBresenham(x0,y0,x1,y1,ink){
drawLine(x0|0,y0|0,x1|0,y1|0,1,ink);
}
function fillRectPx(x0,y0,x1,y1,ink){
if(x0>x1){ const t=x0;x0=x1;x1=t; }
if(y0>y1){ const t=y0;y0=y1;y1=t; }
x0=Math.max(0,x0|0); y0=Math.max(0,y0|0);
x1=Math.min(W-1,x1|0); y1=Math.min(H-1,y1|0);
for(let y=y0;y<=y1;y++) for(let x=x0;x<=x1;x++) pixels[y*W+x]=ink;
}
const gens = {
scribble(){
pixels.fill(1); let x=W/2, y=H/2;
for(let i=0;i<600;i++){
const nx=x+(Math.random()-.5)*14, ny=y+(Math.random()-.5)*14;
drawLine(x|0, y|0, nx|0, ny|0, 1, 0);
x=Math.max(2,Math.min(W-3,nx)); y=Math.max(2,Math.min(H-3,ny));
}
},
sinewave(){
pixels.fill(1);
const layers = 5;
for(let L=0; L<layers; L++){
const amp = 8 + L*4;
const freq = 0.05 + L*0.015;
const phase = L*0.7;
const yc = H/2 + (L-layers/2)*4;
let py = (yc + Math.sin(phase)*amp)|0;
for(let x=0; x<W; x++){
const y = (yc + Math.sin(x*freq + phase)*amp)|0;
drawLine(x-1, py, x, y, 1, 0);
py = y;
}
}
},
interference(){
// Two wave sources; threshold the sum to make moire/ripple bands
pixels.fill(1);
const s1x=W*0.30, s1y=H*0.5;
const s2x=W*0.70, s2y=H*0.5;
const k = 0.55; // wave number
for(let y=0;y<H;y++){
for(let x=0;x<W;x++){
const d1 = Math.sqrt((x-s1x)**2+(y-s1y)**2);
const d2 = Math.sqrt((x-s2x)**2+(y-s2y)**2);
const v = Math.cos(d1*k) + Math.cos(d2*k);
pixels[y*W+x] = v>0 ? 0 : 1;
}
}
},
rose(){
// Rose curve r = a*cos(k*theta), k = n/d
pixels.fill(1);
const cx=W/2, cy=H/2;
const a = Math.min(W,H)*0.45;
const n = 5, d = 1; // 5-petal rose; tweak for variety
const steps = 4000;
let px=null, py=null;
for(let i=0; i<=steps; i++){
const th = (i/steps) * Math.PI * 2 * d;
const r = a * Math.cos(n/d * th);
const x = (cx + r*Math.cos(th))|0;
const y = (cy + r*Math.sin(th))|0;
if(px!==null) drawLine(px,py,x,y,1,0);
px=x; py=y;
}
},
lissajous(){
pixels.fill(1);
const cx=W/2, cy=H/2;
const A=W*0.46, B=H*0.46;
const a=3, b=2, delta=Math.PI/2;
const steps=3000;
let px=null, py=null;
for(let i=0;i<=steps;i++){
const t = (i/steps)*Math.PI*2;
const x = (cx + A*Math.sin(a*t + delta))|0;
const y = (cy + B*Math.sin(b*t))|0;
if(px!==null) drawLine(px,py,x,y,1,0);
px=x; py=y;
}
},
flow(){
// Pseudo-Perlin flow field via cheap value-noise hash
pixels.fill(1);
function hash(x,y){
let h = (x*374761393 + y*668265263) | 0;
h = (h ^ (h>>>13)) * 1274126177 | 0;
return ((h ^ (h>>>16)) >>> 0) / 4294967295;
}
function noise(x,y){
const xi=Math.floor(x), yi=Math.floor(y);
const xf=x-xi, yf=y-yi;
const u=xf*xf*(3-2*xf), v=yf*yf*(3-2*yf);
const n00=hash(xi,yi), n10=hash(xi+1,yi);
const n01=hash(xi,yi+1), n11=hash(xi+1,yi+1);
return (n00*(1-u)+n10*u)*(1-v) + (n01*(1-u)+n11*u)*v;
}
const seeds = 80, steps = 60;
for(let s=0; s<seeds; s++){
let x = Math.random()*W, y = Math.random()*H;
for(let i=0;i<steps;i++){
const ang = noise(x*0.04, y*0.04) * Math.PI * 4;
const nx = x + Math.cos(ang)*1.4;
const ny = y + Math.sin(ang)*1.4;
drawLine(x|0,y|0,nx|0,ny|0,1,0);
x=nx; y=ny;
if(x<0||x>=W||y<0||y>=H) break;
}
}
},
truchet(){
pixels.fill(1); const t=12;
for(let y=0;y<H;y+=t) for(let x=0;x<W;x+=t){
if(Math.random()<.5) drawLine(x,y,x+t,y+t,1,0);
else drawLine(x+t,y,x,y+t,1,0);
}
},
kaleido(){
// Draw random strokes in one wedge, then mirror across N-fold symmetry
pixels.fill(1);
const cx=W/2, cy=H/2;
const N = 6;
const segs = 30;
const pts = [];
for(let i=0;i<segs;i++){
const r1 = Math.random()*Math.min(W,H)*0.45;
const r2 = Math.random()*Math.min(W,H)*0.45;
const a1 = Math.random()*Math.PI*2/N;
const a2 = a1 + (Math.random()-.5)*0.6;
pts.push([r1,a1,r2,a2]);
}
for(let k=0;k<N;k++){
const base = k*Math.PI*2/N;
for(const [r1,a1,r2,a2] of pts){
const x1=cx+Math.cos(base+a1)*r1, y1=cy+Math.sin(base+a1)*r1;
const x2=cx+Math.cos(base+a2)*r2, y2=cy+Math.sin(base+a2)*r2;
drawLine(x1|0,y1|0,x2|0,y2|0,1,0);
// mirror within wedge
const x1m=cx+Math.cos(base-a1)*r1, y1m=cy+Math.sin(base-a1)*r1;
const x2m=cx+Math.cos(base-a2)*r2, y2m=cy+Math.sin(base-a2)*r2;
drawLine(x1m|0,y1m|0,x2m|0,y2m|0,1,0);
}
}
},
hex(){
pixels.fill(1);
const r = 8; // hex circumradius
const dx = r*Math.sqrt(3);
const dy = r*1.5;
for(let row=-1; row*dy<H+r; row++){
for(let col=-1; col*dx<W+r; col++){
const cx = col*dx + (row&1?dx/2:0);
const cy = row*dy;
// hex outline
let px=null, py=null;
for(let k=0;k<=6;k++){
const a = Math.PI/3*k - Math.PI/2;
const x = (cx+Math.cos(a)*r)|0;
const y = (cy+Math.sin(a)*r)|0;
if(px!==null) drawLine(px,py,x,y,1,0);
px=x; py=y;
}
}
}
},
herringbone(){
pixels.fill(1);
const bw=18, bh=6;
for(let y=0;y<H+bh;y+=bh){
for(let x=-bw;x<W;x+=bw){
const ox = ((y/bh)|0) % 2 === 0 ? 0 : bw/2;
// diagonal brick
drawLine(x+ox, y, x+ox+bw, y+bh, 1, 0);
}
}
},
weave(){
pixels.fill(1);
const t=6;
// horizontal stripes
for(let y=0;y<H;y+=t*2){
for(let x=0;x<W;x+=t*2){
fillRectPx(x, y, x+t-1, y+t-1, 0);
fillRectPx(x+t, y+t, x+2*t-1, y+2*t-1, 0);
}
}
},
moire(){
// Two rotated line gratings
pixels.fill(1);
const a1 = 0.1, a2 = -0.13;
const sp = 4;
for(let y=0;y<H;y++){
for(let x=0;x<W;x++){
const u = x*Math.cos(a1)+y*Math.sin(a1);
const v = x*Math.cos(a2)+y*Math.sin(a2);
const on = (Math.floor(u/sp)&1) ^ (Math.floor(v/sp)&1);
if(on) pixels[y*W+x]=0;
}
}
},
sierpinski(){
pixels.fill(1);
const ax=W/2, ay=4;
const bx=4, by=H-4;
const cx=W-4, cy=H-4;
let px=W/2, py=H/2;
const verts=[[ax,ay],[bx,by],[cx,cy]];
// Chaos game
for(let i=0;i<8000;i++){
const v = verts[(Math.random()*3)|0];
px = (px+v[0])/2;
py = (py+v[1])/2;
if(i>20) setPx(px|0, py|0, 0);
}
},
carpet(){
// Sierpinski carpet by recursive subdivision
pixels.fill(0); // start black, carve white holes
function carve(x,y,w,h,depth){
if(depth===0 || w<3 || h<3) return;
const w3=w/3, h3=h/3;
fillRectPx(x+w3, y+h3, x+2*w3-1, y+2*h3-1, 1);
for(let iy=0;iy<3;iy++) for(let ix=0;ix<3;ix++){
if(ix===1 && iy===1) continue;
carve(x+ix*w3, y+iy*h3, w3, h3, depth-1);
}
}
carve(0,0,W,H,4);
},
dragon(){
pixels.fill(1);
let path = [1];
for(let i=0;i<11;i++){
const rev = [];
for(let j=path.length-1;j>=0;j--) rev.push(path[j]^1);
path = path.concat([1]).concat(rev);
}
// walk
let x=W*0.35, y=H*0.55, dir=0;
const step=2;
for(const turn of path){
const nx = x + Math.cos(dir)*step;
const ny = y + Math.sin(dir)*step;
drawLine(x|0,y|0,nx|0,ny|0,1,0);
x=nx; y=ny;
dir += turn ? Math.PI/2 : -Math.PI/2;
}
},
koch(){
pixels.fill(1);
// Koch snowflake from equilateral triangle, 4 iterations
function koch(p1,p2,depth){
if(depth===0){
drawLine(p1[0]|0,p1[1]|0,p2[0]|0,p2[1]|0,1,0);
return;
}
const dx=(p2[0]-p1[0])/3, dy=(p2[1]-p1[1])/3;
const a=[p1[0]+dx, p1[1]+dy];
const b=[p1[0]+2*dx, p1[1]+2*dy];
const ang = Math.atan2(dy,dx) - Math.PI/3;
const len = Math.sqrt(dx*dx+dy*dy);
const peak=[a[0]+Math.cos(ang)*len, a[1]+Math.sin(ang)*len];
koch(p1,a,depth-1); koch(a,peak,depth-1);
koch(peak,b,depth-1); koch(b,p2,depth-1);
}
const cx=W/2, cy=H/2;
const r=Math.min(W,H)*0.42;
const v=[];
for(let i=0;i<3;i++){
const a = -Math.PI/2 + i*Math.PI*2/3;
v.push([cx+Math.cos(a)*r, cy+Math.sin(a)*r]);
}
koch(v[0],v[1],4); koch(v[1],v[2],4); koch(v[2],v[0],4);
},
hilbert(){
pixels.fill(1);
// Iterative Hilbert curve fitting in min(W,H) square
const order = 5; // 2^5 = 32 cells per side
const N = 1<<order;
const cell = Math.min(W,H)/N;
const ox = (W-N*cell)/2, oy = (H-N*cell)/2;
function d2xy(d){
let x=0,y=0,t=d;
for(let s=1;s<N;s<<=1){
const rx = 1 & (t>>1);
const ry = 1 & (t ^ rx);
if(ry===0){
if(rx===1){ x=s-1-x; y=s-1-y; }
const tmp=x; x=y; y=tmp;
}
x += s*rx; y += s*ry;
t >>= 2;
}
return [x,y];
}
let prev=null;
for(let i=0;i<N*N;i++){
const [gx,gy]=d2xy(i);
const x = (ox + gx*cell + cell/2)|0;
const y = (oy + gy*cell + cell/2)|0;
if(prev) drawLine(prev[0],prev[1],x,y,1,0);
prev=[x,y];
}
},
mandelbrot(){
pixels.fill(1);
const xmin=-2.1, xmax=0.7, ymin=-1.0, ymax=1.0;
const maxIter=24;
for(let py=0;py<H;py++){
const cy = ymin + (ymax-ymin)*py/H;
for(let px=0;px<W;px++){
const cx = xmin + (xmax-xmin)*px/W;
let zx=0, zy=0, i=0;
while(i<maxIter && zx*zx+zy*zy<4){
const t = zx*zx-zy*zy+cx;
zy = 2*zx*zy+cy;
zx = t;
i++;
}
// Threshold dither: high-iter -> dark, low-iter -> light
const v = i/maxIter;
const bayer = ((px+py*7)*0.137)%1;
pixels[py*W+px] = v < 0.95 && v*1.1 > bayer ? 0 : 1;
if(i===maxIter) pixels[py*W+px]=0;
}
}
},
grad(){
const bayer=[
[0,32,8,40,2,34,10,42],[48,16,56,24,50,18,58,26],
[12,44,4,36,14,46,6,38],[60,28,52,20,62,30,54,22],
[3,35,11,43,1,33,9,41],[51,19,59,27,49,17,57,25],
[15,47,7,39,13,45,5,37],[63,31,55,23,61,29,53,21]];
for(let y=0;y<H;y++) for(let x=0;x<W;x++){
pixels[y*W+x] = ((x/(W-1))*64 > bayer[y&7][x&7]) ? 1 : 0;
}
},
circles(){
pixels.fill(1);
const cx=W/2, cy=H/2;
for(let r=4; r<Math.max(W,H); r+=6){
drawCircle(cx,cy,r,1,0);
}
},
sunburst(){
pixels.fill(1);
const cx=W/2, cy=H/2;
const rays = 64;
const R = Math.max(W,H);
for(let i=0;i<rays;i++){
const a = i*Math.PI*2/rays;
const x = cx+Math.cos(a)*R;
const y = cy+Math.sin(a)*R;
drawLine(cx|0,cy|0,x|0,y|0,1,0);
}
},
parabolic(){
// Curve stitching: connect points across two perpendicular axes
pixels.fill(1);
const N = 24;
const x0=4, y0=4, x1=W-4, y1=H-4;
// top-left corner
for(let i=0;i<=N;i++){
const t = i/N;
drawLine(x0+(x1-x0)*t|0, y0|0, x0|0, y0+(y1-y0)*t|0, 1, 0);
}
// bottom-right corner
for(let i=0;i<=N;i++){
const t = i/N;
drawLine(x1-(x1-x0)*t|0, y1|0, x1|0, y1-(y1-y0)*t|0, 1, 0);
}
},
mystic(){
// Mystic rose: N points on a circle, every chord drawn
pixels.fill(1);
const cx=W/2, cy=H/2;
const r = Math.min(W,H)*0.45;
const N = 18;
const pts = [];
for(let i=0;i<N;i++){
const a = i*Math.PI*2/N - Math.PI/2;
pts.push([cx+Math.cos(a)*r, cy+Math.sin(a)*r]);
}
for(let i=0;i<N;i++) for(let j=i+1;j<N;j++){
drawLine(pts[i][0]|0, pts[i][1]|0, pts[j][0]|0, pts[j][1]|0, 1, 0);
}
},
spirograph(){
// Hypotrochoid: outer R, inner r, pen offset d
pixels.fill(1);
const cx=W/2, cy=H/2;
const R = Math.min(W,H)*0.42;
const r = R*0.31;
const d = R*0.55;
const steps = 4000;
let px=null, py=null;
for(let i=0;i<=steps;i++){
const t = (i/steps)*Math.PI*2*7;
const x = ((R-r)*Math.cos(t) + d*Math.cos((R-r)/r*t)) + cx;
const y = ((R-r)*Math.sin(t) - d*Math.sin((R-r)/r*t)) + cy;
if(px!==null) drawLine(px|0,py|0,x|0,y|0,1,0);
px=x; py=y;
}
},
stars(){
pixels.fill(0);
for(let i=0;i<180;i++){
stamp((Math.random()*W)|0, (Math.random()*H)|0,
Math.random()<.15?2:1, 1);
}
},
noise(){
for(let i=0;i<pixels.length;i++) pixels[i] = Math.random()<.5 ? 0 : 1;
},
maze(){
pixels.fill(1); const t=8;
for(let y=0;y<H;y+=t) for(let x=0;x<W;x+=t){
if(Math.random()<.5) drawLine(x,y,x+t,y,1,0);
else drawLine(x,y,x,y+t,1,0);
}
drawRect(0,0,W-1,H-1,1,0);
},
life(){
// Run Conway's Game of Life from random soup, snapshot at step N
let cur = new Uint8Array(W*H);
for(let i=0;i<cur.length;i++) cur[i] = Math.random()<.35 ? 1 : 0;
let nxt = new Uint8Array(W*H);
for(let gen=0; gen<30; gen++){
for(let y=0;y<H;y++){
for(let x=0;x<W;x++){
let n=0;
for(let dy=-1;dy<=1;dy++) for(let dx=-1;dx<=1;dx++){
if(dx===0&&dy===0) continue;
const xx=(x+dx+W)%W, yy=(y+dy+H)%H;
n += cur[yy*W+xx];
}
const c = cur[y*W+x];
nxt[y*W+x] = (c && (n===2||n===3)) || (!c && n===3) ? 1 : 0;
}
}
const t=cur; cur=nxt; nxt=t;
}
// alive=black on white paper
for(let i=0;i<pixels.length;i++) pixels[i] = cur[i] ? 0 : 1;
},
voronoi(){
// Voronoi cell edges: pixel is "edge" if its nearest seed differs from a neighbor's
const N = 22;
const sx=[], sy=[];
for(let i=0;i<N;i++){ sx.push(Math.random()*W); sy.push(Math.random()*H); }
const owner = new Int16Array(W*H);
for(let y=0;y<H;y++){
for(let x=0;x<W;x++){
let best=0, bd=1e9;
for(let i=0;i<N;i++){
const dx=x-sx[i], dy=y-sy[i];
const d=dx*dx+dy*dy;
if(d<bd){ bd=d; best=i; }
}
owner[y*W+x] = best;
}
}
pixels.fill(1);
for(let y=0;y<H;y++){
for(let x=0;x<W;x++){
const o = owner[y*W+x];
if(x+1<W && owner[y*W+x+1]!==o){ pixels[y*W+x]=0; }
else if(y+1<H && owner[(y+1)*W+x]!==o){ pixels[y*W+x]=0; }
}
}
},
dla(){
// Diffusion-limited aggregation: random walkers stick to growing tree
pixels.fill(1);
const grid = new Uint8Array(W*H);
const cx=W/2|0, cy=H/2|0;
grid[cy*W+cx]=1; pixels[cy*W+cx]=0;
const maxParticles = 800;
for(let p=0;p<maxParticles;p++){
// spawn on a circle of radius rmax
let ang = Math.random()*Math.PI*2;
let rad = Math.min(W,H)*0.45;
let x = cx + (Math.cos(ang)*rad)|0;
let y = cy + (Math.sin(ang)*rad)|0;
for(let step=0; step<2000; step++){
x += (Math.random()<.5?-1:1);
y += (Math.random()<.5?-1:1);
if(x<1||x>=W-1||y<1||y>=H-1){ break; }
// touch?
if(grid[(y-1)*W+x]||grid[(y+1)*W+x]||
grid[y*W+x-1]||grid[y*W+x+1]){
grid[y*W+x]=1; pixels[y*W+x]=0; break;
}
}
}
},
};
document.querySelectorAll('[data-gen]').forEach(btn => {
btn.addEventListener('click', () => {
const k = btn.dataset.gen;
const fn = gens[k];
if(!fn){ setDbg('unknown gen: '+k); return; }
pushUndo();
try { fn(); } catch(e){ setDbg('gen '+k+' err: '+e.message); }
repaint();
setDbg('generated: '+k);
});
});
// --- network ---
const statusEl = document.getElementById('status');
function setStatus(msg, bad){ statusEl.textContent = msg; statusEl.classList.toggle('bad', !!bad); }
function packBitmap(){
const stride = (W+7)>>3;
const out = new Uint8Array(stride * H);
for(let y=0;y<H;y++){
for(let xb=0;xb<stride;xb++){
let b = 0;
for(let bit=0;bit<8;bit++){
const x = xb*8 + bit;
const v = (x<W) ? pixels[y*W+x] : 1;
if(v) b |= (1 << (7-bit));
}
out[y*stride + xb] = b;
}
}
return out;
}
async function sendDraw(mode){
setStatus('sending...');
try{
const r = await fetch('/draw?mode='+mode, {
method: 'POST',
headers: {'Content-Type':'application/octet-stream'},
body: packBitmap()
});
if(!r.ok) throw new Error('HTTP '+r.status);
const j = await r.json();
setStatus('sent - '+j.mode+' - '+j.ms+'ms');
} catch(err){ setStatus('send failed: '+err.message, true); }
}
async function sendClear(){
setStatus('clearing...');
try{
const r = await fetch('/clear', {method:'POST'});
if(!r.ok) throw new Error('HTTP '+r.status);
setStatus('blanked');
} catch(err){ setStatus('clear failed: '+err.message, true); }
}
document.getElementById('btn-send').addEventListener('click', () => sendDraw('partial'));
document.getElementById('btn-sendfull').addEventListener('click', () => sendDraw('full'));
document.getElementById('btn-wipe').addEventListener('click', sendClear);
fetch('/status').then(r => r.json()).then(j => {
setStatus('connected '+j.w+'x'+j.h);
}).catch(() => setStatus('offline', true));
})();
</script>
</body>
</html>
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:
| Byte | Type | Payload |
|---|---|---|
H (0x48) | start | uint32 LE total audio byte count |
D (0x44) | data | raw int16 LE samples |
E (0x45) | end | — |
Button (active-low on GP2):
| Clicks | Action |
|---|---|
| 1 | Record a 3 s clip into RAM |
| 2 | Play the last clip on the local speaker |
| 3 | Stream 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
EPDclass you can draw into withframebuf, 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

(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 box | What it is in firmware |
|---|---|
| Week 14 canvas / Pack 250×122 | Browser UI → packed bitmap matching what /draw expects. |
| framebuf MONO_HLSB | Landscape internal buffer in EPD (self.fb) before _rotate_to_native(). |
| HTTP … /draw | MicroPython handlers (/status, /draw, /clear) + CYW43 stack. |
| EPD class SPI cmds | Custom driver: _cmd / _data, SoftSPI, busy wait, display_full / display_partial. |
| SPI / I2C / GPIO | Physical buses from Pico pins to PCB nets (SPI to panel, I2C to OLED, GPIO for encoder/button). |
| SSD1306 driver | OLED firmware path (I2C), separate from epd_driver.py. |
| EC11 decode / IC184 debounce | Input logic in your app (polling or ISR) riding on GPIO. |
| Waveshare 2.13 EPD / SSD1306 module | Modules mounted off headers / flex after SKT. |
Custom e-ink module (epd_driver.py)
The panel controller is spoken to over SPI (SoftSPI at 2 MHz) with busy, rst, dc, cs, sck, mosi as in EPD.__init__. Drawing uses a landscape framebuf (250×122, padded stride 256); the panel’s native memory layout is 122×250 portrait, so _rotate_to_native() remaps pixels before display_full() or display_partial(). _LUT_PARTIAL feeds the controller’s partial waveform; _prev_buffer tracks the last image for partial updates.
Raw file for copy-paste or tooling: epd_driver.py
Full MicroPython source (same as the download above — collapse with ▾)
from machine import Pin, SoftSPI
import framebuf
import utime
# Native panel resolution (portrait, as the controller sees it)
EPD_NATIVE_WIDTH = 122
EPD_NATIVE_HEIGHT = 250
_NATIVE_BUF_WIDTH = (EPD_NATIVE_WIDTH + 7) & ~7 # 128
# Logical resolution exposed to the user (landscape)
EPD_WIDTH = 250
EPD_HEIGHT = 122
_FB_WIDTH = (EPD_WIDTH + 7) & ~7 # 256
_LUT_PARTIAL = bytes([
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x28, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00,
])
class EPD:
# Public dimensions are the landscape ones
WIDTH = EPD_WIDTH
HEIGHT = EPD_HEIGHT
def __init__(self,
busy=6, rst=7, dc=8, cs=9,
sck=10, mosi=11, miso_placeholder=13):
self.busy = Pin(busy, Pin.IN)
self.rst = Pin(rst, Pin.OUT)
self.dc = Pin(dc, Pin.OUT)
self.cs = Pin(cs, Pin.OUT)
self.cs.value(1)
self.dc.value(0)
self.rst.value(1)
self.spi = SoftSPI(baudrate=2_000_000, polarity=0, phase=0,
sck=Pin(sck), mosi=Pin(mosi),
miso=Pin(miso_placeholder))
# --- Native (portrait) buffers actually sent to the panel ---
self._native_buf_w = _NATIVE_BUF_WIDTH # 128
self._native_stride = self._native_buf_w // 8 # 16
self.buffer = bytearray(self._native_buf_w * EPD_NATIVE_HEIGHT // 8)
for i in range(len(self.buffer)):
self.buffer[i] = 0xFF
self._prev_buffer = bytearray(self._native_buf_w * EPD_NATIVE_HEIGHT // 8)
for i in range(len(self._prev_buffer)):
self._prev_buffer[i] = 0xFF
# --- User-facing landscape framebuffer (drawn into directly) ---
self._fb_w = _FB_WIDTH # 256
self._fb_h = EPD_HEIGHT # 122
self._fb_stride = self._fb_w // 8 # 32
self._fb_buf = bytearray(self._fb_w * self._fb_h // 8)
self.fb = framebuf.FrameBuffer(self._fb_buf,
self._fb_w, self._fb_h,
framebuf.MONO_HLSB)
self.fb.fill(0xFF)
self._init_full()
# ---------------- low-level I/O ----------------
def _reset(self):
self.rst.value(1); utime.sleep_ms(50)
self.rst.value(0); utime.sleep_ms(5)
self.rst.value(1); utime.sleep_ms(50)
def _cmd(self, c):
self.dc.value(0); self.cs.value(0)
self.spi.write(bytes([c]))
self.cs.value(1)
def _data(self, d):
self.dc.value(1); self.cs.value(0)
if isinstance(d, int):
self.spi.write(bytes([d]))
else:
self.spi.write(d)
self.cs.value(1)
def _wait_busy(self, timeout_ms=10000):
t = 0
while self.busy.value() == 1 and t < timeout_ms:
utime.sleep_ms(10); t += 10
if t >= timeout_ms:
print("EPD BUSY timeout!")
def _set_window(self, x_start_byte, x_end_byte, y_start, y_end):
self._cmd(0x44)
self._data(x_start_byte & 0xFF)
self._data(x_end_byte & 0xFF)
self._cmd(0x45)
self._data(y_start & 0xFF); self._data((y_start >> 8) & 0xFF)
self._data(y_end & 0xFF); self._data((y_end >> 8) & 0xFF)
def _set_cursor(self, x_byte, y):
self._cmd(0x4E); self._data(x_byte & 0xFF)
self._cmd(0x4F); self._data(y & 0xFF); self._data((y >> 8) & 0xFF)
# ---------------- rotation ----------------
def _rotate_to_native(self):
"""
Rotate the landscape framebuffer into the native portrait buffer.
Native pixel (x_n, y_n) with 0 <= x_n < 122, 0 <= y_n < 250
maps to logical pixel (x_l, y_l) with:
x_l = y_n
y_l = (EPD_HEIGHT - 1) - x_n # 90° rotation, text reads correctly
"""
nat = self.buffer
log = self._fb_buf
nat_stride = self._native_stride
log_stride = self._fb_stride
h_minus_1 = EPD_HEIGHT - 1
# Clear native buffer to white
for i in range(len(nat)):
nat[i] = 0xFF
for y_n in range(EPD_NATIVE_HEIGHT): # 0..249
x_l = y_n
if x_l >= EPD_WIDTH: # guard if buf padded beyond 250
continue
log_byte_col = x_l >> 3
log_bit_shift = 7 - (x_l & 7)
for x_n in range(EPD_NATIVE_WIDTH): # 0..121
y_l = h_minus_1 - x_n
lb = log[y_l * log_stride + log_byte_col]
bit = (lb >> log_bit_shift) & 1
ni = y_n * nat_stride + (x_n >> 3)
mask = 1 << (7 - (x_n & 7))
if bit:
nat[ni] |= mask
else:
nat[ni] &= ~mask
# ---------------- panel init sequences ----------------
def _init_full(self):
self._reset()
self._wait_busy()
self._cmd(0x12)
self._wait_busy()
self._cmd(0x01)
self._data(0xF9); self._data(0x00); self._data(0x00)
self._cmd(0x11); self._data(0x03)
self._set_window(0, (self._native_buf_w - 1) >> 3, 0, EPD_NATIVE_HEIGHT - 1)
self._cmd(0x3C); self._data(0x05)
self._cmd(0x21); self._data(0x00); self._data(0x80)
self._cmd(0x18); self._data(0x80)
self._set_cursor(0, 0)
self._wait_busy()
def _init_partial(self):
self._cmd(0x32)
self._data(_LUT_PARTIAL)
self._cmd(0x3C); self._data(0x80)
self._set_window(0, (self._native_buf_w - 1) >> 3, 0, EPD_NATIVE_HEIGHT - 1)
self._set_cursor(0, 0)
# ---------------- public display ops ----------------
def display_full(self):
self._rotate_to_native()
self._init_full()
self._set_cursor(0, 0)
self._cmd(0x24)
self._data(self.buffer)
self._set_cursor(0, 0)
self._cmd(0x26)
self._data(self.buffer)
self._cmd(0x22); self._data(0xF7)
self._cmd(0x20)
self._wait_busy()
for i in range(len(self.buffer)):
self._prev_buffer[i] = self.buffer[i]
def display_partial(self):
self._rotate_to_native()
self._init_partial()
self._set_cursor(0, 0)
self._cmd(0x26)
self._data(self._prev_buffer)
self._set_cursor(0, 0)
self._cmd(0x24)
self._data(self.buffer)
self._cmd(0x22); self._data(0xFF)
self._cmd(0x20)
self._wait_busy()
for i in range(len(self.buffer)):
self._prev_buffer[i] = self.buffer[i]
def clear(self, color=0xFF):
self.fb.fill(color)
self.display_full()
def sleep(self):
self._cmd(0x10); self._data(0x01)
utime.sleep_ms(100)
Calendar UI (portrait 122×250)
This build targets the panel’s logical portrait framebuffer (122×250): _EW / _EH defaults match that; main() still sets _EW, _EH = epd.WIDTH, epd.HEIGHT so whatever your EPD reports wins. Layout is one column, top to bottom—no left/right split.
Vertical bands (_draw_calendar)
- Header — Centered
MMM D YYYY(shortened if wider thanmax_chars_w), then centered full weekday from_DOW_FULL, then a full-width horizontal rule. - Month grid —
M … Srow; grid centered horizontally (grid_x0 = (_EW - grid_w) // 2).cell_w ≈ 16 pxso day numerals are 1–2 digits without padded spacing. Selected day is solid invert; today gets a frame when not selected; days with JSON entries get a 2×2 marker dot. Cell height is computed from remaining vertical space between the grid and the reserved events + footer band (clamped between_LH+2and 16 px). - Second rule, then events for
_date_key(y, m, sel_d)—time+titlelines,+N moreif the list would run into the footer. - Footer — Rule above
Scroll: month/Scroll: day, orMode: M/Mode: Dif the long string would overflow.
Typography constants
_CW = 8,_LH = 10— monospacefb.textstride (matches your font assumptions)._EMG = 3— screen margin.
Refresh policy (_Refresher)
- Same as before: partial batches, forced full every
_PARTIALS_BEFORE_FULL(25) or whenforce_full(first paint usesfull=True).
Encoder mapping
| Action | Behaviour |
|---|---|
dial+ / dial- | _advance by month or day according to mode. |
dial_double | Flip _MODE_MONTH ↔ _MODE_DAY. |
dial | Refresh today from time.localtime() (chained unpack with y, m, d). |
dial_long | Leave loop → main() clears framebuffer, full refresh, sleep(). |
Data
calendar.jsonbeside the script; seedSAMPLE_EVENTSon first run if the file is missing.
Imports
import os— included as in your firmware tree (e.g. paths / SD layout later); not required for the logic shown.from epd2in13 import EPD— your on-device module name; align with the sameEPDAPI as the publishedepd_driver.py(.fb,display_full,display_partial,sleep).pico_ui_input.InputManager— encoder onclk=2, dt=3, sw=4withlong_pressanddouble_click.
Entrypoint
if __name__ == '__main__': main()pluselse: main()so the calendar still runs when the file is imported on your stack (matches your snippet).
Download: calendar_ui.py
Full calendar_ui.py source
import os
import json
import time
from epd2in13 import EPD
from pico_ui_input import InputManager
CAL_FILE = 'calendar.json'
SAMPLE_EVENTS = {
"2026-04-28": [
{"time": "09:00", "title": "Standup"},
{"time": "14:30", "title": "Dentist"},
{"time": "19:00", "title": "Dinner with M"}
],
"2026-04-30": [
{"time": "10:00", "title": "Project review"}
],
"2026-05-03": [
{"time": "All day", "title": "Trip to Bursa"}
]
}
_CW = 8
_LH = 10
_EMG = 3
_PARTIALS_BEFORE_FULL = 25
# Filled in from epd.WIDTH / epd.HEIGHT in main()
# Portrait: 122 wide x 250 tall
_EW = 122
_EH = 250
_MONTHS = ('Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec')
_DOW = ('M', 'T', 'W', 'T', 'F', 'S', 'S')
_DOW_FULL = ('Monday', 'Tuesday', 'Wednesday', 'Thursday',
'Friday', 'Saturday', 'Sunday')
# Scroll modes
_MODE_MONTH = 0
_MODE_DAY = 1
class _Refresher:
def __init__(self, epd):
self.epd = epd
self.partials = 0
def push(self, force_full=False):
if force_full or self.partials >= _PARTIALS_BEFORE_FULL:
self.epd.display_full()
self.partials = 0
else:
if self.partials == 0:
self.epd.display_full()
else:
self.epd.display_partial()
self.partials += 1
def push_full(self):
self.epd.display_full()
self.partials = 0
def _load_events():
try:
with open(CAL_FILE) as f:
return json.load(f)
except OSError:
try:
with open(CAL_FILE, 'w') as f:
json.dump(SAMPLE_EVENTS, f)
except Exception as e:
print('write sample:', e)
return dict(SAMPLE_EVENTS)
except Exception as e:
print('load events:', e)
return {}
def _is_leap(y):
return (y % 4 == 0 and y % 100 != 0) or (y % 400 == 0)
def _days_in_month(y, m):
if m == 2:
return 29 if _is_leap(y) else 28
if m in (4, 6, 9, 11):
return 30
return 31
def _zeller_dow_monday0(y, m, d):
if m < 3:
m += 12
y -= 1
K = y % 100
J = y // 100
h = (d + (13 * (m + 1)) // 5 + K + K // 4 + J // 4 + 5 * J) % 7
return (h + 5) % 7
def _today():
try:
t = time.localtime()
return (t[0], t[1], t[2])
except:
return (2026, 4, 28)
def _date_key(y, m, d):
return '{:04d}-{:02d}-{:02d}'.format(y, m, d)
def _advance(y, m, d, mode, step):
"""Advance (y, m, d) by `step` units of `mode` (day or month)."""
if mode == _MODE_DAY:
s = 1 if step > 0 else -1
for _ in range(abs(step)):
d += s
if d > _days_in_month(y, m):
d = 1
m += 1
if m > 12:
m = 1
y += 1
elif d < 1:
m -= 1
if m < 1:
m = 12
y -= 1
d = _days_in_month(y, m)
return (y, m, d)
else:
m += step
while m > 12:
m -= 12
y += 1
while m < 1:
m += 12
y -= 1
dim = _days_in_month(y, m)
if d > dim:
d = dim
return (y, m, d)
def _draw_calendar(epd, refresher, y, m, sel_d, mode, events, full=False):
"""
Portrait view (122 x 250), top-to-bottom:
1. Header : "MMM D YYYY" + day-of-week subtitle
2. Grid : day-of-week row + month grid with selected day inverted
3. Events : list for the selected day
4. Footer : scroll mode indicator
"""
fb = epd.fb
fb.fill(1)
max_chars_w = (_EW - _EMG * 2) // _CW
# -------- 1. Header --------
hdr_y = _EMG
title = '{} {} {}'.format(_MONTHS[m - 1], sel_d, y)
if len(title) > max_chars_w:
title = '{} {}'.format(_MONTHS[m - 1], sel_d)
tx = (_EW - len(title) * _CW) // 2
if tx < 0:
tx = 0
fb.text(title, tx, hdr_y, 0)
sub_y = hdr_y + _LH + 1
dow_idx = _zeller_dow_monday0(y, m, sel_d)
sub = _DOW_FULL[dow_idx]
if len(sub) > max_chars_w:
sub = sub[:max_chars_w]
sx = (_EW - len(sub) * _CW) // 2
if sx < 0:
sx = 0
fb.text(sub, sx, sub_y, 0)
sep1_y = sub_y + _LH + 2
fb.hline(0, sep1_y, _EW, 0)
# -------- 2. Day-of-week row + month grid --------
grid_x0 = _EMG
grid_w_avail = _EW - _EMG * 2
cell_w = grid_w_avail // 7 # 116 // 7 = 16 px
grid_w = cell_w * 7 # 112 px
# Center the grid horizontally given integer cell_w
grid_x0 = (_EW - grid_w) // 2
dow_y = sep1_y + 3
for i in range(7):
x = grid_x0 + i * cell_w + (cell_w - _CW) // 2
fb.text(_DOW[i], x, dow_y, 0)
dow_line_y = dow_y + _LH
fb.hline(grid_x0, dow_line_y, grid_w, 0)
n_days = _days_in_month(y, m)
first_dow = _zeller_dow_monday0(y, m, 1)
n_rows = (first_dow + n_days + 6) // 7
# Reserve space at the bottom for events + footer
foot_h = _LH + 2
events_min_h = _LH * 3 + 6 # title + ~2 lines minimum
grid_top = dow_line_y + 2
grid_bottom_max = _EH - _EMG - foot_h - events_min_h - 2
avail_h = grid_bottom_max - grid_top
cell_h = avail_h // n_rows
if cell_h < _LH + 2:
cell_h = _LH + 2
if cell_h > 16:
cell_h = 16
grid_h = n_rows * cell_h
for r in range(n_rows + 1):
ly = grid_top - 1 + r * cell_h
fb.hline(grid_x0, ly, grid_w + 1, 0)
for c in range(8):
lx = grid_x0 + c * cell_w
fb.vline(lx, grid_top - 1, grid_h + 1, 0)
today_y, today_m, today_d = _today()
for d in range(1, n_days + 1):
idx = first_dow + (d - 1)
row = idx // 7
col = idx % 7
cx = grid_x0 + col * cell_w
cy = grid_top + row * cell_h
is_sel = (d == sel_d)
is_today = (y == today_y and m == today_m and d == today_d)
if is_sel:
fb.fill_rect(cx + 1, cy, cell_w - 1, cell_h - 1, 0)
text_color = 1
else:
text_color = 0
# cell_w is only ~16 px -> max 2 chars; print without leading space
ds = str(d)
tx2 = cx + (cell_w - len(ds) * _CW) // 2
fb.text(ds, tx2, cy + 1, text_color)
if is_today and not is_sel:
fb.rect(cx + 1, cy, cell_w - 1, cell_h - 1, 0)
if _date_key(y, m, d) in events:
mx = cx + cell_w - 3
my = cy + cell_h - 3
fb.fill_rect(mx, my, 2, 2, 1 if is_sel else 0)
sep2_y = grid_top + grid_h + 2
fb.hline(0, sep2_y, _EW, 0)
# -------- 3. Events --------
body_top = sep2_y + 3
foot_y = _EH - _EMG - _LH
body_bottom = foot_y - 3
key = _date_key(y, m, sel_d)
day_events = events.get(key, [])
if not day_events:
fb.text('No events', _EMG, body_top, 0)
else:
y_cursor = body_top
for i, ev in enumerate(day_events):
if y_cursor + _LH > body_bottom:
more = '+{} more'.format(len(day_events) - i)
if len(more) > max_chars_w:
more = more[:max_chars_w]
fb.text(more, _EMG, y_cursor, 0)
break
t = ev.get('time', '')
title_s = ev.get('title', '')
line = '{} {}'.format(t, title_s) if t else title_s
if len(line) > max_chars_w:
line = line[:max_chars_w]
fb.text(line, _EMG, y_cursor, 0)
y_cursor += _LH
# -------- 4. Footer (mode indicator) --------
fb.hline(0, foot_y - 2, _EW, 0)
mode_str = 'Scroll: month' if mode == _MODE_MONTH else 'Scroll: day'
if len(mode_str) > max_chars_w:
mode_str = 'Mode: M' if mode == _MODE_MONTH else 'Mode: D'
fb.text(mode_str, _EMG, foot_y, 0)
refresher.push(force_full=full)
def _calendar_loop(epd, ui, refresher, events):
today_y, today_m, today_d = _today()
y, m, d = today_y, today_m, today_d
mode = _MODE_MONTH
_draw_calendar(epd, refresher, y, m, d, mode, events, full=True)
while True:
a = ui.wait()
if a == 'dial+':
y, m, d = _advance(y, m, d, mode, +1)
_draw_calendar(epd, refresher, y, m, d, mode, events)
elif a == 'dial-':
y, m, d = _advance(y, m, d, mode, -1)
_draw_calendar(epd, refresher, y, m, d, mode, events)
elif a == 'dial_double':
mode = _MODE_DAY if mode == _MODE_MONTH else _MODE_MONTH
_draw_calendar(epd, refresher, y, m, d, mode, events)
elif a == 'dial':
y, m, d = today_y, today_m, today_d = _today()
_draw_calendar(epd, refresher, y, m, d, mode, events)
elif a == 'dial_long':
return
def main():
global _EW, _EH
epd = EPD()
_EW, _EH = epd.WIDTH, epd.HEIGHT
ui = InputManager()
ui.add_encoder('dial', clk=2, dt=3, sw=4,
long_press=True, double_click=True)
refresher = _Refresher(epd)
events = _load_events()
_calendar_loop(epd, ui, refresher, events)
epd.fb.fill(1)
epd.display_full()
epd.sleep()
if __name__ == '__main__':
main()
else:
main()
E-ink reader / library (portrait 122×250)
Same portrait framebuffer contract as the calendar: _EW / _EH default to 122×250; main() sets them from epd.WIDTH / epd.HEIGHT. The e-ink shows the library (sorted *.txt from /) and the reader (wrapped text, pagination, footer page N/M, progress bar, bookmark glyph in the title row when the current page is bookmarked). Files with saved bookmarks show a small dot in the library list.
Optional OLED (128×64) — I2C(0), sda=16, scl=17, SSD1306_I2C. If init fails, OLED_OK is false and menus fall back to “no OLED” behaviour where applicable. The OLED drives options (bookmark, skip/jump, exit), bookmark list, and short confirm messages so the big screen stays on reading.
Refresh (_Refresher) — Partials are batched; a full refresh runs when force_full, on first paint (partials == 0), or after _PARTIALS_BEFORE_FULL (15) partial updates (counter resets to 1 after each full). File-list scrolling can request push_partial for snappier navigation.
Bookmarks — Persisted in bookmarks.json (page indices per filename). Reopening a file resumes at the latest bookmarked page when any exist.
Encoder (InputManager) — Single encoder dial: clk=2, dt=3, sw=4, long_press=True only (no double_click in this app). Library: dial+ / dial- move selection, dial opens the file, dial_long exits to shutdown path. Reader: dial+ / dial- turn pages, dial opens the OLED options menu, dial_long returns to the library.
Download: eink_reader.py
Full eink_reader.py source
import os
import json
from machine import Pin, I2C
from epd2in13 import EPD
from pico_ui_input import InputManager
_CW = 8
_LH = 10
_EMG = 3
_PARTIALS_BEFORE_FULL = 15
# Portrait: 122 wide x 250 tall
_EW = 122
_EH = 250
OW, OH = 128, 64
BOOKMARKS_FILE = 'bookmarks.json'
try:
from ssd1306 import SSD1306_I2C
_i2c = I2C(0, sda=Pin(16), scl=Pin(17), freq=400_000)
oled = SSD1306_I2C(OW, OH, _i2c)
OLED_OK = True
except Exception as e:
print('OLED unavailable:', e)
oled = None
OLED_OK = False
class _Refresher:
def __init__(self, epd):
self.epd = epd
self.partials = 0
def push(self, force_full=False):
if force_full or self.partials >= _PARTIALS_BEFORE_FULL:
self.epd.display_full()
self.partials = 1
else:
if self.partials == 0:
self.epd.display_full()
self.partials = 1
else:
self.epd.display_partial()
self.partials += 1
def push_partial(self):
if self.partials == 0:
self.epd.display_full()
self.partials = 1
else:
self.epd.display_partial()
self.partials += 1
def push_full(self):
self.epd.display_full()
self.partials = 1
def _load_bookmarks():
try:
with open(BOOKMARKS_FILE) as f:
return json.load(f)
except:
return {}
def _save_bookmarks(bm):
try:
with open(BOOKMARKS_FILE, 'w') as f:
json.dump(bm, f)
except Exception as e:
print('bookmark save:', e)
def _list_txt_files():
out = []
try:
for name in os.listdir('/'):
if name.lower().endswith('.txt'):
out.append(name)
except Exception as e:
print('list_txt_files:', e)
out.sort()
return out
# ----------------- file list -----------------
def _draw_file_list(epd, refresher, files, sel, bookmarks,
full=False, force_partial=False):
fb = epd.fb
fb.fill(1)
# Header
fb.text('Library', _EMG, _EMG, 0)
n = len(files)
if n:
cnt = '{}/{}'.format(sel + 1, n)
cw = len(cnt) * _CW
fb.text(cnt, _EW - _EMG - cw, _EMG, 0)
sep_y = _EMG + _LH + 2
fb.hline(0, sep_y, _EW, 0)
if not files:
fb.text('No .txt files', _EMG, sep_y + 8, 0)
if force_partial:
refresher.push_partial()
else:
refresher.push(force_full=full)
return
list_top = sep_y + 4
list_bottom = _EH - _EMG
# Each row uses _LH px; leave a 2-char left gutter for the cursor + bookmark mark
left_gutter = _CW + 2 # 10 px: cursor (>) or space, then bookmark dot
text_x = _EMG + left_gutter
chars_per_line = max(1, (_EW - text_x - _EMG) // _CW)
rows_visible = max(1, (list_bottom - list_top) // _LH)
# Center the selection in the visible window where possible
if sel < rows_visible // 2:
first = 0
elif sel > n - rows_visible // 2 - 1:
first = max(0, n - rows_visible)
else:
first = sel - rows_visible // 2
last = min(n, first + rows_visible)
for i in range(first, last):
y = list_top + (i - first) * _LH
is_sel = (i == sel)
name = files[i]
if len(name) > chars_per_line:
name = name[:chars_per_line - 1] + '~'
if is_sel:
fb.text('>', _EMG, y, 0)
# bookmark indicator: solid dot before name if file has any bookmarks
if bookmarks.get(files[i]):
fb.fill_rect(_EMG + _CW, y + 3, 2, 2, 0)
fb.text(name, text_x, y, 0)
if force_partial:
refresher.push_partial()
else:
refresher.push(force_full=full)
def _file_list_loop(epd, ui, refresher, bookmarks):
files = _list_txt_files()
sel = 0
_draw_file_list(epd, refresher, files, sel, bookmarks, full=True)
while True:
a = ui.wait()
if a == 'dial+':
if files:
sel = (sel + 1) % len(files)
_draw_file_list(epd, refresher, files, sel, bookmarks,
force_partial=True)
elif a == 'dial-':
if files:
sel = (sel - 1) % len(files)
_draw_file_list(epd, refresher, files, sel, bookmarks,
force_partial=True)
elif a == 'dial':
if files:
return files[sel]
elif a == 'dial_long':
return None
# ----------------- text wrapping -----------------
def _wrap_paragraph(text, cols):
if not text:
return ['']
lines = []
cur = ''
for word in text.split(' '):
if not word:
continue
while len(word) > cols:
if cur:
lines.append(cur); cur = ''
lines.append(word[:cols])
word = word[cols:]
if not cur:
cur = word
elif len(cur) + 1 + len(word) <= cols:
cur = cur + ' ' + word
else:
lines.append(cur); cur = word
if cur:
lines.append(cur)
return lines
def _wrap_text(text, cols):
out = []
text = text.replace('\r\n', '\n').replace('\r', '\n')
for para in text.split('\n'):
if para == '':
out.append('')
else:
out.extend(_wrap_paragraph(para, cols))
return out
def _paginate(lines, rows_per_page):
if rows_per_page < 1:
rows_per_page = 1
pages = []
for i in range(0, len(lines), rows_per_page):
pages.append(lines[i:i + rows_per_page])
if not pages:
pages = [['']]
return pages
# ----------------- reader -----------------
# Layout constants for the reader chrome (top + bottom).
# Recomputed once and reused for both sizing and drawing so the math stays
# consistent.
_HDR_H = _EMG + _LH + 2 # title row + 2 px under separator
_BAR_H = 6 # progress bar height (a touch slimmer)
_FOOT_H = _BAR_H + 2 + _LH + 2 # bar + gap + page text + gap
def _draw_reader_page(epd, refresher, title, page_lines,
page_idx, n_pages, at_end=False, bookmarked=False,
full=False):
fb = epd.fb
fb.fill(1)
chars_per_line = max(1, (_EW - _EMG * 2) // _CW)
# ---- header ----
title_avail = chars_per_line - (2 if bookmarked else 0) # reserve space for icon
title_disp = title
if len(title_disp) > title_avail:
title_disp = title_disp[:title_avail - 1] + '~'
fb.text(title_disp, _EMG, _EMG, 0)
if bookmarked:
bx = _EW - _EMG - 6
by = _EMG - 1
# tiny bookmark glyph (filled rect with notched bottom)
fb.fill_rect(bx, by, 6, 9, 0)
fb.fill_rect(bx + 1, by + 8, 1, 2, 1)
fb.fill_rect(bx + 4, by + 8, 1, 2, 1)
sep_y = _EMG + _LH + 1
fb.hline(0, sep_y, _EW, 0)
# ---- body ----
body_top = sep_y + 3
body_bottom = _EH - _FOOT_H
rows_per_page = max(1, (body_bottom - body_top) // _LH)
for i, line in enumerate(page_lines):
if i >= rows_per_page:
break
if line:
fb.text(line, _EMG, body_top + i * _LH, 0)
# ---- footer: page text + progress bar ----
foot = 'END {}/{}'.format(page_idx + 1, n_pages) if at_end \
else '{}/{}'.format(page_idx + 1, n_pages)
if len(foot) * _CW > _EW - _EMG * 2:
foot = '{}/{}'.format(page_idx + 1, n_pages)
fw = len(foot) * _CW
foot_y = _EH - _BAR_H - 2 - _LH
fb.text(foot, _EW - _EMG - fw, foot_y, 0)
# Progress bar at bottom
bar_x0 = _EMG
bar_x1 = _EW - _EMG
bar_y = _EH - _BAR_H - 1
bar_w = bar_x1 - bar_x0
fb.rect(bar_x0, bar_y, bar_w, _BAR_H, 0)
if n_pages > 1:
fill_w = (bar_w - 4) * page_idx // (n_pages - 1)
else:
fill_w = bar_w - 4
if fill_w > 0:
fb.fill_rect(bar_x0 + 2, bar_y + 2, fill_w, _BAR_H - 4, 0)
refresher.push(force_full=full)
def _reader_rows_per_page():
body_top = _EMG + _LH + 1 + 3
body_bottom = _EH - _FOOT_H
return max(1, (body_bottom - body_top) // _LH)
# ----------------- OLED helpers (unchanged) -----------------
def _oled_clear():
if OLED_OK:
oled.fill(0); oled.show()
def _oled_menu(ui, title, items):
if not OLED_OK:
return None
sel = 0
while True:
oled.fill(0)
oled.fill_rect(0, 0, OW, 11, 1)
title_short = title
if len(title_short) > OW // 8 - 1:
title_short = title_short[:OW // 8 - 1]
oled.text(title_short, 2, 2, 0)
body_top = 14
row_h = 11
rows_visible = (OH - body_top - 2) // row_h
if sel < rows_visible // 2:
first = 0
elif sel > len(items) - rows_visible // 2 - 1:
first = max(0, len(items) - rows_visible)
else:
first = sel - rows_visible // 2
max_chars = OW // 8
for i in range(first, min(len(items), first + rows_visible)):
y = body_top + (i - first) * row_h
label = items[i]
if len(label) > max_chars:
label = label[:max_chars]
if i == sel:
oled.fill_rect(0, y - 1, OW, row_h - 1, 1)
oled.text(label, 2, y, 0)
else:
oled.text(label, 2, y, 1)
oled.show()
a = ui.wait()
if a == 'dial+':
sel = (sel + 1) % len(items)
elif a == 'dial-':
sel = (sel - 1) % len(items)
elif a == 'dial':
return sel
elif a == 'dial_long':
return None
def _oled_message(msg, sub=''):
if not OLED_OK:
return
oled.fill(0)
chars = OW // 8
m = msg if len(msg) <= chars else msg[:chars]
s = sub if len(sub) <= chars else sub[:chars]
oled.text(m, (OW - len(m) * 8) // 2, OH // 2 - 8, 1)
if s:
oled.text(s, (OW - len(s) * 8) // 2, OH // 2 + 4, 1)
oled.show()
def _show_bookmark_list(ui, page, marks):
if not marks:
_oled_message('No bookmarks')
ui.wait()
return None
items = []
sorted_marks = sorted(marks)
for p in sorted_marks:
marker = '*' if p == page else ' '
items.append('{} p{}'.format(marker, p + 1))
sel = _oled_menu(ui, 'Bookmarks', items)
if sel is None:
return None
return sorted_marks[sel]
def _nearest_bookmark(marks, page, direction):
if not marks:
return None
if direction > 0:
candidates = [p for p in marks if p > page]
if candidates:
return min(candidates)
return None
else:
candidates = [p for p in marks if p < page]
if candidates:
return max(candidates)
return None
def _options_menu(ui, page, n_pages, marks):
is_bookmarked = page in marks
items = []
actions = []
if marks and any(p > page for p in marks):
items.append('Skip >>')
actions.append('skip_fwd')
elif page < n_pages - 1:
items.append('Jump end')
actions.append('jump_end')
if marks and any(p < page for p in marks):
items.append('Skip <<')
actions.append('skip_back')
elif page > 0:
items.append('Jump start')
actions.append('jump_start')
if is_bookmarked:
items.append('Remove mark')
actions.append('unmark')
else:
items.append('Set mark')
actions.append('mark')
items.append('Bookmarks...')
actions.append('list')
items.append('Exit book')
actions.append('exit')
items.append('Cancel')
actions.append('cancel')
sel = _oled_menu(ui, 'Options', items)
if sel is None:
return 'cancel'
return actions[sel]
# ----------------- main reader loop -----------------
def _read_file_loop(epd, ui, refresher, filename, all_bookmarks):
try:
with open('/' + filename, 'r') as f:
text = f.read()
except Exception as e:
fb = epd.fb
fb.fill(1)
fb.text('Error reading:', _EMG, _EMG, 0)
fb.text(filename[:14], _EMG, _EMG + _LH * 2, 0)
fb.text(str(e)[:14], _EMG, _EMG + _LH * 4, 0)
refresher.push_full()
ui.wait()
return
chars_per_line = max(1, (_EW - _EMG * 2) // _CW)
rows_per_page = _reader_rows_per_page()
lines = _wrap_text(text, chars_per_line)
pages = _paginate(lines, rows_per_page)
n = len(pages)
title = filename.rsplit('.', 1)[0] if '.' in filename else filename
marks = list(all_bookmarks.get(filename, []))
marks = [p for p in marks if 0 <= p < n]
marks_set = set(marks)
page = 0
if marks:
page = max(marks)
def _redraw(full=False):
_draw_reader_page(epd, refresher, title, pages[page],
page, n, at_end=(page == n - 1),
bookmarked=(page in marks_set), full=full)
_redraw(full=True)
while True:
a = ui.wait()
if a == 'dial+':
if page < n - 1:
page += 1
_redraw()
elif a == 'dial-':
if page > 0:
page -= 1
_redraw()
elif a == 'dial_long':
_oled_clear()
return
elif a == 'dial':
choice = _options_menu(ui, page, n, marks_set)
_oled_clear()
if choice == 'cancel':
pass
elif choice == 'skip_fwd':
target = _nearest_bookmark(marks_set, page, +1)
if target is not None:
page = target
_redraw(full=True)
elif choice == 'skip_back':
target = _nearest_bookmark(marks_set, page, -1)
if target is not None:
page = target
_redraw(full=True)
elif choice == 'jump_end':
page = n - 1
_redraw(full=True)
elif choice == 'jump_start':
page = 0
_redraw(full=True)
elif choice == 'mark':
marks_set.add(page)
all_bookmarks[filename] = sorted(marks_set)
_save_bookmarks(all_bookmarks)
_oled_message('Bookmarked', 'p{}'.format(page + 1))
_redraw()
elif choice == 'unmark':
marks_set.discard(page)
if marks_set:
all_bookmarks[filename] = sorted(marks_set)
else:
all_bookmarks.pop(filename, None)
_save_bookmarks(all_bookmarks)
_oled_message('Removed', 'p{}'.format(page + 1))
_redraw()
elif choice == 'list':
target = _show_bookmark_list(ui, page, marks_set)
_oled_clear()
if target is not None:
page = target
_redraw(full=True)
elif choice == 'exit':
return
def main():
global _EW, _EH
epd = EPD()
_EW, _EH = epd.WIDTH, epd.HEIGHT
ui = InputManager()
ui.add_encoder('dial', clk=2, dt=3, sw=4, long_press=True)
refresher = _Refresher(epd)
bookmarks = _load_bookmarks()
while True:
chosen = _file_list_loop(epd, ui, refresher, bookmarks)
if chosen is None:
break
_read_file_loop(epd, ui, refresher, chosen, bookmarks)
# Bookmarks may have changed inside the reader; reload from memory.
epd.fb.fill(1)
epd.display_full()
epd.sleep()
_oled_clear()
if __name__ == '__main__':
main()
else:
main()
Pico flashcards / SRS (portrait 122×250)
Anki-style spaced repetition on the same stack: EPD portrait framebuffer (EW / EH from epd.WIDTH / epd.HEIGHT), optional SSD1306 on I2C(0) sda=16, scl=17, 128×64. flashcards.json is created on first run with a sample deck; schedule() implements a simplified SM-2 with four grades (Again / Hard / Good / Easy), updating ef, iv (interval days), reps, and due (epoch seconds).
Screens (e-ink) — Deck list: two lines per deck (name + due: N), scroll window, hold to quit in the footer. Review: question centered → click flips to Q | divider | A plus a four-row vertical grade bar; rotate moves highlight, click commits. Nothing due / Session done summary screens.
OLED — During review: deck title bar, cards left, last grade; deck list: short control hints. Cleared in main() finally.
Refresh (_Refresher) — push(force_full=…): forces full, or full every 5 partial updates (counter resets to 0 after full); first paint in the partial path uses full when partials == 0.
Encoder (InputManager) — dial: clk=2, dt=3, sw=4, long_press=True, double_click=True. Deck list: rotate selects, click starts review, long-press exits app. Review (question): click flips; long / double save and return to deck list (double acts as “bail out” to the list). Review (answer): rotate chooses grade, click grades (Again re-queues that card in the session); long / double save and exit review.
Download: flashcards_ui.py
Full flashcards_ui.py source
"""
Pico flashcards — Anki-style SRS on e-ink + OLED + rotary encoder.
Portrait (122x250) layout.
Storage: flashcards.json (auto-created with a sample deck on first run)
SRS: simplified SM-2 (Again/Hard/Good/Easy)
Controls:
rotate = scroll / select
click = confirm / show answer / grade
long-press = back / exit
double = jump to deck list
"""
from machine import Pin, I2C
import time, json, gc
from epd2in13 import EPD
from ssd1306 import SSD1306_I2C
from pico_ui_input import InputManager
# ---------- constants ----------
DB_FILE = 'flashcards.json'
DAY_S = 86400
MIN_EASE = 1.30
START_EASE = 2.50
GRADES = ('Again', 'Hard', 'Good', 'Easy')
DEFAULT_DB = {
'baseline': 0,
'decks': [
{
'name': 'Sample',
'cards': [
{'q': 'Capital of France?', 'a': 'Paris',
'iv': 0, 'ef': START_EASE, 'due': 0, 'reps': 0},
{'q': '2 + 2 = ?', 'a': '4',
'iv': 0, 'ef': START_EASE, 'due': 0, 'reps': 0},
{'q': 'H2O is...', 'a': 'water',
'iv': 0, 'ef': START_EASE, 'due': 0, 'reps': 0},
],
},
],
}
# ---------- DB load / save ----------
def load_db():
try:
with open(DB_FILE) as f:
db = json.load(f)
for deck in db.get('decks', []):
for c in deck.get('cards', []):
c.setdefault('iv', 0)
c.setdefault('ef', START_EASE)
c.setdefault('due', 0)
c.setdefault('reps', 0)
db.setdefault('baseline', 0)
return db
except (OSError, ValueError):
save_db(DEFAULT_DB)
return DEFAULT_DB
def save_db(db):
try:
with open(DB_FILE, 'w') as f:
json.dump(db, f)
except Exception as e:
print('save_db:', e)
def now_s():
return time.time()
# ---------- SRS core ----------
def schedule(card, grade_idx):
ef = card['ef']
iv = card['iv']
reps = card['reps']
if grade_idx == 0: # Again
ef = max(MIN_EASE, ef - 0.20)
iv = 1
reps = 0
elif grade_idx == 1: # Hard
ef = max(MIN_EASE, ef - 0.15)
iv = max(1, int(round(iv * 1.2))) if iv > 0 else 1
reps += 1
elif grade_idx == 2: # Good
if reps == 0:
iv = 1
elif reps == 1:
iv = 3
else:
iv = max(1, int(round(iv * ef)))
reps += 1
else: # Easy
ef = ef + 0.15
if reps == 0:
iv = 4
elif reps == 1:
iv = 7
else:
iv = max(1, int(round(iv * ef * 1.3)))
reps += 1
card['ef'] = round(ef, 3)
card['iv'] = iv
card['reps'] = reps
card['due'] = now_s() + iv * DAY_S
def due_count(deck, now=None):
if now is None:
now = now_s()
n = 0
for c in deck['cards']:
if c['due'] <= now:
n += 1
return n
def collect_due_cards(deck, now=None):
if now is None:
now = now_s()
return [i for i, c in enumerate(deck['cards']) if c['due'] <= now]
# ---------- hardware setup ----------
epd = EPD()
EW, EH = epd.WIDTH, epd.HEIGHT # 122 x 250 in portrait
EMG = 3
CW, LH = 8, 10
i2c0 = I2C(0, sda=Pin(16), scl=Pin(17), freq=400_000)
try:
oled = SSD1306_I2C(128, 64, i2c0)
OLED_OK = True
except Exception as e:
print('OLED init failed:', e)
oled = None
OLED_OK = False
OW, OH = 128, 64
class _Refresher:
def __init__(self, ep):
self.ep = ep
self.partials = 0
def push(self, force_full=False):
if force_full or self.partials >= 5:
self.ep.display_full()
self.partials = 0
else:
if self.partials == 0:
self.ep.display_full()
else:
self.ep.display_partial()
self.partials += 1
refresher = _Refresher(epd)
ui = InputManager()
ui.add_encoder('dial', clk=2, dt=3, sw=4,
long_press=True, double_click=True)
# ---------- text helpers ----------
def _fit(s, max_w):
n = max(0, max_w // CW)
return s if len(s) <= n else s[:n]
def _wrap_lines(s, max_chars):
if max_chars <= 0:
return []
out = []
for paragraph in s.split('\n'):
if not paragraph:
out.append('')
continue
words = paragraph.split(' ')
cur = ''
for w in words:
if len(w) > max_chars:
if cur:
out.append(cur); cur = ''
while len(w) > max_chars:
out.append(w[:max_chars])
w = w[max_chars:]
cur = w
continue
if not cur:
cur = w
elif len(cur) + 1 + len(w) <= max_chars:
cur = cur + ' ' + w
else:
out.append(cur); cur = w
if cur:
out.append(cur)
return out
# ---------- e-ink screens ----------
def render_deck_list(decks, sel):
"""
Vertical list of decks. Two rows per deck: name on row 1,
"due: N" on row 2 in muted style. Avoids name truncation on a 14-char screen.
"""
fb = epd.fb
fb.fill(1)
# Header
fb.text(_fit('Decks', EW - EMG * 2), EMG, EMG, 0)
cnt = '{}/{}'.format(sel + 1, len(decks)) if decks else '0/0'
cw = len(cnt) * CW
fb.text(cnt, EW - EMG - cw, EMG, 0)
sep_y = EMG + 8 + 2
fb.hline(0, sep_y, EW, 0)
# Footer
foot_h = 8
sep_yb = EH - EMG - foot_h - 2
fb.hline(0, sep_yb, EW, 0)
fb.text(_fit('hold to quit', EW - EMG * 2),
EMG, EH - EMG - foot_h, 0)
body_top = sep_y + 4
body_bot = sep_yb - 2
row_h = LH * 2 + 3 # name + count + small gap
max_rows = max(1, (body_bot - body_top) // row_h)
start = 0
if len(decks) > max_rows:
start = max(0, min(sel - max_rows // 2, len(decks) - max_rows))
end = min(len(decks), start + max_rows)
name_max = max(1, (EW - EMG * 2 - CW) // CW) # leave room for cursor
for vis_i, idx in enumerate(range(start, end)):
deck = decks[idx]
n = due_count(deck)
y = body_top + vis_i * row_h
is_sel = (idx == sel)
if is_sel:
# Cursor + bold-ish (we only have one weight so just cursor)
fb.text('>', EMG, y, 0)
name = deck['name'][:name_max]
fb.text(name, EMG + CW, y, 0)
sub = 'due: {}'.format(n)
fb.text(sub, EMG + CW, y + LH, 0)
refresher.push(force_full=True)
def render_card(deck_name, card, idx, total, show_answer, grade_sel,
full=False):
"""
Portrait card screen.
show_answer=False:
header | wrapped question (centered vertically) | hint
show_answer=True:
header | question (top, ~2 lines) | divider | answer (wrapped) |
vertical 4-row grade bar | hint
"""
fb = epd.fb
fb.fill(1)
chars = max(1, (EW - EMG * 2) // CW)
# ---- header ----
progress = '{}/{}'.format(idx + 1, total)
pw = len(progress) * CW
name_w = EW - EMG * 2 - pw - CW
name = _fit(deck_name, name_w)
fb.text(name, EMG, EMG, 0)
fb.text(progress, EW - EMG - pw, EMG, 0)
sep_y = EMG + 8 + 2
fb.hline(0, sep_y, EW, 0)
# ---- footer ----
foot_h = 8
sep_yb = EH - EMG - foot_h - 2
fb.hline(0, sep_yb, EW, 0)
body_top = sep_y + 3
body_bot = sep_yb - 2
body_h = body_bot - body_top
if not show_answer:
lines = _wrap_lines(card['q'], chars)
max_lines = body_h // LH
if len(lines) > max_lines:
lines = lines[:max_lines]
total_h = len(lines) * LH
ystart = body_top + max(0, (body_h - total_h) // 2)
for i, ln in enumerate(lines):
tx = (EW - len(ln) * CW) // 2
if tx < EMG:
tx = EMG
fb.text(ln, tx, ystart + i * LH, 0)
fb.text(_fit('click to flip', EW - EMG * 2),
EMG, EH - EMG - foot_h, 0)
else:
# Vertical grade bar at the bottom of the body area: 4 rows,
# each row 13 px tall (text + 3 px padding). Total = 52 px.
gb_row_h = 13
gb_h = gb_row_h * len(GRADES)
gb_y0 = body_bot - gb_h
# Question + divider + answer fit in the area above the grade bar.
qa_top = body_top
qa_bot = gb_y0 - 2
qa_h = qa_bot - qa_top
# Allow up to 2 lines for question, rest for answer.
q_lines = _wrap_lines(card['q'], chars)
if len(q_lines) > 2:
q_lines = q_lines[:2]
q_h = len(q_lines) * LH
for i, ln in enumerate(q_lines):
fb.text(ln, EMG, qa_top + i * LH, 0)
div_y = qa_top + q_h + 1
# dotted-ish divider: skip every other pixel
for x in range(EMG + 2, EW - EMG - 2, 2):
fb.pixel(x, div_y, 0)
a_top = div_y + 3
a_h = qa_bot - a_top
a_max_lines = max(1, a_h // LH)
a_lines = _wrap_lines(card['a'], chars)
if len(a_lines) > a_max_lines:
a_lines = a_lines[:a_max_lines - 1] + [a_lines[a_max_lines - 1][:chars - 1] + '~']
for i, ln in enumerate(a_lines):
fb.text(ln, EMG, a_top + i * LH, 0)
# Vertical grade bar
for gi, g in enumerate(GRADES):
cy = gb_y0 + gi * gb_row_h
label = g[:chars]
tx = EMG + max(0, (EW - EMG * 2 - len(label) * CW) // 2)
ty = cy + (gb_row_h - LH) // 2 + 1
if gi == grade_sel:
fb.fill_rect(EMG, cy, EW - EMG * 2, gb_row_h, 0)
fb.text(label, tx, ty, 1)
else:
fb.rect(EMG, cy, EW - EMG * 2, gb_row_h, 0)
fb.text(label, tx, ty, 0)
fb.text(_fit('turn pick click ok', EW - EMG * 2),
EMG, EH - EMG - foot_h, 0)
refresher.push(force_full=full)
def render_session_done(deck_name, reviewed):
fb = epd.fb
fb.fill(1)
title = _fit('Session done', EW - EMG * 2)
fb.text(title, (EW - len(title) * CW) // 2, EH // 2 - 20, 0)
sub = _fit('{} cards'.format(reviewed), EW - EMG * 2)
fb.text(sub, (EW - len(sub) * CW) // 2, EH // 2 - 4, 0)
nm = _fit(deck_name, EW - EMG * 2)
fb.text(nm, (EW - len(nm) * CW) // 2, EH // 2 + 10, 0)
hint = _fit('click=continue', EW - EMG * 2)
fb.text(hint, (EW - len(hint) * CW) // 2, EH - EMG - 8, 0)
refresher.push(force_full=True)
def render_empty_deck(deck_name):
fb = epd.fb
fb.fill(1)
msg = _fit('Nothing due', EW - EMG * 2)
fb.text(msg, (EW - len(msg) * CW) // 2, EH // 2 - 12, 0)
nm = _fit(deck_name, EW - EMG * 2)
fb.text(nm, (EW - len(nm) * CW) // 2, EH // 2, 0)
hint = _fit('click=back', EW - EMG * 2)
fb.text(hint, (EW - len(hint) * CW) // 2, EH - EMG - 8, 0)
refresher.push(force_full=True)
# ---------- OLED screens (unchanged — different display) ----------
def oled_status(deck_name, remaining, last_grade=None):
if not OLED_OK: return
oled.fill(0)
oled.fill_rect(0, 0, OW, 11, 1)
name = deck_name[:max(1, OW // 8 - 1)]
oled.text(name, 2, 2, 0)
oled.text('Left: {}'.format(remaining), 0, 18, 1)
lg = last_grade if last_grade is not None else '-'
oled.text('Last: {}'.format(lg), 0, 32, 1)
oled.text('hold=quit', 0, OH - 8, 1)
oled.show()
def oled_decks_hint(n_decks):
if not OLED_OK: return
oled.fill(0)
oled.fill_rect(0, 0, OW, 11, 1)
oled.text('Flashcards', 2, 2, 0)
oled.text('Decks: {}'.format(n_decks), 0, 18, 1)
oled.text('rotate=pick', 0, 32, 1)
oled.text('click=open', 0, 44, 1)
oled.text('hold=quit', 0, OH - 8, 1)
oled.show()
def oled_clear():
if OLED_OK:
oled.fill(0); oled.show()
# ---------- review session ----------
def review_deck(db, deck):
queue = collect_due_cards(deck)
if not queue:
render_empty_deck(deck['name'])
oled_status(deck['name'], 0, None)
while True:
a = ui.wait()
if a in ('dial', 'dial_long', 'dial_double'):
return 0
reviewed = 0
last_grade = None
pos = 0
while pos < len(queue):
card_idx = queue[pos]
card = deck['cards'][card_idx]
# Phase 1: question only
render_card(deck['name'], card, pos, len(queue),
show_answer=False, grade_sel=2, full=(pos == 0))
oled_status(deck['name'], len(queue) - pos, last_grade)
flipped = False
while not flipped:
a = ui.wait()
if a == 'dial':
flipped = True
elif a == 'dial_long':
save_db(db)
return reviewed
elif a == 'dial_double':
save_db(db)
return reviewed
# Phase 2: answer + grading
grade_sel = 2
render_card(deck['name'], card, pos, len(queue),
show_answer=True, grade_sel=grade_sel)
graded = False
while not graded:
a = ui.wait()
if a == 'dial+':
grade_sel = (grade_sel + 1) % len(GRADES)
render_card(deck['name'], card, pos, len(queue),
show_answer=True, grade_sel=grade_sel)
elif a == 'dial-':
grade_sel = (grade_sel - 1) % len(GRADES)
render_card(deck['name'], card, pos, len(queue),
show_answer=True, grade_sel=grade_sel)
elif a == 'dial':
schedule(card, grade_sel)
last_grade = GRADES[grade_sel]
reviewed += 1
save_db(db)
if grade_sel == 0:
queue.append(card_idx)
graded = True
elif a in ('dial_long', 'dial_double'):
save_db(db)
return reviewed
pos += 1
gc.collect()
render_session_done(deck['name'], reviewed)
oled_status(deck['name'], 0, last_grade)
while True:
a = ui.wait()
if a in ('dial', 'dial_long', 'dial_double'):
return reviewed
# ---------- deck list mode ----------
def deck_list_mode(db):
sel = 0
decks = db['decks']
if not decks:
decks = list(DEFAULT_DB['decks'])
db['decks'] = decks
save_db(db)
render_deck_list(decks, sel)
oled_decks_hint(len(decks))
while True:
a = ui.wait()
if a == 'dial+':
sel = (sel + 1) % len(decks)
render_deck_list(decks, sel)
elif a == 'dial-':
sel = (sel - 1) % len(decks)
render_deck_list(decks, sel)
elif a == 'dial':
review_deck(db, decks[sel])
render_deck_list(decks, sel)
oled_decks_hint(len(decks))
elif a == 'dial_long':
return
# ---------- main ----------
def main():
db = load_db()
if db.get('baseline', 0) == 0:
db['baseline'] = now_s()
save_db(db)
try:
deck_list_mode(db)
finally:
save_db(db)
oled_clear()
if __name__ == '__main__':
main()
else:
main()
Weather (portrait 122×250, Open-Meteo)
Network weather on the same hardware: EPD portrait (EW / EH from epd.WIDTH / HEIGHT), SSD1306 on I2C(0) sda=16, scl=17. The e-ink UI is laid out for a tall panel—header and footer rules bracket a body height derived from EH, so the current view stacks city + time, icon (size from remaining space), condition label, 2× scaled temperature, then feels / humidity / wind; forecast splits the body into up to four day rows (day, icon, hi/lo) with adaptive row height. render_current_page / render_forecast_page docstrings describe the vertical bands.
Data — Open-Meteo forecast JSON (current + 4 daily fields). weather.txt lists cities as Name,lat,lon (defaults seeded if missing); weather_settings.json stores °C/°F, wind km/h vs mph, and refresh interval (minutes).
Wi-Fi — Expect a device-local wifi_secrets.py with SSID and PASSWORD (not shipped in this repo). Startup retries on failure.
OLED — pico_anim.Animation: weather-kind–specific loops (sun rays, clouds, rain, etc.) plus a top HUD bar (city / temp). City picker and settings are OLED-only menus.
Refresh (_Refresher) — Same batching pattern as flashcards: full every 5 partials or when push_full / force_full; counter resets to 0 after full.
Encoder — dial: clk=2, dt=3, sw=4, long_press=True, double_click=True. Idle (animated OLED): dial → city picker, dial_double → settings, dial_long → force API refresh, dial+ / dial- → e-ink current ↔ forecast pages.
Python / network (client only) — This firmware does not run an HTTP server on the Pico (contrast canvas_server.py). It is an HTTP(S) client: wifi_connect() brings up network.WLAN(STA_IF) with wifi_secrets, then fetch_weather(lat, lon) calls urequests.get on Open-Meteo — GET https://api.open-meteo.com/v1/forecast with latitude / longitude, current=temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code,apparent_temperature, daily=temperature_2m_max,temperature_2m_min,weather_code,precipitation_probability_max, timezone=auto, forecast_days=4, wind_speed_unit=kmh. JSON is mapped into a single dict (temp, feels, humidity, wind, code, forecast arrays). fetch_for_current_city returns cached data while age_ms < refresh_min × 60 × 1000 unless force; on success it **cache_put**s with time.ticks_ms(). main() also refreshes on a timer when the cache is stale.
Core client code (excerpt from weather_ui.py — Wi‑Fi + Open‑Meteo + cache policy)
def wifi_connect(timeout_s=20):
if wlan.isconnected(): return True
wlan.active(True)
wlan.connect(SSID, PASSWORD)
deadline = time.ticks_add(time.ticks_ms(), timeout_s * 1000)
while time.ticks_diff(deadline, time.ticks_ms()) > 0:
if wlan.isconnected(): return True
time.sleep_ms(150)
return False
def fetch_weather(lat, lon):
url = (
'https://api.open-meteo.com/v1/forecast?'
'latitude={}&longitude={}'
'¤t=temperature_2m,relative_humidity_2m,wind_speed_10m,'
'weather_code,apparent_temperature'
'&daily=temperature_2m_max,temperature_2m_min,weather_code,'
'precipitation_probability_max'
'&timezone=auto&forecast_days=4&wind_speed_unit=kmh'
).format(lat, lon)
try:
r = urequests.get(url)
if r.status_code != 200:
r.close(); return None
data = r.json()
r.close()
gc.collect()
except Exception as e:
print('fetch err:', e)
return None
cur = data.get('current', {}); daily = data.get('daily', {})
return {
'temp': cur.get('temperature_2m'),
'feels': cur.get('apparent_temperature'),
'humidity': cur.get('relative_humidity_2m'),
'wind': cur.get('wind_speed_10m'),
'code': cur.get('weather_code'),
'fc_dates': daily.get('time', []),
'fc_max': daily.get('temperature_2m_max', []),
'fc_min': daily.get('temperature_2m_min', []),
'fc_code': daily.get('weather_code', []),
'fc_pop': daily.get('precipitation_probability_max', []),
}
def fetch_for_current_city(force=False):
name, lat, lon = CITIES[state['city_idx']]
cached = cache_get(name)
age_ms = (time.ticks_diff(time.ticks_ms(), cached['fetched_at'])
if cached else None)
if cached and age_ms < settings['refresh_min'] * 60 * 1000 and not force:
return cached['data']
if not wlan.isconnected():
if not wifi_connect():
return None
w = fetch_weather(lat, lon)
if w is not None:
cache_put(name, w)
return w
Download: weather_ui.py
Full weather_ui.py source
"""
Weather app — Open-Meteo on e-ink + animated OLED HUD.
Portrait (122×250): current page + forecast page; layout sized from EH.
Requires: wifi_secrets.py with SSID, PASSWORD
"""
from machine import Pin, I2C
import time, gc, math, json
import network
import urequests
from epd2in13 import EPD
from ssd1306 import SSD1306_I2C
from pico_ui_input import InputManager
from pico_anim import Animation
try:
from wifi_secrets import SSID, PASSWORD
except ImportError:
print("ERROR: create wifi_secrets.py with SSID and PASSWORD")
raise
CITIES_FILE = 'weather.txt'
SETTINGS_FILE = 'weather_settings.json'
DEFAULT_CITIES = [
('Istanbul', 41.0082, 28.9784),
('Ankara', 39.9334, 32.8597),
('Izmir', 38.4192, 27.1287),
('London', 51.5074, -0.1278),
('Tokyo', 35.6762, 139.6503),
]
DEFAULTS = {
'units_temp': 'C',
'units_wind': 'kmh',
'refresh_min': 10,
}
def _parse_city_line(line):
line = line.strip()
if not line or line.startswith('#'):
return None
parts = [p.strip() for p in line.split(',')]
if len(parts) < 3:
return None
try:
lon = float(parts[-1])
lat = float(parts[-2])
except (ValueError, TypeError):
return None
name = ', '.join(parts[:-2]).strip().strip(',').strip()
has_alnum = False
for c in name:
if ('a' <= c <= 'z') or ('A' <= c <= 'Z') or ('0' <= c <= '9'):
has_alnum = True
break
if not name or not has_alnum:
return None
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
return None
return (name, lat, lon)
def _write_cities_file(cities):
try:
with open(CITIES_FILE, 'w') as f:
f.write('# Cities for the weather app\n')
f.write('# Format: Name,latitude,longitude\n')
for name, lat, lon in cities:
f.write('{},{},{}\n'.format(name, lat, lon))
except Exception as e:
print('write cities:', e)
def load_cities():
parsed = []
try:
with open(CITIES_FILE) as f:
for line in f:
e = _parse_city_line(line)
if e:
parsed.append(e)
except OSError:
_write_cities_file(DEFAULT_CITIES)
return list(DEFAULT_CITIES)
if not parsed:
return list(DEFAULT_CITIES)
return parsed
CITIES = load_cities()
def _read_json(path, default):
try:
with open(path) as f:
return json.load(f)
except:
return default
def _write_json(path, data):
try:
with open(path, 'w') as f:
json.dump(data, f)
except:
pass
settings = dict(DEFAULTS)
settings.update(_read_json(SETTINGS_FILE, {}))
for k, v in DEFAULTS.items():
settings.setdefault(k, v)
def save_settings():
_write_json(SETTINGS_FILE, settings)
def disp_temp(c):
if c is None: return '--'
if settings['units_temp'] == 'F':
return '{}'.format(int(round(c * 9 / 5 + 32)))
return '{}'.format(int(round(c)))
def temp_unit():
return 'F' if settings['units_temp'] == 'F' else 'C'
def disp_wind(kmh):
if kmh is None: return '--'
if settings['units_wind'] == 'mph':
return '{}'.format(int(round(kmh * 0.621)))
return '{}'.format(int(round(kmh)))
def wind_unit():
return 'mph' if settings['units_wind'] == 'mph' else 'kmh'
epd = EPD()
EW, EH = epd.WIDTH, epd.HEIGHT
EMG = 4
CW, LH = 8, 10
i2c0 = I2C(0, sda=Pin(16), scl=Pin(17), freq=400_000)
try:
oled = SSD1306_I2C(128, 64, i2c0)
OLED_OK = True
except Exception as e:
print('OLED init failed:', e)
oled = None
OLED_OK = False
OW, OH = 128, 64
class _Refresher:
def __init__(self, ep):
self.ep = ep
self.partials = 0
def push(self, force_full=False):
if force_full or self.partials >= 5:
self.ep.display_full()
self.partials = 0
else:
if self.partials == 0:
self.ep.display_full()
else:
self.ep.display_partial()
self.partials += 1
def push_full(self):
self.ep.display_full()
self.partials = 0
refresher = _Refresher(epd)
ui = InputManager()
ui.add_encoder('dial', clk=2, dt=3, sw=4,
long_press=True, double_click=True)
WMO = {
0: ('Clear', 'sun'),
1: ('Mostly clear', 'sun'),
2: ('Partly cloud', 'pcloud'),
3: ('Overcast', 'cloud'),
45: ('Fog', 'fog'), 48: ('Rime fog', 'fog'),
51: ('Lt drizzle', 'rain'), 53: ('Drizzle', 'rain'),
55: ('Hv drizzle', 'rain'), 61: ('Lt rain', 'rain'),
63: ('Rain', 'rain'), 65: ('Hv rain', 'rain'),
71: ('Lt snow', 'snow'), 73: ('Snow', 'snow'),
75: ('Hv snow', 'snow'), 77: ('Snow grain','snow'),
80: ('Showers', 'rain'), 81: ('Showers', 'rain'),
82: ('Hv showers', 'rain'), 85: ('Snow shwr', 'snow'),
86: ('Snow shwr', 'snow'),
95: ('Thunder', 'storm'),
96: ('Thunder hail', 'storm'), 99: ('Thunder hail','storm'),
}
def wmo_label(c): return WMO.get(c, ('Unknown', 'cloud'))[0]
def wmo_kind(c): return WMO.get(c, ('Unknown', 'cloud'))[1]
wlan = network.WLAN(network.STA_IF)
def wifi_connect(timeout_s=20):
if wlan.isconnected(): return True
wlan.active(True)
wlan.connect(SSID, PASSWORD)
deadline = time.ticks_add(time.ticks_ms(), timeout_s * 1000)
while time.ticks_diff(deadline, time.ticks_ms()) > 0:
if wlan.isconnected(): return True
time.sleep_ms(150)
return False
def fetch_weather(lat, lon):
url = (
'https://api.open-meteo.com/v1/forecast?'
'latitude={}&longitude={}'
'¤t=temperature_2m,relative_humidity_2m,wind_speed_10m,'
'weather_code,apparent_temperature'
'&daily=temperature_2m_max,temperature_2m_min,weather_code,'
'precipitation_probability_max'
'&timezone=auto&forecast_days=4&wind_speed_unit=kmh'
).format(lat, lon)
try:
r = urequests.get(url)
if r.status_code != 200:
r.close(); return None
data = r.json()
r.close()
gc.collect()
except Exception as e:
print('fetch err:', e)
return None
cur = data.get('current', {}); daily = data.get('daily', {})
return {
'temp': cur.get('temperature_2m'),
'feels': cur.get('apparent_temperature'),
'humidity': cur.get('relative_humidity_2m'),
'wind': cur.get('wind_speed_10m'),
'code': cur.get('weather_code'),
'fc_dates': daily.get('time', []),
'fc_max': daily.get('temperature_2m_max', []),
'fc_min': daily.get('temperature_2m_min', []),
'fc_code': daily.get('weather_code', []),
'fc_pop': daily.get('precipitation_probability_max', []),
}
def weekday_name(date_str):
try:
y, m, d = (int(x) for x in date_str.split('-'))
except:
return '?'
if m < 3: m += 12; y -= 1
K = y % 100; J = y // 100
h = (d + (13 * (m + 1)) // 5 + K + K // 4 + J // 4 + 5 * J) % 7
return ('Sat', 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri')[h]
# ----- e-ink helpers -----
def _ep_filled_circle(fb, cx, cy, r, color):
for dy in range(-r, r + 1):
dx = int((r * r - dy * dy) ** 0.5)
fb.hline(cx - dx, cy + dy, 2 * dx + 1, color)
def _ep_circle_outline(fb, cx, cy, r, color):
for dy in range(-r, r + 1):
for dx in range(-r, r + 1):
d2 = dx * dx + dy * dy
if (r - 1) * (r - 1) <= d2 <= r * r:
fb.pixel(cx + dx, cy + dy, color)
def _ep_cloud_sm(fb, cx, cy, w, color):
r = max(3, w // 4)
_ep_filled_circle(fb, cx - w // 4, cy + 1, r, color)
_ep_filled_circle(fb, cx + w // 4, cy + 1, r, color)
_ep_filled_circle(fb, cx, cy - r // 2, r + 1, color)
fb.fill_rect(cx - w // 3, cy + 1, (2 * w) // 3, r, color)
def ep_icon(fb, cx, cy, kind, size, color=0):
s = size
if kind == 'sun':
_ep_filled_circle(fb, cx, cy, s // 4, color)
for ox, oy in [(0,-1),(1,-1),(1,0),(1,1),(0,1),(-1,1),(-1,0),(-1,-1)]:
x1 = cx + int(ox * (s // 3.2))
y1 = cy + int(oy * (s // 3.2))
x2 = cx + int(ox * (s // 2.2))
y2 = cy + int(oy * (s // 2.2))
fb.line(x1, y1, x2, y2, color)
elif kind == 'pcloud':
scx, scy = cx - s // 4, cy - s // 4
_ep_filled_circle(fb, scx, scy, max(2, s // 8), color)
for ox, oy in [(0,-1),(1,0),(-1,-1)]:
x1 = scx + int(ox * (s // 5))
y1 = scy + int(oy * (s // 5))
x2 = scx + int(ox * (s // 3.5))
y2 = scy + int(oy * (s // 3.5))
fb.line(x1, y1, x2, y2, color)
_ep_cloud_sm(fb, cx + s // 8, cy + s // 8, s, color)
elif kind == 'cloud':
_ep_cloud_sm(fb, cx, cy, s, color)
elif kind == 'rain':
_ep_cloud_sm(fb, cx, cy - s // 8, int(s * 0.85), color)
for dx in (-s // 4, 0, s // 4):
fb.line(cx + dx, cy + s // 4, cx + dx - s // 16,
cy + s // 2, color)
elif kind == 'snow':
_ep_cloud_sm(fb, cx, cy - s // 8, int(s * 0.85), color)
for dx in (-s // 4, 0, s // 4):
sx, sy = cx + dx, cy + s // 3
r = max(2, s // 12)
fb.line(sx - r, sy, sx + r, sy, color)
fb.line(sx, sy - r, sx, sy + r, color)
elif kind == 'fog':
thick = max(1, s // 16)
for i in range(3):
y = cy - s // 3 + i * (s // 4)
for t in range(thick):
fb.hline(cx - s // 2, y + t, s, color)
elif kind == 'storm':
_ep_cloud_sm(fb, cx, cy - s // 8, int(s * 0.85), color)
bx, by = cx, cy + s // 5
fb.line(bx + 1, by, bx - s // 8, by + s // 6, color)
fb.line(bx + 2, by, bx - s // 8 + 1, by + s // 6, color)
fb.line(bx - s // 8, by + s // 6, bx + 1, by + s // 6, color)
fb.line(bx + 1, by + s // 6, bx - s // 12, by + s // 3, color)
def _fit_text(s, max_w):
"""Truncate s to fit within max_w pixels at CW per char."""
chars = max(0, max_w // CW)
if len(s) > chars:
s = s[:chars]
return s
def _draw_big_text(fb, s, x, y, scale):
"""Draw text scaled up by integer factor without per-pixel reads."""
import framebuf
if not s:
return 0
pad_w = ((len(s) * 8 + 7) // 8) * 8
tmp = bytearray(pad_w) # 8 rows * pad_w cols / 8 = pad_w bytes
tfb = framebuf.FrameBuffer(tmp, pad_w, 8, framebuf.MONO_HLSB)
tfb.fill(1)
tfb.text(s, 0, 0, 0)
for ty in range(8):
for txp in range(len(s) * 8):
if tfb.pixel(txp, ty) == 0:
fb.fill_rect(x + txp * scale, y + ty * scale,
scale, scale, 0)
return len(s) * 8 * scale # width in px
# ----- e-ink screens -----
def render_loading(msg):
fb = epd.fb
fb.fill(1)
msg = _fit_text(msg, EW - EMG * 2)
fb.text(msg, (EW - len(msg) * CW) // 2, EH // 2 - 4, 0)
refresher.push_full()
def render_error(msg):
fb = epd.fb
fb.fill(1)
title = 'Error'
fb.text(title, (EW - len(title) * CW) // 2, EH // 2 - 30, 0)
msg = _fit_text(msg, EW - EMG * 2)
fb.text(msg, (EW - len(msg) * CW) // 2, EH // 2 - 4, 0)
hint = _fit_text('click to retry', EW - EMG * 2)
fb.text(hint, (EW - len(hint) * CW) // 2, EH // 2 + 30, 0)
refresher.push_full()
def render_current_page(city, w, full=False):
"""
Layout (top -> bottom):
[HEADER] name (left) ... HH:MM (right) 8 px
[HSEP] 1 px
[ICON] centered weather icon ~icon_h
[LABEL] condition text 8 px
[TEMP] big temperature, 2x scale 16 px
[STATS] feels / hum / wind (3 lines) 3*LH
[HSEP] 1 px
[FOOTER] 'current' ... '1/2' 8 px
Sizes are computed from EH so nothing overlaps even on small panels.
"""
fb = epd.fb
fb.fill(1)
# Header
ts = ''
try:
t = time.localtime()
ts = '{:02d}:{:02d}'.format(t[3], t[4])
except:
ts = ''
ts_w = len(ts) * CW
name_max_w = EW - EMG * 2 - (ts_w + CW if ts else 0)
name = _fit_text(city, name_max_w)
fb.text(name, EMG, EMG, 0)
if ts:
fb.text(ts, EW - EMG - ts_w, EMG, 0)
sep_y_top = EMG + 8 + 2
fb.hline(EMG, sep_y_top, EW - EMG * 2, 0)
# Footer
foot_h = 8
sep_y_bot = EH - EMG - foot_h - 2
fb.hline(EMG, sep_y_bot, EW - EMG * 2, 0)
fb.text('current', EMG, EH - EMG - foot_h, 0)
page_str = '1/2'
fb.text(page_str, EW - EMG - len(page_str) * CW,
EH - EMG - foot_h, 0)
# Body
body_top = sep_y_top + 2
body_bot = sep_y_bot - 2
body_h = body_bot - body_top
# Choose temp scale based on free space
temp_str = '{}{}'.format(disp_temp(w['temp']), temp_unit())
temp_scale = 2 # 16 px tall - safer than 3x (24 px)
label = wmo_label(w['code'])
label = _fit_text(label, EW - EMG * 2)
stats_h = 3 * LH
# Reserve: label (8) + gap (3) + temp (8*scale) + gap (4) + stats
reserved = 8 + 3 + 8 * temp_scale + 4 + stats_h
icon_h = max(20, body_h - reserved - 2)
if icon_h > 56:
icon_h = 56
icon_cx = EW // 2
icon_cy = body_top + icon_h // 2
ep_icon(fb, icon_cx, icon_cy, wmo_kind(w['code']), icon_h, 0)
label_y = body_top + icon_h + 1
fb.text(label, (EW - len(label) * CW) // 2, label_y, 0)
temp_y = label_y + 8 + 3
temp_w = len(temp_str) * 8 * temp_scale
temp_x = (EW - temp_w) // 2
if temp_x < EMG:
temp_x = EMG
_draw_big_text(fb, temp_str, temp_x, temp_y, temp_scale)
stats_y = temp_y + 8 * temp_scale + 4
feels = _fit_text(
'Feels {}{}'.format(disp_temp(w['feels']), temp_unit()),
EW - EMG * 2)
hum = _fit_text(
('Hum {}%'.format(w['humidity'])
if w['humidity'] is not None else 'Hum --'),
EW - EMG * 2)
wind = _fit_text(
'Wind {} {}'.format(disp_wind(w['wind']), wind_unit()),
EW - EMG * 2)
for i, line in enumerate((feels, hum, wind)):
ly = stats_y + i * LH
if ly + 8 > sep_y_bot - 1:
break # safety: don't draw into footer separator
fb.text(line, EMG, ly, 0)
refresher.push(force_full=full)
def render_forecast_page(city, w, full=False):
"""
Layout:
[HEADER] name 8 px
[HSEP] 1 px
[BODY] up to 4 forecast rows, evenly divided
[HSEP] 1 px
[FOOTER] 'forecast' ... '2/2' 8 px
Row contents (left -> right):
day-name (3 ch) icon hi-temp / lo-temp
Row height adapts to fit; no row spills into the footer.
"""
fb = epd.fb
fb.fill(1)
# Header
name = _fit_text(city, EW - EMG * 2)
fb.text(name, EMG, EMG, 0)
sep_y_top = EMG + 8 + 2
fb.hline(EMG, sep_y_top, EW - EMG * 2, 0)
# Footer
foot_h = 8
sep_y_bot = EH - EMG - foot_h - 2
fb.hline(EMG, sep_y_bot, EW - EMG * 2, 0)
fb.text('forecast', EMG, EH - EMG - foot_h, 0)
page_str = '2/2'
fb.text(page_str, EW - EMG - len(page_str) * CW,
EH - EMG - foot_h, 0)
# Body
body_top = sep_y_top + 2
body_bot = sep_y_bot - 2
body_h = body_bot - body_top
n = min(4, len(w.get('fc_dates', [])))
if n <= 0:
msg = _fit_text('No forecast', EW - EMG * 2)
fb.text(msg, (EW - len(msg) * CW) // 2,
body_top + body_h // 2 - 4, 0)
refresher.push(force_full=full)
return
row_h = body_h // n # adaptive, never overflows
icon_size = max(14, min(row_h - 4, 26))
day_w = 3 * CW + 2 # "Mon" + small gap
icon_cx = EMG + day_w + icon_size // 2
text_x = icon_cx + icon_size // 2 + 4
text_avail = EW - EMG - text_x
for i in range(n):
ry = body_top + i * row_h
rcy = ry + row_h // 2
day = weekday_name(w['fc_dates'][i])[:3]
fb.text(day, EMG, rcy - 4, 0)
kind = wmo_kind(w['fc_code'][i] if i < len(w['fc_code']) else 0)
ep_icon(fb, icon_cx, rcy, kind, icon_size, 0)
t_max = '{}{}'.format(
disp_temp(w['fc_max'][i] if i < len(w['fc_max']) else None),
temp_unit())
t_min = '{}{}'.format(
disp_temp(w['fc_min'][i] if i < len(w['fc_min']) else None),
temp_unit())
hi = _fit_text(t_max, text_avail)
lo = _fit_text(t_min, text_avail)
# Stack hi above lo, vertically centered around rcy
hi_y = rcy - 8 - 1
lo_y = rcy + 1
if hi_y < ry:
hi_y = ry
if lo_y + 8 > ry + row_h - 1:
lo_y = ry + row_h - 1 - 8
fb.text(hi, text_x, hi_y, 0)
fb.text(lo, text_x, lo_y, 0)
# Row separator (skip last; never touches footer line)
if i < n - 1:
sy = ry + row_h - 1
if sy < sep_y_bot - 1:
fb.hline(EMG + 2, sy, EW - (EMG + 2) * 2, 0)
refresher.push(force_full=full)
# ----- OLED helpers -----
def _oled_cloud(cx, cy, w):
if not OLED_OK: return
half = w // 2
if cx + half < 0 or cx - half > OW: return
r1 = w // 3
for dy in range(-r1, r1 + 1):
for dx in range(-r1, r1 + 1):
if dx*dx + dy*dy <= r1*r1:
px, py = cx + dx, cy + dy
if 0 <= px < OW and 0 <= py < OH:
oled.pixel(px, py, 1)
r2 = w // 4
for ox in (-w // 3, w // 3):
for dy in range(-r2, r2 + 1):
for dx in range(-r2, r2 + 1):
if dx*dx + dy*dy <= r2*r2:
px, py = cx + ox + dx, cy + dy + 2
if 0 <= px < OW and 0 <= py < OH:
oled.pixel(px, py, 1)
def _hud(ctx):
"""Top status bar: city (left), temp (right). Truncates city to fit."""
if not OLED_OK: return
city = ctx.get('city', '')
temp = ctx.get('temp', '')
if not city and not temp:
return
oled.fill_rect(0, 0, OW, 11, 1)
max_chars = OW // 8
temp_chars = len(temp)
# Reserve: 1 char left margin + temp + 1 char gap + 1 char right margin
city_room = max(0, max_chars - temp_chars - 2)
city_short = city[:city_room]
if city_short:
oled.text(city_short, 2, 2, 0)
if temp:
oled.text(temp, OW - len(temp) * 8 - 2, 2, 0)
def _draw_sun(idx, ctx):
if not OLED_OK: return
oled.fill(0)
cx, cy = OW // 2, OH // 2 + 6
for dy in range(-9, 10):
for dx in range(-9, 10):
if dx*dx + dy*dy <= 81:
oled.pixel(cx + dx, cy + dy, 1)
rot = idx * (2 * math.pi / 16)
for k in range(12):
ang = rot + k * (math.pi / 6)
x1 = cx + int(13 * math.cos(ang))
y1 = cy + int(13 * math.sin(ang))
x2 = cx + int(26 * math.cos(ang))
y2 = cy + int(26 * math.sin(ang))
oled.line(x1, y1, x2, y2, 1)
_hud(ctx)
oled.show()
def _draw_pcloud(idx, ctx):
if not OLED_OK: return
oled.fill(0)
cx, cy = 26, 28
for dy in range(-6, 7):
for dx in range(-6, 7):
if dx*dx + dy*dy <= 36:
oled.pixel(cx + dx, cy + dy, 1)
for ox, oy in [(0,-1),(1,0),(0,1),(-1,0),(1,-1),(1,1),(-1,1),(-1,-1)]:
oled.line(cx + ox*8, cy + oy*8, cx + ox*11, cy + oy*11, 1)
offset = (idx * 2) % (OW + 60) - 30
_oled_cloud(offset + 30, 48, 28)
_hud(ctx)
oled.show()
def _draw_cloud_anim(idx, ctx):
if not OLED_OK: return
oled.fill(0)
o1 = (idx * 2) % (OW + 80) - 40
o2 = (idx * 3 + 40) % (OW + 80) - 40
_oled_cloud(o1, 26, 32)
_oled_cloud(o2, 48, 26)
_hud(ctx)
oled.show()
def _draw_rain(idx, ctx):
if not OLED_OK: return
oled.fill(0)
_oled_cloud(OW // 2, 24, 40)
drops = [(20, 0), (35, 3), (50, 6), (65, 1), (80, 4),
(95, 7), (110, 2), (28, 5), (75, 0)]
for x, phase in drops:
y = 38 + ((idx * 2 + phase * 3) % 22)
oled.line(x, y, x - 1, y + 3, 1)
_hud(ctx)
oled.show()
def _draw_snow(idx, ctx):
if not OLED_OK: return
oled.fill(0)
_oled_cloud(OW // 2, 24, 40)
flakes = [(20, 0), (40, 3), (60, 6), (80, 1), (100, 4),
(30, 5), (70, 2), (90, 7)]
for x, phase in flakes:
y = 38 + ((idx * 2 + phase * 3) % 22)
wobble = int(math.sin((idx + phase) * 0.4) * 2)
fx = x + wobble
if 1 <= fx < OW - 1 and 1 <= y < OH - 1:
oled.pixel(fx, y, 1)
oled.pixel(fx - 1, y, 1)
oled.pixel(fx + 1, y, 1)
oled.pixel(fx, y - 1, 1)
oled.pixel(fx, y + 1, 1)
_hud(ctx)
oled.show()
def _draw_fog(idx, ctx):
if not OLED_OK: return
oled.fill(0)
for row, base_y in enumerate((22, 34, 46, 56)):
offset = (idx * (1 + row % 2) + row * 7) % 16 - 8
for x in range(0, OW, 4):
seg = (x + offset) % OW
oled.line(seg, base_y, min(seg + 2, OW - 1), base_y, 1)
if base_y + 1 < OH:
oled.line(seg, base_y + 1,
min(seg + 2, OW - 1), base_y + 1, 1)
_hud(ctx)
oled.show()
def _draw_storm(idx, ctx):
if not OLED_OK: return
oled.fill(0)
_oled_cloud(OW // 2, 24, 44)
flash = (idx % 6) < 2
if flash:
bx = OW // 2
oled.line(bx + 2, 36, bx - 6, 46, 1)
oled.line(bx + 3, 36, bx - 5, 46, 1)
oled.line(bx - 6, 46, bx + 2, 46, 1)
oled.line(bx + 2, 46, bx - 4, 58, 1)
oled.line(bx + 3, 46, bx - 3, 58, 1)
for x, phase in [(20, 0), (40, 3), (90, 6), (110, 2)]:
y = 40 + ((idx * 2 + phase * 4) % 20)
oled.line(x, y, x - 1, y + 3, 1)
_hud(ctx)
oled.show()
ANIM_CTX = {'city': '', 'temp': '', 'kind': 'cloud'}
ANIMS = {
'sun': Animation(n_frames=16, durations=120, draw_fn=_draw_sun, ctx=ANIM_CTX),
'pcloud': Animation(n_frames=24, durations=140, draw_fn=_draw_pcloud, ctx=ANIM_CTX),
'cloud': Animation(n_frames=24, durations=160, draw_fn=_draw_cloud_anim, ctx=ANIM_CTX),
'rain': Animation(n_frames=8, durations=120, draw_fn=_draw_rain, ctx=ANIM_CTX),
'snow': Animation(n_frames=16, durations=160, draw_fn=_draw_snow, ctx=ANIM_CTX),
'fog': Animation(n_frames=16, durations=180, draw_fn=_draw_fog, ctx=ANIM_CTX),
'storm': Animation(n_frames=12, durations=140, draw_fn=_draw_storm, ctx=ANIM_CTX),
}
def current_anim():
return ANIMS.get(ANIM_CTX['kind'], ANIMS['cloud'])
def oled_clear():
if OLED_OK:
oled.fill(0); oled.show()
def oled_render_city_nav(idx):
"""
Layout:
[BAR] 'Pick city' 0..10
[PICKER] '<' .... NAME .... '>' centered y=24..34
[COUNT] 'i/N' y=42..50
[HINT] 'click ok' ... 'hold x' y=56..63
The center name area is clipped between the arrows so it never overlaps.
"""
if not OLED_OK: return
oled.fill(0)
oled.fill_rect(0, 0, OW, 11, 1)
oled.text('Pick city', 2, 2, 0)
# Arrows at fixed columns
arrow_y = 26
oled.text('<', 0, arrow_y, 1)
oled.text('>', OW - 8, arrow_y, 1)
# Name window strictly between arrows: x in [12, OW-12)
name_left = 12
name_right = OW - 12
name_window = name_right - name_left # px
max_chars = max(1, name_window // 8)
name = CITIES[idx][0]
if len(name) > max_chars:
name = name[:max_chars - 1] + '.' if max_chars > 1 else name[0]
name_w = len(name) * 8
nx = name_left + max(0, (name_window - name_w) // 2)
oled.text(name, nx, arrow_y, 1)
cnt = '{}/{}'.format(idx + 1, len(CITIES))
oled.text(cnt, (OW - len(cnt) * 8) // 2, 42, 1)
# Hints — keep within row
left_hint = 'click ok'
right_hint = 'hold x'
oled.text(left_hint, 0, OH - 8, 1)
oled.text(right_hint, OW - len(right_hint) * 8, OH - 8, 1)
oled.show()
def oled_render_settings(items, sel):
"""
Layout:
[BAR] 'Settings' y=0..10
[ROWS] up to 3 rows of 'label value' y=14, 25, 36
[HINT] 'clk=set hold=x' y=56..63
Rows are computed so highlight bg never touches the bar or hint.
"""
if not OLED_OK: return
oled.fill(0)
oled.fill_rect(0, 0, OW, 11, 1)
oled.text('Settings', 2, 2, 0)
rows_top = 14
row_h = 11
hint_y = OH - 8
max_rows = (hint_y - 2 - rows_top) // row_h # ensure 2 px gap above hint
if max_rows < 1:
max_rows = 1
# Scroll window so selected item is visible
if sel < 0:
sel = 0
if sel >= len(items):
sel = len(items) - 1
start = 0
if len(items) > max_rows:
start = max(0, min(sel - max_rows // 2, len(items) - max_rows))
end = min(len(items), start + max_rows)
max_chars = OW // 8
for vis_i, idx in enumerate(range(start, end)):
label, val = items[idx]
v = str(val)
# Truncate label so 'label value' fits in max_chars with >=1 space
room_label = max(1, max_chars - len(v) - 1)
l = label[:room_label]
pad = max_chars - len(l) - len(v)
if pad < 1:
pad = 1
row = l + ' ' * pad + v
# Hard clip
if len(row) > max_chars:
row = row[:max_chars]
y = rows_top + vis_i * row_h
if idx == sel:
oled.fill_rect(0, y - 1, OW, row_h - 1, 1)
oled.text(row, 0, y, 0)
else:
oled.text(row, 0, y, 1)
# Footer hint, truncated to width
hint = 'clk=set hold=x'
hint = hint[:OW // 8]
oled.text(hint, 0, hint_y, 1)
oled.show()
# ----- state / app loop -----
state = {
'city_idx': 0,
'cache': {},
'last_refresh_ms': 0,
'page': 0,
}
def cache_get(name):
return state['cache'].get(name)
def cache_put(name, data):
state['cache'][name] = {'data': data, 'fetched_at': time.ticks_ms()}
def update_anim_for_current_city():
name = CITIES[state['city_idx']][0]
ANIM_CTX['city'] = name
cached = cache_get(name)
if cached:
w = cached['data']
ANIM_CTX['kind'] = wmo_kind(w['code'])
ANIM_CTX['temp'] = '{}{}'.format(disp_temp(w['temp']), temp_unit())
else:
ANIM_CTX['kind'] = 'cloud'
ANIM_CTX['temp'] = '...'
current_anim().force_redraw()
def fetch_for_current_city(force=False):
name, lat, lon = CITIES[state['city_idx']]
cached = cache_get(name)
age_ms = (time.ticks_diff(time.ticks_ms(), cached['fetched_at'])
if cached else None)
if cached and age_ms < settings['refresh_min'] * 60 * 1000 and not force:
return cached['data']
if not wlan.isconnected():
if not wifi_connect():
return None
w = fetch_weather(lat, lon)
if w is not None:
cache_put(name, w)
return w
def render_eink(full=False):
name = CITIES[state['city_idx']][0]
cached = cache_get(name)
if cached is None:
render_error('No data')
return
w = cached['data']
if state['page'] == 0:
render_current_page(name, w, full=full)
else:
render_forecast_page(name, w, full=full)
def mode_idle():
update_anim_for_current_city()
while True:
a = ui.wait_animated(current_anim().tick)
if a == 'dial':
return 'city_nav'
elif a == 'dial_double':
return 'settings'
elif a == 'dial_long':
return 'force_refresh'
elif a == 'dial+':
state['page'] = (state['page'] + 1) % 2
render_eink()
elif a == 'dial-':
state['page'] = (state['page'] - 1) % 2
render_eink()
def mode_city_nav():
sel = state['city_idx']
oled_render_city_nav(sel)
while True:
a = ui.wait()
if a == 'dial+':
sel = (sel + 1) % len(CITIES)
oled_render_city_nav(sel)
elif a == 'dial-':
sel = (sel - 1) % len(CITIES)
oled_render_city_nav(sel)
elif a == 'dial':
state['city_idx'] = sel
return 'load_and_show'
elif a in ('dial_long', 'dial_double'):
return 'idle'
SETTING_DEFS = [
('Temp', 'units_temp', ['C', 'F']),
('Wind', 'units_wind', ['kmh', 'mph']),
('Refresh', 'refresh_min', [5, 10, 15, 30, 60]),
]
def _cycle_setting(sel, direction):
label, key, options = SETTING_DEFS[sel]
cur = settings[key]
i = options.index(cur) if cur in options else 0
settings[key] = options[(i + direction) % len(options)]
def mode_settings():
sel = 0
while True:
items = []
for label, key, _ in SETTING_DEFS:
v = settings[key]
if key == 'refresh_min':
v = '{}m'.format(v)
items.append((label, str(v)))
oled_render_settings(items, sel)
a = ui.wait()
if a == 'dial+':
sel = (sel + 1) % len(SETTING_DEFS)
elif a == 'dial-':
sel = (sel - 1) % len(SETTING_DEFS)
elif a == 'dial':
_cycle_setting(sel, +1)
elif a in ('dial_long', 'dial_double'):
save_settings()
return 'idle'
def main():
render_loading('Connecting...')
if not wifi_connect():
render_error('WiFi failed')
if OLED_OK:
oled.fill(0)
oled.text('No WiFi', 32, 28, 1)
oled.text('check secrets', 12, 44, 1)
oled.show()
ui.wait()
return main()
render_loading('Fetching...')
w = fetch_for_current_city(force=True)
if w is None:
render_error('Could not fetch')
ui.wait()
return main()
state['last_refresh_ms'] = time.ticks_ms()
render_eink(full=True)
update_anim_for_current_city()
next_mode = 'idle'
while True:
if (time.ticks_diff(time.ticks_ms(), state['last_refresh_ms'])
>= settings['refresh_min'] * 60 * 1000):
fetch_for_current_city(force=True)
state['last_refresh_ms'] = time.ticks_ms()
render_eink()
update_anim_for_current_city()
if next_mode == 'idle':
next_mode = mode_idle()
elif next_mode == 'city_nav':
r = mode_city_nav()
if r == 'load_and_show':
ANIM_CTX['city'] = CITIES[state['city_idx']][0]
ANIM_CTX['temp'] = '...'
ANIM_CTX['kind'] = 'cloud'
current_anim().force_redraw()
fetch_for_current_city(force=False)
state['last_refresh_ms'] = time.ticks_ms()
state['page'] = 0
render_eink(full=True)
next_mode = 'idle'
elif next_mode == 'settings':
mode_settings()
render_eink(full=True)
next_mode = 'idle'
elif next_mode == 'force_refresh':
ANIM_CTX['temp'] = '...'
current_anim().force_redraw()
fetch_for_current_city(force=True)
state['last_refresh_ms'] = time.ticks_ms()
render_eink(full=True)
next_mode = 'idle'
if __name__ == '__main__':
main()
else:
main()





