diff --git a/CODEOWNERS b/CODEOWNERS index 7e263a4514..db9dcccd7f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/esphome/components/remote_base/__init__.py b/esphome/components/remote_base/__init__.py index e347ee96ff..9cf9493cd0 100644 --- a/esphome/components/remote_base/__init__.py +++ b/esphome/components/remote_base/__init__.py @@ -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), +) diff --git a/esphome/components/remote_base/ira211_protocol.cpp b/esphome/components/remote_base/ira211_protocol.cpp new file mode 100644 index 0000000000..b9cd6ec886 --- /dev/null +++ b/esphome/components/remote_base/ira211_protocol.cpp @@ -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(IRA211_MIN_TEMPERATURE * 2); +static constexpr uint8_t MAX_HALF_DEGREES = static_cast(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 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(~value)); } + +static bool is_known_mode(uint8_t value) { + switch (static_cast(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(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 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 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(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(data.mode)); + if (layout->fields & FIELD_FAN) + bytes[count++] = wire_xform(static_cast(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 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(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(wire_xform(bytes[1])); + const optional 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(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(mode); + } + if (layout->fields & FIELD_FAN) { + const uint8_t fan = wire_xform(bytes[index++]); + if (!is_known_fan(fan)) + return {}; + out.fan = static_cast(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 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 diff --git a/esphome/components/remote_base/ira211_protocol.h b/esphome/components/remote_base/ira211_protocol.h new file mode 100644 index 0000000000..205d09215a --- /dev/null +++ b/esphome/components/remote_base/ira211_protocol.h @@ -0,0 +1,65 @@ +#pragma once + +#include "remote_base.h" +#include "esphome/core/helpers.h" + +#include + +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(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 { + public: + void encode(RemoteTransmitData *dst, const IRA211Data &data); + optional decode(RemoteReceiveData src); + void dump(const IRA211Data &data); +}; + +DECLARE_REMOTE_PROTOCOL(IRA211) + +} // namespace esphome::remote_base diff --git a/esphome/components/siemens_ira211/__init__.py b/esphome/components/siemens_ira211/__init__.py new file mode 100644 index 0000000000..1407aff81e --- /dev/null +++ b/esphome/components/siemens_ira211/__init__.py @@ -0,0 +1,2 @@ +CODEOWNERS = ["@fonix232"] +DOMAIN = "siemens_ira211" diff --git a/esphome/components/siemens_ira211/climate.py b/esphome/components/siemens_ira211/climate.py new file mode 100644 index 0000000000..16345e70f1 --- /dev/null +++ b/esphome/components/siemens_ira211/climate.py @@ -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) diff --git a/esphome/components/siemens_ira211/siemens_ira211.cpp b/esphome/components/siemens_ira211/siemens_ira211.cpp new file mode 100644 index 0000000000..e332134eac --- /dev/null +++ b/esphome/components/siemens_ira211/siemens_ira211.cpp @@ -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 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_(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 diff --git a/esphome/components/siemens_ira211/siemens_ira211.h b/esphome/components/siemens_ira211/siemens_ira211.h new file mode 100644 index 0000000000..dcba2ddcb9 --- /dev/null +++ b/esphome/components/siemens_ira211/siemens_ira211.h @@ -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 diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 628435a4d8..3168d269cf 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -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 diff --git a/tests/component_tests/remote_transmitter/config/ira211_action.yaml b/tests/component_tests/remote_transmitter/config/ira211_action.yaml new file mode 100644 index 0000000000..3cba9245ab --- /dev/null +++ b/tests/component_tests/remote_transmitter/config/ira211_action.yaml @@ -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; diff --git a/tests/component_tests/remote_transmitter/test_ira211_action.py b/tests/component_tests/remote_transmitter/test_ira211_action.py new file mode 100644 index 0000000000..2d202998f1 --- /dev/null +++ b/tests/component_tests/remote_transmitter/test_ira211_action.py @@ -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(21.0f));" in lambda_ diff --git a/tests/components/remote_base/__init__.py b/tests/components/remote_base/__init__.py index 2fe959bb16..f104557265 100644 --- a/tests/components/remote_base/__init__.py +++ b/tests/components/remote_base/__init__.py @@ -14,6 +14,7 @@ def override_manifest(manifest: ComponentManifestOverride) -> None: "aeha", "keeloq", "nexus", + "ira211", "nec", "magiquest", "rc5x", diff --git a/tests/components/remote_base/ira211_test.cpp b/tests/components/remote_base/ira211_test.cpp new file mode 100644 index 0000000000..f23d68640d --- /dev/null +++ b/tests/components/remote_base/ira211_test.cpp @@ -0,0 +1,254 @@ +#include +#include "esphome/components/remote_base/ira211_protocol.h" + +#include +#include +#include +#include +#include + +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 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 ALL_MODES = { + IRA211Mode::IRA211_MODE_PROTECTION, + IRA211Mode::IRA211_MODE_TIMER, + IRA211Mode::IRA211_MODE_COMFORT, +}; +constexpr std::array ALL_FANS = { + IRA211Fan::IRA211_FAN_AUTO, + IRA211Fan::IRA211_FAN_LOW, + IRA211Fan::IRA211_FAN_MEDIUM, + IRA211Fan::IRA211_FAN_HIGH, +}; +constexpr std::array 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 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 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(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(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 diff --git a/tests/components/remote_receiver/common-actions.yaml b/tests/components/remote_receiver/common-actions.yaml index cd01690344..6aa7ae37bb 100644 --- a/tests/components/remote_receiver/common-actions.yaml +++ b/tests/components/remote_receiver/common-actions.yaml @@ -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(x.command)", "x.get_temperature()"] diff --git a/tests/components/remote_receiver/esp32-common.yaml b/tests/components/remote_receiver/esp32-common.yaml index d2b135f034..5062319040 100644 --- a/tests/components/remote_receiver/esp32-common.yaml +++ b/tests/components/remote_receiver/esp32-common.yaml @@ -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: diff --git a/tests/components/remote_transmitter/common-buttons.yaml b/tests/components/remote_transmitter/common-buttons.yaml index dff4e8f9c5..b3583ffe12 100644 --- a/tests/components/remote_transmitter/common-buttons.yaml +++ b/tests/components/remote_transmitter/common-buttons.yaml @@ -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: diff --git a/tests/components/siemens_ira211/common.yaml b/tests/components/siemens_ira211/common.yaml new file mode 100644 index 0000000000..fc2c53f967 --- /dev/null +++ b/tests/components/siemens_ira211/common.yaml @@ -0,0 +1,5 @@ +climate: + - platform: siemens_ira211 + name: Siemens IRA211 + transmitter_id: xmitr + receiver_id: rcvr diff --git a/tests/components/siemens_ira211/test.esp32-idf.yaml b/tests/components/siemens_ira211/test.esp32-idf.yaml new file mode 100644 index 0000000000..b217e0f0d9 --- /dev/null +++ b/tests/components/siemens_ira211/test.esp32-idf.yaml @@ -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 diff --git a/tests/components/siemens_ira211/test.esp8266-ard.yaml b/tests/components/siemens_ira211/test.esp8266-ard.yaml new file mode 100644 index 0000000000..b5b3d45c10 --- /dev/null +++ b/tests/components/siemens_ira211/test.esp8266-ard.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