penguin/homekey-relay

homekey-relay

esp32/tools/ld2410s-c3-calib/main/web.html

raw · 12115 bytes

<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>LD2410S wall bench</title><style>
:root{color-scheme:dark}*{box-sizing:border-box}body{margin:0;background:#10191f;color:#e5eee9;font:15px system-ui}main{max-width:1180px;margin:auto;padding:30px}h1{font-size:30px;margin:0}h2{font-size:19px;margin-top:0}.muted{color:#9fb0ba}section{background:#1a272e;border:1px solid #30424a;border-radius:14px;padding:22px;margin:18px 0}.row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}button,input,select{background:#263940;color:inherit;border:1px solid #526770;border-radius:7px;padding:9px}button{cursor:pointer}button:hover{background:#39555e}button:disabled{opacity:.4;cursor:wait}.primary{background:#266c56}.metric{font-size:38px;color:#7ce2b4}label{display:inline-flex;gap:6px;flex-direction:column;margin:8px 8px 8px 0}input[type=number]{width:110px}table{border-collapse:collapse;width:100%}td,th{text-align:left;padding:5px;border-bottom:1px solid #30424a}td input{width:100%!important;max-width:150px}canvas{width:100%;height:180px}pre{font-size:12px;white-space:pre-wrap;max-height:200px;overflow:auto}#message{padding:12px;position:sticky;top:0;background:#10191f;z-index:1;color:#ffcf85}.grid{display:grid;grid-template-columns:1fr 1fr;gap:20px}@media(max-width:750px){.grid{grid-template-columns:1fr}}a{color:#7ce2b4}
</style><main><p class="muted">UART · 115200 8N1 · ESP32-C3 · Wi-Fi bench</p><h1>LD2410S wall bench</h1><div id="message">Loading sensor state…</div>
<section><div class="row"><strong>ESP32-C3 UART sensor</strong><button onclick="act('read')">Read / retry sensor</button><span id="connection"></span></div><p id="wiring" class="muted">Sensor OT1/TX → ESP GPIO3 · sensor RX → ESP GPIO4 · shared GND · regulated 3.3 V. Keep the board fixed.</p><div id="info" class="muted"></div><p id="network" class="muted"></p></section>
<section><div class="grid"><div><h2>Presence</h2><div class="metric" id="presence">—</div><p id="distance">Waiting for readings</p><p class="muted" id="age"></p></div><div><h2>Distance history (cm)</h2><canvas id="history" width="900" height="180"></canvas></div></div><div class="row"><button onclick="act('mode',{mode:'standard'})">Show gate energy</button><button onclick="act('mode',{mode:'minimal'})">Minimal output</button><button onclick="download('/api/csv','ld2410s-readings.csv')">Export readings CSV</button></div><p class="muted">Standard output is for tuning. Restore minimal output for your low-power reader. Presence states: 0/1 absent; 2/3 present.</p></section>
<section><h2>Detection settings</h2><div id="common"></div><div class="row"><button onclick="readSettings()">Read from sensor</button><button onclick="backup()">Download current settings</button><button onclick="previousBackup()">Download pre-change backup</button><button onclick="restoreBackup()">Restore pre-change backup</button><label>Load backup into editor<input type="file" accept=".json" onchange="loadBackup(this)"></label></div><p class="muted">Loading a backup edits this page only. Apply writes to the sensor. Distance gates are approximately 0.70 m steps; check measured distance at your mounting location.</p></section>
<section><h2>Gate energy & thresholds</h2><p class="muted">Green: last reported energy · orange: trigger · blue: hold. Higher thresholds reduce sensitivity. Thresholds use device dB values (10–95); energy displays the reported values.</p><canvas id="energy" width="1000" height="180"></canvas><table><thead><tr><th>Gate</th><th>Energy</th><th>Trigger threshold</th><th>Hold threshold</th></tr></thead><tbody id="gates"></tbody></table><p><button class="primary" onclick="apply()">Apply settings & verify</button></p></section>
<section><h2>Automatic background calibration</h2><p>Mount the sensor in its final position. Keep people and pets out of the detection area during the scan. Calibration changes the sensor’s thresholds. The ESP saves the previous settings to flash first; download a copy too. The start delay gives you time to leave the room.</p><label>Trigger factor<input id="factorT" type="number" min="1" max="65535" value="2"></label><label>Hold factor<input id="factorH" type="number" min="1" max="65535" value="1"></label><label>Scan seconds<input id="seconds" type="number" min="10" max="600" value="120"></label><label>Start delay (s)<input id="delay" type="number" min="0" max="120" value="15"></label><button onclick="calibrate()">Start calibration</button><p id="progress">No scan running</p><p class="muted">After completion, read settings, then test approaching, standing still, walking away, and passing outside your desired range.</p></section>
<section><h2>UART diagnostics</h2><p id="stats" class="muted"></p><pre id="logs"></pre></section></main>
<script>
const token='__TOKEN__', el=id=>document.getElementById(id);let state={},busy=false,loaded=false;
const labels={far_gate:['Farthest gate',1,16,1],near_gate:['Nearest gate',0,16,1],absence_s:['Absence delay (s)',10,120,1],status_hz_x10:['Status reports (Hz)',.5,8,.5],distance_hz_x10:['Distance reports (Hz)',.5,8,.5]};
for(const [k,a] of Object.entries(labels)){el('common').insertAdjacentHTML('beforeend',`<label>${a[0]}<input id="${k}" type="number" min="${a[1]}" max="${a[2]}" step="${a[3]}"></label>`)}
el('common').insertAdjacentHTML('beforeend','<label>Response<select id="response"><option value="5">Normal</option><option value="10">Fast</option></select></label>');
for(let i=0;i<16;i++)el('gates').insertAdjacentHTML('beforeend',`<tr><td>${i}</td><td id="e${i}">—</td><td><input id="t${i}" type="number" min="10" max="95"></td><td><input id="h${i}" type="number" min="10" max="95"></td></tr>`);
function message(t){el('message').textContent=t}
async function api(path,body){const r=await fetch('/api/'+path,{method:body===undefined?'GET':'POST',headers:{'Content-Type':'application/json','X-Bench-Token':token},body:body===undefined?undefined:JSON.stringify(body)});const j=await r.json();if(!r.ok)throw Error(j.error||r.statusText);return j}
async function act(action,args={}){
 if(busy)return;busy=true;document.querySelectorAll('button').forEach(b=>b.disabled=true);message('Working…');
 try{const j=await api(action,args);let deadline=Date.now()+(action==='calibrate'?Number(el('delay').value)*1000+60000:60000);
  for(;;){await poll();if(state.job&&state.job.id>=j.job_id&&!state.job.busy){if(state.job.id!==j.job_id)throw Error('Another session submitted a job; read settings again');if(state.job.status.startsWith('Failed'))throw Error(state.job.status);if(state.settings&&(action==='read'||action==='write'||action==='restore'))populate(state.settings);message(state.job.status);break}if(Date.now()>deadline)throw Error('Job wait timed out; check device state before retrying');await new Promise(r=>setTimeout(r,500))}
 }catch(e){message(e.message)}finally{busy=false;document.querySelectorAll('button').forEach(b=>b.disabled=false)}
}
function populate(s){for(const[k,v]of Object.entries(s.common))el(k).value=k.endsWith('_x10')?v/10:v;for(let i=0;i<16;i++){el('t'+i).value=s.trigger[i];el('h'+i).value=s.hold[i]}}
function edited(){let common={};for(const k of [...Object.keys(labels),'response'])common[k]=Number(el(k).value)*(k.endsWith('_x10')?10:1);return {common,trigger:Array.from({length:16},(_,i)=>Number(el('t'+i).value)),hold:Array.from({length:16},(_,i)=>Number(el('h'+i).value))}}
async function readSettings(){await act('read')}
function save(data,name,type){const u=URL.createObjectURL(new Blob([data],{type}));let a=document.createElement('a');a.href=u;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(u),1000)}
async function backup(){try{const j=await api('backup');save(JSON.stringify(j,null,2),'ld2410s-settings.json','application/json');message('Downloaded the last successfully read settings; click Read first for a fresh snapshot')}catch(e){message(e.message)}}
async function loadBackup(input){try{const j=JSON.parse(await input.files[0].text());await api('validate',{settings:j.settings||j});populate(j.settings||j);message('Backup loaded into editor; click Apply to write')}catch(e){message(e.message)}}
async function apply(){if(!state.settings){message('Read settings first');return}if(confirm('Write the edited settings to the sensor? Download a backup first.'))await act('write',{settings:edited()})}
async function calibrate(){if(confirm('Start automatic calibration? This changes thresholds. Keep the detection area empty.'))await act('calibrate',{trigger:Number(el('factorT').value),hold:Number(el('factorH').value),seconds:Number(el('seconds').value),delay:Number(el('delay').value)})}
async function download(path,name){try{const r=await fetch(path,{headers:{'X-Bench-Token':token}});if(!r.ok)throw Error(await r.text());save(await r.text(),name,'text/csv')}catch(e){message(e.message)}}
function chart(id,values,thresholds){let c=el(id),x=c.getContext('2d'),w=c.width,h=c.height;x.clearRect(0,0,w,h);const max=Math.max(1,...values,...thresholds.flat());x.strokeStyle='#354851';for(let i=1;i<4;i++){x.beginPath();x.moveTo(0,i*h/4);x.lineTo(w,i*h/4);x.stroke()}x.fillStyle='#aabfc9';x.fillText('max '+Math.round(max),5,12);if(id==='energy'){let slot=w/16;values.forEach((v,i)=>{x.fillStyle='#72dfad';x.fillRect(i*slot+8,h-v/max*(h-20),slot-16,v/max*(h-20));thresholds.forEach((a,j)=>{x.strokeStyle=j?'#6bbcff':'#ffbf72';x.beginPath();let y=h-a[i]/max*(h-20);x.moveTo(i*slot+3,y);x.lineTo((i+1)*slot-3,y);x.stroke()})})}else{x.strokeStyle='#72dfad';x.beginPath();values.forEach((v,i)=>{let px=i*w/Math.max(1,values.length-1),py=h-v/max*(h-20);i?x.lineTo(px,py):x.moveTo(px,py)});x.stroke()}}
async function previousBackup(){try{const j=await api('previous-backup');save(JSON.stringify(j,null,2),'ld2410s-pre-change.json','application/json');message('Downloaded the settings saved before the last write/calibration')}catch(e){message(e.message)}}
async function restoreBackup(){if(confirm('Restore the previous settings saved in ESP32 flash?'))await act('restore')}
async function poll(){try{
 state=await api('state');let s=state,l=s.latest,stale=s.age===null||s.age>5;
 el('connection').textContent=s.connected?'Receiving reports':'Waiting for sensor reports';
 el('presence').textContent=stale?'No fresh data':l.present?'PRESENT':'ABSENT';
 el('distance').textContent=stale?'Waiting for fresh readings':l.present?l.distance_cm+' cm · raw state '+l.state:'No detected person · raw state '+l.state;
 el('age').textContent=s.age===null?'No reports received':s.age.toFixed(1)+' s since report · '+l.mode;
 el('wiring').textContent='Sensor OT1/TX → ESP GPIO'+s.info.uart_rx+' · sensor RX → ESP GPIO'+s.info.uart_tx+' · shared GND · regulated 3.3 V. Keep the board fixed.';
 el('info').textContent='Firmware '+(s.info.firmware||'—')+' · serial '+(s.info.serial||'—')+' · raw reply '+s.info.firmware_raw;
 el('network').textContent='AP: '+s.network.ap_ssid+' / '+s.network.ap_ip+(s.network.sta_ip?' · Home Wi-Fi: http://'+s.network.sta_ip+'/':'');
 el('progress').textContent=s.calibration?s.calibration.status+(s.calibration.state===1?' · starts in '+Math.max(0,Math.ceil(s.calibration.remaining_s))+' s':' · '+s.calibration.progress.toFixed(1)+'%'):'No scan running';
 el('logs').textContent=s.logs.join('\n');el('stats').textContent=s.reports+' total reports · '+s.samples+' buffered samples · '+s.bad_bytes+' skipped bytes · '+s.heap_free+' free heap bytes'+(s.error?' · '+s.error:'');
 if(s.settings&&!loaded){populate(s.settings);loaded=true}
 let e=!stale&&l.energy?l.energy:Array(16).fill(0);e.forEach((v,i)=>el('e'+i).textContent=!stale&&l.energy?v:'—');
 chart('energy',e,[Array.from({length:16},(_,i)=>Number(el('t'+i).value)||0),Array.from({length:16},(_,i)=>Number(el('h'+i).value)||0)]);
 chart('history',s.history.map(a=>a.present?a.distance_cm:0),[]);
 return true;
 }catch(e){message('Dashboard connection: '+e.message);return false}
}
(async()=>{await poll();message('Read settings, enable gate energy, then mount the sensor and test.');setInterval(poll,1000)})();
</script>