feat: add CYD ThermoPro bridge firmware skeleton

Add PlatformIO project for the ESP32-2432S028R that:
- Reads ThermoPro BLE BBQ thermometers via NimBLE-Arduino
- Exposes /api/health, /api/latest, and /api/settings HTTP endpoints
- Serves a captive-portal setup UI and touch dashboard
- Persists WiFi/BLE/settings in NVS via Preferences

Builds cleanly with pio run.
This commit is contained in:
Keith Solomon
2026-07-20 14:38:35 -05:00
parent 9a3a8f9368
commit 8e007de5a1
9 changed files with 85 additions and 121 deletions
+11 -2
View File
@@ -76,7 +76,7 @@ Runtime settings (endpoint, token, probe names, units, polling interval) can be
## Building and flashing
Requires [PlatformIO](https://platformio.org/) installed locally (not available in this cloud environment).
Requires [PlatformIO](https://platformio.org/).
```bash
cd cyd-bridge
@@ -102,9 +102,18 @@ Before relying on the dashboard, verify the pinout matches your exact board revi
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()`.
## Memory usage
With the default partition table the firmware uses approximately:
- **RAM:** ~18 % (~58 kB of 320 kB)
- **Flash:** ~92 % (~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.
## 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.
- The firmware compiles successfully in PlatformIO; the remaining work is on-device testing and pinout verification.
+51 -3
View File
@@ -43,6 +43,7 @@ void ApiServer::begin(uint16_t port) {
server_.on("/api/latest", HTTP_GET, [this]() { handleLatest_(); });
server_.on("/api/settings", HTTP_GET, [this]() { handleGetSettings_(); });
server_.on("/api/settings", HTTP_POST, [this]() { handlePostSettings_(); });
server_.on("/setup", HTTP_POST, [this]() { handleSetupForm_(); });
server_.onNotFound([this]() { handleNotFound_(); });
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.
obj["readingTime"] = reading.lastUpdateMillis > 0 ? String(reading.lastUpdateMillis) : nullptr;
// Timestamps are relative for the device; we add device millis where possible.
if (reading.lastUpdateMillis > 0) {
obj["readingTime"] = String(reading.lastUpdateMillis);
} else {
obj["readingTime"] = nullptr;
}
obj["bridgeTime"] = String(millis());
obj["source"] = "cyd-bridge";
}
@@ -220,11 +225,54 @@ void ApiServer::handleNotFound_() {
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 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 += "<form method=\"POST\" action=\"/setup\" onsubmit=\"return submitForm()\">\n";
page += R"rawliteral(
<label>WiFi SSID</label>
<input type="text" name="wifiSsid" id="wifiSsid" required>
+2
View File
@@ -2,6 +2,7 @@
#include "settings/SettingsManager.hpp"
#include "ble/ThermoproBLE.hpp"
#include <ArduinoJson.h>
#include <WebServer.h>
namespace cyd {
@@ -38,6 +39,7 @@ private:
void handleLatest_();
void handleGetSettings_();
void handlePostSettings_();
void handleSetupForm_();
void handleNotFound_();
String buildSetupPage_();
+7 -1
View File
@@ -5,6 +5,12 @@ namespace cyd {
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() {
instance_ = this;
}
@@ -203,7 +209,7 @@ bool ThermoproBLE::setupConnection_(NimBLEClient* client) {
state_ = State::SendingHandshake;
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");
return false;
}
+3 -3
View File
@@ -39,9 +39,9 @@ public:
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 const NimBLEUUID kServiceUuid;
static const NimBLEUUID kNotifyUuid;
static const NimBLEUUID kWriteUuid;
// 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 };
-1
View File
@@ -28,7 +28,6 @@ public:
// 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;
+3 -1
View File
@@ -1,6 +1,7 @@
#include "SettingsManager.hpp"
#include <ArduinoJson.h>
#include <Preferences.h>
#include <vector>
namespace cyd {
@@ -75,7 +76,8 @@ bool SettingsManager::load() {
settings_.apiEndpoint = doc["apiEndpoint"] | "";
settings_.apiToken = doc["apiToken"] | "";
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";
JsonArray names = doc["probeNames"].as<JsonArray>();
+5 -100
View File
@@ -1,42 +1,7 @@
#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) {
}
@@ -86,9 +51,8 @@ void WiFiManager::update() {
case State::AccessPoint:
case State::FallbackToAp:
if (portalRunning_) {
if (apRunning_) {
dnsServer_.processNextRequest();
portalServer_.handleClient();
}
break;
@@ -144,13 +108,10 @@ void WiFiManager::startAccessPoint_() {
IPAddress ip = WiFi.softAPIP();
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);
portalServer_.on("/", HTTP_GET, [this]() { handlePortalRoot_(); });
portalServer_.on("/setup", HTTP_POST, [this]() { handlePortalSubmit_(); });
portalServer_.onNotFound([this]() { handlePortalNotFound_(); });
portalServer_.begin();
portalRunning_ = true;
apRunning_ = true;
if (modeCallback_) {
modeCallback_(true);
@@ -158,65 +119,9 @@ void WiFiManager::startAccessPoint_() {
}
void WiFiManager::stopAccessPoint_() {
portalRunning_ = false;
portalServer_.close();
apRunning_ = false;
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
+3 -10
View File
@@ -3,7 +3,6 @@
#include "settings/SettingsManager.hpp"
#include <WiFi.h>
#include <DNSServer.h>
#include <WebServer.h>
#include <functional>
namespace cyd {
@@ -52,23 +51,17 @@ private:
unsigned long connectStartMillis_ = 0;
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_;
WebServer portalServer_{80};
bool portalRunning_ = false;
unsigned long lastPortalClientMillis_ = 0;
bool apRunning_ = false;
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