From 99b0b09eea0e5f204985a42158dcdea91e19c338 Mon Sep 17 00:00:00 2001 From: Ivan Tumakov <15156664+pridmen@users.noreply.github.com> Date: Thu, 21 May 2026 13:43:21 +0300 Subject: [PATCH 1/2] daikin_madoka: BLE watchdog + multi-client gattc filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two orthogonal hardening fixes for ESP32 setups that pair more than one BRC1H and need to survive Wi-Fi flaps. 1. BLE watchdog. After a Wi-Fi flap the BLE stack occasionally reports ESTABLISHED but the pulse never replies again — parse_cb_() stops, current_temperature stays NAN, and HA's commands disappear into the void. Only a physical power-cycle of the ESP32 recovers. Track last_response_ms_; if it exceeds 5 minutes while node_state is ESTABLISHED, issue a per-channel disconnect (the other BRC1H pair is left alone). After 3 consecutive failures, fall back to App.safe_reboot() as a safety net. - parse_cb_() refreshes the timestamp on every successful reply. - REG_FOR_NOTIFY_EVT refreshes the timestamp on (re)connect so a long disconnect interval does not immediately trip the watchdog. - The watchdog only runs once a reply has been seen at all (last_response_ms_ > 0), so a fresh flash with an unpaired pulse does not reboot-loop. 2. Multi-client gattc_if filter. Recent ESPHome versions dispatch the same gattc_event_handler to every BLEClientNode registered against the same esp32_ble interface, regardless of which BLE connection the callback actually belongs to. Without filtering, a DISCONNECT_EVT from the OTHER BRC1H wipes our current_temperature/target_temperature to NAN. Filter at the top of the handler by gattc_if. --- .../daikin_madoka/daikin_madoka.cpp | 61 +++++++++++++++++++ .../components/daikin_madoka/daikin_madoka.h | 10 +++ 2 files changed, 71 insertions(+) diff --git a/esphome/components/daikin_madoka/daikin_madoka.cpp b/esphome/components/daikin_madoka/daikin_madoka.cpp index d44107bad962..f9ef9037d206 100644 --- a/esphome/components/daikin_madoka/daikin_madoka.cpp +++ b/esphome/components/daikin_madoka/daikin_madoka.cpp @@ -1,6 +1,7 @@ #include "daikin_madoka.h" #include "esphome/core/log.h" +#include "esphome/core/application.h" #include #ifdef USE_ESP32 @@ -20,6 +21,16 @@ static const uint16_t CMD_GET_FAN_SPEED = 0x0050; static const uint16_t CMD_SET_FAN_SPEED = 0x4050; static const uint16_t CMD_GET_SENSOR_INFORMATION = 0x0110; +// BLE watchdog parameters. After a Wi-Fi flap (or a stray bluedroid race) we +// sometimes end up with a live BLE link to BRC1H but no replies — parse_cb_() +// never runs, current_temperature stays NAN, and HA's commands disappear into +// the void. If the link is ESTABLISHED but BRC1H has been silent for this +// long, we issue a per-channel disconnect; after MAX_WATCHDOG_RESETS +// consecutive failures the component falls back to App.safe_reboot() as a +// safety net. +static const uint32_t BLE_WATCHDOG_TIMEOUT_MS = 300000; // 5 minutes +static const uint8_t MAX_WATCHDOG_RESETS = 3; + void DaikinMadoka::dump_config() { LOG_CLIMATE(TAG, "Daikin Madoka Climate Controller", this); } void DaikinMadoka::setup() { this->receive_semaphore_ = xSemaphoreCreateMutex(); } @@ -166,6 +177,16 @@ void DaikinMadoka::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_c void DaikinMadoka::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) { + // Multi-client guard. Recent ESPHome versions dispatch the same + // gattc_event_handler to every BLEClientNode registered against the same + // esp32_ble interface, regardless of which BLE connection the callback + // actually belongs to. Without this filter a DISCONNECT_EVT from the OTHER + // BRC1H wipes our current_temperature/target_temperature to NAN — observed + // on dual-pair setups where one pulse stays connected and the other is + // in a reconnect loop. + if (this->parent_ == nullptr || gattc_if != this->parent_->get_gattc_if()) + return; + switch (event) { case ESP_GATTC_DISCONNECT_EVT: { this->node_state = espbt::ClientState::IDLE; // ?? @@ -189,6 +210,11 @@ void DaikinMadoka::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t } case ESP_GATTC_REG_FOR_NOTIFY_EVT: { this->node_state = espbt::ClientState::ESTABLISHED; // ?? + // BLE watchdog grace: give the freshly-(re)established link the full + // BLE_WATCHDOG_TIMEOUT_MS before the watchdog can kick. Without this + // a long disconnect interval would trip the watchdog immediately after + // reconnect because last_response_ms_ would still be stale. + this->last_response_ms_ = millis(); break; } case ESP_GATTC_NOTIFY_EVT: { @@ -215,6 +241,35 @@ void DaikinMadoka::update() { return; } + // BLE watchdog: if the link is ESTABLISHED but the BRC1H hasn't replied + // for BLE_WATCHDOG_TIMEOUT_MS, kick the per-channel BLE connection. After + // MAX_WATCHDOG_RESETS consecutive failures fall back to App.safe_reboot() — + // the other BRC1H pair on this ESP drops too, but only for ~30s while the + // ESP comes back up. last_response_ms_ is refreshed in parse_cb_() on + // every successful reply and in REG_FOR_NOTIFY_EVT (post-reconnect grace). + // Skip the watchdog until we've seen at least one reply ever, so a brand + // new flash with an unpaired BRC1H doesn't reboot-loop. + if (this->last_response_ms_ > 0 && + (millis() - this->last_response_ms_) > BLE_WATCHDOG_TIMEOUT_MS) { + this->consecutive_watchdog_resets_++; + ESP_LOGW(TAG, "[%s] BLE watchdog: %lu ms since last reply, reset %u/%u", this->get_name().c_str(), + (unsigned long) (millis() - this->last_response_ms_), (unsigned) this->consecutive_watchdog_resets_, + (unsigned) MAX_WATCHDOG_RESETS); + if (this->consecutive_watchdog_resets_ >= MAX_WATCHDOG_RESETS) { + ESP_LOGE(TAG, "[%s] BLE watchdog: %u resets without recovery, rebooting ESP", this->get_name().c_str(), + (unsigned) this->consecutive_watchdog_resets_); + App.safe_reboot(); + } + if (this->parent_ != nullptr) { + this->parent_->disconnect(); + } + // Don't trigger again on the very next update(); give reconnect a full + // watchdog window. REG_FOR_NOTIFY refreshes last_response_ms_ on + // successful re-establish, parse_cb_() refreshes it on first real reply. + this->last_response_ms_ = millis(); + return; + } + std::vector all_cmds{CMD_GET_SETTING_STATUS, CMD_GET_OPERATION_MODE, CMD_GET_SETPOINT, CMD_GET_FAN_SPEED, CMD_GET_SENSOR_INFORMATION}; for (auto cmd : all_cmds) { @@ -311,6 +366,12 @@ void DaikinMadoka::query_(uint16_t cmd, std::vector args, int t_d) { } void DaikinMadoka::parse_cb_(std::vector msg) { + // BLE watchdog: a successful parse_cb_() proves the BRC1H is alive and + // talking. Refresh last_response_ms_ to defer the next watchdog window and + // clear the consecutive-failure counter. + this->last_response_ms_ = millis(); + this->consecutive_watchdog_resets_ = 0; + uint16_t function_id = msg[2] << 8 | msg[3]; uint8_t i = 4; uint8_t message_size = msg.size(); diff --git a/esphome/components/daikin_madoka/daikin_madoka.h b/esphome/components/daikin_madoka/daikin_madoka.h index c9725276f434..f195bb09aa35 100644 --- a/esphome/components/daikin_madoka/daikin_madoka.h +++ b/esphome/components/daikin_madoka/daikin_madoka.h @@ -58,6 +58,16 @@ class DaikinMadoka : public climate::Climate, public esphome::ble_client::BLECli uint16_t wwr_handle_; SemaphoreHandle_t receive_semaphore_ = nullptr; Status cur_status_; + // BLE watchdog state. Detects the "connection ESTABLISHED but pulse silent" + // failure mode: the BLE stack reports a live link but BRC1H never replies, + // so parse_cb_() never runs and current_temperature stays NAN forever (only + // a power-cycle recovers). We refresh last_response_ms_ on every successful + // parse_cb_(); if it grows stale beyond BLE_WATCHDOG_TIMEOUT_MS while + // node_state == ESTABLISHED, update() issues a per-channel disconnect + // (the other BRC1H pair on this ESP is left alone). After MAX_WATCHDOG_RESETS + // consecutive failures we fall back to App.safe_reboot() as a safety net. + uint32_t last_response_ms_{0}; + uint8_t consecutive_watchdog_resets_{0}; std::vector> split_payload_(std::vector msg); std::vector prepare_message_(uint16_t cmd, std::vector args); From 2e1a69e569bd64ac5c7a6dfb705529bbf79642d9 Mon Sep 17 00:00:00 2001 From: Ivan Tumakov <15156664+pridmen@users.noreply.github.com> Date: Thu, 21 May 2026 14:44:22 +0300 Subject: [PATCH 2/2] daikin_madoka: extend multi-client filter to gap_event_handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same broadcast issue that affected gattc_event_handler also affects gap_event_handler — GAP security events are delivered to every BLEClientNode regardless of ownership. With two BRC1H paired to one ESP, an AUTH_CMPL_EVT from the OTHER pulse calls our handler with a remote address that doesn't match this->parent_->get_remote_bda(), drives try_register_notifications_() against the wrong remote, and logs a noisy 'No control service found at device, not a Daikin Madoka..?' warning. GAP events don't carry a gattc_if so filter by remote_bda extracted from whichever sub-struct of ble_security the specific event uses (ble_req.bd_addr or auth_cmpl.bd_addr). --- .../daikin_madoka/daikin_madoka.cpp | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/esphome/components/daikin_madoka/daikin_madoka.cpp b/esphome/components/daikin_madoka/daikin_madoka.cpp index f9ef9037d206..453d72384f9e 100644 --- a/esphome/components/daikin_madoka/daikin_madoka.cpp +++ b/esphome/components/daikin_madoka/daikin_madoka.cpp @@ -2,6 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" +#include #include #ifdef USE_ESP32 @@ -141,6 +142,32 @@ void DaikinMadoka::control(const ClimateCall &call) { } void DaikinMadoka::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { + // Multi-client guard, GAP variant. Just like gattc_event_handler, GAP + // security events are dispatched to every BLEClientNode. Without filtering, + // an AUTH_CMPL_EVT from the OTHER BRC1H drives our try_register_notifications_ + // against the wrong remote — log noise "No control service found at device, + // not a Daikin Madoka..?" on dual-pair setups. GAP events don't carry a + // gattc_if, so filter by remote_bda taken from whichever sub-struct of + // ble_security the event uses. + if (this->parent_ == nullptr) + return; + const uint8_t *event_bda = nullptr; + switch (event) { + case ESP_GAP_BLE_SEC_REQ_EVT: + event_bda = param->ble_security.ble_req.bd_addr; + break; + case ESP_GAP_BLE_NC_REQ_EVT: + event_bda = param->ble_security.ble_req.bd_addr; + break; + case ESP_GAP_BLE_AUTH_CMPL_EVT: + event_bda = param->ble_security.auth_cmpl.bd_addr; + break; + default: + break; + } + if (event_bda != nullptr && memcmp(event_bda, this->parent_->get_remote_bda(), 6) != 0) + return; + switch (event) { case ESP_GAP_BLE_SEC_REQ_EVT: esp_ble_gap_security_rsp(param->ble_security.ble_req.bd_addr, true);