From f03cebf322814dfddb7a6b8f7c684c721fd539bd Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Mon, 20 Jul 2026 18:58:12 -0500 Subject: [PATCH] fix: CYD BLE connection via service-UUID scan and clean connect flow - Scan for ThermoPro by advertised service UUID instead of relying only on MAC, solving resolvable-private-address issues after phone pairing. - Defer connect until after the scan is fully stopped to avoid NimBLE race. - Add connect cleanup, state-name diagnostics, and main-loop retry. - Switch display config to LGFX_AUTODETECT for correct CYD pinout. - Verified: CYD connects to ThermoPro and receives temperature notifications. --- cyd-bridge/src/ble/ThermoproBLE.cpp | 177 ++++++++++++++++------- cyd-bridge/src/ble/ThermoproBLE.hpp | 12 +- cyd-bridge/src/display/Dashboard.cpp | 14 +- cyd-bridge/src/display/DisplayConfig.hpp | 88 ++--------- cyd-bridge/src/main.cpp | 24 ++- 5 files changed, 175 insertions(+), 140 deletions(-) diff --git a/cyd-bridge/src/ble/ThermoproBLE.cpp b/cyd-bridge/src/ble/ThermoproBLE.cpp index 8008f41..389361b 100644 --- a/cyd-bridge/src/ble/ThermoproBLE.cpp +++ b/cyd-bridge/src/ble/ThermoproBLE.cpp @@ -19,17 +19,30 @@ 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; +const char* ThermoproBLE::getStateName() const { + switch (state_) { + case State::Idle: return "idle"; + case State::Scanning: return "scanning"; + case State::Connecting: return "connecting"; + case State::Discovering: return "discovering"; + case State::EnablingNotify: return "enabling-notify"; + case State::SendingHandshake: return "sending-handshake"; + case State::Running: return "running"; + case State::Reconnecting: return "reconnecting"; } + return "unknown"; +} +bool ThermoproBLE::init(const String& deviceName) { 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()); + if (address_.length() > 0) { + Serial.printf("ThermoproBLE: BLE initialized as '%s', target MAC %s\n", deviceName.c_str(), address_.c_str()); + } else { + Serial.printf("ThermoproBLE: BLE initialized as '%s', will scan for ThermoPro service UUID\n", deviceName.c_str()); + } state_ = State::Idle; return true; } @@ -57,16 +70,56 @@ void ThermoproBLE::update() { break; case State::Scanning: + // If we found a candidate during the scan, stop scanning and connect. + if (pendingConnectFound_) { + if (millis() - pendingConnectSeenMillis_ >= 200) { + NimBLEDevice::getScan()->stop(); + state_ = State::Connecting; + stateStartMillis_ = millis(); + connectToAddress_(pendingConnectAddress_); + } + break; + } + + if (millis() - stateStartMillis_ > 25000) { + Serial.println("ThermoproBLE: scan timeout, forcing reconnect"); + NimBLEDevice::getScan()->stop(); + setConnected_(false); + startReconnectDelay_(); + } + break; + case State::Connecting: + if (client_ && client_->isConnected()) { + Serial.println("ThermoproBLE: connected to server"); + if (!setupConnection_(client_)) { + Serial.println("ThermoproBLE: setup failed"); + cleanupClient_(); + setConnected_(false); + startReconnectDelay_(); + break; + } + state_ = State::Running; + stateStartMillis_ = millis(); + lastActivityMillis_ = millis(); + reconnectFailures_ = 0; + reconnectDelayMs_ = 5000; + break; + } + if (millis() - stateStartMillis_ > 25000) { + Serial.println("ThermoproBLE: connect timeout"); + cleanupClient_(); + setConnected_(false); + startReconnectDelay_(); + } + break; + 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(); - } + Serial.printf("ThermoproBLE: setup state timeout in state %d\n", static_cast(state_)); + cleanupClient_(); setConnected_(false); startReconnectDelay_(); } @@ -75,14 +128,14 @@ void ThermoproBLE::update() { case State::Running: { if (!client_ || !client_->isConnected()) { Serial.println("ThermoproBLE: connection lost, reconnecting"); + cleanupClient_(); 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(); + cleanupClient_(); setConnected_(false); startReconnectDelay_(); } @@ -99,9 +152,7 @@ void ThermoproBLE::update() { } void ThermoproBLE::disconnect() { - if (client_ && client_->isConnected()) { - client_->disconnect(); - } + cleanupClient_(); NimBLEDevice::deinit(true); setConnected_(false); state_ = State::Idle; @@ -111,22 +162,34 @@ void ThermoproBLE::ScanCallbacks::onResult(NimBLEAdvertisedDevice* device) { if (ThermoproBLE::instance_ == nullptr) { return; } + auto* self = ThermoproBLE::instance_; String addr(device->getAddress().toString().c_str()); String name(device->getName().c_str()); - bool matchesAddress = addr.equalsIgnoreCase(ThermoproBLE::instance_->address_); + bool matchesAddress = !self->address_.isEmpty() + && addr.equalsIgnoreCase(self->address_); bool matchesName = isThermoproDevice_(name); + bool matchesService = device->isAdvertisingService(ThermoproBLE::kServiceUuid); - 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()); + // Verbose scan logging during bringup. Print every device address + name. + Serial.printf("[ble-scan] %s rssi=%d name='%s' service_match=%s\n", + addr.c_str(), device->getRSSI(), name.c_str(), + matchesService ? "yes" : "no"); + + if (matchesAddress || matchesName || matchesService) { + Serial.printf("ThermoproBLE: discovered candidate %s (%s), addrType=%d\n", + name.c_str(), addr.c_str(), device->getAddress().getType()); + // Record the address for a connect initiated from update() once the + // scan has been stopped cleanly. Don't connect from inside the callback. + self->pendingConnectAddress_ = device->getAddress(); + self->pendingConnectFound_ = true; + self->pendingConnectSeenMillis_ = millis(); } } bool ThermoproBLE::scanAndConnect_() { + pendingConnectFound_ = false; + NimBLEScan* scanner = NimBLEDevice::getScan(); if (!callbacksRegistered_) { scanner->setAdvertisedDeviceCallbacks(&scanCallbacks_, false); @@ -139,46 +202,48 @@ bool ThermoproBLE::scanAndConnect_() { return true; } -bool ThermoproBLE::connectToServer_(NimBLEAdvertisedDevice* device, void* userData) { - auto* self = static_cast(userData); - if (!self) { +bool ThermoproBLE::connectToAddress_(const NimBLEAddress& address) { + state_ = State::Connecting; + stateStartMillis_ = millis(); + + bool addrTypeRandom = (address.getType() == BLE_ADDR_RANDOM); + Serial.printf("ThermoproBLE: connecting to %s, addrType=%s\n", + address.toString().c_str(), addrTypeRandom ? "random" : "public"); + + // The scan must already be stopped before this is called. + delay(300); + + client_ = NimBLEDevice::createClient(); + // Conservative connection parameters. + client_->setConnectionParams(12, 12, 0, 160); + client_->setConnectTimeout(20); + + bool ok = client_->connect(address, false); + if (!ok) { + Serial.println("ThermoproBLE: connect call rejected immediately"); + cleanupClient_(); + setConnected_(false); + startReconnectDelay_(); 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; + Serial.println("ThermoproBLE: connect call accepted"); return true; } +void ThermoproBLE::cleanupClient_() { + if (client_) { + if (client_->isConnected()) { + client_->disconnect(); + delay(100); + } + NimBLEDevice::deleteClient(client_); + client_ = nullptr; + } + notifyChar_ = nullptr; + writeChar_ = nullptr; +} + bool ThermoproBLE::setupConnection_(NimBLEClient* client) { state_ = State::Discovering; stateStartMillis_ = millis(); diff --git a/cyd-bridge/src/ble/ThermoproBLE.hpp b/cyd-bridge/src/ble/ThermoproBLE.hpp index 6fbc6f9..d2d312a 100644 --- a/cyd-bridge/src/ble/ThermoproBLE.hpp +++ b/cyd-bridge/src/ble/ThermoproBLE.hpp @@ -34,6 +34,9 @@ public: // same core/loop in Arduino by default. ThermoproReading getReading() const { return reading_; } + // Human-readable current connection state name for diagnostics. + const char* getStateName() const; + // Register a callback invoked whenever the reading state changes. void onStateChange(StateChangeCallback cb) { stateCallback_ = cb; } @@ -72,9 +75,16 @@ private: unsigned long reconnectDelayMs_ = 5000; bool callbacksRegistered_ = false; + // Address discovered during a scan that we want to connect to. + // The callback records it; update() initiates the connect after stopping the scan. + NimBLEAddress pendingConnectAddress_; + bool pendingConnectFound_ = false; + unsigned long pendingConnectSeenMillis_ = 0; + bool scanAndConnect_(); - static bool connectToServer_(NimBLEAdvertisedDevice* device, void* userData); + bool connectToAddress_(const NimBLEAddress& address); bool setupConnection_(NimBLEClient* client); + void cleanupClient_(); void setConnected_(bool connected); void startReconnectDelay_(); diff --git a/cyd-bridge/src/display/Dashboard.cpp b/cyd-bridge/src/display/Dashboard.cpp index ca2d3b2..132ea77 100644 --- a/cyd-bridge/src/display/Dashboard.cpp +++ b/cyd-bridge/src/display/Dashboard.cpp @@ -9,8 +9,18 @@ Dashboard::Dashboard(CYD_Display& display, SettingsManager& settings, ThermoproB } void Dashboard::begin() { - display_.init(); - display_.initBacklight(); + Serial.println("Dashboard: initializing display with LGFX_AUTODETECT..."); + + if (!display_.init()) { + Serial.println("ERROR: display init failed"); + } else { + Serial.println("Dashboard: display init OK"); + } + + // Backlight on GPIO 21, active HIGH. + pinMode(GPIO_NUM_21, OUTPUT); + digitalWrite(GPIO_NUM_21, HIGH); + display_.setRotation(0); // Portrait 240x320 display_.fillScreen(TFT_BLACK); display_.setTextDatum(middle_center); diff --git a/cyd-bridge/src/display/DisplayConfig.hpp b/cyd-bridge/src/display/DisplayConfig.hpp index 98e76f8..37a3d90 100644 --- a/cyd-bridge/src/display/DisplayConfig.hpp +++ b/cyd-bridge/src/display/DisplayConfig.hpp @@ -1,89 +1,23 @@ #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) +// We use LovyanGFX's built-in autodetection for the CYD board. The autodetect +// table knows the correct SPI buses, pins, panel driver, and touch controller +// for the common ILI9341 and ST7789 variants. +// +// Reference: +// https://github.com/embedded-kiddie/Arduino-CYD-2432S028R +// https://github.com/lovyan03/LovyanGFX/issues/693 #define LGFX_USE_V1 +#define LGFX_AUTODETECT #include +#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_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; -}; +// With LGFX_AUTODETECT we can simply use the stock LGFX class. +using CYD_Display = lgfx::LGFX; } // namespace cyd diff --git a/cyd-bridge/src/main.cpp b/cyd-bridge/src/main.cpp index 72a52e0..55a5524 100644 --- a/cyd-bridge/src/main.cpp +++ b/cyd-bridge/src/main.cpp @@ -35,6 +35,7 @@ enum class AppState { AppState appState = AppState::Init; unsigned long lastHeapLog = 0; unsigned long lastDashboardUpdate = 0; +unsigned long lastBleStartAttempt = 0; void requestReboot() { delay(500); @@ -102,11 +103,12 @@ void loop() { // Heartbeat logging if (millis() - lastHeapLog >= 10000) { lastHeapLog = millis(); - Serial.printf("[heartbeat] state=%d free_heap=%d wifi=%s ble=%s\n", + Serial.printf("[heartbeat] state=%d free_heap=%d wifi=%s ble=%s/%s\n", static_cast(appState), ESP.getFreeHeap(), wifi.isConnected() ? "connected" : "not-connected", - bleClient.getReading().connected ? "connected" : "disconnected"); + bleClient.getReading().connected ? "connected" : "disconnected", + bleClient.getStateName()); } switch (appState) { @@ -144,13 +146,27 @@ void loop() { 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; } + // Proactively (re)start BLE connect if it is not doing anything. + if (!bleClient.getReading().connected) { + const char* state = bleClient.getStateName(); + if (strcmp(state, "idle") == 0 || strcmp(state, "reconnecting") == 0) { + if (millis() - lastBleStartAttempt >= 5000) { + lastBleStartAttempt = millis(); + Serial.printf("Main: requesting BLE connect to %s (state=%s)\n", + settings.getThermoproMac().c_str(), state); + bleClient.setAddress(settings.getThermoproMac()); + if (!bleClient.startConnect()) { + Serial.println("Main: BLE startConnect returned false"); + } + } + } + } + // Dashboard refresh if (millis() - lastDashboardUpdate >= 1000) { lastDashboardUpdate = millis();