diff --git a/CONTEXT.md b/CONTEXT.md index 44734ae..b797ceb 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -44,9 +44,9 @@ - If current temp starts above setpoint, autotune may begin in cooling phase. ## Safety behaviors -- Hard max temp cutoff. +- Hard max temp cutoff (800F). - Thermocouple disconnect handling. -- Idle shutoff timer. +- Idle shutoff timer (default 30 min). - Watchdog status exposed in heartbeat. ## Operations quick commands diff --git a/piNail2/config.json b/piNail2/config.json index fd10259..285c4fe 100644 --- a/piNail2/config.json +++ b/piNail2/config.json @@ -7,8 +7,8 @@ }, "control": { "setpoint": 530, - "loop_size_ms": 3000, - "sleep_time": 0.4, + "loop_size_ms": 1800, + "sleep_time": 0.2, "enabled": false }, "flight": { @@ -26,7 +26,7 @@ ] }, "safety": { - "max_temp_f": 750, + "max_temp_f": 800, "spike_threshold_f": 50.0, "idle_shutoff_minutes": 30, "watchdog_timeout_s": 10, @@ -70,8 +70,8 @@ }, "control": { "setpoint": 530, - "loop_size_ms": 3000, - "sleep_time": 0.4, + "loop_size_ms": 1800, + "sleep_time": 0.2, "enabled": false }, "flight": { @@ -89,7 +89,7 @@ ] }, "safety": { - "max_temp_f": 750, + "max_temp_f": 800, "spike_threshold_f": 50.0, "idle_shutoff_minutes": 30, "watchdog_timeout_s": 10, @@ -123,8 +123,8 @@ }, "control": { "setpoint": 530, - "loop_size_ms": 3000, - "sleep_time": 0.4, + "loop_size_ms": 1800, + "sleep_time": 0.2, "enabled": false }, "flight": { @@ -142,7 +142,7 @@ ] }, "safety": { - "max_temp_f": 750, + "max_temp_f": 800, "spike_threshold_f": 50.0, "idle_shutoff_minutes": 30, "watchdog_timeout_s": 10, diff --git a/piNail2/config.py b/piNail2/config.py index 55b3a85..7a7b2ad 100644 --- a/piNail2/config.py +++ b/piNail2/config.py @@ -21,8 +21,8 @@ DEFAULT_CONFIG = { }, "control": { "setpoint": 530, - "loop_size_ms": 3000, - "sleep_time": 0.4, + "loop_size_ms": 1800, + "sleep_time": 0.2, "enabled": False }, "flight": { @@ -40,7 +40,7 @@ DEFAULT_CONFIG = { ] }, "safety": { - "max_temp_f": 750, + "max_temp_f": 800, "spike_threshold_f": 50.0, "idle_shutoff_minutes": 30, "watchdog_timeout_s": 10, @@ -84,8 +84,8 @@ DEFAULT_CONFIG = { }, "control": { "setpoint": 530, - "loop_size_ms": 3000, - "sleep_time": 0.4, + "loop_size_ms": 1800, + "sleep_time": 0.2, "enabled": False }, "flight": { @@ -103,7 +103,7 @@ DEFAULT_CONFIG = { ] }, "safety": { - "max_temp_f": 750, + "max_temp_f": 800, "spike_threshold_f": 50.0, "idle_shutoff_minutes": 30, "watchdog_timeout_s": 10, @@ -137,8 +137,8 @@ DEFAULT_CONFIG = { }, "control": { "setpoint": 530, - "loop_size_ms": 3000, - "sleep_time": 0.4, + "loop_size_ms": 1800, + "sleep_time": 0.2, "enabled": False }, "flight": { @@ -156,7 +156,7 @@ DEFAULT_CONFIG = { ] }, "safety": { - "max_temp_f": 750, + "max_temp_f": 800, "spike_threshold_f": 50.0, "idle_shutoff_minutes": 30, "watchdog_timeout_s": 10, diff --git a/piNail2/static/app.js b/piNail2/static/app.js index b9987a1..b81ec8a 100644 --- a/piNail2/static/app.js +++ b/piNail2/static/app.js @@ -1,8 +1,10 @@ let pollInterval = 500; let chartMaxPoints = 300; -let lastTimestamp = 0; +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; @@ -12,6 +14,7 @@ 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 }; @@ -166,6 +169,26 @@ 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]; @@ -176,10 +199,35 @@ function updateSimpleCards() { 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'); @@ -282,9 +330,50 @@ function initChart() { }); } +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; - lastTimestamp = 0; + lastTimestampByNail[currentNailId()] = 0; historyBuffer = []; if (chart) { chart.data.datasets.forEach(function(ds) { ds.data = []; }); @@ -293,6 +382,28 @@ function resetChartForNail() { 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; @@ -422,7 +533,7 @@ function renderAdvanced(status) { 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 (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(); @@ -445,12 +556,23 @@ async function pollStatus() { renderAdvanced(states[currentNailId()]); } -async function pollHistory() { - const nailNum = nailNumFromId(currentNailId()); - const data = await fetchJSON('/api/history?since=' + lastTimestamp + '&nail=' + nailNum); +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; - lastTimestamp = data[data.length - 1].timestamp; - addChartData(data); + 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() { @@ -492,6 +614,16 @@ async function simpleTogglePower(num) { 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; @@ -696,6 +828,7 @@ window.addEventListener('appinstalled', function() { }); initChart(); +initSimpleChart(); setInterval(pollStatus, pollInterval); setInterval(pollHistory, pollInterval); setInterval(pollHeartbeat, 2000); diff --git a/piNail2/static/style.css b/piNail2/static/style.css index 8de1610..669bb23 100644 --- a/piNail2/static/style.css +++ b/piNail2/static/style.css @@ -212,6 +212,76 @@ body.ui-simple .app-footer { margin-top: 8px; } +.simple-flight-controls { + display: flex; + gap: 8px; + margin-top: 8px; +} + +.simple-stats { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; + margin: 10px 0; +} + +.simple-stat { + font-size: 0.74rem; + border: 1px solid var(--border); + border-radius: 6px; + padding: 5px 7px; + color: var(--text-dim); + background: rgba(0, 0, 0, 0.25); +} + +.simple-stat strong { + color: var(--text); + font-variant-numeric: tabular-nums; +} + +.simple-alert { + margin-top: 6px; + margin-bottom: 6px; + font-size: 0.73rem; + color: var(--text-dim); + border-left: 2px solid var(--border); + padding-left: 8px; + min-height: 18px; +} + +.simple-alert.warn { + color: #f5b041; + border-left-color: #f5b041; +} + +.simple-alert.bad { + color: var(--accent-red); + border-left-color: var(--accent-red); +} + +.simple-chart-wrap { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px; + margin-bottom: 12px; + height: 280px; + box-shadow: inset 0 0 0 1px rgba(211, 84, 0, 0.12); +} + +.simple-chart-wrap h3 { + font-size: 0.82rem; + text-transform: uppercase; + color: var(--text-dim); + margin-bottom: 8px; + letter-spacing: 0.05em; +} + +.simple-chart-wrap canvas { + width: 100% !important; + height: calc(100% - 26px) !important; +} + .power-mini { border-radius: 999px; border: 1px solid var(--border); @@ -681,6 +751,13 @@ button:disabled { width: 70px; } +#control-loop-size, +#flight-takeoff-seconds, +#flight-descent-seconds, +#flight-descent-target { + width: 112px; +} + /* Status Bar */ .status-bar { display: flex; @@ -783,6 +860,10 @@ button:disabled { grid-template-columns: 1fr; } + .simple-chart-wrap { + height: 220px; + } + .chart-section { height: 220px; } diff --git a/piNail2/templates/index.html b/piNail2/templates/index.html index 9a21eb6..044a0c6 100644 --- a/piNail2/templates/index.html +++ b/piNail2/templates/index.html @@ -41,25 +41,56 @@