Files
SC-F001/main/webpage.html
2026-04-27 17:22:34 -05:00

1882 lines
74 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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">&mdash;</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>