10 lines
34 KiB
HTML
10 lines
34 KiB
HTML
<!doctype html><meta charset=utf-8><title>Control Panel</title><meta content="width=device-width,initial-scale=1.0" name=viewport><style>#wrapper{text-align:center;box-sizing:border-box}#content{max-width:500px;margin:auto;padding:0 10px}body{text-align:center;margin:0;padding:0}*{color:#2f2f2f;background-color:#fff;font-family:Noto Sans,Verdana,sans-serif;font-size:1.2rem}input,button{text-align:right;box-sizing:border-box;background-color:#efede9;border:1px;border-radius:5px;width:100%;margin:5px}input[type=text],input[type=number],input[type=time]{border:1px solid #ba965b;border-radius:5px;font-family:monospace}input[readonly]{background-color:#e4e4e4;border-radius:5px}button{text-align:center;border:1px solid #ba965b;border-radius:5px}.changed,#commit_btn{color:#fff!important;background-color:#2a493d!important}#cancel_btn{color:#fff!important;background-color:#723!important}#commit_btn,#cancel_btn{cursor:pointer;border:none;width:45%;margin-top:10px;padding:10px;font-weight:700}#commit_btn[disabled],#cancel_btn[disabled]{color:#888;cursor:not-allowed;background-color:#444!important}table{border-collapse:collapse;text-align:left;width:100%}td{border-bottom:1px solid #efede9;padding:8px}#log_viewer_table td{padding:2px 6px;font-size:.65rem}#log_viewer_table td:first-child{white-space:nowrap}summary{text-align:left;color:#fff;background-color:#723;border-radius:5px;padding:.3rem;font-weight:700}.cmd{border:none;font-size:1.5rem}#msg{text-align:center}#status_box{text-align:center;padding:6px 0}#status_state{color:#2a493d;padding:4px 0;font-size:1.3rem;font-weight:700}#status_state.error{color:#c33}#status_indicators{flex-wrap:wrap;justify-content:center;gap:6px;margin-top:4px;display:flex}#status_indicators:empty{display:none}.status_pill{color:#fff;white-space:nowrap;background-color:#888;border-radius:12px;padding:3px 10px;font-size:.85rem;font-weight:700}.status_pill.error{background-color:#c33}.status_pill.warn{background-color:#c90}.status_pill.info{background-color:#479}h1{font-size:2.5rem}#popup-overlay{z-index:1000;background-color:#000000b3;justify-content:center;align-items:center;width:100%;height:100%;display:none;position:fixed;top:0;left:0}#popup-content{color:#fff;text-align:center;background-color:#2a493d;border-radius:10px;max-width:400px;padding:30px;box-shadow:0 4px 6px #0000004d}#popup-content h2{color:#fff;background-color:#2a493d;margin-top:0}#popup-content p{color:#fff;background-color:#2a493d;font-size:1.1rem}@media screen and (width<=350px){#content{max-width:100%;padding:0 5px}table:not(#log_viewer_table) tr td{box-sizing:border-box;width:100%;display:block}table:not(#log_viewer_table) tr{margin-bottom:10px;display:block}}#popup-buttons{background-color:#2a493d;flex-wrap:wrap;justify-content:center;gap:10px;margin-top:20px;display:flex}#popup-buttons button{color:#2f2f2f;cursor:pointer;background-color:#efede9;border:1px solid #ba965b;border-radius:5px;min-width:100px;padding:10px 20px;font-weight:700}#popup-buttons button:hover,#popup-buttons button.primary{color:#fff;background-color:#ba965b}#popup-buttons button.primary:hover{background-color:#8a7045}#popup-input-container{background-color:#2a493d;margin:20px 0}#popup-input{color:#2f2f2f;text-align:center;background-color:#efede9;border:1px solid #ba965b;border-radius:5px;width:100%;padding:10px;font-size:1.1rem}.sqbtn{width:35%;padding:30px;font-weight:700}</style></head><body><div id=wrapper><div id=content><h1>CluckCommand</h1><table><tr><td><button onclick="sendCommand('start')" class=cmd>START</button></td><td><button onclick="sendCommand('stop')" class=cmd style=color:#fff;background-color:#723>STOP</button></td><td><button onclick="sendCommand('undo')" class=cmd>UNDO</button></td></tr></table><table><tr><td colspan=3><div id=status_box><div id=status_state>—</div><div id=status_indicators></div></div></td></tr><tr><td colspan=3><input id=UX_TIME readonly step=1 type=datetime-local> <button id=now_btn onclick=setTimeToNow()>Sync Time</button></td></tr><tr><td>Schedule Start</td><td><input id=UX_MOVE_START onchange=changeSchedule(this) type=time></td></tr><tr><td>Schedule End</td><td><input id=UX_MOVE_END onchange=changeSchedule(this) type=time></td></tr><tr><td># Moves/Day</td><td><input id=UX_NUM_MOVES min=0 onchange=changeSchedule(this) type=number></td></tr><tr><td>Next Move At</td><td><input id=UX_NEXT_ALARM readonly type=datetime-local></td></tr><tr><td>Leash (ft)</td><td><input id=UX_REM_DIST onchange=handleRemainingDistChange(this) type=number></td></tr><tr><td>Move Dist. (ft)</td><td><input id=UX_DRIVE_DIST min=0 onchange=changeSchedule(this) type=number></td></tr><tr><td>Jack Ht. (in)</td><td><input id=UX_JACK_DIST min=0 onchange=changeSchedule(this) type=number></td></tr><tr><td>Battery (V)</td><td><input id=voltage readonly></td></tr><tr><td>Program RF Remote</td><td><button onclick=programRFSequence()>Program All Buttons</button></td></tr></table><button disabled id=cancel_btn onclick=location.reload();>Discard</button><button disabled id=commit_btn onclick=commitParams()>Save Changes</button><br><br><details open><summary>REMOTE CONTROL</summary> <br> <button onmousedown="startRemote('fwd', event)" ontouchstart="startRemote('fwd', event)" class=sqbtn onmouseleave=stopRemote() onmouseup=stopRemote() ontouchend=stopRemote()>FWD</button> <button onmousedown="startRemote('rev', event)" ontouchstart="startRemote('rev', event)" class=sqbtn onmouseleave=stopRemote() onmouseup=stopRemote() ontouchend=stopRemote()>REV</button> <button onmousedown="startRemote('up', event)" ontouchstart="startRemote('up', event)" class=sqbtn onmouseleave=stopRemote() onmouseup=stopRemote() ontouchend=stopRemote()>UP</button> <button onmousedown="startRemote('down', event)" ontouchstart="startRemote('down', event)" class=sqbtn onmouseleave=stopRemote() onmouseup=stopRemote() ontouchend=stopRemote()>DOWN</button> <button onmousedown="startRemote('aux', event)" ontouchstart="startRemote('aux', event)" class=sqbtn onmouseleave=stopRemote() onmouseup=stopRemote() ontouchend=stopRemote()>AUX</button></details><br><details><summary>WiFi Settings</summary> <table><tr><td>AP SSID</td><td><input id=PARAM_WIFI_SSID onchange=markChanged(this)></td></tr><tr><td>AP Password</td><td><input id=PARAM_WIFI_PASS onchange=markChanged(this)></td></tr><tr><td></td><td><button onclick=applyWifiSettings()>Apply WiFi Settings</button></td></tr></table></details><br><details id=log_viewer_details><summary style=background-color:#2a493d>EVENT LOG</summary> <div style="box-sizing:border-box;width:100vw;margin-left:-50vw;padding:0 8px;position:relative;left:50%" id=log_viewer_body><button style="width:auto;padding:4px 12px" onclick=loadLogViewer()>Refresh</button><div id=log_viewer_msg style=padding:8px;font-size:.75rem></div><div style=overflow-x:auto><table id=log_viewer_table style=white-space:nowrap;font-family:monospace;font-size:.65rem></table></div></div></details><br><details><summary>DANGER ZONE</summary> <table><tr><td>Calibration</td><td><button onclick="calibrate('jack')">Jack Calibration</button> <button onclick="calibrate('drive')">Drive Calibration</button></td></tr><tr><td>Current Version</td><td><input id=version readonly></td></tr><tr><td>Firmware</td><td><input accept=.bin id=firmware_file style=display:none type=file> <button id=upload_btn onclick=uploadFirmware()>Upload Firmware</button></td></tr><tr><td>Log File</td><td><button id=log_btn onclick=downloadLogFile()>Download Log</button></td></tr></table> <table id=table></table> <button onclick="sendCommand('reboot')" class=cmd style=color:#fff;background-color:#723>REBOOT</button> <button onclick="sendCommand('sleep')" class=cmd style=color:#fff;background-color:#237>SLEEP</button></details></div></div><div id=popup-overlay><div id=popup-content><h2 id=popup-title></h2><p id=popup-message></p><div id=popup-input-container style=display:none><input id=popup-input></div><div id=popup-buttons></div></div></div><script>let data={},paramTableCreated=!1,pollInterval=null,modalResolve=null;const ge=id=>document.getElementById(id);function showModal(title,message,buttons=[],options={}){let overlay=document.getElementById(`popup-overlay`),titleEl=document.getElementById(`popup-title`),messageEl=document.getElementById(`popup-message`),buttonsEl=document.getElementById(`popup-buttons`),inputContainer=document.getElementById(`popup-input-container`),inputEl=document.getElementById(`popup-input`);titleEl.textContent=title,messageEl.innerHTML=message,options.showInput?(inputContainer.style.display=`block`,inputEl.type=options.inputType||`text`,inputEl.value=options.inputValue||``,inputEl.placeholder=options.inputPlaceholder||``,setTimeout(()=>inputEl.focus(),100)):inputContainer.style.display=`none`,buttonsEl.innerHTML=``;let buttonElements=[];buttons.forEach(btn=>{let button=document.createElement(`button`);button.textContent=btn.text,btn.primary&&button.classList.add(`primary`),button.onclick=()=>{let result=options.showInput?{button:btn.value,input:inputEl.value}:btn.value;modalResolve&&=(modalResolve(result),null),hideModal(),btn.callback&&btn.callback(result)},buttonsEl.appendChild(button),buttonElements.push({button,btn})});let keyHandler=e=>{if(e.key===`Enter`){e.preventDefault();let primaryBtn=buttonElements.find(b=>b.btn.primary);primaryBtn?primaryBtn.button.click():buttonElements.length>0&&buttonElements[buttonElements.length-1].button.click()}else if(e.key===`Escape`){e.preventDefault();let cancelBtn=buttonElements.find(b=>!b.btn.primary);cancelBtn?cancelBtn.button.click():buttonElements.length>0&&buttonElements[0].button.click()}};window.modalKeyHandler&&document.removeEventListener(`keydown`,window.modalKeyHandler),window.modalKeyHandler=keyHandler,document.addEventListener(`keydown`,keyHandler),overlay.style.display=`flex`}function hideModal(){let overlay=document.getElementById(`popup-overlay`);overlay.style.display=`none`,modalResolve&&=(modalResolve(!1),null),window.modalKeyHandler&&(document.removeEventListener(`keydown`,window.modalKeyHandler),window.modalKeyHandler=null)}function modalConfirm(message,title=`Confirm`){return new Promise(resolve=>{modalResolve=resolve,showModal(title,message,[{text:`Cancel`,value:!1},{text:`OK`,value:!0,primary:!0}])})}function modalAlert(message,title=`Notice`){return new Promise(resolve=>{modalResolve=resolve,showModal(title,message,[{text:`OK`,value:!0,primary:!0}])})}function modalPrompt(message,title=`Input`,defaultValue=``){return new Promise(resolve=>{modalResolve=result=>{result&&result.button?resolve(result.input):resolve(null)},showModal(title,message,[{text:`Cancel`,value:!1},{text:`OK`,value:!0,primary:!0}],{showInput:!0,inputType:`text`,inputValue:defaultValue,inputPlaceholder:``})})}function showRebootModal(){showModal(`Device Rebooting`,`Page will refresh in <span id="popup-countdown">5</span> seconds...`,[]);let countdown=5,countdownInterval=setInterval(()=>{countdown--;let countdownEl=document.getElementById(`popup-countdown`);countdownEl&&(countdownEl.textContent=countdown),countdown<=0&&(clearInterval(countdownInterval),location.reload())},1e3)}function showRTCSyncModal(){showModal(`Time Not Set`,`The device clock needs to be synchronized.`,[{text:`Sync Time`,value:!0,primary:!0,callback:async()=>{setTimeToNow(),await commitParams()}}])}let warnedOfDesync=!1;function showDesyncModal(driftMinutes){warnedOfDesync||(warnedOfDesync=!0,showModal(`Clock De-Synced`,`The device clock is off by ${driftMinutes} minute${driftMinutes===1?``:`s`}. Sync it now?`,[{text:`Sync Time`,value:!0,primary:!0,callback:async()=>{setTimeToNow(),await commitParams()}},{text:`Dismiss`,value:!1}]))}const warnedOfEOT=!1;function showEOTModal(){warnedOfEOT||(warnedOfEOT=!0,showModal(`Out of Travel`,`Device cannot move until more travel distance is allowed.`,[{text:`OK`,value:!0,primary:!0}]))}let intervalId=null;function remote(command){fetch(`./post`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({cmd:command})}).catch(()=>{});try{navigator.vibrate(200)}catch{}}function startRemote(command,event){event&&event.preventDefault(),intervalId&&clearInterval(intervalId),remote(command),intervalId=setInterval(()=>{remote(command)},150)}function stopRemote(){intervalId&&=(clearInterval(intervalId),null)}async function sendCommand(cmdName){if(!(cmdName===`start`&&!await modalConfirm(`Will begin moving - please confirm.`))&&!(cmdName===`reboot`&&!await modalConfirm(`Device will reboot - clearing clock and distance. Are you sure?`))&&!(cmdName===`sleep`&&!await modalConfirm(`Device will sleep. Are you sure?`)))try{let response=await fetch(`./post`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({cmd:cmdName})});if(!response.ok){await modalAlert(`Command '${cmdName}' failed: ${response.status} ${response.statusText}`);return}cmdName===`reboot`?showRebootModal():setTimeout(fetchStatus,200)}catch(e){console.log(e),await modalAlert(`Network error: ${e.message}`)}}async function calibrate(type){let cmdName=type===`jack`?`cal_jack`:`cal_drive`;if(await modalConfirm(`This will calibrate the ${type}. Continue?`))try{let response=await fetch(`./post`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({cmd:cmdName})});if(!response.ok){await modalAlert(`Calibration failed: ${response.status} ${response.statusText}`);return}await modalAlert(`${type.charAt(0).toUpperCase()+type.slice(1)} calibration started.`),setTimeout(fetchStatus,200)}catch(e){await modalAlert(`Network error: ${e.message}`)}}function setTimeToNow(){let now=/* @__PURE__ */ new Date,input=ge(`UX_TIME`);input.value=`${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,`0`)}-${String(now.getDate()).padStart(2,`0`)}T${String(now.getHours()).padStart(2,`0`)}:${String(now.getMinutes()).padStart(2,`0`)}:${String(now.getSeconds()).padStart(2,`0`)}`,markChanged(input)}function markChanged(el){el.classList.add(`changed`),ge(`commit_btn`).disabled=!1,ge(`cancel_btn`).disabled=!1}function handleRemainingDistChange(input){markChanged(input)}const scheduleInputs=[`MOVE_START`,`MOVE_END`,`NUM_MOVES`,`DRIVE_DIST`,`JACK_DIST`];function changeSchedule(ux_input){if(param_input=ge(`PARAM_${ux_input.id.substring(3)}`),markChanged(ux_input),markChanged(param_input),ux_input.type===`time`){let[hours,minutes]=ux_input.value.split(`:`).map(Number);param_input.value=(hours*60+minutes)*60}else param_input.value=parseFloat(ux_input.value)||0}function updateScheduleInputs(){for(let i=0;i<scheduleInputs.length;i++)if(ux_input=ge(`UX_${scheduleInputs[i]}`),param_input=ge(`PARAM_${scheduleInputs[i]}`),!(!ux_input||!param_input)&&!(ux_input.classList.contains(`changed`)||document.activeElement===ux_input))if(ux_input.type===`time`){let hours=Math.floor(param_input.value/3600),minutes=Math.floor(param_input.value%3600/60);ux_input.value=`${String(hours).padStart(2,`0`)}:${String(minutes).padStart(2,`0`)}`}else ux_input.value=param_input.value}async function commitParams(){let changedInputs=document.querySelectorAll(`input.changed`);if(changedInputs.length===0)return;let payload={};for(let input of changedInputs){let id=input.id;if(id===`UX_TIME`){let dt=new Date(input.value),year=dt.getFullYear(),month=dt.getMonth(),day=dt.getDate(),hours=dt.getHours(),minutes=dt.getMinutes(),seconds=dt.getSeconds();payload.time=Math.floor(Date.UTC(year,month,day,hours,minutes,seconds)/1e3)}else if(id===`UX_REM_DIST`)payload.remaining_dist=parseFloat(input.value)||0;else if(id.startsWith(`PARAM_`)){let paramName=id.substring(6),value=input.value;input.type===`number`&&(value=parseFloat(input.value)||0),payload.parameters||={},payload.parameters[paramName]=value}}try{console.log(`Sending data:`,payload);let response=await fetch(`./post`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify(payload)});if(!response.ok){await modalAlert(`Failed to save changes: ${response.status} ${response.statusText}`);return}changedInputs.forEach(input=>input.classList.remove(`changed`)),ge(`commit_btn`).disabled=!0,ge(`cancel_btn`).disabled=!0,setTimeout(fetchStatus,200)}catch(e){await modalAlert(`Network error: ${e.message}`)}}async function fetchStatus(){try{let response=await fetch(`./get`);if(!response.ok){console.error(`Failed to fetch status:`,response.status);return}data=await response.json(),console.log(`Got data:`,data),updateUI()}catch(e){console.error(`Error fetching status:`,e)}}function _safeSet(el,value){el&&document.activeElement!==el&&(el.classList&&el.classList.contains(`changed`)||(el.value=value))}function updateUI(){if(!data.rtc_set)showRTCSyncModal();else if(data.time!==void 0){let n=/* @__PURE__ */ new Date,localAsUtc=Math.floor(Date.UTC(n.getFullYear(),n.getMonth(),n.getDate(),n.getHours(),n.getMinutes(),n.getSeconds())/1e3),driftS=Math.abs(localAsUtc-data.time);driftS>300&&showDesyncModal(Math.round(driftS/60))}{let state=Array.isArray(data.msg)?data.msg[0]||``:data.msg||``,INDICATORS=[[`safety_trip`,`SAFETY BREAK`,`error`],[`efuse_drive`,`DRIVE EFUSE`,`error`],[`efuse_jack`,`JACK EFUSE`,`error`],[`efuse_aux`,`AUX EFUSE`,`error`],[`low_battery`,`LOW BATTERY`,`warn`],[`leash_hit`,`LEASH LIMIT`,`warn`],[`rtc_not_set`,`CLOCK NOT SET`,`warn`]],errs=data.errors||{},activePills=INDICATORS.filter(([k])=>errs[k]),stateEl=ge(`status_state`);stateEl.textContent=state||`—`,stateEl.classList.toggle(`error`,activePills.length>0);let box=ge(`status_indicators`);box.innerHTML=``;for(let[,label,kind]of activePills){let pill=document.createElement(`span`);pill.className=`status_pill `+kind,pill.textContent=label,box.appendChild(pill)}}data.voltage!==void 0&&_safeSet(ge(`voltage`),data.voltage.toFixed(2)),(data.build_version||data.build_date)&&_safeSet(ge(`version`),data.build_version+` (`+data.build_date+`)`);let timeInput=ge(`UX_TIME`);if(data.time!==void 0){let dt=/* @__PURE__ */ new Date(data.time*1e3);_safeSet(timeInput,`${dt.getUTCFullYear()}-${String(dt.getUTCMonth()+1).padStart(2,`0`)}-${String(dt.getUTCDate()).padStart(2,`0`)}T${String(dt.getUTCHours()).padStart(2,`0`)}:${String(dt.getUTCMinutes()).padStart(2,`0`)}:${String(dt.getUTCSeconds()).padStart(2,`0`)}`)}let timeOutput=ge(`UX_NEXT_ALARM`);if(data.next_alarm!==void 0&&document.activeElement!==timeOutput)if(data.next_alarm>0){timeOutput.type!==`datetime-local`&&(timeOutput.type=`datetime-local`);let dt=/* @__PURE__ */ new Date(data.next_alarm*1e3);timeOutput.value=`${dt.getUTCFullYear()}-${String(dt.getUTCMonth()+1).padStart(2,`0`)}-${String(dt.getUTCDate()).padStart(2,`0`)}T${String(dt.getUTCHours()).padStart(2,`0`)}:${String(dt.getUTCMinutes()).padStart(2,`0`)}:${String(dt.getUTCSeconds()).padStart(2,`0`)}`}else timeOutput.type!==`text`&&(timeOutput.type=`text`),timeOutput.value=`DISABLED`;data.parameters&&(updateParamTable(),updateScheduleInputs()),data.remaining_dist!==void 0&&_safeSet(ge(`UX_REM_DIST`),data.remaining_dist.toFixed(1))}const PARAM_TABLE_SKIP=new Set([`NET_SSID`,`NET_PASS`,`WIFI_SSID`,`WIFI_PASS`]);function updateParamTable(){let table=ge(`table`),sortedParams=Object.entries(data.parameters).filter(([k])=>!PARAM_TABLE_SKIP.has(k)).sort((a,b)=>a[0].localeCompare(b[0]));if(paramTableCreated)for(let[key,value]of sortedParams){let input=ge(`PARAM_${key}`);if(input)_safeSet(input,value);else{paramTableCreated=!1,updateParamTable();return}}else{table.innerHTML=``;for(let[key,value]of sortedParams){let row=table.insertRow(),cell1=row.insertCell(0),cell2=row.insertCell(1);cell1.textContent=key;let input=document.createElement(`input`);input.type=typeof value==`string`?`text`:`number`,input.id=`PARAM_${key}`,input.value=value,input.onchange=function(){markChanged(this)},cell2.appendChild(input)}document.querySelectorAll(`input`).forEach(input=>{input.addEventListener(`click`,function(){this.readOnly!=1&&this.select()})}),paramTableCreated=!0}}async function uploadFirmware(){let fileInput=ge(`firmware_file`);fileInput.click(),await new Promise(resolve=>{fileInput.onchange=()=>resolve()});let file=fileInput.files[0];if(file){if(!file.name.endsWith(`.bin`)){await modalAlert(`Please select a .bin file`);return}if(await modalConfirm(`Upload firmware file "${file.name}"?\n\nDevice will reboot after upload.`))try{let arrayBuffer=await file.arrayBuffer();showModal(`Uploading Firmware`,`<div style="background-color: #2a493d;">
|
||
<div style="background-color: #2a493d; margin-bottom: 10px;">Uploading ${file.name}...</div>
|
||
<div style="background-color: #2a493d; font-size: 2rem; font-weight: bold;" id="upload-progress">0%</div>
|
||
</div>`,[]);let xhr=new XMLHttpRequest;xhr.upload.addEventListener(`progress`,e=>{if(e.lengthComputable){let percentComplete=Math.round(e.loaded/e.total*100),progressEl=document.getElementById(`upload-progress`);progressEl&&(progressEl.textContent=percentComplete+`%`)}});let uploadPromise=new Promise((resolve,reject)=>{xhr.addEventListener(`load`,()=>{xhr.status>=200&&xhr.status<300?resolve():reject(/* @__PURE__ */ Error(`Upload failed: ${xhr.status} ${xhr.statusText}`))}),xhr.addEventListener(`error`,()=>{reject(/* @__PURE__ */ Error(`Network error during upload`))}),xhr.addEventListener(`abort`,()=>{reject(/* @__PURE__ */ Error(`Upload cancelled`))})});xhr.open(`POST`,`./ota`),xhr.setRequestHeader(`Content-Type`,`application/octet-stream`),xhr.send(arrayBuffer),await uploadPromise,showModal(`Firmware Update`,`Firmware uploaded successfully! Device is rebooting...<br>Page will refresh in <span id="popup-countdown">5</span> seconds...`,[]);let countdown=5,countdownInterval=setInterval(()=>{countdown--;let countdownEl=document.getElementById(`popup-countdown`);countdownEl&&(countdownEl.textContent=countdown),countdown<=0&&(clearInterval(countdownInterval),location.reload())},1e3)}catch(e){hideModal(),await modalAlert(`Error: ${e.message}`)}}}function programRF(i){fetch(`./post`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({cmd:`rf_learn`,channel:i})})}async function programRFSequence(){let buttonNames=[`Forward`,`Reverse`,`Up`,`Down`],learnedCodes=[null,null,null,null];if(await modalConfirm(`This will program all 4 RF remote buttons in sequence.
|
||
|
||
Press OK to begin, then follow the prompts.`))try{let response=await fetch(`./post`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({cmd:`rf_clear_temp`})});if(!response.ok){await modalAlert(`Failed to clear RF temp storage`);return}if(response=await fetch(`./post`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({cmd:`rf_disable`})}),!response.ok){await modalAlert(`Failed to disable RF controls`);return}for(let i=0;i<4;i++){await fetch(`./post`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({cmd:`rf_learn`,channel:i})}),await new Promise(resolve=>setTimeout(resolve,100)),await modalAlert(`Button ${i+1}/4: ${buttonNames[i]}\n\nPress the ${buttonNames[i]} button on your remote once now.\n\nPress OK when done (or just press OK to leave unprogrammed).`,`Program Button`),await fetch(`./post`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({cmd:`rf_learn`,channel:-1})});try{let response=await fetch(`./post`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({cmd:`rf_status`})});if(!response.ok)console.error(`Error checking RF status:`,response.status),learnedCodes[i]=0;else{let data=await response.json();learnedCodes[i]=data.codes?data.codes[i]:0}}catch(e){console.error(`Error checking RF status:`,e),learnedCodes[i]=0}learnedCodes[i]===0&&await fetch(`./post`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({cmd:`rf_set_temp`,index:i,code:0})})}for(let i=0;i<4;i++){let input=ge(`PARAM_KEYCODE_${i}`);input&&(input.value=learnedCodes[i],markChanged(input))}let parameters={KEYCODE_0:learnedCodes[0],KEYCODE_1:learnedCodes[1],KEYCODE_2:learnedCodes[2],KEYCODE_3:learnedCodes[3]};if(response=await fetch(`./post`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({parameters})}),response.ok){for(let i=0;i<4;i++){let input=ge(`PARAM_KEYCODE_${i}`);input&&input.classList.remove(`changed`)}document.querySelectorAll(`input.changed`).length===0&&(ge(`commit_btn`).disabled=!0,ge(`cancel_btn`).disabled=!0)}else await modalAlert(`Failed to save RF codes: ${response.status} ${response.statusText}`);await fetch(`./post`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({cmd:`rf_enable`})});let summary=`RF Remote Programming Complete!
|
||
|
||
`;for(let i=0;i<4;i++)learnedCodes[i]===0?summary+=`${buttonNames[i]}: - <br/>`:summary+=`${buttonNames[i]}: ${learnedCodes[i]}<br/>`;await modalAlert(summary),await fetchStatus()}catch(e){await modalAlert(`RF programming error: ${e.message}`)}}async function calibrate(axis){try{let response=await fetch(`./post`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({cmd:`cal_${axis}_start`})});if(!response.ok){await modalAlert(`Failed to start ${axis} calibration: ${response.status}`);return}let amt=await modalPrompt(`Press button on mover. Press button again to stop it.
|
||
|
||
Then, type the actual travelled distance in inches:`,`Calibration`);if(amt===null||amt===``){await modalAlert(`Calibration cancelled`);return}let distance=parseFloat(amt);if(isNaN(distance)||distance<=0){await modalAlert(`Invalid distance entered. Please enter a positive number.`);return}if(response=await fetch(`./post`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({cmd:`cal_get`})}),!response.ok){await modalAlert(`Failed to get calibration data: ${response.status}`);return}let calData=await response.json();if(axis===`drive`){let ke=calData.e/(distance/12),kt=calData.t/(distance/12)*1.2,keInput=ge(`PARAM_DRIVE_KE`),ktInput=ge(`PARAM_DRIVE_KT`);keInput?(keInput.value=ke.toFixed(2),markChanged(keInput)):console.error(`Could not find PARAM_DRIVE_KE`),ktInput?(ktInput.value=Math.round(kt),markChanged(ktInput)):console.error(`Could not find PARAM_DRIVE_KT`),keInput||ktInput?await modalAlert(`Drive calibration complete!\n\n${keInput?`KE(steps/ft):${ke.toFixed(2)}\n`:``}${ktInput?`KT(timeout ms/ft):${Math.round(kt)}\n`:``}\nPlease save changes.`):await modalAlert(`Drive calibration failed: Could not find DRIVE_KE or DRIVE_KT parameters.`)}else if(axis===`jack`){let kt=calData.t/distance,ktInput=ge(`PARAM_JACK_KT`);ktInput?(ktInput.value=Math.round(kt),markChanged(ktInput),await modalAlert(`Jack calibration complete!\n\nKT (timeout ms/in): ${Math.round(kt)}\n\nPlease save changes.`)):(console.error(`Could not find PARAM_JACK_KT`),await modalAlert(`Jack calibration failed: Could not find JACK_KT parameter.`))}}catch(e){await modalAlert(`Calibration error: ${e.message}`)}}async function applyWifiSettings(){if(!await modalConfirm(`WiFi will restart. In STA mode, reconnect at http://sc.local on your network. In AP mode, reconnect to the device's AP.`,`Apply WiFi Settings`))return;let wifiKeys=[`NET_SSID`,`NET_PASS`,`WIFI_SSID`,`WIFI_PASS`],params={};for(let key of wifiKeys){let el=ge(`PARAM_`+key);el&&(params[key]=el.value)}try{let response=await fetch(`./post`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({parameters:params})});if(!response.ok){await modalAlert(`Failed to apply WiFi settings: `+response.status);return}for(let key of wifiKeys){let el=ge(`PARAM_`+key);el&&el.classList.remove(`changed`)}document.querySelectorAll(`input.changed`).length===0&&(ge(`commit_btn`).disabled=!0,ge(`cancel_btn`).disabled=!0),await modalAlert(`WiFi settings applied. Reconnect in a moment.`)}catch(e){await modalAlert(`Network error: `+e.message)}}async function downloadLogFile(){try{let response=await fetch(`./log`);if(!response.ok){await modalAlert(`Failed to download log file: ${response.status} ${response.statusText}`);return}let blob=await response.blob(),now=/* @__PURE__ */ new Date,formattedDate=`${String(now.getDate()).padStart(2,`0`)}${[`JAN`,`FEB`,`MAR`,`APR`,`MAY`,`JUN`,`JUL`,`AUG`,`SEP`,`OCT`,`NOV`,`DEC`][now.getMonth()]}${now.getFullYear()}-${String(now.getHours()).padStart(2,`0`)}${String(now.getMinutes()).padStart(2,`0`)}`,url=URL.createObjectURL(blob),a=document.createElement(`a`);a.href=url,a.download=`storage-${formattedDate}.bin`,document.body.appendChild(a),a.click(),document.body.removeChild(a),URL.revokeObjectURL(url)}catch(error){await modalAlert(`Error downloading log file: ${error.message}`)}}const LOG_FSM_NAMES=[`IDLE`,`MOVE_START_DELAY`,`JACK_UP_START`,`JACK_UP`,`DRIVE_START_DELAY`,`DRIVE`,`DRIVE_END_DELAY`,`JACK_DOWN`,`UNDO_JACK_START`,`CAL_JACK_DELAY`,`CAL_JACK_MOVE`,`CAL_DRIVE_DELAY`,`CAL_DRIVE_MOVE`,`DRIVE_FLUFF_START`],LOG_TYPE_BAT=100,LOG_TYPE_CRASH=101,LOG_TYPE_BOOT=102,LOG_TYPE_TIME_SET=103,LOG_RESET_REASONS={0:`UNKNOWN`,1:`POWERON`,2:`EXT`,3:`SW`,4:`PANIC`,5:`INT_WDT`,6:`TASK_WDT`,7:`WDT`,8:`DEEPSLEEP`,9:`BROWNOUT`,10:`SDIO`};let logViewerLoaded=!1;const LOG_MONTHS=[`JAN`,`FEB`,`MAR`,`APR`,`MAY`,`JUN`,`JUL`,`AUG`,`SEP`,`OCT`,`NOV`,`DEC`];function _logFormatTs(tsMs){if(!tsMs||tsMs<=0)return`-`;let d=new Date(tsMs),pad=(n,w=2)=>String(n).padStart(w,`0`);return`${d.getUTCFullYear()}${LOG_MONTHS[d.getUTCMonth()]}${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}.${pad(d.getUTCMilliseconds(),3)}`}const LOG_SENSOR_NAMES=[`SAFETY`,`DRIVE`,`JACK`,`AUX`];function _decodeSensors(b){let stable=b&15,raw=b>>4&15,parts=[];for(let i=0;i<4;i++){let s=stable>>i&1,r=raw>>i&1;parts.push(`${LOG_SENSOR_NAMES[i]}=${s}${s===r?``:`(raw ${r})`}`)}return parts.join(`, `)}function _parseLogEntries(bytes){let entries=[],n=bytes.length,dv=new DataView(bytes.buffer,bytes.byteOffset,bytes.byteLength),i=0;for(;i<n;){let b=bytes[i];if(b===255||b===0){i=(Math.floor(i/4096)+1)*4096;continue}let entryLen=b,payloadSize=entryLen-1,endOffset=i+entryLen;if(endOffset>=n)break;let type=bytes[endOffset],payloadStart=i+1,entry={type};try{if(type>=0&&type<=13&&payloadSize>=27)entry.ts_ms=Number(dv.getBigUint64(payloadStart+0,!0)),entry.bat_V=dv.getFloat32(payloadStart+8,!0),entry.drive_A=dv.getFloat32(payloadStart+12,!0),entry.jack_A=dv.getFloat32(payloadStart+16,!0),entry.aux_A=dv.getFloat32(payloadStart+20,!0),entry.counter=dv.getInt16(payloadStart+24,!0),entry.sensors=bytes[payloadStart+26],entry.name=LOG_FSM_NAMES[type]||`STATE_${type}`;else if(type===100&&payloadSize>=12)entry.ts_ms=Number(dv.getBigUint64(payloadStart,!0)),entry.bat_V=dv.getFloat32(payloadStart+8,!0),entry.name=`BAT`;else if((type===101||type===102)&&payloadSize>=9){entry.ts_ms=Number(dv.getBigUint64(payloadStart,!0));let info=bytes[payloadStart+8];entry.reason=LOG_RESET_REASONS[info&15]||`UNKNOWN(${info&15})`,entry.name=type===102?`BOOT`:`CRASH`}else type===103&&payloadSize>=8?(entry.ts_ms=Number(dv.getBigUint64(payloadStart,!0)),entry.name=`TIME_SET`):entry.name=`UNK(0x${type.toString(16).padStart(2,`0`)})`}catch{entry.name=`PARSE_ERR`}entries.push(entry),i=endOffset+1}return entries}function _entryCells(e){let sensorHex=e.sensors===void 0?``:`0x`+e.sensors.toString(16).padStart(2,`0`);return[{text:_logFormatTs(e.ts_ms)},{text:e.name||``},{text:e.bat_V===void 0?``:e.bat_V.toFixed(2)},{text:e.drive_A===void 0?``:e.drive_A.toFixed(2)},{text:e.jack_A===void 0?``:e.jack_A.toFixed(2)},{text:e.aux_A===void 0?``:e.aux_A.toFixed(2)},{text:e.counter===void 0?``:String(e.counter)},{text:sensorHex,title:e.sensors===void 0?void 0:_decodeSensors(e.sensors)},{text:e.reason||``}]}function _appendRow(table,cells,opts={}){let row=table.insertRow();return opts.className&&(row.className=opts.className),opts.hidden&&(row.style.display=`none`),opts.onclick&&row.addEventListener(`click`,opts.onclick),opts.style&&Object.assign(row.style,opts.style),cells.forEach(c=>{let td=document.createElement(`td`);typeof c==`string`?td.textContent=c:(td.textContent=c.text??``,c.title&&(td.title=c.title),c.colSpan&&(td.colSpan=c.colSpan),c.style&&Object.assign(td.style,c.style),c.bold&&(td.style.fontWeight=`bold`)),row.appendChild(td)}),row}function _renderLogEntries(entries){let table=ge(`log_viewer_table`);table.innerHTML=``;let cols=[`Time`,`Type`,`Battery V`,`Drive A`,`Jack A`,`Aux A`,`Counter`,`Sensors`,`Extra`];_appendRow(table,cols.map(h=>({text:h,bold:!0})));let groupId=0,i=entries.length-1;for(;i>=0;){let name=entries[i].name||``,j=i;for(;j>=0&&(entries[j].name||``)===name;)j--;let group=entries.slice(j+1,i+1).reverse();if(i=j,group.length===1){_appendRow(table,_entryCells(group[0]));continue}groupId++;let rowClass=`log-group-${groupId}`,collapsed=!0,firstTs=_logFormatTs(group[group.length-1].ts_ms),lastTs=_logFormatTs(group[0].ts_ms),header=_appendRow(table,[{text:`[+] ${name} × ${group.length} ${firstTs} → ${lastTs}`,colSpan:cols.length,style:{cursor:`pointer`,background:`#efede9`,fontWeight:`bold`,fontSize:`0.7rem`}}],{style:{background:`#efede9`}}),childRows=group.map(e=>_appendRow(table,_entryCells(e),{className:rowClass,hidden:!0}));header.addEventListener(`click`,()=>{collapsed=!collapsed,childRows.forEach(r=>r.style.display=collapsed?`none`:``);let cell=header.cells[0];cell.textContent=`[${collapsed?`+`:`-`}] ${name} × ${group.length} ${firstTs} → ${lastTs}`})}}async function loadLogViewer(){let msg=ge(`log_viewer_msg`);msg.textContent=`Connecting...`;try{let resp=await fetch(`./log`);if(!resp.ok){msg.textContent=`Failed: ${resp.status} ${resp.statusText}`;return}let total=parseInt(resp.headers.get(`Content-Length`)||`0`,10),reader=resp.body.getReader(),chunks=[],received=0;for(;;){let{done,value}=await reader.read();if(done)break;chunks.push(value),received+=value.length;let kb=(received/1024).toFixed(1);total?msg.textContent=`Downloading... ${kb} / ${(total/1024).toFixed(1)} KB (${Math.round(received/total*100)}%)`:msg.textContent=`Downloading... ${kb} KB`}let buf=new Uint8Array(received);{let off=0;for(let c of chunks)buf.set(c,off),off+=c.length}if(buf.length<12){msg.textContent=`Log response too short.`;return}msg.textContent=`Parsing ${received} bytes...`;let jsonLen=new DataView(buf.buffer,buf.byteOffset,buf.byteLength).getUint32(0,!1);if(jsonLen>65536||4+jsonLen+8>buf.length){msg.textContent=`Log response malformed.`;return}let binStart=4+jsonLen+8,logBytes=buf.subarray(binStart),entries=_parseLogEntries(logBytes);_renderLogEntries(entries),msg.textContent=`${entries.length} entries (${logBytes.length} bytes)`,logViewerLoaded=!0}catch(err){msg.textContent=`Error: ${err.message}`}}function _attachLogViewerToggle(){let d=ge(`log_viewer_details`);d&&d.addEventListener(`toggle`,()=>{d.open&&!logViewerLoaded&&loadLogViewer()})}function startPolling(){fetchStatus(),pollInterval=setInterval(fetchStatus,3e3)}function stopPolling(){pollInterval&&=(clearInterval(pollInterval),null)}document.addEventListener(`visibilitychange`,function(){document.hidden?stopPolling():(fetchStatus(),pollInterval=setInterval(fetchStatus,3e3))}),window.onload=function(){ge(`commit_btn`).disabled=!0,ge(`cancel_btn`).disabled=!0,_attachLogViewerToggle(),startPolling()};</script></body></html> |