mirror of
https://github.com/esphome/esphome.git
synced 2026-11-09 14:45:57 +00:00
Merge output-channel-key into sx1509-max6956-apply-outputs
This commit is contained in:
23 files changed
+1736
-82
No files matched your search
@@ -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
|
||||
@@ -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(¤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
|
||||
|
||||
@@ -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
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user