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
|
||||
|
||||
- **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://<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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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