[siemens_ira211] Add the Siemens IRA211 protocol and climate platform (#15154)

Co-authored-by: J. Nick Koston <nick@home-assistant.io>
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Jozsef Kiraly
2026-10-13 09:33:33 +13:00
committed by GitHub
co-authored by J. Nick Koston J. Nick Koston
parent 66ba258baa
commit 5a3bee638f
19 changed files with 1000 additions and 0 deletions
+1
View File
@@ -502,6 +502,7 @@ esphome/components/shelly_dimmer/* @edge90 @rnauber
esphome/components/sht3xd/* @mrtoy-me
esphome/components/sht4x/* @sjtrny
esphome/components/shutdown/* @esphome/core @jsuanet
esphome/components/siemens_ira211/* @fonix232
esphome/components/sigma_delta_output/* @Cat-Ion
esphome/components/sim800l/* @glmnet
esphome/components/sm10bit_base/* @Cossid
@@ -21,6 +21,7 @@ from esphome.const import (
CONF_DELTA,
CONF_DEVICE,
CONF_FAMILY,
CONF_FAN_MODE,
CONF_FORCE_UPDATE,
CONF_GROUP,
CONF_HUMIDITY,
@@ -30,6 +31,7 @@ from esphome.const import (
CONF_KEY,
CONF_LEVEL,
CONF_MAGNITUDE,
CONF_MODE,
CONF_NBITS,
CONF_ONE,
CONF_PROTOCOL,
@@ -2771,3 +2773,94 @@ register_protocol_action(
(CONF_COMMAND, "command", cg.uint8),
validate=_toto_default_repeat,
)
# IRA211
(
IRA211Data,
IRA211BinarySensor,
IRA211Trigger,
_,
IRA211Dumper,
) = declare_protocol("IRA211")
IRA211CommandEnum = ns.enum("IRA211Command", is_class=True)
IRA211_COMMANDS = {
"TEMP_UP": IRA211CommandEnum.IRA211_COMMAND_TEMP_UP,
"TEMP_DOWN": IRA211CommandEnum.IRA211_COMMAND_TEMP_DOWN,
"MODE": IRA211CommandEnum.IRA211_COMMAND_MODE,
"FAN": IRA211CommandEnum.IRA211_COMMAND_FAN,
"POWER": IRA211CommandEnum.IRA211_COMMAND_POWER,
"SYNC": IRA211CommandEnum.IRA211_COMMAND_SYNC,
}
IRA211ModeEnum = ns.enum("IRA211Mode", is_class=True)
IRA211_MODES = {
"PROTECTION": IRA211ModeEnum.IRA211_MODE_PROTECTION,
"TIMER": IRA211ModeEnum.IRA211_MODE_TIMER,
"COMFORT": IRA211ModeEnum.IRA211_MODE_COMFORT,
}
IRA211FanEnum = ns.enum("IRA211Fan", is_class=True)
IRA211_FAN_SPEEDS = {
"AUTO": IRA211FanEnum.IRA211_FAN_AUTO,
"LOW": IRA211FanEnum.IRA211_FAN_LOW,
"MEDIUM": IRA211FanEnum.IRA211_FAN_MEDIUM,
"HIGH": IRA211FanEnum.IRA211_FAN_HIGH,
}
def _half_degree_step(value: float) -> float:
if value * 2 != int(value * 2):
raise cv.Invalid("Temperature must be a multiple of 0.5")
return value
IRA211_SCHEMA = cv.Schema(
{
cv.Required(CONF_COMMAND): cv.enum(IRA211_COMMANDS, upper=True),
cv.Optional(CONF_TEMPERATURE, default=20): cv.All(
cv.float_range(min=5, max=35), _half_degree_step
),
cv.Optional(CONF_MODE, default="COMFORT"): cv.enum(IRA211_MODES, upper=True),
cv.Optional(CONF_FAN_MODE, default="AUTO"): cv.enum(
IRA211_FAN_SPEEDS, upper=True
),
}
)
@register_binary_sensor("ira211", IRA211BinarySensor, IRA211_SCHEMA)
def ira211_binary_sensor(var: MockObj, config: ConfigType) -> None:
cg.add(
var.set_data(
cg.StructInitializer(
IRA211Data,
("command", config[CONF_COMMAND]),
("half_degrees", int(config[CONF_TEMPERATURE] * 2)),
("mode", config[CONF_MODE]),
("fan", config[CONF_FAN_MODE]),
)
)
)
@register_trigger("ira211", IRA211Trigger, IRA211Data)
def ira211_trigger(var: MockObj, config: ConfigType) -> None:
"""The trigger takes no options beyond the automation."""
@register_dumper("ira211", IRA211Dumper)
def ira211_dumper(var: MockObj, config: ConfigType) -> None:
"""The dumper takes no options."""
register_protocol_action(
"ira211",
"IRA211",
IRA211_SCHEMA,
(CONF_COMMAND, "command", IRA211CommandEnum),
automation.ApplyField(CONF_TEMPERATURE, "data.set_temperature({})", cg.float_),
(CONF_MODE, "mode", IRA211ModeEnum),
(CONF_FAN_MODE, "fan", IRA211FanEnum),
)
@@ -0,0 +1,292 @@
#include "ira211_protocol.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
namespace esphome::remote_base {
ESPHOME_LOG_TAG(TAG, "remote.ira211");
static constexpr uint32_t CARRIER_FREQUENCY = 38000;
static constexpr uint32_t T_US = 800;
static constexpr uint32_t HEADER_US = 19 * T_US / 2; // 7600 us, also the gap that separates frames
// Midway between the longest data run (9 bits, 7200 us) and the gap, so a gap captured a little short still
// ends the frame
static constexpr uint32_t GAP_MIN_US = HEADER_US - T_US / 4;
static constexpr uint8_t MIN_HALF_DEGREES = static_cast<uint8_t>(IRA211_MIN_TEMPERATURE * 2);
static constexpr uint8_t MAX_HALF_DEGREES = static_cast<uint8_t>(IRA211_MAX_TEMPERATURE * 2);
static constexpr uint8_t DEVICE_ID = 0x66;
static constexpr uint8_t BITS_PER_PACKET = 10;
static constexpr uint8_t MIN_PACKETS = 4;
static constexpr uint8_t MAX_PACKETS = 7;
static constexpr uint8_t MAX_BITS = MAX_PACKETS * BITS_PER_PACKET;
// Fields each command carries, in wire order after the command byte: temperature (whole degrees
// then tenths), mode, fan.
static constexpr uint8_t FIELD_TEMPERATURE = 1 << 0;
static constexpr uint8_t FIELD_MODE = 1 << 1;
static constexpr uint8_t FIELD_FAN = 1 << 2;
struct CommandLayout {
IRA211Command command;
uint8_t fields;
};
static const CommandLayout LAYOUTS[] PROGMEM = {
{IRA211Command::IRA211_COMMAND_TEMP_UP, FIELD_TEMPERATURE},
{IRA211Command::IRA211_COMMAND_TEMP_DOWN, FIELD_TEMPERATURE},
{IRA211Command::IRA211_COMMAND_MODE, FIELD_MODE},
{IRA211Command::IRA211_COMMAND_FAN, FIELD_FAN},
{IRA211Command::IRA211_COMMAND_POWER, FIELD_TEMPERATURE | FIELD_MODE | FIELD_FAN},
{IRA211Command::IRA211_COMMAND_SYNC, FIELD_TEMPERATURE | FIELD_MODE | FIELD_FAN},
};
static optional<CommandLayout> find_layout(IRA211Command command) {
for (const auto &slot : LAYOUTS) {
CommandLayout layout;
progmem_memcpy(&layout, &slot, sizeof(layout));
if (layout.command == command)
return layout;
}
return {};
}
// Device ID, command, fields, checksum
static uint8_t packet_count(const CommandLayout &layout) {
return 3 + ((layout.fields & FIELD_TEMPERATURE) ? 2 : 0) + ((layout.fields & FIELD_MODE) ? 1 : 0) +
((layout.fields & FIELD_FAN) ? 1 : 0);
}
// Value to wire byte and back; the transform is its own inverse
static uint8_t wire_xform(uint8_t value) { return reverse_bits(static_cast<uint8_t>(~value)); }
static bool is_known_mode(uint8_t value) {
switch (static_cast<IRA211Mode>(value)) {
case IRA211Mode::IRA211_MODE_PROTECTION:
case IRA211Mode::IRA211_MODE_TIMER:
case IRA211Mode::IRA211_MODE_COMFORT:
return true;
default:
return false;
}
}
static bool is_known_fan(uint8_t value) {
switch (static_cast<IRA211Fan>(value)) {
case IRA211Fan::IRA211_FAN_AUTO:
case IRA211Fan::IRA211_FAN_LOW:
case IRA211Fan::IRA211_FAN_MEDIUM:
case IRA211Fan::IRA211_FAN_HIGH:
return true;
default:
return false;
}
}
static uint8_t checksum(const uint8_t *bytes, uint8_t count) {
uint8_t sum = count;
for (uint8_t i = 0; i < count; i++)
sum += reverse_bits(bytes[i]);
return reverse_bits(sum);
}
bool IRA211Data::operator==(const IRA211Data &rhs) const {
if (this->command != rhs.command)
return false;
const optional<CommandLayout> layout = find_layout(this->command);
if (!layout.has_value())
return true;
if ((layout->fields & FIELD_TEMPERATURE) && this->half_degrees != rhs.half_degrees)
return false;
if ((layout->fields & FIELD_MODE) && this->mode != rhs.mode)
return false;
return !(layout->fields & FIELD_FAN) || this->fan == rhs.fan;
}
void IRA211Protocol::encode(RemoteTransmitData *dst, const IRA211Data &data) {
const optional<CommandLayout> layout = find_layout(data.command);
if (!layout.has_value())
return;
uint8_t bytes[MAX_PACKETS];
uint8_t count = 0;
bytes[count++] = DEVICE_ID;
bytes[count++] = wire_xform(static_cast<uint8_t>(data.command));
if (layout->fields & FIELD_TEMPERATURE) {
bytes[count++] = wire_xform(data.half_degrees / 2);
bytes[count++] = wire_xform((data.half_degrees & 1) ? 5 : 0);
}
if (layout->fields & FIELD_MODE)
bytes[count++] = wire_xform(static_cast<uint8_t>(data.mode));
if (layout->fields & FIELD_FAN)
bytes[count++] = wire_xform(static_cast<uint8_t>(data.fan));
bytes[count] = checksum(bytes, count);
count++;
dst->set_carrier_frequency(CARRIER_FREQUENCY);
// Preamble, at most one item per bit, trailing mark and gap
dst->reserve(4 + count * BITS_PER_PACKET + 2);
dst->item(HEADER_US, T_US);
dst->item(T_US, HEADER_US);
// Each packet is a 1 bit, the byte MSB first, then a 0 bit; runs of equal bits become one mark or space
bool level = true;
uint32_t run = 0;
for (uint8_t p = 0; p < count; p++) {
const uint16_t packet = (1 << 9) | (bytes[p] << 1);
for (int8_t i = BITS_PER_PACKET - 1; i >= 0; i--) {
const bool bit = (packet >> i) & 1;
if (bit != level && run != 0) {
if (level) {
dst->mark(run * T_US);
} else {
dst->space(run * T_US);
}
run = 0;
}
level = bit;
run++;
}
}
// Every packet ends with a 0 bit, so the frame ends with a space, closed by a trailing mark
dst->space(run * T_US);
dst->mark(T_US);
dst->space(HEADER_US);
}
optional<IRA211Data> IRA211Protocol::decode(RemoteReceiveData src) {
if (!src.expect_item(HEADER_US, T_US) || !src.expect_item(T_US, HEADER_US))
return {};
// Read bits until the end-of-frame gap, checking packet framing and collecting bytes as they arrive
uint8_t bytes[MAX_PACKETS]{};
uint8_t bits = 0;
while (src.is_valid()) {
const int32_t raw = src.peek();
const bool is_mark = raw > 0;
const uint32_t duration = is_mark ? raw : -raw;
// A mark followed by the gap or the end of the data is the trailing mark, not a data bit
if (is_mark && (!src.is_valid(1) || src.peek(1) <= -static_cast<int32_t>(GAP_MIN_US)))
break;
if (!is_mark && duration >= GAP_MIN_US)
break;
const uint32_t run = (duration + T_US / 2) / T_US;
if (run == 0 || bits + run > MAX_BITS)
return {};
for (uint32_t i = 0; i < run; i++, bits++) {
const uint8_t pos = bits % BITS_PER_PACKET;
if ((pos == 0 && !is_mark) || (pos == BITS_PER_PACKET - 1 && is_mark))
return {};
if (pos != 0 && pos != BITS_PER_PACKET - 1) {
uint8_t &byte = bytes[bits / BITS_PER_PACKET];
byte = (byte << 1) | (is_mark ? 1 : 0);
}
}
src.advance();
}
const uint8_t count = bits / BITS_PER_PACKET;
if (bits % BITS_PER_PACKET != 0 || count < MIN_PACKETS || bytes[0] != DEVICE_ID)
return {};
IRA211Data out{};
out.command = static_cast<IRA211Command>(wire_xform(bytes[1]));
const optional<CommandLayout> layout = find_layout(out.command);
if (!layout.has_value() || packet_count(*layout) != count || checksum(bytes, count - 1) != bytes[count - 1]) {
ESP_LOGV(TAG, "Invalid frame (%u bits)", bits);
return {};
}
// A checksum-valid frame can still carry values outside what the thermostat accepts
uint8_t index = 2;
if (layout->fields & FIELD_TEMPERATURE) {
const uint8_t whole = wire_xform(bytes[index++]);
const uint8_t tenths = wire_xform(bytes[index++]);
const uint16_t half_degrees = whole * 2 + (tenths >= 5 ? 1 : 0);
if (half_degrees < MIN_HALF_DEGREES || half_degrees > MAX_HALF_DEGREES)
return {};
out.half_degrees = static_cast<uint8_t>(half_degrees);
}
if (layout->fields & FIELD_MODE) {
const uint8_t mode = wire_xform(bytes[index++]);
if (!is_known_mode(mode))
return {};
out.mode = static_cast<IRA211Mode>(mode);
}
if (layout->fields & FIELD_FAN) {
const uint8_t fan = wire_xform(bytes[index++]);
if (!is_known_fan(fan))
return {};
out.fan = static_cast<IRA211Fan>(fan);
}
return out;
}
static const LogString *command_to_string(IRA211Command command) {
switch (command) {
case IRA211Command::IRA211_COMMAND_TEMP_UP:
return LOG_STR("TEMP_UP");
case IRA211Command::IRA211_COMMAND_TEMP_DOWN:
return LOG_STR("TEMP_DOWN");
case IRA211Command::IRA211_COMMAND_MODE:
return LOG_STR("MODE");
case IRA211Command::IRA211_COMMAND_FAN:
return LOG_STR("FAN");
case IRA211Command::IRA211_COMMAND_POWER:
return LOG_STR("POWER");
case IRA211Command::IRA211_COMMAND_SYNC:
return LOG_STR("SYNC");
default:
return LOG_STR("UNKNOWN");
}
}
static const LogString *mode_to_string(IRA211Mode mode) {
switch (mode) {
case IRA211Mode::IRA211_MODE_PROTECTION:
return LOG_STR("Protection");
case IRA211Mode::IRA211_MODE_TIMER:
return LOG_STR("Timer");
case IRA211Mode::IRA211_MODE_COMFORT:
return LOG_STR("Comfort");
default:
return LOG_STR("Unknown");
}
}
static const LogString *fan_to_string(IRA211Fan fan) {
switch (fan) {
case IRA211Fan::IRA211_FAN_AUTO:
return LOG_STR("Auto");
case IRA211Fan::IRA211_FAN_LOW:
return LOG_STR("1/3");
case IRA211Fan::IRA211_FAN_MEDIUM:
return LOG_STR("2/3");
case IRA211Fan::IRA211_FAN_HIGH:
return LOG_STR("3/3");
default:
return LOG_STR("Unknown");
}
}
void IRA211Protocol::dump(const IRA211Data &data) {
const optional<CommandLayout> layout = find_layout(data.command);
const uint8_t fields = layout.has_value() ? layout->fields : 0;
const unsigned whole = data.half_degrees / 2;
const unsigned tenths = (data.half_degrees & 1) ? 5 : 0;
const auto *command = LOG_STR_ARG(command_to_string(data.command));
if (fields == (FIELD_TEMPERATURE | FIELD_MODE | FIELD_FAN)) {
ESP_LOGI(TAG, "Received IRA211: command=%s, temperature=%u.%uC, mode=%s, fan=%s", command, whole, tenths,
LOG_STR_ARG(mode_to_string(data.mode)), LOG_STR_ARG(fan_to_string(data.fan)));
} else if (fields == FIELD_TEMPERATURE) {
ESP_LOGI(TAG, "Received IRA211: command=%s, temperature=%u.%uC", command, whole, tenths);
} else if (fields == FIELD_MODE) {
ESP_LOGI(TAG, "Received IRA211: command=%s, mode=%s", command, LOG_STR_ARG(mode_to_string(data.mode)));
} else if (fields == FIELD_FAN) {
ESP_LOGI(TAG, "Received IRA211: command=%s, fan=%s", command, LOG_STR_ARG(fan_to_string(data.fan)));
} else {
ESP_LOGI(TAG, "Received IRA211: command=%s", command);
}
}
} // namespace esphome::remote_base
@@ -0,0 +1,65 @@
#pragma once
#include "remote_base.h"
#include "esphome/core/helpers.h"
#include <cmath>
namespace esphome::remote_base {
/* Siemens IRA211 thermostat remote: 38 kHz carrier, NRZ runs of 800 us bits after a fixed preamble.
A frame is 4, 5 or 7 ten-bit packets (start bit 1, 8 data bits, end bit 0): device ID, command,
the command's fields, then a checksum. Field bytes go over the wire as reverse_bits(~value). */
enum class IRA211Command : uint8_t {
IRA211_COMMAND_TEMP_UP = 1,
IRA211_COMMAND_TEMP_DOWN = 2,
IRA211_COMMAND_MODE = 4,
IRA211_COMMAND_FAN = 5,
IRA211_COMMAND_POWER = 6,
IRA211_COMMAND_SYNC = 7,
};
enum class IRA211Mode : uint8_t {
IRA211_MODE_PROTECTION = 0,
IRA211_MODE_TIMER = 85,
IRA211_MODE_COMFORT = 170,
};
enum class IRA211Fan : uint8_t {
IRA211_FAN_AUTO = 3,
IRA211_FAN_LOW = 12,
IRA211_FAN_MEDIUM = 48,
IRA211_FAN_HIGH = 192,
};
static constexpr float IRA211_MIN_TEMPERATURE = 5.0f;
static constexpr float IRA211_MAX_TEMPERATURE = 35.0f;
struct IRA211Data {
IRA211Command command{IRA211Command::IRA211_COMMAND_SYNC};
uint8_t half_degrees{40}; // target temperature in 0.5 C steps
IRA211Mode mode{IRA211Mode::IRA211_MODE_COMFORT};
IRA211Fan fan{IRA211Fan::IRA211_FAN_AUTO};
float get_temperature() const { return this->half_degrees * 0.5f; }
/// Rounds to the nearest half degree and clamps to the 5 to 35 C range the thermostat accepts.
void set_temperature(float temperature) {
this->half_degrees =
static_cast<uint8_t>(lroundf(clamp(temperature, IRA211_MIN_TEMPERATURE, IRA211_MAX_TEMPERATURE) * 2.0f));
}
/// Compares the command and only the fields that command carries.
bool operator==(const IRA211Data &rhs) const;
};
class IRA211Protocol : public RemoteProtocol<IRA211Data> {
public:
void encode(RemoteTransmitData *dst, const IRA211Data &data);
optional<IRA211Data> decode(RemoteReceiveData src);
void dump(const IRA211Data &data);
};
DECLARE_REMOTE_PROTOCOL(IRA211)
} // namespace esphome::remote_base
@@ -0,0 +1,2 @@
CODEOWNERS = ["@fonix232"]
DOMAIN = "siemens_ira211"
@@ -0,0 +1,17 @@
import esphome.codegen as cg
from esphome.components import climate_ir, remote_base
from esphome.types import ConfigType
AUTO_LOAD = ["climate_ir"]
siemens_ira211_ns = cg.esphome_ns.namespace("siemens_ira211")
SiemensIRA211Climate = siemens_ira211_ns.class_(
"SiemensIRA211Climate", climate_ir.ClimateIR
)
CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(SiemensIRA211Climate)
async def to_code(config: ConfigType) -> None:
remote_base.request_protocol("ira211") # used from C++
await climate_ir.new_climate_ir(config)
@@ -0,0 +1,167 @@
#include "siemens_ira211.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
namespace esphome::siemens_ira211 {
using namespace remote_base;
struct PresetMapping {
climate::ClimatePreset preset;
IRA211Mode mode;
};
static const PresetMapping PRESETS[] PROGMEM = {
{climate::CLIMATE_PRESET_COMFORT, IRA211Mode::IRA211_MODE_COMFORT},
{climate::CLIMATE_PRESET_ECO, IRA211Mode::IRA211_MODE_TIMER},
};
struct FanMapping {
climate::ClimateFanMode fan_mode;
IRA211Fan fan;
};
static const FanMapping FANS[] PROGMEM = {
{climate::CLIMATE_FAN_AUTO, IRA211Fan::IRA211_FAN_AUTO},
{climate::CLIMATE_FAN_LOW, IRA211Fan::IRA211_FAN_LOW},
{climate::CLIMATE_FAN_MEDIUM, IRA211Fan::IRA211_FAN_MEDIUM},
{climate::CLIMATE_FAN_HIGH, IRA211Fan::IRA211_FAN_HIGH},
};
// The tables live in flash, so entries are copied out before use
template<typename T> static T load_entry(const T &slot) {
T entry;
progmem_memcpy(&entry, &slot, sizeof(T));
return entry;
}
// The first entry of each table is the fallback for values the other side does not know
static IRA211Mode to_ira211_mode(climate::ClimatePreset preset) {
for (const auto &slot : PRESETS) {
const auto entry = load_entry(slot);
if (entry.preset == preset)
return entry.mode;
}
return load_entry(PRESETS[0]).mode;
}
static climate::ClimatePreset to_climate_preset(IRA211Mode mode) {
for (const auto &slot : PRESETS) {
const auto entry = load_entry(slot);
if (entry.mode == mode)
return entry.preset;
}
return load_entry(PRESETS[0]).preset;
}
static IRA211Fan to_ira211_fan(climate::ClimateFanMode fan_mode) {
for (const auto &slot : FANS) {
const auto entry = load_entry(slot);
if (entry.fan_mode == fan_mode)
return entry.fan;
}
return load_entry(FANS[0]).fan;
}
static climate::ClimateFanMode to_climate_fan_mode(IRA211Fan fan) {
for (const auto &slot : FANS) {
const auto entry = load_entry(slot);
if (entry.fan == fan)
return entry.fan_mode;
}
return load_entry(FANS[0]).fan_mode;
}
void SiemensIRA211Climate::setup() {
// The thermostat decides between heating and cooling itself, so one mode stands for both
if (this->modes_.count(climate::CLIMATE_MODE_HEAT_COOL)) {
this->modes_.erase(climate::CLIMATE_MODE_HEAT);
this->modes_.erase(climate::CLIMATE_MODE_COOL);
}
climate_ir::ClimateIR::setup();
// Match the restored state, so the first command after a reboot does not send a needless POWER frame
this->is_on_ = this->mode != climate::CLIMATE_MODE_OFF;
}
climate::ClimateMode SiemensIRA211Climate::active_mode_() const {
if (this->modes_.count(climate::CLIMATE_MODE_HEAT_COOL))
return climate::CLIMATE_MODE_HEAT_COOL;
if (this->modes_.count(climate::CLIMATE_MODE_HEAT))
return climate::CLIMATE_MODE_HEAT;
return climate::CLIMATE_MODE_COOL;
}
void SiemensIRA211Climate::transmit_frame_(IRA211Command command, IRA211Mode mode, IRA211Fan fan) {
IRA211Data data{};
data.command = command;
data.set_temperature(this->target_temperature);
data.mode = mode;
data.fan = fan;
this->transmit_<IRA211Protocol>(data);
}
void SiemensIRA211Climate::transmit_state() {
const IRA211Mode mode = to_ira211_mode(this->preset.value_or(climate::CLIMATE_PRESET_COMFORT));
const IRA211Fan fan = to_ira211_fan(this->fan_mode.value_or(climate::CLIMATE_FAN_AUTO));
// Show the preset that is actually sent, so a fresh entity does not stay on "none"
this->preset = to_climate_preset(mode);
if (this->mode == climate::CLIMATE_MODE_OFF) {
this->transmit_frame_(IRA211Command::IRA211_COMMAND_POWER, IRA211Mode::IRA211_MODE_PROTECTION, fan);
this->is_on_ = false;
return;
}
if (!this->is_on_) {
// Turning on takes a POWER frame before the state is sent
this->transmit_frame_(IRA211Command::IRA211_COMMAND_POWER, mode, fan);
this->is_on_ = true;
}
this->transmit_frame_(IRA211Command::IRA211_COMMAND_SYNC, mode, fan);
}
void SiemensIRA211Climate::apply_mode_(IRA211Mode mode) {
// The thermostat reports "off" as the PROTECTION mode
if (mode == IRA211Mode::IRA211_MODE_PROTECTION) {
this->mode = climate::CLIMATE_MODE_OFF;
this->is_on_ = false;
return;
}
if (!this->is_on_) {
this->mode = this->active_mode_();
this->is_on_ = true;
}
this->preset = to_climate_preset(mode);
}
bool SiemensIRA211Climate::on_receive(RemoteReceiveData data) {
auto decoded = IRA211Protocol().decode(data);
if (!decoded.has_value())
return false;
const IRA211Data &frame = *decoded;
switch (frame.command) {
case IRA211Command::IRA211_COMMAND_SYNC:
case IRA211Command::IRA211_COMMAND_POWER:
this->apply_mode_(frame.mode);
this->target_temperature = frame.get_temperature();
this->fan_mode = to_climate_fan_mode(frame.fan);
break;
case IRA211Command::IRA211_COMMAND_TEMP_UP:
case IRA211Command::IRA211_COMMAND_TEMP_DOWN:
this->target_temperature = frame.get_temperature();
break;
case IRA211Command::IRA211_COMMAND_MODE:
this->apply_mode_(frame.mode);
break;
case IRA211Command::IRA211_COMMAND_FAN:
this->fan_mode = to_climate_fan_mode(frame.fan);
break;
default:
return false;
}
this->publish_state();
return true;
}
} // namespace esphome::siemens_ira211
@@ -0,0 +1,29 @@
#pragma once
#include "esphome/components/climate_ir/climate_ir.h"
#include "esphome/components/remote_base/ira211_protocol.h"
namespace esphome::siemens_ira211 {
class SiemensIRA211Climate final : public climate_ir::ClimateIR {
public:
void setup() override;
SiemensIRA211Climate()
: climate_ir::ClimateIR(remote_base::IRA211_MIN_TEMPERATURE, remote_base::IRA211_MAX_TEMPERATURE, 0.5f, false,
false,
{climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM,
climate::CLIMATE_FAN_HIGH},
{}, {climate::CLIMATE_PRESET_COMFORT, climate::CLIMATE_PRESET_ECO}) {}
protected:
void transmit_state() override;
bool on_receive(remote_base::RemoteReceiveData data) override;
void transmit_frame_(remote_base::IRA211Command command, remote_base::IRA211Mode mode, remote_base::IRA211Fan fan);
void apply_mode_(remote_base::IRA211Mode mode);
climate::ClimateMode active_mode_() const;
bool is_on_{false};
};
} // namespace esphome::siemens_ira211
+1
View File
@@ -170,6 +170,7 @@
#define USE_REMOTE_PROTOCOL_GOBOX
#define USE_REMOTE_PROTOCOL_HAIER
#define USE_REMOTE_PROTOCOL_HOB2HOOD
#define USE_REMOTE_PROTOCOL_IRA211
#define USE_REMOTE_PROTOCOL_JVC
#define USE_REMOTE_PROTOCOL_KEELOQ
#define USE_REMOTE_PROTOCOL_LG
@@ -0,0 +1,25 @@
esphome:
name: test
esp8266:
board: esp01_1m
remote_transmitter:
pin: GPIO4
carrier_duty_percent: 50%
button:
- platform: template
name: IRA211 constant
on_press:
- remote_transmitter.transmit_ira211:
command: sync
temperature: 22.5
mode: timer
fan_mode: high
- platform: template
name: IRA211 lambda temperature
on_press:
- remote_transmitter.transmit_ira211:
command: sync
temperature: !lambda return 21.0f;
@@ -0,0 +1,20 @@
"""transmit_ira211 sets the temperature through IRA211Data::set_temperature()."""
from collections.abc import Callable
from pathlib import Path
from tests.component_tests.remote_transmitter import protocol_call_bodies
def test_ira211_temperature_is_a_statement_on_the_data(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(component_config_path("ira211_action.yaml"))
constant, lambda_ = protocol_call_bodies(main_cpp)
assert "apply_call.data.set_temperature(22.5f);" in constant
assert (
"apply_call.data.mode = remote_base::IRA211Mode::IRA211_MODE_TIMER;" in constant
)
assert "apply_call.data.fan = remote_base::IRA211Fan::IRA211_FAN_HIGH;" in constant
assert "apply_call.data.set_temperature(static_cast<float>(21.0f));" in lambda_
+1
View File
@@ -14,6 +14,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None:
"aeha",
"keeloq",
"nexus",
"ira211",
"nec",
"magiquest",
"rc5x",
@@ -0,0 +1,254 @@
#include <gtest/gtest.h>
#include "esphome/components/remote_base/ira211_protocol.h"
#include <array>
#include <cstdio>
#include <cstdlib>
#include <string>
#include <utility>
namespace esphome::remote_base::testing {
namespace {
constexpr uint32_t T_US = 800;
constexpr uint32_t HEADER_US = 7600;
constexpr size_t PREAMBLE_ITEMS = 4; // 7600 mark, 800 space, 800 mark, 7600 space
constexpr size_t BITS_PER_PACKET = 10;
constexpr std::array<IRA211Command, 6> ALL_COMMANDS = {
IRA211Command::IRA211_COMMAND_TEMP_UP, IRA211Command::IRA211_COMMAND_TEMP_DOWN, IRA211Command::IRA211_COMMAND_MODE,
IRA211Command::IRA211_COMMAND_FAN, IRA211Command::IRA211_COMMAND_POWER, IRA211Command::IRA211_COMMAND_SYNC,
};
constexpr std::array<IRA211Mode, 3> ALL_MODES = {
IRA211Mode::IRA211_MODE_PROTECTION,
IRA211Mode::IRA211_MODE_TIMER,
IRA211Mode::IRA211_MODE_COMFORT,
};
constexpr std::array<IRA211Fan, 4> ALL_FANS = {
IRA211Fan::IRA211_FAN_AUTO,
IRA211Fan::IRA211_FAN_LOW,
IRA211Fan::IRA211_FAN_MEDIUM,
IRA211Fan::IRA211_FAN_HIGH,
};
constexpr std::array<float, 3> TEMPERATURES = {5.0f, 20.5f, 35.0f};
bool carries_temperature(IRA211Command command) {
return command != IRA211Command::IRA211_COMMAND_MODE && command != IRA211Command::IRA211_COMMAND_FAN;
}
IRA211Data make_data(IRA211Command command, float temperature, IRA211Mode mode, IRA211Fan fan) {
IRA211Data data{};
data.command = command;
data.set_temperature(temperature);
data.mode = mode;
data.fan = fan;
return data;
}
RawTimings encode(const IRA211Data &data) {
RemoteTransmitData transmit;
IRA211Protocol().encode(&transmit, data);
return transmit.get_data();
}
optional<IRA211Data> decode(const RawTimings &timings) {
return IRA211Protocol().decode(RemoteReceiveData(timings, 25, TOLERANCE_MODE_PERCENTAGE));
}
std::string describe(const IRA211Data &data) {
char buf[64];
snprintf(buf, sizeof(buf), "command %d temperature %.1f mode %d fan %d", int(data.command), data.get_temperature(),
int(data.mode), int(data.fan));
return buf;
}
void expect_round_trip(const RawTimings &timings, const IRA211Data &expected) {
auto decoded = decode(timings);
ASSERT_TRUE(decoded.has_value()) << describe(expected);
// clang-tidy's unchecked-optional-access models neither gtest's ASSERT_TRUE nor value() as a check
if (decoded.has_value()) {
EXPECT_TRUE(*decoded == expected) << describe(expected) << " decoded as " << describe(*decoded);
if (carries_temperature(expected.command))
EXPECT_EQ(decoded->get_temperature(), expected.get_temperature()) << describe(expected);
}
}
// Index of the first run of packet `packet`, and of the run after its last one. Packets start with a 1 bit
// after a 0 end bit and end with a 0 bit before a 1 start bit, so no run crosses a packet boundary.
std::pair<size_t, size_t> packet_run_range(const RawTimings &timings, size_t packet) {
size_t begin = 0, end = 0, bits = 0;
for (size_t i = PREAMBLE_ITEMS; i < timings.size(); i++) {
if (bits == packet * BITS_PER_PACKET)
begin = i;
if (bits == (packet + 1) * BITS_PER_PACKET) {
end = i;
break;
}
bits += (std::abs(timings[i]) + T_US / 2) / T_US;
}
return {begin, end};
}
} // namespace
TEST(IRA211ProtocolTest, RoundTripsEveryCommandTemperatureModeAndFan) {
for (auto command : ALL_COMMANDS) {
for (auto temperature : TEMPERATURES) {
for (auto mode : ALL_MODES) {
for (auto fan : ALL_FANS) {
const IRA211Data data = make_data(command, temperature, mode, fan);
expect_round_trip(encode(data), data);
}
}
}
}
}
TEST(IRA211ProtocolTest, EveryFrameEndsWithTheTrailingMarkAndGap) {
for (auto command : ALL_COMMANDS) {
const RawTimings timings =
encode(make_data(command, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_AUTO));
ASSERT_GE(timings.size(), PREAMBLE_ITEMS + 2) << "command " << int(command);
EXPECT_EQ(timings[timings.size() - 2], int32_t(T_US)) << "command " << int(command);
EXPECT_EQ(timings.back(), -int32_t(HEADER_US)) << "command " << int(command);
}
}
TEST(IRA211ProtocolTest, SetTemperatureClampsAndRoundsToHalfDegrees) {
const struct {
float input;
float expected;
} cases[] = {
{4.0f, 5.0f}, {36.0f, 35.0f}, {22.26f, 22.5f}, {22.24f, 22.0f},
{-1.0f, 5.0f}, {140.0f, 35.0f}, {5.0f, 5.0f}, {35.0f, 35.0f},
};
for (const auto &c : cases) {
IRA211Data data{};
data.set_temperature(c.input);
EXPECT_EQ(data.get_temperature(), c.expected) << "input " << c.input;
}
}
// A receiver that stops capturing at the frame gap never sees the trailing mark nor the gap after it.
TEST(IRA211ProtocolTest, DecodesWithoutTheTrailingMarkAndGap) {
for (auto command : ALL_COMMANDS) {
for (auto fan : ALL_FANS) {
const IRA211Data data = make_data(command, 35.0f, IRA211Mode::IRA211_MODE_TIMER, fan);
RawTimings timings = encode(data);
timings.pop_back(); // 7600 us gap
timings.pop_back(); // 800 us trailing mark
expect_round_trip(timings, data);
}
}
}
// Move one bit between two neighbouring runs inside the checksum packet: the bit count and the framing bits
// stay valid, so only the checksum byte changes.
TEST(IRA211ProtocolTest, RejectsACorruptedChecksum) {
for (auto command : ALL_COMMANDS) {
const IRA211Data data = make_data(command, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_LOW);
RawTimings timings = encode(data);
ASSERT_TRUE(decode(timings).has_value()) << describe(data);
size_t bits = 0;
for (size_t i = PREAMBLE_ITEMS; i + 2 < timings.size(); i++)
bits += (std::abs(timings[i]) + T_US / 2) / T_US;
ASSERT_EQ(bits % BITS_PER_PACKET, 0u) << describe(data);
const auto [begin, end] = packet_run_range(timings, bits / BITS_PER_PACKET - 1);
ASSERT_GT(end, begin) << describe(data);
// Shrink the first run that is at least two bits long and grow its neighbour; a run that keeps one bit
// keeps the start or end bit it may hold.
bool corrupted = false;
for (size_t i = begin; i < end && !corrupted; i++) {
if (std::abs(timings[i]) < int32_t(2 * T_US))
continue;
const size_t neighbour = i + 1 < end ? i + 1 : i - 1;
timings[i] -= timings[i] > 0 ? int32_t(T_US) : -int32_t(T_US);
timings[neighbour] += timings[neighbour] > 0 ? int32_t(T_US) : -int32_t(T_US);
corrupted = true;
}
ASSERT_TRUE(corrupted) << describe(data) << ": checksum packet has no run longer than one bit";
EXPECT_FALSE(decode(timings).has_value()) << describe(data);
}
}
// A SYNC frame carries seven packets; dropping a middle one leaves a well formed six packet frame whose
// command still claims seven, so it must be rejected.
TEST(IRA211ProtocolTest, RejectsAFrameOfTheWrongLengthForItsCommand) {
const IRA211Data data =
make_data(IRA211Command::IRA211_COMMAND_SYNC, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_HIGH);
RawTimings timings = encode(data);
ASSERT_TRUE(decode(timings).has_value());
const auto [begin, end] = packet_run_range(timings, 3); // the tenths byte
ASSERT_GT(begin, PREAMBLE_ITEMS - 1);
ASSERT_GT(end, begin);
timings.erase(timings.begin() + begin, timings.begin() + end);
EXPECT_FALSE(decode(timings).has_value());
}
// transmit_state() sends POWER then SYNC back to back, so a receiver with a long idle sees both in one capture.
// The gap may be captured a little short; it must still end the first frame.
TEST(IRA211ProtocolTest, DecodesTheFirstOfTwoFramesWithAShortenedGap) {
const IRA211Data power =
make_data(IRA211Command::IRA211_COMMAND_POWER, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_LOW);
const IRA211Data sync =
make_data(IRA211Command::IRA211_COMMAND_SYNC, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_LOW);
RawTimings timings = encode(power);
timings.back() = -int32_t(HEADER_US - 100);
const RawTimings second = encode(sync);
timings.insert(timings.end(), second.begin(), second.end());
expect_round_trip(timings, power);
}
// Values are encoded as given, so a frame with an out-of-range field still gets a valid checksum.
TEST(IRA211ProtocolTest, RejectsAnOutOfRangeTemperature) {
for (uint8_t half_degrees : {uint8_t(9), uint8_t(71), uint8_t(72), uint8_t(255)}) {
IRA211Data data = make_data(IRA211Command::IRA211_COMMAND_SYNC, 20.5f, IRA211Mode::IRA211_MODE_COMFORT,
IRA211Fan::IRA211_FAN_AUTO);
data.half_degrees = half_degrees;
EXPECT_FALSE(decode(encode(data)).has_value()) << "half degrees " << int(half_degrees);
}
}
TEST(IRA211ProtocolTest, RejectsAnUnknownModeOrFan) {
IRA211Data mode =
make_data(IRA211Command::IRA211_COMMAND_MODE, 20.5f, static_cast<IRA211Mode>(1), IRA211Fan::IRA211_FAN_AUTO);
EXPECT_FALSE(decode(encode(mode)).has_value());
IRA211Data fan =
make_data(IRA211Command::IRA211_COMMAND_SYNC, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, static_cast<IRA211Fan>(1));
EXPECT_FALSE(decode(encode(fan)).has_value());
}
TEST(IRA211ProtocolTest, RejectsAForeignFrame) {
const RawTimings nec_like = {9000, -4500, 560, -560, 560, -1690, 560, -560, 560};
EXPECT_FALSE(decode(nec_like).has_value());
EXPECT_FALSE(decode({}).has_value());
}
TEST(IRA211ProtocolTest, EqualityIgnoresFieldsTheCommandDoesNotCarry) {
const IRA211Data temp_up_auto = make_data(IRA211Command::IRA211_COMMAND_TEMP_UP, 20.5f,
IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_AUTO);
const IRA211Data temp_up_high = make_data(IRA211Command::IRA211_COMMAND_TEMP_UP, 20.5f, IRA211Mode::IRA211_MODE_TIMER,
IRA211Fan::IRA211_FAN_HIGH);
EXPECT_TRUE(temp_up_auto == temp_up_high);
const IRA211Data temp_down = make_data(IRA211Command::IRA211_COMMAND_TEMP_DOWN, 20.5f,
IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_AUTO);
EXPECT_FALSE(temp_up_auto == temp_down);
const IRA211Data temp_up_colder = make_data(IRA211Command::IRA211_COMMAND_TEMP_UP, 20.0f,
IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_AUTO);
EXPECT_FALSE(temp_up_auto == temp_up_colder);
const IRA211Data mode_low =
make_data(IRA211Command::IRA211_COMMAND_MODE, 5.0f, IRA211Mode::IRA211_MODE_TIMER, IRA211Fan::IRA211_FAN_LOW);
const IRA211Data mode_high =
make_data(IRA211Command::IRA211_COMMAND_MODE, 35.0f, IRA211Mode::IRA211_MODE_TIMER, IRA211Fan::IRA211_FAN_HIGH);
EXPECT_TRUE(mode_low == mode_high);
const IRA211Data sync_auto =
make_data(IRA211Command::IRA211_COMMAND_SYNC, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_AUTO);
const IRA211Data sync_high =
make_data(IRA211Command::IRA211_COMMAND_SYNC, 20.5f, IRA211Mode::IRA211_MODE_COMFORT, IRA211Fan::IRA211_FAN_HIGH);
EXPECT_FALSE(sync_auto == sync_high);
}
} // namespace esphome::remote_base::testing
@@ -192,3 +192,8 @@ on_hob2hood:
- logger.log:
format: "on_hob2hood: %u"
args: ["x.command"]
on_ira211:
then:
- logger.log:
format: "on_ira211: %u %.1f"
args: ["static_cast<uint8_t>(x.command)", "x.get_temperature()"]
@@ -43,6 +43,13 @@ binary_sensor:
name: Hob2Hood Light On
hob2hood:
command: light_on
- platform: remote_receiver
name: IRA211 Sync
ira211:
command: sync
temperature: 21.5
mode: comfort
fan_mode: low
- platform: remote_receiver
name: MagiQuest Wand
magiquest:
@@ -92,6 +92,14 @@ button:
- remote_transmitter.transmit_toshiba_ac:
rc_code_1: 0xB24DBF4050AF
rc_code_2: 0xD5660001003C
- platform: template
name: IRA211
on_press:
- remote_transmitter.transmit_ira211:
command: sync
temperature: 22.5
mode: timer
fan_mode: high
- platform: template
name: Sony
on_press:
@@ -0,0 +1,5 @@
climate:
- platform: siemens_ira211
name: Siemens IRA211
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
siemens_ira211: !include common.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
siemens_ira211: !include common.yaml