Compare commits

..
10 Commits
Author SHA1 Message Date
Keith Solomon 8a1c74e7d6 fix: update styles for accessibility 2026-07-21 18:37:58 -05:00
Keith Solomon 2a72972b4f feat: implement probe renaming and settings management via API 2026-07-21 16:37:37 -05:00
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
Keith Solomon f03cebf322 fix: CYD BLE connection via service-UUID scan and clean connect flow
- Scan for ThermoPro by advertised service UUID instead of relying only on MAC,
  solving resolvable-private-address issues after phone pairing.
- Defer connect until after the scan is fully stopped to avoid NimBLE race.
- Add connect cleanup, state-name diagnostics, and main-loop retry.
- Switch display config to LGFX_AUTODETECT for correct CYD pinout.
- Verified: CYD connects to ThermoPro and receives temperature notifications.
2026-07-20 18:58:12 -05:00
Keith Solomon 8e007de5a1 feat: add CYD ThermoPro bridge firmware skeleton
Add PlatformIO project for the ESP32-2432S028R that:
- Reads ThermoPro BLE BBQ thermometers via NimBLE-Arduino
- Exposes /api/health, /api/latest, and /api/settings HTTP endpoints
- Serves a captive-portal setup UI and touch dashboard
- Persists WiFi/BLE/settings in NVS via Preferences

Builds cleanly with pio run.
2026-07-20 14:38:35 -05:00
25 changed files with 1920 additions and 301 deletions
+1
View File
@@ -3,3 +3,4 @@
data/* data/*
thermopro-cli/ thermopro-cli/
notes/ notes/
frontend.tgz
+70 -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,13 +57,14 @@ 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
Requires [PlatformIO](https://platformio.org/) installed locally (not available in this cloud environment). Requires [PlatformIO](https://platformio.org/).
```bash ```bash
cd cyd-bridge cd cyd-bridge
@@ -94,17 +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
With the default partition table the firmware uses approximately:
- **RAM:** ~18 % (~58 kB of 320 kB)
- **Flash:** ~96 % (~1.2 MB of 1.3 MB)
There is enough headroom to run, but large future features (e.g. local graphing, OTA) may require switching to a larger app partition or dropping unused NimBLE options.
## Runtime settings
Change settings by POSTing JSON to `/api/settings`:
```bash
curl -X POST http://192.168.2.55/api/settings \
-H "Content-Type: application/json" \
-d '{
"probeNames": ["Brisket", "Ambient", "Probe 3", "Probe 4"],
"unit": "F",
"deviceId": "cyd-smoker"
}'
```
Available fields:
- `wifiSsid`, `wifiPass`
- `thermoproMac`
- `deviceId`
- `probeNames` (array of 4 strings)
- `unit` (`"F"` or `"C"`)
## Development notes ## 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 has not yet been compiled in this environment due to missing PlatformIO tooling; it should be built locally on your development machine before flashing. - 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.
+51 -3
View File
@@ -43,6 +43,7 @@ void ApiServer::begin(uint16_t port) {
server_.on("/api/latest", HTTP_GET, [this]() { handleLatest_(); }); server_.on("/api/latest", HTTP_GET, [this]() { handleLatest_(); });
server_.on("/api/settings", HTTP_GET, [this]() { handleGetSettings_(); }); server_.on("/api/settings", HTTP_GET, [this]() { handleGetSettings_(); });
server_.on("/api/settings", HTTP_POST, [this]() { handlePostSettings_(); }); server_.on("/api/settings", HTTP_POST, [this]() { handlePostSettings_(); });
server_.on("/setup", HTTP_POST, [this]() { handleSetupForm_(); });
server_.onNotFound([this]() { handleNotFound_(); }); server_.onNotFound([this]() { handleNotFound_(); });
server_.begin(port_); server_.begin(port_);
@@ -116,8 +117,12 @@ void ApiServer::fillLatestJson_(JsonObject& obj) {
} }
} }
// Timestamps are relative for the device; we add ISO-like strings where possible. // Timestamps are relative for the device; we add device millis where possible.
obj["readingTime"] = reading.lastUpdateMillis > 0 ? String(reading.lastUpdateMillis) : nullptr; if (reading.lastUpdateMillis > 0) {
obj["readingTime"] = String(reading.lastUpdateMillis);
} else {
obj["readingTime"] = nullptr;
}
obj["bridgeTime"] = String(millis()); obj["bridgeTime"] = String(millis());
obj["source"] = "cyd-bridge"; obj["source"] = "cyd-bridge";
} }
@@ -220,11 +225,54 @@ void ApiServer::handleNotFound_() {
server_.send(404, "application/json", "{\"ok\":false,\"error\":\"Not found\"}"); server_.send(404, "application/json", "{\"ok\":false,\"error\":\"Not found\"}");
} }
void ApiServer::handleSetupForm_() {
String ssid = server_.arg("ssid");
String pass = server_.arg("pass");
String mac = server_.arg("mac");
if (ssid.length() == 0 || mac.length() == 0) {
server_.send(400, "text/html", "<h1>Error</h1><p>SSID and MAC are required.</p><a href='/'>Go back</a>");
return;
}
if (!SettingsManager::isValidMac(mac)) {
server_.send(422, "text/html", "<h1>Error</h1><p>Invalid MAC address format.</p><a href='/'>Go back</a>");
return;
}
settings_.setWifiSsid(ssid);
settings_.setWifiPass(pass);
settings_.setThermoproMac(mac);
settings_.save();
String html = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Saved</title>
<style>body { font-family: system-ui, sans-serif; margin: 1rem; }</style>
</head>
<body>
<h1>Settings saved</h1>
<p>The CYD will reboot and connect to your WiFi network.</p>
<p>If it fails to connect, it will reopen this setup network.</p>
</body>
</html>
)rawliteral";
server_.send(200, "text/html", html);
if (rebootCallback_) {
rebootCallback_();
}
}
String ApiServer::buildSetupPage_() { String ApiServer::buildSetupPage_() {
String page = htmlHead; String page = htmlHead;
page += "<h1>CYD ThermoPro Setup</h1>\n"; page += "<h1>CYD ThermoPro Setup</h1>\n";
page += "<p>Connect this CYD to your WiFi network and enter your ThermoPro details.</p>\n"; page += "<p>Connect this CYD to your WiFi network and enter your ThermoPro details.</p>\n";
page += "<form method=\"POST\" action=\"/api/settings\" onsubmit=\"return submitForm()\">\n"; page += "<form method=\"POST\" action=\"/setup\" onsubmit=\"return submitForm()\">\n";
page += R"rawliteral( page += R"rawliteral(
<label>WiFi SSID</label> <label>WiFi SSID</label>
<input type="text" name="wifiSsid" id="wifiSsid" required> <input type="text" name="wifiSsid" id="wifiSsid" required>
+2
View File
@@ -2,6 +2,7 @@
#include "settings/SettingsManager.hpp" #include "settings/SettingsManager.hpp"
#include "ble/ThermoproBLE.hpp" #include "ble/ThermoproBLE.hpp"
#include <ArduinoJson.h>
#include <WebServer.h> #include <WebServer.h>
namespace cyd { namespace cyd {
@@ -38,6 +39,7 @@ private:
void handleLatest_(); void handleLatest_();
void handleGetSettings_(); void handleGetSettings_();
void handlePostSettings_(); void handlePostSettings_();
void handleSetupForm_();
void handleNotFound_(); void handleNotFound_();
String buildSetupPage_(); String buildSetupPage_();
+128 -57
View File
@@ -5,6 +5,12 @@ namespace cyd {
ThermoproBLE* ThermoproBLE::instance_ = nullptr; ThermoproBLE* ThermoproBLE::instance_ = nullptr;
const NimBLEUUID ThermoproBLE::kServiceUuid = NimBLEUUID("1086FFF0-3343-4817-8BB2-B32206336CE8");
const NimBLEUUID ThermoproBLE::kNotifyUuid = NimBLEUUID("1086FFF2-3343-4817-8BB2-B32206336CE8");
const NimBLEUUID ThermoproBLE::kWriteUuid = NimBLEUUID("1086FFF1-3343-4817-8BB2-B32206336CE8");
constexpr uint8_t ThermoproBLE::kHandshake[];
ThermoproBLE::ThermoproBLE() { ThermoproBLE::ThermoproBLE() {
instance_ = this; instance_ = this;
} }
@@ -13,17 +19,30 @@ void ThermoproBLE::setAddress(const String& mac) {
address_ = mac; address_ = mac;
} }
bool ThermoproBLE::init(const String& deviceName) { const char* ThermoproBLE::getStateName() const {
if (address_.length() == 0) { switch (state_) {
Serial.println("ThermoproBLE: no MAC address configured"); case State::Idle: return "idle";
return false; case State::Scanning: return "scanning";
case State::Connecting: return "connecting";
case State::Discovering: return "discovering";
case State::EnablingNotify: return "enabling-notify";
case State::SendingHandshake: return "sending-handshake";
case State::Running: return "running";
case State::Reconnecting: return "reconnecting";
} }
return "unknown";
}
bool ThermoproBLE::init(const String& deviceName) {
NimBLEDevice::init(std::string(deviceName.c_str())); NimBLEDevice::init(std::string(deviceName.c_str()));
// We do not need a security/bonded pairing for the ThermoPro protocol. // We do not need a security/bonded pairing for the ThermoPro protocol.
NimBLEDevice::setSecurityAuth(false, false, false); NimBLEDevice::setSecurityAuth(false, false, false);
Serial.printf("ThermoproBLE: BLE initialized as '%s', target %s\n", deviceName.c_str(), address_.c_str()); if (address_.length() > 0) {
Serial.printf("ThermoproBLE: BLE initialized as '%s', target MAC %s\n", deviceName.c_str(), address_.c_str());
} else {
Serial.printf("ThermoproBLE: BLE initialized as '%s', will scan for ThermoPro service UUID\n", deviceName.c_str());
}
state_ = State::Idle; state_ = State::Idle;
return true; return true;
} }
@@ -51,16 +70,56 @@ void ThermoproBLE::update() {
break; break;
case State::Scanning: case State::Scanning:
// If we found a candidate during the scan, stop scanning and connect.
if (pendingConnectFound_) {
if (millis() - pendingConnectSeenMillis_ >= 200) {
NimBLEDevice::getScan()->stop();
state_ = State::Connecting;
stateStartMillis_ = millis();
connectToAddress_(pendingConnectAddress_);
}
break;
}
if (millis() - stateStartMillis_ > 25000) {
Serial.println("ThermoproBLE: scan timeout, forcing reconnect");
NimBLEDevice::getScan()->stop();
setConnected_(false);
startReconnectDelay_();
}
break;
case State::Connecting: case State::Connecting:
if (client_ && client_->isConnected()) {
Serial.println("ThermoproBLE: connected to server");
if (!setupConnection_(client_)) {
Serial.println("ThermoproBLE: setup failed");
cleanupClient_();
setConnected_(false);
startReconnectDelay_();
break;
}
state_ = State::Running;
stateStartMillis_ = millis();
lastActivityMillis_ = millis();
reconnectFailures_ = 0;
reconnectDelayMs_ = 5000;
break;
}
if (millis() - stateStartMillis_ > 25000) {
Serial.println("ThermoproBLE: connect timeout");
cleanupClient_();
setConnected_(false);
startReconnectDelay_();
}
break;
case State::Discovering: case State::Discovering:
case State::EnablingNotify: case State::EnablingNotify:
case State::SendingHandshake: case State::SendingHandshake:
// Time out stuck states and restart the reconnect cycle.
if (millis() - stateStartMillis_ > 15000) { if (millis() - stateStartMillis_ > 15000) {
Serial.printf("ThermoproBLE: state timeout in state %d, forcing reconnect\n", static_cast<int>(state_)); Serial.printf("ThermoproBLE: setup state timeout in state %d\n", static_cast<int>(state_));
if (client_ && client_->isConnected()) { cleanupClient_();
client_->disconnect();
}
setConnected_(false); setConnected_(false);
startReconnectDelay_(); startReconnectDelay_();
} }
@@ -69,14 +128,14 @@ void ThermoproBLE::update() {
case State::Running: { case State::Running: {
if (!client_ || !client_->isConnected()) { if (!client_ || !client_->isConnected()) {
Serial.println("ThermoproBLE: connection lost, reconnecting"); Serial.println("ThermoproBLE: connection lost, reconnecting");
cleanupClient_();
setConnected_(false); setConnected_(false);
startReconnectDelay_(); startReconnectDelay_();
break; break;
} }
// If we haven't seen any notification in 30 seconds, reconnect.
if (millis() - lastActivityMillis_ > 30000) { if (millis() - lastActivityMillis_ > 30000) {
Serial.println("ThermoproBLE: no notifications for 30s, reconnecting"); Serial.println("ThermoproBLE: no notifications for 30s, reconnecting");
client_->disconnect(); cleanupClient_();
setConnected_(false); setConnected_(false);
startReconnectDelay_(); startReconnectDelay_();
} }
@@ -93,9 +152,7 @@ void ThermoproBLE::update() {
} }
void ThermoproBLE::disconnect() { void ThermoproBLE::disconnect() {
if (client_ && client_->isConnected()) { cleanupClient_();
client_->disconnect();
}
NimBLEDevice::deinit(true); NimBLEDevice::deinit(true);
setConnected_(false); setConnected_(false);
state_ = State::Idle; state_ = State::Idle;
@@ -105,22 +162,34 @@ void ThermoproBLE::ScanCallbacks::onResult(NimBLEAdvertisedDevice* device) {
if (ThermoproBLE::instance_ == nullptr) { if (ThermoproBLE::instance_ == nullptr) {
return; return;
} }
auto* self = ThermoproBLE::instance_;
String addr(device->getAddress().toString().c_str()); String addr(device->getAddress().toString().c_str());
String name(device->getName().c_str()); String name(device->getName().c_str());
bool matchesAddress = addr.equalsIgnoreCase(ThermoproBLE::instance_->address_); bool matchesAddress = !self->address_.isEmpty()
&& addr.equalsIgnoreCase(self->address_);
bool matchesName = isThermoproDevice_(name); bool matchesName = isThermoproDevice_(name);
bool matchesService = device->isAdvertisingService(ThermoproBLE::kServiceUuid);
if (matchesAddress) { // Verbose scan logging during bringup. Print every device address + name.
Serial.printf("ThermoproBLE: found target device %s (%s)\n", name.c_str(), addr.c_str()); Serial.printf("[ble-scan] %s rssi=%d name='%s' service_match=%s\n",
NimBLEDevice::getScan()->stop(); addr.c_str(), device->getRSSI(), name.c_str(),
connectToServer_(device, ThermoproBLE::instance_); matchesService ? "yes" : "no");
} else if (matchesName) {
Serial.printf("ThermoproBLE: discovered ThermoPro %s (%s)\n", name.c_str(), addr.c_str()); if (matchesAddress || matchesName || matchesService) {
Serial.printf("ThermoproBLE: discovered candidate %s (%s), addrType=%d\n",
name.c_str(), addr.c_str(), device->getAddress().getType());
// Record the address for a connect initiated from update() once the
// scan has been stopped cleanly. Don't connect from inside the callback.
self->pendingConnectAddress_ = device->getAddress();
self->pendingConnectFound_ = true;
self->pendingConnectSeenMillis_ = millis();
} }
} }
bool ThermoproBLE::scanAndConnect_() { bool ThermoproBLE::scanAndConnect_() {
pendingConnectFound_ = false;
NimBLEScan* scanner = NimBLEDevice::getScan(); NimBLEScan* scanner = NimBLEDevice::getScan();
if (!callbacksRegistered_) { if (!callbacksRegistered_) {
scanner->setAdvertisedDeviceCallbacks(&scanCallbacks_, false); scanner->setAdvertisedDeviceCallbacks(&scanCallbacks_, false);
@@ -133,46 +202,48 @@ bool ThermoproBLE::scanAndConnect_() {
return true; return true;
} }
bool ThermoproBLE::connectToServer_(NimBLEAdvertisedDevice* device, void* userData) { bool ThermoproBLE::connectToAddress_(const NimBLEAddress& address) {
auto* self = static_cast<ThermoproBLE*>(userData); state_ = State::Connecting;
if (!self) { stateStartMillis_ = millis();
bool addrTypeRandom = (address.getType() == BLE_ADDR_RANDOM);
Serial.printf("ThermoproBLE: connecting to %s, addrType=%s\n",
address.toString().c_str(), addrTypeRandom ? "random" : "public");
// The scan must already be stopped before this is called.
delay(300);
client_ = NimBLEDevice::createClient();
// Conservative connection parameters.
client_->setConnectionParams(12, 12, 0, 160);
client_->setConnectTimeout(20);
bool ok = client_->connect(address, false);
if (!ok) {
Serial.println("ThermoproBLE: connect call rejected immediately");
cleanupClient_();
setConnected_(false);
startReconnectDelay_();
return false; return false;
} }
self->state_ = State::Connecting; Serial.println("ThermoproBLE: connect call accepted");
self->stateStartMillis_ = millis();
self->client_ = NimBLEDevice::createClient();
self->client_->setConnectionParams(12, 12, 0, 51);
self->client_->setConnectTimeout(10);
if (!self->client_->connect(device)) {
Serial.println("ThermoproBLE: connect failed");
NimBLEDevice::deleteClient(self->client_);
self->client_ = nullptr;
self->setConnected_(false);
self->startReconnectDelay_();
return false;
}
Serial.println("ThermoproBLE: connected to server");
if (!self->setupConnection_(self->client_)) {
Serial.println("ThermoproBLE: setup failed");
NimBLEDevice::deleteClient(self->client_);
self->client_ = nullptr;
self->setConnected_(false);
self->startReconnectDelay_();
return false;
}
self->state_ = State::Running;
self->stateStartMillis_ = millis();
self->lastActivityMillis_ = millis();
self->reconnectFailures_ = 0;
self->reconnectDelayMs_ = 5000;
return true; return true;
} }
void ThermoproBLE::cleanupClient_() {
if (client_) {
if (client_->isConnected()) {
client_->disconnect();
delay(100);
}
NimBLEDevice::deleteClient(client_);
client_ = nullptr;
}
notifyChar_ = nullptr;
writeChar_ = nullptr;
}
bool ThermoproBLE::setupConnection_(NimBLEClient* client) { bool ThermoproBLE::setupConnection_(NimBLEClient* client) {
state_ = State::Discovering; state_ = State::Discovering;
stateStartMillis_ = millis(); stateStartMillis_ = millis();
@@ -203,7 +274,7 @@ bool ThermoproBLE::setupConnection_(NimBLEClient* client) {
state_ = State::SendingHandshake; state_ = State::SendingHandshake;
stateStartMillis_ = millis(); stateStartMillis_ = millis();
if (!writeChar_->write(const_cast<uint8_t*>(kHandshake), sizeof(kHandshake), true)) { if (!writeChar_->writeValue(const_cast<uint8_t*>(kHandshake), sizeof(kHandshake), true)) {
Serial.println("ThermoproBLE: handshake write failed"); Serial.println("ThermoproBLE: handshake write failed");
return false; return false;
} }
+14 -4
View File
@@ -34,14 +34,17 @@ public:
// same core/loop in Arduino by default. // same core/loop in Arduino by default.
ThermoproReading getReading() const { return reading_; } ThermoproReading getReading() const { return reading_; }
// Human-readable current connection state name for diagnostics.
const char* getStateName() const;
// Register a callback invoked whenever the reading state changes. // Register a callback invoked whenever the reading state changes.
void onStateChange(StateChangeCallback cb) { stateCallback_ = cb; } void onStateChange(StateChangeCallback cb) { stateCallback_ = cb; }
private: private:
// BLE UUIDs (ThermoPro custom service) // BLE UUIDs (ThermoPro custom service)
static inline const NimBLEUUID kServiceUuid = NimBLEUUID("1086FFF0-3343-4817-8BB2-B32206336CE8"); static const NimBLEUUID kServiceUuid;
static inline const NimBLEUUID kNotifyUuid = NimBLEUUID("1086FFF2-3343-4817-8BB2-B32206336CE8"); static const NimBLEUUID kNotifyUuid;
static inline const NimBLEUUID kWriteUuid = NimBLEUUID("1086FFF1-3343-4817-8BB2-B32206336CE8"); static const NimBLEUUID kWriteUuid;
// Static handshake bytes captured from the Android app. // Static handshake bytes captured from the Android app.
static constexpr uint8_t kHandshake[] = { 0x01, 0x09, 0x8a, 0x7a, 0x13, 0xb7, 0x3e, 0xd6, 0x8b, 0x67, 0xc2, 0xa0 }; static constexpr uint8_t kHandshake[] = { 0x01, 0x09, 0x8a, 0x7a, 0x13, 0xb7, 0x3e, 0xd6, 0x8b, 0x67, 0xc2, 0xa0 };
@@ -72,9 +75,16 @@ private:
unsigned long reconnectDelayMs_ = 5000; unsigned long reconnectDelayMs_ = 5000;
bool callbacksRegistered_ = false; bool callbacksRegistered_ = false;
// Address discovered during a scan that we want to connect to.
// The callback records it; update() initiates the connect after stopping the scan.
NimBLEAddress pendingConnectAddress_;
bool pendingConnectFound_ = false;
unsigned long pendingConnectSeenMillis_ = 0;
bool scanAndConnect_(); bool scanAndConnect_();
static bool connectToServer_(NimBLEAdvertisedDevice* device, void* userData); bool connectToAddress_(const NimBLEAddress& address);
bool setupConnection_(NimBLEClient* client); bool setupConnection_(NimBLEClient* client);
void cleanupClient_();
void setConnected_(bool connected); void setConnected_(bool connected);
void startReconnectDelay_(); void startReconnectDelay_();
+12 -2
View File
@@ -9,8 +9,18 @@ Dashboard::Dashboard(CYD_Display& display, SettingsManager& settings, ThermoproB
} }
void Dashboard::begin() { void Dashboard::begin() {
display_.init(); Serial.println("Dashboard: initializing display with LGFX_AUTODETECT...");
display_.initBacklight();
if (!display_.init()) {
Serial.println("ERROR: display init failed");
} else {
Serial.println("Dashboard: display init OK");
}
// Backlight on GPIO 21, active HIGH.
pinMode(GPIO_NUM_21, OUTPUT);
digitalWrite(GPIO_NUM_21, HIGH);
display_.setRotation(0); // Portrait 240x320 display_.setRotation(0); // Portrait 240x320
display_.fillScreen(TFT_BLACK); display_.fillScreen(TFT_BLACK);
display_.setTextDatum(middle_center); display_.setTextDatum(middle_center);
+11 -78
View File
@@ -1,90 +1,23 @@
#pragma once #pragma once
// LovyanGFX configuration for the ESP32-2432S028R "Cheap Yellow Display". // LovyanGFX configuration for the ESP32-2432S028R "Cheap Yellow Display".
// This board uses:
// - ESP32-WROOM-32
// - 320x240 TFT driven by ILI9341
// - Resistive touch panel on XPT2046 (shared SPI bus with display)
// //
// Pinout (ESP32-2432S028R variant): // We use LovyanGFX's built-in autodetection for the CYD board. The autodetect
// SDO/MISO GPIO 19 // table knows the correct SPI buses, pins, panel driver, and touch controller
// LED GPIO 21 (backlight, active high) // for the common ILI9341 and ST7789 variants.
// SCK GPIO 18 //
// SDI/MOSI GPIO 23 // Reference:
// DC GPIO 2 // https://github.com/embedded-kiddie/Arduino-CYD-2432S028R
// RESET GPIO 4 // https://github.com/lovyan03/LovyanGFX/issues/693
// CS GPIO 15
// TOUCH CS GPIO 5
// TOUCH IRQ GPIO - (not used by default)
#define LGFX_USE_V1 #define LGFX_USE_V1
#define LGFX_AUTODETECT
#include <LovyanGFX.hpp> #include <LovyanGFX.hpp>
#include <LGFX_AUTODETECT.hpp>
namespace cyd { namespace cyd {
class CYD_Display : public lgfx::LGFX_Device { // With LGFX_AUTODETECT we can simply use the stock LGFX class.
public: using CYD_Display = lgfx::LGFX;
CYD_Display() {
// Panel configuration
auto cfg = _panel_instance.config();
cfg.pin_cs = GPIO_NUM_15;
cfg.pin_dc = GPIO_NUM_2;
cfg.pin_rst = GPIO_NUM_4;
cfg.bus_shared = true;
cfg.panel_width = 240;
cfg.panel_height = 320;
cfg.offset_x = 0;
cfg.offset_y = 0;
cfg.offset_rotation = 0;
cfg.dummy_read_pixel = 8;
cfg.dummy_read_bits = 1;
cfg.readable = true;
cfg.invert = false;
cfg.rgb_order = false;
cfg.dlen_16bit = false;
_panel_instance.config(cfg);
// SPI bus configuration
auto bus_cfg = _bus_instance.config();
bus_cfg.spi_host = HSPI_HOST;
bus_cfg.spi_mode = 0;
bus_cfg.freq_write = 40000000;
bus_cfg.freq_read = 16000000;
bus_cfg.pin_sclk = GPIO_NUM_18;
bus_cfg.pin_mosi = GPIO_NUM_23;
bus_cfg.pin_miso = GPIO_NUM_19;
bus_cfg.pin_dc = GPIO_NUM_2;
_bus_instance.config(bus_cfg);
_panel_instance.setBus(&_bus_instance);
// Touch panel configuration (XPT2046)
auto touch_cfg = _touch_instance.config();
touch_cfg.x_min = 0;
touch_cfg.x_max = 239;
touch_cfg.y_min = 0;
touch_cfg.y_max = 319;
touch_cfg.pin_int = -1;
touch_cfg.bus_shared = true;
touch_cfg.offset_rotation = 0;
touch_cfg.spi_host = HSPI_HOST;
touch_cfg.freq = 1000000;
touch_cfg.pin_sclk = GPIO_NUM_18;
touch_cfg.pin_mosi = GPIO_NUM_23;
touch_cfg.pin_miso = GPIO_NUM_19;
touch_cfg.pin_cs = GPIO_NUM_5;
_touch_instance.config(touch_cfg);
_panel_instance.setTouch(&_touch_instance);
}
void initBacklight() {
pinMode(GPIO_NUM_21, OUTPUT);
digitalWrite(GPIO_NUM_21, HIGH);
}
private:
lgfx::Bus_SPI _bus_instance;
lgfx::Panel_ILI9341 _panel_instance;
lgfx::Touch_XPT2046 _touch_instance;
};
} // namespace cyd } // namespace cyd
+20 -4
View File
@@ -35,6 +35,7 @@ enum class AppState {
AppState appState = AppState::Init; AppState appState = AppState::Init;
unsigned long lastHeapLog = 0; unsigned long lastHeapLog = 0;
unsigned long lastDashboardUpdate = 0; unsigned long lastDashboardUpdate = 0;
unsigned long lastBleStartAttempt = 0;
void requestReboot() { void requestReboot() {
delay(500); delay(500);
@@ -102,11 +103,12 @@ void loop() {
// Heartbeat logging // Heartbeat logging
if (millis() - lastHeapLog >= 10000) { if (millis() - lastHeapLog >= 10000) {
lastHeapLog = millis(); lastHeapLog = millis();
Serial.printf("[heartbeat] state=%d free_heap=%d wifi=%s ble=%s\n", Serial.printf("[heartbeat] state=%d free_heap=%d wifi=%s ble=%s/%s\n",
static_cast<int>(appState), static_cast<int>(appState),
ESP.getFreeHeap(), ESP.getFreeHeap(),
wifi.isConnected() ? "connected" : "not-connected", wifi.isConnected() ? "connected" : "not-connected",
bleClient.getReading().connected ? "connected" : "disconnected"); bleClient.getReading().connected ? "connected" : "disconnected",
bleClient.getStateName());
} }
switch (appState) { switch (appState) {
@@ -144,13 +146,27 @@ void loop() {
break; break;
} }
// Start BLE if not already trying.
ThermoproReading reading = bleClient.getReading();
if (!settings.hasThermoproMac()) { if (!settings.hasThermoproMac()) {
dashboard.showMessage("No MAC configured", "Open setup at", wifi.getIpAddress()); dashboard.showMessage("No MAC configured", "Open setup at", wifi.getIpAddress());
break; break;
} }
// Proactively (re)start BLE connect if it is not doing anything.
if (!bleClient.getReading().connected) {
const char* state = bleClient.getStateName();
if (strcmp(state, "idle") == 0 || strcmp(state, "reconnecting") == 0) {
if (millis() - lastBleStartAttempt >= 5000) {
lastBleStartAttempt = millis();
Serial.printf("Main: requesting BLE connect to %s (state=%s)\n",
settings.getThermoproMac().c_str(), state);
bleClient.setAddress(settings.getThermoproMac());
if (!bleClient.startConnect()) {
Serial.println("Main: BLE startConnect returned false");
}
}
}
}
// Dashboard refresh // Dashboard refresh
if (millis() - lastDashboardUpdate >= 1000) { if (millis() - lastDashboardUpdate >= 1000) {
lastDashboardUpdate = millis(); lastDashboardUpdate = millis();
+3 -1
View File
@@ -1,6 +1,7 @@
#include "SettingsManager.hpp" #include "SettingsManager.hpp"
#include <ArduinoJson.h> #include <ArduinoJson.h>
#include <Preferences.h> #include <Preferences.h>
#include <vector>
namespace cyd { namespace cyd {
@@ -75,7 +76,8 @@ bool SettingsManager::load() {
settings_.apiEndpoint = doc["apiEndpoint"] | ""; settings_.apiEndpoint = doc["apiEndpoint"] | "";
settings_.apiToken = doc["apiToken"] | ""; settings_.apiToken = doc["apiToken"] | "";
settings_.pollIntervalSec = doc["pollIntervalSec"] | 15; settings_.pollIntervalSec = doc["pollIntervalSec"] | 15;
settings_.unit = doc["unit"] | "F"; String unit = doc["unit"] | "F";
settings_.unit = unit.length() > 0 ? unit.charAt(0) : 'F';
settings_.deviceId = doc["deviceId"] | "cyd-smoker"; settings_.deviceId = doc["deviceId"] | "cyd-smoker";
JsonArray names = doc["probeNames"].as<JsonArray>(); JsonArray names = doc["probeNames"].as<JsonArray>();
+5 -100
View File
@@ -1,42 +1,7 @@
#include "WiFiManager.hpp" #include "WiFiManager.hpp"
#include <ArduinoJson.h>
namespace cyd { namespace cyd {
namespace {
constexpr const char* setupHtml = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CYD ThermoPro Setup</title>
<style>
body { font-family: system-ui, sans-serif; margin: 1rem; max-width: 500px; }
label { display: block; margin-top: 1rem; font-weight: bold; }
input { width: 100%; padding: 0.5rem; box-sizing: border-box; }
button { margin-top: 1.5rem; padding: 0.6rem 1.2rem; font-size: 1rem; }
.note { color: #555; font-size: 0.9rem; margin-top: 0.25rem; }
</style>
</head>
<body>
<h1>CYD ThermoPro Setup</h1>
<p>Enter your WiFi credentials and ThermoPro MAC address, then save.</p>
<form method="POST" action="/setup">
<label>WiFi Network (SSID)</label>
<input type="text" name="ssid" required>
<label>WiFi Password</label>
<input type="password" name="pass" required>
<label>ThermoPro MAC</label>
<input type="text" name="mac" placeholder="C9:48:1D:B1:E1:E7" required>
<div class="note">Format: AA:BB:CC:DD:EE:FF. Use a BLE scanner if you don't know it.</div>
<button type="submit">Save & Reboot</button>
</form>
</body>
</html>
)rawliteral";
} // namespace
WiFiManager::WiFiManager(SettingsManager& settings) : settings_(settings) { WiFiManager::WiFiManager(SettingsManager& settings) : settings_(settings) {
} }
@@ -86,9 +51,8 @@ void WiFiManager::update() {
case State::AccessPoint: case State::AccessPoint:
case State::FallbackToAp: case State::FallbackToAp:
if (portalRunning_) { if (apRunning_) {
dnsServer_.processNextRequest(); dnsServer_.processNextRequest();
portalServer_.handleClient();
} }
break; break;
@@ -144,13 +108,10 @@ void WiFiManager::startAccessPoint_() {
IPAddress ip = WiFi.softAPIP(); IPAddress ip = WiFi.softAPIP();
Serial.printf("WiFiManager: AP '%s' started at %s\n", kApSsid, ip.toString().c_str()); Serial.printf("WiFiManager: AP '%s' started at %s\n", kApSsid, ip.toString().c_str());
// DNS redirect: any hostname requested while connected to the AP resolves
// to the CYD. The HTTP server in ApiServer serves the setup page at /.
dnsServer_.start(53, "*", ip); dnsServer_.start(53, "*", ip);
apRunning_ = true;
portalServer_.on("/", HTTP_GET, [this]() { handlePortalRoot_(); });
portalServer_.on("/setup", HTTP_POST, [this]() { handlePortalSubmit_(); });
portalServer_.onNotFound([this]() { handlePortalNotFound_(); });
portalServer_.begin();
portalRunning_ = true;
if (modeCallback_) { if (modeCallback_) {
modeCallback_(true); modeCallback_(true);
@@ -158,65 +119,9 @@ void WiFiManager::startAccessPoint_() {
} }
void WiFiManager::stopAccessPoint_() { void WiFiManager::stopAccessPoint_() {
portalRunning_ = false; apRunning_ = false;
portalServer_.close();
dnsServer_.stop(); dnsServer_.stop();
WiFi.softAPdisconnect(true); WiFi.softAPdisconnect(true);
} }
void WiFiManager::handlePortalRoot_() {
portalServer_.send(200, "text/html", setupHtml);
}
void WiFiManager::handlePortalSubmit_() {
String ssid = portalServer_.arg("ssid");
String pass = portalServer_.arg("pass");
String mac = portalServer_.arg("mac");
if (ssid.length() == 0 || mac.length() == 0) {
portalServer_.send(400, "text/html", "<h1>Error</h1><p>SSID and MAC are required.</p><a href='/'>Go back</a>");
return;
}
if (!SettingsManager::isValidMac(mac)) {
portalServer_.send(422, "text/html", "<h1>Error</h1><p>Invalid MAC address format.</p><a href='/'>Go back</a>");
return;
}
settings_.setWifiSsid(ssid);
settings_.setWifiPass(pass);
settings_.setThermoproMac(mac);
settings_.save();
String html = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Saved</title>
<style>body { font-family: system-ui, sans-serif; margin: 1rem; }</style>
</head>
<body>
<h1>Settings saved</h1>
<p>The CYD will reboot and connect to your WiFi network.</p>
<p>If it fails to connect, it will reopen this setup network.</p>
</body>
</html>
)rawliteral";
portalServer_.send(200, "text/html", html);
delay(500);
ESP.restart();
}
void WiFiManager::handlePortalNotFound_() {
redirectToPortal_(portalServer_);
}
void WiFiManager::redirectToPortal_(WebServer& server) {
server.sendHeader("Location", "http://" + String(kSetupDomain) + "/", true);
server.send(302, "text/plain", "Redirecting to setup portal...");
}
} // namespace cyd } // namespace cyd
+3 -10
View File
@@ -3,7 +3,6 @@
#include "settings/SettingsManager.hpp" #include "settings/SettingsManager.hpp"
#include <WiFi.h> #include <WiFi.h>
#include <DNSServer.h> #include <DNSServer.h>
#include <WebServer.h>
#include <functional> #include <functional>
namespace cyd { namespace cyd {
@@ -52,23 +51,17 @@ private:
unsigned long connectStartMillis_ = 0; unsigned long connectStartMillis_ = 0;
static constexpr unsigned long kConnectTimeoutMs = 20000; static constexpr unsigned long kConnectTimeoutMs = 20000;
// Captive portal // Captive portal DNS redirect. The actual HTTP server lives in ApiServer
// so we don't bind port 80 in two places.
DNSServer dnsServer_; DNSServer dnsServer_;
WebServer portalServer_{80}; bool apRunning_ = false;
bool portalRunning_ = false;
unsigned long lastPortalClientMillis_ = 0;
static constexpr const char* kApSsid = "CYD-ThermoPro-Setup"; static constexpr const char* kApSsid = "CYD-ThermoPro-Setup";
static constexpr const char* kApPass = ""; static constexpr const char* kApPass = "";
static constexpr const char* kSetupDomain = "cyd.setup";
void startStation_(); void startStation_();
void startAccessPoint_(); void startAccessPoint_();
void stopAccessPoint_(); void stopAccessPoint_();
void handlePortalRoot_();
void handlePortalSubmit_();
void handlePortalNotFound_();
static void redirectToPortal_(WebServer& server);
}; };
} // namespace cyd } // namespace cyd
+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
```
+207
View File
@@ -0,0 +1,207 @@
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)
def _fetch_cyd_settings():
try:
resp = requests.get(f"{config.CYD_BASE_URL}/api/settings", timeout=10)
resp.raise_for_status()
return resp.json()
except Exception as e:
app.logger.warning(f"CYD settings fetch failed: {e}")
return None
@app.route("/api/probes", methods=["GET", "PUT"])
def probes():
if request.method == "GET":
settings = _fetch_cyd_settings()
if settings is None:
# Fall back to the latest reading so the UI still has something to show.
latest = fetch_cyd()
names = []
if latest:
names = [p.get("name", f"Probe {i + 1}") for i, p in enumerate(latest.get("probes", []))]
if not names:
names = [f"Probe {i + 1}" for i in range(4)]
return jsonify({"ok": True, "names": names, "source": "fallback"}), 200
names = settings.get("probeNames", [f"Probe {i + 1}" for i in range(4)])
return jsonify({"ok": True, "names": names, "source": "cyd"})
# PUT — forward to CYD's /api/settings with just the probeNames field.
data = request.get_json() or {}
names = data.get("names", [])
if not isinstance(names, list) or len(names) != 4 or not all(isinstance(n, str) for n in names):
return jsonify({"ok": False, "error": "names must be an array of 4 strings"}), 422
try:
resp = requests.post(
f"{config.CYD_BASE_URL}/api/settings",
json={"probeNames": names},
timeout=10,
)
resp.raise_for_status()
except Exception as e:
app.logger.warning(f"CYD settings update failed: {e}")
return jsonify({"ok": False, "error": f"Unable to update CYD: {e}"}), 502
return jsonify({"ok": True, "names": names})
@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
+454
View File
@@ -0,0 +1,454 @@
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 viewedSession = null;
let allSessions = [];
let latestReading = null;
let setpoints = [];
let chartInstance = null;
let cardsInitialized = false;
let probeNames = [...DEFAULT_PROBE_NAMES];
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" data-probe="${i + 1}" title="Click to rename">${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);
});
});
// Click the probe name to rename it (writes through to the CYD).
document.querySelectorAll('.probe-card .name').forEach(el => {
el.addEventListener('click', () => beginRename(el));
});
cardsInitialized = true;
}
async function beginRename(el) {
if (el.querySelector('input')) return; // already editing
const probe = parseInt(el.dataset.probe, 10);
const current = el.textContent;
el.innerHTML = '';
const input = document.createElement('input');
input.type = 'text';
input.className = 'rename-input';
input.value = current;
input.maxLength = 24;
el.appendChild(input);
input.focus();
input.select();
let done = false;
const finish = async (commit) => {
if (done) return;
done = true;
const newName = (commit ? input.value.trim() : current) || current;
el.textContent = newName;
if (commit && newName !== current) {
await saveProbeName(probe, newName);
}
};
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
if (e.key === 'Escape') { e.preventDefault(); input.value = current; input.blur(); }
});
input.addEventListener('blur', () => finish(true));
}
async function saveProbeName(probeNumber, name) {
const next = [...probeNames];
next[probeNumber - 1] = name;
try {
const result = await api('/api/probes', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ names: next }),
});
if (result.names) probeNames = result.names;
await refreshDashboard(); // pick up the new name in the latest reading
} catch (e) {
console.error('Rename failed:', e);
alert(`Failed to rename probe: ${e.message}`);
}
}
async function loadProbeNames() {
try {
const result = await api('/api/probes');
if (result.names) probeNames = result.names;
} catch (e) {
console.warn('Probe names fetch failed, using defaults:', e);
}
}
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: probeNames[i] || DEFAULT_PROBE_NAMES[i], temperature: null, connected: false };
const setpoint = setpoints.find(s => s.probe_number === (i + 1));
card.classList.toggle('connected', probe.connected);
// Don't overwrite the name while the user is editing it.
const nameEl = card.querySelector('.name');
if (!nameEl.querySelector('input')) {
nameEl.textContent = probe.name || probeNames[i] || DEFAULT_PROBE_NAMES[i];
}
const targetEl = card.querySelector('.target');
if (setpoint) {
const labelText = setpoint.label ? setpoint.label : `Probe ${i + 1}`;
targetEl.textContent = `${labelText}: ${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 (!viewedSession) return;
await api(`/api/setpoints/${viewedSession.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() {
allSessions = await api('/api/sessions');
activeSession = allSessions.find(s => !s.ended_at) || null;
// Default the viewed session to the active one, or the most recent past one.
if (!viewedSession || !allSessions.find(s => s.id === viewedSession.id)) {
viewedSession = activeSession || allSessions[0] || 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. Start one above to begin recording.';
endBtn.disabled = true;
}
renderSessionList();
}
function renderSessionList() {
const listEl = document.getElementById('past-sessions');
if (!listEl) return;
const past = allSessions.filter(s => s.ended_at);
if (past.length === 0) {
listEl.innerHTML = '<p class="muted">No past sessions yet.</p>';
return;
}
listEl.innerHTML = past.map(s => {
const isViewed = viewedSession && viewedSession.id === s.id;
const started = formatTime(s.started_at);
const ended = formatTime(s.ended_at);
return `
<button type="button" class="session-row ${isViewed ? 'viewed' : ''}" data-id="${s.id}">
<span class="session-name">${escapeHtml(s.name)}</span>
<span class="session-times">${started} ${ended}</span>
</button>
`;
}).join('');
listEl.querySelectorAll('.session-row').forEach(btn => {
btn.addEventListener('click', () => {
const id = parseInt(btn.dataset.id, 10);
viewedSession = allSessions.find(s => s.id === id) || null;
renderSessionList();
loadSetpoints();
refreshChart();
updateChartHeader();
});
});
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
}
function updateChartHeader() {
const headerEl = document.getElementById('chart-session-label');
if (!headerEl) return;
if (!viewedSession) {
headerEl.textContent = 'No session';
return;
}
if (activeSession && viewedSession.id === activeSession.id) {
headerEl.textContent = `Live — ${viewedSession.name}`;
} else {
headerEl.textContent = `Viewing past — ${viewedSession.name}`;
}
}
async function loadSetpoints() {
if (!viewedSession) {
setpoints = [];
updateChartHeader();
return;
}
setpoints = await api(`/api/setpoints/${viewedSession.id}`);
updateChartHeader();
}
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 (!chartInstance) return;
if (!viewedSession) {
chartInstance.data.labels = [];
chartInstance.data.datasets = [];
chartInstance.update();
return;
}
const range = parseInt(document.getElementById('chart-range').value, 10);
const data = await api(`/api/readings/${viewedSession.id}?minutes=${range}`);
const labels = data.map(r => formatTime(r.received_at));
const datasets = [1, 2, 3, 4].map((num, idx) => ({
label: probeNames[idx] || (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;
const result = 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();
// Switch the chart to the newly created active session.
viewedSession = allSessions.find(s => s.id === result.session_id) || activeSession;
await loadSetpoints();
await refreshChart();
renderSessionList();
});
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();
// If we were viewing the just-ended session, keep viewing it (now a past session).
await loadSetpoints();
await refreshChart();
});
document.getElementById('refresh-chart-btn').addEventListener('click', refreshChart);
document.getElementById('chart-range').addEventListener('change', refreshChart);
async function init() {
initChart();
await loadProbeNames();
await loadSessions();
await loadSetpoints();
// First call creates the cards; subsequent calls only update values.
await refreshDashboard();
await refreshChart();
setInterval(refreshDashboard, 5000);
setInterval(() => { if (viewedSession) 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"
}
]
}
+315
View File
@@ -0,0 +1,315 @@
:root {
--bg: #121212;
--surface: #1e1e1e;
--text: #f0f0f0;
--muted: #a0a0a0;
--accent: #ff6b35;
--button: #d6420c;
--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(--button);
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;
cursor: text;
padding: 0 0.15rem;
border-radius: 4px;
}
.probe-card .name:hover {
background: #2a2a2a;
}
.probe-card .name .rename-input {
font: inherit;
font-weight: bold;
color: var(--text);
background: #2a2a2a;
border: 1px solid var(--accent);
border-radius: 4px;
padding: 0.1rem 0.3rem;
width: 10ch;
max-width: 100%;
}
.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: var(--button);
border: 1px solid var(--button);
border-radius: 4px;
color: white;
cursor: pointer;
font-weight: bold;
padding: 0.4rem 0.7rem;
}
.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(--button);
border: none;
color: white;
cursor: pointer;
font-weight: bold;
}
.chart-container {
position: relative;
width: 100%;
height: 320px;
}
.chart-container canvas {
width: 100% !important;
height: 100% !important;
display: block;
}
.muted { color: var(--muted); }
#chart-session-label {
font-size: 0.85rem;
font-weight: normal;
margin-left: 0.5rem;
}
#past-sessions {
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 280px;
overflow-y: auto;
}
#past-sessions p {
margin: 0.5rem 0;
}
.session-row {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.15rem;
background: #2a2a2a;
border: 1px solid #333;
border-left: 4px solid var(--muted);
color: var(--text);
text-align: left;
padding: 0.6rem 0.8rem;
border-radius: 6px;
cursor: pointer;
font: inherit;
}
.session-row:hover {
border-left-color: var(--accent);
background: #333;
}
.session-row.viewed {
border-left-color: var(--accent);
background: #2f2520;
}
.session-row .session-name {
font-weight: 600;
}
.session-row .session-times {
font-size: 0.8rem;
color: white;
}
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; }
}
+71
View File
@@ -0,0 +1,71 @@
<!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 Active Session</button>
</div>
<div class="panel">
<h2>Past Sessions</h2>
<div id="past-sessions"><p class="muted">Loading…</p></div>
</div>
</section>
<section id="probe-cards">
<!-- Cards injected by JS -->
</section>
<section id="charts">
<div class="panel">
<h2>Temperature History <span id="chart-session-label" class="muted"></span></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>
<div class="chart-container">
<canvas id="temp-chart"></canvas>
</div>
</div>
</section>
</main>
<footer>
<div id="last-updated">--</div>
</footer>
<script src="{{ url_for('static', filename='app.js') }}"></script>
</body>
</html>