Files

455 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const DEFAULT_PROBE_NAMES = ['Probe 1', 'Probe 2', 'Probe 3', 'Probe 4'];
const PRESETS = [
{ label: 'Brisket', temp: 203 },
{ label: 'Pork Butt', temp: 205 },
{ label: 'Poultry (breast)', temp: 165 },
{ label: 'Poultry (thigh)', temp: 175 },
{ label: 'Ribs', temp: 203 },
];
let activeSession = null;
let viewedSession = null;
let allSessions = [];
let latestReading = null;
let setpoints = [];
let chartInstance = null;
let cardsInitialized = false;
let probeNames = [...DEFAULT_PROBE_NAMES];
async function api(path, options = {}) {
const resp = await fetch(path, options);
if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`);
return resp.json();
}
function formatTime(iso) {
if (!iso) return '--';
const d = new Date(iso + (iso.endsWith('Z') ? '' : 'Z'));
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function statusClass(connected) {
return connected ? 'ok' : 'danger';
}
function createProbeCards() {
const container = document.getElementById('probe-cards');
container.innerHTML = '';
for (let i = 0; i < 4; i++) {
const card = document.createElement('div');
card.className = 'probe-card';
card.dataset.probe = i + 1;
card.innerHTML = `
<div class="header">
<span class="name" data-probe="${i + 1}" title="Click to rename">${DEFAULT_PROBE_NAMES[i]}</span>
<span class="number">#${i + 1}</span>
</div>
<div class="target">No target set</div>
<div class="temp">---</div>
<div class="footer">
<span class="status">Disconnected</span>
</div>
<div class="setpoint-editor">
<select class="preset-select" data-probe="${i + 1}">
<option value="">Preset...</option>
${PRESETS.map(p => `<option value="${p.temp}">${p.label}</option>`).join('')}
<option value="custom">Custom...</option>
</select>
<input type="number" class="custom-target" data-probe="${i + 1}" placeholder="Target">
<input type="text" class="target-label" data-probe="${i + 1}" placeholder="Label">
<button class="save-setpoint" data-probe="${i + 1}">Save</button>
</div>
`;
container.appendChild(card);
}
// Wire up preset selectors and save buttons once.
document.querySelectorAll('.preset-select').forEach(sel => {
sel.addEventListener('change', () => {
const probe = sel.dataset.probe;
const customInput = document.querySelector(`.custom-target[data-probe="${probe}"]`);
if (sel.value && sel.value !== 'custom') {
customInput.value = sel.value;
}
});
});
document.querySelectorAll('.save-setpoint').forEach(btn => {
btn.addEventListener('click', async () => {
const probe = parseInt(btn.dataset.probe, 10);
const target = parseFloat(document.querySelector(`.custom-target[data-probe="${probe}"]`).value);
const label = document.querySelector(`.target-label[data-probe="${probe}"]`).value;
if (isNaN(target)) return;
await saveSetpoint(probe, label, target);
});
});
// Click the probe name to rename it (writes through to the CYD).
document.querySelectorAll('.probe-card .name').forEach(el => {
el.addEventListener('click', () => beginRename(el));
});
cardsInitialized = true;
}
async function beginRename(el) {
if (el.querySelector('input')) return; // already editing
const probe = parseInt(el.dataset.probe, 10);
const current = el.textContent;
el.innerHTML = '';
const input = document.createElement('input');
input.type = 'text';
input.className = 'rename-input';
input.value = current;
input.maxLength = 24;
el.appendChild(input);
input.focus();
input.select();
let done = false;
const finish = async (commit) => {
if (done) return;
done = true;
const newName = (commit ? input.value.trim() : current) || current;
el.textContent = newName;
if (commit && newName !== current) {
await saveProbeName(probe, newName);
}
};
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
if (e.key === 'Escape') { e.preventDefault(); input.value = current; input.blur(); }
});
input.addEventListener('blur', () => finish(true));
}
async function saveProbeName(probeNumber, name) {
const next = [...probeNames];
next[probeNumber - 1] = name;
try {
const result = await api('/api/probes', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ names: next }),
});
if (result.names) probeNames = result.names;
await refreshDashboard(); // pick up the new name in the latest reading
} catch (e) {
console.error('Rename failed:', e);
alert(`Failed to rename probe: ${e.message}`);
}
}
async function loadProbeNames() {
try {
const result = await api('/api/probes');
if (result.names) probeNames = result.names;
} catch (e) {
console.warn('Probe names fetch failed, using defaults:', e);
}
}
function updateProbeCards(reading, setpoints) {
const probes = reading?.probes || [];
const unit = reading?.unit || 'F';
for (let i = 0; i < 4; i++) {
const card = document.querySelector(`.probe-card[data-probe="${i + 1}"]`);
if (!card) continue;
const probe = probes[i] || { id: i + 1, name: probeNames[i] || DEFAULT_PROBE_NAMES[i], temperature: null, connected: false };
const setpoint = setpoints.find(s => s.probe_number === (i + 1));
card.classList.toggle('connected', probe.connected);
// Don't overwrite the name while the user is editing it.
const nameEl = card.querySelector('.name');
if (!nameEl.querySelector('input')) {
nameEl.textContent = probe.name || probeNames[i] || DEFAULT_PROBE_NAMES[i];
}
const targetEl = card.querySelector('.target');
if (setpoint) {
const labelText = setpoint.label ? setpoint.label : `Probe ${i + 1}`;
targetEl.textContent = `${labelText}: ${setpoint.target_temp.toFixed(0)}°${unit}`;
} else {
targetEl.textContent = 'No target set';
}
const tempEl = card.querySelector('.temp');
if (probe.connected) {
tempEl.classList.remove('disconnected');
tempEl.innerHTML = `${probe.temperature.toFixed(1)}<span>°${unit}</span>`;
} else {
tempEl.classList.add('disconnected');
tempEl.textContent = '---';
}
card.querySelector('.status').textContent = probe.connected ? 'Connected' : 'Disconnected';
// Only update setpoint placeholders, never overwrite values while typing.
const customInput = card.querySelector('.custom-target');
const labelInput = card.querySelector('.target-label');
const presetSelect = card.querySelector('.preset-select');
if (!customInput.matches(':focus') && !labelInput.matches(':focus')) {
customInput.placeholder = `Target °${unit}`;
if (!customInput.value && setpoint) {
customInput.value = setpoint.target_temp;
}
if (!labelInput.value && setpoint) {
labelInput.value = setpoint.label || '';
}
// Update preset option labels with current unit.
Array.from(presetSelect.options).forEach((opt, idx) => {
if (idx > 0 && idx <= PRESETS.length) {
const p = PRESETS[idx - 1];
opt.textContent = `${p.label} (${p.temp}°${unit})`;
}
});
}
}
}
async function saveSetpoint(probeNumber, label, targetTemp) {
if (!viewedSession) return;
await api(`/api/setpoints/${viewedSession.id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ probe_number: probeNumber, label, target_temp: targetTemp }),
});
await loadSetpoints();
// Do not call refreshDashboard here; it would clear the input the user just used.
// The next periodic poll will pick up the new setpoint from /api/status.
}
function renderStatusBar(reading) {
const cydEl = document.getElementById('cyd-status');
const battEl = document.getElementById('battery-status');
const sigEl = document.getElementById('signal-status');
if (!reading) {
cydEl.textContent = 'CYD unreachable';
cydEl.className = 'danger';
return;
}
const connected = reading.connected;
cydEl.textContent = connected ? 'CYD connected' : 'CYD: BLE disconnected';
cydEl.className = connected ? 'ok' : 'warn';
battEl.textContent = `Battery: ${reading.battery ?? '--'}%`;
const rssi = reading.rssi;
sigEl.textContent = rssi !== undefined ? `Signal: ${rssi} dBm` : 'Signal: WiFi only';
}
async function loadSessions() {
allSessions = await api('/api/sessions');
activeSession = allSessions.find(s => !s.ended_at) || null;
// Default the viewed session to the active one, or the most recent past one.
if (!viewedSession || !allSessions.find(s => s.id === viewedSession.id)) {
viewedSession = activeSession || allSessions[0] || null;
}
const activeEl = document.getElementById('active-session');
const endBtn = document.getElementById('end-session-btn');
if (activeSession) {
activeEl.innerHTML = `
<strong>${activeSession.name}</strong> started at ${formatTime(activeSession.started_at)}
<br><small>${activeSession.notes || 'No notes'}</small>
`;
endBtn.disabled = false;
} else {
activeEl.textContent = 'No active session. Start one above to begin recording.';
endBtn.disabled = true;
}
renderSessionList();
}
function renderSessionList() {
const listEl = document.getElementById('past-sessions');
if (!listEl) return;
const past = allSessions.filter(s => s.ended_at);
if (past.length === 0) {
listEl.innerHTML = '<p class="muted">No past sessions yet.</p>';
return;
}
listEl.innerHTML = past.map(s => {
const isViewed = viewedSession && viewedSession.id === s.id;
const started = formatTime(s.started_at);
const ended = formatTime(s.ended_at);
return `
<button type="button" class="session-row ${isViewed ? 'viewed' : ''}" data-id="${s.id}">
<span class="session-name">${escapeHtml(s.name)}</span>
<span class="session-times">${started} ${ended}</span>
</button>
`;
}).join('');
listEl.querySelectorAll('.session-row').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id, 10);
viewedSession = allSessions.find(s => s.id === id) || null;
renderSessionList();
loadSetpoints();
refreshChart();
updateChartHeader();
});
});
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
}
function updateChartHeader() {
const headerEl = document.getElementById('chart-session-label');
if (!headerEl) return;
if (!viewedSession) {
headerEl.textContent = 'No session';
return;
}
if (activeSession && viewedSession.id === activeSession.id) {
headerEl.textContent = `Live — ${viewedSession.name}`;
} else {
headerEl.textContent = `Viewing past — ${viewedSession.name}`;
}
}
async function loadSetpoints() {
if (!viewedSession) {
setpoints = [];
updateChartHeader();
return;
}
setpoints = await api(`/api/setpoints/${viewedSession.id}`);
updateChartHeader();
}
async function refreshDashboard() {
try {
const status = await api('/api/status');
latestReading = status.latest_reading;
if (!cardsInitialized) {
createProbeCards();
}
updateProbeCards(status.latest_reading, setpoints);
renderStatusBar(status.latest_reading);
document.getElementById('last-updated').textContent = `Last updated: ${formatTime(new Date().toISOString())}`;
} catch (e) {
console.error('Refresh failed:', e);
renderStatusBar(null);
}
}
async function refreshChart() {
if (!chartInstance) return;
if (!viewedSession) {
chartInstance.data.labels = [];
chartInstance.data.datasets = [];
chartInstance.update();
return;
}
const range = parseInt(document.getElementById('chart-range').value, 10);
const data = await api(`/api/readings/${viewedSession.id}?minutes=${range}`);
const labels = data.map(r => formatTime(r.received_at));
const datasets = [1, 2, 3, 4].map((num, idx) => ({
label: probeNames[idx] || (latestReading?.probes?.[idx]?.name) || `Probe ${num}`,
data: data.map(r => r[`probe${num}_temp`]),
borderColor: ['#ff6b35', '#4caf50', '#2196f3', '#9c27b0'][idx],
backgroundColor: ['rgba(255,107,53,0.1)', 'rgba(76,175,80,0.1)', 'rgba(33,150,243,0.1)', 'rgba(156,39,176,0.1)'][idx],
fill: true,
tension: 0.3,
spanGaps: true,
}));
chartInstance.data.labels = labels;
chartInstance.data.datasets = datasets;
chartInstance.update();
}
function initChart() {
const ctx = document.getElementById('temp-chart').getContext('2d');
chartInstance = new Chart(ctx, {
type: 'line',
data: { labels: [], datasets: [] },
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
title: { display: true, text: 'Temperature' },
grid: { color: '#333' },
ticks: { color: '#aaa' },
},
x: {
grid: { color: '#333' },
ticks: { color: '#aaa', maxTicksLimit: 8 },
},
},
plugins: {
legend: { labels: { color: '#f0f0f0' } },
},
},
});
}
document.getElementById('new-session-form').addEventListener('submit', async (e) => {
e.preventDefault();
const name = document.getElementById('session-name').value;
const notes = document.getElementById('session-notes').value;
const result = await api('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, notes }),
});
document.getElementById('session-name').value = '';
document.getElementById('session-notes').value = '';
await loadSessions();
// Switch the chart to the newly created active session.
viewedSession = allSessions.find(s => s.id === result.session_id) || activeSession;
await loadSetpoints();
await refreshChart();
renderSessionList();
});
document.getElementById('end-session-btn').addEventListener('click', async () => {
if (!activeSession) return;
await api(`/api/sessions/${activeSession.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ end: true }),
});
await loadSessions();
// If we were viewing the just-ended session, keep viewing it (now a past session).
await loadSetpoints();
await refreshChart();
});
document.getElementById('refresh-chart-btn').addEventListener('click', refreshChart);
document.getElementById('chart-range').addEventListener('change', refreshChart);
async function init() {
initChart();
await loadProbeNames();
await loadSessions();
await loadSetpoints();
// First call creates the cards; subsequent calls only update values.
await refreshDashboard();
await refreshChart();
setInterval(refreshDashboard, 5000);
setInterval(() => { if (viewedSession) refreshChart(); }, 30000);
}
init();