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 - Wi-Fi</title>
<style>
/* Dark instrument tokens — see patterns_index.h for the note. */
:root{--cream:#0C0B09;--ink:#EDE7DB;--muted:#8A8272;--faint:#5A5546;
--rule:#242118;--rule-soft:#1B1914;--led:#FF5C2E;--ok:#57B87F;--panel:#131110;
--sans:'Inter',ui-sans-serif,system-ui,sans-serif;
--mono:'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,monospace}
*{box-sizing:border-box}
[hidden]{display:none!important}
body{margin:0;background:var(--cream);color:var(--ink);font-family:var(--sans);
line-height:1.5;-webkit-font-smoothing:antialiased}
.wrap{max-width:680px;margin:0 auto;padding:32px 20px 64px}
header{display:flex;align-items:center;gap:8px;padding-bottom:12px;
border-bottom:1px solid var(--rule)}
.dot{width:7px;height:7px;background:var(--led);border-radius:1px}
h1{font-size:15px;font-weight:600;margin:0;flex:1}
.sub{font-family:var(--mono);font-size:11px;color:var(--faint);text-align:right}
section{margin-top:26px}
h2{font-size:11px;font-weight:600;letter-spacing:.09em;text-transform:uppercase;
color:var(--muted);margin:0 0 8px}
ul{list-style:none;margin:0;padding:0;border-top:1px solid var(--rule-soft)}
li{display:flex;align-items:center;gap:10px;padding:9px 2px;
border-bottom:1px solid var(--rule-soft);font-size:13px}
li .n{font-family:var(--mono);font-size:11px;color:var(--faint);min-width:20px}
.nm{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
font-family:var(--mono);font-size:12px}
.tag{font-family:var(--mono);font-size:10px;letter-spacing:.06em;
text-transform:uppercase;padding:1px 6px;border:1px solid var(--ok);
border-radius:2px;color:var(--ok)}
button{font:inherit;cursor:pointer;position:relative}
button.del{font-size:11px;color:var(--muted);background:none;
border:1px solid var(--rule);border-radius:2px;padding:2px 8px}
button.del:hover{border-color:var(--led);color:var(--led)}
form{margin:0;display:grid;gap:10px;max-width:380px}
label,.lbl{display:block;font-size:12px;color:var(--muted);margin:0 0 3px}
input{width:100%;font:inherit;font-family:var(--mono);font-size:13px;padding:7px 9px;
background:var(--panel);color:var(--ink);border:1px solid var(--rule);border-radius:2px}
input:focus{outline:none;border-color:var(--led)}
.actions{display:flex;gap:8px;align-items:center;margin-top:2px}
button.primary{background:var(--ink);color:var(--cream);border:1px solid var(--ink);
border-radius:2px;padding:7px 16px;font-size:13px}
button.primary:hover{background:var(--led);border-color:var(--led)}
.check{display:flex;gap:6px;align-items:center;font-size:12px;color:var(--muted)}
.check input{width:auto}
/* Result lines, filled by PF.say next to the control that caused them. */
.msg{margin-top:10px;font-family:var(--mono);font-size:11px;min-height:16px;color:var(--muted)}
.msg.pf-ok{color:var(--ok)}.msg.pf-err{color:var(--led)}
#join{margin-top:12px;padding:10px 12px;border:1px solid var(--rule);border-radius:2px;
font-size:13px;line-height:1.5;max-width:520px}
#join.ok{border-color:var(--ok)}
#join.err{border-color:var(--led)}
#join b{font-family:var(--mono);font-weight:600}
#join a{color:var(--ink)}
#join .big{display:inline-block;margin:6px 10px 6px 0;font-family:var(--mono);
font-size:20px;word-break:break-all}
.note{font-size:12px;color:var(--faint);margin:8px 0 0;line-height:1.45}
footer{margin-top:32px;padding-top:12px;border-top:1px solid var(--rule);
font-family:var(--mono);font-size:11px;color:var(--faint)}
a{color:var(--muted)}
.tag.boot{border-color:var(--muted);color:var(--muted)}
select{width:100%;max-width:380px;font:inherit;font-family:var(--mono);font-size:13px;
padding:7px 9px;background:var(--panel);color:var(--ink);border:1px solid var(--rule);
border-radius:2px}
button.secondary{font-size:13px;color:var(--ink);background:none;
border:1px solid var(--rule);border-radius:2px;padding:7px 16px}
button.secondary:hover{border-color:var(--led);color:var(--led)}
.ghost{font-family:var(--mono);font-size:11px;color:var(--muted);background:none;
border:1px solid var(--rule);border-radius:2px;padding:3px 10px}
.ghost:hover{border-color:var(--led);color:var(--led)}
.row{display:flex;gap:6px}
.row input{flex:1;min-width:0}
/* What the hotspot's channel scan found, strongest first: a row to tap. */
.scan li{padding:0}
.scan button{flex:1;display:flex;align-items:center;gap:10px;min-width:0;padding:8px 2px;
background:none;border:0;color:var(--ink);text-align:left}
.scan button:hover .nm{color:var(--led)}
.scan:not(.all) li:nth-child(n+7){display:none}
.bars{display:flex;align-items:flex-end;gap:2px;height:10px}
.bars i{width:3px;height:40%;background:var(--rule)}
.bars i+i{height:60%}.bars i+i+i{height:80%}.bars i+i+i+i{height:100%}
.s1 i:first-child,.s2 i:nth-child(-n+2),.s3 i:nth-child(-n+3),.s4 i{background:var(--muted)}
.more,.chip button{background:none;border:0;padding:0;font-family:var(--mono);
font-size:11px;text-decoration:underline}
.more{margin-top:6px;color:var(--muted)}
.chip{display:flex;flex-wrap:wrap;align-items:center;gap:4px 12px;margin-top:6px;
padding:5px 8px;border:1px solid var(--led);border-radius:2px;font-family:var(--mono);font-size:11px}
.chip button{color:var(--led)}
/* Small controls keep their look; on a touch screen the hit area grows. */
@media(pointer:coarse){.scan button{padding:12px 2px}
.del::after,.more::after,.chip button::after,#join .ghost::after{content:"";
position:absolute;inset:-11px -6px}}
html.pf-offline #st,html.pf-offline #list,html.pf-offline #hsstate{opacity:.45}
</style></head><body><div class="wrap">
<header><span class="dot"></span><h1>Wi-Fi</h1><span class="sub" id="st">-</span></header>
<section id="saved" hidden>
<h2>Saved networks</h2>
<ul id="list"></ul>
<p class="note" id="cap"></p>
<div class="msg" id="listmsg"></div>
</section>
<section id="bootsec" hidden>
<h2>Boot network</h2>
<label for="boot">Try first on power-up</label>
<select id="boot"></select>
<div class="actions" style="margin-top:10px">
<button class="secondary" type="button" id="bootsave">Save boot preference</button>
<button class="secondary" type="button" id="reboot">Reboot</button>
</div>
<div class="msg" id="bootmsg"></div>
<p class="note">Stored on the device. Reboot to join the chosen network first —
the current session is not switched live.</p>
</section>
<section id="add">
<h2>Add a network</h2>
<form id="f">
<div id="scanbox" hidden>
<p class="lbl">Networks the panel found — tap one</p>
<ul class="scan" id="scan"></ul>
<button class="more" type="button" id="more" hidden></button>
</div>
<div>
<label for="ssid">Network name</label>
<input id="ssid" name="ssid" maxlength="32" required list="seen"
autocomplete="off" autocapitalize="none" autocorrect="off" spellcheck="false">
<datalist id="seen"></datalist>
<div class="chip" id="dym" hidden>Did you mean «<b id="dymn"></b>»?
<button type="button" id="dymuse">use it</button><button type="button" id="dymkeep">keep mine</button></div>
</div>
<div>
<label for="pass">Password</label>
<div class="row">
<input id="pass" name="pass" type="password" maxlength="63"
autocomplete="off" autocapitalize="none" autocorrect="off" spellcheck="false">
<button class="ghost" type="button" id="pwt" aria-controls="pass" aria-pressed="false">show</button>
</div>
</div>
<div class="actions">
<button class="primary" type="submit" id="addbtn">Save</button>
<span class="check" id="nowbox">
<input type="checkbox" id="now"><label for="now" style="margin:0">switch now</label>
</span>
</div>
</form>
<div class="msg" id="msg"></div>
<div id="join" hidden></div>
<p class="note">Saved networks are tried in order starting from the boot slot,
wrapping around — so the device joins whichever one it can find, useful if it
moves between home, a studio and a venue. A new network is only stored; leave
<em>switch now</em> unticked and this page keeps working. Ticking it drops the
current connection immediately. On the panel's hotspot a network is tried at once,
and the panel shows the outcome on its screen as well as here: the address to open,
or why it could not join.</p>
</section>
<section>
<h2>Hotspot</h2>
<p class="note" id="hsstate">-</p>
<form id="hsf">
<div>
<label for="hsmode">Mode</label>
<select id="hsmode">
<option value="auto">auto — up whenever the panel has no Wi-Fi</option>
<option value="always">always</option>
<option value="off">off</option>
</select>
</div>
<div>
<label for="hspass">Password</label>
<input id="hspass" type="text" maxlength="63"
autocomplete="off" autocapitalize="none" autocorrect="off" spellcheck="false">
</div>
<div class="actions">
<button class="primary" type="submit" id="hssave">Save</button>
</div>
</form>
<div class="msg" id="hsmsg"></div>
<p class="note">The panel as a network of its own: <b id="hsssid">patternflow-????</b>.
Join it from a phone or a laptop and open <code>http://192.168.4.1/</code> — the
same console, anywhere. <em>auto</em> raises it about fifteen seconds after the panel
finds no network and drops it once one is joined; a network added above from the
hotspot is tried at once. The phone is told the network works, so it stays on it;
it has no internet through the panel.</p>
</section>
<footer><a href="/">Home</a> · <a href="/status">Status</a></footer>
</div>
<script>
function $(i){return document.getElementById(i)}
function esc(t){return String(t).replace(/[&<>"]/g,function(c){
return {'&':'&','<':'<','>':'>','"':'"'}[c]})}
function enc(t){return encodeURIComponent(t)}
function j(r){return r.json()}
function post(u,body){return fetch(u,{method:'POST',
headers:{'Content-Type':'application/x-www-form-urlencoded'},body:body}).then(j)}
function say(t,k){PF.say(t,k||'ok',$('msg'))}
function sayHs(t,k){PF.say(t,k||'ok',$('hsmsg'))}
function bsay(t,k){PF.say(t,k||'ok',$('bootmsg'))}
// Only our own Errors carry the device's words.
function why(e){return e&&e.name==='Error'&&e.message||'failed'}
// On the panel's hotspot every name resolves to the panel, so the address
// cannot say where this phone is; /api/status can.
var viaHs=false,connected=true,max=5;
var seenNames=[],seenKey='',hsName='',hsLoaded=null;
// Unsaved picks the refresh must not overwrite.
var bootDirty=false,hsDirty=false;
var kept='',dymWait=0,joinKey='',listKey='',fast=false,arranged=0;
function dirty(){PF.dirty=bootDirty||hsDirty||!!($('ssid').value||$('pass').value)}
// Asked first: the lane answers it before the lists, so the first reply
// already knows viaHs.
PF.status(function(s){viaHs=!!s.viaHotspot;if(!hsName&&s.hotspot)hsName=s.hotspot.ssid;nowBox()});
// One chained refresh, list then hotspot (the panel answers one connection
// at a time); every 2 s while a join is being tried.
var poll=PF.poll(function(){
var w;
return fetch('/api/wifi').then(j).then(function(d){w=d;return fetch('/api/hotspot').then(j)})
.then(function(h){return {w:w,h:h}},function(e){if(w)return {w:w};throw e});
},5000,function(d){if(d){showWifi(d.w);if(d.h)showHs(d.h)}});
// The status word (NO SSID, AUTH FAIL...) is sized for the LEDs.
function stText(d){
if(d.connected)return 'on '+d.current+' · '+d.ip;
return 'not on Wi-Fi — '+(!d.networks.length?'nothing saved yet':
d.status==='NO SSID'?'no saved network in range':
d.status==='AUTH FAIL'?'a saved network refused the password':'trying the saved networks');
}
// There the panel tries a new network at once anyway (core_wifi_http.h).
function nowBox(){var h=viaHs&&!connected;$('nowbox').hidden=h;if(h)$('now').checked=false}
function showWifi(d){
var n=d.networks,sel=$('boot'),keep=d.bootIdx,cur,k;
max=d.max;connected=d.connected;
$('st').textContent=stText(d);
// Decided once, on the first reply: the form first while setting up (on
// the hotspot, or nothing saved), else below the lists. The lists stay
// hidden till then, so the form never jumps on the hotspot's slow link.
if(!arranged){
arranged=1;
if(viaHs||!n.length)$('add').parentNode.insertBefore($('add'),$('saved'));
$('saved').hidden=false;
}
if(($('bootsec').hidden=n.length<2)&&bootDirty){bootDirty=false;dirty()}
nowBox();
// Rebuilt only on change: a tap on forget and a boot pick survive a refresh.
k=JSON.stringify([n,d.connected&&d.current,d.bootIdx]);
if(k!==listKey){
listKey=k;
if(bootDirty){
cur=parseInt(sel.value,10);
if(cur<n.length)keep=cur;else{bootDirty=false;dirty()}
}
$('list').innerHTML=n.length?'':'<li><span class="nm" style="color:var(--faint)">none saved yet</span></li>';
sel.innerHTML='';
n.forEach(function(x,i){
var li=document.createElement('li');
li.innerHTML='<span class="n">'+(i+1)+'</span><span class="nm"></span>'+
(d.connected&&x.ssid===d.current?'<span class="tag">connected</span>':'')+
(d.bootIdx===i?'<span class="tag boot">boot</span>':'')+
'<button class="del" type="button">forget</button>';
li.children[1].textContent=x.ssid;
li.lastChild.onclick=function(){del(x.ssid,this)};
$('list').appendChild(li);
sel.add(new Option((i+1)+'. '+x.ssid,i,false,keep===i));
});
}
$('cap').textContent=n.length+' of '+max+' slots used'+
(n.length>=max?' — adding another replaces the last one':'');
renderJoin(d.join);
}
// What became of the last network sent from a console (core_wifi.h keeps it).
// Shown until ten minutes after it settled; redrawn only on change.
function renderJoin(j){
var box=$('join'),on=!!j&&j.state!=='none'&&(j.state==='trying'||j.ago<=600),
t=on&&j.state==='trying',k=on?[j.state,j.ssid,j.ip,j.why].join('|'):'',n,u,w;
if(t!==fast){fast=t;poll.set(t?2000:5000)}
if(k===joinKey)return;
joinKey=k;box.hidden=!on;
if(!on)return;
n='<b>'+esc(j.ssid)+'</b>';
box.className='';
if(t){
box.innerHTML='Joining '+n+'… If this page stops answering, the panel has moved '+
'to that network: switch this phone to '+n+' and open the address the panel shows '+
'on its screen'+(hsName?' (or <b>http://'+esc(hsName)+'.local/</b> where names work)':'')+'.';
}else if(j.state==='joined'){
box.className='ok';
u='http://'+esc(j.ip)+'/';
box.innerHTML='Joined '+n+'. Switch this phone to '+n+', then open<br>'+
'<a class="big" href="'+u+'">'+u+'</a><button class="ghost" type="button" id="copy">copy</button>'+
' <span class="msg" id="copymsg"></span><br>The panel shows the address on its screen for a minute.';
$('copy').onclick=function(){
copy('http://'+j.ip+'/').then(function(){PF.say('copied','ok',$('copymsg'))},
function(){PF.say('press and hold the address to copy it','err',$('copymsg'))});
};
}else{
box.className='err';
w=j.why||'no answer from the network';
box.innerHTML='Could not join '+n+': '+esc(w)+'. '+
(w==='wrong password'?'Check the password and send it again.':
w==='network not found'?'Check the name, which is case-sensitive, and that the network is in range.':
'Try again closer to the router.');
}
}
// http:// is not a secure context: navigator.clipboard is usually missing.
function copy(t){
if(navigator.clipboard&&window.isSecureContext)return navigator.clipboard.writeText(t);
var s=document.createElement('span'),g=getSelection(),r=document.createRange(),ok=false;
s.textContent=t;
s.style.cssText='position:fixed;top:0;clip:rect(0,0,0,0);white-space:pre;-webkit-user-select:text;user-select:text';
document.body.appendChild(s);r.selectNodeContents(s);g.removeAllRanges();g.addRange(r);
try{ok=document.execCommand('copy')}catch(e){}
g.removeAllRanges();document.body.removeChild(s);
return ok?Promise.resolve():Promise.reject(Error('copy'));
}
function showHs(d){
var h=d.hotspot,c=h.clients,seen=(d.seen||[]).filter(function(s){return s.ssid}),k=JSON.stringify(seen);
hsName=h.ssid;$('hsssid').textContent=h.ssid;
hsLoaded={mode:h.mode,pass:d.pass};
if(!hsDirty){put($('hsmode'),h.mode);put($('hspass'),d.pass)}
$('hsstate').textContent=h.up?'On: '+h.ssid+' on channel '+h.channel+' · '+
(c?c+(c===1?' device':' devices')+' on it':'nobody on it')+' · '+h.ip:
h.mode==='off'?'Off.':h.mode==='auto'?'Off — up when the panel has no Wi-Fi.':'Off — starting.';
if(k===seenKey)return; // rebuilt only on change, never under a thumb
seenKey=k;seenNames=seen.map(function(s){return s.ssid});
$('seen').innerHTML=$('scan').innerHTML='';
seen.forEach(function(s){
var o=document.createElement('option'),li=document.createElement('li'),
b=document.createElement('button'),q=s.rssi>=-55?4:s.rssi>=-67?3:s.rssi>=-78?2:1;
o.value=s.ssid;$('seen').appendChild(o);
b.type='button';
b.innerHTML='<span class="nm"></span><span class="bars s'+q+'" role="img" aria-label="signal '+q+
' of 4"><i></i><i></i><i></i><i></i></span>';
b.firstChild.textContent=s.ssid;
b.onclick=function(){$('ssid').value=s.ssid;chk();dirty();$('pass').focus()};
li.appendChild(b);$('scan').appendChild(li);
});
$('scanbox').hidden=!seen.length;
$('more').hidden=seen.length<7;
more();
if($('ssid').value)chk();
}
function put(el,v){if(el.value!==v)el.value=v}
function more(){$('more').textContent=$('scan').classList.contains('all')?'show fewer':'show all '+seenNames.length}
$('more').onclick=function(){$('scan').classList.toggle('all');more()};
// Names are case-sensitive; a near miss of one the panel sees is a typo
// ("Wifiiii" for "wifiiii", 2026-09-22). A submit waits for a pick.
function near(s){
if(!s||s===kept||seenNames.indexOf(s)>-1)return '';
for(var i=0,l=s.toLowerCase();i<seenNames.length;i++)
if(seenNames[i].toLowerCase()===l)return seenNames[i];
return '';
}
function chk(wait){
var n=near($('ssid').value.trim());
$('dymn').textContent=n;$('dym').hidden=!n;dymWait=n&&wait?1:0;
return n;
}
function pick(){var w=dymWait;chk();dirty();if(w)add();else $('pass').focus()}
$('dymuse').onclick=function(){$('ssid').value=$('dymn').textContent;pick()};
$('dymkeep').onclick=function(){kept=$('ssid').value.trim();pick()};
$('ssid').oninput=function(){chk();dirty()};
$('pass').oninput=dirty;
function pw(on){
$('pass').type=on?'text':'password';
$('pwt').textContent=on?'hide':'show';$('pwt').setAttribute('aria-pressed',on);
}
$('pwt').onclick=function(){pw($('pass').type==='password')};
function add(){
var ssid=$('ssid').value.trim(),now=$('now').checked,p;
if(!ssid)return say('enter a network name','err');
if(chk(1)){$('dymuse').focus();return}
if(now&&!viaHs&&!confirm('Switch to "'+ssid+'" now?\n\nThis page will stop responding '+
'if the new network is different from the one you are on.'))return;
p=post('/api/wifi','ssid='+enc(ssid)+'&pass='+enc($('pass').value)+(now?'&connect=1':''));
PF.busy($('addbtn'),p).then(function(d){
if(!d.ok)return say(d.error||'failed','err');
$('ssid').value=$('pass').value='';$('now').checked=false;kept='';pw(false);dirty();
say('saved '+d.ssid);
if(d.switching)renderJoin({state:'trying',ssid:d.ssid,ip:'',why:'',ago:0});
poll.now();
},function(){say(viaHs?'no reply — the panel may be joining it: watch its screen':'failed','err')});
}
$('f').onsubmit=function(e){e.preventDefault();add()};
function del(ssid,b){
if(!confirm('Forget "'+ssid+'"?\n\nIf the device is on this network it stays '+
'connected until the next drop.'))return;
PF.busy(b,fetch('/api/wifi?ssid='+enc(ssid),{method:'DELETE'}).then(j)).then(function(d){
PF.say(d.ok?'forgot '+ssid:d.error||'failed',d.ok?'ok':'err',$('listmsg'));poll.now();
},function(){PF.say('failed','err',$('listmsg'))});
}
function saveBoot(){
var i=parseInt($('boot').value,10);
return post('/api/wifi/boot','bootIdx='+i).then(function(d){
if(!d.ok)throw Error(d.error||'failed');
bootDirty=false;dirty();return i;
});
}
$('boot').onchange=function(){bootDirty=true;dirty()};
$('bootsave').onclick=function(){
PF.busy(this,saveBoot()).then(function(i){bsay('boot slot '+(i+1)+' saved — reboot to apply');poll.now()},
function(e){bsay(why(e),'err')});
};
// The handler restarts the panel before serving anything else, so the next
// status is the new boot. Held: nothing queues for a panel that is not there.
$('reboot').onclick=function(){
if(!confirm('Save boot network and reboot the panel now?'))return;
var sent=0,p=saveBoot().then(function(i){
sent=1;bsay('rebooting — boot slot '+(i+1)+'…','info');
return fetch('/api/wifi/reboot',{method:'POST'}).catch(function(){})
.then(function(){return PF.waitForDevice()});
});
PF.busy(this,PF.hold(p)).then(function(){location.reload()},function(e){
bsay(sent?'the panel did not come back at this address — if it joined another '+
'network, open the address it shows on its screen':why(e),'err');
});
};
$('hsmode').onchange=$('hspass').oninput=function(){hsDirty=true;dirty()};
$('hsf').onsubmit=function(e){
e.preventDefault();
var m=$('hsmode').value,pv=$('hspass').value,o=hsLoaded,
// A change restarts the hotspot before the reply (core_hotspot.h): a
// phone on it drops. auto with a Wi-Fi link leaves it down.
drop=viaHs&&(!o||m!==o.mode||pv!==o.pass),
back=m==='always'||m==='auto'&&!connected,p;
if(pv.length<8||pv.length>63)return sayHs('the password is 8 to 63 characters','err');
if(drop&&!confirm(m==='off'?'Turn the hotspot off?\n\nThis phone is on it: this page stops answering.'+
(connected?'':' The panel has no Wi-Fi, so nothing will reach it.'):
back?'Restart the hotspot?\n\nThis phone drops off it. Rejoin "'+hsName+
'" with the password "'+pv+'", then come back here.':
'Save? The panel is on Wi-Fi, so in auto the hotspot goes down now: open the console there.'))return;
p=post('/api/hotspot','mode='+enc(m)+'&pass='+enc(pv));
PF.busy($('hssave'),p).then(function(d){
if(!d.ok)return sayHs(d.error||'failed','err');
hsDirty=false;dirty();
sayHs('saved'+(d.hotspot.mode==='off'?' — hotspot off':''));showHs(d);
},function(){if(!drop)sayHs('failed','err')});
if(!drop)return;
hsDirty=false;dirty();
sayHs(back?'restarting the hotspot — rejoin '+hsName+' with the password '+pv:'the hotspot is going down','info');
// Not before the request went (or 4 s): the old hotspot's last answer is
// not the new one's first.
if(back)settle(p,4000).then(function(d){
if(!(d&&d.ok===false))return PF.hold(PF.waitForDevice({timeout:180000})).then(function(){
sayHs('back on the hotspot');poll.now();
});
}).catch(function(){});
};
function settle(p,ms){return new Promise(function(y){p.then(y,function(){y()});setTimeout(y,ms)})}
</script></body></html>
07e931b46This file is pinned to the selected release. Download the original to open it in your design tool.