565 lines
19 KiB
JavaScript
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 + '°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();
|