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
+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