Files
piNail/piNail2/static/app.js
T

565 lines
19 KiB
JavaScript

/**
* 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]) =>
'<button class="preset-btn" onclick="applyPreset(\'' +
name.replace(/'/g, "\\'") + '\')">' + name + ' (' + temp + '&deg;F)</button>'
).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();