- Split renderProbeCards into createProbeCards (once) and updateProbeCards (mutates only the values: name, target, temp, status, dot color). - Skip refreshing setpoint input values while a field has focus so typing is not interrupted by the 5s poll. - Stop calling refreshDashboard after saveSetpoint to avoid clobbering input.
314 lines
10 KiB
JavaScript
314 lines
10 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 latestReading = null;
|
|
let setpoints = [];
|
|
let chartInstance = null;
|
|
let cardsInitialized = false;
|
|
|
|
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">${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);
|
|
});
|
|
});
|
|
|
|
cardsInitialized = true;
|
|
}
|
|
|
|
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: DEFAULT_PROBE_NAMES[i], temperature: null, connected: false };
|
|
const setpoint = setpoints.find(s => s.probe_number === (i + 1));
|
|
|
|
card.classList.toggle('connected', probe.connected);
|
|
card.querySelector('.name').textContent = probe.name || DEFAULT_PROBE_NAMES[i];
|
|
|
|
const targetEl = card.querySelector('.target');
|
|
if (setpoint) {
|
|
targetEl.textContent = `Target: ${setpoint.label ? setpoint.label + ' ' : ''}${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 (!activeSession) return;
|
|
await api(`/api/setpoints/${activeSession.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() {
|
|
const sessions = await api('/api/sessions');
|
|
activeSession = sessions.find(s => !s.ended_at) || 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.';
|
|
endBtn.disabled = true;
|
|
}
|
|
}
|
|
|
|
async function loadSetpoints() {
|
|
if (!activeSession) {
|
|
setpoints = [];
|
|
return;
|
|
}
|
|
setpoints = await api(`/api/setpoints/${activeSession.id}`);
|
|
}
|
|
|
|
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 (!activeSession || !chartInstance) return;
|
|
const range = parseInt(document.getElementById('chart-range').value, 10);
|
|
const data = await api(`/api/readings/${activeSession.id}?minutes=${range}`);
|
|
|
|
const labels = data.map(r => formatTime(r.received_at));
|
|
const datasets = [1, 2, 3, 4].map((num, idx) => ({
|
|
label: (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;
|
|
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();
|
|
await loadSetpoints();
|
|
await refreshChart();
|
|
});
|
|
|
|
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();
|
|
await loadSetpoints();
|
|
await refreshChart();
|
|
});
|
|
|
|
document.getElementById('refresh-chart-btn').addEventListener('click', refreshChart);
|
|
document.getElementById('chart-range').addEventListener('change', refreshChart);
|
|
|
|
async function init() {
|
|
initChart();
|
|
await loadSessions();
|
|
await loadSetpoints();
|
|
// First call creates the cards; subsequent calls only update values.
|
|
await refreshDashboard();
|
|
await refreshChart();
|
|
|
|
setInterval(refreshDashboard, 5000);
|
|
setInterval(() => { if (activeSession) refreshChart(); }, 30000);
|
|
}
|
|
|
|
init();
|