mirror of
https://github.com/esphome/esphome.git
synced 2026-10-14 02:02:51 +00:00
[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:
co-authored by
J. Nick Koston
J. Nick Koston
parent
66ba258baa
commit
5a3bee638f
@@ -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
|
||||
@@ -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_
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user