Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1bd1ddc20e | ||
|
|
6d103b0426 | ||
|
|
7ef6af1cf3 | ||
|
|
5fda27794d | ||
|
|
c80de43f55 | ||
|
|
9e32b1b179 |
@@ -3,3 +3,4 @@
|
|||||||
data/*
|
data/*
|
||||||
thermopro-cli/
|
thermopro-cli/
|
||||||
notes/
|
notes/
|
||||||
|
frontend.tgz
|
||||||
|
|||||||
+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,6 @@
|
|||||||
|
.venv
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
thermopro.db
|
||||||
|
vendor/
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# 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 (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+ (system install or any working Python interpreter)
|
||||||
|
- SQLite (stdlib)
|
||||||
|
|
||||||
|
Dependencies are installed locally into `frontend/vendor/` so no virtual environment is required.
|
||||||
|
|
||||||
|
## Installing dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
pip install --target vendor -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
On Windows with PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd "C:\Users\ksolo\Projects\House Projects\ThermoPro CYD\frontend"
|
||||||
|
pip install --target vendor -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
CYD_BASE_URL=http://192.168.2.55 python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
On Windows with PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd "C:\Users\ksolo\Projects\House Projects\ThermoPro CYD\frontend"
|
||||||
|
$env:CYD_BASE_URL = "http://192.168.2.55"
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `http://localhost:5000`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Environment variable | Default | Description |
|
||||||
|
|----------------------|---------|-------------|
|
||||||
|
| `CYD_BASE_URL` | `http://192.168.2.55` | Base URL of the CYD bridge |
|
||||||
|
| `POLL_INTERVAL` | `5` | Seconds between backend polls |
|
||||||
|
| `DATABASE_PATH` | `thermopro.db` | SQLite database file |
|
||||||
|
| `DEFAULT_CHART_MINUTES` | `60` | Default chart time range |
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
See `deploy/README.md` for step-by-step Proxmox LXC deployment instructions.
|
||||||
|
|
||||||
|
For a quick local test:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
pip install --target vendor -r requirements.txt
|
||||||
|
CYD_BASE_URL=http://192.168.2.55 python app.py
|
||||||
|
```
|
||||||
+162
@@ -0,0 +1,162 @@
|
|||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add vendor/ to the import path so pip --target dependencies are found.
|
||||||
|
vendor_path = Path(__file__).parent / "vendor"
|
||||||
|
if vendor_path.exists():
|
||||||
|
sys.path.insert(0, str(vendor_path))
|
||||||
|
|
||||||
|
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,136 @@
|
|||||||
|
# Deploying the Frontend to a Proxmox LXC Container
|
||||||
|
|
||||||
|
This folder contains everything you need to run the ThermoPro CYD frontend inside an unprivileged Proxmox LXC container.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
1. Create a Debian 12 (or Ubuntu 22.04/24.04) LXC container in Proxmox.
|
||||||
|
2. Copy the `frontend/` directory into the container.
|
||||||
|
3. Run `deploy/proxmox-lxc-setup.sh` inside the container.
|
||||||
|
4. The container starts the Flask/Waitress app as a systemd service on port 5000.
|
||||||
|
5. Put the container behind a reverse proxy (Caddy/Nginx) for HTTPS and a friendly URL.
|
||||||
|
|
||||||
|
## Container specs (suggested)
|
||||||
|
|
||||||
|
- **OS template:** Debian 12 Standard (or Ubuntu 22.04/24.04 Standard)
|
||||||
|
- **Cores:** 1
|
||||||
|
- **Memory:** 512 MB
|
||||||
|
- **Root disk:** 4 GB
|
||||||
|
- **Network:** DHCP or static IP on your LAN
|
||||||
|
|
||||||
|
## One-time setup
|
||||||
|
|
||||||
|
### 1. Create the LXC
|
||||||
|
|
||||||
|
In the Proxmox web UI:
|
||||||
|
|
||||||
|
- Click **Create CT**.
|
||||||
|
- Choose a Debian 12 template (download from the UI if needed).
|
||||||
|
- Set hostname, password/SSH key, storage, network, etc.
|
||||||
|
- Finish and start the container.
|
||||||
|
|
||||||
|
Or via the Proxmox shell:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pct create 300 local:vztmpl/debian-12-standard_12.x-amd64.tar.zst \
|
||||||
|
--hostname thermopro-frontend \
|
||||||
|
--cores 1 \
|
||||||
|
--memory 512 \
|
||||||
|
--swap 0 \
|
||||||
|
--rootfs local-lvm:4 \
|
||||||
|
--net0 name=eth0,bridge=vmbr0,ip=dhcp \
|
||||||
|
--ostype debian \
|
||||||
|
--unprivileged 1
|
||||||
|
pct start 300
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Copy the frontend into the container
|
||||||
|
|
||||||
|
From your development machine or the Proxmox host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From the repo root
|
||||||
|
cd /path/to/ThermoPro-CYD
|
||||||
|
tar -czf /tmp/frontend.tar.gz frontend/
|
||||||
|
|
||||||
|
# Push it into the container
|
||||||
|
pct push 300 /tmp/frontend.tar.gz /tmp/frontend.tar.gz
|
||||||
|
|
||||||
|
# Extract it
|
||||||
|
pct exec 300 -- mkdir -p /opt
|
||||||
|
pct exec 300 -- tar -xzf /tmp/frontend.tar.gz -C /opt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Run the setup script inside the container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pct exec 300 -- bash /opt/frontend/deploy/proxmox-lxc-setup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This installs Python dependencies into `vendor/`, creates the `thermopro` user, sets up the systemd service, and starts it.
|
||||||
|
|
||||||
|
### 4. Find the container IP
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pct exec 300 -- ip -4 addr show eth0
|
||||||
|
```
|
||||||
|
|
||||||
|
Visit `http://<container-ip>:5000`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Edit the service file to point at your CYD and tune behavior:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pct exec 300 -- nano /etc/systemd/system/thermopro-frontend.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Key environment variables:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `CYD_BASE_URL` | `http://192.168.2.55` | Base URL of the CYD bridge |
|
||||||
|
| `POLL_INTERVAL` | `5` | Seconds between backend polls of the CYD |
|
||||||
|
| `DATABASE_PATH` | `/data/thermopro.db` | SQLite path (persistent mount) |
|
||||||
|
|
||||||
|
After editing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pct exec 300 -- systemctl daemon-reload
|
||||||
|
pct exec 300 -- systemctl restart thermopro-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reverse proxy / HTTPS (recommended)
|
||||||
|
|
||||||
|
Use Caddy or Nginx on the Proxmox host, another container, or your router to expose the app on a friendly URL. Example Caddyfile:
|
||||||
|
|
||||||
|
```
|
||||||
|
bbq.example.com {
|
||||||
|
reverse_proxy http://<container-ip>:5000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updating the app
|
||||||
|
|
||||||
|
To push a new version:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Re-tar the frontend folder and push it into the container
|
||||||
|
tar -czf /tmp/frontend.tar.gz frontend/
|
||||||
|
pct push 300 /tmp/frontend.tar.gz /tmp/frontend.tar.gz
|
||||||
|
|
||||||
|
# Extract over the install directory and restart
|
||||||
|
pct exec 300 -- bash -c "tar -xzf /tmp/frontend.tar.gz -C /opt && chown -R thermopro:thermopro /opt/frontend && systemctl restart thermopro-frontend"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Persistent data
|
||||||
|
|
||||||
|
By default the SQLite database is written to `/data/thermopro.db` inside the container. Proxmox backups include the container root disk, so the database is preserved. If you prefer to store it on the host, add a bind mount in the Proxmox container config:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On the Proxmox host
|
||||||
|
mkdir -p /var/lib/thermopro-data
|
||||||
|
pct set 300 -mp0 /var/lib/thermopro-data,mp=/data
|
||||||
|
```
|
||||||
|
|
||||||
|
Then restart the container.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run this script inside a freshly created Proxmox LXC container
|
||||||
|
# (Debian 12 or Ubuntu 22.04/24.04) to install and configure the
|
||||||
|
# ThermoPro CYD frontend.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INSTALL_DIR="/opt/frontend"
|
||||||
|
DATA_DIR="/data"
|
||||||
|
SERVICE_NAME="thermopro-frontend"
|
||||||
|
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
echo "This script must be run as root inside the LXC container" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Updating packages ==="
|
||||||
|
apt-get update
|
||||||
|
apt-get upgrade -y
|
||||||
|
|
||||||
|
echo "=== Installing Python tooling ==="
|
||||||
|
apt-get install -y python3 python3-pip git curl
|
||||||
|
|
||||||
|
echo "=== Creating application user ==="
|
||||||
|
useradd -r -s /bin/false -d "${INSTALL_DIR}" thermopro || true
|
||||||
|
|
||||||
|
echo "=== Creating data directory ==="
|
||||||
|
mkdir -p "${DATA_DIR}"
|
||||||
|
chown thermopro:thermopro "${DATA_DIR}"
|
||||||
|
|
||||||
|
if [[ ! -d "${INSTALL_DIR}" ]]; then
|
||||||
|
echo "ERROR: ${INSTALL_DIR} does not exist."
|
||||||
|
echo "Copy the frontend/ folder into the container at ${INSTALL_DIR} first."
|
||||||
|
echo "Example from Proxmox host:"
|
||||||
|
echo " pct push <ctid> /path/to/frontend.tar.gz /tmp/frontend.tar.gz"
|
||||||
|
echo " pct exec <ctid> -- tar -xzf /tmp/frontend.tar.gz -C /opt"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "${INSTALL_DIR}"
|
||||||
|
|
||||||
|
echo "=== Installing Python dependencies into vendor/ ==="
|
||||||
|
pip3 install --target vendor --upgrade -r requirements.txt
|
||||||
|
|
||||||
|
echo "=== Setting ownership ==="
|
||||||
|
chown -R thermopro:thermopro "${INSTALL_DIR}"
|
||||||
|
|
||||||
|
echo "=== Installing systemd service ==="
|
||||||
|
cp "${INSTALL_DIR}/deploy/thermopro-frontend.service" /etc/systemd/system/
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable "${SERVICE_NAME}"
|
||||||
|
systemctl start "${SERVICE_NAME}"
|
||||||
|
|
||||||
|
echo "=== Waiting for service to start ==="
|
||||||
|
sleep 2
|
||||||
|
systemctl status "${SERVICE_NAME}" --no-pager
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Installation complete ==="
|
||||||
|
echo "The frontend should now be running on http://<container-ip>:5000"
|
||||||
|
echo ""
|
||||||
|
echo "To check logs: journalctl -u ${SERVICE_NAME} -f"
|
||||||
|
echo "To edit config: nano /etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
|
echo "Then reload: systemctl daemon-reload && systemctl restart ${SERVICE_NAME}"
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=ThermoPro CYD Frontend
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=thermopro
|
||||||
|
Group=thermopro
|
||||||
|
WorkingDirectory=/opt/thermopro-frontend
|
||||||
|
Environment=CYD_BASE_URL=http://192.168.2.55
|
||||||
|
Environment=POLL_INTERVAL=5
|
||||||
|
Environment=DATABASE_PATH=/data/thermopro.db
|
||||||
|
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
ExecStart=/usr/bin/python3 /opt/thermopro-frontend/app.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
flask>=3.0.0
|
||||||
|
requests>=2.31.0
|
||||||
|
waitress>=3.0.0
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
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;
|
||||||
|
let cardsInitialized = false;
|
||||||
|
|
||||||
|
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 createProbeCards() {
|
||||||
|
const container = document.getElementById('probe-cards');
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'probe-card';
|
||||||
|
card.dataset.probe = i + 1;
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="header">
|
||||||
|
<span class="name">${DEFAULT_PROBE_NAMES[i]}</span>
|
||||||
|
<span class="number">#${i + 1}</span>
|
||||||
|
</div>
|
||||||
|
<div class="target">No target set</div>
|
||||||
|
<div class="temp">---</div>
|
||||||
|
<div class="footer">
|
||||||
|
<span class="status">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}</option>`).join('')}
|
||||||
|
<option value="custom">Custom...</option>
|
||||||
|
</select>
|
||||||
|
<input type="number" class="custom-target" data-probe="${i + 1}" placeholder="Target">
|
||||||
|
<input type="text" class="target-label" data-probe="${i + 1}" placeholder="Label">
|
||||||
|
<button class="save-setpoint" data-probe="${i + 1}">Save</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire up preset selectors and save buttons once.
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
cardsInitialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProbeCards(reading, setpoints) {
|
||||||
|
const probes = reading?.probes || [];
|
||||||
|
const unit = reading?.unit || 'F';
|
||||||
|
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
const card = document.querySelector(`.probe-card[data-probe="${i + 1}"]`);
|
||||||
|
if (!card) continue;
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
card.classList.toggle('connected', probe.connected);
|
||||||
|
card.querySelector('.name').textContent = probe.name || DEFAULT_PROBE_NAMES[i];
|
||||||
|
|
||||||
|
const targetEl = card.querySelector('.target');
|
||||||
|
if (setpoint) {
|
||||||
|
targetEl.textContent = `Target: ${setpoint.label ? setpoint.label + ' ' : ''}${setpoint.target_temp.toFixed(0)}°${unit}`;
|
||||||
|
} else {
|
||||||
|
targetEl.textContent = 'No target set';
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempEl = card.querySelector('.temp');
|
||||||
|
if (probe.connected) {
|
||||||
|
tempEl.classList.remove('disconnected');
|
||||||
|
tempEl.innerHTML = `${probe.temperature.toFixed(1)}<span>°${unit}</span>`;
|
||||||
|
} else {
|
||||||
|
tempEl.classList.add('disconnected');
|
||||||
|
tempEl.textContent = '---';
|
||||||
|
}
|
||||||
|
|
||||||
|
card.querySelector('.status').textContent = probe.connected ? 'Connected' : 'Disconnected';
|
||||||
|
|
||||||
|
// Only update setpoint placeholders, never overwrite values while typing.
|
||||||
|
const customInput = card.querySelector('.custom-target');
|
||||||
|
const labelInput = card.querySelector('.target-label');
|
||||||
|
const presetSelect = card.querySelector('.preset-select');
|
||||||
|
if (!customInput.matches(':focus') && !labelInput.matches(':focus')) {
|
||||||
|
customInput.placeholder = `Target °${unit}`;
|
||||||
|
if (!customInput.value && setpoint) {
|
||||||
|
customInput.value = setpoint.target_temp;
|
||||||
|
}
|
||||||
|
if (!labelInput.value && setpoint) {
|
||||||
|
labelInput.value = setpoint.label || '';
|
||||||
|
}
|
||||||
|
// Update preset option labels with current unit.
|
||||||
|
Array.from(presetSelect.options).forEach((opt, idx) => {
|
||||||
|
if (idx > 0 && idx <= PRESETS.length) {
|
||||||
|
const p = PRESETS[idx - 1];
|
||||||
|
opt.textContent = `${p.label} (${p.temp}°${unit})`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
// Do not call refreshDashboard here; it would clear the input the user just used.
|
||||||
|
// The next periodic poll will pick up the new setpoint from /api/status.
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (!cardsInitialized) {
|
||||||
|
createProbeCards();
|
||||||
|
}
|
||||||
|
updateProbeCards(status.latest_reading, setpoints);
|
||||||
|
|
||||||
|
renderStatusBar(status.latest_reading);
|
||||||
|
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();
|
||||||
|
// First call creates the cards; subsequent calls only update values.
|
||||||
|
await refreshDashboard();
|
||||||
|
await refreshChart();
|
||||||
|
|
||||||
|
setInterval(refreshDashboard, 5000);
|
||||||
|
setInterval(() => { if (activeSession) 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