internal/app/page.html
raw · 23825 bytes
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Home Key locks</title>
<style>
[hidden]{display:none!important}
:root{font:16px system-ui;color:#172735;background:#f4f6f8}body{max-width:920px;margin:30px auto;padding:0 20px}h1{margin-bottom:8px}h2{margin-top:0}article,section{background:white;border:1px solid #ccd6df;border-radius:12px;padding:20px;margin:18px 0}button,input,select{font:inherit}button{padding:9px 16px;border:1px solid #bcc9d5;border-radius:6px;background:white;cursor:pointer}button.primary{background:#136fb1;color:white;border-color:#136fb1}button:disabled{opacity:.5;cursor:default}.toolbar{display:flex;gap:10px;flex-wrap:wrap;margin:16px 0}.muted,small{color:#536675}.error{color:#a02020}label{display:block;margin:14px 0}input:not([type=checkbox]),select{box-sizing:border-box;display:block;width:100%;margin-top:6px;padding:9px;border:1px solid #bcc9d5;border-radius:5px}input[type=checkbox]{margin-right:10px;accent-color:#136fb1}fieldset{border:1px solid #ccd6df;border-radius:6px}dialog{border:1px solid #bcc9d5;border-radius:12px;padding:24px;max-width:520px;width:calc(100% - 90px)}dialog::backdrop{background:#17273577}code{font-size:26px}.badge{font-size:14px;background:#eaf1f6;border-radius:4px;padding:4px 7px}.reader{display:flex;justify-content:space-between;gap:12px;align-items:center;border-top:1px solid #e0e7ed;padding:14px 0}.reader:first-child{border-top:0}.reader p{margin:4px 0}.empty{color:#536675}.notice{min-height:24px}details{margin:15px 0}
</style></head><body>
<h1>Home Key locks</h1><p class="muted">Assign NFC readers and physical locks to a Home Key lock, then pair with Apple Home.</p>
<div class="toolbar"><button id="add-lock" class="primary">Add lock</button><button id="save" disabled>Save configuration</button></div>
<p id="message" class="notice" role="status"></p><main id="locks"></main>
<section><h2>NFC readers</h2><p class="muted">Readers can be assigned to several locks in the same Apple Home.</p><div id="readers"></div><div class="toolbar"><button id="add-reader">Add reader</button><button id="scan">Scan Bluetooth</button></div><p id="discovery-status" class="muted" role="status"></p></section>
<section><h2>Physical locks</h2><p class="muted">Add a Sesame lock, set its positions, then assign it to a Home Key lock.</p><div id="physical-locks"></div><div class="toolbar"><button id="add-physical">Add physical lock</button><button id="scan-physical">Scan for locks</button></div><p id="physical-discovery-status" class="muted" role="status"></p></section>
<details><summary>Network settings</summary><label>LAN interface <input id="interface" placeholder="Automatic"></label><small>Leave empty for automatic HomeKit discovery interface selection.</small></details>
<p class="muted">Saving briefly restarts HomeKit and reader connections. Pairings and credentials are preserved.</p>
<dialog id="lock-dialog"><form id="lock-form"><h2 id="lock-title">Add lock</h2><small id="lock-id"></small><label>Name <input id="lock-name" required maxlength="128"></label><fieldset><legend>Assigned readers</legend><div id="assignments"></div></fieldset><fieldset><legend>Assigned physical locks</legend><div id="physical-assignments"></div><small>With none assigned, this remains a simulated lock.</small></fieldset><details><summary>HomeKit settings</summary><label>HomeKit port <input id="lock-port" type="number" min="1" max="65535" required></label><label>Wallet artwork <select id="lock-finish"><option>silver</option><option>black</option><option>gold</option><option>tan</option></select></label></details><p id="lock-error" class="error" role="alert"></p><div class="toolbar"><button class="primary" type="submit">Apply to configuration</button><button type="button" id="cancel-lock">Cancel</button></div></form></dialog>
<dialog id="reader-dialog"><form id="reader-form"><h2 id="reader-title">Add reader</h2><label>Available reader <select id="reader-device"></select></label><label id="manual-address-label" hidden>Bluetooth address <input id="reader-address" placeholder="70:AF:09:16:42:5A"></label><label>Bluetooth adapter <select id="reader-adapter"></select></label><label>Reader name <input id="reader-name" required maxlength="128"></label><small id="reader-id"></small><p id="reader-error" class="error" role="alert"></p><div class="toolbar"><button class="primary" type="submit">Apply to configuration</button><button type="button" id="cancel-reader">Cancel</button></div></form></dialog>
<dialog id="physical-dialog"><form id="physical-form"><h2>Add physical lock</h2><p>Select a nearby lock. For a lock already set up in Sesame, import its local key JSON.</p><label>Available lock <select id="physical-device" required></select></label><label>Name <input id="physical-name" required maxlength="128"></label><label>Setup method <select id="physical-mode"><option value="import">Import an existing key</option><option value="register">Register an unregistered lock</option></select></label><label id="physical-key-label">Sesame key JSON <textarea id="physical-key" rows="6" spellcheck="false" autocomplete="off" style="box-sizing:border-box;width:100%;display:block"></textarea></label><p id="physical-register-note" hidden>Registration claims this unregistered lock and saves its key here. This does not reset an existing lock or add it to the Sesame cloud account.</p><p id="physical-error" class="error" role="alert"></p><div class="toolbar"><button id="physical-submit" class="primary" type="submit">Add physical lock</button><button type="button" id="cancel-physical">Cancel</button></div></form></dialog>
<dialog id="calibration-dialog"><h2 id="calibration-name">Lock positions</h2><p>Turn the thumb-turn manually to each desired position, wait for the displayed position to update, then save it. Test movement with the door open.</p><p id="calibration-status" role="status"></p><dl><dt>Current position</dt><dd id="calibration-position">Waiting…</dd><dt>Locked position</dt><dd id="calibration-locked">Waiting…</dd><dt>Unlocked position</dt><dd id="calibration-unlocked">Waiting…</dd><dt>Lock/unlock boundary</dt><dd id="calibration-boundary">Waiting…</dd></dl><div class="toolbar"><button id="capture-lock">Use current as locked</button><button id="capture-unlock">Use current as unlocked</button><button id="capture-boundary">Use current as boundary</button></div><small id="boundary-help"></small><div class="toolbar"><button id="test-lock">Test lock</button><button id="test-unlock">Test unlock</button></div><p id="calibration-error" class="error" role="alert"></p><button id="close-calibration">Close</button></dialog>
<script>
const $=id=>document.getElementById(id);
const apiBase=location.pathname+(location.pathname.endsWith('/')?'':'/');
const el=(tag,text,cls)=>{const n=document.createElement(tag);n.textContent=text;if(cls)n.className=cls;return n};
const button=(text,fn)=>{const b=el('button',text);b.type='button';b.onclick=fn;return b};
let config={locks:[],readers:[],interface:''},revision='',status=[],discovery={adapters:[],readers:[]},dirty=false,restarting=false,scanning=false,editingLock=null,editingReader=null;
let physicalLocks=[],physicalDiscovery={locks:[],adapters:[],warnings:[]},physicalScanning=false,physicalAdding=false,calibrating=null,calibrationBusy=false;
const label=r=>r.name||r.id;
function message(text,error=false){$('message').textContent=text;$('message').className='notice'+(error?' error':'')}
async function request(path,options={}){const r=await fetch(apiBase+path,options);if(!r.ok)throw Error(await r.text());return r.status===204||r.status===202?null:r.json()}
function markDirty(){dirty=true;$('save').disabled=false;message('Unsaved changes. Save configuration to apply them.');render()}
function render(){
const main=$('locks');main.replaceChildren();if(!config.locks.length)main.append(el('p','No locks yet. Add a lock to get started.','empty'));
for(const l of config.locks){const a=el('article',''),s=status.find(x=>x.id===l.id);a.append(el('h2',l.name),el('small','ID: '+l.id+' · HomeKit port: '+l.port));const names=l.readers.map(id=>{const r=config.readers.find(r=>r.id===id);return r?label(r):id});a.append(el('p','Readers: '+(names.join(', ')||'None assigned')));a.append(el('p','Physical locks: '+((l.physical_locks||[]).map(id=>physicalLocks.find(p=>p.id===id)?.name||id).join(', ')||'None (simulation)')));const paired=!!s?.summary.paired_controllers;a.append(el('span',s?(paired?'Paired':'Unpaired'):'Pending save','badge'));const actions=el('div','','toolbar');actions.append(button('Edit',()=>openLock(l.id)),button('Remove',()=>{config.locks=config.locks.filter(x=>x.id!==l.id);markDirty()}));
if(s?.pin){a.append(el('p','Pairing code'),el('code',s.pin),el('p','Window ends '+new Date(s.until).toLocaleTimeString()));actions.append(button('Cancel pairing',()=>pair(l.id,'cancel')))}else if(s&&!paired){const b=button('Pair',()=>pair(l.id,'pair'));b.disabled=dirty||restarting;actions.append(b)}a.append(actions);main.append(a)}
const readers=$('readers');readers.replaceChildren();if(!config.readers.length)readers.append(el('p','No readers configured. Click Add reader to choose a Bluetooth reader.','empty'));
for(const r of config.readers){const row=el('div','','reader'),info=el('div','');info.append(el('strong',label(r)),el('p',r.address+' · '+r.adapter,'muted'));const actions=el('div','','toolbar');actions.append(button('Edit',()=>openReader(r.id)),button('Remove',()=>{config.readers=config.readers.filter(x=>x.id!==r.id);for(const l of config.locks)l.readers=l.readers.filter(id=>id!==r.id);markDirty()}));row.append(info,actions);readers.append(row)}
}
async function load(){const data=await request('api/config');config=data.config;revision=data.revision;dirty=false;$('save').disabled=true;$('interface').value=config.interface||'';await refresh()}
async function refresh(){try{const [data,physical]=await Promise.all([request('api/locks'),request('api/physical-locks')]);status=data.locks;physicalLocks=physical;render();renderPhysical();updateCalibration();if(!dirty&&!restarting)message('');for(const r of data.readers){if(r.warning)message(label(r)+': '+r.warning,true)}}catch(e){if(!restarting)message(e.message,true)}}
async function pair(id,action){try{await request('api/locks/'+encodeURIComponent(id)+'/'+action,{method:'POST',headers:{'X-Homekey-Action':'pairing'}});await refresh()}catch(e){message(e.message,true)}}
function openLock(id=null){editingLock=id;const l=config.locks.find(x=>x.id===id);$('lock-title').textContent=l?'Edit lock':'Add lock';$('lock-id').textContent=l?'Stable ID: '+l.id:'A stable ID will be generated automatically.';$('lock-name').value=l?.name||'';let port=51826;while(config.locks.some(x=>x.port===port))port++;$('lock-port').value=l?.port||port;$('lock-finish').value=l?.finish||'silver';$('lock-error').textContent='';const list=$('assignments');list.replaceChildren();if(!config.readers.length)list.append(el('p','No readers configured. Add a reader after creating this lock.'));for(const r of config.readers){const labelNode=el('label',''),input=document.createElement('input');input.type='checkbox';input.value=r.id;input.checked=!!l?.readers.includes(r.id);labelNode.append(input,document.createTextNode(label(r)+' · '+r.address));list.append(labelNode)}const physicalList=$('physical-assignments');physicalList.replaceChildren();if(!physicalLocks.length)physicalList.append(el('p','No physical locks added yet.'));for(const p of physicalLocks){const row=el('label',''),input=document.createElement('input');input.type='checkbox';input.value=p.id;input.checked=!!l?.physical_locks?.includes(p.id);row.append(input,document.createTextNode(p.name+' · '+p.target.model));physicalList.append(row)}$('lock-dialog').showModal()}
$('lock-form').onsubmit=e=>{e.preventDefault();const name=$('lock-name').value.trim(),port=Number($('lock-port').value);if(!name){$('lock-error').textContent='Enter a lock name.';return}if(config.locks.some(x=>x.id!==editingLock&&x.port===port)||port===8099){$('lock-error').textContent='Choose an unused HomeKit port.';return}const id=editingLock||'lock-'+Array.from(crypto.getRandomValues(new Uint8Array(8)),x=>x.toString(16).padStart(2,'0')).join('');const l={id,name,port,finish:$('lock-finish').value,readers:[...$('assignments').querySelectorAll('input:checked')].map(x=>x.value),physical_locks:[...$('physical-assignments').querySelectorAll('input:checked')].map(x=>x.value)};if(editingLock)config.locks=config.locks.map(x=>x.id===editingLock?l:x);else config.locks.push(l);$('lock-dialog').close();markDirty()};
$('cancel-lock').onclick=()=>$('lock-dialog').close();$('add-lock').onclick=()=>openLock();
function options(select,items,value){select.replaceChildren();for(const item of items){const o=el('option',item.label);o.value=item.value;select.append(o)}if(items.some(x=>x.value===value))select.value=value}
function readerChoices(){const current=config.readers.find(r=>r.id===editingReader),devices=[...discovery.readers];if(current&&!devices.some(d=>d.address===current.address&&d.adapter===current.adapter))devices.push({...current,name:label(current)});const available=devices.filter(d=>!config.readers.some(r=>r.id!==editingReader&&r.address===d.address));const selected=$('reader-device').value;options($('reader-device'),[...available.map(d=>({value:d.adapter+'|'+d.address,label:d.name+' · '+d.address+' · '+d.adapter})),{value:'manual',label:'Enter address manually'}],selected|| (current?current.adapter+'|'+current.address:''));const adapters=discovery.adapters.map(a=>({value:a.id,label:(a.name||'Bluetooth adapter')+' · '+a.id+(a.powered?'':' (off)')}));for(const r of config.readers){if(!adapters.some(a=>a.value===r.adapter))adapters.push({value:r.adapter,label:r.adapter+' (configured)'})}if(!adapters.length)adapters.push({value:'hci0',label:'Default adapter (hci0) — scan to verify'});options($('reader-adapter'),adapters,$('reader-adapter').value||current?.adapter);selectDevice()}
function selectDevice(){const value=$('reader-device').value,manual=value==='manual';$('manual-address-label').hidden=!manual;if(!manual){const [adapter,address]=value.split('|');$('reader-address').value=address;$('reader-adapter').value=adapter;const d=discovery.readers.find(x=>x.address===address&&x.adapter===adapter);if(!editingReader&&d)$('reader-name').value=d.name}$('reader-adapter').disabled=!manual}
function openReader(id=null){editingReader=id;const r=config.readers.find(x=>x.id===id);$('reader-title').textContent=r?'Edit reader':'Add reader';$('reader-name').value=r?label(r):'';$('reader-address').value=r?.address||'';$('reader-id').textContent=r?'Stable ID: '+r.id:'A stable ID will be generated automatically.';$('reader-error').textContent='';$('reader-device').replaceChildren();$('reader-adapter').replaceChildren();readerChoices();$('reader-dialog').showModal();if(!discovery.adapters.length&&!scanning)scan()}
$('reader-device').onchange=selectDevice;
$('reader-form').onsubmit=e=>{e.preventDefault();const address=$('reader-address').value.trim().toUpperCase(),adapter=$('reader-adapter').value,name=$('reader-name').value.trim();if(!/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/.test(address)){ $('reader-error').textContent='Enter a six-octet Bluetooth address, for example 70:AF:09:16:42:5A.';return}if(!name||!adapter){$('reader-error').textContent='Choose an adapter and enter a reader name.';return}if(config.readers.some(r=>r.id!==editingReader&&r.address===address)){$('reader-error').textContent='This reader is already configured.';return}const id=editingReader||'reader-'+address.replaceAll(':','').toLowerCase(),r={id,name,address,adapter};if(editingReader)config.readers=config.readers.map(x=>x.id===id?r:x);else config.readers.push(r);$('reader-dialog').close();markDirty()};
$('add-reader').onclick=()=>openReader();$('cancel-reader').onclick=()=>$('reader-dialog').close();
async function scan(){if(scanning)return;scanning=true;$('scan').disabled=true;$('discovery-status').textContent='Scanning Bluetooth for Home Key readers…';try{discovery=await request('api/discovery');$('discovery-status').textContent=(discovery.readers.length?discovery.readers.length+' reader(s) found.':'No Home Key readers found. Power on your reader and scan again, or enter its address manually.')+' '+(discovery.warnings||[]).join(' · ');if($('reader-dialog').open)readerChoices()}catch(e){$('discovery-status').textContent=e.message+' You can still enter a reader address manually.'}finally{scanning=false;$('scan').disabled=false}}
$('scan').onclick=scan;$('interface').oninput=()=>{config.interface=$('interface').value.trim();markDirty()};
$('save').onclick=async()=>{if(restarting)return;restarting=true;$('save').disabled=true;message('Saving configuration and restarting connections…');try{await request('api/config',{method:'POST',headers:{'Content-Type':'application/json','X-Homekey-Action':'configuration'},body:JSON.stringify({config,revision})});for(let i=0;i<40;i++){await new Promise(resolve=>setTimeout(resolve,500));try{await load();restarting=false;message('Configuration saved.');return}catch{}}throw Error('Configuration saved; service is still restarting. Reload this page.')}catch(e){restarting=false;$('save').disabled=!dirty;message(e.message,true)}};
function renderPhysical(){
const list=$('physical-locks');list.replaceChildren();if(!physicalLocks.length)list.append(el('p','No physical locks added.','empty'));
for(const p of physicalLocks){const row=el('div','','reader'),info=el('div',''),state=p.state;info.append(el('strong',p.name),el('p',p.target.model+' · '+p.target.adapter,'muted'));info.append(el('p',physicalState(state),state.online?'muted':'error'));const actions=el('div','','toolbar');actions.append(button('Set positions',()=>openCalibration(p.id)));row.append(info,actions);list.append(row)}
}
function physicalState(s){if(!s.online)return s.error||'Connecting…';if(s.position===undefined)return 'Connected; waiting for position';if(s.critical)return 'Lock reports a critical condition';if(!s.stopped)return 'Moving · '+s.position+'°';return (s.locked?'Locked':'Unlocked')+' · '+s.position+'°'+(s.battery_critical?' · Low battery':'')}
function physicalChoices(){const old=$('physical-device').value,available=physicalDiscovery.locks.filter(t=>!physicalLocks.some(p=>p.target.uuid===t.uuid));options($('physical-device'),available.map(t=>({value:t.adapter+'|'+t.uuid,label:t.model+' · '+t.address+' · '+t.adapter+(t.registered?' · Registered':' · Unregistered')})),old);selectPhysical()}
function selectedPhysical(){return physicalDiscovery.locks.find(t=>t.adapter+'|'+t.uuid===$('physical-device').value)}
function selectPhysical(){const t=selectedPhysical();if(t){$('physical-mode').value=t.registered?'import':'register';if(!$('physical-name').value)$('physical-name').value=t.model.replaceAll('_',' ')}physicalMode();$('physical-submit').disabled=!t||physicalAdding}
function physicalMode(){const registering=$('physical-mode').value==='register';$('physical-key-label').hidden=registering;$('physical-key').required=!registering;$('physical-register-note').hidden=!registering}
async function scanPhysical(){if(physicalScanning)return;physicalScanning=true;$('scan-physical').disabled=true;$('physical-discovery-status').textContent='Scanning for supported Sesame locks…';try{physicalDiscovery=await request('api/physical-locks/discovery');$('physical-discovery-status').textContent=physicalDiscovery.locks.length+' supported lock(s) found. '+(physicalDiscovery.warnings||[]).join(' · ');if($('physical-dialog').open)physicalChoices()}catch(e){$('physical-discovery-status').textContent=e.message;if($('physical-dialog').open)$('physical-error').textContent=e.message}finally{physicalScanning=false;$('scan-physical').disabled=false}}
$('scan-physical').onclick=scanPhysical;
$('add-physical').onclick=()=>{$('physical-name').value='';$('physical-key').value='';$('physical-error').textContent='';physicalChoices();$('physical-dialog').showModal();scanPhysical()};
$('physical-device').onchange=selectPhysical;$('physical-mode').onchange=physicalMode;
$('cancel-physical').onclick=()=>{if(!physicalAdding){$('physical-key').value='';$('physical-dialog').close()}};
$('physical-dialog').addEventListener('cancel',e=>{if(physicalAdding)e.preventDefault();else $('physical-key').value=''});
$('physical-form').onsubmit=async e=>{e.preventDefault();if(physicalAdding)return;const target=selectedPhysical();if(!target){$('physical-error').textContent='Scan and select a supported lock first.';return}const mode=$('physical-mode').value,body={name:$('physical-name').value.trim(),target,mode};try{if(mode==='import'){const k=JSON.parse($('physical-key').value);body.key={deviceUUID:k.deviceUUID,deviceModel:k.deviceModel,secretKey:k.secretKey,keyIndex:k.keyIndex||'',sesame2PublicKey:k.sesame2PublicKey||''}}}catch{$('physical-error').textContent='Enter valid Sesame key JSON.';return}
physicalAdding=true;for(const n of $('physical-form').elements)n.disabled=true;$('physical-error').textContent=mode==='register'?'Registering and saving the key…':'Verifying the key…';try{const added=await request('api/physical-locks',{method:'POST',headers:{'Content-Type':'application/json','X-Homekey-Action':'physical-lock'},body:JSON.stringify(body)});$('physical-key').value='';$('physical-dialog').close();await refresh();openCalibration(added.id)}catch(err){$('physical-error').textContent=err.message}finally{physicalAdding=false;for(const n of $('physical-form').elements)n.disabled=false}}
function openCalibration(id){calibrating=id;$('calibration-error').textContent='';updateCalibration();$('calibration-dialog').showModal()}
function updateCalibration(){if(!calibrating)return;const p=physicalLocks.find(p=>p.id===calibrating);if(!p)return;const s=p.state,angle=v=>v===undefined?'Not reported':v+'°';$('calibration-name').textContent=p.name+' — positions';$('calibration-status').textContent=physicalState(s);$('calibration-position').textContent=angle(s.position);$('calibration-locked').textContent=angle(s.lock_position);$('calibration-unlocked').textContent=angle(s.unlock_position);$('calibration-boundary').textContent=angle(s.boundary);
const ready=s.online&&s.position!==undefined&&!calibrationBusy;
$('capture-lock').disabled=$('capture-unlock').disabled=!ready||s.lock_position===undefined||s.unlock_position===undefined;$('capture-boundary').disabled=!ready||s.boundary===undefined;$('test-lock').disabled=$('test-unlock').disabled=!s.online||calibrationBusy;
$('boundary-help').textContent=s.boundary===undefined?'Boundary adjustment becomes available when the lock firmware reports support.':'The boundary determines where the lock changes between locked and unlocked status. Settings are saved directly to the lock.';
}
async function physicalAction(action){if(calibrationBusy||!calibrating)return;calibrationBusy=true;$('calibration-error').textContent='';updateCalibration();try{await request('api/physical-locks/'+encodeURIComponent(calibrating)+'/'+action,{method:'POST',headers:{'X-Homekey-Action':'physical-lock'}});await refresh()}catch(e){$('calibration-error').textContent=e.message}finally{calibrationBusy=false;updateCalibration()}}
$('capture-lock').onclick=()=>physicalAction('set-lock');$('capture-unlock').onclick=()=>physicalAction('set-unlock');$('capture-boundary').onclick=()=>physicalAction('set-boundary');$('test-lock').onclick=()=>physicalAction('lock');$('test-unlock').onclick=()=>physicalAction('unlock');$('close-calibration').onclick=()=>{$('calibration-dialog').close();calibrating=null};
load().then(()=>scan()).catch(e=>message(e.message,true));setInterval(()=>{if(!restarting)refresh()},2000);
</script></body></html>