/** * piNail2 Frontend — Dashboard Controller * * Polls the REST API for status updates, renders a live Chart.js chart, * and provides controls for setpoint, PID tuning, and power toggle. */ // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- let pollInterval = 500; // ms between status polls let chartMaxPoints = 300; // max data points on chart let lastTimestamp = 0; // for incremental history fetches let isEnabled = false; let currentSetpoint = 530; let chart = null; let lastApiError = ''; let actionBannerTimer = null; let heartbeatMisses = 0; let heartbeatInstanceId = null; let controlsEnabled = true; function nowHms() { const d = new Date(); return d.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) { if (controlsEnabled === enabled) return; controlsEnabled = enabled; const btns = document.querySelectorAll('button'); btns.forEach(function(b) { if (b.id === 'autotune-stop-btn' && enabled) { // stop button is governed by autotune state in setAutotuneUi() return; } b.disabled = !enabled; }); } function setBackendStatus(mode, text) { const el = document.getElementById('backend-status'); if (!el) return; el.className = 'backend-status ' + mode; el.textContent = text; } 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'; } } else if (tune && tune.message) { const lower = String(tune.message).toLowerCase(); statusEl.className = 'autotune-status ' + (lower.indexOf('failed') >= 0 ? 'error' : 'idle'); statusEl.textContent = tune.message; if (pill) { const failed = lower.indexOf('failed') >= 0 || lower.indexOf('error') >= 0; pill.className = 'autotune-pill ' + (failed ? 'error' : 'idle'); pill.textContent = failed ? 'Autotune: Error' : 'Autotune: Idle'; } } else { statusEl.className = 'autotune-status idle'; statusEl.textContent = 'Idle'; if (pill) { pill.className = 'autotune-pill idle'; pill.textContent = 'Autotune: Idle'; } } } // --------------------------------------------------------------------------- // Chart Setup // --------------------------------------------------------------------------- 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: 'Setpoint (F)', borderColor: '#4ecdc4', borderWidth: 1.5, borderDash: [5, 5], 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', display: true, title: { display: false }, ticks: { color: '#888', callback: function(value) { // Show relative seconds return Math.round(value) + 's'; }, maxTicksLimit: 10 }, grid: { color: 'rgba(255,255,255,0.05)' } }, y: { type: 'linear', display: true, 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', display: true, 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 } } } } }); } // --------------------------------------------------------------------------- // Data Update // --------------------------------------------------------------------------- let firstTimestamp = null; function addChartData(points) { if (!chart || !points.length) return; if (firstTimestamp === null) { firstTimestamp = points[0].timestamp; } for (const p of points) { const x = p.timestamp - firstTimestamp; // relative seconds 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: p.output }); } // Trim to max points for (const ds of chart.data.datasets) { if (ds.data.length > chartMaxPoints) { ds.data = ds.data.slice(ds.data.length - chartMaxPoints); } } chart.update('none'); // skip animation for performance } // --------------------------------------------------------------------------- // API Calls // --------------------------------------------------------------------------- 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) { // API-level error, connection is still fine. } else { setConnectionStatus(false); } return null; } } async function pollStatus() { const status = await fetchJSON('/api/status'); if (!status) return; setConnectionStatus(true); // Temperature display const tempEl = document.getElementById('current-temp'); tempEl.textContent = status.temp.toFixed(1); // Color the temp based on proximity to setpoint 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'; } // Setpoint display document.getElementById('current-setpoint').textContent = status.setpoint.toFixed(0); currentSetpoint = status.setpoint; // Power button isEnabled = status.enabled; const powerBtn = document.getElementById('power-btn'); if (isEnabled) { powerBtn.textContent = 'ON'; powerBtn.className = 'power-btn on'; } else { powerBtn.textContent = 'OFF'; powerBtn.className = 'power-btn off'; } // Safety banner 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'); } // Status bar 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'); if (status.uptime_seconds !== null) { const mins = Math.floor(status.uptime_seconds / 60); const secs = Math.floor(status.uptime_seconds % 60); document.getElementById('status-uptime').textContent = mins + 'm ' + secs + 's'; } else { document.getElementById('status-uptime').textContent = '--'; } 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'); // PID fields (only update if user isn't focused on them) 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') { const mode = status.pid.proportional_mode || (status.pid.proportional_on_measurement ? 'measurement' : 'error'); document.getElementById('pid-pmode').value = mode; } const tune = status.autotune || {}; setAutotuneUi(tune); // Setpoint input (only update if user isn't focused) if (document.activeElement.id !== 'setpoint-input') document.getElementById('setpoint-input').value = status.setpoint; // Presets if (status.presets) { renderPresets(status.presets); } } async function pollHistory() { const data = await fetchJSON('/api/history?since=' + lastTimestamp); if (!data || !data.length) return; lastTimestamp = data[data.length - 1].timestamp; addChartData(data); } function setConnectionStatus(connected) { const dot = document.getElementById('connection-status'); 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'); } } 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); } } // --------------------------------------------------------------------------- // User Actions // --------------------------------------------------------------------------- async function togglePower() { const result = await fetchJSON('/api/power', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: !isEnabled }) }); if (!result) { setLastAck('power failed', false); return; } setLastAck('power ' + (result.enabled ? 'ON' : 'OFF'), true); // Reset chart on power toggle if (!isEnabled) { firstTimestamp = null; lastTimestamp = 0; if (chart) { for (const ds of chart.data.datasets) ds.data = []; chart.update('none'); } } } async function applySetpoint() { const value = parseFloat(document.getElementById('setpoint-input').value); if (isNaN(value)) return; const result = await fetchJSON('/api/setpoint', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ setpoint: value }) }); if (!result) { setLastAck('setpoint failed', false); return; } setLastAck('setpoint ' + value + 'F', true); } function adjustSetpoint(delta) { const input = document.getElementById('setpoint-input'); const newVal = parseFloat(input.value) + delta; input.value = newVal; 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 fetchJSON('/api/pid', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kP: kp, kI: ki, kD: kd, proportional_mode: pMode }) }); if (!result) { setLastAck('PID apply failed', false); return; } setLastAck('PID applied (' + pMode + ')', true); } async function resetPID() { const result = await fetchJSON('/api/pid/reset', { method: 'POST' }); if (!result) { setLastAck('PID reset failed', false); return; } setLastAck('PID reset', true); } async function startAutotune() { const target = parseFloat(document.getElementById('setpoint-input').value); showAction('Starting autotune at ' + target + 'F (auto-enables heater if needed)...', 'info', 5000); setAutotuneUi({ message: 'Starting autotune...', last_result: null, active: true, high_peaks: 0, cycles: 0 }); const result = await fetchJSON('/api/autotune/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ setpoint: target }) }); if (!result) { setAutotuneUi({ message: lastApiError || 'Failed to start autotune', active: false, last_result: null }); showAction(lastApiError || 'Failed to start autotune', 'error', 6000); setLastAck('autotune start failed', false); return; } showAction('Autotune started. Watch peaks progress.', 'success', 5000); setAutotuneUi(result.autotune || { message: 'Autotune started', active: true }); setLastAck('autotune started', true); } async function stopAutotune() { setAutotuneUi({ message: 'Stopping autotune...', last_result: null, active: false }); showAction('Stopping autotune...', 'info', 3000); const result = await fetchJSON('/api/autotune/stop', { method: 'POST' }); if (!result) { setAutotuneUi({ message: lastApiError || 'Failed to stop autotune', active: false, last_result: null }); showAction(lastApiError || 'Failed to stop autotune', 'error', 6000); setLastAck('autotune stop failed', false); return; } showAction('Autotune stopped.', 'success', 4000); setAutotuneUi(result.autotune || { message: 'Autotune stopped', active: false, last_result: null }); setLastAck('autotune stopped', true); } async function applyPreset(name) { const result = await fetchJSON('/api/preset/' + encodeURIComponent(name), { method: 'POST' }); if (!result) { setLastAck('preset failed', false); return; } setLastAck('preset ' + name, true); } async function resetSafety() { const result = await fetchJSON('/api/safety/reset', { method: 'POST' }); if (!result) { setLastAck('safety reset failed', false); return; } setLastAck('safety reset', true); } function renderPresets(presets) { const container = document.getElementById('presets-container'); const buttons = Object.entries(presets).map(([name, temp]) => '' ).join(''); container.innerHTML = buttons; } // Handle Enter key in setpoint input document.addEventListener('DOMContentLoaded', function() { document.getElementById('setpoint-input').addEventListener('keydown', function(e) { if (e.key === 'Enter') applySetpoint(); }); setAutotuneUi({ active: false, message: 'Idle', last_result: null }); }); // --------------------------------------------------------------------------- // Init // --------------------------------------------------------------------------- initChart(); // Start polling loops setInterval(pollStatus, pollInterval); setInterval(pollHistory, pollInterval); setInterval(pollHeartbeat, 2000); // Initial fetch pollStatus(); pollHistory(); pollHeartbeat();