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:
1 parent
97ebae93b2
commit
8a377af116
9 files changed
+336
-6
No files matched your search
@@ -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 & calibrate</a></footer>
|
||||
· <a href="/jog">Jog & 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 & calibrate</h1>',
|
||||
'<p><a href="/">← Library</a></p><h1>Gyre: jog & 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)),
|
||||
|
||||
@@ -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__':
|
||||
|
||||
Reference in new issue
Block a user