diff --git a/cyd-bridge/README.md b/cyd-bridge/README.md index 845367b..f14340c 100644 --- a/cyd-bridge/README.md +++ b/cyd-bridge/README.md @@ -5,51 +5,37 @@ Firmware for an ESP32 "Cheap Yellow Display" (ESP32-2432S028R) that reads temper ## Hardware - **Board:** ESP32-2432S028R - - 320×240 TFT (ILI9341 driver) + - 320×240 TFT (ILI9341 driver, detected via `LGFX_AUTODETECT`) - Resistive touch panel (XPT2046) - ESP32-WROOM-32 with 4 MB flash - **Thermometer:** ThermoPro TP25 / TP25W / TP920 / TP930 / TP960 -## Firmware architecture +## What it does -``` -┌─────────────────────────────────────────┐ -│ CYD (ESP32-2432S028R) │ -│ │ -│ ┌─────────────┐ ┌──────────────┐ │ -│ │ ThermoPro │ │ HTTP API │ │ -│ │ BLE client │─────▶│ Web server │ │ -│ └─────────────┘ └──────────────┘ │ -│ │ │ │ -│ ▼ ▼ │ -│ ┌──────────────────────────────────┐ │ -│ │ SettingsManager (NVS/Preferences)│ │ -│ └──────────────────────────────────┘ │ -│ │ -│ ┌──────────────────────────────────┐ │ -│ │ Touch UI: status + setup screens │ │ -│ └──────────────────────────────────┘ │ -└─────────────────────────────────────────┘ -``` +- 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. +- 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. ## API endpoints -- `GET /api/health` — device health and connectivity status. -- `GET /api/latest` — latest ThermoPro reading. -- `GET /api/settings` — current settings (excluding WiFi password). -- `POST /api/settings` — update settings (JSON body). -- `GET /` — setup page when in AP mode; status page when in STA mode. +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/health` | Device health and connectivity status. | +| `GET` | `/api/latest` | Latest ThermoPro reading. | +| `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` +### `GET /api/latest` example ```json { "ok": true, "deviceId": "cyd-smoker", "device": "ThermoPro TP930", - "mac": "C9:48:1D:B1:E1:E7", + "mac": "c9:48:1d:b1:e1:e7", "connected": true, "battery": 90, "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": 4, "name": "Probe 4", "temperature": null, "connected": false } ], - "readingTime": "2026-07-19T12:00:00+00:00", - "bridgeTime": "2026-07-19T12:00:01+00:00" + "readingTime": "12345", + "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`. 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. -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:///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 @@ -94,26 +82,57 @@ If you have not installed PlatformIO yet: 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`. -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. -3. If the screen stays white or touch is inverted, adjust `cfg.offset_rotation` in `DisplayConfig.hpp` or `setRotation()` in `Dashboard::begin()`. +1. Try both USB ports; only one is wired to the UART chip. +2. Install the USB-to-serial driver if Windows shows an unknown device: + - 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 With the default partition table the firmware uses approximately: - **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. +## 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 -- 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. +- 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`. -- 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. diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..5865d9e --- /dev/null +++ b/frontend/README.md @@ -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. diff --git a/frontend/app.py b/frontend/app.py new file mode 100644 index 0000000..73550e1 --- /dev/null +++ b/frontend/app.py @@ -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/", 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/") +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/", 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) diff --git a/frontend/config.py b/frontend/config.py new file mode 100644 index 0000000..85be295 --- /dev/null +++ b/frontend/config.py @@ -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")) diff --git a/frontend/db.py b/frontend/db.py new file mode 100644 index 0000000..66345d4 --- /dev/null +++ b/frontend/db.py @@ -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] diff --git a/frontend/requirements.txt b/frontend/requirements.txt new file mode 100644 index 0000000..5729766 --- /dev/null +++ b/frontend/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0.0 +requests>=2.31.0 +waitress>=3.0.0 diff --git a/frontend/static/app.js b/frontend/static/app.js new file mode 100644 index 0000000..28978de --- /dev/null +++ b/frontend/static/app.js @@ -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 + ? `
${probe.temperature.toFixed(1)}°${unit}
` + : `
---
`; + + const targetDisplay = setpoint + ? `
Target: ${setpoint.label || ''} ${setpoint.target_temp.toFixed(0)}°${unit}
` + : `
No target set
`; + + card.innerHTML = ` +
+ ${probe.name || DEFAULT_PROBE_NAMES[i]} + #${i + 1} +
+ ${targetDisplay} + ${tempDisplay} + +
+ + + + +
+ `; + + 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 = ` + ${activeSession.name} started at ${formatTime(activeSession.started_at)} +
${activeSession.notes || 'No notes'} + `; + 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(); diff --git a/frontend/static/manifest.json b/frontend/static/manifest.json new file mode 100644 index 0000000..5383ec3 --- /dev/null +++ b/frontend/static/manifest.json @@ -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" + } + ] +} diff --git a/frontend/static/style.css b/frontend/static/style.css new file mode 100644 index 0000000..a670a52 --- /dev/null +++ b/frontend/static/style.css @@ -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; } +} diff --git a/frontend/templates/index.html b/frontend/templates/index.html new file mode 100644 index 0000000..003d9c3 --- /dev/null +++ b/frontend/templates/index.html @@ -0,0 +1,64 @@ + + + + + + ThermoPro CYD Dashboard + + + + + +
+

ThermoPro CYD Dashboard

+
+ Checking CYD... + -- + -- +
+
+ +
+
+
+

Session

+
No active session.
+
+ + + +
+ +
+
+ +
+ +
+ +
+
+

Temperature History

+
+ + +
+ +
+
+
+ +
+
--
+
+ + + +