455 lines
15 KiB
JavaScript
455 lines
15 KiB
JavaScript
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 => ({
|
||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||
}[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();
|