diff --git a/esphome/components/fujitsu_general/fujitsu_general.cpp b/esphome/components/fujitsu_general/fujitsu_general.cpp index aca643defe..9995a3abbd 100644 --- a/esphome/components/fujitsu_general/fujitsu_general.cpp +++ b/esphome/components/fujitsu_general/fujitsu_general.cpp @@ -2,12 +2,6 @@ namespace esphome::fujitsu_general { -// bytes' bits are reversed for fujitsu, so nibbles are ordered 1, 0, 3, 2, 5, 4, etc... - -#define SET_NIBBLE(message, nibble, value) \ - ((message)[(nibble) / 2] |= ((value) &0b00001111) << (((nibble) % 2) ? 0 : 4)) -#define GET_NIBBLE(message, nibble) (((message)[(nibble) / 2] >> (((nibble) % 2) ? 0 : 4)) & 0b00001111) - static const char *const TAG = "fujitsu_general.climate"; // Common header @@ -36,32 +30,29 @@ constexpr uint8_t FUJITSU_GENERAL_STATE_HEADER_BYTE1 = 0x30; // State footer constexpr uint8_t FUJITSU_GENERAL_STATE_FOOTER_BYTE0 = 0x20; -// Temperature -constexpr uint8_t FUJITSU_GENERAL_TEMPERATURE_NIBBLE = 16; - // Power on -constexpr uint8_t FUJITSU_GENERAL_POWER_ON_NIBBLE = 17; constexpr uint8_t FUJITSU_GENERAL_POWER_OFF = 0x00; constexpr uint8_t FUJITSU_GENERAL_POWER_ON = 0x01; // Mode -constexpr uint8_t FUJITSU_GENERAL_MODE_NIBBLE = 19; +// Bit 3 is the clean flag, which is also 10 degree heat on the ARRAH2E and ARREW4E remotes. +constexpr uint8_t FUJITSU_GENERAL_MODE_MASK = 0b0111; +constexpr uint8_t FUJITSU_GENERAL_CLEAN_BIT = 0b1000; constexpr uint8_t FUJITSU_GENERAL_MODE_AUTO = 0x00; constexpr uint8_t FUJITSU_GENERAL_MODE_COOL = 0x01; constexpr uint8_t FUJITSU_GENERAL_MODE_DRY = 0x02; constexpr uint8_t FUJITSU_GENERAL_MODE_FAN = 0x03; constexpr uint8_t FUJITSU_GENERAL_MODE_HEAT = 0x04; -// const uint8_t FUJITSU_GENERAL_MODE_10C = 0x0B; // Swing -constexpr uint8_t FUJITSU_GENERAL_SWING_NIBBLE = 20; +constexpr uint8_t FUJITSU_GENERAL_SWING_MASK = 0b0011; constexpr uint8_t FUJITSU_GENERAL_SWING_NONE = 0x00; constexpr uint8_t FUJITSU_GENERAL_SWING_VERTICAL = 0x01; constexpr uint8_t FUJITSU_GENERAL_SWING_HORIZONTAL = 0x02; constexpr uint8_t FUJITSU_GENERAL_SWING_BOTH = 0x03; // Fan -constexpr uint8_t FUJITSU_GENERAL_FAN_NIBBLE = 21; +constexpr uint8_t FUJITSU_GENERAL_FAN_MASK = 0b0111; constexpr uint8_t FUJITSU_GENERAL_FAN_AUTO = 0x00; constexpr uint8_t FUJITSU_GENERAL_FAN_HIGH = 0x01; constexpr uint8_t FUJITSU_GENERAL_FAN_MEDIUM = 0x02; @@ -111,68 +102,67 @@ void FujitsuGeneralClimate::transmit_state() { uint8_t temperature_clamped = (uint8_t) roundf(clamp(this->target_temperature, FUJITSU_GENERAL_TEMP_MIN, FUJITSU_GENERAL_TEMP_MAX)); uint8_t temperature_offset = temperature_clamped - FUJITSU_GENERAL_TEMP_MIN; - SET_NIBBLE(remote_state, FUJITSU_GENERAL_TEMPERATURE_NIBBLE, temperature_offset); + set_nibble(remote_state, FUJITSU_GENERAL_TEMPERATURE_NIBBLE, temperature_offset); // Set power on if (!this->power_) { - SET_NIBBLE(remote_state, FUJITSU_GENERAL_POWER_ON_NIBBLE, FUJITSU_GENERAL_POWER_ON); + set_nibble(remote_state, FUJITSU_GENERAL_POWER_ON_NIBBLE, FUJITSU_GENERAL_POWER_ON); } // Set mode switch (this->mode) { case climate::CLIMATE_MODE_COOL: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_COOL); + set_nibble(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_COOL); break; case climate::CLIMATE_MODE_HEAT: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_HEAT); + set_nibble(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_HEAT); break; case climate::CLIMATE_MODE_DRY: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_DRY); + set_nibble(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_DRY); break; case climate::CLIMATE_MODE_FAN_ONLY: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_FAN); + set_nibble(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_FAN); break; case climate::CLIMATE_MODE_HEAT_COOL: default: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_AUTO); + set_nibble(remote_state, FUJITSU_GENERAL_MODE_NIBBLE, FUJITSU_GENERAL_MODE_AUTO); break; - // TODO: CLIMATE_MODE_10C is missing from esphome } // Set fan switch (this->fan_mode.value_or(climate::CLIMATE_FAN_ON)) { case climate::CLIMATE_FAN_HIGH: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_HIGH); + set_nibble(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_HIGH); break; case climate::CLIMATE_FAN_MEDIUM: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_MEDIUM); + set_nibble(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_MEDIUM); break; case climate::CLIMATE_FAN_LOW: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_LOW); + set_nibble(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_LOW); break; case climate::CLIMATE_FAN_QUIET: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_SILENT); + set_nibble(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_SILENT); break; case climate::CLIMATE_FAN_AUTO: default: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_AUTO); + set_nibble(remote_state, FUJITSU_GENERAL_FAN_NIBBLE, FUJITSU_GENERAL_FAN_AUTO); break; } // Set swing switch (this->swing_mode) { case climate::CLIMATE_SWING_VERTICAL: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_SWING_NIBBLE, FUJITSU_GENERAL_SWING_VERTICAL); + set_nibble(remote_state, FUJITSU_GENERAL_SWING_NIBBLE, FUJITSU_GENERAL_SWING_VERTICAL); break; case climate::CLIMATE_SWING_HORIZONTAL: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_SWING_NIBBLE, FUJITSU_GENERAL_SWING_HORIZONTAL); + set_nibble(remote_state, FUJITSU_GENERAL_SWING_NIBBLE, FUJITSU_GENERAL_SWING_HORIZONTAL); break; case climate::CLIMATE_SWING_BOTH: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_SWING_NIBBLE, FUJITSU_GENERAL_SWING_BOTH); + set_nibble(remote_state, FUJITSU_GENERAL_SWING_NIBBLE, FUJITSU_GENERAL_SWING_BOTH); break; case climate::CLIMATE_SWING_OFF: default: - SET_NIBBLE(remote_state, FUJITSU_GENERAL_SWING_NIBBLE, FUJITSU_GENERAL_SWING_NONE); + set_nibble(remote_state, FUJITSU_GENERAL_SWING_NIBBLE, FUJITSU_GENERAL_SWING_NONE); break; } @@ -243,6 +233,52 @@ uint8_t FujitsuGeneralClimate::checksum_state_(uint8_t const *message) { uint8_t FujitsuGeneralClimate::checksum_util_(uint8_t const *message) { return 255 - message[5]; } +// These decoders use if chains rather than switches: on ESP8266 the compiler turns a dense switch +// into a lookup table in .rodata, which lives in RAM there. +climate::ClimateMode decode_mode(uint8_t mode_field, climate::ClimateMode current_mode) { + const uint8_t mode = mode_field & FUJITSU_GENERAL_MODE_MASK; + if (mode == FUJITSU_GENERAL_MODE_COOL) + return climate::CLIMATE_MODE_COOL; + if (mode == FUJITSU_GENERAL_MODE_HEAT) + return climate::CLIMATE_MODE_HEAT; + if (mode == FUJITSU_GENERAL_MODE_DRY) + return climate::CLIMATE_MODE_DRY; + if (mode == FUJITSU_GENERAL_MODE_FAN) + return climate::CLIMATE_MODE_FAN_ONLY; + if (mode == FUJITSU_GENERAL_MODE_AUTO) + return climate::CLIMATE_MODE_HEAT_COOL; + // A state frame means the unit is on, so never keep OFF. + ESP_LOGW(TAG, "Received unassigned mode %X, keeping the current mode", mode); + return current_mode == climate::CLIMATE_MODE_OFF ? climate::CLIMATE_MODE_HEAT_COOL : current_mode; +} + +optional decode_fan_mode(uint8_t fan_field, optional current_mode) { + const uint8_t fan = fan_field & FUJITSU_GENERAL_FAN_MASK; + if (fan == FUJITSU_GENERAL_FAN_HIGH) + return climate::CLIMATE_FAN_HIGH; + if (fan == FUJITSU_GENERAL_FAN_MEDIUM) + return climate::CLIMATE_FAN_MEDIUM; + if (fan == FUJITSU_GENERAL_FAN_LOW) + return climate::CLIMATE_FAN_LOW; + if (fan == FUJITSU_GENERAL_FAN_SILENT) + return climate::CLIMATE_FAN_QUIET; + if (fan == FUJITSU_GENERAL_FAN_AUTO) + return climate::CLIMATE_FAN_AUTO; + ESP_LOGW(TAG, "Received unassigned fan speed %X, keeping the current fan mode", fan); + return current_mode; +} + +climate::ClimateSwingMode decode_swing_mode(uint8_t swing_field) { + const uint8_t swing = swing_field & FUJITSU_GENERAL_SWING_MASK; + if (swing == FUJITSU_GENERAL_SWING_VERTICAL) + return climate::CLIMATE_SWING_VERTICAL; + if (swing == FUJITSU_GENERAL_SWING_HORIZONTAL) + return climate::CLIMATE_SWING_HORIZONTAL; + if (swing == FUJITSU_GENERAL_SWING_BOTH) + return climate::CLIMATE_SWING_BOTH; + return climate::CLIMATE_SWING_OFF; +} + bool FujitsuGeneralClimate::on_receive(remote_base::RemoteReceiveData data) { ESP_LOGV(TAG, "Received IR message"); @@ -316,70 +352,25 @@ bool FujitsuGeneralClimate::on_receive(remote_base::RemoteReceiveData data) { } if (recv_message_type == FUJITSU_GENERAL_MESSAGE_TYPE_STATE) { - const uint8_t recv_tempertature = GET_NIBBLE(recv_message, FUJITSU_GENERAL_TEMPERATURE_NIBBLE); + const uint8_t recv_tempertature = get_nibble(recv_message, FUJITSU_GENERAL_TEMPERATURE_NIBBLE); const uint8_t offset_temperature = recv_tempertature + FUJITSU_GENERAL_TEMP_MIN; this->target_temperature = offset_temperature; ESP_LOGV(TAG, "Received temperature %d", offset_temperature); - const uint8_t recv_mode = GET_NIBBLE(recv_message, FUJITSU_GENERAL_MODE_NIBBLE); + const uint8_t recv_mode = get_nibble(recv_message, FUJITSU_GENERAL_MODE_NIBBLE); ESP_LOGV(TAG, "Received mode %X", recv_mode); - switch (recv_mode) { - case FUJITSU_GENERAL_MODE_COOL: - this->mode = climate::CLIMATE_MODE_COOL; - break; - case FUJITSU_GENERAL_MODE_HEAT: - this->mode = climate::CLIMATE_MODE_HEAT; - break; - case FUJITSU_GENERAL_MODE_DRY: - this->mode = climate::CLIMATE_MODE_DRY; - break; - case FUJITSU_GENERAL_MODE_FAN: - this->mode = climate::CLIMATE_MODE_FAN_ONLY; - break; - case FUJITSU_GENERAL_MODE_AUTO: - default: - // TODO: CLIMATE_MODE_10C is missing from esphome - this->mode = climate::CLIMATE_MODE_HEAT_COOL; - break; + if ((recv_mode & FUJITSU_GENERAL_CLEAN_BIT) != 0) { + ESP_LOGW(TAG, "Received a frame with the clean / 10 degree heat bit set, which is not supported"); } + this->mode = decode_mode(recv_mode, this->mode); - const uint8_t recv_fan_mode = GET_NIBBLE(recv_message, FUJITSU_GENERAL_FAN_NIBBLE); + const uint8_t recv_fan_mode = get_nibble(recv_message, FUJITSU_GENERAL_FAN_NIBBLE); ESP_LOGV(TAG, "Received fan mode %X", recv_fan_mode); - switch (recv_fan_mode) { - case FUJITSU_GENERAL_FAN_SILENT: - this->fan_mode = climate::CLIMATE_FAN_QUIET; - break; - case FUJITSU_GENERAL_FAN_LOW: - this->fan_mode = climate::CLIMATE_FAN_LOW; - break; - case FUJITSU_GENERAL_FAN_MEDIUM: - this->fan_mode = climate::CLIMATE_FAN_MEDIUM; - break; - case FUJITSU_GENERAL_FAN_HIGH: - this->fan_mode = climate::CLIMATE_FAN_HIGH; - break; - case FUJITSU_GENERAL_FAN_AUTO: - default: - this->fan_mode = climate::CLIMATE_FAN_AUTO; - break; - } + this->fan_mode = decode_fan_mode(recv_fan_mode, this->fan_mode); - const uint8_t recv_swing_mode = GET_NIBBLE(recv_message, FUJITSU_GENERAL_SWING_NIBBLE); + const uint8_t recv_swing_mode = get_nibble(recv_message, FUJITSU_GENERAL_SWING_NIBBLE); ESP_LOGV(TAG, "Received swing mode %X", recv_swing_mode); - switch (recv_swing_mode) { - case FUJITSU_GENERAL_SWING_VERTICAL: - this->swing_mode = climate::CLIMATE_SWING_VERTICAL; - break; - case FUJITSU_GENERAL_SWING_HORIZONTAL: - this->swing_mode = climate::CLIMATE_SWING_HORIZONTAL; - break; - case FUJITSU_GENERAL_SWING_BOTH: - this->swing_mode = climate::CLIMATE_SWING_BOTH; - break; - case FUJITSU_GENERAL_SWING_NONE: - default: - this->swing_mode = climate::CLIMATE_SWING_OFF; - } + this->swing_mode = decode_swing_mode(recv_swing_mode); this->power_ = true; } diff --git a/esphome/components/fujitsu_general/fujitsu_general.h b/esphome/components/fujitsu_general/fujitsu_general.h index 93363860d4..20bda883b2 100644 --- a/esphome/components/fujitsu_general/fujitsu_general.h +++ b/esphome/components/fujitsu_general/fujitsu_general.h @@ -43,9 +43,36 @@ constexpr uint8_t FUJITSU_GENERAL_TEMP_MAX = 30; // Celsius * heat 30 swing vert 00101000 11000110 00000000 00001000 00001000 01111111 10010000 00001100 00000111 00100000 00101000 00000000 00000000 00000000 00000100 00011101 * heat 30 noswing 00101000 11000110 00000000 00001000 00001000 01111111 10010000 00001100 00000111 00100000 00100000 00000000 00000000 00000000 00000100 00010011 * ``` + * + * The column markers show which bits varied in these captures, not field widths. */ // clang-format on +// Bits are reversed within each byte, so an odd nibble index is the low half of its byte. +constexpr uint8_t get_nibble(const uint8_t *message, uint8_t nibble) { + return (message[nibble / 2] >> ((nibble % 2) ? 0 : 4)) & 0b00001111; +} + +/// Write a nibble into a zero-initialised frame. +constexpr void set_nibble(uint8_t *message, uint8_t nibble, uint8_t value) { + message[nibble / 2] |= (value & 0b00001111) << ((nibble % 2) ? 0 : 4); +} + +// Nibble indices of the state frame fields. +constexpr uint8_t FUJITSU_GENERAL_TEMPERATURE_NIBBLE = 16; +constexpr uint8_t FUJITSU_GENERAL_POWER_ON_NIBBLE = 17; +constexpr uint8_t FUJITSU_GENERAL_MODE_NIBBLE = 19; +constexpr uint8_t FUJITSU_GENERAL_SWING_NIBBLE = 20; +constexpr uint8_t FUJITSU_GENERAL_FAN_NIBBLE = 21; + +/// Unassigned values keep the current mode, except that OFF becomes HEAT_COOL. +climate::ClimateMode decode_mode(uint8_t mode_field, climate::ClimateMode current_mode); + +/// Unassigned values keep the current fan mode. +optional decode_fan_mode(uint8_t fan_field, optional current_mode); + +climate::ClimateSwingMode decode_swing_mode(uint8_t swing_field); + class FujitsuGeneralClimate final : public climate_ir::ClimateIR { public: FujitsuGeneralClimate() diff --git a/tests/components/fujitsu_general/__init__.py b/tests/components/fujitsu_general/__init__.py new file mode 100644 index 0000000000..4ade799826 --- /dev/null +++ b/tests/components/fujitsu_general/__init__.py @@ -0,0 +1,9 @@ +from tests.testing_helpers import ComponentManifestOverride + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # This component's AUTO_LOAD = ["climate_ir"] sits on the climate platform manifest, while its + # own __init__.py is empty. The unit test build resolves the bare `fujitsu_general` domain, so + # it never sees that manifest. And climate_ir itself doesn't declare `climate` even though + # ClimateIR derives from climate::Climate. Pull both in so the test can include the header. + manifest.dependencies = manifest.dependencies + ["climate_ir", "climate"] diff --git a/tests/components/fujitsu_general/fujitsu_general_test.cpp b/tests/components/fujitsu_general/fujitsu_general_test.cpp new file mode 100644 index 0000000000..65b204131a --- /dev/null +++ b/tests/components/fujitsu_general/fujitsu_general_test.cpp @@ -0,0 +1,254 @@ +#include +#include "esphome/components/fujitsu_general/fujitsu_general.h" + +namespace esphome::fujitsu_general::testing { + +// The mode field of a received frame is three bits wide. The fourth bit of the same nibble belongs +// to the clean feature, so it has to be ignored when reading the mode. + +TEST(FujitsuGeneralDecodeModeTest, DecodesTheAssignedModes) { + EXPECT_EQ(decode_mode(0x00, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_HEAT_COOL); + EXPECT_EQ(decode_mode(0x01, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_COOL); + EXPECT_EQ(decode_mode(0x02, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_DRY); + EXPECT_EQ(decode_mode(0x03, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_FAN_ONLY); + EXPECT_EQ(decode_mode(0x04, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_HEAT); +} + +TEST(FujitsuGeneralDecodeModeTest, IgnoresTheCleanBit) { + // 0x0B is fan mode with the clean bit set. It used to be read as one value and reported as + // heat/cool, which is the bug this covers. + EXPECT_EQ(decode_mode(0x0B, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_FAN_ONLY); + + EXPECT_EQ(decode_mode(0x08, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_HEAT_COOL); + EXPECT_EQ(decode_mode(0x09, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_COOL); + EXPECT_EQ(decode_mode(0x0A, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_DRY); + EXPECT_EQ(decode_mode(0x0C, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_HEAT); +} + +TEST(FujitsuGeneralDecodeModeTest, KeepsTheCurrentModeForUnassignedValues) { + // 0x5 to 0x7 fit in the field but the protocol does not use them. + EXPECT_EQ(decode_mode(0x05, climate::CLIMATE_MODE_COOL), climate::CLIMATE_MODE_COOL); + EXPECT_EQ(decode_mode(0x06, climate::CLIMATE_MODE_HEAT), climate::CLIMATE_MODE_HEAT); + EXPECT_EQ(decode_mode(0x07, climate::CLIMATE_MODE_DRY), climate::CLIMATE_MODE_DRY); + + // The same three with the clean bit set. Without the mask these would not reach this branch. + EXPECT_EQ(decode_mode(0x0D, climate::CLIMATE_MODE_COOL), climate::CLIMATE_MODE_COOL); + EXPECT_EQ(decode_mode(0x0E, climate::CLIMATE_MODE_HEAT), climate::CLIMATE_MODE_HEAT); + EXPECT_EQ(decode_mode(0x0F, climate::CLIMATE_MODE_FAN_ONLY), climate::CLIMATE_MODE_FAN_ONLY); +} + +TEST(FujitsuGeneralDecodeModeTest, NeverReportsOffForAStateFrame) { + // A state frame describes a running unit, so keeping an off current mode would publish it as off + // and turn the next transmission into a power off command. Automatic is the least specific mode + // available, which is what the field's unassigned values decoded to before they were masked. + for (uint8_t field = 0x05; field <= 0x07; field++) { + SCOPED_TRACE(static_cast(field)); + EXPECT_EQ(decode_mode(field, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_HEAT_COOL); + EXPECT_EQ(decode_mode(field | 0b1000, climate::CLIMATE_MODE_OFF), climate::CLIMATE_MODE_HEAT_COOL); + } +} + +// The fan speed field is three bits wide as well, and used to fold every value it did not +// recognise into the automatic speed. + +TEST(FujitsuGeneralDecodeFanModeTest, DecodesTheAssignedSpeeds) { + EXPECT_EQ(decode_fan_mode(0x00, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_AUTO); + EXPECT_EQ(decode_fan_mode(0x01, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_HIGH); + EXPECT_EQ(decode_fan_mode(0x02, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_MEDIUM); + EXPECT_EQ(decode_fan_mode(0x03, climate::CLIMATE_FAN_AUTO), climate::CLIMATE_FAN_LOW); + EXPECT_EQ(decode_fan_mode(0x04, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_QUIET); +} + +TEST(FujitsuGeneralDecodeFanModeTest, IgnoresTheFourthBit) { + EXPECT_EQ(decode_fan_mode(0x08, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_AUTO); + EXPECT_EQ(decode_fan_mode(0x09, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_HIGH); + EXPECT_EQ(decode_fan_mode(0x0A, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_MEDIUM); + EXPECT_EQ(decode_fan_mode(0x0B, climate::CLIMATE_FAN_AUTO), climate::CLIMATE_FAN_LOW); + EXPECT_EQ(decode_fan_mode(0x0C, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_QUIET); +} + +TEST(FujitsuGeneralDecodeFanModeTest, KeepsTheCurrentFanModeForUnassignedValues) { + EXPECT_EQ(decode_fan_mode(0x05, climate::CLIMATE_FAN_HIGH), climate::CLIMATE_FAN_HIGH); + EXPECT_EQ(decode_fan_mode(0x06, climate::CLIMATE_FAN_MEDIUM), climate::CLIMATE_FAN_MEDIUM); + EXPECT_EQ(decode_fan_mode(0x07, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_LOW); + EXPECT_EQ(decode_fan_mode(0x0D, climate::CLIMATE_FAN_HIGH), climate::CLIMATE_FAN_HIGH); + EXPECT_EQ(decode_fan_mode(0x0E, climate::CLIMATE_FAN_HIGH), climate::CLIMATE_FAN_HIGH); + EXPECT_EQ(decode_fan_mode(0x0F, climate::CLIMATE_FAN_LOW), climate::CLIMATE_FAN_LOW); +} + +TEST(FujitsuGeneralDecodeFanModeTest, LeavesAnUnsetFanModeUnset) { + EXPECT_FALSE(decode_fan_mode(0x05, {}).has_value()); +} + +// The swing field is only two bits wide. The two bits above it are reserved, and were read as part +// of the value. + +TEST(FujitsuGeneralDecodeSwingModeTest, DecodesTheAssignedValues) { + EXPECT_EQ(decode_swing_mode(0x00), climate::CLIMATE_SWING_OFF); + EXPECT_EQ(decode_swing_mode(0x01), climate::CLIMATE_SWING_VERTICAL); + EXPECT_EQ(decode_swing_mode(0x02), climate::CLIMATE_SWING_HORIZONTAL); + EXPECT_EQ(decode_swing_mode(0x03), climate::CLIMATE_SWING_BOTH); +} + +TEST(FujitsuGeneralDecodeSwingModeTest, IgnoresTheReservedBits) { + // Without the mask everything from 0x04 up fell through to the default branch and reported swing + // off. All twelve are covered, so the field's whole input space is asserted. + const climate::ClimateSwingMode expected[] = {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_VERTICAL, + climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}; + for (uint8_t field = 0x04; field <= 0x0F; field++) { + SCOPED_TRACE(static_cast(field)); + EXPECT_EQ(decode_swing_mode(field), expected[field & 0b0011]); + } +} + +// Every state frame annotated in fujitsu_general.h, as the bytes those rows spell out. None of them +// sets the fourth bit of the mode or fan field, or either bit above the swing field, so the masks +// must leave all of them decoding exactly as they did before this change. + +namespace { + +struct CapturedFrame { + const char *label; + uint8_t bytes[16]; + uint8_t temperature; + bool turn_on; + climate::ClimateMode mode; + climate::ClimateFanMode fan_mode; + climate::ClimateSwingMode swing_mode; +}; + +constexpr CapturedFrame CAPTURED_FRAMES[] = { + {"auto auto 18", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0x21, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x8F}, + 18, + true, + climate::CLIMATE_MODE_HEAT_COOL, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"auto auto 19", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0x31, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x7F}, + 19, + true, + climate::CLIMATE_MODE_HEAT_COOL, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"auto auto 30 (temperatures)", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xCF}, + 30, + true, + climate::CLIMATE_MODE_HEAT_COOL, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"on at 16", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0x01, 0x04, 0x00, 0x00, 0x00, 0x00, 0x20, 0xAB}, + 16, + true, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"down to 16", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x20, 0xAC}, + 16, + false, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"auto auto 30 (mode options)", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0xCF}, + 30, + true, + climate::CLIMATE_MODE_HEAT_COOL, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"cool auto 30", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x01, 0x00, 0x00, 0x00, 0x00, 0x20, 0xCE}, + 30, + true, + climate::CLIMATE_MODE_COOL, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"dry auto 30", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x02, 0x00, 0x00, 0x00, 0x00, 0x20, 0xCD}, + 30, + true, + climate::CLIMATE_MODE_DRY, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"fan (auto) (30)", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x03, 0x00, 0x00, 0x00, 0x00, 0x20, 0xCC}, + 30, + true, + climate::CLIMATE_MODE_FAN_ONLY, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"heat auto 30", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x04, 0x00, 0x00, 0x00, 0x00, 0x20, 0xCB}, + 30, + true, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_AUTO, + climate::CLIMATE_SWING_OFF}, + {"heat 30 high", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE1, 0x04, 0x01, 0x00, 0x00, 0x00, 0x20, 0xCA}, + 30, + true, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_HIGH, + climate::CLIMATE_SWING_OFF}, + {"heat 30 med", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE0, 0x04, 0x02, 0x00, 0x00, 0x00, 0x20, 0xCA}, + 30, + false, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_SWING_OFF}, + {"heat 30 low", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE0, 0x04, 0x03, 0x00, 0x00, 0x00, 0x20, 0xC9}, + 30, + false, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_LOW, + climate::CLIMATE_SWING_OFF}, + {"heat 30 quiet", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE0, 0x04, 0x04, 0x00, 0x00, 0x00, 0x20, 0xC8}, + 30, + false, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_QUIET, + climate::CLIMATE_SWING_OFF}, + {"heat 30 swing vert", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE0, 0x04, 0x14, 0x00, 0x00, 0x00, 0x20, 0xB8}, + 30, + false, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_QUIET, + climate::CLIMATE_SWING_VERTICAL}, + {"heat 30 noswing", + {0x14, 0x63, 0x00, 0x10, 0x10, 0xFE, 0x09, 0x30, 0xE0, 0x04, 0x04, 0x00, 0x00, 0x00, 0x20, 0xC8}, + 30, + false, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_FAN_QUIET, + climate::CLIMATE_SWING_OFF}, +}; + +} // namespace + +TEST(FujitsuGeneralCaptureTest, DecodesEveryCapturedFrame) { + for (const auto &frame : CAPTURED_FRAMES) { + SCOPED_TRACE(frame.label); + // Read through the component's own nibble helper and field indices, so this also fails if the + // frame layout the header records ever stops matching what on_receive() reads. + EXPECT_EQ(get_nibble(frame.bytes, FUJITSU_GENERAL_TEMPERATURE_NIBBLE) + FUJITSU_GENERAL_TEMP_MIN, + frame.temperature); + // The turn on flag is only written by transmit_state(), so this pins the frame layout rather + // than a decode path. + EXPECT_EQ(get_nibble(frame.bytes, FUJITSU_GENERAL_POWER_ON_NIBBLE) != 0, frame.turn_on); + EXPECT_EQ(decode_mode(get_nibble(frame.bytes, FUJITSU_GENERAL_MODE_NIBBLE), climate::CLIMATE_MODE_OFF), frame.mode); + EXPECT_EQ(decode_fan_mode(get_nibble(frame.bytes, FUJITSU_GENERAL_FAN_NIBBLE), climate::CLIMATE_FAN_ON), + frame.fan_mode); + EXPECT_EQ(decode_swing_mode(get_nibble(frame.bytes, FUJITSU_GENERAL_SWING_NIBBLE)), frame.swing_mode); + } +} + +} // namespace esphome::fujitsu_general::testing