[fujitsu_general] Read the mode, fan speed and swing fields with their real width (#18297)

Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
Leonardo Rivera
2026-09-23 12:03:59 +01:00
committed by GitHub
co-authored by J. Nick Koston J. Nick Koston
parent a4bebc3083
commit ca46954335
4 changed files with 366 additions and 85 deletions
@@ -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<float>(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<climate::ClimateFanMode> decode_fan_mode(uint8_t fan_field, optional<climate::ClimateFanMode> 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;
}
@@ -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<climate::ClimateFanMode> decode_fan_mode(uint8_t fan_field, optional<climate::ClimateFanMode> current_mode);
climate::ClimateSwingMode decode_swing_mode(uint8_t swing_field);
class FujitsuGeneralClimate final : public climate_ir::ClimateIR {
public:
FujitsuGeneralClimate()
@@ -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"]
@@ -0,0 +1,254 @@
#include <gtest/gtest.h>
#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<int>(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<int>(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