[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:
longerCZE
2026-10-10 17:17:22 -10:00
committed by GitHub
co-authored by pre-commit-ci-lite[bot] J. Nick Koston
parent 70e4463644
commit 377a43c5be
9 changed files with 411 additions and 93 deletions
+1
View File
@@ -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(
+196 -89
View File
@@ -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
+16 -4
View File
@@ -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_;
};
+15
View File
@@ -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
+4
View File
@@ -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
+165
View File
@@ -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