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:
Keith Solomon
2026-07-20 20:14:48 -05:00
parent f03cebf322
commit 9e32b1b179
10 changed files with 1077 additions and 42 deletions
+50
View File
@@ -0,0 +1,50 @@
# ThermoPro CYD Frontend
A Progressive Web App (PWA) that consumes the CYD ThermoPro Bridge API, stores session history in SQLite, and displays probe temperatures with charts, target setpoints, and freeform notes.
## Architecture
```
LXC/container
├── Python backend (FastAPI/Flask)
│ ├── Polls CYD /api/latest periodically
│ ├── Stores readings + sessions + notes in SQLite
│ └── Serves the static PWA and API
└── PWA (HTML/CSS/JS)
├── Dashboard with probe cards
├── Temperature history charts
├── Target setpoint editor
└── Session notes
```
## Requirements
- Python 3.10+
- `flask`, `requests`, `gunicorn` (or `uvicorn` if FastAPI)
- SQLite (stdlib)
## Running locally
```bash
cd frontend
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
python app.py
```
Then open `http://localhost:5000`.
## Configuration
The backend needs to know the CYD IP. Set the environment variable:
```bash
export CYD_BASE_URL=http://192.168.2.55
```
or edit the default in `config.py`.
## Deployment
Serve the app behind a reverse proxy (e.g. Caddy or Nginx) in an LXC. The backend will continue polling the CYD as long as it can reach the device.
+155
View File
@@ -0,0 +1,155 @@
import json
import threading
import time
from datetime import datetime, timezone
import requests
from flask import Flask, jsonify, request, render_template
from waitress import serve
import config
import db
app = Flask(__name__, static_folder="static", template_folder="templates")
db.init_db()
_poller_thread = None
_stop_polling = threading.Event()
def fetch_cyd():
try:
resp = requests.get(
f"{config.CYD_BASE_URL}/api/latest",
timeout=10,
)
resp.raise_for_status()
return resp.json()
except Exception as e:
app.logger.warning(f"CYD fetch failed: {e}")
return None
def poller_loop():
while not _stop_polling.is_set():
session = db.get_active_session()
if session:
payload = fetch_cyd()
if payload:
db.insert_reading(session["id"], payload)
time.sleep(config.POLL_INTERVAL)
def start_poller():
global _poller_thread
if _poller_thread is None or not _poller_thread.is_alive():
_stop_polling.clear()
_poller_thread = threading.Thread(target=poller_loop, daemon=True)
_poller_thread.start()
@app.route("/")
def index():
return render_template("index.html")
@app.route("/api/status")
def api_status():
"""Backend status plus current CYD reading."""
session = db.get_active_session()
latest = db.get_latest_reading(session["id"]) if session else None
latest_proxy = fetch_cyd()
return jsonify({
"ok": True,
"cyd_base_url": config.CYD_BASE_URL,
"poll_interval": config.POLL_INTERVAL,
"active_session": session,
"latest_reading": latest_proxy or latest,
})
@app.route("/api/cyd/latest")
def api_cyd_latest():
"""Proxy to the CYD's own /api/latest."""
payload = fetch_cyd()
if payload is None:
return jsonify({"ok": False, "error": "Unable to reach CYD"}), 502
return jsonify(payload)
@app.route("/api/sessions", methods=["GET", "POST"])
def sessions():
if request.method == "POST":
data = request.get_json() or {}
name = data.get("name", f"Cook {datetime.now(timezone.utc).isoformat()[:19]}")
notes = data.get("notes", "")
# End any currently active session first.
active = db.get_active_session()
if active:
db.end_session(active["id"])
session_id = db.create_session(name, notes)
return jsonify({"ok": True, "session_id": session_id}), 201
with db.get_connection() as conn:
rows = conn.execute(
"SELECT * FROM sessions ORDER BY started_at DESC"
).fetchall()
return jsonify([dict(row) for row in rows])
@app.route("/api/sessions/<int:session_id>", methods=["GET", "PUT", "DELETE"])
def session_detail(session_id):
if request.method == "GET":
with db.get_connection() as conn:
row = conn.execute(
"SELECT * FROM sessions WHERE id = ?", (session_id,)
).fetchone()
if not row:
return jsonify({"ok": False, "error": "Session not found"}), 404
return jsonify(dict(row))
if request.method == "PUT":
data = request.get_json() or {}
if "notes" in data:
db.update_session_notes(session_id, data["notes"])
if data.get("end"):
db.end_session(session_id)
return jsonify({"ok": True})
if request.method == "DELETE":
with db.get_connection() as conn:
conn.execute("DELETE FROM readings WHERE session_id = ?", (session_id,))
conn.execute("DELETE FROM setpoints WHERE session_id = ?", (session_id,))
conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
conn.commit()
return jsonify({"ok": True})
@app.route("/api/readings/<int:session_id>")
def readings(session_id):
minutes = request.args.get("minutes", config.DEFAULT_CHART_MINUTES, type=int)
probe = request.args.get("probe", type=int)
data = db.get_readings(session_id, minutes=minutes, probe_number=probe)
return jsonify(data)
@app.route("/api/setpoints/<int:session_id>", methods=["GET", "POST"])
def setpoints(session_id):
if request.method == "POST":
data = request.get_json() or {}
probe_number = data.get("probe_number")
label = data.get("label", "")
target_temp = data.get("target_temp")
if probe_number is None or target_temp is None:
return jsonify({"ok": False, "error": "probe_number and target_temp required"}), 422
db.set_setpoint(session_id, probe_number, label, float(target_temp))
return jsonify({"ok": True})
return jsonify(db.get_setpoints(session_id))
if __name__ == "__main__":
start_poller()
print(f"Starting server on http://0.0.0.0:5000")
print(f"Polling CYD at {config.CYD_BASE_URL} every {config.POLL_INTERVAL}s")
serve(app, host="0.0.0.0", port=5000)
+13
View File
@@ -0,0 +1,13 @@
import os
# Base URL of the CYD ThermoPro Bridge.
CYD_BASE_URL = os.environ.get("CYD_BASE_URL", "http://192.168.2.55")
# How often (seconds) the backend polls the CYD for new readings.
POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "5"))
# SQLite database path.
DATABASE_PATH = os.environ.get("DATABASE_PATH", "thermopro.db")
# Number of minutes of history shown by default on the dashboard chart.
DEFAULT_CHART_MINUTES = int(os.environ.get("DEFAULT_CHART_MINUTES", "60"))
+214
View File
@@ -0,0 +1,214 @@
import sqlite3
from contextlib import contextmanager
from pathlib import Path
from datetime import datetime, timezone
import config
INIT_SQL = """
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
ended_at TEXT,
notes TEXT
);
CREATE TABLE IF NOT EXISTS readings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
received_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
device_id TEXT,
mac TEXT,
ble_connected INTEGER,
battery INTEGER,
unit TEXT,
probe1_temp REAL,
probe1_connected INTEGER,
probe2_temp REAL,
probe2_connected INTEGER,
probe3_temp REAL,
probe3_connected INTEGER,
probe4_temp REAL,
probe4_connected INTEGER,
FOREIGN KEY (session_id) REFERENCES sessions(id)
);
CREATE TABLE IF NOT EXISTS setpoints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
probe_number INTEGER NOT NULL,
label TEXT,
target_temp REAL NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
FOREIGN KEY (session_id) REFERENCES sessions(id),
UNIQUE(session_id, probe_number)
);
CREATE INDEX IF NOT EXISTS idx_readings_session_time
ON readings(session_id, received_at);
"""
def init_db():
Path(config.DATABASE_PATH).parent.mkdir(parents=True, exist_ok=True)
with get_connection() as conn:
conn.executescript(INIT_SQL)
conn.commit()
@contextmanager
def get_connection():
conn = sqlite3.connect(config.DATABASE_PATH)
conn.row_factory = sqlite3.Row
try:
yield conn
finally:
conn.close()
def utc_now_iso():
return datetime.now(timezone.utc).isoformat()
def create_session(name: str, notes: str = ""):
with get_connection() as conn:
cursor = conn.execute(
"INSERT INTO sessions (name, notes) VALUES (?, ?)",
(name, notes),
)
conn.commit()
return cursor.lastrowid
def get_active_session():
with get_connection() as conn:
row = conn.execute(
"SELECT * FROM sessions WHERE ended_at IS NULL ORDER BY started_at DESC LIMIT 1"
).fetchone()
return dict(row) if row else None
def end_session(session_id: int):
with get_connection() as conn:
conn.execute(
"UPDATE sessions SET ended_at = ? WHERE id = ?",
(utc_now_iso(), session_id),
)
conn.commit()
def update_session_notes(session_id: int, notes: str):
with get_connection() as conn:
conn.execute(
"UPDATE sessions SET notes = ? WHERE id = ?",
(notes, session_id),
)
conn.commit()
def insert_reading(session_id: int, payload: dict):
probes = payload.get("probes", [])
temps = []
connected = []
for i in range(4):
if i < len(probes) and probes[i].get("connected"):
temps.append(probes[i].get("temperature"))
connected.append(1)
else:
temps.append(None)
connected.append(0)
with get_connection() as conn:
conn.execute(
"""
INSERT INTO readings (
session_id, received_at, device_id, mac, ble_connected, battery, unit,
probe1_temp, probe1_connected,
probe2_temp, probe2_connected,
probe3_temp, probe3_connected,
probe4_temp, probe4_connected
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
session_id,
utc_now_iso(),
payload.get("deviceId"),
payload.get("mac"),
1 if payload.get("connected") else 0,
payload.get("battery"),
payload.get("unit"),
temps[0], connected[0],
temps[1], connected[1],
temps[2], connected[2],
temps[3], connected[3],
),
)
conn.commit()
def get_latest_reading(session_id: int):
with get_connection() as conn:
row = conn.execute(
"SELECT * FROM readings WHERE session_id = ? ORDER BY received_at DESC LIMIT 1",
(session_id,),
).fetchone()
return dict(row) if row else None
def get_readings(session_id: int, minutes: int = 60, probe_number: int = None):
with get_connection() as conn:
if probe_number is not None:
column = f"probe{probe_number}_temp"
rows = conn.execute(
f"""
SELECT received_at, {column} AS temp, probe{probe_number}_connected AS connected
FROM readings
WHERE session_id = ?
AND received_at > datetime('now', '-{minutes} minutes')
AND {column} IS NOT NULL
ORDER BY received_at ASC
""",
(session_id,),
).fetchall()
else:
rows = conn.execute(
f"""
SELECT received_at,
probe1_temp, probe1_connected,
probe2_temp, probe2_connected,
probe3_temp, probe3_connected,
probe4_temp, probe4_connected
FROM readings
WHERE session_id = ?
AND received_at > datetime('now', '-{minutes} minutes')
ORDER BY received_at ASC
""",
(session_id,),
).fetchall()
return [dict(row) for row in rows]
def set_setpoint(session_id: int, probe_number: int, label: str, target_temp: float):
with get_connection() as conn:
conn.execute(
"""
INSERT INTO setpoints (session_id, probe_number, label, target_temp)
VALUES (?, ?, ?, ?)
ON CONFLICT(session_id, probe_number) DO UPDATE SET
label = excluded.label,
target_temp = excluded.target_temp,
created_at = excluded.created_at
""",
(session_id, probe_number, label, target_temp),
)
conn.commit()
def get_setpoints(session_id: int):
with get_connection() as conn:
rows = conn.execute(
"SELECT * FROM setpoints WHERE session_id = ? ORDER BY probe_number",
(session_id,),
).fetchall()
return [dict(row) for row in rows]
+3
View File
@@ -0,0 +1,3 @@
flask>=3.0.0
requests>=2.31.0
waitress>=3.0.0
+272
View File
@@ -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();
+20
View File
@@ -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"
}
]
}
+225
View File
@@ -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; }
}
+64
View File
@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ThermoPro CYD Dashboard</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
</head>
<body>
<header>
<h1>ThermoPro CYD Dashboard</h1>
<div id="status-bar">
<span id="cyd-status">Checking CYD...</span>
<span id="battery-status">--</span>
<span id="signal-status">--</span>
</div>
</header>
<main>
<section id="session-controls">
<div class="panel">
<h2>Session</h2>
<div id="active-session">No active session.</div>
<form id="new-session-form">
<input type="text" id="session-name" placeholder="Session name" required>
<textarea id="session-notes" placeholder="Notes (freeform)"></textarea>
<button type="submit">Start New Session</button>
</form>
<button id="end-session-btn" disabled>End Session</button>
</div>
</section>
<section id="probe-cards">
<!-- Cards injected by JS -->
</section>
<section id="charts">
<div class="panel">
<h2>Temperature History</h2>
<div class="chart-controls">
<label>Range:
<select id="chart-range">
<option value="15">15 min</option>
<option value="60" selected>1 hour</option>
<option value="240">4 hours</option>
<option value="720">12 hours</option>
</select>
</label>
<button id="refresh-chart-btn">Refresh Chart</button>
</div>
<canvas id="temp-chart"></canvas>
</div>
</section>
</main>
<footer>
<div id="last-updated">--</div>
</footer>
<script src="{{ url_for('static', filename='app.js') }}"></script>
</body>
</html>