From f106a24bdece24efe4005ef78f8843deb9c9ccd6 Mon Sep 17 00:00:00 2001 From: pridmen <15156664+pridmen@users.noreply.github.com> Date: Sun, 17 May 2026 02:20:19 +0300 Subject: [PATCH 1/2] daikin_madoka: retry SET_SETTING_STATUS when BRC1H drops the chunk silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When one ESP32 proxies two BRC1H pairs over BLE, the back-to-back SET_OPERATION_MODE (600 ms) and SET_SETTING_STATUS (200 ms) we emit from control() occasionally have the second chunk silently dropped by one of the pulses. SET_OPERATION_MODE goes through (the LCD switches to the requested mode icon), but SET_SETTING_STATUS does not, so the unit stays physically off. Polling 5–15 s later reports status=0 and flips climate::mode to OFF in HA, surprising the user who just turned it on. The other paired BRC1H on the same ESP works fine in the same window. The existing BLE_SEND_MAX_RETRIES loop in query_() only retries the raw chunk write; if all 5 writes succeed at the link layer but the pulse-side parser silently rejects, we never know. Add a higher-level status retry: stash the last requested status byte in last_set_status_byte_ on every control() that emits SET_SETTING_STATUS. In parse_cb_, on CMD_GET_SETTING_STATUS readback within STATUS_RETRY_WINDOW_MS (30 s after control()) — if cur_status_.status disagrees with our intent, re-send SET_SETTING_STATUS once more, up to MAX_STATUS_RETRIES (2). Patch the local cur_status_.status so the downstream switch doesn't flip climate to OFF based on the stale readback. Caveats: - If the user toggled the unit on the BRC1H buttons within the same 30 s window, we'd fight back briefly. Acceptable: the user usually notices and either the second retry settles or a fresh control() resets the guard. - 30 s window is conservative; in practice a single retry resolves it within the same poll cycle. --- .../daikin_madoka/daikin_madoka.cpp | 28 +++++++++++++++++++ .../components/daikin_madoka/daikin_madoka.h | 9 ++++++ 2 files changed, 37 insertions(+) diff --git a/esphome/components/daikin_madoka/daikin_madoka.cpp b/esphome/components/daikin_madoka/daikin_madoka.cpp index 283fe0a96ac7..5b198162b2a9 100644 --- a/esphome/components/daikin_madoka/daikin_madoka.cpp +++ b/esphome/components/daikin_madoka/daikin_madoka.cpp @@ -20,6 +20,12 @@ 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; +// Status retry parameters: if the BRC1H reports a status that disagrees with +// the last HA-issued status within this window, re-send SET_SETTING_STATUS up +// to MAX_STATUS_RETRIES times before accepting the readback as truth. +static const uint32_t STATUS_RETRY_WINDOW_MS = 30000; +static const uint8_t MAX_STATUS_RETRIES = 2; + void DaikinMadoka::dump_config() { LOG_CLIMATE(TAG, "Daikin Madoka Climate Controller", this); } void DaikinMadoka::setup() { this->receive_semaphore_ = xSemaphoreCreateMutex(); } @@ -81,6 +87,9 @@ void DaikinMadoka::control(const ClimateCall &call) { this->query_(CMD_SET_OPERATION_MODE, std::vector{0x20, 0x01, (uint8_t) mode_out}, 600); } this->query_(CMD_SET_SETTING_STATUS, std::vector{0x20, 0x01, (uint8_t) status_out}, 200); + this->last_set_status_byte_ = static_cast(status_out); + this->last_set_ms_ = millis(); + this->status_retry_count_ = 0; } std::vector temp_setpoint_args; if (call.get_target_temperature_high().has_value()) { @@ -321,6 +330,25 @@ void DaikinMadoka::parse_cb_(std::vector msg) { } i += len; } + // Status retry guard — see header comment on last_set_status_byte_. + if (this->last_set_ms_ > 0 && + (millis() - this->last_set_ms_) < STATUS_RETRY_WINDOW_MS && + ((uint8_t) (this->cur_status_.status ? 1 : 0)) != this->last_set_status_byte_ && + this->status_retry_count_ < MAX_STATUS_RETRIES) { + this->status_retry_count_++; + ESP_LOGW(TAG, + "[%s] SETTING_STATUS readback mismatch (got=%d, expected=%d), retry %d/%d", + this->get_name().c_str(), + (int) (this->cur_status_.status ? 1 : 0), + (int) this->last_set_status_byte_, + (int) this->status_retry_count_, + (int) MAX_STATUS_RETRIES); + this->query_(CMD_SET_SETTING_STATUS, + std::vector{0x20, 0x01, this->last_set_status_byte_}, 200); + // Don't let the rest of parse_cb_ act on the stale readback — pretend the + // pulse acknowledged what we asked for; the next poll round will confirm. + this->cur_status_.status = (this->last_set_status_byte_ != 0); + } break; case CMD_GET_OPERATION_MODE: while (i < message_size) { diff --git a/esphome/components/daikin_madoka/daikin_madoka.h b/esphome/components/daikin_madoka/daikin_madoka.h index c9725276f434..d6cb1ed894ba 100644 --- a/esphome/components/daikin_madoka/daikin_madoka.h +++ b/esphome/components/daikin_madoka/daikin_madoka.h @@ -58,6 +58,15 @@ class DaikinMadoka : public climate::Climate, public esphome::ble_client::BLECli uint16_t wwr_handle_; SemaphoreHandle_t receive_semaphore_ = nullptr; Status cur_status_; + // Status retry guard. The BRC1H sometimes drops the SET_SETTING_STATUS chunk + // silently (typically when one ESP juggles two BRC1H pairs at once over BLE). + // The unit then stays physically off even though we just told it to turn on, + // and the next poll reports status=0 and flips climate::mode to OFF in HA. + // Remember the requested status for a short window and re-issue + // SET_SETTING_STATUS if the readback disagrees, up to a small cap. + uint8_t last_set_status_byte_{0}; + uint32_t last_set_ms_{0}; + uint8_t status_retry_count_{0}; std::vector> split_payload_(std::vector msg); std::vector prepare_message_(uint16_t cmd, std::vector args); From c77504323dcfd5716254c56a3902b877676b72d8 Mon Sep 17 00:00:00 2001 From: "i.tumakov" <> Date: Sun, 17 May 2026 12:18:01 +0300 Subject: [PATCH 2/2] daikin_madoka: retry SET_OPERATION_MODE when BRC1H drops the chunk silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symmetric counterpart to the SET_SETTING_STATUS retry: when one ESP32 proxies two BRC1H pairs over BLE, the back-to-back SET_OPERATION_MODE (600 ms) emitted from control() occasionally has its chunk silently dropped by one of the pulses. The unit stays in the previous mode (typically FAN_ONLY left over from an earlier scene), and 5-15 s later the next CMD_GET_OPERATION_MODE poll reports the stale byte and flips climate::mode in HA back to that value — surprising the user who just picked COOL/HEAT/AUTO in HomeKit or HA. Concrete trigger seen here: scene-restore at the end of an airing routine left both BRC1H pairs in FAN_ONLY. Forty minutes later, a HomeKit COOL command went through to the active climate entity (state flipped to cool in HA), but the SET_OPERATION_MODE chunk for that device was dropped at the pulse, while SET_SETTING_STATUS landed. The next poll round read mode=0 (FAN_ONLY) from the BRC1H and parse_cb_ snapped HA back to fan_only within 24 s. The existing sticky-FAN_ONLY guard only protects against the inverse (pulse reverting FAN_ONLY back to a main mode), so it didn't help here. We already remember last_set_status_byte_ / last_set_ms_ from the SETTING_STATUS retry path. Stash the requested raw mode byte too (255 = unset, used for the OFF case where no SET_OPERATION_MODE is sent), and on a CMD_GET_OPERATION_MODE readback mismatch within STATUS_RETRY_WINDOW_MS, re-issue SET_OPERATION_MODE up to MAX_STATUS_RETRIES times. Pretend the readback matched what we requested so the switch below does not snap climate::mode to the stale value while we wait for the retry to land — the next poll round will confirm or trigger another retry. Byte 0 and 5 are treated as equivalent by madoka_mode_byte_equiv — both decode to CLIMATE_MODE_FAN_ONLY downstream, and the BRC1H is free to switch between them on its own. Reuses the same window and retry cap as the SETTING_STATUS guard. --- .../daikin_madoka/daikin_madoka.cpp | 38 ++++++++++++++++++- .../components/daikin_madoka/daikin_madoka.h | 9 +++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/esphome/components/daikin_madoka/daikin_madoka.cpp b/esphome/components/daikin_madoka/daikin_madoka.cpp index 5b198162b2a9..e9585b2f68a0 100644 --- a/esphome/components/daikin_madoka/daikin_madoka.cpp +++ b/esphome/components/daikin_madoka/daikin_madoka.cpp @@ -22,10 +22,20 @@ static const uint16_t CMD_GET_SENSOR_INFORMATION = 0x0110; // Status retry parameters: if the BRC1H reports a status that disagrees with // the last HA-issued status within this window, re-send SET_SETTING_STATUS up -// to MAX_STATUS_RETRIES times before accepting the readback as truth. +// to MAX_STATUS_RETRIES times before accepting the readback as truth. The +// same parameters also gate the symmetric mode-retry below. static const uint32_t STATUS_RETRY_WINDOW_MS = 30000; static const uint8_t MAX_STATUS_RETRIES = 2; +// FAN_ONLY is reported as either byte 0 or byte 5 depending on whether the +// pulse treats it as a transient fan flag or as the main operation mode. +// Treat both as the same outcome when comparing readback to requested mode. +static inline bool madoka_mode_byte_equiv(uint8_t a, uint8_t b) { + if (a == b) return true; + if ((a == 0 && b == 5) || (a == 5 && b == 0)) return true; + return false; +} + void DaikinMadoka::dump_config() { LOG_CLIMATE(TAG, "Daikin Madoka Climate Controller", this); } void DaikinMadoka::setup() { this->receive_semaphore_ = xSemaphoreCreateMutex(); } @@ -88,8 +98,10 @@ void DaikinMadoka::control(const ClimateCall &call) { } this->query_(CMD_SET_SETTING_STATUS, std::vector{0x20, 0x01, (uint8_t) status_out}, 200); this->last_set_status_byte_ = static_cast(status_out); + this->last_set_mode_byte_ = mode_out; // 255 keeps mode-retry disabled for OFF (no SET_OPERATION_MODE was sent) this->last_set_ms_ = millis(); this->status_retry_count_ = 0; + this->mode_retry_count_ = 0; } std::vector temp_setpoint_args; if (call.get_target_temperature_high().has_value()) { @@ -360,6 +372,30 @@ void DaikinMadoka::parse_cb_(std::vector msg) { } i += len; } + // Mode retry guard — symmetric to the SETTING_STATUS retry above. If the + // last HA-issued SET_OPERATION_MODE was within the retry window and the + // pulse-reported byte still doesn't match (BLE drop or pulse hadn't + // applied yet), re-issue SET_OPERATION_MODE and pretend the readback + // already matched, so the switch below doesn't snap climate::mode back + // to the stale value. last_set_mode_byte_ == 255 means we haven't issued + // a mode-setting command (OFF doesn't go via OPERATION_MODE) — guard off. + if (this->last_set_ms_ > 0 && + (millis() - this->last_set_ms_) < STATUS_RETRY_WINDOW_MS && + this->last_set_mode_byte_ != 255 && + !madoka_mode_byte_equiv(this->cur_status_.mode, this->last_set_mode_byte_) && + this->mode_retry_count_ < MAX_STATUS_RETRIES) { + this->mode_retry_count_++; + ESP_LOGW(TAG, + "[%s] OPERATION_MODE readback mismatch (got=%d, expected=%d), retry %d/%d", + this->get_name().c_str(), + (int) this->cur_status_.mode, + (int) this->last_set_mode_byte_, + (int) this->mode_retry_count_, + (int) MAX_STATUS_RETRIES); + this->query_(CMD_SET_OPERATION_MODE, + std::vector{0x20, 0x01, this->last_set_mode_byte_}, 600); + this->cur_status_.mode = this->last_set_mode_byte_; + } break; default: break; diff --git a/esphome/components/daikin_madoka/daikin_madoka.h b/esphome/components/daikin_madoka/daikin_madoka.h index d6cb1ed894ba..663c0a30d5c5 100644 --- a/esphome/components/daikin_madoka/daikin_madoka.h +++ b/esphome/components/daikin_madoka/daikin_madoka.h @@ -67,6 +67,15 @@ class DaikinMadoka : public climate::Climate, public esphome::ble_client::BLECli uint8_t last_set_status_byte_{0}; uint32_t last_set_ms_{0}; uint8_t status_retry_count_{0}; + // Mode retry guard, symmetric to the SETTING_STATUS retry above. Same drop + // scenario, this time for SET_OPERATION_MODE: chunk silently lost on BLE → + // next poll reports the old pulse-side mode → parse_cb_ flips climate::mode + // in HA back to that stale value. Remembering the raw byte we requested + // (0..5; 255 = unset, e.g. OFF which doesn't go via OPERATION_MODE) lets us + // re-issue SET_OPERATION_MODE up to MAX_STATUS_RETRIES times before + // accepting the readback as truth. + uint8_t last_set_mode_byte_{255}; + uint8_t mode_retry_count_{0}; std::vector> split_payload_(std::vector msg); std::vector prepare_message_(uint16_t cmd, std::vector args);