From 5d2961e96bd5d38c2fb7d6521d9d2d4a2dd2a42b Mon Sep 17 00:00:00 2001 From: David Abrahams <40281893+DavidWAbrahams@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:01:09 -0700 Subject: [PATCH 1/3] [mitsubishi_cn105] Hold the climate action through wind-down and keep the AUTO direction (#20819) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- .../mitsubishi_cn105/mitsubishi_cn105.cpp | 112 +++++++- .../mitsubishi_cn105/mitsubishi_cn105.h | 27 +- .../mitsubishi_cn105_climate.cpp | 33 +-- .../mitsubishi_cn105_component.h | 1 + .../mitsubishi_cn105_climate_tests.cpp | 95 ++++++- .../climate/mitsubishi_cn105_tests.cpp | 258 ++++++++++++++++-- tests/components/mitsubishi_cn105/common.h | 24 +- 7 files changed, 485 insertions(+), 65 deletions(-) diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp index d66eb2c6113..f5760e75228 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.cpp @@ -31,6 +31,9 @@ static constexpr uint8_t STATUS_MSG_OPERATING_STATUS = 0x06; static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_REQUEST = 0x41; static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_RESPONSE = 0x61; +// After a power-off or a mode change that ends the run, 0x06 is read this often while the unit winds down. +static constexpr uint32_t WIND_DOWN_READ_INTERVAL_MS = 10000; + static constexpr uint8_t checksum(const uint8_t *bytes, size_t length) { return static_cast(0xFC - sum_bytes(bytes, length)); } @@ -62,7 +65,8 @@ bool MitsubishiCN105::update() { this->status_update_wait_credit_ms_ = std::min(this->update_interval_ms_, get_loop_time_ms() - this->operation_start_ms_); this->set_state_(State::APPLYING_SETTINGS); - return false; + // Publishes a hold started by the write + return this->take_status_changed_(); } if (this->has_timed_out_(this->update_interval_ms_)) { this->set_state_(State::UPDATING_STATUS); @@ -79,6 +83,7 @@ bool MitsubishiCN105::update() { if (this->state_ == State::UPDATING_STATUS && this->current_status_msg_type_ == STATUS_MSG_OPERATING_STATUS) { if (++this->operating_status_misses_ >= OPERATING_STATUS_MAX_MISSES) { ESP_LOGI(TAG, "No operating status response, climate action stays idle"); + this->wind_down_reads_ = 0; } this->frame_parser_.reset(); this->set_state_(State::STATUS_UPDATED); @@ -181,7 +186,7 @@ void MitsubishiCN105::did_transition_(State to) { this->last_telemetry_update_ms_ = get_loop_time_ms(); this->has_telemetry_update_ = true; this->telemetry_refresh_ = TelemetryRefresh::TELEMETRY_REFRESH_NONE; - } else { + } else if (this->telemetry_refresh_ == TelemetryRefresh::TELEMETRY_REFRESH_REQUESTED) { this->telemetry_refresh_ = TelemetryRefresh::TELEMETRY_REFRESH_SPENT; } } @@ -218,9 +223,7 @@ void MitsubishiCN105::did_transition_(State to) { } bool MitsubishiCN105::should_request_telemetry_() const { - return this->is_telemetry_polling_enabled() && - (this->telemetry_refresh_ == TelemetryRefresh::TELEMETRY_REFRESH_REQUESTED || - this->is_telemetry_interval_elapsed_()); + return this->is_telemetry_polling_enabled() && (this->is_refresh_due_() || this->is_telemetry_interval_elapsed_()); } bool MitsubishiCN105::is_telemetry_interval_elapsed_() const { @@ -229,14 +232,91 @@ bool MitsubishiCN105::is_telemetry_interval_elapsed_() const { } bool MitsubishiCN105::is_early_telemetry_refresh_() const { - return this->telemetry_refresh_ == TelemetryRefresh::TELEMETRY_REFRESH_REQUESTED && - !this->is_telemetry_interval_elapsed_(); + return this->is_refresh_due_() && !this->is_telemetry_interval_elapsed_(); +} + +// An early 0x06 read after a mode or power change, or the next read while the unit winds down. +bool MitsubishiCN105::is_refresh_due_() const { + return this->telemetry_refresh_ == TelemetryRefresh::TELEMETRY_REFRESH_REQUESTED || + (this->wind_down_reads_ != 0 && (get_loop_time_ms() - this->wind_down_read_ms_) >= WIND_DOWN_READ_INTERVAL_MS); +} + +// A switch keeps the run going when the new mode drives it the same way: AUTO keeps any direction, and HEAT or COOL +// continue a run of that direction. A run in AUTO of unknown direction counts as continuing. +static constexpr bool continues_run(MitsubishiCN105::Mode from, MitsubishiCN105::Mode to, + MitsubishiCN105::RunDirection direction) { + using Mode = MitsubishiCN105::Mode; + using RunDirection = MitsubishiCN105::RunDirection; + if (to == Mode::AUTO) { + return direction != RunDirection::RUN_DIRECTION_NONE; + } + const RunDirection implied = to == Mode::HEAT ? RunDirection::RUN_DIRECTION_HEATING + : to == Mode::COOL ? RunDirection::RUN_DIRECTION_COOLING + : RunDirection::RUN_DIRECTION_NONE; + return implied != RunDirection::RUN_DIRECTION_NONE && + (direction == implied || (from == Mode::AUTO && direction == RunDirection::RUN_DIRECTION_NONE)); +} + +// The unit keeps reporting "running" while it winds down from a run that a power-off or a mode change ended. +// A change during a hold neither restarts nor ends it, since operating is already false. +bool MitsubishiCN105::hold_if_run_ended_(bool was_on, Mode was_mode) { + if (!this->status_.operating || !was_on || this->is_operating_status_unsupported_()) { + return false; + } + if (this->status_.power_on && + (was_mode == this->status_.mode || continues_run(was_mode, this->status_.mode, this->run_direction_))) { + return false; + } + this->status_.operating = false; + this->wind_down_reads_ = 1; + // The first read is due right away + this->wind_down_read_ms_ = get_loop_time_ms() - WIND_DOWN_READ_INTERVAL_MS; + return true; +} + +// Called before a local power or mode change, so a change reverted before it is sent does not start a hold. +void MitsubishiCN105::save_unsent_power_and_mode_() { + if (!this->pending_updates_.contains(PropertyId::POWER) && !this->pending_updates_.contains(PropertyId::MODE)) { + this->unsent_from_power_on_ = this->status_.power_on; + this->unsent_from_mode_ = this->status_.mode; + } +} + +// AUTO keeps the direction of a run, and infers it from room vs. target temperature only when the run starts: a run +// that starts at a tie stays without a direction, as the unit may still be overshooting either way. +void MitsubishiCN105::update_run_direction_(bool was_operating) { + if (!this->status_.power_on || !this->status_.operating) { + this->run_direction_ = RunDirection::RUN_DIRECTION_NONE; + return; + } + switch (this->status_.mode) { + case Mode::HEAT: + this->run_direction_ = RunDirection::RUN_DIRECTION_HEATING; + break; + case Mode::COOL: + this->run_direction_ = RunDirection::RUN_DIRECTION_COOLING; + break; + case Mode::AUTO: + if (this->run_direction_ != RunDirection::RUN_DIRECTION_NONE || was_operating) { + break; + } + if (this->status_.room_temperature > this->status_.target_temperature) { + this->run_direction_ = RunDirection::RUN_DIRECTION_COOLING; + } else if (this->status_.room_temperature < this->status_.target_temperature) { + this->run_direction_ = RunDirection::RUN_DIRECTION_HEATING; + } + break; + default: + this->run_direction_ = RunDirection::RUN_DIRECTION_NONE; + break; + } } // A mode or power change makes the operating status stale, so read it again without waiting for the interval. +// A wind-down hold has its own reads. void MitsubishiCN105::request_telemetry_refresh_() { if (this->telemetry_refresh_ == TelemetryRefresh::TELEMETRY_REFRESH_NONE && - !this->is_operating_status_unsupported_()) { + !this->is_operating_status_unsupported_() && this->wind_down_reads_ == 0) { this->telemetry_refresh_ = TelemetryRefresh::TELEMETRY_REFRESH_REQUESTED; } } @@ -307,8 +387,10 @@ bool MitsubishiCN105::process_status_packet_(const uint8_t *payload, size_t len) const bool power_or_mode_changed = previous.power_on != this->status_.power_on || previous.mode != this->status_.mode; if (power_or_mode_changed) { + this->hold_if_run_ended_(previous.power_on, previous.mode); this->request_telemetry_refresh_(); } + this->update_run_direction_(previous.operating); if (msg_type == this->current_status_msg_type_) { this->set_state_(State::STATUS_UPDATED); @@ -351,6 +433,15 @@ bool MitsubishiCN105::parse_status_payload_(uint8_t msg_type, const uint8_t *pay return false; } this->operating_status_misses_ = 0; + // The hold ends at the first "stopped" report, or after WIND_DOWN_HELD_READS reads + if (this->wind_down_reads_ != 0 && this->status_.operating && this->wind_down_reads_ <= WIND_DOWN_HELD_READS) { + ESP_LOGV(TAG, "Ignoring running report during wind-down"); + this->status_.operating = false; + this->wind_down_reads_++; + this->wind_down_read_ms_ = get_loop_time_ms(); + } else { + this->wind_down_reads_ = 0; + } return true; default: @@ -381,6 +472,7 @@ void MitsubishiCN105::set_remote_temperature_half_deg_(uint8_t temperature_half_ } void MitsubishiCN105::set_power(bool power_on) { + this->save_unsent_power_and_mode_(); this->status_.power_on = power_on; this->pending_updates_.set(Property::Power::ID); } @@ -395,6 +487,7 @@ void MitsubishiCN105::set_target_temperature(float target_temperature) { } void MitsubishiCN105::set_mode(Mode mode) { + this->save_unsent_power_and_mode_(); if (!Property::Mode::validate_and_set(mode, this->status_, this->pending_updates_)) { ESP_LOGD(TAG, "Ignoring invalid mode: %u", static_cast(mode)); } @@ -422,8 +515,11 @@ void MitsubishiCN105::apply_settings_() { std::array payload{}; Property::Encoder encoder{payload.data(), this->property_context_, this->pending_updates_}; if (this->pending_updates_.contains(PropertyId::POWER) || this->pending_updates_.contains(PropertyId::MODE)) { + this->status_changed_ |= this->hold_if_run_ended_(this->unsent_from_power_on_, this->unsent_from_mode_); this->request_telemetry_refresh_(); } + // Local changes reach the hold and the run direction only once they are sent; they never start a run + this->update_run_direction_(this->status_.operating); // Apply all other pending settings first; handle REMOTE_TEMPERATURE last if (this->pending_updates_.contains_only(Property::Temperature::Remote::ID)) { diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h index abb685df6e9..a1a09c8eac9 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105.h @@ -54,11 +54,18 @@ class MitsubishiCN105 { UNKNOWN, }; + // Direction of the current run. 0x06 does not report it, so in AUTO it is inferred when the run starts. + enum class RunDirection : uint8_t { + RUN_DIRECTION_NONE, + RUN_DIRECTION_HEATING, + RUN_DIRECTION_COOLING, + }; + struct Status { float target_temperature{NAN}; float room_temperature{NAN}; bool power_on{false}; - bool operating{false}; + bool operating{false}; // held false while the unit winds down after a run ended Mode mode{Mode::UNKNOWN}; FanMode fan_mode{FanMode::UNKNOWN}; VaneMode vane_mode{VaneMode::UNKNOWN}; @@ -94,6 +101,7 @@ class MitsubishiCN105 { void set_wide_vane_mode(WideVaneMode mode); void set_remote_temperature(float temperature); void clear_remote_temperature(); + RunDirection run_direction() const { return this->run_direction_; } protected: enum class State : uint8_t { @@ -156,8 +164,8 @@ class MitsubishiCN105 { }; struct PropertyContext { - bool use_temperature_encoding_b{false}; - bool set_wide_vane_high_bit{false}; + bool use_temperature_encoding_b : 1 {false}; + bool set_wide_vane_high_bit : 1 {false}; }; friend struct Property; @@ -172,12 +180,16 @@ class MitsubishiCN105 { bool should_request_telemetry_() const; bool is_telemetry_interval_elapsed_() const; bool is_early_telemetry_refresh_() const; + bool is_refresh_due_() const; void request_telemetry_refresh_(); uint8_t next_status_msg_type_() const; bool take_status_changed_(); bool is_operating_status_unsupported_() const { return this->operating_status_misses_ >= OPERATING_STATUS_MAX_MISSES; } + bool hold_if_run_ended_(bool was_on, Mode was_mode); + void save_unsent_power_and_mode_(); + void update_run_direction_(bool was_operating); void apply_settings_(); bool has_timed_out_(uint32_t timeout) const { return ((get_loop_time_ms() - this->operation_start_ms_) >= timeout); } void set_remote_temperature_half_deg_(uint8_t temperature_half_deg); @@ -194,6 +206,7 @@ class MitsubishiCN105 { // omitted. Remove legacy note in 2027.2.0. uint32_t telemetry_request_min_interval_ms_{60000}; uint32_t last_telemetry_update_ms_{0}; + uint32_t wind_down_read_ms_{0}; Status status_{}; State state_{State::NOT_CONNECTED}; UpdateFlags pending_updates_; @@ -209,6 +222,14 @@ class MitsubishiCN105 { // Consecutive 0x06 timeouts; 0x06 is no longer requested once it reaches the maximum. uint8_t operating_status_misses_ : 2 {0}; static constexpr uint8_t OPERATING_STATUS_MAX_MISSES = 3; + RunDirection run_direction_ : 2 {RunDirection::RUN_DIRECTION_NONE}; + // Non-zero while a "running" 0x06 report is taken as wind-down from the previous mode or power state: one plus the + // number of 0x06 reads since the hold started. The last read was at wind_down_read_ms_. + uint8_t wind_down_reads_ : 4 {0}; + // Power and mode before the first local change that has not been sent yet; the hold starts from what is sent. + bool unsent_from_power_on_ : 1 {false}; + Mode unsent_from_mode_ : 3 {Mode::UNKNOWN}; + static constexpr uint8_t WIND_DOWN_HELD_READS = 9; }; } // namespace esphome::mitsubishi_cn105 diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp index e7e0b8a8ba2..7cb3547dd8a 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_climate.cpp @@ -51,8 +51,8 @@ static constexpr std::optional reverse_map_lookup(const std::array status.target_temperature) { - return climate::CLIMATE_ACTION_COOLING; - } - if (status.room_temperature < status.target_temperature) { - return climate::CLIMATE_ACTION_HEATING; - } - return climate::CLIMATE_ACTION_IDLE; } void MitsubishiCN105Climate::dump_config() { @@ -160,7 +150,6 @@ void MitsubishiCN105Climate::apply_values_() { this->target_temperature = this->parent_->get_temperature_mapping().from_mitsubishi(status.target_temperature); - const climate::ClimateMode previous_mode = this->mode; if (status.power_on) { if (!map_lookup(MODE_MAP, status.mode, this->mode)) { ESP_LOGD(TAG, "Unable to map mode"); @@ -171,9 +160,7 @@ void MitsubishiCN105Climate::apply_values_() { if (this->parent_->is_telemetry_polling_enabled()) { this->current_temperature = this->parent_->get_temperature_mapping().from_mitsubishi(status.room_temperature); - const auto running_action = - previous_mode == climate::CLIMATE_MODE_HEAT_COOL ? this->action : climate::CLIMATE_ACTION_IDLE; - this->action = resolve_climate_action(this->mode, running_action, status); + this->action = resolve_climate_action(this->mode, status, this->parent_->run_direction()); } climate::ClimateFanMode fan_mode; diff --git a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h index 4007c30f4eb..796a7cc1a0f 100644 --- a/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h +++ b/esphome/components/mitsubishi_cn105/mitsubishi_cn105_component.h @@ -135,6 +135,7 @@ class MitsubishiCN105Component final : public Component, public uart::UARTDevice const MitsubishiCN105::Status &status() const { return this->hp_.status(); } bool is_status_initialized() const { return this->hp_.is_status_initialized(); } bool is_telemetry_polling_enabled() const { return this->hp_.is_telemetry_polling_enabled(); } + MitsubishiCN105::RunDirection run_direction() const { return this->hp_.run_direction(); } const TemperatureMapping &get_temperature_mapping() const { return this->temperature_mapping_; } template void add_on_status_callback(F &&callback) { diff --git a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_climate_tests.cpp b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_climate_tests.cpp index 4d284e09502..9019256937c 100644 --- a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_climate_tests.cpp +++ b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_climate_tests.cpp @@ -124,6 +124,14 @@ TEST(MitsubishiCN105ClimateTests, TelemetryDisabledDisablesCurrentTemperatureAnd EXPECT_FALSE(traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION)); } +// The unit reports stopped, then running. +static void restart_operation(MitsubishiCN105ClimateTestContext &context) { + push_operating_status_response(context.uart, false); + context.component.loop(); + push_operating_status_response(context.uart, true); + context.component.loop(); +} + TEST(MitsubishiCN105ClimateTests, ActionMapsModeAndOperatingState) { using Mode = MitsubishiCN105::Mode; MitsubishiCN105ClimateTestContext context; @@ -151,15 +159,18 @@ TEST(MitsubishiCN105ClimateTests, ActionMapsModeAndOperatingState) { std::pair{Mode::DRY, climate::CLIMATE_ACTION_DRYING}, std::pair{Mode::FAN_ONLY, climate::CLIMATE_ACTION_FAN}, }; + // Each change ends the run, so it is reported once the unit stops and starts again. for (const auto &[mode, action] : OPERATING_ACTIONS) { context.component.set_mode(mode); - context.component.publish_status(); + restart_operation(context); EXPECT_EQ(context.sut.action, action) << "operating, mode " << static_cast(mode); } - // Switching to AUTO infers the direction from room (21°C) vs. target (24°C). + // Starting in AUTO infers the direction from room (21°C) vs. target (24°C). context.component.set_mode(Mode::AUTO); context.component.publish_status(); + EXPECT_EQ(context.sut.action, climate::CLIMATE_ACTION_IDLE); + restart_operation(context); EXPECT_EQ(context.sut.action, climate::CLIMATE_ACTION_HEATING); // Overshooting the target while still operating keeps HEATING. @@ -176,11 +187,8 @@ TEST(MitsubishiCN105ClimateTests, ActionMapsModeAndOperatingState) { EXPECT_EQ(context.sut.action, climate::CLIMATE_ACTION_COOLING); // A tie on start reports IDLE until a direction is known. - context.component.set_mode(Mode::HEAT); - context.component.publish_status(); context.component.set_target_temperature(21.0f); - context.component.set_mode(Mode::AUTO); - context.component.publish_status(); + restart_operation(context); EXPECT_EQ(context.sut.action, climate::CLIMATE_ACTION_IDLE); // Operating = false @@ -200,4 +208,79 @@ TEST(MitsubishiCN105ClimateTests, ActionMapsModeAndOperatingState) { } } +TEST(MitsubishiCN105ClimateTests, CoolIntoAutoKeepsCooling) { + using Mode = MitsubishiCN105::Mode; + MitsubishiCN105ClimateTestContext context; + context.sut.setup(); + + // Room temperature 21.0°C: cooling towards 24°C would be inferred as heating in AUTO. + push_room_temperature_response(context.uart); + context.component.loop(); + context.component.set_power(true); + context.component.set_mode(Mode::COOL); + context.component.set_target_temperature(24.0f); + push_operating_status_response(context.uart, true); + context.component.loop(); + EXPECT_EQ(context.sut.action, climate::CLIMATE_ACTION_COOLING); + + context.component.set_mode(Mode::AUTO); + context.component.publish_status(); + EXPECT_EQ(context.sut.action, climate::CLIMATE_ACTION_COOLING); +} + +TEST(MitsubishiCN105ClimateTests, AutoSwitchedOnTheUnitUsesTheRunningDirection) { + MitsubishiCN105ClimateTestContext context; + context.sut.setup(); + + // Heating in AUTO as reported by the unit: room 21°C, target 24°C. + push_room_temperature_response(context.uart); + context.component.loop(); + push_settings_response(context.uart, true, RAW_MODE_AUTO); + context.component.loop(); + push_operating_status_response(context.uart, true); + context.component.loop(); + EXPECT_EQ(context.sut.action, climate::CLIMATE_ACTION_HEATING); + + // HEAT continues the run. + push_settings_response(context.uart, true, RAW_MODE_HEAT); + context.component.loop(); + EXPECT_EQ(context.sut.action, climate::CLIMATE_ACTION_HEATING); + + // Back to AUTO and then COOL ends it. + push_settings_response(context.uart, true, RAW_MODE_AUTO); + context.component.loop(); + EXPECT_EQ(context.sut.action, climate::CLIMATE_ACTION_HEATING); + push_settings_response(context.uart, true, RAW_MODE_COOL); + context.component.loop(); + EXPECT_EQ(context.sut.mode, climate::CLIMATE_MODE_COOL); + EXPECT_EQ(context.sut.action, climate::CLIMATE_ACTION_IDLE); +} + +TEST(MitsubishiCN105ClimateTests, AutoRunStartedAtATieKeepsNoDirection) { + using RunDirection = MitsubishiCN105::RunDirection; + MitsubishiCN105ClimateTestContext context; + context.sut.setup(); + + // AUTO, target 24°C, and the run starts with the room at 24°C. + push_room_temperature_response(context.uart, 24); + context.component.loop(); + push_settings_response(context.uart, true, RAW_MODE_AUTO); + context.component.loop(); + push_operating_status_response(context.uart, true); + context.component.loop(); + EXPECT_EQ(context.component.run_direction(), RunDirection::RUN_DIRECTION_NONE); + EXPECT_EQ(context.sut.action, climate::CLIMATE_ACTION_IDLE); + + // The room moving during the run does not give it a direction. + push_room_temperature_response(context.uart, 21); + context.component.loop(); + EXPECT_EQ(context.component.run_direction(), RunDirection::RUN_DIRECTION_NONE); + EXPECT_EQ(context.sut.action, climate::CLIMATE_ACTION_IDLE); + + // The next run starts below the target: heating. + restart_operation(context); + EXPECT_EQ(context.component.run_direction(), RunDirection::RUN_DIRECTION_HEATING); + EXPECT_EQ(context.sut.action, climate::CLIMATE_ACTION_HEATING); +} + } // namespace esphome::mitsubishi_cn105::testing diff --git a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp index c3d43b023e8..d1f3d7abdef 100644 --- a/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp +++ b/tests/components/mitsubishi_cn105/climate/mitsubishi_cn105_tests.cpp @@ -138,8 +138,9 @@ static uint8_t send_deferred_status_request(MitsubishiCN105TestsContext &ctx, ui } // Answers the pending 0x02 request at `now` and the 0x03 request, leaving the 0x06 request sent at now + 3 ms. -static void answer_until_operating_status_request(MitsubishiCN105TestsContext &ctx, uint32_t now) { - push_settings_response(ctx.uart); +static void answer_until_operating_status_request(MitsubishiCN105TestsContext &ctx, uint32_t now, bool power_on = false, + uint8_t raw_mode = RAW_MODE_AUTO) { + push_settings_response(ctx.uart, power_on, raw_mode); ctx.sut.set_current_time(now); ASSERT_FALSE(ctx.sut.update()); ASSERT_EQ(send_deferred_status_request(ctx, now + 1), 0x03); @@ -153,18 +154,20 @@ static void answer_until_operating_status_request(MitsubishiCN105TestsContext &c } // Connects and answers 0x02 and 0x03, leaving the 0x06 request sent at 105 ms. -static void run_until_operating_status_request(MitsubishiCN105TestsContext &ctx) { +static void run_until_operating_status_request(MitsubishiCN105TestsContext &ctx, bool power_on = false, + uint8_t raw_mode = RAW_MODE_AUTO) { ctx.sut.initialize(); ctx.uart.push_rx({0xFC, 0x7A, 0x01, 0x30, 0x00, 0x55}); ctx.sut.set_current_time(100); ASSERT_FALSE(ctx.sut.update()); ASSERT_EQ(send_deferred_status_request(ctx, 101), 0x02); - answer_until_operating_status_request(ctx, 102); + answer_until_operating_status_request(ctx, 102, power_on, raw_mode); } // Completes the first chain with operating = true, publishing at 106 ms. -static void run_until_status_published(MitsubishiCN105TestsContext &ctx) { - run_until_operating_status_request(ctx); +static void run_until_status_published(MitsubishiCN105TestsContext &ctx, bool power_on = false, + uint8_t raw_mode = RAW_MODE_AUTO) { + run_until_operating_status_request(ctx, power_on, raw_mode); push_operating_status_response(ctx.uart, true); ctx.sut.set_current_time(106); ASSERT_TRUE(ctx.sut.update()); @@ -190,6 +193,40 @@ static void miss_operating_status(MitsubishiCN105TestsContext &ctx, uint32_t sen EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); } +static constexpr uint32_t CYCLE_MS = 1010; + +// Runs `cycles` scheduled cycles CYCLE_MS apart from `now`, answering every request with the unit reporting +// `power_on`, `raw_mode` and `running`. Returns the cycle times at which 0x06 was read; `now` moves past the last one. +static std::vector run_cycles(MitsubishiCN105TestsContext &ctx, uint32_t &now, int cycles, bool power_on, + uint8_t raw_mode, bool running) { + std::vector reads; + for (int i = 0; i < cycles; ++i, now += CYCLE_MS) { + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); + ctx.uart.tx.clear(); + ctx.sut.set_current_time(now); + ctx.sut.update(); + if (ctx.uart.tx.size() <= 5 || ctx.uart.tx[5] != 0x02) { + ADD_FAILURE() << "no settings request at " << now; + return reads; + } + push_settings_response(ctx.uart, power_on, raw_mode); + uint32_t t = now + 1; + ctx.sut.set_current_time(t); + ctx.sut.update(); + while (ctx.sut.state_ == TestableMitsubishiCN105::State::DEFERRED_STATUS_REQUEST) { + if (send_deferred_status_request(ctx, ++t) == 0x03) { + push_room_temperature_response(ctx.uart); + } else { + reads.push_back(now); + push_operating_status_response(ctx.uart, running); + } + ctx.sut.set_current_time(++t); + ctx.sut.update(); + } + } + return reads; +} + TEST(MitsubishiCN105Tests, OperatingStatusEndsTelemetryChainWithOnePublish) { MitsubishiCN105TestsContext ctx; run_until_status_published(ctx); @@ -248,11 +285,17 @@ TEST(MitsubishiCN105Tests, UnitWithoutOperatingStatusInitializesAndStopsRequesti ctx.sut.set_current_time(10116); ASSERT_FALSE(ctx.sut.update()); ctx.uart.tx.clear(); - push_settings_response(ctx.uart, false, 0x01); + push_settings_response(ctx.uart, false, RAW_MODE_HEAT); ctx.sut.set_current_time(10117); ASSERT_TRUE(ctx.sut.update()); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); EXPECT_TRUE(ctx.uart.tx.empty()); + + // Power and mode changes never start the faster wind-down reads. + uint32_t now = 11117; + EXPECT_TRUE(run_cycles(ctx, now, 15, true, RAW_MODE_COOL, true).empty()); + EXPECT_TRUE(run_cycles(ctx, now, 15, false, RAW_MODE_HEAT, true).empty()); + EXPECT_FALSE(ctx.sut.status().operating); } TEST(MitsubishiCN105Tests, OperatingStatusAnswerResetsMissCount) { @@ -286,7 +329,7 @@ TEST(MitsubishiCN105Tests, ModeChangeRefreshesOperatingStatusOncePerInterval) { ctx.sut.set_current_time(1106); ASSERT_FALSE(ctx.sut.update()); ctx.uart.tx.clear(); - push_settings_response(ctx.uart, false, 0x01); + push_settings_response(ctx.uart, false, RAW_MODE_HEAT); ctx.sut.set_current_time(1107); ASSERT_FALSE(ctx.sut.update()); EXPECT_EQ(ctx.sut.status().mode, MitsubishiCN105::Mode::HEAT); @@ -321,8 +364,7 @@ TEST(MitsubishiCN105Tests, LocalModeChangeRefreshesOperatingStatus) { ASSERT_FALSE(ctx.sut.update()); ASSERT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS); ctx.uart.tx.clear(); - ctx.uart.push_rx({0xFC, 0x61, 0x01, 0x30, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5E}); + push_write_settings_response(ctx.uart); ctx.sut.set_current_time(108); ASSERT_FALSE(ctx.sut.update()); ASSERT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); @@ -332,12 +374,194 @@ TEST(MitsubishiCN105Tests, LocalModeChangeRefreshesOperatingStatus) { ASSERT_FALSE(ctx.sut.update()); ASSERT_EQ(ctx.uart.tx[5], 0x02); ctx.uart.tx.clear(); - push_settings_response(ctx.uart, false, 0x03); + push_settings_response(ctx.uart, false, RAW_MODE_COOL); ctx.sut.set_current_time(1109); ASSERT_FALSE(ctx.sut.update()); EXPECT_EQ(send_deferred_status_request(ctx, 1110), 0x06); } +TEST(MitsubishiCN105Tests, ModeChangeWhileRunningHoldsWindDownUntilStopped) { + MitsubishiCN105TestsContext ctx; + run_until_status_published(ctx, true, RAW_MODE_HEAT); + ASSERT_TRUE(ctx.sut.status().operating); + + // HEAT -> COOL: "running" is wind-down from heating, and 0x06 is read every 10 cycles instead of once a minute. + uint32_t now = 1106; + EXPECT_THAT(run_cycles(ctx, now, 24, true, RAW_MODE_COOL, true), ::testing::ElementsAre(1106, 11206, 21306)); + EXPECT_FALSE(ctx.sut.status().operating); + + // A "stopped" report ends the hold. + EXPECT_THAT(run_cycles(ctx, now, 7, true, RAW_MODE_COOL, false), ::testing::ElementsAre(31406)); + EXPECT_FALSE(ctx.sut.status().operating); + + // Back to the regular schedule, where "running" is cooling. + EXPECT_TRUE(run_cycles(ctx, now, 28, true, RAW_MODE_COOL, true).empty()); + EXPECT_THAT(run_cycles(ctx, now, 1, true, RAW_MODE_COOL, true), ::testing::ElementsAre(60696)); + EXPECT_TRUE(ctx.sut.status().operating); +} + +TEST(MitsubishiCN105Tests, WindDownHoldEndsAfterNineReads) { + MitsubishiCN105TestsContext ctx; + run_until_status_published(ctx, true, RAW_MODE_HEAT); + + // Nine held reads, the regular one at 60 s among them, then the tenth is trusted. + uint32_t now = 1106; + EXPECT_THAT(run_cycles(ctx, now, 81, true, RAW_MODE_COOL, true), + ::testing::ElementsAre(1106, 11206, 21306, 31406, 41506, 51606, 60696, 70796, 80896)); + EXPECT_FALSE(ctx.sut.status().operating); + EXPECT_THAT(run_cycles(ctx, now, 10, true, RAW_MODE_COOL, true), ::testing::ElementsAre(90996)); + EXPECT_TRUE(ctx.sut.status().operating); + + // The regular schedule follows. + EXPECT_TRUE(run_cycles(ctx, now, 20, true, RAW_MODE_COOL, true).empty()); +} + +TEST(MitsubishiCN105Tests, PowerOffWhileRunningHoldsWindDownAfterPowerOn) { + MitsubishiCN105TestsContext ctx; + run_until_status_published(ctx, true, RAW_MODE_HEAT); + + uint32_t now = 1106; + EXPECT_THAT(run_cycles(ctx, now, 3, false, RAW_MODE_HEAT, true), ::testing::ElementsAre(1106)); + EXPECT_FALSE(ctx.sut.status().operating); + + // Turned back on within the hold: the leftover run is still not reported, and the faster reads go on. + EXPECT_THAT(run_cycles(ctx, now, 10, true, RAW_MODE_HEAT, true), ::testing::ElementsAre(11206)); + EXPECT_FALSE(ctx.sut.status().operating); + EXPECT_THAT(run_cycles(ctx, now, 10, true, RAW_MODE_HEAT, false), ::testing::ElementsAre(21306)); + EXPECT_TRUE(run_cycles(ctx, now, 10, true, RAW_MODE_HEAT, true).empty()); + EXPECT_FALSE(ctx.sut.status().operating); +} + +// Sends the queued local settings at `now` and acknowledges them. Returns whether sending them published a change. +static bool send_local_settings(MitsubishiCN105TestsContext &ctx, uint32_t now) { + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); + ctx.sut.set_current_time(now); + const bool published = ctx.sut.update(); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::APPLYING_SETTINGS); + push_write_settings_response(ctx.uart); + ctx.sut.set_current_time(now + 1); + EXPECT_FALSE(ctx.sut.update()); + EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); + return published; +} + +TEST(MitsubishiCN105Tests, LocalModeChangeWhileRunningHoldsWindDownOnceSent) { + MitsubishiCN105TestsContext ctx; + run_until_status_published(ctx, true, RAW_MODE_HEAT); + + ctx.sut.set_mode(MitsubishiCN105::Mode::COOL); + EXPECT_TRUE(ctx.sut.status().operating); + EXPECT_TRUE(send_local_settings(ctx, 107)); + EXPECT_FALSE(ctx.sut.status().operating); + + uint32_t now = 1108; + EXPECT_THAT(run_cycles(ctx, now, 11, true, RAW_MODE_COOL, true), ::testing::ElementsAre(1108, 11208)); + EXPECT_FALSE(ctx.sut.status().operating); +} + +TEST(MitsubishiCN105Tests, LocalChangeRevertedBeforeSendStartsNoHold) { + using Mode = MitsubishiCN105::Mode; + // Off then on, and HEAT -> COOL -> HEAT, send no change of power or mode. + for (const bool power_cycle : {true, false}) { + MitsubishiCN105TestsContext ctx; + run_until_status_published(ctx, true, RAW_MODE_HEAT); + + if (power_cycle) { + ctx.sut.set_power(false); + ctx.sut.set_power(true); + } else { + ctx.sut.set_mode(Mode::COOL); + ctx.sut.set_mode(Mode::HEAT); + } + EXPECT_FALSE(send_local_settings(ctx, 107)); + EXPECT_TRUE(ctx.sut.status().operating); + + // Only the early read after a power or mode write, and "running" still counts. + uint32_t now = 1108; + EXPECT_THAT(run_cycles(ctx, now, 20, true, RAW_MODE_HEAT, true), ::testing::ElementsAre(1108)) + << "power cycle " << power_cycle; + EXPECT_TRUE(ctx.sut.status().operating); + EXPECT_EQ(ctx.sut.run_direction(), MitsubishiCN105::RunDirection::RUN_DIRECTION_HEATING); + } +} + +TEST(MitsubishiCN105Tests, AutoAndHeatOrCoolSwitchesKeepTheRun) { + MitsubishiCN105TestsContext ctx; + run_until_status_published(ctx, true, RAW_MODE_HEAT); + + // HEAT -> AUTO and back keep heating; only the one early read follows. + uint32_t now = 1106; + EXPECT_THAT(run_cycles(ctx, now, 1, true, RAW_MODE_AUTO, true), ::testing::ElementsAre(1106)); + EXPECT_TRUE(ctx.sut.status().operating); + EXPECT_TRUE(run_cycles(ctx, now, 1, true, RAW_MODE_HEAT, true).empty()); + EXPECT_TRUE(run_cycles(ctx, now, 20, true, RAW_MODE_AUTO, true).empty()); + EXPECT_TRUE(ctx.sut.status().operating); + EXPECT_EQ(ctx.sut.run_direction(), MitsubishiCN105::RunDirection::RUN_DIRECTION_HEATING); + + // The same holds for local changes, until one ends the run. + ctx.sut.set_mode(MitsubishiCN105::Mode::HEAT); + EXPECT_FALSE(send_local_settings(ctx, now)); + EXPECT_TRUE(ctx.sut.status().operating); + ctx.sut.set_mode(MitsubishiCN105::Mode::COOL); + EXPECT_TRUE(send_local_settings(ctx, now + 2)); + EXPECT_FALSE(ctx.sut.status().operating); +} + +TEST(MitsubishiCN105Tests, SwitchFromAutoDependsOnTheRunningDirection) { + // Heating in AUTO: room 21°C, target 24°C. COOL ends the run and starts the hold, HEAT continues it. + struct Case { + uint8_t raw_mode; + std::vector reads; + bool operating; + }; + const std::array cases{Case{RAW_MODE_COOL, {1106, 11206}, false}, Case{RAW_MODE_HEAT, {1106}, true}}; + for (const auto &[raw_mode, reads, operating] : cases) { + MitsubishiCN105TestsContext ctx; + run_until_status_published(ctx, true, RAW_MODE_AUTO); + ASSERT_EQ(ctx.sut.run_direction(), MitsubishiCN105::RunDirection::RUN_DIRECTION_HEATING); + + uint32_t now = 1106; + EXPECT_EQ(run_cycles(ctx, now, 11, true, raw_mode, true), reads) << "raw mode " << static_cast(raw_mode); + EXPECT_EQ(ctx.sut.status().operating, operating) << "raw mode " << static_cast(raw_mode); + } +} + +TEST(MitsubishiCN105Tests, ModeChangeWhileNotRunningStartsNoHold) { + MitsubishiCN105TestsContext ctx; + run_until_operating_status_request(ctx, true, RAW_MODE_HEAT); + push_operating_status_response(ctx.uart, false); + ctx.sut.set_current_time(106); + ASSERT_TRUE(ctx.sut.update()); + + uint32_t now = 1106; + EXPECT_THAT(run_cycles(ctx, now, 1, true, RAW_MODE_COOL, true), ::testing::ElementsAre(1106)); + EXPECT_TRUE(ctx.sut.status().operating); + EXPECT_TRUE(run_cycles(ctx, now, 20, true, RAW_MODE_COOL, true).empty()); +} + +TEST(MitsubishiCN105Tests, WindDownHoldEndsWhenOperatingStatusStopsAnswering) { + MitsubishiCN105TestsContext ctx; + run_until_status_published(ctx, true, RAW_MODE_HEAT); + + // HEAT -> COOL starts the hold; its reads go unanswered until 0x06 counts as unsupported. + for (const uint32_t now : {1106u, 4110u, 7114u}) { + ASSERT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); + ctx.sut.set_current_time(now); + ASSERT_FALSE(ctx.sut.update()); + push_settings_response(ctx.uart, true, RAW_MODE_COOL); + ctx.sut.set_current_time(now + 1); + ctx.sut.update(); + ASSERT_EQ(send_deferred_status_request(ctx, now + 2), 0x06); + ctx.uart.tx.clear(); + miss_operating_status(ctx, now + 2); + } + ASSERT_TRUE(ctx.sut.is_operating_status_unsupported_()); + + uint32_t now = 10116; + EXPECT_TRUE(run_cycles(ctx, now, 20, true, RAW_MODE_COOL, true).empty()); + EXPECT_FALSE(ctx.sut.status().operating); +} + TEST(MitsubishiCN105Tests, NoResponseTriggersReconnect) { MitsubishiCN105TestsContext ctx; @@ -705,8 +929,7 @@ TEST(MitsubishiCN105Tests, WriteInterruptsWaitingForNextStatusUpdate) { EXPECT_THAT(ctx.uart.tx, ::testing::ElementsAre(0xFC, 0x41, 0x01, 0x30, 0x10, 0x01, 0x1F, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xB2, 0x00, 0xAB)); // Write ACK response - ctx.uart.push_rx({0xFC, 0x61, 0x01, 0x30, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5E}); + push_write_settings_response(ctx.uart); ctx.sut.set_current_time(6500); ASSERT_FALSE(ctx.sut.update()); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); @@ -757,8 +980,7 @@ TEST(MitsubishiCN105Tests, SetAndClearRemoteRoomTemp) { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x94)); // Write ACK response - ctx.uart.push_rx({0xFC, 0x61, 0x01, 0x30, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5E}); + push_write_settings_response(ctx.uart); ASSERT_FALSE(ctx.sut.update()); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); @@ -773,8 +995,7 @@ TEST(MitsubishiCN105Tests, SetAndClearRemoteRoomTemp) { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF7)); // Write ACK response - ctx.uart.push_rx({0xFC, 0x61, 0x01, 0x30, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5E}); + push_write_settings_response(ctx.uart); ASSERT_FALSE(ctx.sut.update()); EXPECT_EQ(ctx.sut.state_, TestableMitsubishiCN105::State::WAITING_FOR_SCHEDULED_STATUS_UPDATE); } @@ -804,8 +1025,7 @@ TEST(MitsubishiCN105Tests, ApplyQueuedSettingsThenRemoteRoomTempInSecondWrite) { // ACK the first write. Remote temperature should still be pending afterward. ctx.uart.tx.clear(); - ctx.uart.push_rx({0xFC, 0x61, 0x01, 0x30, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x5E}); + push_write_settings_response(ctx.uart); ASSERT_FALSE(ctx.sut.update()); EXPECT_TRUE(ctx.sut.pending_updates_.contains(TestableMitsubishiCN105::PropertyId::REMOTE_TEMPERATURE)); diff --git a/tests/components/mitsubishi_cn105/common.h b/tests/components/mitsubishi_cn105/common.h index bd98763a788..ea40cf0473e 100644 --- a/tests/components/mitsubishi_cn105/common.h +++ b/tests/components/mitsubishi_cn105/common.h @@ -54,16 +54,23 @@ template inline void push_frame(MockUARTComponent &uart, const st uart.rx.push_back(static_cast(0xFC - sum)); } -// 0x02 settings: target 24°C (encoding A), fan auto, vane position 4, wide vane swing. Mode 0x08 is AUTO. -inline void push_settings_response(MockUARTComponent &uart, bool power_on = false, uint8_t raw_mode = 0x08) { +// Raw 0x02 mode bytes. +static constexpr uint8_t RAW_MODE_HEAT = 0x01; +static constexpr uint8_t RAW_MODE_COOL = 0x03; +static constexpr uint8_t RAW_MODE_AUTO = 0x08; + +// 0x02 settings: target 24°C (encoding A), fan auto, vane position 4, wide vane swing. +inline void push_settings_response(MockUARTComponent &uart, bool power_on = false, uint8_t raw_mode = RAW_MODE_AUTO) { push_frame(uart, std::array{0xFC, 0x62, 0x01, 0x30, 0x10, 0x02, 0x00, 0x00, power_on, raw_mode, 0x07, 0x00, 0x04, 0x00, 0x00, 0x0C, 0x00, 0x00, 0x00, 0x00, 0x00}); } -// 0x03 room temperature: 21°C. -inline void push_room_temperature_response(MockUARTComponent &uart) { - push_frame(uart, std::array{0xFC, 0x62, 0x01, 0x30, 0x10, 0x03, 0x00, 0x00, 0x0B, 0x00, 0x00, - 0xAA, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}); +// 0x03 room temperature, 21°C by default, in whole degrees. +inline void push_room_temperature_response(MockUARTComponent &uart, uint8_t celsius = 21) { + std::array frame{0xFC, 0x62, 0x01, 0x30, 0x10, 0x03}; + frame[8] = celsius - 10; + frame[11] = celsius * 2 + 128; + push_frame(uart, frame); } // 0x06 operating status. @@ -72,6 +79,11 @@ inline void push_operating_status_response(MockUARTComponent &uart, bool operati 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}); } +// Write settings acknowledgement. +inline void push_write_settings_response(MockUARTComponent &uart) { + push_frame(uart, std::array{0xFC, 0x61, 0x01, 0x30, 0x10}); +} + class TestableMitsubishiCN105 : public MitsubishiCN105 { public: using MitsubishiCN105::MitsubishiCN105; From 848231f59e5eec03b2227d74e1b3f40bf7efb7e2 Mon Sep 17 00:00:00 2001 From: Guillaume DELVIT Date: Fri, 30 Oct 2026 06:31:37 +0100 Subject: [PATCH 2/3] [airton_serial] Add Airton serial climate component (#21872) Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston --- CODEOWNERS | 1 + esphome/components/airton_serial/__init__.py | 14 + .../airton_serial/airton_serial_climate.cpp | 418 ++++++++++++++++++ .../airton_serial/airton_serial_climate.h | 83 ++++ esphome/components/airton_serial/climate.py | 30 ++ esphome/components/airton_serial/sensor.py | 39 ++ esphome/components/airton_serial/switch.py | 45 ++ .../climate/airton_serial_frame_tests.cpp | 308 +++++++++++++ tests/components/airton_serial/common.h | 41 ++ tests/components/airton_serial/common.yaml | 26 ++ .../airton_serial/test.esp32-idf.yaml | 7 + .../airton_serial/test.esp8266-ard.yaml | 7 + 12 files changed, 1019 insertions(+) create mode 100644 esphome/components/airton_serial/__init__.py create mode 100644 esphome/components/airton_serial/airton_serial_climate.cpp create mode 100644 esphome/components/airton_serial/airton_serial_climate.h create mode 100644 esphome/components/airton_serial/climate.py create mode 100644 esphome/components/airton_serial/sensor.py create mode 100644 esphome/components/airton_serial/switch.py create mode 100644 tests/components/airton_serial/climate/airton_serial_frame_tests.cpp create mode 100644 tests/components/airton_serial/common.h create mode 100644 tests/components/airton_serial/common.yaml create mode 100644 tests/components/airton_serial/test.esp32-idf.yaml create mode 100644 tests/components/airton_serial/test.esp8266-ard.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 3161f639bfa..2a3fd3935fc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -35,6 +35,7 @@ esphome/components/airthings_wave_plus/* @jeromelaban @precurse esphome/components/airton/* @Procsiab esphome/components/airton/select/* @Procsiab esphome/components/airton/switch/* @Procsiab +esphome/components/airton_serial/* @guiguid esphome/components/alarm_control_panel/* @grahambrown11 @hwstar esphome/components/alpha3/* @jan-hofmeier esphome/components/am2315c/* @swoboda1337 diff --git a/esphome/components/airton_serial/__init__.py b/esphome/components/airton_serial/__init__.py new file mode 100644 index 00000000000..60649604d28 --- /dev/null +++ b/esphome/components/airton_serial/__init__.py @@ -0,0 +1,14 @@ +"""Airton serial climate component for ESPHome.""" + +import esphome.codegen as cg +from esphome.components import climate, uart + +CODEOWNERS = ["@guiguid"] +DOMAIN = "airton_serial" + +CONF_AIRTON_SERIAL_ID = "airton_serial_id" + +airton_serial_ns = cg.esphome_ns.namespace(DOMAIN) +AirtonSerialClimate = airton_serial_ns.class_( + "AirtonSerialClimate", uart.UARTDevice, climate.Climate, cg.Component +) diff --git a/esphome/components/airton_serial/airton_serial_climate.cpp b/esphome/components/airton_serial/airton_serial_climate.cpp new file mode 100644 index 00000000000..b7336da1b27 --- /dev/null +++ b/esphome/components/airton_serial/airton_serial_climate.cpp @@ -0,0 +1,418 @@ +#include "airton_serial_climate.h" + +#include +#include +#include +#include + +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::airton_serial { + +ESPHOME_LOG_TAG(TAG, "airton_serial.climate"); + +static constexpr uint8_t SYNC_BYTE = 0x7A; +static constexpr uint8_t LENGTH_POS = 4; +static constexpr uint8_t OPCODE_POS = 7; +static constexpr uint8_t MIN_FRAME_LEN = OPCODE_POS + 1 + 2; // Up to the opcode, then the CRC +// A partial frame left this long without a new byte is not a frame start. +static constexpr uint32_t FRAME_GAP_MS = 50; + +static constexpr uint8_t ADDR_MODULE = 0x21; +static constexpr uint8_t ADDR_DISPLAY = 0xD1; +static constexpr uint8_t ADDR_BOARD = 0xD5; + +static constexpr uint8_t OP_A3_STATE = 0xA3; +static constexpr uint8_t OP_A4_ACK = 0xA4; +static constexpr uint8_t OP_AC_POLL = 0xAC; + +// Announcement sent to the display every 10 s, followed by the ESP's MAC address and the CRC. +static constexpr uint8_t A5_HEADER[] PROGMEM = { + 0x7A, 0x7A, 0x21, 0xD1, 0x1B, 0x00, 0x00, 0xA5, 0x0A, 0x0A, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, +}; + +// Poll sent to the climate board every 60 s. +static constexpr uint8_t AB_FRAME[] PROGMEM = { + 0x7A, 0x7A, 0x21, 0xD5, 0x0C, 0x00, 0x00, 0xAB, 0x0A, 0x0A, 0xFC, 0xF9, +}; + +static constexpr uint8_t TEMP_MIN = 16; +static constexpr uint8_t TEMP_MAX = 31; + +// State byte: bit 7 super, bits 4-6 fan speed, bit 3 power, bits 0-2 mode. +static constexpr uint8_t POWER_BIT = 0x08; +static constexpr uint8_t MODE_MASK = 0x07; +static constexpr uint8_t FAN_AUTO = 0x0; +static constexpr uint8_t FAN_SUPER = 0x8; // Bit 7 of the state byte, the top bit of the fan nibble +static constexpr uint8_t FAN_STRONG = FAN_SUPER | 0x5; +// Setpoint byte: bit 6 mute, bit 5 Fahrenheit display, bits 0-4 setpoint above TEMP_MIN. +static constexpr uint8_t FLAG_QUIET = 0x40; +static constexpr uint8_t FLAG_FAHRENHEIT = 0x20; +static constexpr uint8_t SETPOINT_MASK = 0x1F; +// Swing byte: bit 0 vertical, bit 4 horizontal. +static constexpr uint8_t SWING_VERTICAL = 0x01; +static constexpr uint8_t SWING_HORIZONTAL = 0x10; +static constexpr uint8_t SWING_MASK = SWING_VERTICAL | SWING_HORIZONTAL; + +struct CodeMap { + uint8_t value; // ESPHome enum value + uint8_t code; // Protocol nibble +}; + +// Mode bits; off is the power bit, which keeps the mode bits. +static constexpr CodeMap MODE_CODES[] PROGMEM = { + {climate::CLIMATE_MODE_HEAT_COOL, 0x0}, {climate::CLIMATE_MODE_COOL, 0x1}, {climate::CLIMATE_MODE_DRY, 0x2}, + {climate::CLIMATE_MODE_FAN_ONLY, 0x3}, {climate::CLIMATE_MODE_HEAT, 0x4}, +}; + +static constexpr CodeMap SWING_CODES[] PROGMEM = { + {climate::CLIMATE_SWING_OFF, 0x00}, + {climate::CLIMATE_SWING_VERTICAL, SWING_VERTICAL}, + {climate::CLIMATE_SWING_HORIZONTAL, SWING_HORIZONTAL}, + {climate::CLIMATE_SWING_BOTH, SWING_MASK}, +}; + +static constexpr CodeMap FAN_CODES[] PROGMEM = { + {climate::CLIMATE_FAN_AUTO, FAN_AUTO}, + {climate::CLIMATE_FAN_LOW, 0x1}, + {climate::CLIMATE_FAN_MEDIUM, 0x3}, + {climate::CLIMATE_FAN_HIGH, 0x5}, +}; + +// Speeds with no standard ESPHome fan mode, and their codes at the same index. +static constexpr const char *const CUSTOM_FAN_MODES[] = {"medlow", "medhigh", "strong"}; +static constexpr uint8_t CUSTOM_FAN_CODES[] PROGMEM = {0x2, 0x4, FAN_STRONG}; + +template static bool code_of(const CodeMap (&table)[N], uint8_t value, uint8_t &code) { + for (const CodeMap &entry : table) { + if (progmem_read_byte(&entry.value) == value) { + code = progmem_read_byte(&entry.code); + return true; + } + } + return false; +} + +template static bool value_of(const CodeMap (&table)[N], uint8_t code, uint8_t &value) { + for (const CodeMap &entry : table) { + if (progmem_read_byte(&entry.code) == code) { + value = progmem_read_byte(&entry.value); + return true; + } + } + return false; +} + +AirtonSerialClimate::AirtonSerialClimate() { + this->set_supported_custom_fan_modes_static(CUSTOM_FAN_MODES, std::size(CUSTOM_FAN_MODES)); + this->target_temperature = 21.0f; +} + +void AirtonSerialClimate::setup() { + // Announce and poll immediately, then keep the module's cadence. + this->send_a5_(); + this->send_ab_(); + this->set_interval(10000, [this]() { this->send_a5_(); }); + this->set_interval(60000, [this]() { this->send_ab_(); }); +} + +void AirtonSerialClimate::loop() { + const uint32_t now = App.get_loop_component_start_time(); + bool received = false; + this->read_available([this, &received](uint8_t byte) { + received = true; + this->handle_byte_(byte); + }); + if (received) { + this->last_rx_ms_ = now; + } else if (this->rx_pos_ > 0 && now - this->last_rx_ms_ >= FRAME_GAP_MS) { + // A corrupt length can hold back complete frames behind it; look for them in what is buffered. + while (this->rx_pos_ > 0) + this->drop_first_byte_(); + } +} + +void AirtonSerialClimate::handle_byte_(uint8_t byte) { + // Bytes are checked as they arrive, so rx_pos_ stays below the longest frame length. + this->rx_buffer_[this->rx_pos_++] = byte; + this->scan_buffer_(); +} + +void AirtonSerialClimate::drop_first_byte_() { + this->rx_pos_--; + std::memmove(this->rx_buffer_.data(), this->rx_buffer_.data() + 1, this->rx_pos_); + this->scan_buffer_(); +} + +void AirtonSerialClimate::scan_buffer_() { + const uint8_t *buf = this->rx_buffer_.data(); + while (this->rx_pos_ > 0) { + uint8_t drop = 1; + if (buf[0] == SYNC_BYTE && (this->rx_pos_ < 2 || buf[1] == SYNC_BYTE)) { + if (this->rx_pos_ <= LENGTH_POS) + return; + const uint8_t len = buf[LENGTH_POS]; + if (len >= MIN_FRAME_LEN && len <= MAX_FRAME_LEN) { + if (this->rx_pos_ < len) + return; + if (crc16(buf, len - 2) == encode_uint16(buf[len - 2], buf[len - 1])) { + this->handle_frame_(buf, len); + drop = len; + } else { + ESP_LOGW(TAG, "Bad CRC on %u-byte frame", len); + } + } + } + // Not a frame start, or a bad frame: look for the next start after its first byte. + this->rx_pos_ -= drop; + std::memmove(this->rx_buffer_.data(), buf + drop, this->rx_pos_); + } +} + +void AirtonSerialClimate::handle_frame_(const uint8_t *data, size_t len) { + const uint8_t opcode = data[OPCODE_POS]; +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + if (opcode == OP_A3_STATE || opcode == OP_AC_POLL) { + char hex[format_hex_pretty_size(MAX_FRAME_LEN)]; + ESP_LOGV(TAG, "%02X raw: %s", opcode, format_hex_pretty_to(hex, data, len)); + } +#endif + if (data[3] != ADDR_MODULE) + return; + if (data[2] == ADDR_BOARD) { + if (len == 34 && opcode == OP_A3_STATE) { + this->handle_a3_(data); + } else if (len == 18 && opcode == OP_AC_POLL) { + this->handle_ac_(data); + } + } else if (data[2] == ADDR_DISPLAY && len == 13 && opcode == OP_A4_ACK) { + ESP_LOGD(TAG, "A4 acknowledgement from display"); + } +} + +void AirtonSerialClimate::handle_a3_(const uint8_t *data) { + const uint8_t mode_fan = data[13]; + const uint8_t setpoint_raw = data[14]; + // Kept so that quiet, which mutes whatever speed is set, and speeds with no fan mode are sent back unchanged. + this->fan_raw_ = mode_fan >> 4; + this->fahrenheit_ = (setpoint_raw & FLAG_FAHRENHEIT) != 0; + // Super is reported with the speed it runs at; it is the "strong" custom mode. + const uint8_t fan = (this->fan_raw_ & FAN_SUPER) != 0 ? FAN_STRONG : this->fan_raw_; + const uint8_t mode = mode_fan & (POWER_BIT | MODE_MASK); + const uint8_t setpoint = (setpoint_raw & SETPOINT_MASK) + TEMP_MIN; + + this->current_temperature = static_cast(data[10]) + static_cast(data[11]) / 10.0f; + this->target_temperature = static_cast(setpoint); + + uint8_t value; + this->mode_bits_ = mode & MODE_MASK; + if ((mode & POWER_BIT) == 0) { + this->mode = climate::CLIMATE_MODE_OFF; + } else if (value_of(MODE_CODES, this->mode_bits_, value)) { + this->mode = static_cast(value); + } + + if ((setpoint_raw & FLAG_QUIET) != 0) { + this->set_fan_mode_(climate::CLIMATE_FAN_QUIET); + } else if (value_of(FAN_CODES, fan, value)) { + this->set_fan_mode_(static_cast(value)); + } else { + bool found = false; + for (size_t i = 0; i < std::size(CUSTOM_FAN_MODES) && !found; i++) { + found = progmem_read_byte(&CUSTOM_FAN_CODES[i]) == fan; + if (found) + this->set_custom_fan_mode_(CUSTOM_FAN_MODES[i]); + } + if (!found) { + // Speed 6 has no fan mode; with none set, commands send fan_raw_ back. + this->fan_mode.reset(); + this->clear_custom_fan_mode_(); + } + } + + this->swing_ = data[15]; + if (value_of(SWING_CODES, this->swing_ & SWING_MASK, value)) + this->swing_mode = static_cast(value); + this->flags_ = data[16]; + this->state_received_ = true; + ESP_LOGD(TAG, "A3: mode=0x%02X fan=0x%02X setpoint=%u ambient=%.1f swing=%u flags=0x%02X", mode, fan, setpoint, + this->current_temperature, data[15], data[16]); + this->publish_settings_(); + this->publish_state(); +} + +void AirtonSerialClimate::handle_ac_(const uint8_t *data) { + const uint16_t total_usage_time = encode_uint16(data[10], data[11]); + const uint16_t revision = encode_uint16(data[13], data[14]); +#ifdef USE_SENSOR + this->total_usage_time_sensor_.publish_state_if_not_dup(total_usage_time); + this->revision_sensor_.publish_state_if_not_dup(revision); +#endif + ESP_LOGD(TAG, "AC: total_usage_time=%u h revision=%u", total_usage_time, revision); +} + +void AirtonSerialClimate::publish_settings_() { +#ifdef USE_SWITCH + if (this->display_switch_ != nullptr) + this->display_switch_->publish_state((this->flags_ & FLAG_DISPLAY) != 0); + if (this->ionizer_switch_ != nullptr) + this->ionizer_switch_->publish_state((this->flags_ & FLAG_IONIZER) != 0); + if (this->sleep_switch_ != nullptr) + this->sleep_switch_->publish_state((this->flags_ & FLAG_SLEEP) != 0); + if (this->eco_switch_ != nullptr) + this->eco_switch_->publish_state((this->flags_ & FLAG_ECO) != 0); + if (this->health_switch_ != nullptr) + this->health_switch_->publish_state((this->flags_ & FLAG_HEALTH) != 0); +#endif +} + +bool AirtonSerialClimate::can_send_command_() { + if (!this->state_received_) { + ESP_LOGW(TAG, "No state from the unit yet, change ignored"); + } + return this->state_received_; +} + +bool AirtonSerialClimate::set_flag(uint8_t flag, bool state) { + if (!this->can_send_command_()) + return false; + assign_bits(this->flags_, flag, state); + this->send_command_(); + return true; +} + +void AirtonSerialClimate::control(const climate::ClimateCall &call) { + // A1 carries the whole state, so sending it before the unit reported its own would apply the defaults. + if (!this->can_send_command_()) + return; + if (call.get_mode().has_value()) + this->mode = *call.get_mode(); + if (call.get_target_temperature().has_value()) { + // The unit takes whole degrees. + this->target_temperature = static_cast( + std::clamp(static_cast(std::lround(*call.get_target_temperature())), TEMP_MIN, TEMP_MAX)); + } + if (call.has_custom_fan_mode()) { + this->set_custom_fan_mode_(call.get_custom_fan_mode()); + } else if (call.get_fan_mode().has_value()) { + this->set_fan_mode_(*call.get_fan_mode()); + } + if (call.get_swing_mode().has_value()) { + this->swing_mode = *call.get_swing_mode(); + uint8_t code = 0; + code_of(SWING_CODES, this->swing_mode, code); + // Other bits of the swing byte are kept as the unit reported them. + this->swing_ = static_cast((this->swing_ & ~SWING_MASK) | code); + } + + this->send_command_(); + this->publish_state(); +} + +void AirtonSerialClimate::send_command_() { + // Off clears the power bit and leaves the last mode bits, as the unit reports them. + uint8_t mode_nibble = this->mode_bits_; + if (code_of(MODE_CODES, this->mode, this->mode_bits_)) + mode_nibble = this->mode_bits_ | POWER_BIT; + + uint8_t fan_nibble = this->fan_raw_; + bool quiet = false; + if (this->has_custom_fan_mode()) { + const StringRef custom = this->get_custom_fan_mode(); + for (size_t i = 0; i < std::size(CUSTOM_FAN_MODES); i++) { + if (custom == CUSTOM_FAN_MODES[i]) { + fan_nibble = progmem_read_byte(&CUSTOM_FAN_CODES[i]); + break; + } + } + // Super runs on top of a speed; keep the one the unit reported instead of forcing speed 5 + if (fan_nibble == FAN_STRONG && (this->fan_raw_ & FAN_SUPER) != 0) + fan_nibble = this->fan_raw_; + } else if (this->fan_mode.has_value()) { + quiet = *this->fan_mode == climate::CLIMATE_FAN_QUIET; + if (!quiet) + code_of(FAN_CODES, *this->fan_mode, fan_nibble); + } + this->fan_raw_ = fan_nibble; + + const int32_t rounded = + std::clamp(static_cast(std::lround(this->target_temperature)), TEMP_MIN, TEMP_MAX); + uint8_t setpoint = static_cast(rounded - TEMP_MIN); + if (quiet) + setpoint |= FLAG_QUIET; + if (this->fahrenheit_) + setpoint |= FLAG_FAHRENHEIT; + + const uint8_t state = static_cast((fan_nibble << 4) | mode_nibble); + + uint8_t frame[24]{}; + frame[0] = SYNC_BYTE; + frame[1] = SYNC_BYTE; + frame[2] = ADDR_MODULE; + frame[3] = ADDR_BOARD; + frame[LENGTH_POS] = sizeof(frame); + frame[OPCODE_POS] = 0xA1; + frame[12] = state; + frame[13] = setpoint; + frame[14] = this->swing_; + frame[15] = this->flags_; + this->write_frame_(frame, sizeof(frame)); + ESP_LOGD(TAG, "A1: state=0x%02X setpoint=0x%02X swing=0x%02X flags=0x%02X", state, setpoint, this->swing_, + this->flags_); +} + +void AirtonSerialClimate::send_a5_() { + uint8_t frame[sizeof(A5_HEADER) + MAC_ADDRESS_SIZE + 2]{}; // MAC stays zero without networking + progmem_memcpy(frame, A5_HEADER, sizeof(A5_HEADER)); + get_mac_address_raw(frame + sizeof(A5_HEADER)); + this->write_frame_(frame, sizeof(frame)); +} + +void AirtonSerialClimate::send_ab_() { + uint8_t frame[sizeof(AB_FRAME)]; + progmem_memcpy(frame, AB_FRAME, sizeof(AB_FRAME)); + this->write_array(frame, sizeof(frame)); +} + +void AirtonSerialClimate::write_frame_(uint8_t *frame, size_t len) { + const uint16_t crc = crc16(frame, len - 2); + frame[len - 2] = static_cast(crc >> 8); + frame[len - 1] = static_cast(crc); + this->write_array(frame, len); +} + +climate::ClimateTraits AirtonSerialClimate::traits() { + climate::ClimateTraits traits; + traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); + traits.set_supported_modes({climate::CLIMATE_MODE_OFF, climate::CLIMATE_MODE_HEAT_COOL, climate::CLIMATE_MODE_COOL, + climate::CLIMATE_MODE_HEAT, climate::CLIMATE_MODE_DRY, climate::CLIMATE_MODE_FAN_ONLY}); + traits.set_supported_fan_modes({climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH, climate::CLIMATE_FAN_QUIET}); + traits.set_supported_swing_modes({climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL, + climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}); + traits.set_visual_min_temperature(TEMP_MIN); + traits.set_visual_max_temperature(TEMP_MAX); + traits.set_visual_target_temperature_step(1); + traits.set_visual_current_temperature_step(0.1f); + return traits; +} + +void AirtonSerialClimate::dump_config() { + LOG_CLIMATE("", "Airton Serial Climate", this); +#ifdef USE_SWITCH + LOG_SWITCH(" ", "Display", this->display_switch_); + LOG_SWITCH(" ", "Ionizer", this->ionizer_switch_); + LOG_SWITCH(" ", "Sleep", this->sleep_switch_); + LOG_SWITCH(" ", "Eco", this->eco_switch_); + LOG_SWITCH(" ", "Health", this->health_switch_); +#endif +#ifdef USE_SENSOR + LOG_SENSOR_WITH_DEDUP(" ", "Total Usage Time", this->total_usage_time_sensor_); + LOG_SENSOR_WITH_DEDUP(" ", "Revision", this->revision_sensor_); +#endif +} + +} // namespace esphome::airton_serial diff --git a/esphome/components/airton_serial/airton_serial_climate.h b/esphome/components/airton_serial/airton_serial_climate.h new file mode 100644 index 00000000000..37eecca11b3 --- /dev/null +++ b/esphome/components/airton_serial/airton_serial_climate.h @@ -0,0 +1,83 @@ +#pragma once + +#include +#include +#include + +#include "esphome/components/climate/climate.h" +#include "esphome/components/uart/uart.h" +#include "esphome/core/component.h" + +#ifdef USE_SENSOR +#include "esphome/components/sensor/sensor_with_dedup.h" +#endif +#ifdef USE_SWITCH +#include "esphome/components/switch/switch.h" +#endif + +namespace esphome::airton_serial { + +// Bits of the settings byte, one per switch. +static constexpr uint8_t FLAG_DISPLAY = 0x80; +static constexpr uint8_t FLAG_IONIZER = 0x40; +static constexpr uint8_t FLAG_HEALTH = 0x10; +static constexpr uint8_t FLAG_SLEEP = 0x02; +static constexpr uint8_t FLAG_ECO = 0x01; + +static constexpr uint8_t MAX_FRAME_LEN = 64; + +/// Climate on the Airton serial bus. The ESP takes the place of the original Wi-Fi module (address 0x21): +/// it announces itself to the display (A5), polls the climate board (AB), parses the A3 state and AC poll +/// frames and sends A1 commands. Protocol: https://github.com/coworking-metz/ha-skyworth-airton +class AirtonSerialClimate final : public uart::UARTDevice, public climate::Climate, public Component { + public: + AirtonSerialClimate(); + + void setup() override; + void loop() override; + void dump_config() override; + + climate::ClimateTraits traits() override; + void control(const climate::ClimateCall &call) override; + + /// Set or clear one FLAG_* bit and send it to the unit; false until the unit has reported its state. + bool set_flag(uint8_t flag, bool state); + +#ifdef USE_SWITCH + SUB_SWITCH(display) + SUB_SWITCH(ionizer) + SUB_SWITCH(sleep) + SUB_SWITCH(eco) + SUB_SWITCH(health) +#endif +#ifdef USE_SENSOR + SUB_SENSOR_WITH_DEDUP(total_usage_time, uint16_t) + SUB_SENSOR_WITH_DEDUP(revision, uint16_t) +#endif + + protected: + void send_a5_(); + void send_ab_(); + bool can_send_command_(); + void send_command_(); + void write_frame_(uint8_t *frame, size_t len); + void handle_byte_(uint8_t byte); + void drop_first_byte_(); + void scan_buffer_(); + void handle_frame_(const uint8_t *data, size_t len); + void handle_a3_(const uint8_t *data); + void handle_ac_(const uint8_t *data); + void publish_settings_(); + + std::array rx_buffer_; + uint8_t rx_pos_{0}; + uint8_t flags_{FLAG_DISPLAY}; + bool state_received_{false}; + uint8_t swing_{0}; // Raw swing byte + uint8_t mode_bits_{0}; // Mode bits, kept while the unit is off + uint8_t fan_raw_{0}; // Fan nibble last reported or sent, super bit included + bool fahrenheit_{false}; + uint32_t last_rx_ms_{0}; +}; + +} // namespace esphome::airton_serial diff --git a/esphome/components/airton_serial/climate.py b/esphome/components/airton_serial/climate.py new file mode 100644 index 00000000000..be443fb2b3d --- /dev/null +++ b/esphome/components/airton_serial/climate.py @@ -0,0 +1,30 @@ +import esphome.codegen as cg +from esphome.components import climate, uart +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import DOMAIN, AirtonSerialClimate + +DEPENDENCIES = ["uart"] + +CONFIG_SCHEMA = ( + climate.climate_schema(AirtonSerialClimate) + .extend(uart.UART_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + DOMAIN, + baud_rate=9600, + require_rx=True, + require_tx=True, + data_bits=8, + parity="NONE", + stop_bits=1, +) + + +async def to_code(config: ConfigType) -> None: + var = await climate.new_climate(config) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) diff --git a/esphome/components/airton_serial/sensor.py b/esphome/components/airton_serial/sensor.py new file mode 100644 index 00000000000..43a7837cadf --- /dev/null +++ b/esphome/components/airton_serial/sensor.py @@ -0,0 +1,39 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_DURATION, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_TOTAL_INCREASING, + UNIT_HOUR, +) +from esphome.types import ConfigType + +from . import CONF_AIRTON_SERIAL_ID, AirtonSerialClimate + +CONF_TOTAL_USAGE_TIME = "total_usage_time" +CONF_REVISION = "revision" + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_AIRTON_SERIAL_ID): cv.use_id(AirtonSerialClimate), + cv.Optional(CONF_TOTAL_USAGE_TIME): sensor.sensor_schema( + unit_of_measurement=UNIT_HOUR, + accuracy_decimals=0, + device_class=DEVICE_CLASS_DURATION, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_REVISION): sensor.sensor_schema( + accuracy_decimals=0, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_AIRTON_SERIAL_ID]) + sensors = sensor.sub_sensors(config) + await sensors(CONF_TOTAL_USAGE_TIME, hub.set_total_usage_time_sensor) + await sensors(CONF_REVISION, hub.set_revision_sensor) diff --git a/esphome/components/airton_serial/switch.py b/esphome/components/airton_serial/switch.py new file mode 100644 index 00000000000..734a6784dfa --- /dev/null +++ b/esphome/components/airton_serial/switch.py @@ -0,0 +1,45 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.const import CONF_DISPLAY, ENTITY_CATEGORY_CONFIG +from esphome.cpp_generator import MockObj +from esphome.types import ConfigType + +from . import CONF_AIRTON_SERIAL_ID, AirtonSerialClimate + +CONF_ECO = "eco" +CONF_HEALTH = "health" +CONF_IONIZER = "ionizer" +CONF_SLEEP = "sleep" + +_SWITCH_SCHEMA = switch.switch_schema( + switch.ApplySwitch, + entity_category=ENTITY_CATEGORY_CONFIG, + default_restore_mode="DISABLED", +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_AIRTON_SERIAL_ID): cv.use_id(AirtonSerialClimate), + cv.Optional(CONF_DISPLAY): _SWITCH_SCHEMA, + cv.Optional(CONF_IONIZER): _SWITCH_SCHEMA, + cv.Optional(CONF_SLEEP): _SWITCH_SCHEMA, + cv.Optional(CONF_ECO): _SWITCH_SCHEMA, + cv.Optional(CONF_HEALTH): _SWITCH_SCHEMA, + } +) + + +async def to_code(config: ConfigType) -> None: + hub = await cg.get_variable(config[CONF_AIRTON_SERIAL_ID]) + switches = switch.sub_switches(config, parent=hub) + + async def flag_switch(key: str, setter: MockObj, flag: str) -> None: + apply = f"set_flag(airton_serial::{flag}, state)" + await switches(key, setter, apply=apply, publish="on_success") + + await flag_switch(CONF_DISPLAY, hub.set_display_switch, "FLAG_DISPLAY") + await flag_switch(CONF_IONIZER, hub.set_ionizer_switch, "FLAG_IONIZER") + await flag_switch(CONF_SLEEP, hub.set_sleep_switch, "FLAG_SLEEP") + await flag_switch(CONF_ECO, hub.set_eco_switch, "FLAG_ECO") + await flag_switch(CONF_HEALTH, hub.set_health_switch, "FLAG_HEALTH") diff --git a/tests/components/airton_serial/climate/airton_serial_frame_tests.cpp b/tests/components/airton_serial/climate/airton_serial_frame_tests.cpp new file mode 100644 index 00000000000..21c72893159 --- /dev/null +++ b/tests/components/airton_serial/climate/airton_serial_frame_tests.cpp @@ -0,0 +1,308 @@ +#include + +#include +#include + +#include "esphome/components/airton_serial/airton_serial_climate.h" +#include "esphome/core/application.h" +#include "esphome/core/helpers.h" +#include "../common.h" + +namespace esphome::airton_serial::testing { + +namespace { + +void set_crc(std::vector &frame) { + const uint16_t crc = crc16(frame.data(), frame.size() - 2); + frame[frame.size() - 2] = crc >> 8; + frame[frame.size() - 1] = crc & 0xFF; +} + +// A3 state frame from the climate board, by default on, cool, fan high, 24 C setpoint, 22 C ambient. +std::vector a3_frame(uint8_t swing = 0, uint8_t state = 0x59, uint8_t setpoint = 24 - 16) { + std::vector frame(34, 0); + frame[0] = 0x7A; + frame[1] = 0x7A; + frame[2] = 0xD5; + frame[3] = 0x21; + frame[4] = 34; + frame[7] = 0xA3; + frame[10] = 22; + frame[13] = state; + frame[14] = setpoint; + frame[15] = swing; + frame[16] = 0x80; + set_crc(frame); + return frame; +} + +class AirtonSerialFrameTest : public ::testing::Test { + protected: + void SetUp() override { + this->sut_.set_uart_parent(&this->uart_); + this->sut_.mode = climate::CLIMATE_MODE_OFF; + } + + void expect_a3_applied_() { + EXPECT_EQ(this->sut_.mode, climate::CLIMATE_MODE_COOL); + EXPECT_FLOAT_EQ(this->sut_.target_temperature, 24.0f); + EXPECT_FLOAT_EQ(this->sut_.current_temperature, 22.0f); + ASSERT_TRUE(this->sut_.fan_mode.has_value()); + EXPECT_EQ(*this->sut_.fan_mode, climate::CLIMATE_FAN_HIGH); + } + + void loop_at_(uint32_t now) { + LoopBlockingGuard{nullptr, nullptr, now}; + this->sut_.loop(); + } + + // Byte of the last A1 frame sent. + uint8_t sent_(size_t index) const { + if (this->uart_.tx.size() < 24) { + ADD_FAILURE() << "no A1 frame sent"; + return 0; + } + return this->uart_.tx[this->uart_.tx.size() - 24 + index]; + } + + void receive_state_(uint8_t swing = 0, uint8_t state = 0x59, uint8_t setpoint = 24 - 16) { + this->uart_.push_rx(a3_frame(swing, state, setpoint)); + this->sut_.loop(); + } + + MockUARTComponent uart_; + AirtonSerialClimate sut_; +}; + +} // namespace + +TEST_F(AirtonSerialFrameTest, ValidFrameAfterNoise) { + this->uart_.push_rx({0x00, 0x7A, 0x13, 0xFF}); + this->uart_.push_rx(a3_frame()); + this->sut_.loop(); + this->expect_a3_applied_(); +} + +TEST_F(AirtonSerialFrameTest, FrameSplitAcrossLoops) { + const auto frame = a3_frame(); + this->uart_.rx.assign(frame.begin(), frame.begin() + 10); + this->sut_.loop(); + EXPECT_EQ(this->sut_.mode, climate::CLIMATE_MODE_OFF); + this->uart_.rx.assign(frame.begin() + 10, frame.end()); + this->sut_.loop(); + this->expect_a3_applied_(); +} + +TEST_F(AirtonSerialFrameTest, BadCrcDropped) { + auto frame = a3_frame(); + frame[33] ^= 0xFF; + this->uart_.push_rx(frame); + this->sut_.loop(); + EXPECT_EQ(this->sut_.mode, climate::CLIMATE_MODE_OFF); + + this->uart_.push_rx(a3_frame()); + this->sut_.loop(); + this->expect_a3_applied_(); +} + +TEST_F(AirtonSerialFrameTest, BadLengthRejected) { + this->uart_.push_rx({0x7A, 0x7A, 0xD5, 0x21, 0x05}); + this->uart_.push_rx({0x7A, 0x7A, 0xD5, 0x21, MAX_FRAME_LEN + 1}); + this->uart_.push_rx(a3_frame()); + this->sut_.loop(); + this->expect_a3_applied_(); +} + +// The rejected header holds the start of the real frame: 7A 7A FF, then 7A 7A whose second byte reads as a +// length of 0x7A. +TEST_F(AirtonSerialFrameTest, ResyncInsideRejectedHeader) { + const auto frame = a3_frame(); + this->uart_.push_rx({0x7A, 0x7A, 0xFF}); + this->uart_.push_rx(frame); + this->sut_.loop(); + this->expect_a3_applied_(); +} + +// Without the start check, the 34 declared bytes would swallow the head of the next frame. +TEST_F(AirtonSerialFrameTest, TruncatedFrameThenValidFrame) { + const auto frame = a3_frame(); + this->uart_.rx.assign(frame.begin(), frame.begin() + 20); + this->uart_.push_rx(frame); + this->sut_.loop(); + this->expect_a3_applied_(); +} + +TEST_F(AirtonSerialFrameTest, NothingSentBeforeFirstState) { + EXPECT_FALSE(this->sut_.set_flag(FLAG_ECO, true)); + this->sut_.make_call().set_mode(climate::CLIMATE_MODE_HEAT).perform(); + EXPECT_TRUE(this->uart_.tx.empty()); + EXPECT_EQ(this->sut_.mode, climate::CLIMATE_MODE_OFF); +} + +// Bytes produced by the original driver for the same state. +TEST_F(AirtonSerialFrameTest, SetFlagSendsA1AfterState) { + this->uart_.push_rx(a3_frame()); + this->sut_.loop(); + EXPECT_TRUE(this->sut_.set_flag(FLAG_ECO, true)); + const std::vector expected = {0x7A, 0x7A, 0x21, 0xD5, 0x18, 0x00, 0x00, 0xA1, 0x00, 0x00, 0x00, 0x00, + 0x59, 0x08, 0x00, 0x81, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1B, 0x9B}; + EXPECT_EQ(this->uart_.tx, expected); +} + +// Shorter than its opcode and CRC, so it is not a frame even with a valid CRC. +TEST_F(AirtonSerialFrameTest, ShortFrameRejected) { + std::vector short_frame = {0x7A, 0x7A, 0xD5, 0x21, 7, 0x00, 0x00}; + set_crc(short_frame); + this->uart_.push_rx(short_frame); + this->uart_.push_rx(a3_frame()); + this->sut_.loop(); + this->expect_a3_applied_(); +} + +// A corrupt length that is in range waits for bytes that never come; the gap releases the frame behind it. +TEST_F(AirtonSerialFrameTest, StalledPartialFrameReleasedAfterGap) { + this->uart_.push_rx({0x7A, 0x7A, 0xD5, 0x21, 0x40}); + this->uart_.push_rx(a3_frame()); + this->loop_at_(1000); + EXPECT_EQ(this->sut_.mode, climate::CLIMATE_MODE_OFF); + this->loop_at_(1049); + EXPECT_EQ(this->sut_.mode, climate::CLIMATE_MODE_OFF); + this->loop_at_(1050); + this->expect_a3_applied_(); +} + +TEST_F(AirtonSerialFrameTest, TargetTemperatureRoundedAndClamped) { + this->receive_state_(); + this->sut_.make_call().set_target_temperature(35.0f).perform(); + EXPECT_FLOAT_EQ(this->sut_.target_temperature, 31.0f); + EXPECT_EQ(this->sent_(13), 31 - 16); + this->sut_.make_call().set_target_temperature(16.5f).perform(); + EXPECT_FLOAT_EQ(this->sut_.target_temperature, 17.0f); + EXPECT_EQ(this->sent_(13), 17 - 16); + this->sut_.make_call().set_target_temperature(10.0f).perform(); + EXPECT_FLOAT_EQ(this->sut_.target_temperature, 16.0f); + EXPECT_EQ(this->sent_(13), 0); +} + +struct SwingCase { + climate::ClimateSwingMode mode; + uint8_t code; +}; + +constexpr SwingCase SWING_CASES[] = { + {climate::CLIMATE_SWING_OFF, 0x00}, + {climate::CLIMATE_SWING_VERTICAL, 0x01}, + {climate::CLIMATE_SWING_HORIZONTAL, 0x10}, + {climate::CLIMATE_SWING_BOTH, 0x11}, +}; + +TEST_F(AirtonSerialFrameTest, SwingDecoded) { + for (const auto &c : SWING_CASES) { + this->receive_state_(c.code); + EXPECT_EQ(this->sut_.swing_mode, c.mode) << "code " << static_cast(c.code); + } +} + +TEST_F(AirtonSerialFrameTest, SwingEncoded) { + for (const auto &from : SWING_CASES) { + for (const auto &to : SWING_CASES) { + this->receive_state_(from.code); + this->sut_.make_call().set_swing_mode(to.mode).perform(); + EXPECT_EQ(this->sent_(14), to.code) + << "from " << static_cast(from.code) << " to " << static_cast(to.code); + } + } +} + +// An unrelated change sends the swing byte back as the unit reported it. +TEST_F(AirtonSerialFrameTest, SwingKeptOnOtherChanges) { + this->receive_state_(0x10); + this->sut_.set_flag(FLAG_ECO, true); + EXPECT_EQ(this->sent_(14), 0x10); +} + +// Quiet mutes the current speed; a switch toggle keeps that speed. +TEST_F(AirtonSerialFrameTest, QuietKeepsFanSpeed) { + this->receive_state_(0, 0x19, (24 - 16) | 0x40); + ASSERT_TRUE(this->sut_.fan_mode.has_value()); + EXPECT_EQ(*this->sut_.fan_mode, climate::CLIMATE_FAN_QUIET); + this->sut_.set_flag(FLAG_ECO, true); + EXPECT_EQ(this->sent_(12), 0x19); + EXPECT_EQ(this->sent_(13), (24 - 16) | 0x40); +} + +// Turned off from cool, the unit reports the power bit clear and keeps the cool mode bits. +TEST_F(AirtonSerialFrameTest, OffFromCoolStaysOff) { + this->receive_state_(); + EXPECT_EQ(this->sut_.mode, climate::CLIMATE_MODE_COOL); + this->receive_state_(0, 0x51); + EXPECT_EQ(this->sut_.mode, climate::CLIMATE_MODE_OFF); + this->sut_.set_flag(FLAG_DISPLAY, false); + EXPECT_EQ(this->sent_(12), 0x51); +} + +TEST_F(AirtonSerialFrameTest, TurnOffFromHeat) { + this->receive_state_(0, 0x5C); + EXPECT_EQ(this->sut_.mode, climate::CLIMATE_MODE_HEAT); + this->sut_.make_call().set_mode(climate::CLIMATE_MODE_OFF).perform(); + EXPECT_EQ(this->sut_.mode, climate::CLIMATE_MODE_OFF); + EXPECT_EQ(this->sent_(12), 0x54); + this->sut_.make_call().set_mode(climate::CLIMATE_MODE_COOL).perform(); + EXPECT_EQ(this->sent_(12), 0x59); +} + +// Super is the top bit of the fan nibble, reported with the speed it runs at. +TEST_F(AirtonSerialFrameTest, SuperFanIsStrong) { + this->receive_state_(0, 0xD9); + EXPECT_EQ(this->sut_.get_custom_fan_mode(), "strong"); + this->sut_.set_flag(FLAG_ECO, true); + EXPECT_EQ(this->sent_(12), 0xD9); +} + +TEST_F(AirtonSerialFrameTest, SuperKeepsReportedSpeed) { + // Super on top of low (0x9) stays low instead of becoming speed 5 + this->receive_state_(0, 0x99); + EXPECT_EQ(this->sut_.get_custom_fan_mode(), "strong"); + this->sut_.set_flag(FLAG_ECO, true); + EXPECT_EQ(this->sent_(12), 0x99); +} + +// The speed sent last is the one quiet mutes, even before the unit reports it. +TEST_F(AirtonSerialFrameTest, QuietAfterHighKeepsHigh) { + this->receive_state_(0, 0x19); + this->sut_.make_call().set_fan_mode(climate::CLIMATE_FAN_HIGH).perform(); + EXPECT_EQ(this->sent_(12), 0x59); + this->sut_.make_call().set_fan_mode(climate::CLIMATE_FAN_QUIET).perform(); + EXPECT_EQ(this->sent_(12), 0x59); + EXPECT_EQ(this->sent_(13), (24 - 16) | 0x40); +} + +TEST_F(AirtonSerialFrameTest, RoomTemperatureTenths) { + auto frame = a3_frame(); + frame[11] = 5; + set_crc(frame); + this->uart_.push_rx(frame); + this->sut_.loop(); + EXPECT_FLOAT_EQ(this->sut_.current_temperature, 22.5f); + const auto traits = this->sut_.get_traits(); + EXPECT_FLOAT_EQ(traits.get_visual_current_temperature_step(), 0.1f); + EXPECT_FLOAT_EQ(traits.get_visual_target_temperature_step(), 1.0f); +} + +// Speed 6 has no fan mode; an unrelated change sends it back as reported. +TEST_F(AirtonSerialFrameTest, FanSpeedSixKept) { + this->receive_state_(0, 0x69); + EXPECT_FALSE(this->sut_.fan_mode.has_value()); + EXPECT_FALSE(this->sut_.has_custom_fan_mode()); + this->sut_.set_flag(FLAG_DISPLAY, false); + EXPECT_EQ(this->sent_(12), 0x69); +} + +TEST_F(AirtonSerialFrameTest, FahrenheitBitKept) { + this->receive_state_(0, 0x59, (24 - 16) | 0x20); + EXPECT_FLOAT_EQ(this->sut_.target_temperature, 24.0f); + this->sut_.set_flag(FLAG_ECO, true); + EXPECT_EQ(this->sent_(13), (24 - 16) | 0x20); +} + +} // namespace esphome::airton_serial::testing diff --git a/tests/components/airton_serial/common.h b/tests/components/airton_serial/common.h new file mode 100644 index 00000000000..ce0704746eb --- /dev/null +++ b/tests/components/airton_serial/common.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include "esphome/components/uart/uart_component.h" + +namespace esphome::airton_serial::testing { + +class MockUARTComponent : public uart::UARTComponent { + public: + std::vector tx; + std::vector rx; + + void push_rx(std::initializer_list data) { this->rx.insert(this->rx.end(), data.begin(), data.end()); } + void push_rx(const std::vector &data) { this->rx.insert(this->rx.end(), data.begin(), data.end()); } + + void write_array(const uint8_t *data, size_t len) override { this->tx.insert(this->tx.end(), data, data + len); } + + bool read_array(uint8_t *data, size_t len) override { + if (this->rx.size() < len) + return false; + std::copy(this->rx.begin(), this->rx.begin() + len, data); + this->rx.erase(this->rx.begin(), this->rx.begin() + len); + return true; + } + + size_t available() override { return this->rx.size(); } + + MOCK_METHOD(bool, peek_byte, (uint8_t *data), (override)); + MOCK_METHOD(uart::UARTFlushResult, flush, (), (override)); + MOCK_METHOD(void, check_logger_conflict, (), (override)); +#if defined(USE_ESP8266) || defined(USE_ESP32) + void load_settings(bool dump_config) override {} +#endif +}; + +} // namespace esphome::airton_serial::testing diff --git a/tests/components/airton_serial/common.yaml b/tests/components/airton_serial/common.yaml new file mode 100644 index 00000000000..c5a7cd5fe7a --- /dev/null +++ b/tests/components/airton_serial/common.yaml @@ -0,0 +1,26 @@ +climate: + - platform: airton_serial + id: airton_serial_climate + name: Airton Serial Climate + +switch: + - platform: airton_serial + airton_serial_id: airton_serial_climate + display: + name: Display + ionizer: + name: Ionizer + sleep: + name: Sleep + eco: + name: Eco + health: + name: Health + +sensor: + - platform: airton_serial + airton_serial_id: airton_serial_climate + total_usage_time: + name: Total usage time + revision: + name: Protocol revision diff --git a/tests/components/airton_serial/test.esp32-idf.yaml b/tests/components/airton_serial/test.esp32-idf.yaml new file mode 100644 index 00000000000..eefd6ac078d --- /dev/null +++ b/tests/components/airton_serial/test.esp32-idf.yaml @@ -0,0 +1,7 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + airton_serial: !include common.yaml + +wifi: + ssid: MySSID + password: password1 diff --git a/tests/components/airton_serial/test.esp8266-ard.yaml b/tests/components/airton_serial/test.esp8266-ard.yaml new file mode 100644 index 00000000000..f421862deb9 --- /dev/null +++ b/tests/components/airton_serial/test.esp8266-ard.yaml @@ -0,0 +1,7 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + airton_serial: !include common.yaml + +wifi: + ssid: MySSID + password: password1 From b4048bfb6f4eb37f88178d49ba3713c8d055b132 Mon Sep 17 00:00:00 2001 From: Steffen Rusitschka Date: Fri, 30 Oct 2026 06:43:17 +0100 Subject: [PATCH 3/3] [wifi] Handle intentional ESP32 disconnects during roaming (#21758) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/wifi/expected_disconnect.h | 51 ++++++++ esphome/components/wifi/wifi_component.h | 1 + .../wifi/wifi_component_esp_idf.cpp | 87 +++++++++++--- .../wifi/expected_disconnect_test.cpp | 110 ++++++++++++++++++ 4 files changed, 232 insertions(+), 17 deletions(-) create mode 100644 esphome/components/wifi/expected_disconnect.h create mode 100644 tests/components/wifi/expected_disconnect_test.cpp diff --git a/esphome/components/wifi/expected_disconnect.h b/esphome/components/wifi/expected_disconnect.h new file mode 100644 index 00000000000..b1be7b36626 --- /dev/null +++ b/esphome/components/wifi/expected_disconnect.h @@ -0,0 +1,51 @@ +#pragma once +#include +#include +#include + +namespace esphome::wifi { + +// Switching APs calls esp_wifi_disconnect(), which queues an association-leave event from the old AP. Remembers +// that AP so its leave event does not end the connection attempt started after it. Platform free for host tests. +class ExpectedDisconnect { + public: + void arm(const uint8_t *bssid) { + std::copy_n(bssid, this->bssid_.size(), this->bssid_.begin()); + this->armed_ = true; + } + void clear() { this->armed_ = false; } + + /// Disconnects from the current AP and, once that succeeds, expects its leave event. read_bssid(uint8_t *out) + /// returns whether there was an AP and runs first, since the record is gone after the disconnect; disconnect() + /// returns whether it succeeded, which is also the result. Any earlier expectation is dropped. + template + bool disconnect_and_expect_leave(ReadBssid &&read_bssid, Disconnect &&disconnect) { + this->armed_ = false; + std::array bssid{}; + const bool had_ap = read_bssid(bssid.data()); + if (!disconnect()) + return false; + if (had_ap) + this->arm(bssid.data()); + return true; + } + + /// A connected event still queued from the previous AP; it keeps the expectation. + bool is_previous_ap(const uint8_t *bssid) const { + return this->armed_ && std::equal(this->bssid_.begin(), this->bssid_.end(), bssid); + } + + /// Whether a disconnect is the previous AP's leave. Every disconnect ends the expectation, so it matches at + /// most once; the caller handles roaming disconnects before this. + bool consume_disconnect(const uint8_t *bssid, bool assoc_leave) { + const bool expected = assoc_leave && this->is_previous_ap(bssid); + this->armed_ = false; + return expected; + } + + protected: + std::array bssid_{}; + bool armed_{false}; +}; + +} // namespace esphome::wifi diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 7657022077c..c95afaa38ab 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -957,6 +957,7 @@ class WiFiComponent final : public Component { #ifdef USE_ESP32 void wifi_process_event_(IDFWiFiEvent *data); + void clear_sta_session_(); friend void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data); #endif diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index cecfd64d1db..01dc3f6d0c7 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -1,4 +1,5 @@ #include "wifi_component.h" +#include "expected_disconnect.h" #include "esphome/components/network/util.h" #ifdef USE_WIFI @@ -68,6 +69,8 @@ static bool s_sta_connect_error = false; // NOLINT(cppcoreguidelines-avoid- static bool s_sta_connecting = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static bool s_wifi_started = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static ExpectedDisconnect s_expected_disconnect; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + #ifdef USE_WIFI_DPP /// Terminated copies of the received station credentials; the supplicant's wifi_config_t would grow every event @@ -420,6 +423,7 @@ bool WiFiComponent::wifi_apply_band_mode_() { return esp_wifi_set_band_mode(this #endif bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { + s_expected_disconnect.clear(); // enable STA if (!this->wifi_mode_(true, {})) return false; @@ -505,11 +509,26 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { } if (memcmp(¤t_conf, &conf, sizeof(wifi_config_t)) != 0) { // NOLINT - err = esp_wifi_disconnect(); - if (err != ESP_OK) { + // Events are handled in loop(), so expecting the leave after the disconnect is in time for it + const bool disconnected = s_expected_disconnect.disconnect_and_expect_leave( + [](uint8_t *bssid) { + wifi_ap_record_t old_ap{}; + if (esp_wifi_sta_get_ap_info(&old_ap) != ESP_OK) + return false; + std::copy_n(old_ap.bssid, sizeof(old_ap.bssid), bssid); + return true; + }, + [&err]() { + err = esp_wifi_disconnect(); + return err == ESP_OK; + }); + if (!disconnected) { ESP_LOGV(TAG, "esp_wifi_disconnect failed: %s", esp_err_to_name(err)); return false; } + // The old session is gone even if configuring the next attempt fails. + s_sta_connecting = false; + this->clear_sta_session_(); } err = esp_wifi_set_config(WIFI_IF_STA, &conf); @@ -613,25 +632,30 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { // Reset flags, do this _before_ wifi_station_connect as the callback method // may be called from wifi_station_connect s_sta_connecting = true; - s_sta_connected = false; s_sta_connect_error = false; s_sta_connect_not_found = false; - // Reset IP address flags - ensures we don't report connected before DHCP completes - // (IP_EVENT_STA_LOST_IP doesn't always fire on disconnect) - this->got_ipv4_address_ = false; -#if USE_NETWORK_IPV6 - this->num_ipv6_addresses_ = 0; -#endif + // Ensures we don't report connected before DHCP completes (IP_EVENT_STA_LOST_IP doesn't always fire on disconnect) + this->clear_sta_session_(); err = esp_wifi_connect(); if (err != ESP_OK) { ESP_LOGW(TAG, "esp_wifi_connect failed: %s", esp_err_to_name(err)); + // An expected leave no longer clears this, so the next attempt would wait for the connect timeout + s_sta_connecting = false; return false; } return true; } +void WiFiComponent::clear_sta_session_() { + s_sta_connected = false; + this->got_ipv4_address_ = false; +#if USE_NETWORK_IPV6 + this->num_ipv6_addresses_ = 0; +#endif +} + // sntp_servermode_dhcp() is an empty macro unless lwIP takes NTP servers from DHCP; that build needs the core lock static void disable_dhcp_sntp() { #if SNTP_GET_SERVERS_FROM_DHCP || SNTP_GET_SERVERS_FROM_DHCPV6 @@ -1020,6 +1044,8 @@ bool WiFiComponent::wifi_loop_() { uint16_t dropped = this->event_queue_.get_and_reset_dropped_count(); if (dropped > 0) { ESP_LOGW(TAG, "Dropped %u WiFi events due to buffer overflow", dropped); + // The previous AP's leave event may be among them, so it can no longer be told apart + s_expected_disconnect.clear(); #ifdef USE_WIFI_AP // A dropped join or leave is made up for by reading the list again if (this->ap_setup_) @@ -1048,6 +1074,7 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { #endif } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_STOP) { + s_expected_disconnect.clear(); ESP_LOGV(TAG, "STA stop"); s_sta_connecting = false; @@ -1057,6 +1084,11 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_CONNECTED) { const auto &it = data->data.sta_connected; + if (s_expected_disconnect.is_previous_ap(it.bssid)) { + ESP_LOGD(TAG, "Ignoring queued connection from previous AP"); + return; + } + s_expected_disconnect.clear(); #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE char bssid_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(it.bssid, bssid_buf); @@ -1105,13 +1137,18 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_DISCONNECTED) { const auto &it = data->data.sta_disconnected; - if (it.reason == WIFI_REASON_NO_AP_FOUND) { + if (it.reason == WIFI_REASON_ROAMING) { + ESP_LOGI(TAG, "Disconnected ssid='%.*s' reason='Station Roaming'", it.ssid_len, (const char *) it.ssid); + return; + } + const bool expected_disconnect = + s_expected_disconnect.consume_disconnect(it.bssid, it.reason == WIFI_REASON_ASSOC_LEAVE); + if (expected_disconnect) { + ESP_LOGD(TAG, "Expected disconnect from previous AP"); + } else if (it.reason == WIFI_REASON_NO_AP_FOUND) { ESP_LOGW(TAG, "Disconnected ssid='%.*s' reason='Probe Request Unsuccessful'", it.ssid_len, (const char *) it.ssid); s_sta_connect_not_found = true; - } else if (it.reason == WIFI_REASON_ROAMING) { - ESP_LOGI(TAG, "Disconnected ssid='%.*s' reason='Station Roaming'", it.ssid_len, (const char *) it.ssid); - return; } else { char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; format_mac_addr_upper(it.bssid, bssid_s); @@ -1120,9 +1157,11 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { s_sta_connect_error = true; } s_sta_connected = false; - s_sta_connecting = false; - error_from_callback_ = true; - // Refresh is_connected() cache; error_from_callback_ makes it false. + if (!expected_disconnect) { + s_sta_connecting = false; + this->error_from_callback_ = true; + } + // Refresh is_connected() cache; the old session is no longer connected. this->update_connected_state_(); #ifdef USE_WIFI_CONNECT_STATE_LISTENERS this->notify_disconnect_state_listeners_(); @@ -1135,6 +1174,12 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { #endif } else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_STA_GOT_IP) { const auto &it = data->data.ip_got_ip; + esp_netif_ip_info_t current_ip{}; + if (!s_sta_connected || !esp_netif_is_netif_up(s_sta_netif) || + esp_netif_get_ip_info(s_sta_netif, ¤t_ip) != ESP_OK || current_ip.ip.addr != it.ip_info.ip.addr) { + ESP_LOGV(TAG, "Ignoring stale IPv4 address event"); + return; + } ESP_LOGV(TAG, "static_ip=" IPSTR " gateway=" IPSTR, IP2STR(&it.ip_info.ip), IP2STR(&it.ip_info.gw)); this->got_ipv4_address_ = true; #if defined(USE_WIFI_AP_WISP) && !defined(USE_WIFI_AP_WISP_STATIC_DNS) @@ -1150,8 +1195,16 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { #if USE_NETWORK_IPV6 } else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_GOT_IP6) { const auto &it = data->data.ip_got_ip6; + if (!s_sta_connected) { + ESP_LOGV(TAG, "Ignoring IPv6 address event before association"); + return; + } + esp_ip6_addr_t addresses[CONFIG_LWIP_IPV6_NUM_ADDRESSES]; + { + LwIPLock lock; + this->num_ipv6_addresses_ = esp_netif_get_all_ip6(s_sta_netif, addresses); + } ESP_LOGV(TAG, "IPv6 address=" IPV6STR, IPV62STR(it.ip6_info.ip)); - this->num_ipv6_addresses_++; #ifdef USE_WIFI_IP_STATE_LISTENERS this->notify_ip_state_listeners_(); #endif diff --git a/tests/components/wifi/expected_disconnect_test.cpp b/tests/components/wifi/expected_disconnect_test.cpp new file mode 100644 index 00000000000..d03fd4ed362 --- /dev/null +++ b/tests/components/wifi/expected_disconnect_test.cpp @@ -0,0 +1,110 @@ +#include + +#include +#include + +#include "esphome/components/wifi/expected_disconnect.h" + +namespace esphome::wifi::testing { +namespace { + +constexpr uint8_t OLD_AP[6] = {0x02, 0x00, 0x00, 0x00, 0x00, 0x01}; +constexpr uint8_t NEW_AP[6] = {0x02, 0x00, 0x00, 0x00, 0x00, 0x02}; + +ExpectedDisconnect armed() { + ExpectedDisconnect expected; + expected.arm(OLD_AP); + return expected; +} + +TEST(ExpectedDisconnectTest, PreviousApLeaveIsExpectedOnce) { + auto expected = armed(); + + EXPECT_TRUE(expected.consume_disconnect(OLD_AP, true)); + EXPECT_FALSE(expected.is_previous_ap(OLD_AP)); + // A second leave from the same AP is a real disconnect + EXPECT_FALSE(expected.consume_disconnect(OLD_AP, true)); +} + +TEST(ExpectedDisconnectTest, OtherDisconnectsAreRealAndEndTheExpectation) { + auto other_ap = armed(); + EXPECT_FALSE(other_ap.consume_disconnect(NEW_AP, true)); + EXPECT_FALSE(other_ap.consume_disconnect(OLD_AP, true)); + + auto other_reason = armed(); + EXPECT_FALSE(other_reason.consume_disconnect(OLD_AP, false)); + EXPECT_FALSE(other_reason.consume_disconnect(OLD_AP, true)); +} + +TEST(ExpectedDisconnectTest, QueuedConnectionFromPreviousApKeepsTheExpectation) { + auto expected = armed(); + + EXPECT_TRUE(expected.is_previous_ap(OLD_AP)); + EXPECT_FALSE(expected.is_previous_ap(NEW_AP)); + EXPECT_TRUE(expected.consume_disconnect(OLD_AP, true)); +} + +TEST(ExpectedDisconnectTest, NothingIsExpectedUnlessArmed) { + ExpectedDisconnect expected; + EXPECT_FALSE(expected.is_previous_ap(OLD_AP)); + EXPECT_FALSE(expected.consume_disconnect(OLD_AP, true)); + + auto cleared = armed(); + cleared.clear(); + EXPECT_FALSE(cleared.consume_disconnect(OLD_AP, true)); +} + +// Stands in for esp_wifi_sta_get_ap_info() and esp_wifi_disconnect(): the AP record is gone after disconnecting +struct Driver { + bool has_ap{true}; + bool disconnect_ok{true}; + const uint8_t *ap{OLD_AP}; + + bool read_bssid(uint8_t *out) const { + if (!this->has_ap) + return false; + std::copy_n(this->ap, 6, out); + return true; + } + bool disconnect() { + this->has_ap = false; + return this->disconnect_ok; + } +}; + +bool disconnect(ExpectedDisconnect &expected, Driver &driver) { + return expected.disconnect_and_expect_leave([&driver](uint8_t *out) { return driver.read_bssid(out); }, + [&driver]() { return driver.disconnect(); }); +} + +TEST(ExpectedDisconnectTest, DisconnectExpectsTheLeaveOfTheApItLeft) { + ExpectedDisconnect expected; + Driver driver; + + EXPECT_TRUE(disconnect(expected, driver)); + EXPECT_TRUE(expected.consume_disconnect(OLD_AP, true)); +} + +TEST(ExpectedDisconnectTest, NoApMeansNoExpectation) { + ExpectedDisconnect expected; + Driver driver; + driver.has_ap = false; + + EXPECT_TRUE(disconnect(expected, driver)); + EXPECT_FALSE(expected.consume_disconnect(OLD_AP, true)); +} + +TEST(ExpectedDisconnectTest, FailedDisconnectExpectsNothing) { + auto expected = armed(); + Driver driver; + driver.ap = NEW_AP; + driver.disconnect_ok = false; + + EXPECT_FALSE(disconnect(expected, driver)); + // The earlier expectation is dropped too + EXPECT_FALSE(expected.is_previous_ap(OLD_AP)); + EXPECT_FALSE(expected.is_previous_ap(NEW_AP)); +} + +} // namespace +} // namespace esphome::wifi::testing