diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..9abcd61 --- /dev/null +++ b/PLAN.md @@ -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. diff --git a/cyd-bridge/.gitignore b/cyd-bridge/.gitignore new file mode 100644 index 0000000..6e9a7f6 --- /dev/null +++ b/cyd-bridge/.gitignore @@ -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/ diff --git a/cyd-bridge/README.md b/cyd-bridge/README.md new file mode 100644 index 0000000..3b9e734 --- /dev/null +++ b/cyd-bridge/README.md @@ -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. diff --git a/cyd-bridge/platformio.ini b/cyd-bridge/platformio.ini new file mode 100644 index 0000000..1712e2b --- /dev/null +++ b/cyd-bridge/platformio.ini @@ -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 diff --git a/cyd-bridge/src/api/ApiServer.cpp b/cyd-bridge/src/api/ApiServer.cpp new file mode 100644 index 0000000..eda3478 --- /dev/null +++ b/cyd-bridge/src/api/ApiServer.cpp @@ -0,0 +1,294 @@ +#include "ApiServer.hpp" +#include +#include + +namespace cyd { + +namespace { +constexpr const char* htmlHead = R"rawliteral( + + + + + + CYD ThermoPro Bridge + + + +)rawliteral"; + +constexpr const char* htmlFoot = R"rawliteral( + + +)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(); + 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(); + for (size_t i = 0; i < 4; ++i) { + JsonObject p = probes.add(); + 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(); + 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()) { + settings_.setWifiSsid(doc["wifiSsid"].as()); + changed = true; + } + if (doc["wifiPass"].is()) { + String pass = doc["wifiPass"].as(); + // Allow empty string to clear the stored password, otherwise keep existing. + settings_.setWifiPass(pass); + changed = true; + } + if (doc["thermoproMac"].is()) { + String mac = doc["thermoproMac"].as(); + 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()) { + settings_.setApiEndpoint(doc["apiEndpoint"].as()); + changed = true; + } + if (doc["apiToken"].is()) { + settings_.setApiToken(doc["apiToken"].as()); + changed = true; + } + if (doc["pollIntervalSec"].is()) { + settings_.setPollIntervalSec(doc["pollIntervalSec"].as()); + changed = true; + } + if (doc["unit"].is()) { + String unit = doc["unit"].as(); + settings_.setUnit(unit.length() > 0 ? unit.charAt(0) : 'F'); + changed = true; + } + if (doc["deviceId"].is()) { + settings_.setDeviceId(doc["deviceId"].as()); + changed = true; + } + if (doc["probeNames"].is()) { + JsonArray arr = doc["probeNames"].as(); + for (size_t i = 0; i < 4 && i < arr.size(); ++i) { + settings_.setProbeName(i, arr[i].as()); + } + 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 += "

CYD ThermoPro Setup

\n"; + page += "

Connect this CYD to your WiFi network and enter your ThermoPro details.

\n"; + page += "
\n"; + page += R"rawliteral( + + + + +
Leave blank to keep the existing password.
+ + +
Format: AA:BB:CC:DD:EE:FF. Find it with a BLE scanner.
+ + + +
+ + +)rawliteral"; + page += htmlFoot; + return page; +} + +String ApiServer::buildStatusPage_() { + String page = htmlHead; + page += "

CYD ThermoPro Bridge

\n"; + page += "
\n"; + page += "

Status page

\n"; + page += "

Use the JSON API for programmatic access:

\n"; + page += " \n"; + page += "

POST JSON to /api/settings to change probe names, endpoint, interval, unit, etc.

\n"; + page += "
\n"; + page += htmlFoot; + return page; +} + +} // namespace cyd diff --git a/cyd-bridge/src/api/ApiServer.hpp b/cyd-bridge/src/api/ApiServer.hpp new file mode 100644 index 0000000..c81da0f --- /dev/null +++ b/cyd-bridge/src/api/ApiServer.hpp @@ -0,0 +1,50 @@ +#pragma once + +#include "settings/SettingsManager.hpp" +#include "ble/ThermoproBLE.hpp" +#include + +namespace cyd { + +// Serves the CYD's HTTP API and the setup/status web UI. +class ApiServer { +public: + using RebootRequestedCallback = std::function; + + 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 diff --git a/cyd-bridge/src/ble/ThermoproBLE.cpp b/cyd-bridge/src/ble/ThermoproBLE.cpp new file mode 100644 index 0000000..e106e2d --- /dev/null +++ b/cyd-bridge/src/ble/ThermoproBLE.cpp @@ -0,0 +1,350 @@ +#include "ThermoproBLE.hpp" +#include + +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(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(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(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(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(data[4]), static_cast(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(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(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 diff --git a/cyd-bridge/src/ble/ThermoproBLE.hpp b/cyd-bridge/src/ble/ThermoproBLE.hpp new file mode 100644 index 0000000..7a964cb --- /dev/null +++ b/cyd-bridge/src/ble/ThermoproBLE.hpp @@ -0,0 +1,102 @@ +#pragma once + +#include "ThermoproReading.hpp" +#include +#include + +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; + + 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 diff --git a/cyd-bridge/src/ble/ThermoproReading.hpp b/cyd-bridge/src/ble/ThermoproReading.hpp new file mode 100644 index 0000000..01979a7 --- /dev/null +++ b/cyd-bridge/src/ble/ThermoproReading.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include +#include + +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 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(toupper(from)); + char tt = static_cast(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 diff --git a/cyd-bridge/src/display/Dashboard.cpp b/cyd-bridge/src/display/Dashboard.cpp new file mode 100644 index 0000000..ca2d3b2 --- /dev/null +++ b/cyd-bridge/src/display/Dashboard.cpp @@ -0,0 +1,172 @@ +#include "Dashboard.hpp" +#include +#include + +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 diff --git a/cyd-bridge/src/display/Dashboard.hpp b/cyd-bridge/src/display/Dashboard.hpp new file mode 100644 index 0000000..c6aaf06 --- /dev/null +++ b/cyd-bridge/src/display/Dashboard.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include "DisplayConfig.hpp" +#include "settings/SettingsManager.hpp" +#include "ble/ThermoproBLE.hpp" +#include + +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 lastTemps_ = { -9999.0f, -9999.0f, -9999.0f, -9999.0f }; + std::array 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 diff --git a/cyd-bridge/src/display/DisplayConfig.hpp b/cyd-bridge/src/display/DisplayConfig.hpp new file mode 100644 index 0000000..335c306 --- /dev/null +++ b/cyd-bridge/src/display/DisplayConfig.hpp @@ -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 + +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 diff --git a/cyd-bridge/src/main.cpp b/cyd-bridge/src/main.cpp new file mode 100644 index 0000000..72a52e0 --- /dev/null +++ b/cyd-bridge/src/main.cpp @@ -0,0 +1,176 @@ +#include +#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(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 +} diff --git a/cyd-bridge/src/settings/Settings.hpp b/cyd-bridge/src/settings/Settings.hpp new file mode 100644 index 0000000..10fb7cd --- /dev/null +++ b/cyd-bridge/src/settings/Settings.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +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 probeNames = { + "Probe 1", "Probe 2", "Probe 3", "Probe 4" + }; + + // Version marker to allow future migrations. + uint16_t version = 1; +}; + +} // namespace cyd diff --git a/cyd-bridge/src/settings/SettingsManager.cpp b/cyd-bridge/src/settings/SettingsManager.cpp new file mode 100644 index 0000000..ad5b658 --- /dev/null +++ b/cyd-bridge/src/settings/SettingsManager.cpp @@ -0,0 +1,173 @@ +#include "SettingsManager.hpp" +#include +#include + +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 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(); + for (size_t i = 0; i < settings_.probeNames.size(); ++i) { + if (i < names.size()) { + settings_.probeNames[i] = names[i].as(); + } 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(); + for (const auto& name : settings_.probeNames) { + names.add(name); + } + + size_t size = measureJson(doc); + std::vector 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 diff --git a/cyd-bridge/src/settings/SettingsManager.hpp b/cyd-bridge/src/settings/SettingsManager.hpp new file mode 100644 index 0000000..8a83ae6 --- /dev/null +++ b/cyd-bridge/src/settings/SettingsManager.hpp @@ -0,0 +1,68 @@ +#pragma once + +#include "Settings.hpp" +#include + +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 diff --git a/cyd-bridge/src/wifi/WiFiManager.cpp b/cyd-bridge/src/wifi/WiFiManager.cpp new file mode 100644 index 0000000..1fb2468 --- /dev/null +++ b/cyd-bridge/src/wifi/WiFiManager.cpp @@ -0,0 +1,222 @@ +#include "WiFiManager.hpp" +#include + +namespace cyd { + +namespace { +constexpr const char* setupHtml = R"rawliteral( + + + + + + CYD ThermoPro Setup + + + +

CYD ThermoPro Setup

+

Enter your WiFi credentials and ThermoPro MAC address, then save.

+
+ + + + + + +
Format: AA:BB:CC:DD:EE:FF. Use a BLE scanner if you don't know it.
+ +
+ + +)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", "

Error

SSID and MAC are required.

Go back"); + return; + } + + if (!SettingsManager::isValidMac(mac)) { + portalServer_.send(422, "text/html", "

Error

Invalid MAC address format.

Go back"); + return; + } + + settings_.setWifiSsid(ssid); + settings_.setWifiPass(pass); + settings_.setThermoproMac(mac); + settings_.save(); + + String html = R"rawliteral( + + + + + + Saved + + + +

Settings saved

+

The CYD will reboot and connect to your WiFi network.

+

If it fails to connect, it will reopen this setup network.

+ + +)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 diff --git a/cyd-bridge/src/wifi/WiFiManager.hpp b/cyd-bridge/src/wifi/WiFiManager.hpp new file mode 100644 index 0000000..bc516c8 --- /dev/null +++ b/cyd-bridge/src/wifi/WiFiManager.hpp @@ -0,0 +1,74 @@ +#pragma once + +#include "settings/SettingsManager.hpp" +#include +#include +#include +#include + +namespace cyd { + +// Manages station WiFi connection and a captive-portal AP for first-time setup. +class WiFiManager { +public: + using ModeChangeCallback = std::function; + + 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