Add ThermoPro BLE support and dashboard display

- Implement ThermoproBLE class for handling BLE communication with ThermoPro BBQ thermometer.
- Create ThermoproReading structure to hold thermometer data.
- Develop Dashboard class for displaying thermometer readings and WiFi status on a TFT display.
- Configure display settings and touch input handling.
- Introduce SettingsManager for managing user settings stored in NVS.
- Implement WiFiManager for handling WiFi connections and captive portal setup.
- Add HTML interface for user to input WiFi credentials and ThermoPro MAC address.
- Ensure proper state management for WiFi and BLE connections in the main application loop.
This commit is contained in:
Keith Solomon
2026-07-19 19:33:18 -05:00
parent 801e16f7ff
commit 9a3a8f9368
18 changed files with 2212 additions and 0 deletions
+170
View File
@@ -0,0 +1,170 @@
# CYD ThermoPro Bridge Integration Plan
## Goal
Move the BLE reading and HTTP bridging onto an ESP32 "Cheap Yellow Display" (CYD) board so the Linux host is no longer required. Provide a lightweight touch UI on the CYD for initial WiFi setup and runtime settings (ThermoPro MAC, API endpoint, token, polling interval, probe names, temperature unit).
## Recommended approach: Arduino / PlatformIO C++ firmware
A native Arduino C++ firmware is the best fit for the CYD:
- Mature ESP32 WiFi, BLE, HTTP, and display libraries.
- Direct control over the TFT and touch controller.
- Enough RAM/flash for a small touch UI plus BLE + HTTP at the same time.
- Large CYD community examples and known pinouts.
**Alternatives considered:**
- **MicroPython on the ESP32:** possible, but the ThermoPro protocol would need a custom async BLE implementation and the touch UI ecosystem is less mature. Not recommended unless you strongly prefer staying in Python.
- **Keep the Linux bridge and use the CYD only as a display:** this contradicts the goal of integrating the bridge into the CYD itself.
## Milestones / implementation steps
### 1. Identify the exact CYD variant
CYD boards vary (ESP32-2432S028C, ESP32-3248S035, etc.). We need to confirm:
- Display controller (ILI9341, ST7789, ST7796) and resolution (common: 320×240, 480×320).
- Touch controller (usually XPT2046) and whether it shares the SPI bus with the display.
- Backlight GPIO and whether it needs PWM dimming.
- Available free GPIOs, power source, and USB/UART programming arrangement.
Deliverable: a `docs/CYD_BRINGUP.md` file with the exact pinout and a "hello world" sketch that lights up the screen and prints to Serial.
### 2. Create the firmware project skeleton
New folder: `cyd-bridge/`.
- Use PlatformIO (`platformio.ini`, `board = esp32dev`, `framework = arduino`). PlatformIO makes dependency management and library pin versions much easier than the Arduino IDE, but an `.ino` wrapper can be kept for Arduino IDE users.
- Recommended partition scheme: `default` or `large_spiffs` depending on whether we want to store a local log or OTA images.
Core libraries to add:
- **Display:** `TFT_eSPI` (widely used, simple) or `LovyanGFX` (faster, more flexible). Choice depends on the exact panel; both need correct driver/pin configuration.
- **Touch:** `XPT2046_Bitbang` or the touch support built into LovyanGFX.
- **UI:** start with custom screens drawn with the display library. `LVGL` is powerful but adds significant complexity and RAM use; defer unless the UI grows beyond a few screens.
- **Network:** built-in `WiFi`, `WiFiManager` or a small custom captive portal.
- **BLE:** `NimBLE-Arduino` — much lighter than the default Bluedroid stack and sufficient for this custom protocol.
- **HTTP:** built-in `HTTPClient`.
- **JSON:** `ArduinoJson`.
- **Storage:** `Preferences` (NVS) for settings.
- **(Optional later):** `ArduinoOTA` for over-the-air updates.
### 3. Settings storage with NVS
Create a `SettingsManager` class backed by `Preferences`.
Stored fields:
- `wifi_ssid`, `wifi_pass`
- `tp_mac` — ThermoPro BLE address
- `api_endpoint` — URL to POST readings to
- `api_token` — shared secret for `X-Bridge-Token`
- `interval_sec` — default 15
- `unit``F` or `C`
- `probe_names[4]`
- `device_id` — stable bridge identifier
Include load/save/reset-to-defaults, validation, and a version key so we can migrate the settings struct later.
### 4. WiFi setup UI
**First-boot / unconfigured mode:**
- Start an access point named something like `CYD-ThermoPro-Setup`.
- Run a captive portal (`DNSServer` + `WebServer`) that serves a single HTML form for WiFi SSID and password.
- On the CYD screen, show the AP name, a URL or QR code, and instructions.
- After the user submits valid credentials, the device tries to connect, shuts down the AP, and persists the credentials.
**Runtime:**
- A "Network" screen reachable from the status UI shows SSID, IP, RSSI, and a button to re-enter setup mode.
Avoid writing a full on-screen keyboard for WiFi credentials; the captive-portal web form is much faster and more reliable.
### 5. Settings / configuration UI
The on-device touch UI should stay light:
- **Home / status screen:** WiFi state, BLE state, API/POST status, battery %, unit, probe temperatures, last update time.
- **Settings menu:** list items such as endpoint, token, MAC, interval, unit, probe names, factory reset.
- **Text-heavy fields** (endpoint, token, probe names) are easiest to edit through a second web form served by the device on the local network or in setup AP mode. The touch UI can show the current value and a "Edit on phone/PC" button with a URL/QR code.
- **Toggle/picker fields** (unit F/C, interval presets, factory reset) can be handled entirely on the touchscreen.
This split keeps the touch code small while giving a full editor for long strings.
### 6. Port the ThermoPro BLE protocol to C++
Create a `ThermoproBLE` C++ class using NimBLE-Arduino. Port the protocol logic directly from the working Python `thermopro_cli.py`:
- Service UUID: `1086FFF0-3343-4817-8BB2-B32206336CE8`
- Notify characteristic: `1086FFF2-3343-4817-8BB2-B32206336CE8`
- Write characteristic: `1086FFF1-3343-4817-8BB2-B32206336CE8`
- Send the static handshake bytes (`01098a7a13b73ed68b67c2a0`) and optional timestamp sync.
- Parse incoming notifications; specifically command `0x30` for temperature data.
- Decode the custom BCD temperature format and handle sentinel values (`-999.0`, `-100.0`, `666.0`) as "probe not connected".
- Maintain latest state: `connected`, `battery`, `device_unit`, `probe_count`, `temperatures[4]`, `last_update`.
- Implement a non-blocking connection loop with exponential backoff, matching the Python `connection_loop()` behavior.
### 7. HTTP bridge client
Create a `BridgeClient` class:
- On each polling interval, read the latest BLE state.
- Build a JSON payload matching the existing `receiver.php` contract using `ArduinoJson`.
- POST to the configured endpoint with the `X-Bridge-Token` header and a 10-second timeout.
- Track last POST status/time and surface it on the UI.
- If BLE is disconnected, still POST a "bridge alive" payload with `connected: false` so the server knows the device is reachable but the thermometer is out of range.
### 8. Main application loop
Use a small state machine:
1. `SETUP` — AP + captive portal if no credentials stored.
2. `CONNECT_WIFI` — connect to configured WiFi with timeout and retry.
3. `CONNECT_BLE` — start NimBLE scan/connect to the configured MAC.
4. `RUNNING` — read BLE, POST readings, refresh UI, handle reconnections.
5. `ERROR` — show a readable error screen and retry after a backoff.
Keep everything non-blocking. Each `loop()` iteration services BLE events, HTTP state, UI input, and WiFi health. Enable the ESP32 task watchdog to recover from hangs.
### 9. Status dashboard on the CYD
A single main screen is enough for day-to-day use:
- Header: WiFi icon, BLE icon, battery percentage, current unit (`°F`/`°C`).
- Body: up to 4 probe tiles. Each tile shows the probe name, current temperature or `---`, and a visual indicator for connected/disconnected.
- Footer: last successful POST time/status, local IP, and a tap hint to open settings.
If the screen is landscape 480×320, the four probes can be shown as a 2×2 grid. If it is portrait 320×480, a vertical list works better.
### 10. Receiver compatibility
The existing `receiver.php` should accept the same JSON payload unchanged. Two small additions could help the CYD:
- `GET /api/thermopro/health` returning HTTP 200 — lets the CYD verify API reachability before POSTing.
- CORS headers on the readings endpoint if the CYD ever serves its settings UI from the browser while the PHP server is on another origin.
Neither is required for the MVP.
### 11. Build, flash, and debug workflow
- Serial logging at 115200 baud with timestamped, human-readable messages.
- `docs/CYD_BRINGUP.md` with exact board variant, wiring, PlatformIO library versions, and `pio run -t upload` steps.
- `.gitignore` for PlatformIO build artifacts, `.pio/`, `.vscode/` if generated.
- Optional: a simple Python script under `tools/` to scan for the CYD's IP or to flash the firmware.
### 12. Post-MVP ideas
- Over-the-air firmware updates via a web upload page on the CYD.
- Direct MQTT publishing from the CYD, bypassing the PHP receiver entirely (nice for Home Assistant users).
- Temperature alarms with on-screen banners and optional buzzer/LED.
- Local mini graph of recent temperatures using the NDJSON log or SPIFFS.
- Support for multiple ThermoPro thermometers on one CYD.
## Open questions before implementation
1. **Which exact CYD board do you have?** Model name, screen resolution, and whether you already know the display/touch drivers will determine the first milestone.
2. **Do you want to keep the PHP receiver as the destination, or is the real end goal to push straight to Home Assistant / MQTT?** This affects whether we keep the HTTP bridge client or add MQTT.
3. **Are you comfortable with Arduino C++ / PlatformIO, or should I also sketch a MicroPython alternative?** The plan above assumes C++.
4. **Should the CYD itself show a local "smoker dashboard" with temperatures, or is the primary value the bridge plus a setup UI?** The plan includes a dashboard, but we can trim it to just setup if you prefer.
+11
View File
@@ -0,0 +1,11 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch
*.code-workspace
.pioenvs
.piolibdeps
.clang_complete
.gcc-flags.json
build/
+110
View File
@@ -0,0 +1,110 @@
# CYD ThermoPro Bridge
Firmware for an ESP32 "Cheap Yellow Display" (ESP32-2432S028R) that reads temperature data from a ThermoPro BLE BBQ thermometer and exposes it through a local HTTP API.
## Hardware
- **Board:** ESP32-2432S028R
- 320×240 TFT (ILI9341 driver)
- Resistive touch panel (XPT2046)
- ESP32-WROOM-32 with 4 MB flash
- **Thermometer:** ThermoPro TP25 / TP25W / TP920 / TP930 / TP960
## Firmware architecture
```
┌─────────────────────────────────────────┐
│ CYD (ESP32-2432S028R) │
│ │
│ ┌─────────────┐ ┌──────────────┐ │
│ │ ThermoPro │ │ HTTP API │ │
│ │ BLE client │─────▶│ Web server │ │
│ └─────────────┘ └──────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────────────────────┐ │
│ │ SettingsManager (NVS/Preferences)│ │
│ └──────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────┐ │
│ │ Touch UI: status + setup screens │ │
│ └──────────────────────────────────┘ │
└─────────────────────────────────────────┘
```
## API endpoints
- `GET /api/health` — device health and connectivity status.
- `GET /api/latest` — latest ThermoPro reading.
- `GET /api/settings` — current settings (excluding WiFi password).
- `POST /api/settings` — update settings (JSON body).
- `GET /` — setup page when in AP mode; status page when in STA mode.
## Endpoints response examples
### `GET /api/latest`
```json
{
"ok": true,
"deviceId": "cyd-smoker",
"device": "ThermoPro TP930",
"mac": "C9:48:1D:B1:E1:E7",
"connected": true,
"battery": 90,
"unit": "F",
"probeCount": 4,
"probes": [
{ "id": 1, "name": "Brisket", "temperature": 266.2, "connected": true },
{ "id": 2, "name": "Ambient", "temperature": 235.5, "connected": true },
{ "id": 3, "name": "Probe 3", "temperature": null, "connected": false },
{ "id": 4, "name": "Probe 4", "temperature": null, "connected": false }
],
"readingTime": "2026-07-19T12:00:00+00:00",
"bridgeTime": "2026-07-19T12:00:01+00:00"
}
```
## Setup workflow
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`.
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.
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.
## Building and flashing
Requires [PlatformIO](https://platformio.org/) installed locally (not available in this cloud environment).
```bash
cd cyd-bridge
# Compile only
pio run
# Compile and upload to the CYD
pio run -t upload
# View serial output
pio device monitor
```
If you have not installed PlatformIO yet:
```bash
pip install platformio
```
## First-time hardware check
Before relying on the dashboard, verify the pinout matches your exact board revision:
1. Open `src/display/DisplayConfig.hpp`.
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.
3. If the screen stays white or touch is inverted, adjust `cfg.offset_rotation` in `DisplayConfig.hpp` or `setRotation()` in `Dashboard::begin()`.
## Development notes
- LovyanGFX driver/pin configuration lives in `src/display/DisplayConfig.hpp` and is tuned for the ESP32-2432S028R variant.
- BLE uses `NimBLE-Arduino` to leave enough RAM for the web server and UI.
- 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.
+27
View File
@@ -0,0 +1,27 @@
; PlatformIO project for CYD ThermoPro bridge
; Target: ESP32-2432S028R (ESP32 + ILI9341 320x240 TFT + XPT2046 touch)
[env:esp32dev]
platform = espressif32 @ ^6.9.0
board = esp32dev
framework = arduino
monitor_speed = 115200
monitor_filters = esp32_exception_decoder
; The CYD needs a decent amount of RAM/flash for BLE + HTTP + display + JSON.
; default.csv gives ~1.3MB app / 1.3MB spiffs which is plenty for the MVP.
board_build.partitions = default.csv
; Required libraries
lib_deps =
; Lightweight BLE stack (much smaller than Bluedroid)
h2zero/NimBLE-Arduino @ ^1.4.3
; JSON serialization
bblanchon/ArduinoJson @ ^7.3.0
; Graphics library with broad CYD support and fast SPI
lovyan03/LovyanGFX @ ^1.2.0
; Build flags to set the program name and version
build_flags =
-D CYD_THERMOPRO_BRIDGE=1
-D CORE_DEBUG_LEVEL=3
+294
View File
@@ -0,0 +1,294 @@
#include "ApiServer.hpp"
#include <ArduinoJson.h>
#include <WiFi.h>
namespace cyd {
namespace {
constexpr const char* htmlHead = R"rawliteral(
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CYD ThermoPro Bridge</title>
<style>
body { font-family: system-ui, sans-serif; margin: 1rem; max-width: 700px; }
label { display: block; margin-top: 1rem; font-weight: bold; }
input, select { 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; }
.card { background: #f4f4f4; padding: 1rem; border-radius: 0.5rem; margin-top: 1rem; }
pre { overflow-x: auto; }
</style>
</head>
<body>
)rawliteral";
constexpr const char* htmlFoot = R"rawliteral(
</body>
</html>
)rawliteral";
} // namespace
ApiServer::ApiServer(SettingsManager& settings, ThermoproBLE& bleClient)
: server_(80), settings_(settings), bleClient_(bleClient) {
}
void ApiServer::begin(uint16_t port) {
port_ = port;
server_.on("/", HTTP_GET, [this]() { handleRoot_(); });
server_.on("/api/health", HTTP_GET, [this]() { handleHealth_(); });
server_.on("/api/latest", HTTP_GET, [this]() { handleLatest_(); });
server_.on("/api/settings", HTTP_GET, [this]() { handleGetSettings_(); });
server_.on("/api/settings", HTTP_POST, [this]() { handlePostSettings_(); });
server_.onNotFound([this]() { handleNotFound_(); });
server_.begin(port_);
Serial.printf("ApiServer: listening on port %d\n", port_);
}
void ApiServer::update() {
server_.handleClient();
}
void ApiServer::handleRoot_() {
if (setupMode_) {
server_.send(200, "text/html", buildSetupPage_());
} else {
server_.send(200, "text/html", buildStatusPage_());
}
}
void ApiServer::handleHealth_() {
JsonDocument doc;
doc["ok"] = true;
doc["deviceId"] = settings_.getDeviceId();
doc["wifiConnected"] = (WiFi.status() == WL_CONNECTED);
doc["wifiSsid"] = settings_.getWifiSsid();
doc["ip"] = WiFi.localIP().toString();
doc["bleConnected"] = bleClient_.getReading().connected;
doc["freeHeap"] = ESP.getFreeHeap();
String body;
serializeJsonPretty(doc, body);
server_.send(200, "application/json", body);
}
void ApiServer::handleLatest_() {
JsonDocument doc;
JsonObject obj = doc.to<JsonObject>();
fillLatestJson_(obj);
String body;
serializeJsonPretty(doc, body);
server_.send(200, "application/json", body);
}
void ApiServer::fillLatestJson_(JsonObject& obj) {
ThermoproReading reading = bleClient_.getReading();
char targetUnit = settings_.getUnit();
obj["ok"] = true;
obj["deviceId"] = settings_.getDeviceId();
obj["device"] = "ThermoPro TP930";
obj["mac"] = settings_.getThermoproMac();
obj["connected"] = reading.connected;
obj["battery"] = reading.battery;
obj["unit"] = String(targetUnit);
obj["probeCount"] = reading.probeCount;
JsonArray probes = obj["probes"].to<JsonArray>();
for (size_t i = 0; i < 4; ++i) {
JsonObject p = probes.add<JsonObject>();
p["id"] = i + 1;
p["name"] = settings_.getProbeName(i);
float raw = reading.rawTemperatures[i];
if (isValidTemperature(raw)) {
float display = convertTemperature(raw, reading.deviceUnit, targetUnit);
p["temperature"] = serialized(String(display, 1).c_str());
p["connected"] = true;
} else {
p["temperature"] = nullptr;
p["connected"] = false;
}
}
// Timestamps are relative for the device; we add ISO-like strings where possible.
obj["readingTime"] = reading.lastUpdateMillis > 0 ? String(reading.lastUpdateMillis) : nullptr;
obj["bridgeTime"] = String(millis());
obj["source"] = "cyd-bridge";
}
void ApiServer::handleGetSettings_() {
JsonDocument doc;
doc["ok"] = true;
doc["wifiSsid"] = settings_.getWifiSsid();
doc["thermoproMac"] = settings_.getThermoproMac();
doc["apiEndpoint"] = settings_.getApiEndpoint();
// do not expose apiToken
doc["pollIntervalSec"] = settings_.getPollIntervalSec();
doc["unit"] = String(settings_.getUnit());
doc["deviceId"] = settings_.getDeviceId();
JsonArray names = doc["probeNames"].to<JsonArray>();
for (size_t i = 0; i < 4; ++i) {
names.add(settings_.getProbeName(i));
}
String body;
serializeJsonPretty(doc, body);
server_.send(200, "application/json", body);
}
void ApiServer::handlePostSettings_() {
String body = server_.arg("plain");
JsonDocument doc;
DeserializationError err = deserializeJson(doc, body);
if (err != DeserializationError::Ok) {
server_.send(400, "application/json", "{\"ok\":false,\"error\":\"Invalid JSON\"}");
return;
}
bool changed = false;
if (doc["wifiSsid"].is<String>()) {
settings_.setWifiSsid(doc["wifiSsid"].as<String>());
changed = true;
}
if (doc["wifiPass"].is<String>()) {
String pass = doc["wifiPass"].as<String>();
// Allow empty string to clear the stored password, otherwise keep existing.
settings_.setWifiPass(pass);
changed = true;
}
if (doc["thermoproMac"].is<String>()) {
String mac = doc["thermoproMac"].as<String>();
if (SettingsManager::isValidMac(mac)) {
settings_.setThermoproMac(mac);
changed = true;
} else {
server_.send(422, "application/json", "{\"ok\":false,\"error\":\"Invalid MAC address\"}");
return;
}
}
if (doc["apiEndpoint"].is<String>()) {
settings_.setApiEndpoint(doc["apiEndpoint"].as<String>());
changed = true;
}
if (doc["apiToken"].is<String>()) {
settings_.setApiToken(doc["apiToken"].as<String>());
changed = true;
}
if (doc["pollIntervalSec"].is<uint16_t>()) {
settings_.setPollIntervalSec(doc["pollIntervalSec"].as<uint16_t>());
changed = true;
}
if (doc["unit"].is<String>()) {
String unit = doc["unit"].as<String>();
settings_.setUnit(unit.length() > 0 ? unit.charAt(0) : 'F');
changed = true;
}
if (doc["deviceId"].is<String>()) {
settings_.setDeviceId(doc["deviceId"].as<String>());
changed = true;
}
if (doc["probeNames"].is<JsonArray>()) {
JsonArray arr = doc["probeNames"].as<JsonArray>();
for (size_t i = 0; i < 4 && i < arr.size(); ++i) {
settings_.setProbeName(i, arr[i].as<String>());
}
changed = true;
}
if (changed) {
settings_.save();
}
// Re-read and respond with current settings.
handleGetSettings_();
if (rebootCallback_ && (doc["reboot"] | false)) {
rebootCallback_();
}
}
void ApiServer::handleNotFound_() {
server_.send(404, "application/json", "{\"ok\":false,\"error\":\"Not found\"}");
}
String ApiServer::buildSetupPage_() {
String page = htmlHead;
page += "<h1>CYD ThermoPro Setup</h1>\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 += R"rawliteral(
<label>WiFi SSID</label>
<input type="text" name="wifiSsid" id="wifiSsid" required>
<label>WiFi Password</label>
<input type="password" name="wifiPass" id="wifiPass">
<div class="note">Leave blank to keep the existing password.</div>
<label>ThermoPro MAC address</label>
<input type="text" name="thermoproMac" id="thermoproMac" placeholder="C9:48:1D:B1:E1:E7" required>
<div class="note">Format: AA:BB:CC:DD:EE:FF. Find it with a BLE scanner.</div>
<label>Device ID</label>
<input type="text" name="deviceId" id="deviceId" value=")rawliteral";
page += settings_.getDeviceId();
page += R"rawliteral(">
<button type="submit">Save & Connect</button>
</form>
<div id="result" class="card" style="display:none"></div>
<script>
async function submitForm() {
const result = document.getElementById('result');
const payload = {
wifiSsid: document.getElementById('wifiSsid').value,
wifiPass: document.getElementById('wifiPass').value,
thermoproMac: document.getElementById('thermoproMac').value,
deviceId: document.getElementById('deviceId').value,
reboot: true
};
try {
const res = await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const json = await res.json();
result.style.display = 'block';
result.textContent = json.ok
? 'Saved. The CYD will restart and join your WiFi now.'
: 'Error: ' + (json.error || 'unknown');
} catch (e) {
result.style.display = 'block';
result.textContent = 'Error: ' + e.message;
}
return false;
}
</script>
)rawliteral";
page += htmlFoot;
return page;
}
String ApiServer::buildStatusPage_() {
String page = htmlHead;
page += "<h1>CYD ThermoPro Bridge</h1>\n";
page += "<div class=\"card\">\n";
page += " <p><strong>Status page</strong></p>\n";
page += " <p>Use the JSON API for programmatic access:</p>\n";
page += " <ul>\n";
page += " <li><a href=\"/api/health\">GET /api/health</a></li>\n";
page += " <li><a href=\"/api/latest\">GET /api/latest</a></li>\n";
page += " <li><a href=\"/api/settings\">GET /api/settings</a></li>\n";
page += " </ul>\n";
page += " <p>POST JSON to <code>/api/settings</code> to change probe names, endpoint, interval, unit, etc.</p>\n";
page += "</div>\n";
page += htmlFoot;
return page;
}
} // namespace cyd
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include "settings/SettingsManager.hpp"
#include "ble/ThermoproBLE.hpp"
#include <WebServer.h>
namespace cyd {
// Serves the CYD's HTTP API and the setup/status web UI.
class ApiServer {
public:
using RebootRequestedCallback = std::function<void()>;
ApiServer(SettingsManager& settings, ThermoproBLE& bleClient);
// Start the web server on the given port. Must be called after WiFi is up.
void begin(uint16_t port = 80);
// Process pending HTTP requests. Call frequently from loop().
void update();
// Tell the server whether the device is currently in AP setup mode.
void setSetupMode(bool setupMode) { setupMode_ = setupMode; }
// Optionally register a callback to request a reboot after settings changes.
void onRebootRequested(RebootRequestedCallback cb) { rebootCallback_ = cb; }
private:
WebServer server_;
uint16_t port_ = 80;
SettingsManager& settings_;
ThermoproBLE& bleClient_;
bool setupMode_ = false;
RebootRequestedCallback rebootCallback_;
void handleRoot_();
void handleHealth_();
void handleLatest_();
void handleGetSettings_();
void handlePostSettings_();
void handleNotFound_();
String buildSetupPage_();
String buildStatusPage_();
// Returns the latest reading as an ArduinoJson document.
void fillLatestJson_(JsonObject& obj);
};
} // namespace cyd
+350
View File
@@ -0,0 +1,350 @@
#include "ThermoproBLE.hpp"
#include <Arduino.h>
namespace cyd {
ThermoproBLE* ThermoproBLE::instance_ = nullptr;
ThermoproBLE::ThermoproBLE() {
instance_ = this;
}
void ThermoproBLE::setAddress(const String& mac) {
address_ = mac;
}
bool ThermoproBLE::init(const String& deviceName) {
if (address_.length() == 0) {
Serial.println("ThermoproBLE: no MAC address configured");
return false;
}
NimBLEDevice::init(std::string(deviceName.c_str()));
// We do not need a security/bonded pairing for the ThermoPro protocol.
NimBLEDevice::setSecurityAuth(false, false, false);
Serial.printf("ThermoproBLE: BLE initialized as '%s', target %s\n", deviceName.c_str(), address_.c_str());
state_ = State::Idle;
return true;
}
bool ThermoproBLE::startConnect() {
if (state_ != State::Idle && state_ != State::Reconnecting) {
Serial.println("ThermoproBLE: connect already in progress");
return false;
}
if (NimBLEDevice::getScan()->isScanning()) {
NimBLEDevice::getScan()->stop();
}
Serial.println("ThermoproBLE: starting scan/connect");
state_ = State::Scanning;
stateStartMillis_ = millis();
return scanAndConnect_();
}
void ThermoproBLE::update() {
switch (state_) {
case State::Idle:
// Waiting for an explicit startConnect() call.
break;
case State::Scanning:
case State::Connecting:
case State::Discovering:
case State::EnablingNotify:
case State::SendingHandshake:
// Time out stuck states and restart the reconnect cycle.
if (millis() - stateStartMillis_ > 15000) {
Serial.printf("ThermoproBLE: state timeout in state %d, forcing reconnect\n", static_cast<int>(state_));
if (client_ && client_->isConnected()) {
client_->disconnect();
}
setConnected_(false);
startReconnectDelay_();
}
break;
case State::Running: {
if (!client_ || !client_->isConnected()) {
Serial.println("ThermoproBLE: connection lost, reconnecting");
setConnected_(false);
startReconnectDelay_();
break;
}
// If we haven't seen any notification in 30 seconds, reconnect.
if (millis() - lastActivityMillis_ > 30000) {
Serial.println("ThermoproBLE: no notifications for 30s, reconnecting");
client_->disconnect();
setConnected_(false);
startReconnectDelay_();
}
break;
}
case State::Reconnecting:
if (millis() - stateStartMillis_ >= reconnectDelayMs_) {
state_ = State::Idle;
startConnect();
}
break;
}
}
void ThermoproBLE::disconnect() {
if (client_ && client_->isConnected()) {
client_->disconnect();
}
NimBLEDevice::deinit(true);
setConnected_(false);
state_ = State::Idle;
}
void ThermoproBLE::ScanCallbacks::onResult(NimBLEAdvertisedDevice* device) {
if (ThermoproBLE::instance_ == nullptr) {
return;
}
String addr(device->getAddress().toString().c_str());
String name(device->getName().c_str());
bool matchesAddress = addr.equalsIgnoreCase(ThermoproBLE::instance_->address_);
bool matchesName = isThermoproDevice_(name);
if (matchesAddress) {
Serial.printf("ThermoproBLE: found target device %s (%s)\n", name.c_str(), addr.c_str());
NimBLEDevice::getScan()->stop();
connectToServer_(device, ThermoproBLE::instance_);
} else if (matchesName) {
Serial.printf("ThermoproBLE: discovered ThermoPro %s (%s)\n", name.c_str(), addr.c_str());
}
}
bool ThermoproBLE::scanAndConnect_() {
NimBLEScan* scanner = NimBLEDevice::getScan();
if (!callbacksRegistered_) {
scanner->setAdvertisedDeviceCallbacks(&scanCallbacks_, false);
callbacksRegistered_ = true;
}
scanner->setActiveScan(true);
scanner->setInterval(100);
scanner->setWindow(99);
scanner->start(5, false);
return true;
}
bool ThermoproBLE::connectToServer_(NimBLEAdvertisedDevice* device, void* userData) {
auto* self = static_cast<ThermoproBLE*>(userData);
if (!self) {
return false;
}
self->state_ = State::Connecting;
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;
}
bool ThermoproBLE::setupConnection_(NimBLEClient* client) {
state_ = State::Discovering;
stateStartMillis_ = millis();
NimBLERemoteService* service = client->getService(kServiceUuid);
if (!service) {
Serial.println("ThermoproBLE: service not found");
return false;
}
notifyChar_ = service->getCharacteristic(kNotifyUuid);
writeChar_ = service->getCharacteristic(kWriteUuid);
if (!notifyChar_ || !writeChar_) {
Serial.println("ThermoproBLE: required characteristics missing");
return false;
}
state_ = State::EnablingNotify;
stateStartMillis_ = millis();
if (!notifyChar_->subscribe(true, notifyCallback_)) {
Serial.println("ThermoproBLE: subscribe failed");
return false;
}
Serial.println("ThermoproBLE: notifications enabled");
state_ = State::SendingHandshake;
stateStartMillis_ = millis();
if (!writeChar_->write(const_cast<uint8_t*>(kHandshake), sizeof(kHandshake), true)) {
Serial.println("ThermoproBLE: handshake write failed");
return false;
}
Serial.println("ThermoproBLE: handshake sent");
return true;
}
void ThermoproBLE::setConnected_(bool connected) {
bool changed = reading_.connected != connected;
reading_.connected = connected;
if (!connected) {
reading_.battery = 0;
for (auto& t : reading_.rawTemperatures) {
t = -999.0f;
}
}
if (changed && stateCallback_) {
stateCallback_(reading_);
}
}
void ThermoproBLE::startReconnectDelay_() {
state_ = State::Reconnecting;
stateStartMillis_ = millis();
reconnectFailures_ = min(static_cast<int>(reconnectFailures_) + 1, 6);
reconnectDelayMs_ = min(5000UL * (1UL << reconnectFailures_), 300000UL);
Serial.printf("ThermoproBLE: reconnect in %lu ms (failure %d)\n", reconnectDelayMs_, reconnectFailures_);
}
void ThermoproBLE::notifyCallback_(BLERemoteCharacteristic* characteristic, uint8_t* data, size_t length, bool isNotify) {
(void)characteristic;
(void)isNotify;
if (instance_) {
instance_->handlePacket_(data, length);
}
}
void ThermoproBLE::handlePacket_(const uint8_t* data, size_t length) {
if (length < 2) {
return;
}
lastActivityMillis_ = millis();
uint8_t cmd = data[0];
switch (cmd) {
case 0x30:
parseTemperaturePacket_(data, length);
break;
case 0x01:
case 0x41:
case 0xE0:
// Handshake ack / version / keepalive — not temperature data.
break;
default:
break;
}
}
void ThermoproBLE::parseTemperaturePacket_(const uint8_t* data, size_t length) {
if (length < 6) {
return;
}
reading_.battery = data[2];
// data[3] appears to be a unit/flags byte in some captures; we keep the
// device unit reported by the app/protocol as Celsius by default.
reading_.deviceUnit = 'C';
if (data[4] == 0x00) {
reading_.probeCount = 4;
} else {
reading_.probeCount = min(static_cast<uint8_t>(data[4]), static_cast<uint8_t>(4));
}
size_t probeOffset = 5;
for (size_t i = 0; i < 4; ++i) {
float temp = -999.0f;
size_t offset = probeOffset + i * 2;
if (offset + 1 < length) {
temp = decodeTemperature_(data[offset], data[offset + 1]);
}
reading_.rawTemperatures[i] = temp;
}
reading_.lastUpdateMillis = millis();
bool wasConnected = reading_.connected;
reading_.connected = true;
Serial.printf("ThermoproBLE: battery=%d%% temps=", reading_.battery);
for (size_t i = 0; i < 4; ++i) {
if (isValidTemperature(reading_.rawTemperatures[i])) {
Serial.printf("%.1f ", reading_.rawTemperatures[i]);
} else {
Serial.print("--- ");
}
}
Serial.println();
if (stateCallback_) {
stateCallback_(reading_);
}
}
float ThermoproBLE::decodeTemperature_(uint8_t byte1, uint8_t byte2) {
if (byte1 == 0xFF && byte2 == 0xFF) {
return -999.0f;
}
if (byte1 == 0xDD && byte2 == 0xDD) {
return -100.0f;
}
if (byte1 == 0xEE && byte2 == 0xEE) {
return 666.0f;
}
bool isNegative = (byte1 & 0x80) != 0;
int hundreds = ((byte1 & 0x70) >> 4) * 100;
int tens = (byte1 & 0x0F) * 10;
int ones = (byte2 & 0xF0) >> 4;
float decimal = (byte2 & 0x0F) * 0.1f;
float temp = static_cast<float>(hundreds + tens + ones) + decimal;
if (isNegative) {
temp = -temp;
}
return temp;
}
uint8_t ThermoproBLE::calculateChecksum_(const uint8_t* data, size_t len) {
uint16_t sum = 0;
for (size_t i = 0; i < len; ++i) {
sum += data[i];
}
return static_cast<uint8_t>(sum & 0xFF);
}
bool ThermoproBLE::isThermoproDevice_(const String& name) {
String lower = name;
lower.toLowerCase();
return lower.indexOf("thermo") >= 0 || (name.length() >= 2 && name.startsWith("TP"));
}
} // namespace cyd
+102
View File
@@ -0,0 +1,102 @@
#pragma once
#include "ThermoproReading.hpp"
#include <NimBLEDevice.h>
#include <functional>
namespace cyd {
// NimBLE-based client for the ThermoPro BLE BBQ thermometer protocol.
// Replicates the behavior of thermopro_cli.py from the proof of concept.
class ThermoproBLE {
public:
using StateChangeCallback = std::function<void(const ThermoproReading&)>;
ThermoproBLE();
// Set the target BLE address before calling connect().
void setAddress(const String& mac);
// Initialize the BLE stack and register this instance as the client.
bool init(const String& deviceName);
// Non-blocking connect. Returns true if the async connect started successfully.
bool startConnect();
// Call this frequently from loop(). Handles connection state machine,
// reconnection backoff, and notifications.
void update();
// Disconnect cleanly.
void disconnect();
// Latest decoded reading, thread-safe enough because NimBLE callbacks run on the
// same core/loop in Arduino by default.
ThermoproReading getReading() const { return reading_; }
// Register a callback invoked whenever the reading state changes.
void onStateChange(StateChangeCallback cb) { stateCallback_ = cb; }
private:
// BLE UUIDs (ThermoPro custom service)
static inline const NimBLEUUID kServiceUuid = NimBLEUUID("1086FFF0-3343-4817-8BB2-B32206336CE8");
static inline const NimBLEUUID kNotifyUuid = NimBLEUUID("1086FFF2-3343-4817-8BB2-B32206336CE8");
static inline const NimBLEUUID kWriteUuid = NimBLEUUID("1086FFF1-3343-4817-8BB2-B32206336CE8");
// 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 };
enum class State {
Idle,
Scanning,
Connecting,
Discovering,
EnablingNotify,
SendingHandshake,
Running,
Reconnecting
};
String address_;
State state_ = State::Idle;
ThermoproReading reading_;
StateChangeCallback stateCallback_;
NimBLEClient* client_ = nullptr;
NimBLERemoteCharacteristic* notifyChar_ = nullptr;
NimBLERemoteCharacteristic* writeChar_ = nullptr;
unsigned long stateStartMillis_ = 0;
unsigned long lastActivityMillis_ = 0;
uint8_t reconnectFailures_ = 0;
unsigned long reconnectDelayMs_ = 5000;
bool callbacksRegistered_ = false;
bool scanAndConnect_();
static bool connectToServer_(NimBLEAdvertisedDevice* device, void* userData);
bool setupConnection_(NimBLEClient* client);
void setConnected_(bool connected);
void startReconnectDelay_();
// Notification handler (called from NimBLE task).
static void notifyCallback_(BLERemoteCharacteristic* characteristic, uint8_t* data, size_t length, bool isNotify);
void handlePacket_(const uint8_t* data, size_t length);
void parseTemperaturePacket_(const uint8_t* data, size_t length);
// Reusable scan callback. Uses the global instance pointer, so only one
// ThermoproBLE object may be active at a time.
class ScanCallbacks : public NimBLEAdvertisedDeviceCallbacks {
public:
void onResult(NimBLEAdvertisedDevice* device) override;
};
ScanCallbacks scanCallbacks_;
// Helpers
static float decodeTemperature_(uint8_t byte1, uint8_t byte2);
static uint8_t calculateChecksum_(const uint8_t* data, size_t len);
static bool isThermoproDevice_(const String& name);
static ThermoproBLE* instance_;
};
} // namespace cyd
+42
View File
@@ -0,0 +1,42 @@
#pragma once
#include <Arduino.h>
#include <array>
namespace cyd {
// Holds the latest decoded state from the ThermoPro thermometer.
struct ThermoproReading {
bool connected = false;
uint8_t battery = 0;
char deviceUnit = 'C'; // unit reported by the device ('C' or 'F')
uint8_t probeCount = 4; // number of probe slots the device advertises
std::array<float, 4> rawTemperatures = { -999.0f, -999.0f, -999.0f, -999.0f };
unsigned long lastUpdateMillis = 0; // device-local millis of last notification
};
// Returns true if a raw temperature value is a real reading (not a sentinel).
inline bool isValidTemperature(float t) {
return t != -999.0f && t != -100.0f && t != 666.0f;
}
// Convert a raw temperature between Celsius and Fahrenheit.
inline float convertTemperature(float t, char from, char to) {
if (!isValidTemperature(t)) {
return t;
}
char f = static_cast<char>(toupper(from));
char tt = static_cast<char>(toupper(to));
if (f == tt) {
return t;
}
if (f == 'C' && tt == 'F') {
return t * 9.0f / 5.0f + 32.0f;
}
if (f == 'F' && tt == 'C') {
return (t - 32.0f) * 5.0f / 9.0f;
}
return t;
}
} // namespace cyd
+172
View File
@@ -0,0 +1,172 @@
#include "Dashboard.hpp"
#include <WiFi.h>
#include <math.h>
namespace cyd {
Dashboard::Dashboard(CYD_Display& display, SettingsManager& settings, ThermoproBLE& bleClient)
: display_(display), settings_(settings), bleClient_(bleClient) {
}
void Dashboard::begin() {
display_.init();
display_.initBacklight();
display_.setRotation(0); // Portrait 240x320
display_.fillScreen(TFT_BLACK);
display_.setTextDatum(middle_center);
drawStatusBar_();
for (size_t i = 0; i < 4; ++i) {
int x = (i % 2) * 120;
int y = 40 + (i / 2) * 110;
drawProbeTile_(i, x, y, 120, 110);
}
drawFooter_("Touch here for settings");
}
void Dashboard::update(bool force) {
if (!force && millis() - lastUpdateMillis_ < 1000) {
return;
}
lastUpdateMillis_ = millis();
ThermoproReading reading = bleClient_.getReading();
char targetUnit = settings_.getUnit();
if (force || reading.connected != lastBleConnected_ || reading.battery != lastBattery_) {
lastBleConnected_ = reading.connected;
lastBattery_ = reading.battery;
drawStatusBar_();
}
for (size_t i = 0; i < 4; ++i) {
float raw = reading.rawTemperatures[i];
bool conn = isValidTemperature(raw);
float displayTemp = conn ? convertTemperature(raw, reading.deviceUnit, targetUnit) : -9999.0f;
if (force || conn != lastConnected_[i] || fabsf(displayTemp - lastTemps_[i]) > 0.05f) {
lastConnected_[i] = conn;
lastTemps_[i] = displayTemp;
int x = (i % 2) * 120;
int y = 40 + (i / 2) * 110;
drawProbeTile_(i, x, y, 120, 110);
}
}
String footer = "IP: " + WiFi.localIP().toString();
if (WiFi.status() != WL_CONNECTED) {
footer = "AP: CYD-ThermoPro-Setup";
}
drawFooter_(footer);
}
bool Dashboard::checkTouch() {
uint16_t x, y;
if (display_.getTouch(&x, &y)) {
// The footer area is at the bottom 40 px of the screen in portrait mode.
if (y > 280) {
// Debounce: consume all touch events for 300 ms.
unsigned long start = millis();
while (display_.getTouch(&x, &y) && millis() - start < 300) {
delay(10);
}
return true;
}
}
return false;
}
void Dashboard::showMessage(const String& title, const String& line2, const String& line3) {
display_.fillScreen(TFT_BLACK);
display_.setTextColor(TFT_WHITE, TFT_BLACK);
display_.setTextDatum(top_center);
display_.setTextSize(2);
display_.drawString(title.c_str(), display_.width() / 2, 60);
display_.setTextSize(1);
if (line2.length()) {
display_.drawString(line2.c_str(), display_.width() / 2, 120);
}
if (line3.length()) {
display_.drawString(line3.c_str(), display_.width() / 2, 150);
}
display_.setTextDatum(middle_center); // restore default
}
void Dashboard::drawStatusBar_() {
int w = display_.width();
display_.fillRect(0, 0, w, 36, TFT_DARKGREY);
display_.drawLine(0, 36, w, 36, TFT_LIGHTGREY);
ThermoproReading reading = bleClient_.getReading();
// WiFi icon placeholder: small circle + text
display_.setTextColor(TFT_WHITE, TFT_DARKGREY);
display_.setTextDatum(middle_left);
display_.setTextSize(1);
display_.drawString("WiFi", 4, 18);
String wifiStatus = (WiFi.status() == WL_CONNECTED) ? "OK" : "--";
display_.drawString(wifiStatus.c_str(), 34, 18);
// BLE status
display_.setTextDatum(middle_right);
String ble = reading.connected ? "BLE OK" : "BLE --";
display_.drawString(ble.c_str(), w - 54, 18);
// Battery
String batt = String(reading.battery) + "%";
display_.drawString(batt.c_str(), w - 4, 18);
}
void Dashboard::drawProbeTile_(size_t index, int x, int y, int tileW, int tileH) {
ThermoproReading reading = bleClient_.getReading();
char targetUnit = settings_.getUnit();
float raw = reading.rawTemperatures[index];
bool connected = isValidTemperature(raw);
float temp = connected ? convertTemperature(raw, reading.deviceUnit, targetUnit) : -9999.0f;
// Background
uint16_t bgColor = (index % 2 == 0) ? TFT_DARKGREY : TFT_BLACK;
display_.fillRect(x + 1, y + 1, tileW - 2, tileH - 2, bgColor);
display_.drawRect(x, y, tileW, tileH, TFT_LIGHTGREY);
// Probe name
display_.setTextColor(TFT_WHITE, bgColor);
display_.setTextDatum(top_center);
display_.setTextSize(1);
display_.drawString(settings_.getProbeName(index).c_str(), x + tileW / 2, y + 8);
// Temperature
display_.setTextSize(2);
if (connected) {
String t = formatTemperature_(temp);
display_.drawString(t.c_str(), x + tileW / 2, y + tileH / 2 - 8);
display_.setTextSize(1);
display_.drawString((String("°") + targetUnit).c_str(), x + tileW / 2, y + tileH / 2 + 22);
} else {
display_.setTextColor(TFT_LIGHTGREY, bgColor);
display_.drawString("---", x + tileW / 2, y + tileH / 2);
}
// Connection dot
display_.fillCircle(x + tileW - 12, y + 12, 4, connected ? TFT_GREEN : TFT_RED);
}
void Dashboard::drawFooter_(const String& message) {
int y = display_.height() - 40;
display_.fillRect(0, y, display_.width(), 40, TFT_NAVY);
display_.drawLine(0, y, display_.width(), y, TFT_LIGHTGREY);
display_.setTextColor(TFT_WHITE, TFT_NAVY);
display_.setTextDatum(middle_center);
display_.setTextSize(1);
display_.drawString(message.c_str(), display_.width() / 2, y + 20);
}
String Dashboard::formatTemperature_(float temp) {
char buf[12];
snprintf(buf, sizeof(buf), "%.1f", temp);
return String(buf);
}
} // namespace cyd
+45
View File
@@ -0,0 +1,45 @@
#pragma once
#include "DisplayConfig.hpp"
#include "settings/SettingsManager.hpp"
#include "ble/ThermoproBLE.hpp"
#include <array>
namespace cyd {
// Simple touch dashboard for the CYD: status bar + four probe tiles + footer.
class Dashboard {
public:
Dashboard(CYD_Display& display, SettingsManager& settings, ThermoproBLE& bleClient);
// Initialize display and draw the static chrome.
void begin();
// Refresh dynamic content. Call at most a few times per second.
void update(bool force = false);
// Check for touch events and return true if the settings "button" was hit.
bool checkTouch();
// Draw a full-screen message (used during setup/error states).
void showMessage(const String& title, const String& line2 = "", const String& line3 = "");
private:
CYD_Display& display_;
SettingsManager& settings_;
ThermoproBLE& bleClient_;
bool lastBleConnected_ = false;
uint8_t lastBattery_ = 0;
std::array<float, 4> lastTemps_ = { -9999.0f, -9999.0f, -9999.0f, -9999.0f };
std::array<bool, 4> lastConnected_ = { false, false, false, false };
unsigned long lastUpdateMillis_ = 0;
void drawStatusBar_();
void drawProbeTile_(size_t index, int x, int y, int w, int h);
void drawFooter_(const String& message);
static String formatTemperature_(float temp);
};
} // namespace cyd
+90
View File
@@ -0,0 +1,90 @@
#pragma once
// 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):
// SDO/MISO GPIO 19
// LED GPIO 21 (backlight, active high)
// SCK GPIO 18
// SDI/MOSI GPIO 23
// DC GPIO 2
// RESET GPIO 4
// CS GPIO 15
// TOUCH CS GPIO 5
// TOUCH IRQ GPIO - (not used by default)
#define LGFX_USE_V1
#include <LovyanGFX.hpp>
namespace cyd {
class CYD_Display : public lgfx::LGFX_Device {
public:
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
+176
View File
@@ -0,0 +1,176 @@
#include <Arduino.h>
#include "settings/SettingsManager.hpp"
#include "ble/ThermoproBLE.hpp"
#include "ble/ThermoproReading.hpp"
#include "wifi/WiFiManager.hpp"
#include "api/ApiServer.hpp"
#include "display/DisplayConfig.hpp"
#include "display/Dashboard.hpp"
using cyd::ApiServer;
using cyd::CYD_Display;
using cyd::Dashboard;
using cyd::SettingsManager;
using cyd::ThermoproBLE;
using cyd::ThermoproReading;
using cyd::WiFiManager;
namespace {
SettingsManager settings;
ThermoproBLE bleClient;
CYD_Display display;
Dashboard dashboard(display, settings, bleClient);
WiFiManager wifi(settings);
ApiServer apiServer(settings, bleClient);
enum class AppState {
Init,
SetupAP,
Connecting,
Running,
Error
};
AppState appState = AppState::Init;
unsigned long lastHeapLog = 0;
unsigned long lastDashboardUpdate = 0;
void requestReboot() {
delay(500);
ESP.restart();
}
void onWifiModeChange(bool apMode) {
apiServer.setSetupMode(apMode);
if (apMode) {
appState = AppState::SetupAP;
dashboard.showMessage("Setup Mode", "Connect to AP:", "CYD-ThermoPro-Setup");
} else {
appState = AppState::Connecting;
}
}
} // namespace
void setup() {
Serial.begin(115200);
while (!Serial && millis() < 2000) {
;
}
Serial.println("\n=== CYD ThermoPro Bridge ===");
Serial.printf("ESP32 chip model: %s, revision: %d\n", ESP.getChipModel(), ESP.getChipRevision());
Serial.printf("Flash size: %d MB\n", ESP.getFlashChipSize() / (1024 * 1024));
Serial.printf("Free heap at boot: %d bytes\n", ESP.getFreeHeap());
if (!settings.begin()) {
Serial.println("ERROR: failed to initialize settings storage");
} else {
Serial.println("Settings storage initialized");
Serial.printf("Device ID: %s\n", settings.getDeviceId().c_str());
Serial.printf("ThermoPro MAC: %s\n", settings.getThermoproMac().c_str());
}
// Initialize display early so we can show status on the screen.
dashboard.begin();
// Register callbacks
wifi.onModeChange(onWifiModeChange);
apiServer.onRebootRequested(requestReboot);
// Initialize BLE
if (!settings.hasThermoproMac()) {
Serial.println("No ThermoPro MAC configured — starting setup AP");
wifi.enterSetupMode();
} else {
bleClient.setAddress(settings.getThermoproMac());
bleClient.init("cyd-thermopro");
wifi.begin();
}
// Start the HTTP API last, after WiFi/BLE mode is decided.
apiServer.begin(80);
Serial.println("Setup complete — entering main loop");
}
void loop() {
wifi.update();
apiServer.update();
bleClient.update();
// Heartbeat logging
if (millis() - lastHeapLog >= 10000) {
lastHeapLog = millis();
Serial.printf("[heartbeat] state=%d free_heap=%d wifi=%s ble=%s\n",
static_cast<int>(appState),
ESP.getFreeHeap(),
wifi.isConnected() ? "connected" : "not-connected",
bleClient.getReading().connected ? "connected" : "disconnected");
}
switch (appState) {
case AppState::Init:
// Transitioned via onWifiModeChange callback.
break;
case AppState::SetupAP:
if (wifi.isConnected()) {
// User submitted setup form and device rebooted into station mode.
appState = AppState::Connecting;
}
break;
case AppState::Connecting:
if (wifi.isConnected()) {
Serial.println("WiFi connected — starting BLE and dashboard");
appState = AppState::Running;
dashboard.begin();
if (settings.hasThermoproMac()) {
bleClient.setAddress(settings.getThermoproMac());
bleClient.startConnect();
}
} else if (wifi.isApMode()) {
Serial.println("WiFi failed — back in setup mode");
appState = AppState::SetupAP;
}
break;
case AppState::Running: {
if (!wifi.isConnected()) {
Serial.println("WiFi lost during run");
appState = AppState::Connecting;
break;
}
// Start BLE if not already trying.
ThermoproReading reading = bleClient.getReading();
if (!settings.hasThermoproMac()) {
dashboard.showMessage("No MAC configured", "Open setup at", wifi.getIpAddress());
break;
}
// Dashboard refresh
if (millis() - lastDashboardUpdate >= 1000) {
lastDashboardUpdate = millis();
dashboard.update();
}
// Touch settings button
if (dashboard.checkTouch()) {
Serial.println("Settings button touched — entering setup AP");
wifi.enterSetupMode();
}
break;
}
case AppState::Error:
dashboard.showMessage("Error", "Check serial log", "");
delay(5000);
ESP.restart();
break;
}
delay(1); // yield to RTOS tasks
}
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include <Arduino.h>
#include <array>
namespace cyd {
// Stored in NVS via Preferences. Keep this struct simple and POD-like.
struct Settings {
// WiFi credentials
String wifiSsid;
String wifiPass;
// ThermoPro BLE
String thermoproMac; // e.g. "C9:48:1D:B1:E1:E7"
// HTTP API that the CYD exposes to frontends. Optional destination URL if the
// device should also POST readings somewhere else.
String apiEndpoint; // e.g. "http://192.168.1.50/api/thermopro/readings"
String apiToken; // shared secret for X-Bridge-Token when forwarding
// Behaviour
uint16_t pollIntervalSec = 15; // how often to read / refresh state
char unit = 'F'; // 'F' or 'C'
String deviceId = "cyd-smoker";
// Probe labels, indexed 0..3 for probes 1..4.
std::array<String, 4> probeNames = {
"Probe 1", "Probe 2", "Probe 3", "Probe 4"
};
// Version marker to allow future migrations.
uint16_t version = 1;
};
} // namespace cyd
+173
View File
@@ -0,0 +1,173 @@
#include "SettingsManager.hpp"
#include <ArduinoJson.h>
#include <Preferences.h>
namespace cyd {
SettingsManager::SettingsManager() = default;
SettingsManager::~SettingsManager() {
end();
}
bool SettingsManager::begin() {
if (begun_) {
return true;
}
begun_ = prefs_.begin(kNamespace, false);
if (begun_) {
load();
}
return begun_;
}
void SettingsManager::end() {
if (begun_) {
prefs_.end();
begun_ = false;
}
}
void SettingsManager::resetToDefaults() {
Settings defaults;
settings_ = defaults;
}
bool SettingsManager::load() {
if (!begun_) {
return false;
}
size_t len = prefs_.getBytesLength(kSettingsKey);
if (len == 0) {
Serial.println("Settings: no stored settings found, using defaults");
resetToDefaults();
return true;
}
std::vector<char> buffer;
buffer.resize(len);
size_t read = prefs_.getBytes(kSettingsKey, buffer.data(), len);
if (read != len) {
Serial.println("Settings: read size mismatch, resetting");
resetToDefaults();
return false;
}
JsonDocument doc;
DeserializationError err = deserializeJson(doc, buffer.data(), len);
if (err != DeserializationError::Ok) {
Serial.printf("Settings: JSON parse error: %s\n", err.c_str());
resetToDefaults();
return false;
}
uint16_t storedVersion = doc["version"] | 0;
if (storedVersion != kCurrentVersion) {
Serial.printf("Settings: migrating from version %d to %d\n", storedVersion, kCurrentVersion);
migrateFrom(storedVersion);
return true;
}
settings_.wifiSsid = doc["wifiSsid"] | "";
settings_.wifiPass = doc["wifiPass"] | "";
settings_.thermoproMac = doc["thermoproMac"] | "";
settings_.apiEndpoint = doc["apiEndpoint"] | "";
settings_.apiToken = doc["apiToken"] | "";
settings_.pollIntervalSec = doc["pollIntervalSec"] | 15;
settings_.unit = doc["unit"] | "F";
settings_.deviceId = doc["deviceId"] | "cyd-smoker";
JsonArray names = doc["probeNames"].as<JsonArray>();
for (size_t i = 0; i < settings_.probeNames.size(); ++i) {
if (i < names.size()) {
settings_.probeNames[i] = names[i].as<String>();
} else {
settings_.probeNames[i] = "Probe " + String(i + 1);
}
}
Serial.println("Settings: loaded from NVS");
return true;
}
bool SettingsManager::save() {
if (!begun_) {
return false;
}
JsonDocument doc;
doc["version"] = kCurrentVersion;
doc["wifiSsid"] = settings_.wifiSsid;
doc["wifiPass"] = settings_.wifiPass;
doc["thermoproMac"] = settings_.thermoproMac;
doc["apiEndpoint"] = settings_.apiEndpoint;
doc["apiToken"] = settings_.apiToken;
doc["pollIntervalSec"] = settings_.pollIntervalSec;
doc["unit"] = String(settings_.unit);
doc["deviceId"] = settings_.deviceId;
JsonArray names = doc["probeNames"].to<JsonArray>();
for (const auto& name : settings_.probeNames) {
names.add(name);
}
size_t size = measureJson(doc);
std::vector<char> buffer;
buffer.resize(size);
serializeJson(doc, buffer.data(), size);
prefs_.putBytes(kSettingsKey, buffer.data(), size);
Serial.println("Settings: saved to NVS");
return true;
}
String SettingsManager::getProbeName(size_t index) const {
if (index < settings_.probeNames.size()) {
return settings_.probeNames[index];
}
return "Probe " + String(index + 1);
}
void SettingsManager::setProbeName(size_t index, const String& value) {
if (index < settings_.probeNames.size()) {
settings_.probeNames[index] = value.length() ? value : ("Probe " + String(index + 1));
}
}
bool SettingsManager::hasThermoproMac() const {
return isValidMac(settings_.thermoproMac);
}
bool SettingsManager::hasWifiCredentials() const {
return settings_.wifiSsid.length() > 0;
}
bool SettingsManager::isValidMac(const String& mac) {
if (mac.length() != 17) {
return false;
}
for (int i = 0; i < mac.length(); ++i) {
char c = mac.charAt(i);
if ((i + 1) % 3 == 0) {
if (c != ':') {
return false;
}
} else {
if (!isxdigit(c)) {
return false;
}
}
}
return true;
}
void SettingsManager::migrateFrom(uint16_t oldVersion) {
// For now, any version mismatch simply resets to defaults. As the schema
// evolves we can add per-version migrations here.
(void)oldVersion;
resetToDefaults();
save();
}
} // namespace cyd
@@ -0,0 +1,68 @@
#pragma once
#include "Settings.hpp"
#include <Preferences.h>
namespace cyd {
// Wraps Preferences (NVS) for all user configuration.
class SettingsManager {
public:
SettingsManager();
~SettingsManager();
// Initialize the underlying Preferences namespace. Call once in setup().
bool begin();
void end();
// Load / save the full settings structure.
bool load();
bool save();
// Reset to sensible defaults (does not write until save() is called).
void resetToDefaults();
// Accessors
const Settings& get() const { return settings_; }
Settings& mutableGet() { return settings_; }
String getDeviceId() const { return settings_.deviceId; }
String getThermoproMac() const { return settings_.thermoproMac; }
String getWifiSsid() const { return settings_.wifiSsid; }
String getWifiPass() const { return settings_.wifiPass; }
String getApiEndpoint() const { return settings_.apiEndpoint; }
String getApiToken() const { return settings_.apiToken; }
uint16_t getPollIntervalSec() const { return settings_.pollIntervalSec; }
char getUnit() const { return settings_.unit; }
String getProbeName(size_t index) const;
// Mutators (must call save() to persist)
void setWifiSsid(const String& value) { settings_.wifiSsid = value; }
void setWifiPass(const String& value) { settings_.wifiPass = value; }
void setThermoproMac(const String& value) { settings_.thermoproMac = value; }
void setApiEndpoint(const String& value) { settings_.apiEndpoint = value; }
void setApiToken(const String& value) { settings_.apiToken = value; }
void setPollIntervalSec(uint16_t value) { settings_.pollIntervalSec = constrain(value, 1, 600); }
void setUnit(char value) { settings_.unit = (value == 'C' || value == 'c') ? 'C' : 'F'; }
void setProbeName(size_t index, const String& value);
void setDeviceId(const String& value) { settings_.deviceId = value.length() ? value : "cyd-smoker"; }
// Validation helpers
bool hasThermoproMac() const;
bool hasWifiCredentials() const;
static bool isValidMac(const String& mac);
private:
static constexpr const char* kNamespace = "cyd-bridge";
static constexpr const char* kSettingsKey = "settings-v1";
static constexpr uint16_t kCurrentVersion = 1;
Preferences prefs_;
Settings settings_;
bool begun_ = false;
// Migration entry point. Called from load() when stored version differs.
void migrateFrom(uint16_t oldVersion);
};
} // namespace cyd
+222
View File
@@ -0,0 +1,222 @@
#include "WiFiManager.hpp"
#include <ArduinoJson.h>
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) {
}
void WiFiManager::begin() {
WiFi.disconnect(true);
delay(100);
if (settings_.hasWifiCredentials()) {
Serial.printf("WiFiManager: loaded SSID '%s', connecting...\n", settings_.getWifiSsid().c_str());
startStation_();
} else {
Serial.println("WiFiManager: no credentials stored, starting setup AP");
startAccessPoint_();
}
}
void WiFiManager::update() {
switch (state_) {
case State::Uninitialized:
break;
case State::Connecting:
if (WiFi.status() == WL_CONNECTED) {
Serial.printf("WiFiManager: connected, IP %s\n", WiFi.localIP().toString().c_str());
state_ = State::StationConnected;
if (modeCallback_) {
modeCallback_(false);
}
return;
}
if (millis() - connectStartMillis_ > kConnectTimeoutMs) {
Serial.println("WiFiManager: station connection timeout");
WiFi.disconnect(true);
state_ = State::StationFailed;
startAccessPoint_();
}
break;
case State::StationConnected:
if (WiFi.status() != WL_CONNECTED) {
Serial.println("WiFiManager: station lost connection, retrying");
state_ = State::Connecting;
connectStartMillis_ = millis();
WiFi.reconnect();
}
break;
case State::AccessPoint:
case State::FallbackToAp:
if (portalRunning_) {
dnsServer_.processNextRequest();
portalServer_.handleClient();
}
break;
case State::StationFailed:
// Should have transitioned to AP above.
break;
}
}
String WiFiManager::getIpAddress() const {
if (isApMode()) {
return WiFi.softAPIP().toString();
}
if (state_ == State::StationConnected) {
return WiFi.localIP().toString();
}
return "0.0.0.0";
}
void WiFiManager::enterSetupMode() {
if (state_ == State::StationConnected || state_ == State::Connecting) {
WiFi.disconnect(true);
}
startAccessPoint_();
}
void WiFiManager::configureAndConnect(const String& ssid, const String& pass) {
settings_.setWifiSsid(ssid);
settings_.setWifiPass(pass);
settings_.save();
stopAccessPoint_();
startStation_();
}
void WiFiManager::startStation_() {
state_ = State::Connecting;
connectStartMillis_ = millis();
WiFi.mode(WIFI_STA);
WiFi.begin(settings_.getWifiSsid().c_str(), settings_.getWifiPass().c_str());
if (modeCallback_) {
modeCallback_(false);
}
}
void WiFiManager::startAccessPoint_() {
state_ = State::AccessPoint;
WiFi.mode(WIFI_AP);
WiFi.softAP(kApSsid, kApPass);
IPAddress ip = WiFi.softAPIP();
Serial.printf("WiFiManager: AP '%s' started at %s\n", kApSsid, ip.toString().c_str());
dnsServer_.start(53, "*", ip);
portalServer_.on("/", HTTP_GET, [this]() { handlePortalRoot_(); });
portalServer_.on("/setup", HTTP_POST, [this]() { handlePortalSubmit_(); });
portalServer_.onNotFound([this]() { handlePortalNotFound_(); });
portalServer_.begin();
portalRunning_ = true;
if (modeCallback_) {
modeCallback_(true);
}
}
void WiFiManager::stopAccessPoint_() {
portalRunning_ = false;
portalServer_.close();
dnsServer_.stop();
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
+74
View File
@@ -0,0 +1,74 @@
#pragma once
#include "settings/SettingsManager.hpp"
#include <WiFi.h>
#include <DNSServer.h>
#include <WebServer.h>
#include <functional>
namespace cyd {
// Manages station WiFi connection and a captive-portal AP for first-time setup.
class WiFiManager {
public:
using ModeChangeCallback = std::function<void(bool apMode)>;
enum class State {
Uninitialized,
Connecting,
StationConnected,
StationFailed,
AccessPoint,
FallbackToAp
};
explicit WiFiManager(SettingsManager& settings);
// Load credentials and either connect as station or start AP.
void begin();
// Non-blocking update. Must be called frequently from loop().
void update();
State getState() const { return state_; }
bool isApMode() const { return state_ == State::AccessPoint || state_ == State::FallbackToAp; }
bool isConnected() const { return state_ == State::StationConnected; }
String getIpAddress() const;
void onModeChange(ModeChangeCallback cb) { modeCallback_ = cb; }
// Start the AP+captive portal explicitly (e.g. from a "settings" UI button).
void enterSetupMode();
// Persist new credentials and try to connect. Useful from the API server.
void configureAndConnect(const String& ssid, const String& pass);
private:
SettingsManager& settings_;
State state_ = State::Uninitialized;
ModeChangeCallback modeCallback_;
// Station connect timing
unsigned long connectStartMillis_ = 0;
static constexpr unsigned long kConnectTimeoutMs = 20000;
// Captive portal
DNSServer dnsServer_;
WebServer portalServer_{80};
bool portalRunning_ = false;
unsigned long lastPortalClientMillis_ = 0;
static constexpr const char* kApSsid = "CYD-ThermoPro-Setup";
static constexpr const char* kApPass = "";
static constexpr const char* kSetupDomain = "cyd.setup";
void startStation_();
void startAccessPoint_();
void stopAccessPoint_();
void handlePortalRoot_();
void handlePortalSubmit_();
void handlePortalNotFound_();
static void redirectToPortal_(WebServer& server);
};
} // namespace cyd