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:
+61
-42
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
@@ -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
@@ -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]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
flask>=3.0.0
|
||||||
|
requests>=2.31.0
|
||||||
|
waitress>=3.0.0
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user