let pollInterval = 500; let chartMaxPoints = 300; let lastTimestampByNail = { nail1: 0, nail2: 0 }; let chart = null; let firstTimestamp = null; let simpleChart = null; let simpleFirstTimestamp = 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 simpleHistoryByNail = { nail1: [], nail2: [] }; 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 formatSeconds(sec) { if (typeof sec !== 'number' || !isFinite(sec)) return '--'; const s = Math.max(0, Math.round(sec)); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const r = s % 60; if (h > 0) return h + 'h ' + m + 'm ' + r + 's'; return m + 'm ' + r + 's'; } function formatUptime(sec) { if (typeof sec !== 'number' || !isFinite(sec)) return '--'; const s = Math.max(0, Math.round(sec)); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const r = s % 60; if (h > 0) return h + 'h ' + m + 'm'; return m + 'm ' + r + 's'; } 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); const relayEl = document.getElementById('simple-relay-' + suffix); const outEl = document.getElementById('simple-output-' + suffix); const tcEl = document.getElementById('simple-tc-' + suffix); const upEl = document.getElementById('simple-uptime-' + suffix); const etaEl = document.getElementById('simple-eta-' + suffix); const cutoffEl = document.getElementById('simple-cutoff-' + suffix); const alertEl = document.getElementById('simple-alert-' + 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 (relayEl) relayEl.textContent = s.relay_on ? 'ON' : 'OFF'; if (outEl) outEl.textContent = (typeof s.output === 'number' ? s.output.toFixed(0) : '--'); if (tcEl) tcEl.textContent = s.thermocouple_connected ? 'OK' : 'DISC'; if (upEl) upEl.textContent = formatUptime(s.uptime_seconds); if (etaEl) etaEl.textContent = formatSeconds(s.mode_eta_seconds); if (cutoffEl) cutoffEl.textContent = s.scheduler && s.scheduler.enabled ? formatSeconds(s.next_cutoff_seconds) : 'Scheduler off'; if (alertEl) { if (s.safety_tripped) { alertEl.className = 'simple-alert bad'; alertEl.textContent = 'Safety trip: ' + (s.safety_reason || 'Unknown'); } else if (!s.thermocouple_connected) { alertEl.className = 'simple-alert warn'; alertEl.textContent = 'Thermocouple disconnected.'; } else { alertEl.className = 'simple-alert'; alertEl.textContent = 'No active safety alerts.'; } } 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 initSimpleChart() { const canvas = document.getElementById('simple-temp-chart'); if (!canvas) return; const ctx = canvas.getContext('2d'); simpleChart = new Chart(ctx, { type: 'line', data: { datasets: [ { label: 'Nail 1 Temp', borderColor: '#ff6b35', borderWidth: 2, pointRadius: 0, fill: false, data: [] }, { label: 'Nail 2 Temp', borderColor: '#4ecdc4', borderWidth: 2, pointRadius: 0, fill: false, data: [] }, { label: 'Nail 1 Target', borderColor: '#ff9f6b', borderWidth: 1, borderDash: [5, 4], pointRadius: 0, fill: false, data: [] }, { label: 'Nail 2 Target', borderColor: '#7df7ef', borderWidth: 1, borderDash: [5, 4], pointRadius: 0, fill: false, data: [] }, ]}, 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', ticks: { color: '#ccc' }, grid: { color: 'rgba(255,255,255,0.05)' }, suggestedMin: 0, suggestedMax: 700, title: { display: true, text: 'Temperature (F)', color: '#aaa' }, }, }, plugins: { legend: { labels: { color: '#ccc', boxWidth: 12 } } }, }, }); } function resetChartForNail() { firstTimestamp = null; lastTimestampByNail[currentNailId()] = 0; historyBuffer = []; if (chart) { chart.data.datasets.forEach(function(ds) { ds.data = []; }); chart.update('none'); } updateErrorStats(); } function addSimpleChartData(nailId, points) { if (!simpleChart || !points || !points.length) return; if (simpleFirstTimestamp === null) simpleFirstTimestamp = points[0].timestamp; const tempDatasetIndex = (nailId === 'nail2') ? 1 : 0; const targetDatasetIndex = (nailId === 'nail2') ? 3 : 2; points.forEach(function(p) { const x = p.timestamp - simpleFirstTimestamp; simpleChart.data.datasets[tempDatasetIndex].data.push({ x: x, y: p.temp }); simpleChart.data.datasets[targetDatasetIndex].data.push({ x: x, y: p.setpoint }); simpleHistoryByNail[nailId].push(p); }); const newest = simpleHistoryByNail[nailId].length ? simpleHistoryByNail[nailId][simpleHistoryByNail[nailId].length - 1].timestamp : 0; simpleHistoryByNail[nailId] = simpleHistoryByNail[nailId].filter(function(p) { return p.timestamp >= (newest - 600); }); simpleChart.data.datasets.forEach(function(ds) { if (ds.data.length > chartMaxPoints) ds.data = ds.data.slice(ds.data.length - chartMaxPoints); }); simpleChart.update('none'); } 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 = ''; 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 pollHistoryForNail(nailId) { const since = lastTimestampByNail[nailId] || 0; const nailNum = nailNumFromId(nailId); const data = await fetchJSON('/api/history?since=' + since + '&nail=' + nailNum); if (!data || !data.length) return; lastTimestampByNail[nailId] = data[data.length - 1].timestamp; addSimpleChartData(nailId, data); if (nailId === currentNailId()) { addChartData(data); } } async function pollHistory() { await Promise.all([ pollHistoryForNail('nail1'), pollHistoryForNail('nail2'), ]); } 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 simpleSetFlightMode(num, mode) { const id = nailIdFromNum(num); const result = await apiPost('/api/flight', { mode: mode }, id); if (!result) { setLastAck('mode ' + mode + ' failed', false); return; } setLastAck('mode ' + mode + ' (' + 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(); initSimpleChart(); setInterval(pollStatus, pollInterval); setInterval(pollHistory, pollInterval); setInterval(pollHeartbeat, 2000); setInterval(updateWallClock, 1000); pollStatus(); pollHistory(); pollHeartbeat(); updateWallClock();