mirror of
https://github.com/esphome/esphome.git
synced 2026-10-14 10:12:52 +00:00
[toshiba] Add 'seiya' model support (#17830)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Co-authored-by: J. Nick Koston <nick@home-assistant.io> Signed-off-by: Petr Helebrant <petr@helebrant.cz>
This commit is contained in:
co-authored by
pre-commit-ci-lite[bot]
J. Nick Koston
parent
70e4463644
commit
377a43c5be
@@ -16,6 +16,7 @@ MODELS = {
|
||||
"RAC-PT1411HWRU-C": Model.MODEL_RAC_PT1411HWRU_C,
|
||||
"RAC-PT1411HWRU-F": Model.MODEL_RAC_PT1411HWRU_F,
|
||||
"RAS-2819T": Model.MODEL_RAS_2819T,
|
||||
"SEIYA": Model.MODEL_SEIYA,
|
||||
}
|
||||
|
||||
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(ToshibaClimate).extend(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "toshiba.h"
|
||||
#include "esphome/components/remote_base/toshiba_ac_protocol.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include <vector>
|
||||
@@ -163,6 +164,41 @@ const uint8_t RAS_2819T_AUTO_DRY_FAN_BYTE = 0x65;
|
||||
const uint8_t RAS_2819T_AUTO_DRY_SUFFIX = 0x3A;
|
||||
const uint8_t RAS_2819T_HEAT_SUFFIX = 0x3B;
|
||||
|
||||
// Bit timings differ per protocol family; the frame layout is shared
|
||||
struct ToshibaTimings {
|
||||
uint16_t header_mark;
|
||||
uint16_t header_space;
|
||||
uint16_t bit_mark;
|
||||
uint16_t zero_space;
|
||||
uint16_t one_space;
|
||||
uint16_t gap_space;
|
||||
bool gap_after_last; // Seiya ends on a bare mark and only puts the gap between repeats
|
||||
};
|
||||
// Kept in flash on ESP8266, where plain const objects referenced through a function would land in RAM
|
||||
static const ToshibaTimings TOSHIBA_TIMINGS PROGMEM = {TOSHIBA_HEADER_MARK,
|
||||
TOSHIBA_HEADER_SPACE,
|
||||
TOSHIBA_BIT_MARK,
|
||||
TOSHIBA_ZERO_SPACE,
|
||||
TOSHIBA_ONE_SPACE,
|
||||
TOSHIBA_GAP_SPACE,
|
||||
true};
|
||||
static const ToshibaTimings SEIYA_TIMINGS PROGMEM = {4630, 4450, 625, 490, 1570, 5830, false};
|
||||
|
||||
static ToshibaTimings timings_for(Model model) {
|
||||
ToshibaTimings timings;
|
||||
progmem_memcpy(&timings, model == MODEL_SEIYA ? &SEIYA_TIMINGS : &TOSHIBA_TIMINGS, sizeof(timings));
|
||||
return timings;
|
||||
}
|
||||
|
||||
// Seiya is the generic frame plus two bytes: a feature marker and a one-shot swing command
|
||||
static constexpr uint8_t SEIYA_MESSAGE_LENGTH = 11;
|
||||
static constexpr uint8_t SEIYA_FEATURE_MARKER = 0x21;
|
||||
static constexpr uint8_t SEIYA_SWING_BOTH = 0x01;
|
||||
static constexpr uint8_t SEIYA_SWING_OMNI = 0x02; // no ESPHome equivalent; treated as BOTH
|
||||
static constexpr uint8_t SEIYA_SWING_VERTICAL = 0x08;
|
||||
static constexpr uint8_t SEIYA_SWING_HORIZONTAL = 0x09;
|
||||
static constexpr uint8_t SEIYA_SWING_NEUTRAL = 0x17; // no swing change in this frame
|
||||
|
||||
// RAS-2819T temperature codes for 18-30°C
|
||||
static const uint8_t RAS_2819T_TEMP_CODES[] = {
|
||||
0x10, // 18°C
|
||||
@@ -404,12 +440,15 @@ void ToshibaClimate::setup() {
|
||||
this->maximum_temperature_ = this->temperature_max_();
|
||||
this->swing_modes_ = this->toshiba_swing_modes_();
|
||||
|
||||
// Ensure swing mode is always initialized to a valid value
|
||||
// Ensure swing mode is always initialized to a valid value. OFF also stands for "not yet
|
||||
// commanded" on models that cannot switch the swing off, such as Seiya.
|
||||
if (this->swing_modes_.empty() || !this->swing_modes_.count(this->swing_mode)) {
|
||||
// No swing support for this model or current swing mode not supported, reset to OFF
|
||||
this->swing_mode = climate::CLIMATE_SWING_OFF;
|
||||
}
|
||||
|
||||
// A restored swing mode is already in effect, so it must not go out as a fresh swing command
|
||||
this->last_swing_mode_ = this->swing_mode;
|
||||
|
||||
// Ensure mode is valid - ESPHome should only use standard climate modes
|
||||
if (this->mode != climate::CLIMATE_MODE_OFF && this->mode != climate::CLIMATE_MODE_HEAT &&
|
||||
this->mode != climate::CLIMATE_MODE_COOL && this->mode != climate::CLIMATE_MODE_HEAT_COOL &&
|
||||
@@ -451,8 +490,9 @@ void ToshibaClimate::transmit_state() {
|
||||
}
|
||||
|
||||
void ToshibaClimate::transmit_generic_() {
|
||||
const bool seiya = this->model_ == MODEL_SEIYA;
|
||||
uint8_t message[16] = {0};
|
||||
uint8_t message_length = 9;
|
||||
const uint8_t message_length = seiya ? SEIYA_MESSAGE_LENGTH : 9;
|
||||
|
||||
// Header
|
||||
message[0] = 0xf2;
|
||||
@@ -473,6 +513,40 @@ void ToshibaClimate::transmit_generic_() {
|
||||
message[5] = (temperature - static_cast<uint8_t>(TOSHIBA_GENERIC_TEMP_C_MIN)) << 4;
|
||||
|
||||
// Mode and fan
|
||||
message[6] = this->encode_mode_fan_();
|
||||
|
||||
// Zero
|
||||
message[7] = 0x00;
|
||||
|
||||
if (seiya) {
|
||||
// Captured OFF frames carry no fan bits
|
||||
if (this->mode == climate::CLIMATE_MODE_OFF) {
|
||||
message[6] = TOSHIBA_MODE_OFF;
|
||||
}
|
||||
message[8] = SEIYA_FEATURE_MARKER;
|
||||
// Swing is a momentary command, sent only when the mode changes, like the remote does
|
||||
message[9] = SEIYA_SWING_NEUTRAL;
|
||||
if (this->swing_mode != this->last_swing_mode_) {
|
||||
message[9] = this->seiya_swing_code_();
|
||||
this->last_swing_mode_ = this->swing_mode;
|
||||
}
|
||||
}
|
||||
|
||||
// The last byte is the xor of all bytes from [4]
|
||||
for (uint8_t i = 4; i < message_length - 1; i++) {
|
||||
message[message_length - 1] ^= message[i];
|
||||
}
|
||||
|
||||
// Transmit
|
||||
auto transmit = this->transmitter_->transmit();
|
||||
auto *data = transmit.get_data();
|
||||
|
||||
this->encode_(data, message, message_length, 1);
|
||||
|
||||
transmit.perform();
|
||||
}
|
||||
|
||||
uint8_t ToshibaClimate::encode_mode_fan_() const {
|
||||
uint8_t mode;
|
||||
switch (this->mode) {
|
||||
case climate::CLIMATE_MODE_OFF:
|
||||
@@ -523,27 +597,20 @@ void ToshibaClimate::transmit_generic_() {
|
||||
fan = TOSHIBA_FAN_SPEED_AUTO;
|
||||
break;
|
||||
}
|
||||
message[6] = fan | mode;
|
||||
return fan | mode;
|
||||
}
|
||||
|
||||
// Zero
|
||||
message[7] = 0x00;
|
||||
|
||||
// If timers bit in the command is set, two extra bytes are added here
|
||||
|
||||
// If power bit is set in the command, one extra byte is added here
|
||||
|
||||
// The last byte is the xor of all bytes from [4]
|
||||
for (uint8_t i = 4; i < 8; i++) {
|
||||
message[8] ^= message[i];
|
||||
uint8_t ToshibaClimate::seiya_swing_code_() const {
|
||||
switch (this->swing_mode) {
|
||||
case climate::CLIMATE_SWING_VERTICAL:
|
||||
return SEIYA_SWING_VERTICAL;
|
||||
case climate::CLIMATE_SWING_HORIZONTAL:
|
||||
return SEIYA_SWING_HORIZONTAL;
|
||||
case climate::CLIMATE_SWING_BOTH:
|
||||
return SEIYA_SWING_BOTH;
|
||||
default:
|
||||
return SEIYA_SWING_NEUTRAL;
|
||||
}
|
||||
|
||||
// Transmit
|
||||
auto transmit = this->transmitter_->transmit();
|
||||
auto *data = transmit.get_data();
|
||||
|
||||
this->encode_(data, message, message_length, 1);
|
||||
|
||||
transmit.perform();
|
||||
}
|
||||
|
||||
void ToshibaClimate::transmit_rac_pt1411hwru_() {
|
||||
@@ -1085,14 +1152,16 @@ bool ToshibaClimate::process_ras_2819t_command_(const remote_base::ToshibaAcData
|
||||
|
||||
bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) {
|
||||
// Try modern ToshibaAcProtocol decoder first (handles RAS-2819T and potentially others)
|
||||
remote_base::ToshibaAcProtocol toshiba_protocol;
|
||||
auto decode_result = toshiba_protocol.decode(data);
|
||||
if (this->model_ != MODEL_SEIYA) {
|
||||
remote_base::ToshibaAcProtocol toshiba_protocol;
|
||||
auto decode_result = toshiba_protocol.decode(data);
|
||||
|
||||
if (decode_result.has_value()) {
|
||||
auto toshiba_data = decode_result.value();
|
||||
// Validate and process RAS-2819T commands
|
||||
if (is_valid_ras_2819t_command(toshiba_data.rc_code_1, toshiba_data.rc_code_2)) {
|
||||
return this->process_ras_2819t_command_(toshiba_data);
|
||||
if (decode_result.has_value()) {
|
||||
auto toshiba_data = decode_result.value();
|
||||
// Validate and process RAS-2819T commands
|
||||
if (is_valid_ras_2819t_command(toshiba_data.rc_code_1, toshiba_data.rc_code_2)) {
|
||||
return this->process_ras_2819t_command_(toshiba_data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1101,7 +1170,8 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) {
|
||||
uint8_t message_length = TOSHIBA_HEADER_LENGTH, temperature_code = 0;
|
||||
|
||||
// Validate header
|
||||
if (!data.expect_item(TOSHIBA_HEADER_MARK, TOSHIBA_HEADER_SPACE)) {
|
||||
const ToshibaTimings timings = timings_for(this->model_);
|
||||
if (!data.expect_item(timings.header_mark, timings.header_space)) {
|
||||
return false;
|
||||
}
|
||||
// Read incoming bits into buffer
|
||||
@@ -1117,6 +1187,10 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) {
|
||||
return false;
|
||||
} else {
|
||||
// First checksum was valid so continue receiving the remaining bits
|
||||
// The shortest frame is 9 bytes (length byte 3); longer ones must still fit the buffer
|
||||
if (message[2] < 3 || message[2] > sizeof(message) - TOSHIBA_HEADER_LENGTH - 2) {
|
||||
return false;
|
||||
}
|
||||
message_length = message[2] + 2;
|
||||
}
|
||||
// Decode the remaining bytes
|
||||
@@ -1276,59 +1350,11 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get the mode
|
||||
switch (message[6] & 0x0F) {
|
||||
case TOSHIBA_MODE_OFF:
|
||||
this->mode = climate::CLIMATE_MODE_OFF;
|
||||
break;
|
||||
|
||||
case TOSHIBA_MODE_COOL:
|
||||
this->mode = climate::CLIMATE_MODE_COOL;
|
||||
break;
|
||||
|
||||
case TOSHIBA_MODE_DRY:
|
||||
this->mode = climate::CLIMATE_MODE_DRY;
|
||||
break;
|
||||
|
||||
case TOSHIBA_MODE_FAN_ONLY:
|
||||
this->mode = climate::CLIMATE_MODE_FAN_ONLY;
|
||||
break;
|
||||
|
||||
case TOSHIBA_MODE_HEAT:
|
||||
this->mode = climate::CLIMATE_MODE_HEAT;
|
||||
break;
|
||||
|
||||
case TOSHIBA_MODE_AUTO:
|
||||
default:
|
||||
this->mode = climate::CLIMATE_MODE_HEAT_COOL;
|
||||
this->decode_mode_fan_temperature_(message);
|
||||
// The marker byte tells a Seiya frame from another checksum-valid 11 byte frame within receiver tolerance
|
||||
if (this->model_ == MODEL_SEIYA && message_length == SEIYA_MESSAGE_LENGTH && message[8] == SEIYA_FEATURE_MARKER) {
|
||||
this->seiya_decode_swing_(message[9]);
|
||||
}
|
||||
|
||||
// Get the fan mode
|
||||
switch (message[6] & 0xF0) {
|
||||
case TOSHIBA_FAN_SPEED_QUIET:
|
||||
this->fan_mode = climate::CLIMATE_FAN_QUIET;
|
||||
break;
|
||||
|
||||
case TOSHIBA_FAN_SPEED_1:
|
||||
this->fan_mode = climate::CLIMATE_FAN_LOW;
|
||||
break;
|
||||
|
||||
case TOSHIBA_FAN_SPEED_3:
|
||||
this->fan_mode = climate::CLIMATE_FAN_MEDIUM;
|
||||
break;
|
||||
|
||||
case TOSHIBA_FAN_SPEED_5:
|
||||
this->fan_mode = climate::CLIMATE_FAN_HIGH;
|
||||
break;
|
||||
|
||||
case TOSHIBA_FAN_SPEED_AUTO:
|
||||
default:
|
||||
this->fan_mode = climate::CLIMATE_FAN_AUTO;
|
||||
break;
|
||||
}
|
||||
|
||||
// Get the target temperature
|
||||
this->target_temperature = (message[5] >> 4) + TOSHIBA_GENERIC_TEMP_C_MIN;
|
||||
}
|
||||
|
||||
this->publish_state();
|
||||
@@ -1337,31 +1363,36 @@ bool ToshibaClimate::on_receive(remote_base::RemoteReceiveData data) {
|
||||
|
||||
void ToshibaClimate::encode_(remote_base::RemoteTransmitData *data, const uint8_t *message, const uint8_t nbytes,
|
||||
const uint8_t repeat) {
|
||||
const ToshibaTimings timings = timings_for(this->model_);
|
||||
data->set_carrier_frequency(TOSHIBA_CARRIER_FREQUENCY);
|
||||
|
||||
for (uint8_t copy = 0; copy <= repeat; copy++) {
|
||||
data->item(TOSHIBA_HEADER_MARK, TOSHIBA_HEADER_SPACE);
|
||||
data->item(timings.header_mark, timings.header_space);
|
||||
|
||||
for (uint8_t byte = 0; byte < nbytes; byte++) {
|
||||
for (uint8_t bit = 0; bit < 8; bit++) {
|
||||
data->mark(TOSHIBA_BIT_MARK);
|
||||
data->mark(timings.bit_mark);
|
||||
if (message[byte] & (1 << (7 - bit))) {
|
||||
data->space(TOSHIBA_ONE_SPACE);
|
||||
data->space(timings.one_space);
|
||||
} else {
|
||||
data->space(TOSHIBA_ZERO_SPACE);
|
||||
data->space(timings.zero_space);
|
||||
}
|
||||
}
|
||||
}
|
||||
data->item(TOSHIBA_BIT_MARK, TOSHIBA_GAP_SPACE);
|
||||
data->mark(timings.bit_mark);
|
||||
if (copy < repeat || timings.gap_after_last) {
|
||||
data->space(timings.gap_space);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool ToshibaClimate::decode_(remote_base::RemoteReceiveData *data, uint8_t *message, const uint8_t nbytes) {
|
||||
const ToshibaTimings timings = timings_for(this->model_);
|
||||
for (uint8_t byte = 0; byte < nbytes; byte++) {
|
||||
for (uint8_t bit = 0; bit < 8; bit++) {
|
||||
if (data->expect_item(TOSHIBA_BIT_MARK, TOSHIBA_ONE_SPACE)) {
|
||||
if (data->expect_item(timings.bit_mark, timings.one_space)) {
|
||||
message[byte] |= 1 << (7 - bit);
|
||||
} else if (data->expect_item(TOSHIBA_BIT_MARK, TOSHIBA_ZERO_SPACE)) {
|
||||
} else if (data->expect_item(timings.bit_mark, timings.zero_space)) {
|
||||
message[byte] &= static_cast<uint8_t>(~(1 << (7 - bit)));
|
||||
} else {
|
||||
return false;
|
||||
@@ -1371,4 +1402,80 @@ bool ToshibaClimate::decode_(remote_base::RemoteReceiveData *data, uint8_t *mess
|
||||
return true;
|
||||
}
|
||||
|
||||
void ToshibaClimate::decode_mode_fan_temperature_(const uint8_t *message) {
|
||||
switch (message[6] & 0x0F) {
|
||||
case TOSHIBA_MODE_OFF:
|
||||
this->mode = climate::CLIMATE_MODE_OFF;
|
||||
break;
|
||||
|
||||
case TOSHIBA_MODE_COOL:
|
||||
this->mode = climate::CLIMATE_MODE_COOL;
|
||||
break;
|
||||
|
||||
case TOSHIBA_MODE_DRY:
|
||||
this->mode = climate::CLIMATE_MODE_DRY;
|
||||
break;
|
||||
|
||||
case TOSHIBA_MODE_FAN_ONLY:
|
||||
this->mode = climate::CLIMATE_MODE_FAN_ONLY;
|
||||
break;
|
||||
|
||||
case TOSHIBA_MODE_HEAT:
|
||||
this->mode = climate::CLIMATE_MODE_HEAT;
|
||||
break;
|
||||
|
||||
case TOSHIBA_MODE_AUTO:
|
||||
default:
|
||||
this->mode = climate::CLIMATE_MODE_HEAT_COOL;
|
||||
}
|
||||
|
||||
// Speeds 2 and 4 are not exposed; report the nearest mode
|
||||
switch (message[6] & 0xF0) {
|
||||
case TOSHIBA_FAN_SPEED_QUIET:
|
||||
this->fan_mode = climate::CLIMATE_FAN_QUIET;
|
||||
break;
|
||||
|
||||
case TOSHIBA_FAN_SPEED_1:
|
||||
this->fan_mode = climate::CLIMATE_FAN_LOW;
|
||||
break;
|
||||
|
||||
case TOSHIBA_FAN_SPEED_2:
|
||||
case TOSHIBA_FAN_SPEED_3:
|
||||
this->fan_mode = climate::CLIMATE_FAN_MEDIUM;
|
||||
break;
|
||||
|
||||
case TOSHIBA_FAN_SPEED_4:
|
||||
case TOSHIBA_FAN_SPEED_5:
|
||||
this->fan_mode = climate::CLIMATE_FAN_HIGH;
|
||||
break;
|
||||
|
||||
case TOSHIBA_FAN_SPEED_AUTO:
|
||||
default:
|
||||
this->fan_mode = climate::CLIMATE_FAN_AUTO;
|
||||
break;
|
||||
}
|
||||
|
||||
this->target_temperature = (message[5] >> 4) + TOSHIBA_GENERIC_TEMP_C_MIN;
|
||||
}
|
||||
|
||||
void ToshibaClimate::seiya_decode_swing_(uint8_t code) {
|
||||
// A momentary command: the neutral value on an ordinary update means no change, not swing off
|
||||
switch (code) {
|
||||
case SEIYA_SWING_BOTH:
|
||||
case SEIYA_SWING_OMNI:
|
||||
this->swing_mode = climate::CLIMATE_SWING_BOTH;
|
||||
break;
|
||||
case SEIYA_SWING_VERTICAL:
|
||||
this->swing_mode = climate::CLIMATE_SWING_VERTICAL;
|
||||
break;
|
||||
case SEIYA_SWING_HORIZONTAL:
|
||||
this->swing_mode = climate::CLIMATE_SWING_HORIZONTAL;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
// Keep the tracker in sync so the received command is not sent back out
|
||||
this->last_swing_mode_ = this->swing_mode;
|
||||
}
|
||||
|
||||
} // namespace esphome::toshiba
|
||||
|
||||
@@ -11,6 +11,7 @@ enum Model {
|
||||
MODEL_RAC_PT1411HWRU_C = 1, // Temperature range is from 16 to 30
|
||||
MODEL_RAC_PT1411HWRU_F = 2, // Temperature range is from 16 to 30
|
||||
MODEL_RAS_2819T = 3, // RAS-2819T protocol variant, temperature range 18 to 30
|
||||
MODEL_SEIYA = 4, // Seiya family (RAS-B13E2KVG-E etc.), generic frame with a swing byte
|
||||
};
|
||||
|
||||
// Supported temperature ranges
|
||||
@@ -50,7 +51,7 @@ class ToshibaClimate final : public climate_ir::ClimateIR {
|
||||
bool on_receive(remote_base::RemoteReceiveData data) override;
|
||||
|
||||
private:
|
||||
// RAS-2819T state tracking for swing mode optimization
|
||||
// Last swing mode sent or received, for models whose swing is a one-shot command
|
||||
climate::ClimateSwingMode last_swing_mode_{climate::CLIMATE_SWING_OFF};
|
||||
climate::ClimateMode last_mode_{climate::CLIMATE_MODE_OFF};
|
||||
optional<climate::ClimateFanMode> last_fan_mode_{};
|
||||
@@ -71,13 +72,24 @@ class ToshibaClimate final : public climate_ir::ClimateIR {
|
||||
return TOSHIBA_GENERIC_TEMP_C_MAX; // Default to GENERIC for unknown models
|
||||
}
|
||||
climate::ClimateSwingModeMask toshiba_swing_modes_() {
|
||||
return (this->model_ == MODEL_GENERIC)
|
||||
? climate::ClimateSwingModeMask()
|
||||
: climate::ClimateSwingModeMask{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL};
|
||||
if (this->model_ == MODEL_GENERIC)
|
||||
return climate::ClimateSwingModeMask();
|
||||
if (this->model_ == MODEL_SEIYA) {
|
||||
// No captured code stops the swing, so OFF is not offered; the remote only selects a direction
|
||||
return climate::ClimateSwingModeMask{climate::CLIMATE_SWING_VERTICAL, climate::CLIMATE_SWING_HORIZONTAL,
|
||||
climate::CLIMATE_SWING_BOTH};
|
||||
}
|
||||
return climate::ClimateSwingModeMask{climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL};
|
||||
}
|
||||
void encode_(remote_base::RemoteTransmitData *data, const uint8_t *message, uint8_t nbytes, uint8_t repeat);
|
||||
bool decode_(remote_base::RemoteReceiveData *data, uint8_t *message, uint8_t nbytes);
|
||||
|
||||
// Shared by every model that uses the F2 0D frame
|
||||
uint8_t encode_mode_fan_() const;
|
||||
void decode_mode_fan_temperature_(const uint8_t *message);
|
||||
uint8_t seiya_swing_code_() const;
|
||||
void seiya_decode_swing_(uint8_t code);
|
||||
|
||||
Model model_;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import esphome.codegen as cg
|
||||
from tests.testing_helpers import ComponentManifestOverride
|
||||
|
||||
|
||||
def override_manifest(manifest: ComponentManifestOverride) -> None:
|
||||
# The harness walks dependencies from the component manifest, not from the
|
||||
# climate platform, so declare the platform's auto-load here. Otherwise the
|
||||
# transmitter's code generation runs and the host build cannot link it.
|
||||
manifest.auto_load = ["climate_ir", "climate"]
|
||||
|
||||
async def to_code_testing(config):
|
||||
# on_receive decodes the generic frame through the shared protocol class
|
||||
cg.add_define("USE_REMOTE_PROTOCOL_TOSHIBA_AC")
|
||||
|
||||
manifest.to_code = to_code_testing
|
||||
@@ -0,0 +1,4 @@
|
||||
# Registers the climate platform so USE_CLIMATE is defined for the host unit test
|
||||
# build. The toshiba, climate_ir and remote_transmitter sources come in through the
|
||||
# dependency walk in __init__.py; their code generation is suppressed.
|
||||
climate: []
|
||||
@@ -0,0 +1,6 @@
|
||||
climate:
|
||||
- platform: toshiba
|
||||
name: "Seiya Climate"
|
||||
model: seiya
|
||||
transmitter_id: xmitr
|
||||
receiver_id: rcvr
|
||||
@@ -0,0 +1,4 @@
|
||||
packages:
|
||||
remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml
|
||||
remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml
|
||||
toshiba: !include common_seiya.yaml
|
||||
@@ -0,0 +1,4 @@
|
||||
packages:
|
||||
remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml
|
||||
remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml
|
||||
toshiba: !include common_seiya.yaml
|
||||
@@ -0,0 +1,165 @@
|
||||
#include <gtest/gtest.h>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "esphome/components/remote_base/remote_base.h"
|
||||
#include "esphome/components/toshiba/toshiba.h"
|
||||
|
||||
namespace esphome::toshiba::testing {
|
||||
|
||||
struct Timings {
|
||||
uint32_t header_mark;
|
||||
uint32_t header_space;
|
||||
uint32_t bit_mark;
|
||||
uint32_t zero_space;
|
||||
uint32_t one_space;
|
||||
uint32_t gap_space;
|
||||
bool gap_after_last;
|
||||
};
|
||||
|
||||
static constexpr Timings GENERIC{4380, 4370, 540, 540, 1620, 5480, true};
|
||||
static constexpr Timings SEIYA{4630, 4450, 625, 490, 1570, 5830, false};
|
||||
|
||||
/// The waveform the component must produce for a frame: two copies, MSB first
|
||||
static remote_base::RawTimings waveform(const std::vector<uint8_t> &bytes, const Timings &t) {
|
||||
remote_base::RawTimings raw;
|
||||
for (int copy = 0; copy < 2; copy++) {
|
||||
raw.push_back(t.header_mark);
|
||||
raw.push_back(-static_cast<int32_t>(t.header_space));
|
||||
for (uint8_t byte : bytes) {
|
||||
for (int bit = 7; bit >= 0; bit--) {
|
||||
raw.push_back(t.bit_mark);
|
||||
raw.push_back(-static_cast<int32_t>((byte >> bit) & 1 ? t.one_space : t.zero_space));
|
||||
}
|
||||
}
|
||||
raw.push_back(t.bit_mark);
|
||||
if (copy == 0 || t.gap_after_last) {
|
||||
raw.push_back(-static_cast<int32_t>(t.gap_space));
|
||||
}
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
class CapturingTransmitter : public remote_base::RemoteTransmitterBase {
|
||||
public:
|
||||
CapturingTransmitter() : RemoteTransmitterBase(nullptr) {}
|
||||
std::vector<remote_base::RawTimings> frames;
|
||||
|
||||
protected:
|
||||
void send_internal(uint32_t send_times, uint32_t send_wait) override {
|
||||
this->frames.push_back(this->temp_.get_data());
|
||||
}
|
||||
};
|
||||
|
||||
struct Fixture {
|
||||
CapturingTransmitter transmitter;
|
||||
ToshibaClimate climate;
|
||||
|
||||
explicit Fixture(Model model) {
|
||||
this->climate.set_model(model);
|
||||
this->climate.set_transmitter(&this->transmitter);
|
||||
this->climate.setup();
|
||||
}
|
||||
|
||||
void command(climate::ClimateMode mode, float temperature, climate::ClimateFanMode fan,
|
||||
optional<climate::ClimateSwingMode> swing = {}) {
|
||||
auto call = this->climate.make_call();
|
||||
call.set_mode(mode);
|
||||
call.set_target_temperature(temperature);
|
||||
call.set_fan_mode(fan);
|
||||
if (swing.has_value()) {
|
||||
call.set_swing_mode(*swing);
|
||||
}
|
||||
call.perform();
|
||||
}
|
||||
|
||||
const remote_base::RawTimings &last_frame() const { return this->transmitter.frames.back(); }
|
||||
};
|
||||
|
||||
/// Decodes the first copy of a frame back into bytes so a mismatch is readable
|
||||
static std::string frame_bytes(const remote_base::RawTimings &raw, size_t nbytes) {
|
||||
std::string out;
|
||||
for (size_t byte = 0; byte < nbytes; byte++) {
|
||||
unsigned value = 0;
|
||||
for (size_t bit = 0; bit < 8; bit++) {
|
||||
const size_t space = 3 + 2 * (byte * 8 + bit);
|
||||
value = (value << 1) | (space < raw.size() && -raw[space] > 1000 ? 1 : 0);
|
||||
}
|
||||
char buf[4];
|
||||
snprintf(buf, sizeof(buf), "%02X ", value);
|
||||
out += buf;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Reports the frame bytes instead of gtest's truncated vector dump
|
||||
static void expect_frame(const remote_base::RawTimings &actual, const remote_base::RawTimings &expected,
|
||||
size_t nbytes) {
|
||||
EXPECT_EQ(actual, expected) << "actual " << frame_bytes(actual, nbytes) << "\nexpected "
|
||||
<< frame_bytes(expected, nbytes);
|
||||
}
|
||||
|
||||
TEST(ToshibaSeiya, EncodesCapturedFrames) {
|
||||
Fixture f(MODEL_SEIYA);
|
||||
|
||||
f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_AUTO);
|
||||
expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x70, 0x01, 0x00, 0x21, 0x17, 0x46}, SEIYA), 11);
|
||||
|
||||
f.command(climate::CLIMATE_MODE_OFF, 23, climate::CLIMATE_FAN_AUTO);
|
||||
expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x60, 0x07, 0x00, 0x21, 0x17, 0x50}, SEIYA), 11);
|
||||
|
||||
f.command(climate::CLIMATE_MODE_HEAT, 22, climate::CLIMATE_FAN_AUTO);
|
||||
expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x50, 0x03, 0x00, 0x21, 0x17, 0x64}, SEIYA), 11);
|
||||
|
||||
// The swing code goes out once, when the swing mode changes
|
||||
f.command(climate::CLIMATE_MODE_DRY, 22, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_VERTICAL);
|
||||
expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x50, 0x02, 0x00, 0x21, 0x08, 0x7A}, SEIYA), 11);
|
||||
f.command(climate::CLIMATE_MODE_DRY, 23, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_VERTICAL);
|
||||
expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x60, 0x02, 0x00, 0x21, 0x17, 0x55}, SEIYA), 11);
|
||||
}
|
||||
|
||||
TEST(ToshibaGeneric, WaveformIsUnchangedByTheSharedPath) {
|
||||
Fixture f(MODEL_GENERIC);
|
||||
|
||||
f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_AUTO);
|
||||
expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x70, 0x01, 0x00, 0x70}, GENERIC), 9);
|
||||
|
||||
f.command(climate::CLIMATE_MODE_HEAT, 20, climate::CLIMATE_FAN_HIGH);
|
||||
expect_frame(f.last_frame(), waveform({0xF2, 0x0D, 0x03, 0xFC, 0x01, 0x30, 0xC3, 0x00, 0xF2}, GENERIC), 9);
|
||||
}
|
||||
|
||||
static bool receive(ToshibaClimate &climate, const std::vector<uint8_t> &bytes, const Timings &t) {
|
||||
const remote_base::RawTimings raw = waveform(bytes, t);
|
||||
remote_base::RemoteReceiveData data(raw, 25, remote_base::TOLERANCE_MODE_PERCENTAGE);
|
||||
// The receiver calls through the listener interface, where on_receive is public
|
||||
return static_cast<remote_base::RemoteReceiverListener &>(climate).on_receive(data);
|
||||
}
|
||||
|
||||
TEST(ToshibaSeiya, DecodesStateAndKeepsSwingOnNeutralByte) {
|
||||
Fixture f(MODEL_SEIYA);
|
||||
f.command(climate::CLIMATE_MODE_COOL, 24, climate::CLIMATE_FAN_AUTO, climate::CLIMATE_SWING_VERTICAL);
|
||||
|
||||
// An ordinary update carries the neutral swing byte and must not touch the swing mode
|
||||
EXPECT_TRUE(receive(f.climate, {0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x50, 0x02, 0x00, 0x21, 0x17, 0x65}, SEIYA));
|
||||
EXPECT_EQ(f.climate.mode, climate::CLIMATE_MODE_DRY);
|
||||
EXPECT_FLOAT_EQ(f.climate.target_temperature, 22);
|
||||
EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_VERTICAL);
|
||||
|
||||
// A direction code switches the swing mode
|
||||
EXPECT_TRUE(receive(f.climate, {0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x50, 0x02, 0x00, 0x21, 0x09, 0x7B}, SEIYA));
|
||||
EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_HORIZONTAL);
|
||||
|
||||
// Without the feature marker the swing byte is not a Seiya swing command
|
||||
EXPECT_TRUE(receive(f.climate, {0xF2, 0x0D, 0x05, 0xFA, 0x01, 0x50, 0x02, 0x00, 0x00, 0x01, 0x52}, SEIYA));
|
||||
EXPECT_EQ(f.climate.swing_mode, climate::CLIMATE_SWING_HORIZONTAL);
|
||||
}
|
||||
|
||||
TEST(ToshibaSeiya, RejectsBadLengthByte) {
|
||||
Fixture f(MODEL_SEIYA);
|
||||
// Length byte 0x01 passes the header checksum but describes a frame shorter than any Toshiba frame
|
||||
EXPECT_FALSE(receive(f.climate, {0xF2, 0x0D, 0x01, 0xFE, 0x01, 0x50, 0x02}, SEIYA));
|
||||
}
|
||||
|
||||
} // namespace esphome::toshiba::testing
|
||||
Reference in New Issue
Block a user