let pollInterval = 500;
let chartMaxPoints = 300;
let lastTimestamp = 0;
let chart = null;
let firstTimestamp = null;
let lastApiError = '';
let actionBannerTimer = null;
let heartbeatMisses = 0;
let heartbeatInstanceId = null;
let controlsEnabled = true;
let deferredInstallPrompt = null;
let uiMode = 'nail1';
let activeNailId = 'nail1';
let historyBuffer = [];
let schedulerTimesByNail = { nail1: [], nail2: [] };
let states = { nail1: null, nail2: null };
function nailIdFromNum(num) {
return String(num) === '2' ? 'nail2' : 'nail1';
}
function nailNumFromId(id) {
return id === 'nail2' ? 2 : 1;
}
function currentNailId() {
return activeNailId;
}
function nowHms() {
return new Date().toLocaleTimeString();
}
function setLastAck(message, ok=true) {
const el = document.getElementById('last-ack');
if (!el) return;
el.className = 'last-ack ' + (ok ? 'ok' : 'err');
el.textContent = 'Last command: ' + message + ' at ' + nowHms();
}
function showAction(message, type='info', timeoutMs=3000) {
const banner = document.getElementById('action-banner');
const msg = document.getElementById('action-message');
if (!banner || !msg) return;
msg.textContent = message;
banner.className = 'action-banner ' + type;
if (actionBannerTimer) clearTimeout(actionBannerTimer);
if (timeoutMs > 0) {
actionBannerTimer = setTimeout(function() {
banner.className = 'action-banner hidden';
}, timeoutMs);
}
}
function setControlsEnabled(enabled) {
controlsEnabled = enabled;
document.querySelectorAll('button').forEach(function(btn) {
if (btn.id === 'autotune-stop-btn' && enabled) return;
btn.disabled = !enabled;
});
}
function setBackendStatus(mode, text) {
const el = document.getElementById('backend-status');
if (!el) return;
el.className = 'backend-status ' + mode;
el.textContent = text;
}
function setConnectionStatus(connected) {
const dot = document.getElementById('connection-status');
if (!dot) return;
if (connected) {
dot.className = 'status-dot connected';
dot.title = 'Connected';
setBackendStatus('online', 'Backend: Online');
} else {
dot.className = 'status-dot disconnected';
dot.title = 'Disconnected';
setBackendStatus('offline', 'Backend: Offline');
}
}
function setUiMode(mode, persist=true) {
if (mode === 'nail2') activeNailId = 'nail2';
if (mode === 'nail1') activeNailId = 'nail1';
uiMode = (mode === 'simple' || mode === 'nail2' || mode === 'nail1') ? mode : 'nail1';
document.body.classList.remove('ui-simple', 'ui-nail1', 'ui-nail2');
document.body.classList.add('ui-' + uiMode);
['simple', 'nail1', 'nail2'].forEach(function(key) {
const btn = document.getElementById('mode-' + key + '-btn');
if (btn) btn.classList.toggle('active', key === uiMode);
});
const label = document.getElementById('advanced-nail-label');
if (label) label.textContent = 'Nail ' + nailNumFromId(activeNailId);
if (persist) {
try {
localStorage.setItem('pinail_ui_mode', uiMode);
} catch (e) {
console.warn('Could not persist ui mode:', e);
}
}
resetChartForNail();
}
function updateWallClock() {
const el = document.getElementById('wall-clock');
if (!el) return;
const d = new Date();
const hh = ('0' + d.getHours()).slice(-2);
const mm = ('0' + d.getMinutes()).slice(-2);
const ss = ('0' + d.getSeconds()).slice(-2);
el.textContent = hh + ':' + mm + ':' + ss;
}
function normalizeTimeString(t) {
const m = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec((t || '').trim());
if (!m) return null;
return ('0' + m[1]).slice(-2) + ':' + ('0' + m[2]).slice(-2);
}
function renderSchedulerTimes() {
const id = currentNailId();
const times = schedulerTimesByNail[id] || [];
const container = document.getElementById('sched-time-list');
if (!container) return;
if (!times.length) {
container.innerHTML = 'No descent times configured.';
return;
}
container.innerHTML = times.map(function(t) {
return '' + t + '';
}).join('');
}
function addSchedulerTime() {
const input = document.getElementById('sched-time-input');
if (!input) return;
const norm = normalizeTimeString(input.value);
if (!norm) {
showAction('Invalid time format. Use HH:MM.', 'error', 3000);
return;
}
const id = currentNailId();
if (!schedulerTimesByNail[id]) schedulerTimesByNail[id] = [];
if (schedulerTimesByNail[id].indexOf(norm) === -1) {
schedulerTimesByNail[id].push(norm);
schedulerTimesByNail[id].sort();
renderSchedulerTimes();
saveSchedulerSettings();
}
}
function removeSchedulerTime(t) {
const id = currentNailId();
schedulerTimesByNail[id] = (schedulerTimesByNail[id] || []).filter(function(x) { return x !== t; });
renderSchedulerTimes();
saveSchedulerSettings();
}
function schedulerEnabledChanged() {
saveSchedulerSettings();
}
function updateSimpleCards() {
['nail1', 'nail2'].forEach(function(id) {
const s = states[id];
if (!s) return;
const suffix = id === 'nail2' ? 'nail2' : 'nail1';
const tEl = document.getElementById('simple-temp-' + suffix);
const mEl = document.getElementById('simple-mode-' + suffix);
const spEl = document.getElementById('simple-target-' + suffix);
const pEl = document.getElementById('simple-power-' + suffix);
const inEl = document.getElementById('simple-setpoint-' + suffix);
if (tEl) tEl.textContent = s.temp.toFixed(1);
if (mEl) mEl.textContent = s.mode || 'grounded';
if (spEl) spEl.textContent = s.effective_setpoint.toFixed(0);
if (inEl && document.activeElement !== inEl) inEl.value = s.setpoint;
if (pEl) {
pEl.textContent = s.enabled ? 'ON' : 'OFF';
pEl.className = 'power-mini ' + (s.enabled ? 'on' : 'off');
}
});
}
function updateErrorStats() {
const el = document.getElementById('error-stats');
if (!el) return;
if (!historyBuffer.length) {
el.textContent = 'Err(3m): --';
return;
}
const nowTs = historyBuffer[historyBuffer.length - 1].timestamp;
const window = historyBuffer.filter(function(p) { return p.timestamp >= (nowTs - 180); });
if (!window.length) {
el.textContent = 'Err(3m): --';
return;
}
let sumAbs = 0;
let sumSq = 0;
let maxAbs = 0;
let sumSigned = 0;
window.forEach(function(p) {
const target = (typeof p.flight_setpoint === 'number') ? p.flight_setpoint : p.setpoint;
const err = p.temp - target;
const abs = Math.abs(err);
sumAbs += abs;
sumSq += err * err;
sumSigned += err;
if (abs > maxAbs) maxAbs = abs;
});
const n = window.length;
const mae = sumAbs / n;
const rmse = Math.sqrt(sumSq / n);
const bias = sumSigned / n;
el.textContent = 'Err(3m) MAE ' + mae.toFixed(1) + 'F | RMSE ' + rmse.toFixed(1) + 'F | Max ' + maxAbs.toFixed(1) + 'F | Bias ' + bias.toFixed(1) + 'F';
}
function setAutotuneUi(tune) {
const statusEl = document.getElementById('autotune-status');
const startBtn = document.getElementById('autotune-start-btn');
const stopBtn = document.getElementById('autotune-stop-btn');
const pill = document.getElementById('autotune-pill');
if (!statusEl || !startBtn || !stopBtn) return;
if (tune && tune.active) {
statusEl.className = 'autotune-status running';
const phase = tune.phase ? (' ' + tune.phase) : '';
statusEl.textContent = 'Running' + phase + ' (' + tune.high_peaks + '/' + tune.cycles + ' peaks)';
if (pill) {
pill.className = 'autotune-pill running';
pill.textContent = 'Autotune: Running' + phase;
}
startBtn.disabled = true;
stopBtn.disabled = false;
return;
}
startBtn.disabled = false;
stopBtn.disabled = true;
if (tune && tune.last_result) {
statusEl.className = 'autotune-status done';
statusEl.textContent = 'Complete: kP ' + tune.last_result.kP + ', kI ' + tune.last_result.kI + ', kD ' + tune.last_result.kD;
if (pill) {
pill.className = 'autotune-pill done';
pill.textContent = 'Autotune: Complete';
}
return;
}
statusEl.className = 'autotune-status idle';
statusEl.textContent = (tune && tune.message) ? tune.message : 'Idle';
if (pill) {
pill.className = 'autotune-pill idle';
pill.textContent = 'Autotune: Idle';
}
}
function initChart() {
const ctx = document.getElementById('temp-chart').getContext('2d');
chart = new Chart(ctx, {
type: 'line',
data: { datasets: [
{ label: 'Temperature (F)', borderColor: '#ff6b35', backgroundColor: 'rgba(255, 107, 53, 0.1)', borderWidth: 2, pointRadius: 0, fill: true, data: [], yAxisID: 'y' },
{ label: 'Target Setpoint (F)', borderColor: '#4ecdc4', borderWidth: 1.5, borderDash: [5, 5], pointRadius: 0, fill: false, data: [], yAxisID: 'y' },
{ label: 'Flight Trajectory (F)', borderColor: '#f39c12', borderWidth: 2, pointRadius: 0, fill: false, data: [], yAxisID: 'y' },
{ label: 'Output', borderColor: '#45b7d1', backgroundColor: 'rgba(69, 183, 209, 0.1)', borderWidth: 1, pointRadius: 0, fill: true, data: [], yAxisID: 'y1' },
]},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
interaction: { mode: 'index', intersect: false },
scales: {
x: { type: 'linear', ticks: { color: '#888', callback: function(value) { return Math.round(value) + 's'; }, maxTicksLimit: 10 }, grid: { color: 'rgba(255,255,255,0.05)' } },
y: { type: 'linear', position: 'left', title: { display: true, text: 'Temperature (F)', color: '#aaa' }, ticks: { color: '#ff6b35' }, grid: { color: 'rgba(255,255,255,0.05)' }, suggestedMin: 0, suggestedMax: 700 },
y1: { type: 'linear', position: 'right', title: { display: true, text: 'PID Output', color: '#aaa' }, ticks: { color: '#45b7d1' }, grid: { drawOnChartArea: false }, suggestedMin: 0 },
},
plugins: { legend: { labels: { color: '#ccc', boxWidth: 12 } } },
},
});
}
function resetChartForNail() {
firstTimestamp = null;
lastTimestamp = 0;
historyBuffer = [];
if (chart) {
chart.data.datasets.forEach(function(ds) { ds.data = []; });
chart.update('none');
}
updateErrorStats();
}
function addChartData(points) {
if (!chart || !points.length) return;
if (firstTimestamp === null) firstTimestamp = points[0].timestamp;
points.forEach(function(p) {
const x = p.timestamp - firstTimestamp;
const inRamp = p.mode === 'takeoff' || p.mode === 'descent';
const flightSp = inRamp && (typeof p.flight_setpoint === 'number') ? p.flight_setpoint : null;
chart.data.datasets[0].data.push({ x: x, y: p.temp });
chart.data.datasets[1].data.push({ x: x, y: p.setpoint });
chart.data.datasets[2].data.push({ x: x, y: flightSp });
chart.data.datasets[3].data.push({ x: x, y: p.output });
historyBuffer.push(p);
});
const newestTs = historyBuffer.length ? historyBuffer[historyBuffer.length - 1].timestamp : 0;
historyBuffer = historyBuffer.filter(function(p) { return p.timestamp >= (newestTs - 600); });
chart.data.datasets.forEach(function(ds) {
if (ds.data.length > chartMaxPoints) ds.data = ds.data.slice(ds.data.length - chartMaxPoints);
});
chart.update('none');
updateErrorStats();
}
async function fetchJSON(url, options) {
try {
const resp = await fetch(url, options);
const payload = await resp.json();
if (!resp.ok) throw new Error(payload.error || ('HTTP ' + resp.status));
lastApiError = '';
return payload;
} catch (e) {
console.error('API error:', url, e);
lastApiError = String(e);
if (String(e).indexOf('HTTP') < 0) setConnectionStatus(false);
return null;
}
}
async function apiPost(path, payload, nailId) {
const body = Object.assign({}, payload || {}, { nail: nailId || currentNailId() });
return fetchJSON(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
function renderPresets(presets) {
const container = document.getElementById('presets-container');
if (!container) return;
const buttons = Object.entries(presets || {}).map(function(entry) {
const name = entry[0];
const temp = entry[1];
return '';
}).join('');
container.innerHTML = buttons;
}
function renderAdvanced(status) {
if (!status) return;
const tempEl = document.getElementById('current-temp');
tempEl.textContent = status.temp.toFixed(1);
const delta = Math.abs(status.temp - status.setpoint);
if (!status.enabled) tempEl.className = 'temp-value';
else if (delta < 15) tempEl.className = 'temp-value temp-good';
else if (delta < 50) tempEl.className = 'temp-value temp-warming';
else tempEl.className = 'temp-value temp-cold';
const effective = (typeof status.effective_setpoint === 'number') ? status.effective_setpoint : status.setpoint;
document.getElementById('current-setpoint').textContent = effective.toFixed(0);
const mode = status.mode || 'grounded';
const modePill = document.getElementById('mode-pill');
modePill.className = 'mode-pill ' + mode;
modePill.textContent = 'Mode: ' + mode;
const modeEta = document.getElementById('mode-eta');
if ((mode === 'takeoff' || mode === 'descent') && typeof status.mode_eta_seconds === 'number') {
const sec = Math.max(0, Math.round(status.mode_eta_seconds));
const mm = Math.floor(sec / 60);
const ss = sec % 60;
modeEta.textContent = (mode === 'takeoff' ? 'Time to cruise: ' : 'Time to grounded: ') + mm + 'm ' + ss + 's';
} else {
modeEta.textContent = 'ETA: --';
}
const schedEta = document.getElementById('sched-eta');
const schedEnabled = !!(status.scheduler && status.scheduler.enabled);
if (schedEnabled && typeof status.next_cutoff_seconds === 'number') {
const sec = Math.max(0, Math.round(status.next_cutoff_seconds));
const hh = Math.floor(sec / 3600);
const mm = Math.floor((sec % 3600) / 60);
const ss = sec % 60;
schedEta.textContent = 'Time to descent: ' + hh + 'h ' + mm + 'm ' + ss + 's';
} else if (schedEnabled) schedEta.textContent = 'Time to descent: --';
else schedEta.textContent = 'Scheduler: off';
const powerBtn = document.getElementById('power-btn');
powerBtn.textContent = status.enabled ? 'ON' : 'OFF';
powerBtn.className = 'power-btn ' + (status.enabled ? 'on' : 'off');
const banner = document.getElementById('safety-banner');
if (status.safety_tripped) {
banner.classList.remove('hidden');
document.getElementById('safety-message').textContent = 'SAFETY TRIP: ' + status.safety_reason;
} else banner.classList.add('hidden');
document.getElementById('status-output').textContent = status.output.toFixed(1) + ' / ' + (status.config.loop_size_ms || '?');
const relayEl = document.getElementById('status-relay');
relayEl.textContent = status.relay_on ? 'ON' : 'OFF';
relayEl.className = 'value ' + (status.relay_on ? 'relay-on' : 'relay-off');
document.getElementById('status-uptime').textContent = status.uptime_seconds !== null ? (Math.floor(status.uptime_seconds / 60) + 'm ' + Math.floor(status.uptime_seconds % 60) + 's') : '--';
document.getElementById('status-loops').textContent = status.loop_count;
const tcEl = document.getElementById('status-tc');
tcEl.textContent = status.thermocouple_connected ? 'OK' : 'DISCONNECTED';
tcEl.className = 'value ' + (status.thermocouple_connected ? 'tc-ok' : 'tc-err');
if (document.activeElement.id !== 'setpoint-input') document.getElementById('setpoint-input').value = status.setpoint;
if (document.activeElement.id !== 'pid-kp') document.getElementById('pid-kp').value = status.pid.kP;
if (document.activeElement.id !== 'pid-ki') document.getElementById('pid-ki').value = status.pid.kI;
if (document.activeElement.id !== 'pid-kd') document.getElementById('pid-kd').value = status.pid.kD;
if (document.activeElement.id !== 'pid-pmode') document.getElementById('pid-pmode').value = status.pid.proportional_mode || (status.pid.proportional_on_measurement ? 'measurement' : 'error');
if (document.activeElement.id !== 'control-loop-size') document.getElementById('control-loop-size').value = status.config.loop_size_ms;
if (document.activeElement.id !== 'control-sleep-time') document.getElementById('control-sleep-time').value = status.config.sleep_time;
const flight = status.flight || {};
if (document.activeElement.id !== 'flight-takeoff-seconds') document.getElementById('flight-takeoff-seconds').value = flight.takeoff_seconds || 90;
if (document.activeElement.id !== 'flight-descent-seconds') document.getElementById('flight-descent-seconds').value = flight.descent_seconds || 90;
if (document.activeElement.id !== 'flight-descent-target') document.getElementById('flight-descent-target').value = flight.descent_target_f || 120;
const fm = document.getElementById('flight-mode-status');
if (fm) fm.textContent = 'Current mode: ' + (status.mode || 'grounded') + ' (Power button handles Grounded/Cruise)';
if (document.activeElement.id !== 'sched-enabled') document.getElementById('sched-enabled').value = status.scheduler.enabled ? 'true' : 'false';
const incoming = (status.scheduler.cutoff_times || []).slice().sort();
if (JSON.stringify(incoming) !== JSON.stringify(schedulerTimesByNail[currentNailId()] || [])) {
schedulerTimesByNail[currentNailId()] = incoming;
renderSchedulerTimes();
}
setAutotuneUi(status.autotune || {});
}
async function pollStatus() {
const payload = await fetchJSON('/api/status/all');
if (!payload || !payload.nails) return;
setConnectionStatus(true);
states.nail1 = payload.nails.nail1 || states.nail1;
states.nail2 = payload.nails.nail2 || states.nail2;
if (payload.presets) renderPresets(payload.presets);
updateSimpleCards();
renderAdvanced(states[currentNailId()]);
}
async function pollHistory() {
const nailNum = nailNumFromId(currentNailId());
const data = await fetchJSON('/api/history?since=' + lastTimestamp + '&nail=' + nailNum);
if (!data || !data.length) return;
lastTimestamp = data[data.length - 1].timestamp;
addChartData(data);
}
async function pollHeartbeat() {
const hb = await fetchJSON('/api/heartbeat?ts=' + Date.now());
if (!hb || !hb.ok) {
heartbeatMisses += 1;
if (heartbeatMisses >= 2) {
setBackendStatus('reconnecting', 'Backend: Reconnecting...');
setControlsEnabled(false);
}
return;
}
if (heartbeatMisses >= 2) showAction('Backend reconnected.', 'success', 2500);
heartbeatMisses = 0;
setConnectionStatus(true);
setControlsEnabled(true);
if (heartbeatInstanceId === null) heartbeatInstanceId = hb.instance_id;
else if (heartbeatInstanceId !== hb.instance_id) {
showAction('Backend restarted. Reloading UI...', 'info', 1500);
setTimeout(function() { window.location.reload(); }, 1200);
}
}
async function togglePower() {
const id = currentNailId();
const s = states[id];
const target = !(s && s.enabled);
const result = await apiPost('/api/power', { enabled: target }, id);
if (!result) return setLastAck('power failed', false);
setLastAck('power ' + (result.enabled ? 'ON' : 'OFF') + ' (' + id + ')', true);
}
async function simpleTogglePower(num) {
const id = nailIdFromNum(num);
const s = states[id];
const target = !(s && s.enabled);
const result = await apiPost('/api/power', { enabled: target }, id);
if (!result) return setLastAck('power failed', false);
setLastAck('power ' + (result.enabled ? 'ON' : 'OFF') + ' (' + id + ')', true);
}
async function applySetpoint() {
const value = parseFloat(document.getElementById('setpoint-input').value);
if (isNaN(value)) return;
const result = await apiPost('/api/setpoint', { setpoint: value }, currentNailId());
if (!result) return setLastAck('setpoint failed', false);
setLastAck('setpoint ' + value + 'F (' + currentNailId() + ')', true);
}
async function simpleApplySetpoint(num) {
const id = nailIdFromNum(num);
const input = document.getElementById('simple-setpoint-' + id);
const value = parseFloat(input.value);
if (isNaN(value)) return;
const result = await apiPost('/api/setpoint', { setpoint: value }, id);
if (!result) return setLastAck('setpoint failed', false);
setLastAck('setpoint ' + value + 'F (' + id + ')', true);
}
function adjustSetpoint(delta) {
const input = document.getElementById('setpoint-input');
input.value = parseFloat(input.value) + delta;
applySetpoint();
}
async function applyPID() {
const kp = parseFloat(document.getElementById('pid-kp').value);
const ki = parseFloat(document.getElementById('pid-ki').value);
const kd = parseFloat(document.getElementById('pid-kd').value);
const pMode = document.getElementById('pid-pmode').value;
if (isNaN(kp) || isNaN(ki) || isNaN(kd)) return;
const result = await apiPost('/api/pid', { kP: kp, kI: ki, kD: kd, proportional_mode: pMode }, currentNailId());
if (!result) return setLastAck('PID apply failed', false);
setLastAck('PID applied (' + currentNailId() + ')', true);
}
async function applyControlTiming() {
const loopSize = parseInt(document.getElementById('control-loop-size').value, 10);
const sleepTime = parseFloat(document.getElementById('control-sleep-time').value);
if (isNaN(loopSize) || isNaN(sleepTime)) return;
let payload = { loop_size_ms: loopSize, sleep_time: sleepTime };
let result = await apiPost('/api/control', payload, currentNailId());
if (!result && (lastApiError || '').indexOf('confirm_extreme') >= 0) {
if (window.confirm('This timing is EXTREME and can overheat quickly. Continue anyway?')) {
payload.confirm_extreme = true;
result = await apiPost('/api/control', payload, currentNailId());
}
}
if (!result) {
setLastAck('timing apply failed', false);
showAction(lastApiError || 'Timing update failed', 'error', 4000);
return;
}
setLastAck('timing updated (' + currentNailId() + ')', true);
}
function applyTimingProfile(profile) {
if (profile === 'conservative') {
document.getElementById('control-loop-size').value = 3000;
document.getElementById('control-sleep-time').value = 0.4;
} else if (profile === 'balanced') {
document.getElementById('control-loop-size').value = 2500;
document.getElementById('control-sleep-time').value = 0.25;
} else if (profile === 'responsive') {
document.getElementById('control-loop-size').value = 1800;
document.getElementById('control-sleep-time').value = 0.2;
}
applyControlTiming();
}
async function setFlightMode(mode) {
const takeoff = parseFloat(document.getElementById('flight-takeoff-seconds').value);
const descent = parseFloat(document.getElementById('flight-descent-seconds').value);
const descentTarget = parseFloat(document.getElementById('flight-descent-target').value);
const result = await apiPost('/api/flight', {
mode: mode,
takeoff_seconds: takeoff,
descent_seconds: descent,
descent_target_f: descentTarget,
}, currentNailId());
if (!result) {
setLastAck('mode ' + mode + ' failed', false);
return;
}
setLastAck('mode ' + mode + ' (' + currentNailId() + ')', true);
}
async function saveSchedulerSettings() {
const id = currentNailId();
const enabled = document.getElementById('sched-enabled').value === 'true';
const times = (schedulerTimesByNail[id] || []).slice();
const result = await apiPost('/api/scheduler', { enabled: enabled, cutoff_times: times }, id);
if (!result) {
setLastAck('scheduler update failed', false);
return;
}
schedulerTimesByNail[id] = (result.scheduler && result.scheduler.cutoff_times) ? result.scheduler.cutoff_times.slice() : times;
renderSchedulerTimes();
setLastAck('scheduler updated (' + id + ')', true);
}
async function resetPID() {
const result = await apiPost('/api/pid/reset', {}, currentNailId());
if (!result) return setLastAck('PID reset failed', false);
setLastAck('PID reset (' + currentNailId() + ')', true);
}
async function startAutotune() {
const target = parseFloat(document.getElementById('setpoint-input').value);
const result = await apiPost('/api/autotune/start', { setpoint: target }, currentNailId());
if (!result) return setLastAck('autotune start failed', false);
setLastAck('autotune started (' + currentNailId() + ')', true);
}
async function stopAutotune() {
const result = await apiPost('/api/autotune/stop', {}, currentNailId());
if (!result) return setLastAck('autotune stop failed', false);
setLastAck('autotune stopped (' + currentNailId() + ')', true);
}
async function applyPreset(name) {
const result = await apiPost('/api/preset/' + encodeURIComponent(name), {}, currentNailId());
if (!result) return setLastAck('preset failed', false);
setLastAck('preset ' + name + ' (' + currentNailId() + ')', true);
}
async function resetSafety() {
const result = await apiPost('/api/safety/reset', {}, currentNailId());
if (!result) return setLastAck('safety reset failed', false);
setLastAck('safety reset (' + currentNailId() + ')', true);
}
function isStandalone() {
return window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true;
}
function showInstallButton(show) {
const btn = document.getElementById('install-btn');
if (!btn) return;
btn.hidden = !show;
}
async function installApp() {
if (!deferredInstallPrompt) {
showAction('Install prompt unavailable. Use your browser menu to install.', 'info', 5000);
return;
}
deferredInstallPrompt.prompt();
deferredInstallPrompt = null;
showInstallButton(false);
}
document.addEventListener('DOMContentLoaded', function() {
const setpointInput = document.getElementById('setpoint-input');
if (setpointInput) {
setpointInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') applySetpoint();
});
}
let savedMode = null;
try {
savedMode = localStorage.getItem('pinail_ui_mode');
} catch (e) {
savedMode = null;
}
if (savedMode === 'advanced') savedMode = 'nail1';
setUiMode(savedMode || 'nail1', false);
const schedInput = document.getElementById('sched-time-input');
if (schedInput) {
schedInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
addSchedulerTime();
}
});
schedInput.addEventListener('blur', function() {
const n = normalizeTimeString(schedInput.value);
if (n) schedInput.value = n;
});
}
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/static/sw.js').catch(function(err) {
console.error('Service worker register failed:', err);
});
}
if (isStandalone()) showInstallButton(false);
});
window.addEventListener('beforeinstallprompt', function(e) {
e.preventDefault();
deferredInstallPrompt = e;
if (!isStandalone()) showInstallButton(true);
});
window.addEventListener('appinstalled', function() {
deferredInstallPrompt = null;
showInstallButton(false);
showAction('piNail2 installed.', 'success', 4000);
});
initChart();
setInterval(pollStatus, pollInterval);
setInterval(pollHistory, pollInterval);
setInterval(pollHeartbeat, 2000);
setInterval(updateWallClock, 1000);
pollStatus();
pollHistory();
pollHeartbeat();
updateWallClock();