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:
@@ -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.
|
||||
@@ -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/
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user