Raspberry Pi Zero 2 W image builder; Power off/Restart buttons

pi/build-image.sh makes a ready-to-flash card: Raspberry Pi OS Lite 2026-10-06
+ the Gyre software as a service on http://gyre.local (port 80), first-boot
setup through cloud-init (user, SSH key, Wi-Fi, packages from the card), USB
host mode on the Zero 2's data port. No root: mtools for the boot partition,
debugfs for the root filesystem. Wi-Fi and credentials stay in the work dir.

Web page: Power off / Restart links when GYRE_POWER_BUTTONS=1 (set by the Pi
service); thumbnails keyed by file contents so pre-made ones ship on the card;
stale thumbnails pruned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ckochandClaude Opus 5.5 committed 2026-11-01 21:10:06 -05:00
1 parent 97ebae93b2
commit 8a377af116
9 files changed
+336 -6

No files matched your search

+53 -6
View File
@@ -25,6 +25,7 @@ CACHE_DIR = os.path.join(SOFTWARE, '.cache', 'thumbs')
REMOVED_DIR = os.path.join(SOFTWARE, 'removed-patterns')
UPLOADS = 'uploads'
LOG_PATH = os.path.join(SOFTWARE, '.cache', 'web.log')
POWER_BUTTONS = os.environ.get('GYRE_POWER_BUTTONS') == '1' # set by the Raspberry Pi service
def log(msg):
@@ -51,6 +52,7 @@ class Library:
self.cfg, self.root, self.cache, self.removed = cfg, root, cache, removed
os.makedirs(cache, exist_ok=True)
self.lock = threading.Lock()
self._hashes = {}
self.index_path = os.path.join(cache, 'index.json')
try:
with open(self.index_path) as f:
@@ -82,8 +84,18 @@ class Library:
return os.path.basename(ident)[:-4], thr.load(self.path(ident))
def _key(self, ident):
st = os.stat(self.path(ident))
return f'{st.st_mtime_ns}-{st.st_size}'
"""Identifies a design's contents, so a thumbnail made on another computer (the Pi image
ships them pre-made) is still recognised. Hashed once per file version."""
p = self.path(ident)
st = os.stat(p)
stamp = (st.st_mtime_ns, st.st_size)
hit = self._hashes.get(p)
if not hit or hit[0] != stamp:
import hashlib
with open(p, 'rb') as f:
hit = (stamp, f'{st.st_size}-{hashlib.sha1(f.read()).hexdigest()[:16]}')
self._hashes[p] = hit
return hit[1]
def thumb(self, ident, size=360):
"""PNG path for a design's thumbnail, made on first use."""
@@ -109,12 +121,19 @@ class Library:
return png
def warm(self):
"""Make every missing thumbnail (in the background at start-up)."""
"""Make every missing thumbnail (in the background at start-up) and delete stale ones."""
keep = set()
for it in self.items():
try:
self.thumb(it['id'])
keep.add(os.path.basename(self.thumb(it['id'])))
except Exception:
pass
for fn in os.listdir(self.cache):
if fn.endswith('.png') and fn not in keep:
try:
os.remove(os.path.join(self.cache, fn))
except OSError:
pass
def add(self, filename, text):
"""Save an uploaded design into patterns/uploads/ and return its id. A .thr file is kept as
@@ -411,7 +430,8 @@ a{color:var(--acc)}
<footer>Click a design to draw it. Each one starts with an erase spiral (turn off "erase first" to draw over the sand as it is).
Make your own at <a href="https://sandify.org" target="_blank" rel="noopener">sandify.org</a> (export as Theta Rho .thr), or upload a line drawing as .svg: every line in it is joined into one path for the ball.
· <a href="/jog">Jog &amp; calibrate</a></footer>
· <a href="/jog">Jog &amp; calibrate</a>
<span id="power" style="display:none"> · <a href="#" onclick="return powerAct('reboot')">Restart</a> · <a href="#" onclick="return powerAct('poweroff')">Power off</a></span></footer>
</main>
<script>
let LIB=[], CAT='all', ST=null;
@@ -485,6 +505,7 @@ async function tick(){
if(!m.connected && !p.message) showErr(m.error);
else if(m.connected && !p.message && $('err').textContent===lastConnErr) showErr('');
lastConnErr=m.connected?'':m.error;
$('power').style.display = ST.power_buttons ? 'inline' : 'none';
if(!settingsLoaded){$('speed').value=p.settings.speed;$('speedVal').textContent=p.settings.speed+' mm/s';$('erase').checked=p.settings.erase;settingsLoaded=true;}
if(p.message) showErr(p.message);
const now=p.now;
@@ -500,6 +521,11 @@ async function tick(){
markPlaying();
}catch(e){$('mach').textContent='offline: is `python3 -m sandtable web` still running?';}
}
function powerAct(what){
const q = what==='poweroff' ? 'Power off the table\'s computer? Unplug it only after the green light on the Pi stops blinking.' : 'Restart the table\'s computer? The page comes back in about a minute.';
if(confirm(q)) post(what).then(j=>{ if(j.ok) showErr(j.msg); });
return false;
}
loadLib(); tick(); setInterval(tick,1000);
</script></body></html>
"""
@@ -514,6 +540,25 @@ def serve(ctrl, cfg, host='127.0.0.1', port=8765, open_browser=True, open_path='
jog_page = JOG_PAGE.replace('<h1>Gyre: jog &amp; calibrate</h1>',
'<p><a href="/">&larr; Library</a></p><h1>Gyre: jog &amp; calibrate</h1>')
def power(what):
"""Shut the computer this runs on down (or restart it) cleanly: stop any design and let
the motors rest first. Pulling the plug on a Raspberry Pi can corrupt its SD card."""
if not POWER_BUTTONS:
return False, 'not available here'
log(f'{what} requested from the page')
player.stop()
try:
if panel.connected:
with panel.lock:
ctrl.motors_off()
except Exception:
pass
import subprocess
r = subprocess.run(['sudo', '-n', '/usr/bin/systemctl', what], capture_output=True, text=True)
if r.returncode != 0:
return False, f'could not {what}: {(r.stderr or r.stdout).strip()}'
return True, 'powering off: wait until the Pi\'s green light stops blinking before unplugging' if what == 'poweroff' else 'restarting'
def busy():
if not panel.connected:
return False, panel.error or 'the Uno is not connected'
@@ -545,7 +590,7 @@ def serve(ctrl, cfg, host='127.0.0.1', port=8765, open_browser=True, open_path='
self._send(200, json.dumps({'machine': st, 'player': player.status(),
'state': st['state'], 'theta': st['theta'], 'rho': st['rho'],
'switch': st['switch'], 'pins': st['pins'], 'homed': st['homed'], 'steps': st['steps'],
'error': st['error']}))
'error': st['error'], 'power_buttons': POWER_BUTTONS}))
elif u.path == '/api/library':
self._send(200, json.dumps(lib.items()))
elif u.path == '/thumb':
@@ -595,6 +640,8 @@ def serve(ctrl, cfg, host='127.0.0.1', port=8765, open_browser=True, open_path='
ok, msg = True, 'saved'
elif act == 'delete':
lib.remove(body.get('id', '')); ok, msg = True, 'removed'
elif act in ('poweroff', 'reboot'):
ok, msg = power(act)
elif act in ('jog', 'home', 'unlock', 'calibrate'):
ok, msg = busy() or {
'jog': lambda: panel.jog(body.get('axis'), body.get('d', 0)),
+11
View File
@@ -64,6 +64,15 @@ class TestLibrary(unittest.TestCase):
self.assertTrue(os.path.getsize(png) > 1000)
self.assertGreater(self.lib.items()[1]['est'], 0) # running time known once the thumbnail is made
def test_thumb_key_follows_contents_not_dates(self):
k1 = self.lib._key('spirals/tiny.thr')
p = self.lib.path('spirals/tiny.thr')
os.utime(p, (1, 1)) # same contents, other date
self.assertEqual(self.lib._key('spirals/tiny.thr'), k1)
with open(p, 'a') as f:
f.write('0 0\n')
self.assertNotEqual(self.lib._key('spirals/tiny.thr'), k1)
def test_refuses_paths_outside(self):
for bad in ('../cache/index.json', '../../tiny.thr', '/etc/passwd', 'spirals/../../x.thr', 'spirals/nope.thr'):
with self.assertRaises(KeyError):
@@ -264,6 +273,8 @@ class TestHttp(unittest.TestCase):
self.assertFalse(self.post('play', {'ids': ['../../etc/passwd']})['ok'])
up = self.post('upload?name=new.thr', raw=b'0 0\n6.28 1\n')
self.assertEqual(up['msg'], 'uploads/new.thr')
self.assertFalse(json.loads(self.get('/api/status'))['power_buttons']) # off unless on the Pi
self.assertFalse(self.post('poweroff')['ok'])
if __name__ == '__main__':