Compare commits

..
6 Commits
Author SHA1 Message Date
Keith Solomon 1bd1ddc20e fix: correct installation directory path in Proxmox LXC setup script 2026-07-20 22:41:16 -05:00
Keith Solomon 6d103b0426 chore: remove stray frontend.tgz and ignore it 2026-07-20 21:26:09 -05:00
Keith Solomon 7ef6af1cf3 fix: stop probe card re-render from clearing form inputs
- Split renderProbeCards into createProbeCards (once) and updateProbeCards
  (mutates only the values: name, target, temp, status, dot color).
- Skip refreshing setpoint input values while a field has focus so typing
  is not interrupted by the 5s poll.
- Stop calling refreshDashboard after saveSetpoint to avoid clobbering input.
2026-07-20 21:23:10 -05:00
Keith Solomon 5fda27794d feat: add Proxmox LXC deployment files
- Add deploy/proxmox-lxc-setup.sh to configure the frontend inside a Debian/Ubuntu LXC.
- Add deploy/thermopro-frontend.service systemd unit.
- Add deploy/README.md with Proxmox CT creation, file transfer, setup, proxy, and update steps.
- Update frontend README.md to point to the deployment guide.
2026-07-20 20:37:02 -05:00
Keith Solomon c80de43f55 fix: run frontend without virtualenv
- Modify app.py to add vendor/ to sys.path so pip --target works.
- Add frontend/.gitignore to exclude vendor/, __pycache__, thermopro.db.
- Update README with pip --target instructions.
2026-07-20 20:28:10 -05:00
Keith Solomon 9e32b1b179 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.
2026-07-20 20:14:48 -05:00
15 changed files with 1379 additions and 42 deletions
+1
View File
@@ -3,3 +3,4 @@
data/* data/*
thermopro-cli/ thermopro-cli/
notes/ notes/
frontend.tgz
+61 -42
View File
@@ -5,51 +5,37 @@ Firmware for an ESP32 "Cheap Yellow Display" (ESP32-2432S028R) that reads temper
## Hardware ## Hardware
- **Board:** ESP32-2432S028R - **Board:** ESP32-2432S028R
- 320×240 TFT (ILI9341 driver) - 320×240 TFT (ILI9341 driver, detected via `LGFX_AUTODETECT`)
- Resistive touch panel (XPT2046) - Resistive touch panel (XPT2046)
- ESP32-WROOM-32 with 4 MB flash - ESP32-WROOM-32 with 4 MB flash
- **Thermometer:** ThermoPro TP25 / TP25W / TP920 / TP930 / TP960 - **Thermometer:** ThermoPro TP25 / TP25W / TP920 / TP930 / TP960
## Firmware architecture ## What it does
``` - Connects the CYD to your WiFi network.
┌─────────────────────────────────────────┐ - Scans for a ThermoPro thermometer by its advertised service UUID (`1086FFF0-3343-4817-8BB2-B32206336CE8`) and connects over BLE.
│ CYD (ESP32-2432S028R) │ - Decodes battery level, connection status, and up to four probe temperatures.
│ │ - Runs a small HTTP API that any frontend (web app, mobile app, Home Assistant, etc.) can consume.
│ ┌─────────────┐ ┌──────────────┐ │ - Shows a simple touch dashboard with probe temperatures and connection status.
│ │ ThermoPro │ │ HTTP API │ │
│ │ BLE client │─────▶│ Web server │ │
│ └─────────────┘ └──────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────────────────────┐ │
│ │ SettingsManager (NVS/Preferences)│ │
│ └──────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────┐ │
│ │ Touch UI: status + setup screens │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────┘
```
## API endpoints ## API endpoints
- `GET /api/health` — device health and connectivity status. | Method | Path | Description |
- `GET /api/latest` — latest ThermoPro reading. |--------|------|-------------|
- `GET /api/settings` — current settings (excluding WiFi password). | `GET` | `/api/health` | Device health and connectivity status. |
- `POST /api/settings` — update settings (JSON body). | `GET` | `/api/latest` | Latest ThermoPro reading. |
- `GET /` — setup page when in AP mode; status page when in STA mode. | `GET` | `/api/settings` | Current settings (WiFi password omitted). |
| `POST` | `/api/settings` | Update settings (JSON body). |
| `GET` | `/` | Setup page in AP mode; status page in STA mode. |
## Endpoints response examples ### `GET /api/latest` example
### `GET /api/latest`
```json ```json
{ {
"ok": true, "ok": true,
"deviceId": "cyd-smoker", "deviceId": "cyd-smoker",
"device": "ThermoPro TP930", "device": "ThermoPro TP930",
"mac": "C9:48:1D:B1:E1:E7", "mac": "c9:48:1d:b1:e1:e7",
"connected": true, "connected": true,
"battery": 90, "battery": 90,
"unit": "F", "unit": "F",
@@ -60,8 +46,9 @@ Firmware for an ESP32 "Cheap Yellow Display" (ESP32-2432S028R) that reads temper
{ "id": 3, "name": "Probe 3", "temperature": null, "connected": false }, { "id": 3, "name": "Probe 3", "temperature": null, "connected": false },
{ "id": 4, "name": "Probe 4", "temperature": null, "connected": false } { "id": 4, "name": "Probe 4", "temperature": null, "connected": false }
], ],
"readingTime": "2026-07-19T12:00:00+00:00", "readingTime": "12345",
"bridgeTime": "2026-07-19T12:00:01+00:00" "bridgeTime": "12346",
"source": "cyd-bridge"
} }
``` ```
@@ -70,9 +57,10 @@ Firmware for an ESP32 "Cheap Yellow Display" (ESP32-2432S028R) that reads temper
1. **First boot:** the CYD starts a captive portal AP named `CYD-ThermoPro-Setup`. 1. **First boot:** the CYD starts a captive portal AP named `CYD-ThermoPro-Setup`.
2. Connect to the AP with a phone or laptop and open `http://192.168.4.1`. 2. Connect to the AP with a phone or laptop and open `http://192.168.4.1`.
3. Enter your home WiFi SSID and password, plus the ThermoPro MAC address. 3. Enter your home WiFi SSID and password, plus the ThermoPro MAC address.
4. The device connects to WiFi, stores the credentials, and reboots into normal mode. 4. The device saves the credentials and reboots into normal mode.
5. Find the CYD's local IP from your router or the serial monitor, then open `http://<cyd-ip>/api/latest`.
Runtime settings (endpoint, token, probe names, units, polling interval) can be changed through the same web UI at the device's local IP or via the JSON API. **Note on MAC addresses:** If you previously paired the thermometer with the official ThermoPro app, the app may show a *resolved* public-looking MAC while the device actually advertises with a *random* BLE address. The CYD scans by the ThermoPro service UUID, so it will find the device regardless. Enter whatever address the app displays; the UUID match takes priority.
## Building and flashing ## Building and flashing
@@ -94,26 +82,57 @@ If you have not installed PlatformIO yet:
pip install platformio pip install platformio
``` ```
## First-time hardware check ### First-time upload
Before relying on the dashboard, verify the pinout matches your exact board revision: If the CYD is not detected:
1. Open `src/display/DisplayConfig.hpp`. 1. Try both USB ports; only one is wired to the UART chip.
2. Confirm `TFT_CS`, `TFT_DC`, `TFT_RST`, `TFT_BL`, `TOUCH_CS`, and the SPI pins match the silkscreen or schematic of your ESP32-2432S028R. 2. Install the USB-to-serial driver if Windows shows an unknown device:
3. If the screen stays white or touch is inverted, adjust `cfg.offset_rotation` in `DisplayConfig.hpp` or `setRotation()` in `Dashboard::begin()`. - CH340/CH343: [WCH driver](https://www.wch-ic.com/downloads/CH343SER_ZIP.html)
- CP2102: [Silicon Labs driver](https://www.silabs.com/developers/usb-to-uart-bridge-vcp-drivers)
3. If the upload still fails, hold the **BOOT** button, press and release **RST**, then release **BOOT**, and retry.
## Memory usage ## Memory usage
With the default partition table the firmware uses approximately: With the default partition table the firmware uses approximately:
- **RAM:** ~18 % (~58 kB of 320 kB) - **RAM:** ~18 % (~58 kB of 320 kB)
- **Flash:** ~92 % (~1.2 MB of 1.3 MB) - **Flash:** ~96 % (~1.2 MB of 1.3 MB)
There is enough headroom to run, but large future features (e.g. local graphing, OTA) may require switching to a larger app partition or dropping unused NimBLE options. There is enough headroom to run, but large future features (e.g. local graphing, OTA) may require switching to a larger app partition or dropping unused NimBLE options.
## Runtime settings
Change settings by POSTing JSON to `/api/settings`:
```bash
curl -X POST http://192.168.2.55/api/settings \
-H "Content-Type: application/json" \
-d '{
"probeNames": ["Brisket", "Ambient", "Probe 3", "Probe 4"],
"unit": "F",
"deviceId": "cyd-smoker"
}'
```
Available fields:
- `wifiSsid`, `wifiPass`
- `thermoproMac`
- `deviceId`
- `probeNames` (array of 4 strings)
- `unit` (`"F"` or `"C"`)
## Development notes ## Development notes
- LovyanGFX driver/pin configuration lives in `src/display/DisplayConfig.hpp` and is tuned for the ESP32-2432S028R variant. - Display pinout is handled by `LGFX_AUTODETECT`, which detects the common `Sunton_2432S028` (ILI9341) layout.
- BLE uses `NimBLE-Arduino` to leave enough RAM for the web server and UI. - BLE uses `NimBLE-Arduino` to leave enough RAM for the web server and UI.
- The CYD connects to the ThermoPro by scanning for the advertised service UUID. This is more reliable than MAC matching when the thermometer uses BLE privacy/random addresses.
- All user settings are stored in ESP32 NVS via `Preferences`. - All user settings are stored in ESP32 NVS via `Preferences`.
- The firmware compiles successfully in PlatformIO; the remaining work is on-device testing and pinout verification. - Serial log runs at 115200 baud for diagnostics.
## Known issues / next steps
- `/api/latest` timestamps are device-relative millis; wall-clock time would require NTP.
- Touch dashboard is minimal; a nicer layout, settings menu, and local alerts are planned.
- Flash usage is tight due to `LGFX_AUTODETECT`; a hardcoded board config would free space.
+6
View File
@@ -0,0 +1,6 @@
.venv
__pycache__/
*.pyc
thermopro.db
vendor/
.DS_Store
+77
View File
@@ -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
View File
@@ -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)
+13
View File
@@ -0,0 +1,13 @@
import os
# Base URL of the CYD ThermoPro Bridge.
CYD_BASE_URL = os.environ.get("CYD_BASE_URL", "http://192.168.2.55")
# How often (seconds) the backend polls the CYD for new readings.
POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "5"))
# SQLite database path.
DATABASE_PATH = os.environ.get("DATABASE_PATH", "thermopro.db")
# Number of minutes of history shown by default on the dashboard chart.
DEFAULT_CHART_MINUTES = int(os.environ.get("DEFAULT_CHART_MINUTES", "60"))
+214
View File
@@ -0,0 +1,214 @@
import sqlite3
from contextlib import contextmanager
from pathlib import Path
from datetime import datetime, timezone
import config
INIT_SQL = """
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
ended_at TEXT,
notes TEXT
);
CREATE TABLE IF NOT EXISTS readings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
received_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
device_id TEXT,
mac TEXT,
ble_connected INTEGER,
battery INTEGER,
unit TEXT,
probe1_temp REAL,
probe1_connected INTEGER,
probe2_temp REAL,
probe2_connected INTEGER,
probe3_temp REAL,
probe3_connected INTEGER,
probe4_temp REAL,
probe4_connected INTEGER,
FOREIGN KEY (session_id) REFERENCES sessions(id)
);
CREATE TABLE IF NOT EXISTS setpoints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
probe_number INTEGER NOT NULL,
label TEXT,
target_temp REAL NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
FOREIGN KEY (session_id) REFERENCES sessions(id),
UNIQUE(session_id, probe_number)
);
CREATE INDEX IF NOT EXISTS idx_readings_session_time
ON readings(session_id, received_at);
"""
def init_db():
Path(config.DATABASE_PATH).parent.mkdir(parents=True, exist_ok=True)
with get_connection() as conn:
conn.executescript(INIT_SQL)
conn.commit()
@contextmanager
def get_connection():
conn = sqlite3.connect(config.DATABASE_PATH)
conn.row_factory = sqlite3.Row
try:
yield conn
finally:
conn.close()
def utc_now_iso():
return datetime.now(timezone.utc).isoformat()
def create_session(name: str, notes: str = ""):
with get_connection() as conn:
cursor = conn.execute(
"INSERT INTO sessions (name, notes) VALUES (?, ?)",
(name, notes),
)
conn.commit()
return cursor.lastrowid
def get_active_session():
with get_connection() as conn:
row = conn.execute(
"SELECT * FROM sessions WHERE ended_at IS NULL ORDER BY started_at DESC LIMIT 1"
).fetchone()
return dict(row) if row else None
def end_session(session_id: int):
with get_connection() as conn:
conn.execute(
"UPDATE sessions SET ended_at = ? WHERE id = ?",
(utc_now_iso(), session_id),
)
conn.commit()
def update_session_notes(session_id: int, notes: str):
with get_connection() as conn:
conn.execute(
"UPDATE sessions SET notes = ? WHERE id = ?",
(notes, session_id),
)
conn.commit()
def insert_reading(session_id: int, payload: dict):
probes = payload.get("probes", [])
temps = []
connected = []
for i in range(4):
if i < len(probes) and probes[i].get("connected"):
temps.append(probes[i].get("temperature"))
connected.append(1)
else:
temps.append(None)
connected.append(0)
with get_connection() as conn:
conn.execute(
"""
INSERT INTO readings (
session_id, received_at, device_id, mac, ble_connected, battery, unit,
probe1_temp, probe1_connected,
probe2_temp, probe2_connected,
probe3_temp, probe3_connected,
probe4_temp, probe4_connected
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
session_id,
utc_now_iso(),
payload.get("deviceId"),
payload.get("mac"),
1 if payload.get("connected") else 0,
payload.get("battery"),
payload.get("unit"),
temps[0], connected[0],
temps[1], connected[1],
temps[2], connected[2],
temps[3], connected[3],
),
)
conn.commit()
def get_latest_reading(session_id: int):
with get_connection() as conn:
row = conn.execute(
"SELECT * FROM readings WHERE session_id = ? ORDER BY received_at DESC LIMIT 1",
(session_id,),
).fetchone()
return dict(row) if row else None
def get_readings(session_id: int, minutes: int = 60, probe_number: int = None):
with get_connection() as conn:
if probe_number is not None:
column = f"probe{probe_number}_temp"
rows = conn.execute(
f"""
SELECT received_at, {column} AS temp, probe{probe_number}_connected AS connected
FROM readings
WHERE session_id = ?
AND received_at > datetime('now', '-{minutes} minutes')
AND {column} IS NOT NULL
ORDER BY received_at ASC
""",
(session_id,),
).fetchall()
else:
rows = conn.execute(
f"""
SELECT received_at,
probe1_temp, probe1_connected,
probe2_temp, probe2_connected,
probe3_temp, probe3_connected,
probe4_temp, probe4_connected
FROM readings
WHERE session_id = ?
AND received_at > datetime('now', '-{minutes} minutes')
ORDER BY received_at ASC
""",
(session_id,),
).fetchall()
return [dict(row) for row in rows]
def set_setpoint(session_id: int, probe_number: int, label: str, target_temp: float):
with get_connection() as conn:
conn.execute(
"""
INSERT INTO setpoints (session_id, probe_number, label, target_temp)
VALUES (?, ?, ?, ?)
ON CONFLICT(session_id, probe_number) DO UPDATE SET
label = excluded.label,
target_temp = excluded.target_temp,
created_at = excluded.created_at
""",
(session_id, probe_number, label, target_temp),
)
conn.commit()
def get_setpoints(session_id: int):
with get_connection() as conn:
rows = conn.execute(
"SELECT * FROM setpoints WHERE session_id = ? ORDER BY probe_number",
(session_id,),
).fetchall()
return [dict(row) for row in rows]
+136
View File
@@ -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.
+64
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
flask>=3.0.0
requests>=2.31.0
waitress>=3.0.0
+313
View File
@@ -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();
+20
View File
@@ -0,0 +1,20 @@
{
"name": "ThermoPro CYD Dashboard",
"short_name": "CYD BBQ",
"start_url": "/",
"display": "standalone",
"background_color": "#1a1a1a",
"theme_color": "#ff6b35",
"icons": [
{
"src": "icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+225
View File
@@ -0,0 +1,225 @@
:root {
--bg: #121212;
--surface: #1e1e1e;
--text: #f0f0f0;
--muted: #a0a0a0;
--accent: #ff6b35;
--ok: #4caf50;
--warn: #ff9800;
--danger: #f44336;
--radius: 12px;
--shadow: 0 2px 8px rgba(0,0,0,0.4);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
header {
background: var(--surface);
padding: 1rem;
box-shadow: var(--shadow);
position: sticky;
top: 0;
z-index: 10;
}
header h1 {
margin: 0 0 0.5rem;
font-size: 1.5rem;
color: var(--accent);
}
#status-bar {
display: flex;
gap: 1rem;
font-size: 0.9rem;
color: var(--muted);
}
#status-bar .ok { color: var(--ok); }
#status-bar .warn { color: var(--warn); }
#status-bar .danger { color: var(--danger); }
main {
padding: 1rem;
max-width: 1200px;
margin: 0 auto;
}
.panel {
background: var(--surface);
border-radius: var(--radius);
padding: 1rem;
box-shadow: var(--shadow);
margin-bottom: 1rem;
}
.panel h2 {
margin-top: 0;
font-size: 1.1rem;
color: var(--accent);
}
#session-controls form {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.75rem;
}
#session-controls input,
#session-controls textarea,
#session-controls select {
background: #2a2a2a;
border: 1px solid #333;
color: var(--text);
padding: 0.6rem;
border-radius: 6px;
font: inherit;
}
#session-controls textarea {
min-height: 80px;
resize: vertical;
}
#session-controls button {
background: var(--accent);
color: #fff;
border: none;
padding: 0.7rem 1rem;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
}
#session-controls button:disabled {
background: #444;
cursor: not-allowed;
}
#end-session-btn {
margin-top: 0.5rem;
width: 100%;
}
#probe-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
}
.probe-card {
background: var(--surface);
border-radius: var(--radius);
padding: 1rem;
box-shadow: var(--shadow);
border-left: 4px solid var(--muted);
transition: border-color 0.2s;
}
.probe-card.connected {
border-left-color: var(--ok);
}
.probe-card .header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.probe-card .name {
font-weight: bold;
font-size: 1.1rem;
}
.probe-card .target {
font-size: 0.85rem;
color: var(--muted);
}
.probe-card .temp {
font-size: 2.5rem;
font-weight: 300;
margin: 0.5rem 0;
}
.probe-card .temp.disconnected {
color: var(--muted);
font-size: 1.8rem;
}
.probe-card .footer {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
color: var(--muted);
}
.setpoint-editor {
margin-top: 0.75rem;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.setpoint-editor input {
flex: 1;
min-width: 80px;
background: #2a2a2a;
border: 1px solid #333;
color: var(--text);
padding: 0.4rem;
border-radius: 4px;
}
.setpoint-editor button {
background: #333;
color: var(--text);
border: 1px solid #444;
padding: 0.4rem 0.7rem;
border-radius: 4px;
cursor: pointer;
}
.chart-controls {
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: 1rem;
}
.chart-controls select,
.chart-controls button {
background: #2a2a2a;
border: 1px solid #333;
color: var(--text);
padding: 0.5rem;
border-radius: 6px;
}
.chart-controls button {
background: var(--accent);
border: none;
cursor: pointer;
}
footer {
text-align: center;
padding: 1rem;
color: var(--muted);
font-size: 0.85rem;
}
@media (max-width: 600px) {
#status-bar { flex-direction: column; gap: 0.25rem; }
.probe-card .temp { font-size: 2rem; }
}
+64
View File
@@ -0,0 +1,64 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ThermoPro CYD Dashboard</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
</head>
<body>
<header>
<h1>ThermoPro CYD Dashboard</h1>
<div id="status-bar">
<span id="cyd-status">Checking CYD...</span>
<span id="battery-status">--</span>
<span id="signal-status">--</span>
</div>
</header>
<main>
<section id="session-controls">
<div class="panel">
<h2>Session</h2>
<div id="active-session">No active session.</div>
<form id="new-session-form">
<input type="text" id="session-name" placeholder="Session name" required>
<textarea id="session-notes" placeholder="Notes (freeform)"></textarea>
<button type="submit">Start New Session</button>
</form>
<button id="end-session-btn" disabled>End Session</button>
</div>
</section>
<section id="probe-cards">
<!-- Cards injected by JS -->
</section>
<section id="charts">
<div class="panel">
<h2>Temperature History</h2>
<div class="chart-controls">
<label>Range:
<select id="chart-range">
<option value="15">15 min</option>
<option value="60" selected>1 hour</option>
<option value="240">4 hours</option>
<option value="720">12 hours</option>
</select>
</label>
<button id="refresh-chart-btn">Refresh Chart</button>
</div>
<canvas id="temp-chart"></canvas>
</div>
</section>
</main>
<footer>
<div id="last-updated">--</div>
</footer>
<script src="{{ url_for('static', filename='app.js') }}"></script>
</body>
</html>