Merge output-channel-key into sx1509-max6956-apply-outputs

This commit is contained in:
J. Nick Koston committed 2026-10-29 20:28:04 -10:00
commit e2c7d1e29c
23 files changed
+1736 -82

No files matched your search

+1
View File
@@ -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
@@ -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
)
@@ -0,0 +1,418 @@
#include "airton_serial_climate.h"
#include <algorithm>
#include <cmath>
#include <cstring>
#include <iterator>
#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<size_t N> 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<size_t N> 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<float>(data[10]) + static_cast<float>(data[11]) / 10.0f;
this->target_temperature = static_cast<float>(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<climate::ClimateMode>(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<climate::ClimateFanMode>(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<climate::ClimateSwingMode>(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<float>(
std::clamp<int32_t>(static_cast<int32_t>(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<uint8_t>((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<int32_t>(static_cast<int32_t>(std::lround(this->target_temperature)), TEMP_MIN, TEMP_MAX);
uint8_t setpoint = static_cast<uint8_t>(rounded - TEMP_MIN);
if (quiet)
setpoint |= FLAG_QUIET;
if (this->fahrenheit_)
setpoint |= FLAG_FAHRENHEIT;
const uint8_t state = static_cast<uint8_t>((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<uint8_t>(crc >> 8);
frame[len - 1] = static_cast<uint8_t>(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
@@ -0,0 +1,83 @@
#pragma once
#include <array>
#include <cstddef>
#include <cstdint>
#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<uint8_t, MAX_FRAME_LEN> 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
@@ -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)
@@ -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)
@@ -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")
@@ -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<uint8_t>(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<uint8_t>(mode));
}
@@ -422,8 +515,11 @@ void MitsubishiCN105::apply_settings_() {
std::array<uint8_t, REQUEST_PAYLOAD_LEN> 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)) {
@@ -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
@@ -51,8 +51,8 @@ static constexpr std::optional<Left> reverse_map_lookup(const std::array<std::pa
}
static constexpr climate::ClimateAction resolve_climate_action(climate::ClimateMode mode,
climate::ClimateAction running_action,
const MitsubishiCN105::Status &status) {
const MitsubishiCN105::Status &status,
MitsubishiCN105::RunDirection direction) {
if (!status.power_on) {
return climate::CLIMATE_ACTION_OFF;
}
@@ -62,27 +62,17 @@ static constexpr climate::ClimateAction resolve_climate_action(climate::ClimateM
if (!status.operating) {
return climate::CLIMATE_ACTION_IDLE;
}
switch (mode) {
case climate::CLIMATE_MODE_HEAT:
if (mode == climate::CLIMATE_MODE_DRY) {
return climate::CLIMATE_ACTION_DRYING;
}
switch (direction) {
case MitsubishiCN105::RunDirection::RUN_DIRECTION_HEATING:
return climate::CLIMATE_ACTION_HEATING;
case climate::CLIMATE_MODE_COOL:
case MitsubishiCN105::RunDirection::RUN_DIRECTION_COOLING:
return climate::CLIMATE_ACTION_COOLING;
case climate::CLIMATE_MODE_DRY:
return climate::CLIMATE_ACTION_DRYING;
default:
break;
return climate::CLIMATE_ACTION_IDLE;
}
// AUTO: 0x06 carries no direction, so keep the running one and infer it only when operation starts
if (running_action == climate::CLIMATE_ACTION_HEATING || running_action == climate::CLIMATE_ACTION_COOLING) {
return running_action;
}
if (status.room_temperature > 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;
@@ -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<typename F> void add_on_status_callback(F &&callback) {
@@ -0,0 +1,51 @@
#pragma once
#include <algorithm>
#include <array>
#include <cstdint>
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<typename ReadBssid, typename Disconnect>
bool disconnect_and_expect_leave(ReadBssid &&read_bssid, Disconnect &&disconnect) {
this->armed_ = false;
std::array<uint8_t, 6> 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<uint8_t, 6> bssid_{};
bool armed_{false};
};
} // namespace esphome::wifi
+1
View File
@@ -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
@@ -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(&current_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, &current_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
@@ -0,0 +1,308 @@
#include <gtest/gtest.h>
#include <cstdint>
#include <vector>
#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<uint8_t> &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<uint8_t> a3_frame(uint8_t swing = 0, uint8_t state = 0x59, uint8_t setpoint = 24 - 16) {
std::vector<uint8_t> 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<uint8_t> 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<uint8_t> 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<int>(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<int>(from.code) << " to " << static_cast<int>(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
+41
View File
@@ -0,0 +1,41 @@
#pragma once
#include <gmock/gmock.h>
#include <gtest/gtest.h>
#include <algorithm>
#include <cstdint>
#include <initializer_list>
#include <vector>
#include "esphome/components/uart/uart_component.h"
namespace esphome::airton_serial::testing {
class MockUARTComponent : public uart::UARTComponent {
public:
std::vector<uint8_t> tx;
std::vector<uint8_t> rx;
void push_rx(std::initializer_list<uint8_t> data) { this->rx.insert(this->rx.end(), data.begin(), data.end()); }
void push_rx(const std::vector<uint8_t> &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
@@ -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
@@ -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
@@ -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
@@ -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<int>(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
@@ -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<uint32_t> run_cycles(MitsubishiCN105TestsContext &ctx, uint32_t &now, int cycles, bool power_on,
uint8_t raw_mode, bool running) {
std::vector<uint32_t> 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<uint32_t> 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<int>(raw_mode);
EXPECT_EQ(ctx.sut.status().operating, operating) << "raw mode " << static_cast<int>(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));
+18 -6
View File
@@ -54,16 +54,23 @@ template<std::size_t N> inline void push_frame(MockUARTComponent &uart, const st
uart.rx.push_back(static_cast<uint8_t>(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<uint8_t, 21>{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<uint8_t, 21>{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<uint8_t, 21> 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<uint8_t, 21>{0xFC, 0x61, 0x01, 0x30, 0x10});
}
class TestableMitsubishiCN105 : public MitsubishiCN105 {
public:
using MitsubishiCN105::MitsubishiCN105;
@@ -0,0 +1,110 @@
#include <gtest/gtest.h>
#include <algorithm>
#include <cstdint>
#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