feat: add PWA frontend for CYD ThermoPro dashboard
Add a Flask backend + SQLite + HTML/CSS/JS PWA that: - Polls the CYD /api/latest endpoint - Stores readings, sessions, and freeform notes in SQLite - Displays probe cards with target setpoints and preset options - Renders temperature history with Chart.js - Serves a PWA manifest and dark-themed dashboard Also update cyd-bridge/README.md with verified bringup instructions.
This commit is contained in:
+155
@@ -0,0 +1,155 @@
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import requests
|
||||
from flask import Flask, jsonify, request, render_template
|
||||
from waitress import serve
|
||||
|
||||
import config
|
||||
import db
|
||||
|
||||
app = Flask(__name__, static_folder="static", template_folder="templates")
|
||||
db.init_db()
|
||||
|
||||
_poller_thread = None
|
||||
_stop_polling = threading.Event()
|
||||
|
||||
|
||||
def fetch_cyd():
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{config.CYD_BASE_URL}/api/latest",
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
app.logger.warning(f"CYD fetch failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def poller_loop():
|
||||
while not _stop_polling.is_set():
|
||||
session = db.get_active_session()
|
||||
if session:
|
||||
payload = fetch_cyd()
|
||||
if payload:
|
||||
db.insert_reading(session["id"], payload)
|
||||
time.sleep(config.POLL_INTERVAL)
|
||||
|
||||
|
||||
def start_poller():
|
||||
global _poller_thread
|
||||
if _poller_thread is None or not _poller_thread.is_alive():
|
||||
_stop_polling.clear()
|
||||
_poller_thread = threading.Thread(target=poller_loop, daemon=True)
|
||||
_poller_thread.start()
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return render_template("index.html")
|
||||
|
||||
|
||||
@app.route("/api/status")
|
||||
def api_status():
|
||||
"""Backend status plus current CYD reading."""
|
||||
session = db.get_active_session()
|
||||
latest = db.get_latest_reading(session["id"]) if session else None
|
||||
latest_proxy = fetch_cyd()
|
||||
return jsonify({
|
||||
"ok": True,
|
||||
"cyd_base_url": config.CYD_BASE_URL,
|
||||
"poll_interval": config.POLL_INTERVAL,
|
||||
"active_session": session,
|
||||
"latest_reading": latest_proxy or latest,
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/cyd/latest")
|
||||
def api_cyd_latest():
|
||||
"""Proxy to the CYD's own /api/latest."""
|
||||
payload = fetch_cyd()
|
||||
if payload is None:
|
||||
return jsonify({"ok": False, "error": "Unable to reach CYD"}), 502
|
||||
return jsonify(payload)
|
||||
|
||||
|
||||
@app.route("/api/sessions", methods=["GET", "POST"])
|
||||
def sessions():
|
||||
if request.method == "POST":
|
||||
data = request.get_json() or {}
|
||||
name = data.get("name", f"Cook {datetime.now(timezone.utc).isoformat()[:19]}")
|
||||
notes = data.get("notes", "")
|
||||
# End any currently active session first.
|
||||
active = db.get_active_session()
|
||||
if active:
|
||||
db.end_session(active["id"])
|
||||
session_id = db.create_session(name, notes)
|
||||
return jsonify({"ok": True, "session_id": session_id}), 201
|
||||
|
||||
with db.get_connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM sessions ORDER BY started_at DESC"
|
||||
).fetchall()
|
||||
return jsonify([dict(row) for row in rows])
|
||||
|
||||
|
||||
@app.route("/api/sessions/<int:session_id>", methods=["GET", "PUT", "DELETE"])
|
||||
def session_detail(session_id):
|
||||
if request.method == "GET":
|
||||
with db.get_connection() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM sessions WHERE id = ?", (session_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return jsonify({"ok": False, "error": "Session not found"}), 404
|
||||
return jsonify(dict(row))
|
||||
|
||||
if request.method == "PUT":
|
||||
data = request.get_json() or {}
|
||||
if "notes" in data:
|
||||
db.update_session_notes(session_id, data["notes"])
|
||||
if data.get("end"):
|
||||
db.end_session(session_id)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
if request.method == "DELETE":
|
||||
with db.get_connection() as conn:
|
||||
conn.execute("DELETE FROM readings WHERE session_id = ?", (session_id,))
|
||||
conn.execute("DELETE FROM setpoints WHERE session_id = ?", (session_id,))
|
||||
conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,))
|
||||
conn.commit()
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.route("/api/readings/<int:session_id>")
|
||||
def readings(session_id):
|
||||
minutes = request.args.get("minutes", config.DEFAULT_CHART_MINUTES, type=int)
|
||||
probe = request.args.get("probe", type=int)
|
||||
data = db.get_readings(session_id, minutes=minutes, probe_number=probe)
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@app.route("/api/setpoints/<int:session_id>", methods=["GET", "POST"])
|
||||
def setpoints(session_id):
|
||||
if request.method == "POST":
|
||||
data = request.get_json() or {}
|
||||
probe_number = data.get("probe_number")
|
||||
label = data.get("label", "")
|
||||
target_temp = data.get("target_temp")
|
||||
if probe_number is None or target_temp is None:
|
||||
return jsonify({"ok": False, "error": "probe_number and target_temp required"}), 422
|
||||
db.set_setpoint(session_id, probe_number, label, float(target_temp))
|
||||
return jsonify({"ok": True})
|
||||
|
||||
return jsonify(db.get_setpoints(session_id))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
start_poller()
|
||||
print(f"Starting server on http://0.0.0.0:5000")
|
||||
print(f"Polling CYD at {config.CYD_BASE_URL} every {config.POLL_INTERVAL}s")
|
||||
serve(app, host="0.0.0.0", port=5000)
|
||||
Reference in New Issue
Block a user