1882 lines
74 KiB
HTML
1882 lines
74 KiB
HTML
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>Control Panel</title>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<style>
|
||
/* === COLOR TOKENS ===
|
||
* Single source of truth for every color used in the UI. Item 14:
|
||
* green/red/yellow each appear in exactly one place here and are
|
||
* referenced everywhere else via var(--…). */
|
||
:root {
|
||
--green: #38B000;
|
||
--red: #C1121F;
|
||
--yellow: #FFDD00;
|
||
|
||
/* Dark theme surfaces (item 15) */
|
||
--bg: #1a1a1a; /* page background */
|
||
--surface: #262626; /* cards, inputs, popups */
|
||
--surface-2: #333333; /* hover / readonly tint */
|
||
--text: #f0f0f0; /* primary text */
|
||
--text-dim: #a0a0a0; /* secondary text */
|
||
--border: #444444;
|
||
--accent: #ba965b; /* brand tan kept as accent */
|
||
}
|
||
|
||
html, body { background-color: var(--bg); color: var(--text); }
|
||
body { text-align: center; margin: 0; padding: 0; }
|
||
#wrapper { text-align: center; box-sizing: border-box; }
|
||
#content { max-width: 500px; margin: auto; padding: 0 10px; }
|
||
|
||
* {
|
||
font-size: 1.2rem;
|
||
color: var(--text);
|
||
font-family: system-ui, sans-serif;
|
||
background-color: transparent;
|
||
}
|
||
|
||
/* === FORM CONTROLS === */
|
||
input, button {
|
||
width: 100%;
|
||
background-color: var(--surface);
|
||
color: var(--text);
|
||
text-align: right;
|
||
box-sizing: border-box;
|
||
border: 1px solid var(--border);
|
||
border-radius: 5px;
|
||
margin: 5px;
|
||
}
|
||
input[type="text"], input[type="number"], input[type="time"] {
|
||
/* Numerical / freeform-text inputs keep monospace per CHANGELIST. */
|
||
font-family: monospace;
|
||
border: 1px solid var(--accent);
|
||
}
|
||
input[readonly] {
|
||
background-color: var(--surface-2);
|
||
color: var(--text-dim);
|
||
}
|
||
|
||
button {
|
||
text-align: center;
|
||
border: 1px solid var(--accent);
|
||
border-radius: 5px;
|
||
cursor: pointer;
|
||
}
|
||
button:hover { background-color: var(--surface-2); }
|
||
|
||
/* Save / Discard footer buttons. */
|
||
.changed, #commit_btn { background-color: var(--green) !important; color: #ffffff !important; border-color: var(--green) !important; }
|
||
#cancel_btn { background-color: var(--red) !important; color: #ffffff !important; border-color: var(--red) !important; }
|
||
#commit_btn, #cancel_btn { width: 45%; margin-top: 10px; padding: 10px; font-weight: bold; }
|
||
#commit_btn[disabled], #cancel_btn[disabled] {
|
||
background-color: var(--surface-2) !important;
|
||
color: var(--text-dim) !important;
|
||
border-color: var(--border) !important;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
table { width: 100%; border-collapse: collapse; text-align: left; }
|
||
td { padding: 8px; border-bottom: 1px solid var(--border); }
|
||
#log_viewer_table td { padding: 2px 6px; font-size: 0.65rem; }
|
||
#log_viewer_table td:first-child { white-space: nowrap; }
|
||
|
||
summary {
|
||
border-radius: 5px;
|
||
font-weight: bold;
|
||
text-align: left;
|
||
color: #ffffff;
|
||
background-color: var(--red);
|
||
padding: 0.3rem;
|
||
cursor: pointer;
|
||
}
|
||
|
||
/* === ACTION BUTTON ROW (item 12) ===
|
||
* Flex layout instead of a table: each button gets exactly 50% of
|
||
* the row width with a 10px gap, regardless of label width. The
|
||
* `.cmd` text wraps when "START MOVE" doesn't fit on one line. */
|
||
.action_row {
|
||
display: flex;
|
||
gap: 10px;
|
||
padding: 5px 0;
|
||
align-items: stretch;
|
||
}
|
||
.action_row > button {
|
||
flex: 1 1 0; /* equal width — both buttons take 50% */
|
||
margin: 0;
|
||
min-height: 64px;
|
||
}
|
||
.cmd {
|
||
font-size: 1.5rem;
|
||
font-weight: bold;
|
||
white-space: normal;
|
||
word-break: break-word;
|
||
border: none;
|
||
line-height: 1.15;
|
||
}
|
||
.cmd_action { background-color: var(--green); color: #ffffff; }
|
||
.cmd_action.moving { background-color: var(--yellow); color: #1a1a1a; }
|
||
.cmd_estop { background-color: var(--red); color: #ffffff; }
|
||
|
||
#msg { text-align: center; }
|
||
|
||
/* === STATUS BOX === */
|
||
#status_box { text-align: center; padding: 6px 0; }
|
||
/* Item 13: status text always uses the default theme color — no
|
||
* error-class override that flips it red. The pill row below does
|
||
* the error signaling. */
|
||
#status_state {
|
||
font-weight: bold;
|
||
font-size: 1.3rem;
|
||
padding: 4px 0;
|
||
color: var(--text);
|
||
}
|
||
#status_indicators { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; margin-top: 4px; }
|
||
#status_indicators:empty { display: none; }
|
||
.status_pill {
|
||
padding: 3px 10px;
|
||
border-radius: 12px;
|
||
font-size: 0.85rem;
|
||
font-weight: bold;
|
||
color: #ffffff;
|
||
background-color: var(--surface-2);
|
||
white-space: nowrap;
|
||
}
|
||
.status_pill.error { background-color: var(--red); }
|
||
.status_pill.warn { background-color: var(--yellow); color: #1a1a1a; }
|
||
.status_pill.info { background-color: var(--accent); }
|
||
|
||
h1 {
|
||
font-size: 2.5rem;
|
||
color: var(--text);
|
||
}
|
||
|
||
/* === POPUP MODAL === */
|
||
#popup-overlay {
|
||
display: none;
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background-color: rgba(0, 0, 0, 0.75);
|
||
z-index: 1000;
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
#popup-content {
|
||
background-color: var(--surface);
|
||
color: var(--text);
|
||
padding: 30px;
|
||
border-radius: 10px;
|
||
border: 1px solid var(--border);
|
||
text-align: center;
|
||
max-width: 400px;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||
}
|
||
#popup-content h2,
|
||
#popup-content p {
|
||
background-color: var(--surface);
|
||
color: var(--text);
|
||
}
|
||
#popup-content h2 { margin-top: 0; }
|
||
#popup-content p { font-size: 1.1rem; }
|
||
|
||
@media screen and (max-width: 350px) {
|
||
#content { max-width: 100%; padding: 0 5px; }
|
||
/* Stack table cells vertically on mobile, but exempt the log
|
||
* viewer — it stays as a wide table with horizontal scroll. */
|
||
table:not(#log_viewer_table) tr td { display: block; width: 100%; box-sizing: border-box; }
|
||
table:not(#log_viewer_table) tr { display: block; margin-bottom: 10px; }
|
||
}
|
||
|
||
#popup-buttons {
|
||
background-color: var(--surface);
|
||
margin-top: 20px;
|
||
display: flex;
|
||
gap: 10px;
|
||
justify-content: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
#popup-buttons button {
|
||
background-color: var(--surface-2);
|
||
color: var(--text);
|
||
border: 1px solid var(--accent);
|
||
padding: 10px 20px;
|
||
cursor: pointer;
|
||
border-radius: 5px;
|
||
font-weight: bold;
|
||
min-width: 100px;
|
||
}
|
||
#popup-buttons button:hover {
|
||
background-color: var(--accent);
|
||
color: #1a1a1a;
|
||
}
|
||
#popup-buttons button.primary {
|
||
background-color: var(--accent);
|
||
color: #1a1a1a;
|
||
}
|
||
#popup-buttons button.primary:hover {
|
||
background-color: #d4b27e;
|
||
}
|
||
|
||
#popup-input-container {
|
||
background-color: var(--surface);
|
||
margin: 20px 0;
|
||
}
|
||
#popup-input {
|
||
width: 100%;
|
||
padding: 10px;
|
||
border: 1px solid var(--accent);
|
||
border-radius: 5px;
|
||
background-color: var(--surface-2);
|
||
color: var(--text);
|
||
text-align: center;
|
||
font-size: 1.1rem;
|
||
}
|
||
|
||
.sqbtn {
|
||
width: 35%;
|
||
padding: 30px;
|
||
font-weight: bold;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div id="wrapper">
|
||
<div id="content">
|
||
|
||
<h1>CluckCommand</h1>
|
||
|
||
<div class="action_row">
|
||
<button class="cmd cmd_action" id="action_btn" onclick="actionBtnClick()">START MOVE</button>
|
||
<button class="cmd cmd_estop" onclick="sendCommand('stop')">E-STOP</button>
|
||
</div>
|
||
|
||
|
||
|
||
<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 type="datetime-local" id="UX_TIME" step="1" readonly=""/>
|
||
<button id="now_btn" onclick="setTimeToNow()">Sync Time</button></td>
|
||
</tr>
|
||
<tr>
|
||
<td>Schedule Start</td>
|
||
<td><input type="time" id="UX_MOVE_START" onchange="changeSchedule(this)"/></td>
|
||
</tr>
|
||
<tr>
|
||
<td>Schedule End</td>
|
||
<td><input type="time" id="UX_MOVE_END" onchange="changeSchedule(this)"/></td>
|
||
</tr>
|
||
<tr>
|
||
<td># Moves/Day</td>
|
||
<td><input type="number" min="0" id="UX_NUM_MOVES" onchange="changeSchedule(this)"/></td>
|
||
</tr>
|
||
<tr>
|
||
<td>Next Move At</td>
|
||
<td><input type="datetime-local" id="UX_NEXT_ALARM" readonly=""/></td>
|
||
</tr>
|
||
<tr>
|
||
<td>Leash (ft)</td>
|
||
<td><input type="number" id="UX_REM_DIST" onchange="handleRemainingDistChange(this)"/></td>
|
||
</tr>
|
||
<tr>
|
||
<td>Move Dist. (ft)</td>
|
||
<td><input type="number" min="0" id="UX_DRIVE_DIST" onchange="changeSchedule(this)"/></td>
|
||
</tr>
|
||
<tr>
|
||
<td>Jack Ht. (in)</td>
|
||
<td><input type="number" min="0" id="UX_JACK_DIST" onchange="changeSchedule(this)"/></td>
|
||
</tr>
|
||
<tr>
|
||
<td>Battery (V)</td>
|
||
<td><input readonly="" id="voltage"/></td>
|
||
</tr>
|
||
<tr>
|
||
<td>Program RF Remote</td>
|
||
<td>
|
||
<button onclick="programRFSequence()">Program All Buttons</button>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
|
||
<button id="cancel_btn" onclick="location.reload();" disabled>Discard</button>
|
||
<button id="commit_btn" onclick="commitParams()" disabled>Save Changes</button>
|
||
|
||
<br/>
|
||
<br/>
|
||
<details open>
|
||
<summary>REMOTE CONTROL</summary>
|
||
<br/>
|
||
<button class="sqbtn"
|
||
onmousedown="startRemote('fwd', event)"
|
||
onmouseup="stopRemote()"
|
||
onmouseleave="stopRemote()"
|
||
ontouchstart="startRemote('fwd', event)"
|
||
ontouchend="stopRemote()">
|
||
FWD
|
||
</button>
|
||
<button class="sqbtn"
|
||
onmousedown="startRemote('rev', event)"
|
||
onmouseup="stopRemote()"
|
||
onmouseleave="stopRemote()"
|
||
ontouchstart="startRemote('rev', event)"
|
||
ontouchend="stopRemote()">
|
||
REV
|
||
</button>
|
||
<button class="sqbtn"
|
||
onmousedown="startRemote('up', event)"
|
||
onmouseup="stopRemote()"
|
||
onmouseleave="stopRemote()"
|
||
ontouchstart="startRemote('up', event)"
|
||
ontouchend="stopRemote()">
|
||
UP
|
||
</button>
|
||
<button class="sqbtn"
|
||
onmousedown="startRemote('down', event)"
|
||
onmouseup="stopRemote()"
|
||
onmouseleave="stopRemote()"
|
||
ontouchstart="startRemote('down', event)"
|
||
ontouchend="stopRemote()">
|
||
DOWN
|
||
</button>
|
||
<button class="sqbtn"
|
||
onmousedown="startRemote('aux', event)"
|
||
onmouseup="stopRemote()"
|
||
onmouseleave="stopRemote()"
|
||
ontouchstart="startRemote('aux', event)"
|
||
ontouchend="stopRemote()">
|
||
AUX
|
||
</button>
|
||
</details>
|
||
<br/>
|
||
|
||
<details>
|
||
<summary>WiFi Settings</summary>
|
||
<table>
|
||
<!-- STA mode disabled pending network stack fixes
|
||
<tr>
|
||
<td>Network SSID</td>
|
||
<td><input type="text" id="PARAM_NET_SSID" onchange="markChanged(this)"/></td>
|
||
</tr>
|
||
<tr>
|
||
<td>Network Password</td>
|
||
<td><input type="text" id="PARAM_NET_PASS" onchange="markChanged(this)"/></td>
|
||
</tr>
|
||
-->
|
||
<tr>
|
||
<td>AP SSID</td>
|
||
<td><input type="text" id="PARAM_WIFI_SSID" onchange="markChanged(this)"/></td>
|
||
</tr>
|
||
<tr>
|
||
<td>AP Password</td>
|
||
<td><input type="text" 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:var(--surface-2);">EVENT LOG</summary>
|
||
<!-- Body breaks out of the 500px #content column so the table has
|
||
room to breathe. The summary above stays inside the column. -->
|
||
<div id="log_viewer_body" style="position:relative;width:100vw;left:50%;margin-left:-50vw;padding:0 8px;box-sizing:border-box;">
|
||
<button onclick="loadLogViewer()" style="width:auto;padding:4px 12px;">Refresh</button>
|
||
<div id="log_viewer_msg" style="padding:8px;font-size:0.75rem;"></div>
|
||
<div style="overflow-x:auto;">
|
||
<table id="log_viewer_table" style="font-family:monospace;font-size:0.65rem;white-space:nowrap;"></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 readonly="" id="version"/></td>
|
||
</tr>
|
||
<tr>
|
||
<td>Firmware</td>
|
||
<td>
|
||
<input type="file" id="firmware_file" accept=".bin" style="display: none;">
|
||
<!-- Single button to trigger the entire flow -->
|
||
<button id="upload_btn" onclick="uploadFirmware()">Upload Firmware</button></td>
|
||
</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 class="cmd" onclick="sendCommand('reboot')" style="background-color:var(--red); color:#fff">REBOOT</button>
|
||
<button class="cmd" onclick="sendCommand('sleep')" style="background-color:var(--surface-2); color:var(--text)">SLEEP</button>
|
||
</details>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Popup overlay -->
|
||
<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 type="text" id="popup-input" placeholder="">
|
||
</div>
|
||
<div id="popup-buttons"></div>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
|
||
|
||
|
||
<script>
|
||
/**
|
||
* UNIFIED POST FORMAT
|
||
* All POST requests to ./post accept this format (all fields optional):
|
||
* {
|
||
* "time": 1234567, // Update RTC time
|
||
* "state": 2, // Update FSM state
|
||
* "cmd": "start", // Execute command
|
||
* "voltage": 12.45, // Update individual parameter
|
||
* "parameters": { // Batch update parameters
|
||
* "PARAM_NAME": -15,
|
||
* "PARAM_NAME2": 12.0
|
||
* }
|
||
* }
|
||
*/
|
||
|
||
let data = {}; // Store full status object
|
||
let paramTableCreated = false; // Track if param table has been created
|
||
let pollInterval = null; // Store interval ID for polling
|
||
let modalResolve = null;
|
||
|
||
const ge = (id) => document.getElementById(id);
|
||
|
||
// Modal system
|
||
function showModal(title, message, buttons = [], options = {}) {
|
||
const overlay = document.getElementById('popup-overlay');
|
||
const titleEl = document.getElementById('popup-title');
|
||
const messageEl = document.getElementById('popup-message');
|
||
const buttonsEl = document.getElementById('popup-buttons');
|
||
const inputContainer = document.getElementById('popup-input-container');
|
||
const inputEl = document.getElementById('popup-input');
|
||
|
||
titleEl.textContent = title;
|
||
messageEl.innerHTML = message;
|
||
|
||
// Handle input field
|
||
if (options.showInput) {
|
||
inputContainer.style.display = 'block';
|
||
inputEl.type = options.inputType || 'text';
|
||
inputEl.value = options.inputValue || '';
|
||
inputEl.placeholder = options.inputPlaceholder || '';
|
||
setTimeout(() => inputEl.focus(), 100);
|
||
} else {
|
||
inputContainer.style.display = 'none';
|
||
}
|
||
|
||
// Clear and create buttons
|
||
buttonsEl.innerHTML = '';
|
||
const buttonElements = [];
|
||
buttons.forEach(btn => {
|
||
const button = document.createElement('button');
|
||
button.textContent = btn.text;
|
||
if (btn.primary) button.classList.add('primary');
|
||
button.onclick = () => {
|
||
const result = options.showInput ? { button: btn.value, input: inputEl.value } : btn.value;
|
||
if (modalResolve) {
|
||
modalResolve(result);
|
||
modalResolve = null;
|
||
}
|
||
hideModal();
|
||
if (btn.callback) btn.callback(result);
|
||
};
|
||
buttonsEl.appendChild(button);
|
||
buttonElements.push({ button, btn });
|
||
});
|
||
|
||
// Add keyboard event listener for Enter and Escape
|
||
const keyHandler = (e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
// Find and click the primary button (or the last button if no primary)
|
||
const primaryBtn = buttonElements.find(b => b.btn.primary);
|
||
if (primaryBtn) {
|
||
primaryBtn.button.click();
|
||
} else if (buttonElements.length > 0) {
|
||
buttonElements[buttonElements.length - 1].button.click();
|
||
}
|
||
} else if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
// Find and click the cancel/non-primary button (or first button if all primary)
|
||
const cancelBtn = buttonElements.find(b => !b.btn.primary);
|
||
if (cancelBtn) {
|
||
cancelBtn.button.click();
|
||
} else if (buttonElements.length > 0) {
|
||
buttonElements[0].button.click();
|
||
}
|
||
}
|
||
};
|
||
|
||
// Remove any existing keyboard handler
|
||
if (window.modalKeyHandler) {
|
||
document.removeEventListener('keydown', window.modalKeyHandler);
|
||
}
|
||
|
||
// Store and add new handler
|
||
window.modalKeyHandler = keyHandler;
|
||
document.addEventListener('keydown', keyHandler);
|
||
|
||
overlay.style.display = 'flex';
|
||
}
|
||
|
||
function hideModal() {
|
||
const overlay = document.getElementById('popup-overlay');
|
||
overlay.style.display = 'none';
|
||
if (modalResolve) {
|
||
modalResolve(false);
|
||
modalResolve = null;
|
||
}
|
||
// Remove keyboard handler when modal closes
|
||
if (window.modalKeyHandler) {
|
||
document.removeEventListener('keydown', window.modalKeyHandler);
|
||
window.modalKeyHandler = null;
|
||
}
|
||
}
|
||
|
||
// Promise-based modal for confirm-style dialogs
|
||
function modalConfirm(message, title = 'Confirm') {
|
||
return new Promise((resolve) => {
|
||
modalResolve = resolve;
|
||
showModal(title, message, [
|
||
{ text: 'Cancel', value: false },
|
||
{ text: 'OK', value: true, primary: true }
|
||
]);
|
||
});
|
||
}
|
||
|
||
// Promise-based modal for alert-style dialogs
|
||
function modalAlert(message, title = 'Notice') {
|
||
return new Promise((resolve) => {
|
||
modalResolve = resolve;
|
||
showModal(title, message, [
|
||
{ text: 'OK', value: true, primary: true }
|
||
]);
|
||
});
|
||
}
|
||
|
||
// Promise-based modal for prompt-style dialogs with text input
|
||
function modalPrompt(message, title = 'Input', defaultValue = '') {
|
||
return new Promise((resolve) => {
|
||
modalResolve = (result) => {
|
||
if (result && result.button) {
|
||
resolve(result.input);
|
||
} else {
|
||
resolve(null);
|
||
}
|
||
};
|
||
showModal(title, message, [
|
||
{ text: 'Cancel', value: false },
|
||
{ text: 'OK', value: true, primary: true }
|
||
], {
|
||
showInput: true,
|
||
inputType: 'text',
|
||
inputValue: defaultValue,
|
||
inputPlaceholder: ''
|
||
});
|
||
});
|
||
}
|
||
|
||
function showRebootModal() {
|
||
showModal('Device Rebooting', 'Page will refresh in <span id="popup-countdown">5</span> seconds...', []);
|
||
let countdown = 5;
|
||
const countdownInterval = setInterval(() => {
|
||
countdown--;
|
||
const countdownEl = document.getElementById('popup-countdown');
|
||
if (countdownEl) {
|
||
countdownEl.textContent = countdown;
|
||
}
|
||
if (countdown <= 0) {
|
||
clearInterval(countdownInterval);
|
||
location.reload();
|
||
}
|
||
}, 1000);
|
||
}
|
||
|
||
function showRTCSyncModal() {
|
||
showModal(
|
||
'Time Not Set',
|
||
'The device clock needs to be synchronized.',
|
||
[
|
||
{
|
||
text: 'Sync Time',
|
||
value: true,
|
||
primary: true,
|
||
callback: async () => {
|
||
// Simulate clicking Set Time then Save Changes
|
||
setTimeToNow();
|
||
await commitParams();
|
||
}
|
||
}
|
||
]
|
||
);
|
||
}
|
||
|
||
let warnedOfDesync = false;
|
||
|
||
function showDesyncModal(driftMinutes) {
|
||
if (warnedOfDesync) return;
|
||
warnedOfDesync = true;
|
||
showModal(
|
||
'Clock De-Synced',
|
||
`The device clock is off by ${driftMinutes} minute${driftMinutes !== 1 ? 's' : ''}. Sync it now?`,
|
||
[
|
||
{
|
||
text: 'Sync Time',
|
||
value: true,
|
||
primary: true,
|
||
callback: async () => {
|
||
setTimeToNow();
|
||
await commitParams();
|
||
}
|
||
},
|
||
{
|
||
text: 'Dismiss',
|
||
value: false
|
||
}
|
||
]
|
||
);
|
||
}
|
||
|
||
const warnedOfEOT = false;
|
||
|
||
function showEOTModal() {
|
||
if(warnedOfEOT) return;
|
||
warnedOfEOT = true;
|
||
showModal(
|
||
'Out of Travel',
|
||
'Device cannot move until more travel distance is allowed.',
|
||
[
|
||
{
|
||
text: 'OK',
|
||
value: true,
|
||
primary: true
|
||
}
|
||
]
|
||
);
|
||
}
|
||
|
||
let intervalId = null;
|
||
|
||
function remote(command) {
|
||
// Fire-and-forget POST. Held-button jog sends this every 150 ms —
|
||
// transient NetworkErrors must not pop a modal (would spam the UI
|
||
// and race with subsequent sends). Any error is swallowed.
|
||
fetch('./post', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({cmd: command})
|
||
}).catch(() => {});
|
||
try {
|
||
navigator.vibrate(200);
|
||
} catch (error) {}
|
||
}
|
||
|
||
function startRemote(command, event) {
|
||
if (event) {
|
||
event.preventDefault();
|
||
}
|
||
// Clear any existing interval
|
||
if (intervalId) {
|
||
clearInterval(intervalId);
|
||
}
|
||
|
||
// Send immediately
|
||
remote(command);
|
||
|
||
// Then send while held
|
||
intervalId = setInterval(() => {
|
||
remote(command);
|
||
}, 150); //ms
|
||
}
|
||
|
||
function stopRemote() {
|
||
if (intervalId) {
|
||
clearInterval(intervalId);
|
||
intervalId = null;
|
||
}
|
||
}
|
||
|
||
// Single action-button dispatcher. STATE_IDLE (0) → start, anything else
|
||
// → undo. Uses last polled FSM state so the click matches what the
|
||
// operator sees on the button label.
|
||
function actionBtnClick() {
|
||
const isIdle = !data.state || data.state === 0;
|
||
sendCommand(isIdle ? 'start' : 'undo');
|
||
}
|
||
|
||
async function sendCommand(cmdName) {
|
||
if (cmdName === 'start') {
|
||
if (!await modalConfirm("Will begin moving - please confirm."))
|
||
return;
|
||
}
|
||
if (cmdName === 'reboot') {
|
||
if (!await modalConfirm("Device will reboot - clearing clock and distance. Are you sure?"))
|
||
return;
|
||
}
|
||
if (cmdName === 'sleep') {
|
||
if (!await modalConfirm("Device will sleep. Are you sure?"))
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const 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;
|
||
}
|
||
|
||
// Show popup for reboot commands
|
||
if (cmdName === 'reboot') {
|
||
showRebootModal();
|
||
} else {
|
||
// Wait a moment, then refresh status
|
||
setTimeout(fetchStatus, 200);
|
||
}
|
||
} catch (e) {
|
||
console.log(e);
|
||
await modalAlert(`Network error: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
async function calibrate(type) {
|
||
const cmdName = type === 'jack' ? 'cal_jack' : 'cal_drive';
|
||
|
||
if (!await modalConfirm(`This will calibrate the ${type}. Continue?`))
|
||
return;
|
||
|
||
try {
|
||
const 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() {
|
||
const now = new Date();
|
||
const input = ge('UX_TIME');
|
||
|
||
// Format local time directly without timezone conversion
|
||
const year = now.getFullYear();
|
||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||
const day = String(now.getDate()).padStart(2, '0');
|
||
const hours = String(now.getHours()).padStart(2, '0');
|
||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||
const seconds = String(now.getSeconds()).padStart(2, '0');
|
||
|
||
input.value = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
|
||
markChanged(input);
|
||
}
|
||
|
||
function markChanged(el) {
|
||
el.classList.add("changed");
|
||
ge('commit_btn').disabled = false;
|
||
ge('cancel_btn').disabled = false;
|
||
}
|
||
|
||
function handleRemainingDistChange(input) {
|
||
// remaining_dist is a special parameter that should be sent directly
|
||
markChanged(input);
|
||
}
|
||
|
||
const scheduleInputs = ['MOVE_START', 'MOVE_END', 'NUM_MOVES', 'DRIVE_DIST', 'JACK_DIST'];
|
||
|
||
|
||
function changeSchedule(ux_input) {
|
||
// When a schedule field changes:
|
||
// 1. Mark the input as changed
|
||
// 2. Update the corresponding parameter in the parameter table
|
||
|
||
param_input = ge(`PARAM_${ux_input.id.substring(3)}`);
|
||
markChanged(ux_input);
|
||
markChanged(param_input);
|
||
|
||
if (ux_input.type === 'time') {
|
||
// Convert time (HH:MM) to seconds since midnight
|
||
const [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() {
|
||
// Update the schedule fields with info from the parameter table
|
||
|
||
for (let i = 0; i < scheduleInputs.length; i++) {
|
||
ux_input = ge(`UX_${scheduleInputs[i]}`);
|
||
param_input = ge(`PARAM_${scheduleInputs[i]}`);
|
||
|
||
if (!ux_input || !param_input) continue;
|
||
|
||
// Skip if the UX input is changed or focused
|
||
if (ux_input.classList.contains('changed') || document.activeElement === ux_input) {
|
||
continue;
|
||
}
|
||
|
||
if (ux_input.type === 'time') {
|
||
// Convert seconds since midnight to HH:MM
|
||
const hours = Math.floor(param_input.value / 3600);
|
||
const 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() {
|
||
const changedInputs = document.querySelectorAll('input.changed');
|
||
if (changedInputs.length === 0) return;
|
||
|
||
const payload = {};
|
||
|
||
// Process each changed input
|
||
for (const input of changedInputs) {
|
||
const id = input.id;
|
||
|
||
if (id === 'UX_TIME') {
|
||
// Special handling for time - convert to Unix timestamp (local time, no timezone)
|
||
const dt = new Date(input.value);
|
||
// Extract local components and calculate seconds since epoch as if it were UTC
|
||
const year = dt.getFullYear();
|
||
const month = dt.getMonth();
|
||
const day = dt.getDate();
|
||
const hours = dt.getHours();
|
||
const minutes = dt.getMinutes();
|
||
const seconds = dt.getSeconds();
|
||
payload.time = Math.floor(Date.UTC(year, month, day, hours, minutes, seconds) / 1000);
|
||
} else if (id === 'UX_REM_DIST') {
|
||
// Special parameter - send directly
|
||
payload.remaining_dist = parseFloat(input.value) || 0;
|
||
} else if (id.startsWith('PARAM_')) {
|
||
// Parameter table inputs
|
||
const paramName = id.substring(6);
|
||
|
||
let value = input.value;
|
||
if (input.type === 'number')
|
||
value = parseFloat(input.value) || 0;
|
||
|
||
// Add to parameters object
|
||
if (!payload.parameters) {
|
||
payload.parameters = {};
|
||
}
|
||
payload.parameters[paramName] = value;
|
||
}
|
||
}
|
||
|
||
try {
|
||
console.log('Sending data:', payload);
|
||
|
||
const 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;
|
||
}
|
||
|
||
// Clear all 'changed' classes
|
||
changedInputs.forEach(input => input.classList.remove('changed'));
|
||
ge('commit_btn').disabled = true;
|
||
ge('cancel_btn').disabled = true;
|
||
|
||
// Refresh status after a moment
|
||
setTimeout(fetchStatus, 200);
|
||
} catch (e) {
|
||
await modalAlert(`Network error: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
async function fetchStatus() {
|
||
try {
|
||
const 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);
|
||
}
|
||
}
|
||
|
||
// Shared guard: skip writes into fields the user is currently editing
|
||
// (focused OR marked .changed). Applies uniformly across the front
|
||
// page AND the DANGER ZONE param table.
|
||
function _safeSet(el, value) {
|
||
if (!el) return;
|
||
if (document.activeElement === el) return;
|
||
if (el.classList && el.classList.contains('changed')) return;
|
||
el.value = value;
|
||
}
|
||
|
||
function updateUI() {
|
||
if (!data.rtc_set) {
|
||
showRTCSyncModal();
|
||
} else if (data.time !== undefined) {
|
||
// The device stores "local time as UTC" (same encoding commitParams uses).
|
||
// Compare against that same encoding, not raw Date.now(), to avoid
|
||
// a false timezone offset appearing as drift.
|
||
const n = new Date();
|
||
const localAsUtc = Math.floor(Date.UTC(n.getFullYear(), n.getMonth(), n.getDate(),
|
||
n.getHours(), n.getMinutes(), n.getSeconds()) / 1000);
|
||
const driftS = Math.abs(localAsUtc - data.time);
|
||
if (driftS > 300) showDesyncModal(Math.round(driftS / 60));
|
||
}
|
||
|
||
// Status box: state label + per-error pills.
|
||
{
|
||
const state = Array.isArray(data.msg) ? data.msg[0] || '' : (data.msg || '');
|
||
// Pills rendered in priority order: hardware errors first,
|
||
// then config / housekeeping warnings. Each entry:
|
||
// [errorKey, pill text, 'error' | 'warn']
|
||
const 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'],
|
||
];
|
||
const errs = data.errors || {};
|
||
const activePills = INDICATORS.filter(([k]) => errs[k]);
|
||
|
||
const stateEl = ge('status_state');
|
||
stateEl.textContent = state || '—';
|
||
stateEl.classList.toggle('error', activePills.length > 0);
|
||
|
||
// Action button: STATE_IDLE (0) → "START MOVE" (green);
|
||
// any other state → "UNDO" (yellow). Single button replaces
|
||
// the old separate START/UNDO controls.
|
||
const actionBtn = ge('action_btn');
|
||
if (actionBtn) {
|
||
const isIdle = !data.state || data.state === 0;
|
||
actionBtn.textContent = isIdle ? 'START MOVE' : 'UNDO';
|
||
actionBtn.classList.toggle('moving', !isIdle);
|
||
}
|
||
|
||
const box = ge('status_indicators');
|
||
box.innerHTML = '';
|
||
for (const [, label, kind] of activePills) {
|
||
const pill = document.createElement('span');
|
||
pill.className = 'status_pill ' + kind;
|
||
pill.textContent = label;
|
||
box.appendChild(pill);
|
||
}
|
||
}
|
||
|
||
// Update voltage (read-only — but still skip while focused so a
|
||
// user copying the value doesn't lose their selection)
|
||
if (data.voltage !== undefined) {
|
||
_safeSet(ge('voltage'), data.voltage.toFixed(2));
|
||
}
|
||
|
||
if (data.build_version || data.build_date) {
|
||
_safeSet(ge('version'), data.build_version + ' ('+data.build_date+')');
|
||
}
|
||
|
||
// Update time
|
||
const timeInput = ge('UX_TIME');
|
||
if (data.time !== undefined) {
|
||
// Treat incoming timestamp as local time (no timezone conversion)
|
||
const dt = new Date(data.time * 1000);
|
||
const year = dt.getUTCFullYear();
|
||
const month = String(dt.getUTCMonth() + 1).padStart(2, '0');
|
||
const day = String(dt.getUTCDate()).padStart(2, '0');
|
||
const hours = String(dt.getUTCHours()).padStart(2, '0');
|
||
const minutes = String(dt.getUTCMinutes()).padStart(2, '0');
|
||
const seconds = String(dt.getUTCSeconds()).padStart(2, '0');
|
||
_safeSet(timeInput, `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`);
|
||
}
|
||
|
||
const timeOutput = ge('UX_NEXT_ALARM');
|
||
if (data.next_alarm !== undefined && document.activeElement !== timeOutput) {
|
||
if (data.next_alarm > 0) {
|
||
// Flip the element back to a datetime-local input so
|
||
// the value parses — the "disabled" branch swaps it to
|
||
// a plain text input to display a message.
|
||
if (timeOutput.type !== 'datetime-local') timeOutput.type = 'datetime-local';
|
||
// Treat incoming timestamp as local time (no timezone conversion)
|
||
const dt = new Date(data.next_alarm * 1000);
|
||
const year = dt.getUTCFullYear();
|
||
const month = String(dt.getUTCMonth() + 1).padStart(2, '0');
|
||
const day = String(dt.getUTCDate()).padStart(2, '0');
|
||
const hours = String(dt.getUTCHours()).padStart(2, '0');
|
||
const minutes = String(dt.getUTCMinutes()).padStart(2, '0');
|
||
const seconds = String(dt.getUTCSeconds()).padStart(2, '0');
|
||
timeOutput.value = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
|
||
} else {
|
||
// <=0 means firmware has no alarm scheduled (e.g. NUM_MOVES=0).
|
||
if (timeOutput.type !== 'text') timeOutput.type = 'text';
|
||
timeOutput.value = 'DISABLED';
|
||
}
|
||
}
|
||
|
||
// Update parameters
|
||
if (data.parameters) {
|
||
updateParamTable();
|
||
updateScheduleInputs();
|
||
}
|
||
|
||
// Update remaining distance (special parameter)
|
||
if (data.remaining_dist !== undefined) {
|
||
_safeSet(ge('UX_REM_DIST'), data.remaining_dist.toFixed(1));
|
||
}
|
||
|
||
|
||
}
|
||
|
||
// Keys whose inputs live OUTSIDE the auto-generated DANGER ZONE
|
||
// table (in the dedicated WiFi section, or commented out entirely).
|
||
// Must be filtered in BOTH the build and update paths — otherwise a
|
||
// poll sees "no input for NET_SSID" and force-rebuilds the whole
|
||
// table every tick, wiping every in-progress edit.
|
||
const PARAM_TABLE_SKIP = new Set(['NET_SSID', 'NET_PASS', 'WIFI_SSID', 'WIFI_PASS']);
|
||
|
||
function updateParamTable() {
|
||
const table = ge('table');
|
||
|
||
// Sort parameters alphabetically, pre-filtering keys that don't
|
||
// belong in the auto-generated DANGER ZONE table.
|
||
const sortedParams = Object.entries(data.parameters)
|
||
.filter(([k]) => !PARAM_TABLE_SKIP.has(k))
|
||
.sort((a, b) => a[0].localeCompare(b[0]));
|
||
|
||
if (!paramTableCreated) {
|
||
// Create table for the first time
|
||
table.innerHTML = '';
|
||
for (const [key, value] of sortedParams) {
|
||
const row = table.insertRow();
|
||
const cell1 = row.insertCell(0);
|
||
const cell2 = row.insertCell(1);
|
||
|
||
cell1.textContent = key;
|
||
|
||
const 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);
|
||
}
|
||
|
||
// add listener so anytime you click anything you select the box
|
||
// duh
|
||
document.querySelectorAll('input').forEach(input => {
|
||
input.addEventListener('click', function() {
|
||
if (this.readOnly != true)
|
||
this.select();
|
||
});
|
||
});
|
||
|
||
paramTableCreated = true;
|
||
} else {
|
||
// Update existing table
|
||
for (const [key, value] of sortedParams) {
|
||
const input = ge(`PARAM_${key}`);
|
||
if (input) {
|
||
// _safeSet skips changed/focused inputs so in-progress edits survive.
|
||
_safeSet(input, value);
|
||
} else {
|
||
// Genuinely new param (unknown key AND not in skip list) → rebuild.
|
||
paramTableCreated = false;
|
||
updateParamTable();
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
async function uploadFirmware() {
|
||
// Trigger file picker
|
||
const fileInput = ge('firmware_file');
|
||
fileInput.click();
|
||
|
||
// Wait for file selection
|
||
await new Promise((resolve) => {
|
||
fileInput.onchange = () => resolve();
|
||
});
|
||
|
||
const file = fileInput.files[0];
|
||
|
||
if (!file) {
|
||
// User cancelled file picker
|
||
return;
|
||
}
|
||
|
||
if (!file.name.endsWith('.bin')) {
|
||
await modalAlert('Please select a .bin file');
|
||
return;
|
||
}
|
||
|
||
// Confirm upload
|
||
if (!await modalConfirm(`Upload firmware file "${file.name}"?\n\nDevice will reboot after upload.`)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Read the file as an ArrayBuffer (raw binary)
|
||
const arrayBuffer = await file.arrayBuffer();
|
||
|
||
// Show progress modal
|
||
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>`,
|
||
[]
|
||
);
|
||
|
||
// Create XMLHttpRequest for progress tracking
|
||
const xhr = new XMLHttpRequest();
|
||
|
||
// Track upload progress
|
||
xhr.upload.addEventListener('progress', (e) => {
|
||
if (e.lengthComputable) {
|
||
const percentComplete = Math.round((e.loaded / e.total) * 100);
|
||
const progressEl = document.getElementById('upload-progress');
|
||
if (progressEl) {
|
||
progressEl.textContent = percentComplete + '%';
|
||
}
|
||
}
|
||
});
|
||
|
||
// Handle completion
|
||
const uploadPromise = new Promise((resolve, reject) => {
|
||
xhr.addEventListener('load', () => {
|
||
if (xhr.status >= 200 && xhr.status < 300) {
|
||
resolve();
|
||
} else {
|
||
reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`));
|
||
}
|
||
});
|
||
|
||
xhr.addEventListener('error', () => {
|
||
reject(new Error('Network error during upload'));
|
||
});
|
||
|
||
xhr.addEventListener('abort', () => {
|
||
reject(new Error('Upload cancelled'));
|
||
});
|
||
});
|
||
|
||
// Send the request
|
||
xhr.open('POST', './ota');
|
||
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
|
||
xhr.send(arrayBuffer);
|
||
|
||
// Wait for completion
|
||
await uploadPromise;
|
||
|
||
// Show success and countdown
|
||
showModal('Firmware Update',
|
||
'Firmware uploaded successfully! Device is rebooting...<br>Page will refresh in <span id="popup-countdown">5</span> seconds...',
|
||
[]
|
||
);
|
||
|
||
let countdown = 5;
|
||
const countdownInterval = setInterval(() => {
|
||
countdown--;
|
||
const countdownEl = document.getElementById('popup-countdown');
|
||
if (countdownEl) {
|
||
countdownEl.textContent = countdown;
|
||
}
|
||
if (countdown <= 0) {
|
||
clearInterval(countdownInterval);
|
||
location.reload();
|
||
}
|
||
}, 1000);
|
||
|
||
} 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() {
|
||
const buttonNames = ["Forward", "Reverse", "Up", "Down"];
|
||
const learnedCodes = [null, null, null, null];
|
||
|
||
if (!await modalConfirm("This will program all 4 RF remote buttons in sequence.\n\nPress OK to begin, then follow the prompts.")) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Clear temp storage and disable RF controls during programming
|
||
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;
|
||
}
|
||
|
||
response = await fetch('./post', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({cmd: 'rf_disable'})
|
||
});
|
||
if (!response.ok) {
|
||
await modalAlert('Failed to disable RF controls');
|
||
return;
|
||
}
|
||
|
||
for (let i = 0; i < 4; i++) {
|
||
// START LEARNING for this button
|
||
await fetch('./post', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({cmd: 'rf_learn', channel: i})
|
||
});
|
||
|
||
// Give RF module a moment to enter learning mode
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
|
||
// Show dialog and instruct user to press button
|
||
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'
|
||
);
|
||
|
||
// STOP LEARNING
|
||
await fetch('./post', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({cmd: 'rf_learn', channel: -1})
|
||
});
|
||
|
||
// Check what was learned (if anything)
|
||
try {
|
||
const 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 {
|
||
const data = await response.json();
|
||
// Get whatever is in temp storage (could be 0 if nothing was pressed)
|
||
learnedCodes[i] = data.codes ? data.codes[i] : 0;
|
||
}
|
||
} catch (e) {
|
||
console.error("Error checking RF status:", e);
|
||
learnedCodes[i] = 0;
|
||
}
|
||
|
||
// If nothing was learned, make sure it's set to 0
|
||
if (learnedCodes[i] === 0) {
|
||
await fetch('./post', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({cmd: 'rf_set_temp', index: i, code: 0})
|
||
});
|
||
}
|
||
}
|
||
|
||
// Update input fields for (PARAM_KEYCODE_0 through PARAM_KEYCODE_3)
|
||
for (let i = 0; i < 4; i++) {
|
||
const input = ge(`PARAM_KEYCODE_${i}`);
|
||
if (input) {
|
||
input.value = learnedCodes[i];
|
||
markChanged(input);
|
||
}
|
||
}
|
||
|
||
// Commit just the RF keycodes using parameters object
|
||
const parameters = {
|
||
'KEYCODE_0': learnedCodes[0],
|
||
'KEYCODE_1': learnedCodes[1],
|
||
'KEYCODE_2': learnedCodes[2],
|
||
'KEYCODE_3': learnedCodes[3]
|
||
};
|
||
|
||
response = await fetch('./post', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({parameters: parameters})
|
||
});
|
||
|
||
if (response.ok) {
|
||
// Unhighlight the keycode inputs
|
||
for (let i = 0; i < 4; i++) {
|
||
const input = ge(`PARAM_KEYCODE_${i}`);
|
||
if (input) {
|
||
input.classList.remove("changed");
|
||
}
|
||
}
|
||
|
||
// Check if commit button should stay enabled
|
||
if (document.querySelectorAll('input.changed').length === 0) {
|
||
ge('commit_btn').disabled = true;
|
||
ge('cancel_btn').disabled = true;
|
||
}
|
||
} else {
|
||
await modalAlert(`Failed to save RF codes: ${response.status} ${response.statusText}`);
|
||
}
|
||
|
||
// Re-enable RF controls after programming
|
||
await fetch('./post', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({cmd: 'rf_enable'})
|
||
});
|
||
|
||
// Show summary
|
||
let summary = "RF Remote Programming Complete!\n\n";
|
||
for (let i = 0; i < 4; i++) {
|
||
if (learnedCodes[i] === 0) {
|
||
summary += `${buttonNames[i]}: - <br/>`;
|
||
} else {
|
||
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 {
|
||
// Start calibration
|
||
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;
|
||
}
|
||
|
||
// Prompt user for actual distance
|
||
const amt = await modalPrompt(
|
||
`Press button on mover. Press button again to stop it.\n\nThen, type the actual travelled distance in inches:`,
|
||
'Calibration'
|
||
);
|
||
|
||
if (amt === null || amt === '') {
|
||
await modalAlert('Calibration cancelled');
|
||
return;
|
||
}
|
||
|
||
const distance = parseFloat(amt);
|
||
if (isNaN(distance) || distance <= 0) {
|
||
await modalAlert('Invalid distance entered. Please enter a positive number.');
|
||
return;
|
||
}
|
||
|
||
// Get calibration data
|
||
response = await fetch('./post', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({cmd: 'cal_get'})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
await modalAlert(`Failed to get calibration data: ${response.status}`);
|
||
return;
|
||
}
|
||
|
||
const calData = await response.json();
|
||
|
||
// Calculate and update parameters based on axis
|
||
if (axis === 'drive') {
|
||
// Convert inches to feet: divide by 12
|
||
const ke = calData.e / (distance / 12); // encoder steps per foot
|
||
const kt = calData.t / (distance / 12) * 1.2; // timeout ms per foot, with 20% margin
|
||
|
||
const keInput = ge('PARAM_DRIVE_KE');
|
||
const ktInput = ge('PARAM_DRIVE_KT');
|
||
|
||
if (keInput) {
|
||
keInput.value = ke.toFixed(2);
|
||
markChanged(keInput);
|
||
} else {
|
||
console.error('Could not find PARAM_DRIVE_KE');
|
||
}
|
||
|
||
if (ktInput) {
|
||
ktInput.value = Math.round(kt);
|
||
markChanged(ktInput);
|
||
} else {
|
||
console.error('Could not find PARAM_DRIVE_KT');
|
||
}
|
||
|
||
if (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.`);
|
||
} else {
|
||
await modalAlert('Drive calibration failed: Could not find DRIVE_KE or DRIVE_KT parameters.');
|
||
}
|
||
|
||
} else if (axis === 'jack') {
|
||
const kt = calData.t / distance; // timeout ms per inch
|
||
|
||
const ktInput = ge('PARAM_JACK_KT');
|
||
|
||
if (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.`);
|
||
} else {
|
||
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;
|
||
|
||
const wifiKeys = ['NET_SSID', 'NET_PASS', 'WIFI_SSID', 'WIFI_PASS'];
|
||
const params = {};
|
||
for (const key of wifiKeys) {
|
||
const el = ge('PARAM_' + key);
|
||
if (el) params[key] = el.value;
|
||
}
|
||
|
||
try {
|
||
const 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 (const key of wifiKeys) {
|
||
const el = ge('PARAM_' + key);
|
||
if (el) el.classList.remove('changed');
|
||
}
|
||
if (document.querySelectorAll('input.changed').length === 0) {
|
||
ge('commit_btn').disabled = true;
|
||
ge('cancel_btn').disabled = true;
|
||
}
|
||
await modalAlert('WiFi settings applied. Reconnect in a moment.');
|
||
} catch(e) {
|
||
await modalAlert('Network error: ' + e.message);
|
||
}
|
||
}
|
||
|
||
async function downloadLogFile() {
|
||
try {
|
||
const response = await fetch('./log');
|
||
if (!response.ok) {
|
||
await modalAlert(`Failed to download log file: ${response.status} ${response.statusText}`);
|
||
return;
|
||
}
|
||
const blob = await response.blob();
|
||
|
||
// Get current date and time
|
||
const now = new Date();
|
||
const day = String(now.getDate()).padStart(2, '0');
|
||
const monthNames = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
|
||
const month = monthNames[now.getMonth()];
|
||
const year = now.getFullYear();
|
||
const hours = String(now.getHours()).padStart(2, '0');
|
||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||
|
||
const formattedDate = `${day}${month}${year}-${hours}${minutes}`;
|
||
|
||
const url = URL.createObjectURL(blob);
|
||
const 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}`);
|
||
}
|
||
}
|
||
|
||
// ---- Log viewer ------------------------------------------------
|
||
// Parses the /log endpoint binary blob:
|
||
// [4B BE json_len][json_len bytes JSON][4B BE tail][4B BE head][log data...]
|
||
// Log data entries (current FW): [len u8][payload (len-1 bytes)][type u8]
|
||
// Total per entry = len+1. 0xFF / 0x00 bytes → skip to next 4096 sector.
|
||
|
||
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',
|
||
];
|
||
const LOG_TYPE_BAT = 100, LOG_TYPE_CRASH = 101, LOG_TYPE_BOOT = 102, LOG_TYPE_TIME_SET = 103;
|
||
const 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 = false;
|
||
|
||
const LOG_MONTHS = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'];
|
||
function _logFormatTs(tsMs) {
|
||
// Device stores "local time as UTC ms" — display via getUTC* to avoid
|
||
// the browser re-applying its own timezone offset.
|
||
// Format: 2026APR08 14:23:45.123
|
||
if (!tsMs || tsMs <= 0) return '-';
|
||
const d = new Date(tsMs);
|
||
const 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)}`;
|
||
}
|
||
|
||
// Decode the sensors byte: low nibble = debounced stable state,
|
||
// high nibble = raw level. Bit order SAFETY / DRIVE / JACK / AUX.
|
||
const LOG_SENSOR_NAMES = ['SAFETY', 'DRIVE', 'JACK', 'AUX'];
|
||
function _decodeSensors(b) {
|
||
const stable = b & 0x0F, raw = (b >> 4) & 0x0F;
|
||
const parts = [];
|
||
for (let i = 0; i < 4; i++) {
|
||
const s = (stable >> i) & 1;
|
||
const r = (raw >> i) & 1;
|
||
parts.push(`${LOG_SENSOR_NAMES[i]}=${s}${s !== r ? ` (raw ${r})` : ''}`);
|
||
}
|
||
return parts.join(', ');
|
||
}
|
||
|
||
function _parseLogEntries(bytes) {
|
||
const entries = [];
|
||
const n = bytes.length;
|
||
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||
let i = 0;
|
||
while (i < n) {
|
||
const b = bytes[i];
|
||
if (b === 0xFF || b === 0x00) {
|
||
// Skip to next 4096-aligned sector.
|
||
i = (Math.floor(i / 4096) + 1) * 4096;
|
||
continue;
|
||
}
|
||
const entryLen = b; // stored len = payload_size + 1
|
||
const payloadSize = entryLen - 1;
|
||
const endOffset = i + entryLen;
|
||
if (endOffset >= n) break; // truncated
|
||
const type = bytes[endOffset];
|
||
const payloadStart = i + 1;
|
||
const entry = { type };
|
||
try {
|
||
if (type >= 0 && type <= 13 && payloadSize >= 19) {
|
||
// Single-current FSM payload (25 bytes):
|
||
// ts(8) bat(4) current(4) counter(2) sensors(1)
|
||
// heat(4) i2c_out(2)
|
||
entry.ts_ms = Number(dv.getBigUint64(payloadStart + 0, true));
|
||
entry.bat_V = dv.getFloat32(payloadStart + 8, true);
|
||
entry.current_A = dv.getFloat32(payloadStart + 12, true);
|
||
entry.counter = dv.getInt16(payloadStart + 16, true);
|
||
entry.sensors = bytes[payloadStart + 18];
|
||
if (payloadSize >= 23) {
|
||
entry.heat = dv.getFloat32(payloadStart + 19, true);
|
||
}
|
||
if (payloadSize >= 25) {
|
||
entry.i2c_out = dv.getUint16(payloadStart + 23, true);
|
||
}
|
||
entry.name = LOG_FSM_NAMES[type] || `STATE_${type}`;
|
||
} else if (type === LOG_TYPE_BAT && payloadSize >= 12) {
|
||
entry.ts_ms = Number(dv.getBigUint64(payloadStart, true));
|
||
entry.bat_V = dv.getFloat32(payloadStart + 8, true);
|
||
entry.name = 'BAT';
|
||
} else if ((type === LOG_TYPE_CRASH || type === LOG_TYPE_BOOT) && payloadSize >= 9) {
|
||
entry.ts_ms = Number(dv.getBigUint64(payloadStart, true));
|
||
const info = bytes[payloadStart + 8];
|
||
entry.reason = LOG_RESET_REASONS[info & 0x0F] || `UNKNOWN(${info & 0x0F})`;
|
||
entry.name = type === LOG_TYPE_BOOT ? 'BOOT' : 'CRASH';
|
||
} else if (type === LOG_TYPE_TIME_SET && payloadSize >= 8) {
|
||
entry.ts_ms = Number(dv.getBigUint64(payloadStart, true));
|
||
entry.name = 'TIME_SET';
|
||
} else {
|
||
entry.name = `UNK(0x${type.toString(16).padStart(2,'0')})`;
|
||
}
|
||
} catch (e) {
|
||
entry.name = 'PARSE_ERR';
|
||
}
|
||
entries.push(entry);
|
||
i = endOffset + 1;
|
||
}
|
||
return entries;
|
||
}
|
||
|
||
function _entryCells(e) {
|
||
// Returns an array of {text, title?} describing each column for
|
||
// a single entry. `title` becomes the tooltip (title attribute).
|
||
const sensorHex = e.sensors !== undefined
|
||
? '0x' + e.sensors.toString(16).padStart(2,'0')
|
||
: '';
|
||
const i2cHex = e.i2c_out !== undefined
|
||
? '0x' + e.i2c_out.toString(16).padStart(4,'0')
|
||
: '';
|
||
return [
|
||
{ text: _logFormatTs(e.ts_ms) },
|
||
{ text: e.name || '' },
|
||
{ text: e.bat_V !== undefined ? e.bat_V.toFixed(2) : '' },
|
||
{ text: e.current_A !== undefined ? e.current_A.toFixed(2) : '' },
|
||
{ text: e.counter !== undefined ? String(e.counter) : '' },
|
||
{ text: sensorHex,
|
||
title: e.sensors !== undefined ? _decodeSensors(e.sensors) : undefined },
|
||
{ text: e.heat !== undefined ? e.heat.toFixed(2) : '' },
|
||
{ text: i2cHex,
|
||
title: e.i2c_out !== undefined
|
||
? '0b' + e.i2c_out.toString(2).padStart(16,'0')
|
||
: undefined },
|
||
{ text: e.reason || '' },
|
||
];
|
||
}
|
||
|
||
function _appendRow(table, cells, opts = {}) {
|
||
const row = table.insertRow();
|
||
if (opts.className) row.className = opts.className;
|
||
if (opts.hidden) row.style.display = 'none';
|
||
if (opts.onclick) row.addEventListener('click', opts.onclick);
|
||
if (opts.style) Object.assign(row.style, opts.style);
|
||
cells.forEach(c => {
|
||
const td = document.createElement('td');
|
||
if (typeof c === 'string') {
|
||
td.textContent = c;
|
||
} else {
|
||
td.textContent = c.text ?? '';
|
||
if (c.title) td.title = c.title;
|
||
if (c.colSpan) td.colSpan = c.colSpan;
|
||
if (c.style) Object.assign(td.style, c.style);
|
||
if (c.bold) td.style.fontWeight = 'bold';
|
||
}
|
||
row.appendChild(td);
|
||
});
|
||
return row;
|
||
}
|
||
|
||
function _renderLogEntries(entries) {
|
||
const table = ge('log_viewer_table');
|
||
table.innerHTML = '';
|
||
const cols = ['Time','Type','Battery V','Current A','Counter','Sensors','Heat','I2C Out','Extra'];
|
||
_appendRow(table, cols.map(h => ({ text: h, bold: true })));
|
||
|
||
// Walk newest-first, group contiguous entries sharing the same
|
||
// state name. Groups with ≥2 members render as a single clickable
|
||
// header row (default collapsed) followed by their entry rows.
|
||
let groupId = 0;
|
||
let i = entries.length - 1;
|
||
while (i >= 0) {
|
||
const name = entries[i].name || '';
|
||
let j = i;
|
||
while (j >= 0 && (entries[j].name || '') === name) j--;
|
||
const group = entries.slice(j + 1, i + 1).reverse(); // newest-first
|
||
i = j;
|
||
|
||
if (group.length === 1) {
|
||
_appendRow(table, _entryCells(group[0]));
|
||
continue;
|
||
}
|
||
|
||
groupId++;
|
||
const rowClass = `log-group-${groupId}`;
|
||
let collapsed = true;
|
||
const firstTs = _logFormatTs(group[group.length - 1].ts_ms);
|
||
const lastTs = _logFormatTs(group[0].ts_ms);
|
||
const header = _appendRow(table, [{
|
||
text: `[+] ${name} × ${group.length} ${firstTs} → ${lastTs}`,
|
||
colSpan: cols.length,
|
||
style: { cursor: 'pointer', background: 'var(--surface-2)',
|
||
color: 'var(--text)',
|
||
fontWeight: 'bold', fontSize: '0.7rem' },
|
||
}], {
|
||
style: { background: 'var(--surface-2)' },
|
||
});
|
||
// Defer wiring the onclick until we've appended the child rows.
|
||
const childRows = group.map(e =>
|
||
_appendRow(table, _entryCells(e), {
|
||
className: rowClass,
|
||
hidden: true,
|
||
}));
|
||
header.addEventListener('click', () => {
|
||
collapsed = !collapsed;
|
||
childRows.forEach(r => r.style.display = collapsed ? 'none' : '');
|
||
const cell = header.cells[0];
|
||
cell.textContent = `[${collapsed ? '+' : '-'}] ${name} × ${group.length} ${firstTs} → ${lastTs}`;
|
||
});
|
||
}
|
||
}
|
||
|
||
async function loadLogViewer() {
|
||
const msg = ge('log_viewer_msg');
|
||
msg.textContent = 'Connecting...';
|
||
try {
|
||
const resp = await fetch('./log');
|
||
if (!resp.ok) { msg.textContent = `Failed: ${resp.status} ${resp.statusText}`; return; }
|
||
|
||
// Stream the body so we can report progress as bytes arrive.
|
||
// /log sets Content-Length, so we know the total ahead of time.
|
||
const total = parseInt(resp.headers.get('Content-Length') || '0', 10);
|
||
const reader = resp.body.getReader();
|
||
const chunks = [];
|
||
let received = 0;
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
chunks.push(value);
|
||
received += value.length;
|
||
const kb = (received / 1024).toFixed(1);
|
||
if (total) {
|
||
const totalKb = (total / 1024).toFixed(1);
|
||
const pct = Math.round((received / total) * 100);
|
||
msg.textContent = `Downloading... ${kb} / ${totalKb} KB (${pct}%)`;
|
||
} else {
|
||
msg.textContent = `Downloading... ${kb} KB`;
|
||
}
|
||
}
|
||
|
||
// Concatenate chunks into a single Uint8Array.
|
||
const buf = new Uint8Array(received);
|
||
{
|
||
let off = 0;
|
||
for (const 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...`;
|
||
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
|
||
const jsonLen = dv.getUint32(0, false);
|
||
if (jsonLen > 65536 || 4 + jsonLen + 8 > buf.length) {
|
||
msg.textContent = 'Log response malformed.'; return;
|
||
}
|
||
const binStart = 4 + jsonLen + 8;
|
||
const logBytes = buf.subarray(binStart);
|
||
const entries = _parseLogEntries(logBytes);
|
||
_renderLogEntries(entries);
|
||
msg.textContent = `${entries.length} entries (${logBytes.length} bytes)`;
|
||
logViewerLoaded = true;
|
||
} catch (err) {
|
||
msg.textContent = `Error: ${err.message}`;
|
||
}
|
||
}
|
||
|
||
// Populate the first time the details section is opened.
|
||
function _attachLogViewerToggle() {
|
||
const d = ge('log_viewer_details');
|
||
if (!d) return;
|
||
d.addEventListener('toggle', () => {
|
||
if (d.open && !logViewerLoaded) loadLogViewer();
|
||
});
|
||
}
|
||
|
||
// Start automatic polling
|
||
function startPolling() {
|
||
// Initial fetch
|
||
fetchStatus();
|
||
|
||
// Set up interval for polling in ms
|
||
pollInterval = setInterval(fetchStatus, 3000);
|
||
}
|
||
|
||
// Stop polling (if needed in the future)
|
||
function stopPolling() {
|
||
if (pollInterval) {
|
||
clearInterval(pollInterval);
|
||
pollInterval = null;
|
||
}
|
||
}
|
||
|
||
document.addEventListener('visibilitychange', function() {
|
||
if (document.hidden) {
|
||
// Tab is hidden - stop polling
|
||
stopPolling();
|
||
} else {
|
||
// Tab is visible again - resume polling
|
||
fetchStatus(); // Immediate fetch when returning
|
||
pollInterval = setInterval(fetchStatus, 3000);
|
||
}
|
||
});
|
||
|
||
// Initial Load with polling
|
||
window.onload = function() {
|
||
ge('commit_btn').disabled = true;
|
||
ge('cancel_btn').disabled = true;
|
||
_attachLogViewerToggle();
|
||
startPolling();
|
||
}
|
||
</script>
|
||
</body>
|
||
</html> |