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
+61 -42
View File
@@ -5,51 +5,37 @@ Firmware for an ESP32 "Cheap Yellow Display" (ESP32-2432S028R) that reads temper
## Hardware ## Hardware
- **Board:** ESP32-2432S028R - **Board:** ESP32-2432S028R
- 320×240 TFT (ILI9341 driver) - 320×240 TFT (ILI9341 driver, detected via `LGFX_AUTODETECT`)
- Resistive touch panel (XPT2046) - Resistive touch panel (XPT2046)
- ESP32-WROOM-32 with 4 MB flash - ESP32-WROOM-32 with 4 MB flash
- **Thermometer:** ThermoPro TP25 / TP25W / TP920 / TP930 / TP960 - **Thermometer:** ThermoPro TP25 / TP25W / TP920 / TP930 / TP960
## Firmware architecture ## What it does
``` - Connects the CYD to your WiFi network.
┌─────────────────────────────────────────┐ - Scans for a ThermoPro thermometer by its advertised service UUID (`1086FFF0-3343-4817-8BB2-B32206336CE8`) and connects over BLE.
│ CYD (ESP32-2432S028R) │ - Decodes battery level, connection status, and up to four probe temperatures.
│ │ - Runs a small HTTP API that any frontend (web app, mobile app, Home Assistant, etc.) can consume.
│ ┌─────────────┐ ┌──────────────┐ │ - Shows a simple touch dashboard with probe temperatures and connection status.
│ │ ThermoPro │ │ HTTP API │ │
│ │ BLE client │─────▶│ Web server │ │
│ └─────────────┘ └──────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────────────────────┐ │
│ │ SettingsManager (NVS/Preferences)│ │
│ └──────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────┐ │
│ │ Touch UI: status + setup screens │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────┘
```
## API endpoints ## API endpoints
- `GET /api/health` — device health and connectivity status. | Method | Path | Description |
- `GET /api/latest` — latest ThermoPro reading. |--------|------|-------------|
- `GET /api/settings` — current settings (excluding WiFi password). | `GET` | `/api/health` | Device health and connectivity status. |
- `POST /api/settings` — update settings (JSON body). | `GET` | `/api/latest` | Latest ThermoPro reading. |
- `GET /` — setup page when in AP mode; status page when in STA mode. | `GET` | `/api/settings` | Current settings (WiFi password omitted). |
| `POST` | `/api/settings` | Update settings (JSON body). |
| `GET` | `/` | Setup page in AP mode; status page in STA mode. |
## Endpoints response examples ### `GET /api/latest` example
### `GET /api/latest`
```json ```json
{ {
"ok": true, "ok": true,
"deviceId": "cyd-smoker", "deviceId": "cyd-smoker",
"device": "ThermoPro TP930", "device": "ThermoPro TP930",
"mac": "C9:48:1D:B1:E1:E7", "mac": "c9:48:1d:b1:e1:e7",
"connected": true, "connected": true,
"battery": 90, "battery": 90,
"unit": "F", "unit": "F",
@@ -60,8 +46,9 @@ Firmware for an ESP32 "Cheap Yellow Display" (ESP32-2432S028R) that reads temper
{ "id": 3, "name": "Probe 3", "temperature": null, "connected": false }, { "id": 3, "name": "Probe 3", "temperature": null, "connected": false },
{ "id": 4, "name": "Probe 4", "temperature": null, "connected": false } { "id": 4, "name": "Probe 4", "temperature": null, "connected": false }
], ],
"readingTime": "2026-07-19T12:00:00+00:00", "readingTime": "12345",
"bridgeTime": "2026-07-19T12:00:01+00:00" "bridgeTime": "12346",
"source": "cyd-bridge"
} }
``` ```
@@ -70,9 +57,10 @@ Firmware for an ESP32 "Cheap Yellow Display" (ESP32-2432S028R) that reads temper
1. **First boot:** the CYD starts a captive portal AP named `CYD-ThermoPro-Setup`. 1. **First boot:** the CYD starts a captive portal AP named `CYD-ThermoPro-Setup`.
2. Connect to the AP with a phone or laptop and open `http://192.168.4.1`. 2. Connect to the AP with a phone or laptop and open `http://192.168.4.1`.
3. Enter your home WiFi SSID and password, plus the ThermoPro MAC address. 3. Enter your home WiFi SSID and password, plus the ThermoPro MAC address.
4. The device connects to WiFi, stores the credentials, and reboots into normal mode. 4. The device saves the credentials and reboots into normal mode.
5. Find the CYD's local IP from your router or the serial monitor, then open `http://<cyd-ip>/api/latest`.
Runtime settings (endpoint, token, probe names, units, polling interval) can be changed through the same web UI at the device's local IP or via the JSON API. **Note on MAC addresses:** If you previously paired the thermometer with the official ThermoPro app, the app may show a *resolved* public-looking MAC while the device actually advertises with a *random* BLE address. The CYD scans by the ThermoPro service UUID, so it will find the device regardless. Enter whatever address the app displays; the UUID match takes priority.
## Building and flashing ## Building and flashing
@@ -94,26 +82,57 @@ If you have not installed PlatformIO yet:
pip install platformio pip install platformio
``` ```
## First-time hardware check ### First-time upload
Before relying on the dashboard, verify the pinout matches your exact board revision: If the CYD is not detected:
1. Open `src/display/DisplayConfig.hpp`. 1. Try both USB ports; only one is wired to the UART chip.
2. Confirm `TFT_CS`, `TFT_DC`, `TFT_RST`, `TFT_BL`, `TOUCH_CS`, and the SPI pins match the silkscreen or schematic of your ESP32-2432S028R. 2. Install the USB-to-serial driver if Windows shows an unknown device:
3. If the screen stays white or touch is inverted, adjust `cfg.offset_rotation` in `DisplayConfig.hpp` or `setRotation()` in `Dashboard::begin()`. - CH340/CH343: [WCH driver](https://www.wch-ic.com/downloads/CH343SER_ZIP.html)
- CP2102: [Silicon Labs driver](https://www.silabs.com/developers/usb-to-uart-bridge-vcp-drivers)
3. If the upload still fails, hold the **BOOT** button, press and release **RST**, then release **BOOT**, and retry.
## Memory usage ## Memory usage
With the default partition table the firmware uses approximately: With the default partition table the firmware uses approximately:
- **RAM:** ~18 % (~58 kB of 320 kB) - **RAM:** ~18 % (~58 kB of 320 kB)
- **Flash:** ~92 % (~1.2 MB of 1.3 MB) - **Flash:** ~96 % (~1.2 MB of 1.3 MB)
There is enough headroom to run, but large future features (e.g. local graphing, OTA) may require switching to a larger app partition or dropping unused NimBLE options. There is enough headroom to run, but large future features (e.g. local graphing, OTA) may require switching to a larger app partition or dropping unused NimBLE options.
## Runtime settings
Change settings by POSTing JSON to `/api/settings`:
```bash
curl -X POST http://192.168.2.55/api/settings \
-H "Content-Type: application/json" \
-d '{
"probeNames": ["Brisket", "Ambient", "Probe 3", "Probe 4"],
"unit": "F",
"deviceId": "cyd-smoker"
}'
```
Available fields:
- `wifiSsid`, `wifiPass`
- `thermoproMac`
- `deviceId`
- `probeNames` (array of 4 strings)
- `unit` (`"F"` or `"C"`)
## Development notes ## Development notes
- LovyanGFX driver/pin configuration lives in `src/display/DisplayConfig.hpp` and is tuned for the ESP32-2432S028R variant. - Display pinout is handled by `LGFX_AUTODETECT`, which detects the common `Sunton_2432S028` (ILI9341) layout.
- BLE uses `NimBLE-Arduino` to leave enough RAM for the web server and UI. - BLE uses `NimBLE-Arduino` to leave enough RAM for the web server and UI.
- The CYD connects to the ThermoPro by scanning for the advertised service UUID. This is more reliable than MAC matching when the thermometer uses BLE privacy/random addresses.
- All user settings are stored in ESP32 NVS via `Preferences`. - All user settings are stored in ESP32 NVS via `Preferences`.
- The firmware compiles successfully in PlatformIO; the remaining work is on-device testing and pinout verification. - Serial log runs at 115200 baud for diagnostics.
## Known issues / next steps
- `/api/latest` timestamps are device-relative millis; wall-clock time would require NTP.
- Touch dashboard is minimal; a nicer layout, settings menu, and local alerts are planned.
- Flash usage is tight due to `LGFX_AUTODETECT`; a hardcoded board config would free space.
+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>