707 lines
28 KiB
JavaScript
707 lines
28 KiB
JavaScript
let pollInterval = 500;
|
|
let chartMaxPoints = 300;
|
|
let lastTimestamp = 0;
|
|
let chart = null;
|
|
let firstTimestamp = 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 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 = '<span class="autotune-status idle">No descent times configured.</span>';
|
|
return;
|
|
}
|
|
container.innerHTML = times.map(function(t) {
|
|
return '<span class="sched-time-item">' + t + '<button class="sched-del" onclick="removeSchedulerTime(\'' + t + '\')">x</button></span>';
|
|
}).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 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);
|
|
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 (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 resetChartForNail() {
|
|
firstTimestamp = null;
|
|
lastTimestamp = 0;
|
|
historyBuffer = [];
|
|
if (chart) {
|
|
chart.data.datasets.forEach(function(ds) { ds.data = []; });
|
|
chart.update('none');
|
|
}
|
|
updateErrorStats();
|
|
}
|
|
|
|
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 '<button class="preset-btn" onclick="applyPreset(\'' + name.replace(/'/g, "\\'") + '\')">' + name + ' (' + temp + '°F)</button>';
|
|
}).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 = 'Current mode: ' + (status.mode || 'grounded') + ' (Power button handles Grounded/Cruise)';
|
|
|
|
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 pollHistory() {
|
|
const nailNum = nailNumFromId(currentNailId());
|
|
const data = await fetchJSON('/api/history?since=' + lastTimestamp + '&nail=' + nailNum);
|
|
if (!data || !data.length) return;
|
|
lastTimestamp = data[data.length - 1].timestamp;
|
|
addChartData(data);
|
|
}
|
|
|
|
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 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();
|
|
setInterval(pollStatus, pollInterval);
|
|
setInterval(pollHistory, pollInterval);
|
|
setInterval(pollHeartbeat, 2000);
|
|
setInterval(updateWallClock, 1000);
|
|
pollStatus();
|
|
pollHistory();
|
|
pollHeartbeat();
|
|
updateWallClock();
|