Compare commits
10
Commits
9a3a8f9368
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a1c74e7d6 | ||
|
|
2a72972b4f | ||
|
|
1bd1ddc20e | ||
|
|
6d103b0426 | ||
|
|
7ef6af1cf3 | ||
|
|
5fda27794d | ||
|
|
c80de43f55 | ||
|
|
9e32b1b179 | ||
|
|
f03cebf322 | ||
|
|
8e007de5a1 |
@@ -3,3 +3,4 @@
|
|||||||
data/*
|
data/*
|
||||||
thermopro-cli/
|
thermopro-cli/
|
||||||
notes/
|
notes/
|
||||||
|
frontend.tgz
|
||||||
|
|||||||
+70
-42
@@ -5,51 +5,37 @@ Firmware for an ESP32 "Cheap Yellow Display" (ESP32-2432S028R) that reads temper
|
|||||||
## Hardware
|
## Hardware
|
||||||
|
|
||||||
- **Board:** ESP32-2432S028R
|
- **Board:** ESP32-2432S028R
|
||||||
- 320×240 TFT (ILI9341 driver)
|
- 320×240 TFT (ILI9341 driver, detected via `LGFX_AUTODETECT`)
|
||||||
- Resistive touch panel (XPT2046)
|
- Resistive touch panel (XPT2046)
|
||||||
- ESP32-WROOM-32 with 4 MB flash
|
- ESP32-WROOM-32 with 4 MB flash
|
||||||
- **Thermometer:** ThermoPro TP25 / TP25W / TP920 / TP930 / TP960
|
- **Thermometer:** ThermoPro TP25 / TP25W / TP920 / TP930 / TP960
|
||||||
|
|
||||||
## Firmware architecture
|
## What it does
|
||||||
|
|
||||||
```
|
- Connects the CYD to your WiFi network.
|
||||||
┌─────────────────────────────────────────┐
|
- Scans for a ThermoPro thermometer by its advertised service UUID (`1086FFF0-3343-4817-8BB2-B32206336CE8`) and connects over BLE.
|
||||||
│ CYD (ESP32-2432S028R) │
|
- Decodes battery level, connection status, and up to four probe temperatures.
|
||||||
│ │
|
- Runs a small HTTP API that any frontend (web app, mobile app, Home Assistant, etc.) can consume.
|
||||||
│ ┌─────────────┐ ┌──────────────┐ │
|
- Shows a simple touch dashboard with probe temperatures and connection status.
|
||||||
│ │ ThermoPro │ │ HTTP API │ │
|
|
||||||
│ │ BLE client │─────▶│ Web server │ │
|
|
||||||
│ └─────────────┘ └──────────────┘ │
|
|
||||||
│ │ │ │
|
|
||||||
│ ▼ ▼ │
|
|
||||||
│ ┌──────────────────────────────────┐ │
|
|
||||||
│ │ SettingsManager (NVS/Preferences)│ │
|
|
||||||
│ └──────────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌──────────────────────────────────┐ │
|
|
||||||
│ │ Touch UI: status + setup screens │ │
|
|
||||||
│ └──────────────────────────────────┘ │
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## API endpoints
|
## API endpoints
|
||||||
|
|
||||||
- `GET /api/health` — device health and connectivity status.
|
| Method | Path | Description |
|
||||||
- `GET /api/latest` — latest ThermoPro reading.
|
|--------|------|-------------|
|
||||||
- `GET /api/settings` — current settings (excluding WiFi password).
|
| `GET` | `/api/health` | Device health and connectivity status. |
|
||||||
- `POST /api/settings` — update settings (JSON body).
|
| `GET` | `/api/latest` | Latest ThermoPro reading. |
|
||||||
- `GET /` — setup page when in AP mode; status page when in STA mode.
|
| `GET` | `/api/settings` | Current settings (WiFi password omitted). |
|
||||||
|
| `POST` | `/api/settings` | Update settings (JSON body). |
|
||||||
|
| `GET` | `/` | Setup page in AP mode; status page in STA mode. |
|
||||||
|
|
||||||
## Endpoints response examples
|
### `GET /api/latest` example
|
||||||
|
|
||||||
### `GET /api/latest`
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"ok": true,
|
"ok": true,
|
||||||
"deviceId": "cyd-smoker",
|
"deviceId": "cyd-smoker",
|
||||||
"device": "ThermoPro TP930",
|
"device": "ThermoPro TP930",
|
||||||
"mac": "C9:48:1D:B1:E1:E7",
|
"mac": "c9:48:1d:b1:e1:e7",
|
||||||
"connected": true,
|
"connected": true,
|
||||||
"battery": 90,
|
"battery": 90,
|
||||||
"unit": "F",
|
"unit": "F",
|
||||||
@@ -60,8 +46,9 @@ Firmware for an ESP32 "Cheap Yellow Display" (ESP32-2432S028R) that reads temper
|
|||||||
{ "id": 3, "name": "Probe 3", "temperature": null, "connected": false },
|
{ "id": 3, "name": "Probe 3", "temperature": null, "connected": false },
|
||||||
{ "id": 4, "name": "Probe 4", "temperature": null, "connected": false }
|
{ "id": 4, "name": "Probe 4", "temperature": null, "connected": false }
|
||||||
],
|
],
|
||||||
"readingTime": "2026-07-19T12:00:00+00:00",
|
"readingTime": "12345",
|
||||||
"bridgeTime": "2026-07-19T12:00:01+00:00"
|
"bridgeTime": "12346",
|
||||||
|
"source": "cyd-bridge"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -70,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.
|
||||||
|
|||||||
@@ -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,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_();
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_();
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.venv
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
thermopro.db
|
||||||
|
vendor/
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# ThermoPro CYD Frontend
|
||||||
|
|
||||||
|
A Progressive Web App (PWA) that consumes the CYD ThermoPro Bridge API, stores session history in SQLite, and displays probe temperatures with charts, target setpoints, and freeform notes.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
LXC/container
|
||||||
|
├── Python backend (Flask)
|
||||||
|
│ ├── Polls CYD /api/latest periodically
|
||||||
|
│ ├── Stores readings + sessions + notes in SQLite
|
||||||
|
│ └── Serves the static PWA and API
|
||||||
|
└── PWA (HTML/CSS/JS)
|
||||||
|
├── Dashboard with probe cards
|
||||||
|
├── Temperature history charts
|
||||||
|
├── Target setpoint editor
|
||||||
|
└── Session notes
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.10+ (system install or any working Python interpreter)
|
||||||
|
- SQLite (stdlib)
|
||||||
|
|
||||||
|
Dependencies are installed locally into `frontend/vendor/` so no virtual environment is required.
|
||||||
|
|
||||||
|
## Installing dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
pip install --target vendor -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
On Windows with PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd "C:\Users\ksolo\Projects\House Projects\ThermoPro CYD\frontend"
|
||||||
|
pip install --target vendor -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running locally
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
CYD_BASE_URL=http://192.168.2.55 python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
On Windows with PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd "C:\Users\ksolo\Projects\House Projects\ThermoPro CYD\frontend"
|
||||||
|
$env:CYD_BASE_URL = "http://192.168.2.55"
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open `http://localhost:5000`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Environment variable | Default | Description |
|
||||||
|
|----------------------|---------|-------------|
|
||||||
|
| `CYD_BASE_URL` | `http://192.168.2.55` | Base URL of the CYD bridge |
|
||||||
|
| `POLL_INTERVAL` | `5` | Seconds between backend polls |
|
||||||
|
| `DATABASE_PATH` | `thermopro.db` | SQLite database file |
|
||||||
|
| `DEFAULT_CHART_MINUTES` | `60` | Default chart time range |
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
See `deploy/README.md` for step-by-step Proxmox LXC deployment instructions.
|
||||||
|
|
||||||
|
For a quick local test:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
pip install --target vendor -r requirements.txt
|
||||||
|
CYD_BASE_URL=http://192.168.2.55 python app.py
|
||||||
|
```
|
||||||
+207
@@ -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)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
# Base URL of the CYD ThermoPro Bridge.
|
||||||
|
CYD_BASE_URL = os.environ.get("CYD_BASE_URL", "http://192.168.2.55")
|
||||||
|
|
||||||
|
# How often (seconds) the backend polls the CYD for new readings.
|
||||||
|
POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "5"))
|
||||||
|
|
||||||
|
# SQLite database path.
|
||||||
|
DATABASE_PATH = os.environ.get("DATABASE_PATH", "thermopro.db")
|
||||||
|
|
||||||
|
# Number of minutes of history shown by default on the dashboard chart.
|
||||||
|
DEFAULT_CHART_MINUTES = int(os.environ.get("DEFAULT_CHART_MINUTES", "60"))
|
||||||
+214
@@ -0,0 +1,214 @@
|
|||||||
|
import sqlite3
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
|
INIT_SQL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
ended_at TEXT,
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS readings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id INTEGER NOT NULL,
|
||||||
|
received_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
device_id TEXT,
|
||||||
|
mac TEXT,
|
||||||
|
ble_connected INTEGER,
|
||||||
|
battery INTEGER,
|
||||||
|
unit TEXT,
|
||||||
|
probe1_temp REAL,
|
||||||
|
probe1_connected INTEGER,
|
||||||
|
probe2_temp REAL,
|
||||||
|
probe2_connected INTEGER,
|
||||||
|
probe3_temp REAL,
|
||||||
|
probe3_connected INTEGER,
|
||||||
|
probe4_temp REAL,
|
||||||
|
probe4_connected INTEGER,
|
||||||
|
FOREIGN KEY (session_id) REFERENCES sessions(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS setpoints (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id INTEGER NOT NULL,
|
||||||
|
probe_number INTEGER NOT NULL,
|
||||||
|
label TEXT,
|
||||||
|
target_temp REAL NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
FOREIGN KEY (session_id) REFERENCES sessions(id),
|
||||||
|
UNIQUE(session_id, probe_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_readings_session_time
|
||||||
|
ON readings(session_id, received_at);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
Path(config.DATABASE_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with get_connection() as conn:
|
||||||
|
conn.executescript(INIT_SQL)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_connection():
|
||||||
|
conn = sqlite3.connect(config.DATABASE_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now_iso():
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def create_session(name: str, notes: str = ""):
|
||||||
|
with get_connection() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"INSERT INTO sessions (name, notes) VALUES (?, ?)",
|
||||||
|
(name, notes),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_session():
|
||||||
|
with get_connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM sessions WHERE ended_at IS NULL ORDER BY started_at DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def end_session(session_id: int):
|
||||||
|
with get_connection() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE sessions SET ended_at = ? WHERE id = ?",
|
||||||
|
(utc_now_iso(), session_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def update_session_notes(session_id: int, notes: str):
|
||||||
|
with get_connection() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE sessions SET notes = ? WHERE id = ?",
|
||||||
|
(notes, session_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def insert_reading(session_id: int, payload: dict):
|
||||||
|
probes = payload.get("probes", [])
|
||||||
|
temps = []
|
||||||
|
connected = []
|
||||||
|
for i in range(4):
|
||||||
|
if i < len(probes) and probes[i].get("connected"):
|
||||||
|
temps.append(probes[i].get("temperature"))
|
||||||
|
connected.append(1)
|
||||||
|
else:
|
||||||
|
temps.append(None)
|
||||||
|
connected.append(0)
|
||||||
|
|
||||||
|
with get_connection() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO readings (
|
||||||
|
session_id, received_at, device_id, mac, ble_connected, battery, unit,
|
||||||
|
probe1_temp, probe1_connected,
|
||||||
|
probe2_temp, probe2_connected,
|
||||||
|
probe3_temp, probe3_connected,
|
||||||
|
probe4_temp, probe4_connected
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
session_id,
|
||||||
|
utc_now_iso(),
|
||||||
|
payload.get("deviceId"),
|
||||||
|
payload.get("mac"),
|
||||||
|
1 if payload.get("connected") else 0,
|
||||||
|
payload.get("battery"),
|
||||||
|
payload.get("unit"),
|
||||||
|
temps[0], connected[0],
|
||||||
|
temps[1], connected[1],
|
||||||
|
temps[2], connected[2],
|
||||||
|
temps[3], connected[3],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def get_latest_reading(session_id: int):
|
||||||
|
with get_connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM readings WHERE session_id = ? ORDER BY received_at DESC LIMIT 1",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def get_readings(session_id: int, minutes: int = 60, probe_number: int = None):
|
||||||
|
with get_connection() as conn:
|
||||||
|
if probe_number is not None:
|
||||||
|
column = f"probe{probe_number}_temp"
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT received_at, {column} AS temp, probe{probe_number}_connected AS connected
|
||||||
|
FROM readings
|
||||||
|
WHERE session_id = ?
|
||||||
|
AND received_at > datetime('now', '-{minutes} minutes')
|
||||||
|
AND {column} IS NOT NULL
|
||||||
|
ORDER BY received_at ASC
|
||||||
|
""",
|
||||||
|
(session_id,),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT received_at,
|
||||||
|
probe1_temp, probe1_connected,
|
||||||
|
probe2_temp, probe2_connected,
|
||||||
|
probe3_temp, probe3_connected,
|
||||||
|
probe4_temp, probe4_connected
|
||||||
|
FROM readings
|
||||||
|
WHERE session_id = ?
|
||||||
|
AND received_at > datetime('now', '-{minutes} minutes')
|
||||||
|
ORDER BY received_at ASC
|
||||||
|
""",
|
||||||
|
(session_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def set_setpoint(session_id: int, probe_number: int, label: str, target_temp: float):
|
||||||
|
with get_connection() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO setpoints (session_id, probe_number, label, target_temp)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(session_id, probe_number) DO UPDATE SET
|
||||||
|
label = excluded.label,
|
||||||
|
target_temp = excluded.target_temp,
|
||||||
|
created_at = excluded.created_at
|
||||||
|
""",
|
||||||
|
(session_id, probe_number, label, target_temp),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def get_setpoints(session_id: int):
|
||||||
|
with get_connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM setpoints WHERE session_id = ? ORDER BY probe_number",
|
||||||
|
(session_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# Deploying the Frontend to a Proxmox LXC Container
|
||||||
|
|
||||||
|
This folder contains everything you need to run the ThermoPro CYD frontend inside an unprivileged Proxmox LXC container.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
1. Create a Debian 12 (or Ubuntu 22.04/24.04) LXC container in Proxmox.
|
||||||
|
2. Copy the `frontend/` directory into the container.
|
||||||
|
3. Run `deploy/proxmox-lxc-setup.sh` inside the container.
|
||||||
|
4. The container starts the Flask/Waitress app as a systemd service on port 5000.
|
||||||
|
5. Put the container behind a reverse proxy (Caddy/Nginx) for HTTPS and a friendly URL.
|
||||||
|
|
||||||
|
## Container specs (suggested)
|
||||||
|
|
||||||
|
- **OS template:** Debian 12 Standard (or Ubuntu 22.04/24.04 Standard)
|
||||||
|
- **Cores:** 1
|
||||||
|
- **Memory:** 512 MB
|
||||||
|
- **Root disk:** 4 GB
|
||||||
|
- **Network:** DHCP or static IP on your LAN
|
||||||
|
|
||||||
|
## One-time setup
|
||||||
|
|
||||||
|
### 1. Create the LXC
|
||||||
|
|
||||||
|
In the Proxmox web UI:
|
||||||
|
|
||||||
|
- Click **Create CT**.
|
||||||
|
- Choose a Debian 12 template (download from the UI if needed).
|
||||||
|
- Set hostname, password/SSH key, storage, network, etc.
|
||||||
|
- Finish and start the container.
|
||||||
|
|
||||||
|
Or via the Proxmox shell:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pct create 300 local:vztmpl/debian-12-standard_12.x-amd64.tar.zst \
|
||||||
|
--hostname thermopro-frontend \
|
||||||
|
--cores 1 \
|
||||||
|
--memory 512 \
|
||||||
|
--swap 0 \
|
||||||
|
--rootfs local-lvm:4 \
|
||||||
|
--net0 name=eth0,bridge=vmbr0,ip=dhcp \
|
||||||
|
--ostype debian \
|
||||||
|
--unprivileged 1
|
||||||
|
pct start 300
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Copy the frontend into the container
|
||||||
|
|
||||||
|
From your development machine or the Proxmox host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From the repo root
|
||||||
|
cd /path/to/ThermoPro-CYD
|
||||||
|
tar -czf /tmp/frontend.tar.gz frontend/
|
||||||
|
|
||||||
|
# Push it into the container
|
||||||
|
pct push 300 /tmp/frontend.tar.gz /tmp/frontend.tar.gz
|
||||||
|
|
||||||
|
# Extract it
|
||||||
|
pct exec 300 -- mkdir -p /opt
|
||||||
|
pct exec 300 -- tar -xzf /tmp/frontend.tar.gz -C /opt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Run the setup script inside the container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pct exec 300 -- bash /opt/frontend/deploy/proxmox-lxc-setup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This installs Python dependencies into `vendor/`, creates the `thermopro` user, sets up the systemd service, and starts it.
|
||||||
|
|
||||||
|
### 4. Find the container IP
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pct exec 300 -- ip -4 addr show eth0
|
||||||
|
```
|
||||||
|
|
||||||
|
Visit `http://<container-ip>:5000`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Edit the service file to point at your CYD and tune behavior:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pct exec 300 -- nano /etc/systemd/system/thermopro-frontend.service
|
||||||
|
```
|
||||||
|
|
||||||
|
Key environment variables:
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `CYD_BASE_URL` | `http://192.168.2.55` | Base URL of the CYD bridge |
|
||||||
|
| `POLL_INTERVAL` | `5` | Seconds between backend polls of the CYD |
|
||||||
|
| `DATABASE_PATH` | `/data/thermopro.db` | SQLite path (persistent mount) |
|
||||||
|
|
||||||
|
After editing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pct exec 300 -- systemctl daemon-reload
|
||||||
|
pct exec 300 -- systemctl restart thermopro-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reverse proxy / HTTPS (recommended)
|
||||||
|
|
||||||
|
Use Caddy or Nginx on the Proxmox host, another container, or your router to expose the app on a friendly URL. Example Caddyfile:
|
||||||
|
|
||||||
|
```
|
||||||
|
bbq.example.com {
|
||||||
|
reverse_proxy http://<container-ip>:5000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updating the app
|
||||||
|
|
||||||
|
To push a new version:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Re-tar the frontend folder and push it into the container
|
||||||
|
tar -czf /tmp/frontend.tar.gz frontend/
|
||||||
|
pct push 300 /tmp/frontend.tar.gz /tmp/frontend.tar.gz
|
||||||
|
|
||||||
|
# Extract over the install directory and restart
|
||||||
|
pct exec 300 -- bash -c "tar -xzf /tmp/frontend.tar.gz -C /opt && chown -R thermopro:thermopro /opt/frontend && systemctl restart thermopro-frontend"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Persistent data
|
||||||
|
|
||||||
|
By default the SQLite database is written to `/data/thermopro.db` inside the container. Proxmox backups include the container root disk, so the database is preserved. If you prefer to store it on the host, add a bind mount in the Proxmox container config:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On the Proxmox host
|
||||||
|
mkdir -p /var/lib/thermopro-data
|
||||||
|
pct set 300 -mp0 /var/lib/thermopro-data,mp=/data
|
||||||
|
```
|
||||||
|
|
||||||
|
Then restart the container.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run this script inside a freshly created Proxmox LXC container
|
||||||
|
# (Debian 12 or Ubuntu 22.04/24.04) to install and configure the
|
||||||
|
# ThermoPro CYD frontend.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
INSTALL_DIR="/opt/frontend"
|
||||||
|
DATA_DIR="/data"
|
||||||
|
SERVICE_NAME="thermopro-frontend"
|
||||||
|
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
echo "This script must be run as root inside the LXC container" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Updating packages ==="
|
||||||
|
apt-get update
|
||||||
|
apt-get upgrade -y
|
||||||
|
|
||||||
|
echo "=== Installing Python tooling ==="
|
||||||
|
apt-get install -y python3 python3-pip git curl
|
||||||
|
|
||||||
|
echo "=== Creating application user ==="
|
||||||
|
useradd -r -s /bin/false -d "${INSTALL_DIR}" thermopro || true
|
||||||
|
|
||||||
|
echo "=== Creating data directory ==="
|
||||||
|
mkdir -p "${DATA_DIR}"
|
||||||
|
chown thermopro:thermopro "${DATA_DIR}"
|
||||||
|
|
||||||
|
if [[ ! -d "${INSTALL_DIR}" ]]; then
|
||||||
|
echo "ERROR: ${INSTALL_DIR} does not exist."
|
||||||
|
echo "Copy the frontend/ folder into the container at ${INSTALL_DIR} first."
|
||||||
|
echo "Example from Proxmox host:"
|
||||||
|
echo " pct push <ctid> /path/to/frontend.tar.gz /tmp/frontend.tar.gz"
|
||||||
|
echo " pct exec <ctid> -- tar -xzf /tmp/frontend.tar.gz -C /opt"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "${INSTALL_DIR}"
|
||||||
|
|
||||||
|
echo "=== Installing Python dependencies into vendor/ ==="
|
||||||
|
pip3 install --target vendor --upgrade -r requirements.txt
|
||||||
|
|
||||||
|
echo "=== Setting ownership ==="
|
||||||
|
chown -R thermopro:thermopro "${INSTALL_DIR}"
|
||||||
|
|
||||||
|
echo "=== Installing systemd service ==="
|
||||||
|
cp "${INSTALL_DIR}/deploy/thermopro-frontend.service" /etc/systemd/system/
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable "${SERVICE_NAME}"
|
||||||
|
systemctl start "${SERVICE_NAME}"
|
||||||
|
|
||||||
|
echo "=== Waiting for service to start ==="
|
||||||
|
sleep 2
|
||||||
|
systemctl status "${SERVICE_NAME}" --no-pager
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Installation complete ==="
|
||||||
|
echo "The frontend should now be running on http://<container-ip>:5000"
|
||||||
|
echo ""
|
||||||
|
echo "To check logs: journalctl -u ${SERVICE_NAME} -f"
|
||||||
|
echo "To edit config: nano /etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
|
echo "Then reload: systemctl daemon-reload && systemctl restart ${SERVICE_NAME}"
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=ThermoPro CYD Frontend
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=thermopro
|
||||||
|
Group=thermopro
|
||||||
|
WorkingDirectory=/opt/thermopro-frontend
|
||||||
|
Environment=CYD_BASE_URL=http://192.168.2.55
|
||||||
|
Environment=POLL_INTERVAL=5
|
||||||
|
Environment=DATABASE_PATH=/data/thermopro.db
|
||||||
|
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
ExecStart=/usr/bin/python3 /opt/thermopro-frontend/app.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
flask>=3.0.0
|
||||||
|
requests>=2.31.0
|
||||||
|
waitress>=3.0.0
|
||||||
@@ -0,0 +1,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 => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||||
|
}[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();
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "ThermoPro CYD Dashboard",
|
||||||
|
"short_name": "CYD BBQ",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#1a1a1a",
|
||||||
|
"theme_color": "#ff6b35",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,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; }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user