Loading files
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<script src="/pf-console.js"></script>
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Patternflow — Firmware update</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--cream:#0C0B09;--cream2:#1B1914;--ink:#EDE7DB;--muted:#8A8272;--faint:#5A5546;--rule:#242118;--rule-soft:#1B1914;--led:#FF5C2E;--panel:#131110;
--sans:'Inter',ui-sans-serif,system-ui,sans-serif;
--mono:'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace}
*{box-sizing:border-box;margin:0;padding:0}
body{min-height:100vh;background:var(--cream);color:var(--ink);
font:15px/1.55 var(--sans);-webkit-font-smoothing:antialiased;text-rendering:geometricPrecision;
padding:24px 24px 64px}
.panel{width:100%;max-width:560px;background:var(--panel);margin:16px auto 0;padding:48px 48px 44px}
header{display:flex;align-items:center;gap:8px;padding-bottom:12px;border-bottom:1px solid var(--rule);margin-bottom:24px}
.dot{width:7px;height:7px;background:var(--led);border-radius:1px}
h1{font-size:15px;font-weight:600;letter-spacing:.01em;margin:0;flex:1}
.back{font-family:var(--mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;
color:var(--muted);text-decoration:none}
.back:hover{color:var(--ink);text-decoration:underline;text-underline-offset:3px}
#pill{font-family:var(--mono);font-size:10.5px;letter-spacing:.12em;text-transform:uppercase;
padding:5px 10px 4px;border:1px solid var(--faint);color:var(--muted)}
#pill.ok{border-color:var(--ink);color:var(--ink)}
#pill.warn{border-color:var(--led);color:var(--led)}
#pill.bad{border-color:var(--faint);color:var(--faint)}
.sub{max-width:36ch;color:var(--muted);font-size:15px;line-height:1.5;margin-bottom:32px}
.pf-kicker{display:block;font-family:var(--mono);font-size:11px;letter-spacing:.14em;text-transform:uppercase;
color:var(--muted);margin-bottom:2px}
.step{display:grid;grid-template-columns:40px 1fr;gap:20px;padding:14px 0;border-bottom:1px solid var(--rule);align-items:baseline}
.step:first-of-type{border-top:1px solid var(--rule)}
.step .n{font-family:var(--mono);font-size:10px;letter-spacing:.12em;color:var(--muted)}
.step p{font-size:14px;line-height:1.5;color:var(--muted)}
.step b{font-weight:500;color:var(--ink)}
#armHint{display:none;margin:20px 0 0;border:1px dashed var(--rule);background:var(--cream);
padding:14px 16px;font-family:var(--mono);font-size:11.5px;line-height:1.6;color:var(--muted)}
#armHint b{font-weight:500;color:var(--ink)}
#handoff{display:none;margin:0 0 24px;padding:16px 18px;border:1px solid var(--rule);background:var(--cream)}
#handoffFrom{font-family:var(--mono);font-size:11px;color:var(--muted);word-break:break-all;margin:6px 0 14px;line-height:1.6}
#handoffGo{font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;
background:var(--ink);color:var(--cream);border:1px solid var(--ink);padding:12px 16px;cursor:pointer;
transition:background .15s ease,color .15s ease}
#handoffGo:hover{background:transparent;color:var(--ink)}
#handoffGo:disabled{opacity:.5;cursor:default}
#drop{margin-top:28px;min-height:176px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;
border:1px dashed var(--rule);background:var(--cream);cursor:pointer;text-align:center;padding:24px;
transition:border-color .16s ease,background .16s ease}
#drop .big{font-family:var(--mono);font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink)}
#drop .small{font-family:var(--mono);font-size:10px;letter-spacing:.08em;text-transform:uppercase;color:var(--faint)}
#drop.hover{border-color:var(--ink);background:var(--cream2)}
#drop.disabled{opacity:.45}
#bar{height:6px;background:var(--rule-soft);margin:20px 0 10px;display:none}
#fill{height:100%;width:0%;background:var(--led);transition:width .1s linear}
#msg{font-size:13px;min-height:20px;color:var(--muted);line-height:1.55;margin-top:12px}
#msg.ok{color:var(--ink)} #msg.bad{color:var(--led)}
@media(max-width:560px){body{padding:12px 12px 56px}.panel{padding:36px 24px 32px}}
</style></head><body>
<main class="panel">
<header><span class="dot"></span><h1>Update</h1><span id="pill">…</span></header>
<p class="sub">Drop a firmware .bin — the device flashes itself over the LAN and reboots on the new build.</p>
<!-- Shown when the build page hands a firmware URL over via ?src= —
the phone never touches a file picker. -->
<div id="handoff">
<span class="pf-kicker">Linked build</span>
<div id="handoffFrom"></div>
<button id="handoffGo" type="button">Fetch & flash</button>
</div>
<span class="pf-kicker">Two steps</span>
<div class="step"><span class="n">01</span><p>Build & download a <b>.bin</b> — patternflow.work → Build, or an <b>arduino-cli</b> export.</p></div>
<div class="step"><span class="n">02</span><p>Drop it below. Keep the device powered; it verifies, flashes, and reboots itself.</p></div>
<div id="armHint">This build requires arming first. On the device: hold <b>K2</b> → NETWORK, then turn <b>K4</b> → <b>UPDATE</b>.</div>
<div id="drop">
<span class="big">Drop .bin here</span>
<span class="small">or click to choose a file</span>
</div>
<!-- accept="*/*", not ".bin": Android maps unknown extensions to a
media-only picker (camera/gallery), and on some devices NO accept
does the same — an explicit any-type accept is what reliably brings
up the full chooser with the file manager. The JS validates
extension and size instead. -->
<input id="file" type="file" accept="*/*" style="display:none">
<div id="bar"><div id="fill"></div></div>
<div id="msg"></div>
</main>
<script>
var drop=document.getElementById('drop'),fileIn=document.getElementById('file'),
pill=document.getElementById('pill'),bar=document.getElementById('bar'),
fill=document.getElementById('fill'),msg=document.getElementById('msg'),
armHint=document.getElementById('armHint');
var armed=false,busy=false,uploading=false;
function setPill(t,c){pill.textContent=t;pill.className=c||''}
function setMsg(t,c){msg.textContent=t;msg.className=c||''}
// The device serves one client at a time. Skipping polls while uploading is
// not enough on its own: a poll issued a moment BEFORE the upload starts is
// still in flight, and that one request is enough to wedge a 1.2 MB POST into
// "Connection lost during upload". So the poll is abortable and the upload
// cancels it before opening its own connection.
var pollAbort=null;
function poll(){
if(uploading)return;
pollAbort=new AbortController();
fetch('/update/status',{cache:'no-store',signal:pollAbort.signal})
.then(function(r){return r.json()}).then(function(s){
armed=s.armed;busy=s.busy;
if(busy)setPill('BUSY','warn');
else if(armed)setPill('READY','ok');
else setPill('LOCKED','bad');
armHint.style.display=(!armed&&!busy)?'block':'none';
drop.classList.toggle('disabled',!armed||busy);
}).catch(function(e){if(!e||e.name!=='AbortError')setPill('OFFLINE','bad')});
}
setInterval(poll,2000);poll();
drop.addEventListener('click',function(){if(!uploading)fileIn.click()});
fileIn.addEventListener('change',function(){if(fileIn.files.length)upload(fileIn.files[0])});
['dragover','dragenter'].forEach(function(ev){drop.addEventListener(ev,function(e){e.preventDefault();drop.classList.add('hover')})});
['dragleave','drop'].forEach(function(ev){drop.addEventListener(ev,function(e){e.preventDefault();drop.classList.remove('hover')})});
drop.addEventListener('drop',function(e){if(!uploading&&e.dataTransfer.files.length)upload(e.dataTransfer.files[0])});
function upload(f){
if(uploading)return;
if(!/\.bin$/i.test(f.name)){setMsg('That is not a .bin file.','bad');return}
if(f.size>3*1024*1024){setMsg('Too big for the 3 MB app partition. This looks like a merged full-flash image; upload the app .bin instead.','bad');return}
if(f.size<200*1024){setMsg('Suspiciously small for Patternflow firmware. Wrong file?','bad');return}
if(!armed){setMsg('Device is locked - see the arming note above.','bad');return}
uploading=true;
if(pollAbort)pollAbort.abort();
setPill('FLASHING','warn');
bar.style.display='block';fill.style.width='0%';
setMsg('Uploading '+f.name+' ('+(f.size/1048576).toFixed(2)+' MB). Keep this tab open and the device powered.');
var xhr=new XMLHttpRequest();
xhr.open('POST','/update?size='+f.size);
xhr.timeout=300000;
xhr.upload.onprogress=function(e){if(e.lengthComputable)fill.style.width=Math.round(e.loaded*100/e.total)+'%'};
xhr.onload=function(){
uploading=false;
if(xhr.status===200){
fill.style.width='100%';
setMsg('Flashed. Rebooting - the device should be back in about 10 seconds.','ok');
waitForReboot();
}else{
var t='upload failed ('+xhr.status+')';
try{t=JSON.parse(xhr.responseText).error||t}catch(e){}
setMsg('Failed: '+t,'bad');
bar.style.display='none';
}
};
xhr.onerror=function(){
uploading=false;
setMsg('Connection lost during upload. The device still runs its old firmware unless it just rebooted - if in doubt, try again.','bad');
bar.style.display='none';
};
xhr.ontimeout=function(){
uploading=false;
setMsg('Upload timed out. Try again closer to the access point, or flash over USB.','bad');
bar.style.display='none';
};
var fd=new FormData();fd.append('firmware',f,f.name);
xhr.send(fd);
}
function waitForReboot(){
var tries=0;
var t=setInterval(function(){
if(++tries>30){clearInterval(t);return}
fetch('/update/status',{cache:'no-store'}).then(function(r){
if(r.ok){clearInterval(t);setPill('BACK','ok');setMsg('Device is back online on the new firmware.','ok')}
}).catch(function(){});
},2000);
}
// ?src= handoff from the build page ("Send over Wi-Fi"): this page fetches
// the .bin itself and feeds it into the same upload path — no file picker,
// which some Android systems cannot show for arbitrary files. The firmware
// route serves public CORS for exactly this fetch.
var srcUrl=null;
try{
var s=new URLSearchParams(location.search).get('src');
if(s&&/^https?:\/\//i.test(s)){
srcUrl=s;
document.getElementById('handoffFrom').textContent=s.replace(/^https?:\/\//i,'');
document.getElementById('handoff').style.display='block';
}
}catch(e){}
document.getElementById('handoffGo').addEventListener('click',function(){
if(!srcUrl||uploading)return;
if(!armed){setMsg('Device is locked - see the arming note above.','bad');return}
var btn=this;
btn.disabled=true;
setPill('FETCHING','warn');
setMsg('Downloading the build from '+srcUrl.split('/')[2]+' ...');
fetch(srcUrl).then(function(r){
if(!r.ok)throw new Error('HTTP '+r.status);
return r.blob();
}).then(function(b){
btn.disabled=false;
upload(new File([b],'firmware.bin',{type:'application/octet-stream'}));
}).catch(function(e){
btn.disabled=false;
poll();
setMsg('Could not download the build ('+e.message+'). Download the .bin on this device and drop it above instead.','bad');
});
});
</script>
</body></html>
e5b871ed5This file is pinned to the selected release. Download the original to open it in your design tool.