feat: add PWA frontend for CYD ThermoPro dashboard
Add a Flask backend + SQLite + HTML/CSS/JS PWA that: - Polls the CYD /api/latest endpoint - Stores readings, sessions, and freeform notes in SQLite - Displays probe cards with target setpoints and preset options - Renders temperature history with Chart.js - Serves a PWA manifest and dark-themed dashboard Also update cyd-bridge/README.md with verified bringup instructions.
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
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;
|
||||
|
||||
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 renderProbeCards(reading, setpoints) {
|
||||
const container = document.getElementById('probe-cards');
|
||||
container.innerHTML = '';
|
||||
|
||||
const probes = reading?.probes || [];
|
||||
const unit = reading?.unit || 'F';
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
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));
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = `probe-card ${probe.connected ? 'connected' : ''}`;
|
||||
|
||||
const tempDisplay = probe.connected
|
||||
? `<div class="temp">${probe.temperature.toFixed(1)}<span>°${unit}</span></div>`
|
||||
: `<div class="temp disconnected">---</div>`;
|
||||
|
||||
const targetDisplay = setpoint
|
||||
? `<div class="target">Target: ${setpoint.label || ''} ${setpoint.target_temp.toFixed(0)}°${unit}</div>`
|
||||
: `<div class="target">No target set</div>`;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="header">
|
||||
<span class="name">${probe.name || DEFAULT_PROBE_NAMES[i]}</span>
|
||||
<span>#${i + 1}</span>
|
||||
</div>
|
||||
${targetDisplay}
|
||||
${tempDisplay}
|
||||
<div class="footer">
|
||||
<span>${probe.connected ? 'Connected' : '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} (${p.temp}°${unit})</option>`).join('')}
|
||||
<option value="custom">Custom...</option>
|
||||
</select>
|
||||
<input type="number" class="custom-target" data-probe="${i + 1}" placeholder="Target °${unit}" value="${setpoint ? setpoint.target_temp : ''}">
|
||||
<input type="text" class="target-label" data-probe="${i + 1}" placeholder="Label" value="${setpoint ? setpoint.label : ''}">
|
||||
<button class="save-setpoint" data-probe="${i + 1}">Save</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.appendChild(card);
|
||||
}
|
||||
|
||||
// Wire up preset selectors and save buttons.
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
await refreshDashboard();
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Add probe names from active session if we had stored them; for now use CYD names.
|
||||
if (latestReading) {
|
||||
// The CYD returns probe names; pass them through.
|
||||
}
|
||||
|
||||
renderStatusBar(status.latest_reading);
|
||||
renderProbeCards(status.latest_reading, setpoints);
|
||||
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();
|
||||
await refreshDashboard();
|
||||
await refreshChart();
|
||||
|
||||
setInterval(async () => {
|
||||
await refreshDashboard();
|
||||
}, 5000);
|
||||
|
||||
setInterval(async () => {
|
||||
if (activeSession) await refreshChart();
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "ThermoPro CYD Dashboard",
|
||||
"short_name": "CYD BBQ",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#1a1a1a",
|
||||
"theme_color": "#ff6b35",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
:root {
|
||||
--bg: #121212;
|
||||
--surface: #1e1e1e;
|
||||
--text: #f0f0f0;
|
||||
--muted: #a0a0a0;
|
||||
--accent: #ff6b35;
|
||||
--ok: #4caf50;
|
||||
--warn: #ff9800;
|
||||
--danger: #f44336;
|
||||
--radius: 12px;
|
||||
--shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
header {
|
||||
background: var(--surface);
|
||||
padding: 1rem;
|
||||
box-shadow: var(--shadow);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
#status-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
#status-bar .ok { color: var(--ok); }
|
||||
#status-bar .warn { color: var(--warn); }
|
||||
#status-bar .danger { color: var(--danger); }
|
||||
|
||||
main {
|
||||
padding: 1rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
box-shadow: var(--shadow);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin-top: 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
#session-controls form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
#session-controls input,
|
||||
#session-controls textarea,
|
||||
#session-controls select {
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #333;
|
||||
color: var(--text);
|
||||
padding: 0.6rem;
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
#session-controls textarea {
|
||||
min-height: 80px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
#session-controls button {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 0.7rem 1rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#session-controls button:disabled {
|
||||
background: #444;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#end-session-btn {
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#probe-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.probe-card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
box-shadow: var(--shadow);
|
||||
border-left: 4px solid var(--muted);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.probe-card.connected {
|
||||
border-left-color: var(--ok);
|
||||
}
|
||||
|
||||
.probe-card .header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.probe-card .name {
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.probe-card .target {
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.probe-card .temp {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 300;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.probe-card .temp.disconnected {
|
||||
color: var(--muted);
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.probe-card .footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.setpoint-editor {
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.setpoint-editor input {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #333;
|
||||
color: var(--text);
|
||||
padding: 0.4rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.setpoint-editor button {
|
||||
background: #333;
|
||||
color: var(--text);
|
||||
border: 1px solid #444;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chart-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.chart-controls select,
|
||||
.chart-controls button {
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #333;
|
||||
color: var(--text);
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.chart-controls button {
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
#status-bar { flex-direction: column; gap: 0.25rem; }
|
||||
.probe-card .temp { font-size: 2rem; }
|
||||
}
|
||||
Reference in New Issue
Block a user