From 743e487dd45ba13227277318caadc034d7e91dcd Mon Sep 17 00:00:00 2001 From: yoziru Date: Sun, 4 Aug 2024 20:59:54 +0200 Subject: [PATCH] feat: Add Argo Ulisse A/C IR remote tested on Argo Ulisse 13 DCI ECO (WREM-3 remote) --- esphome/components/argo_ulisse/__init__.py | 1 + .../components/argo_ulisse/argo_ulisse.cpp | 239 ++++++++++++++++++ esphome/components/argo_ulisse/argo_ulisse.h | 203 +++++++++++++++ esphome/components/argo_ulisse/climate.py | 18 ++ 4 files changed, 461 insertions(+) create mode 100644 esphome/components/argo_ulisse/__init__.py create mode 100644 esphome/components/argo_ulisse/argo_ulisse.cpp create mode 100644 esphome/components/argo_ulisse/argo_ulisse.h create mode 100644 esphome/components/argo_ulisse/climate.py diff --git a/esphome/components/argo_ulisse/__init__.py b/esphome/components/argo_ulisse/__init__.py new file mode 100644 index 0000000000..515931469b --- /dev/null +++ b/esphome/components/argo_ulisse/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@yoziru"] diff --git a/esphome/components/argo_ulisse/argo_ulisse.cpp b/esphome/components/argo_ulisse/argo_ulisse.cpp new file mode 100644 index 0000000000..f1177f386d --- /dev/null +++ b/esphome/components/argo_ulisse/argo_ulisse.cpp @@ -0,0 +1,239 @@ +#include "argo_ulisse.h" + +#include // std::isnan + +#include "esphome/components/remote_base/remote_base.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace argo_ulisse { + +static const char *const TAG = "argo.climate"; + +void ArgoUlisseClimate::setup() { + climate_ir::ClimateIR::setup(); + + // Never send nan to HA + if (std::isnan(this->current_temperature)) + this->current_temperature = 0; + + // add a callback to handle the iFeel feature + ESP_LOGCONFIG(TAG, "Setting up iFeel callback.."); + this->add_on_state_callback([this](climate::Climate &climate) { + ESP_LOGV(TAG, "Received state callback for iFeel report.."); + if (this->ifeel_) { + this->transmit_ifeel_(); + } + }); +} + +uint8_t ArgoUlisseClimate::calc_checksum_(const ArgoProtocolWREM3 *data, size_t length) { + ESP_LOGV(TAG, "Calculating checksum.."); + if (length < 1) { + ESP_LOGW(TAG, "Nothing to calculate checksum on"); + return 0; // Changed from -1 to 0 as the return type is uint8_t + } + + size_t payloadSizeBits = (length - 1) * 8; // Last byte carries checksum + + uint8_t checksum = 0; // Initialize checksum + const uint8_t *ptr = data->raw; // Point to the start of the raw data + + // Calculate checksum for all bytes except the last one + for (size_t i = 0; i < length - 1; i++) { + checksum += ptr[i]; + } + + // Add stray bits from last byte to the checksum (if any) + const uint8_t maskPayload = 0xFF >> (8 - (payloadSizeBits % 8)); + checksum += (ptr[length - 1] & maskPayload); + + const uint8_t maskChecksum = 0xFF >> (payloadSizeBits % 8); + return checksum & maskChecksum; +} + +void ArgoUlisseClimate::transmit_state() { + ESP_LOGD(TAG, "Transmitting state.."); + this->last_transmit_time_ = millis(); + ArgoProtocolWREM3 ac_packet; + memset(&ac_packet, 0, sizeof(ac_packet)); + + // Set up the header + ac_packet.Pre1 = ARGO_PREAMBLE; + ac_packet.IrChannel = 0; // Assume channel 0, adjust if needed + ac_packet.IrCommandType = ArgoIRMessageType_AC_CONTROL; + + // Set default HA clima features + ESP_LOGV(TAG, " Setting default HA climate features.."); + ac_packet.Mode = this->operation_mode_(); // Set mode + ac_packet.RoomTemp = this->sensor_temperature_(); // for iFeel + ac_packet.Temp = this->temperature_(); // target temperature + ac_packet.Fan = this->fan_speed_(); + ac_packet.Flap = this->swing_mode_(); + + // Set other features (adjust as needed) + ESP_LOGV(TAG, " Setting up other features.."); + ac_packet.Power = this->mode != climate::CLIMATE_MODE_OFF; + ac_packet.iFeel = this->ifeel_; + ac_packet.Night = this->preset == climate::CLIMATE_PRESET_SLEEP; + ac_packet.Eco = this->preset == climate::CLIMATE_PRESET_ECO; + ac_packet.Boost = this->preset == climate::CLIMATE_PRESET_BOOST; + ac_packet.Filter = this->filter_; + ac_packet.Light = + this->light_ && this->preset != climate::CLIMATE_PRESET_SLEEP && this->mode != climate::CLIMATE_MODE_OFF; + + ESP_LOGD(TAG, " iFeel: %s", ac_packet.iFeel ? "true" : "false"); + ESP_LOGD(TAG, " Light: %s", ac_packet.Light ? "true" : "false"); + ESP_LOGD(TAG, " Filter: %s", ac_packet.Filter ? "true" : "false"); + + ac_packet.Post1 = ARGO_POST1; + + // Calculate checksum + ESP_LOGV(TAG, " Calculating AC checksum.."); + ac_packet.Sum = this->calc_checksum_(&ac_packet, ArgoIRMessageLength_AC_CONTROL); + + // Transmit the IR signal + auto transmit = this->transmitter_->transmit(); + auto *data = transmit.get_data(); + data->set_carrier_frequency(ARGO_IR_FREQUENCY); // Adjust if the frequency is different + + // Send the header + data->mark(ARGO_HEADER_MARK); + data->space(ARGO_HEADER_SPACE); + + // Transmit the data + for (uint8_t i = 0; i < ArgoIRMessageLength_AC_CONTROL; i++) { + for (uint8_t mask = 1; mask > 0; mask <<= 1) { + data->mark(ARGO_BIT_MARK); + bool bit = ac_packet.raw[i] & mask; + data->space(bit ? ARGO_ONE_SPACE : ARGO_ZERO_SPACE); + } + } + data->mark(ARGO_BIT_MARK); + data->space(0); + + transmit.perform(); +} + +void ArgoUlisseClimate::transmit_ifeel_() { + // Send current room temperature for the iFeel feature as a silent IR + // message (no acknowledgement from the device) (WREM3) + ESP_LOGD(TAG, "Transmitting iFeel report.."); + this->last_transmit_time_ = millis(); + + ArgoProtocolWREM3 ifeel_packet; + + // Set up the header + ifeel_packet.Pre1 = ARGO_PREAMBLE; + ifeel_packet.IrChannel = 0; // Assume channel 0, adjust if needed + ifeel_packet.IrCommandType = ArgoIRMessageType_IFEEL_TEMP_REPORT; + + ifeel_packet.ifeel.SensorT = this->sensor_temperature_(); + + // Calculate checksum + ESP_LOGV(TAG, " Calculating iFeel checksum.."); + ifeel_packet.ifeel.CheckHi = this->calc_checksum_(&ifeel_packet, ArgoIRMessageLength_IFEEL_TEMP_REPORT); + + // Transmit the IR signal + auto transmit = this->transmitter_->transmit(); + auto *data = transmit.get_data(); + data->set_carrier_frequency(ARGO_IR_FREQUENCY); // Adjust if the frequency is different + + // Send the header + data->mark(ARGO_HEADER_MARK); + data->space(ARGO_HEADER_SPACE); + + // Transmit the data + for (uint8_t i = 0; i < ArgoIRMessageLength_IFEEL_TEMP_REPORT; i++) { + for (uint8_t mask = 1; mask > 0; mask <<= 1) { + data->mark(ARGO_BIT_MARK); + bool bit = ifeel_packet.raw[i] & mask; + data->space(bit ? ARGO_ONE_SPACE : ARGO_ZERO_SPACE); + } + } + data->mark(ARGO_BIT_MARK); + data->space(0); + + transmit.perform(); +} + +uint8_t ArgoUlisseClimate::operation_mode_() { + switch (this->mode) { + case climate::CLIMATE_MODE_COOL: + return ARGO_MODE_COOL; + case climate::CLIMATE_MODE_DRY: + return ARGO_MODE_DRY; + case climate::CLIMATE_MODE_HEAT: + return ARGO_MODE_HEAT; + case climate::CLIMATE_MODE_HEAT_COOL: + return ARGO_MODE_AUTO; + case climate::CLIMATE_MODE_FAN_ONLY: + return ARGO_MODE_FAN; + case climate::CLIMATE_MODE_OFF: + default: + return ARGO_MODE_OFF; + } +} + +uint8_t ArgoUlisseClimate::fan_speed_() { + switch (this->fan_mode.value()) { + case climate::CLIMATE_FAN_LOW: + return ARGO_FAN_SILENT; + case climate::CLIMATE_FAN_MEDIUM: + return ARGO_FAN_MEDIUM; + case climate::CLIMATE_FAN_HIGH: + return ARGO_FAN_HIGHEST; + case climate::CLIMATE_FAN_AUTO: + default: + return ARGO_FAN_AUTO; + } +} + +uint8_t ArgoUlisseClimate::swing_mode_() { + switch (this->swing_mode) { + case climate::CLIMATE_SWING_VERTICAL: + return ARGO_FLAP_FULL_AUTO; + case climate::CLIMATE_SWING_HORIZONTAL: + return ARGO_FLAP_HALF_AUTO; // hacky way to set half auto vertical swing + default: + return ARGO_FLAP_MIDDLE_HIGH; + } +} + +uint8_t ArgoUlisseClimate::temperature_() { + // Clamp the temperature to the valid range + float temp_valid = + clamp(this->target_temperature, ARGO_TEMP_MIN - ARGO_TEMP_OFFSET, ARGO_TEMP_MAX - ARGO_TEMP_OFFSET); + return process_temperature_(temp_valid); +} + +uint8_t ArgoUlisseClimate::sensor_temperature_() { + float temp_valid = clamp(this->current_temperature, ARGO_SENSOR_TEMP_MIN - ARGO_TEMP_OFFSET, + ARGO_SENSOR_TEMP_MAX - ARGO_TEMP_OFFSET); + return process_temperature_(temp_valid); +} + +uint8_t ArgoUlisseClimate::process_temperature_(float temperature) { + ESP_LOGV(TAG, "Processing temperature.."); + ESP_LOGV(TAG, " Input Temperature: %.2f°C", temperature); + + // Sending 0 equals +4, e.g. "If I want 12 degrees, I need to send 8" + temperature -= ARGO_TEMP_OFFSET; + ESP_LOGV(TAG, " Delta Temperature: %.2f°C", temperature); + + // floor the temperature + uint8_t temp = (uint8_t) round(temperature); + ESP_LOGV(TAG, " Processed Temperature: %d°C", temp); + return temp; +} + +climate::ClimateTraits ArgoUlisseClimate::traits() { + climate::ClimateTraits traits = climate_ir::ClimateIR::traits(); + traits.set_supports_current_temperature(true); + return traits; +} + +void ArgoUlisseClimate::control(const climate::ClimateCall &call) { climate_ir::ClimateIR::control(call); } + +} // namespace argo_ulisse +} // namespace esphome diff --git a/esphome/components/argo_ulisse/argo_ulisse.h b/esphome/components/argo_ulisse/argo_ulisse.h new file mode 100644 index 0000000000..07f40da79a --- /dev/null +++ b/esphome/components/argo_ulisse/argo_ulisse.h @@ -0,0 +1,203 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" + +namespace esphome { +namespace argo_ulisse { + +// Values for Argo Ulisse 13 DCI Eco (WREM-3 remote) IR Controllers +// Originally reverse-engineered by @mbronk +// Temperature +const uint8_t ARGO_TEMP_MIN = 10; // Celsius +const uint8_t ARGO_TEMP_MAX = 32; // Celsius +const uint8_t ARGO_SENSOR_TEMP_MIN = 0; // Celsius +const uint8_t ARGO_SENSOR_TEMP_MAX = 35; // Celsius +const uint8_t ARGO_TEMP_OFFSET = 4; // Celsius + +// Modes +const uint8_t ARGO_MODE_OFF = 0x00; +const uint8_t ARGO_MODE_ON = 0x01; +const uint8_t ARGO_MODE_COOL = 0x01; +const uint8_t ARGO_MODE_DRY = 0x02; +const uint8_t ARGO_MODE_HEAT = 0x03; +const uint8_t ARGO_MODE_FAN = 0x04; +const uint8_t ARGO_MODE_AUTO = 0x05; + +// Fan Speed +const uint8_t ARGO_FAN_AUTO = 0x00; +const uint8_t ARGO_FAN_SILENT = 0x01; // lowest +const uint8_t ARGO_FAN_LOWEST = 0x02; +const uint8_t ARGO_FAN_LOW = 0x03; +const uint8_t ARGO_FAN_MEDIUM = 0x04; +const uint8_t ARGO_FAN_HIGH = 0x05; +const uint8_t ARGO_FAN_HIGHEST = 0x06; // highest + +// Flap position (swing modes) +const uint8_t ARGO_FLAP_HALF_AUTO = 0x00; +const uint8_t ARGO_FLAP_HIGHEST = 0x01; +const uint8_t ARGO_FLAP_HIGH = 0x02; +const uint8_t ARGO_FLAP_MIDDLE_HIGH = 0x03; +const uint8_t ARGO_FLAP_MIDDLE_LOW = 0x04; +const uint8_t ARGO_FLAP_LOW = 0x05; +const uint8_t ARGO_FLAP_LOWEST = 0x06; +const uint8_t ARGO_FLAP_FULL_AUTO = 0x07; + +// IR Transmission +const uint32_t ARGO_IR_FREQUENCY = 38000; + +const uint32_t ARGO_HEADER_MARK = 6400; // 00F7 = 247 * 26.3 = 6496.1 µs +const uint32_t ARGO_HEADER_SPACE = 3200; // 007C = 124 * 26.3 = 3261.2 µs +const uint32_t ARGO_BIT_MARK = 400; // 0010 = 16 * 26.3 = 420.8 µs +const uint32_t ARGO_ONE_SPACE = 2200; // 0054 = 84 * 26.3 = 2209.2 µs +const uint32_t ARGO_ZERO_SPACE = 900; // 0022 = 34 * 26.3 = 894.2 µs + +const uint8_t ARGO_PREAMBLE = 0xB; // 0b1011 +const uint8_t ARGO_POST1 = 0x30; // unknown, always 0b110000 (TempScale?) +const size_t ARGO_STATE_LENGTH = 12; + +// Native representation of A/C IR message for WREM-3 remote +#pragma pack(push, 1) // Add a packing directive to ensure the structure is tightly packed: +union ArgoProtocolWREM3 { + uint8_t raw[ARGO_STATE_LENGTH]; ///< The state in native IR code form + struct { + // Byte 0 (same definition across the union) + uint8_t Pre1 : 4; /// Preamble: 0b1011 @ref ARGO_PREAMBLE + uint8_t IrChannel : 2; /// 0..3 range + uint8_t IrCommandType : 2; /// @ref argoIrMessageType_t + // Byte 1 + uint8_t RoomTemp : 5; // in Celsius, range: 4..35 (offset by -4[*C]) + uint8_t Mode : 3; /// @ref argoMode_t + // Byte 2 + uint8_t Temp : 5; // in Celsius, range: 10..32 (offset by -4[*C]) + uint8_t Fan : 3; /// @ref argoFan_t + // Byte3 + uint8_t Flap : 3; /// SwingV @ref argoFlap_t + uint8_t Power : 1; // boost mode + uint8_t iFeel : 1; + uint8_t Night : 1; + uint8_t Eco : 1; + uint8_t Boost : 1; ///< a.k.a. Turbo + // Byte4 + uint8_t Filter : 1; + uint8_t Light : 1; + uint8_t Post1 : 6; /// Unknown, always 0b110000 (TempScale?) + // Byte5 + uint8_t Sum : 8; /// Checksum + }; + struct iFeel { + // Byte 0 (same definition across the union) + uint8_t : 8; // {Pre1 | IrChannel | IrCommandType} + // Byte 1 + uint8_t SensorT : 5; // in Celsius, range: 4..35 (offset by -4[*C]) + uint8_t CheckHi : 3; // Checksum (short) + } ifeel; + struct Timer { + // Byte 0 (same definition across the union) + uint8_t : 8; // {Pre1 | IrChannel | IrCommandType} + // Byte 1 + uint8_t IsOn : 1; + uint8_t TimerType : 3; + uint8_t CurrentTimeLo : 4; + // Byte 2 + uint8_t CurrentTimeHi : 7; + uint8_t CurrentWeekdayLo : 1; + // Byte 3 + uint8_t CurrentWeekdayHi : 2; + uint8_t DelayTimeLo : 6; + // Byte 4 + uint8_t DelayTimeHi : 5; + uint8_t TimerStartLo : 3; + // Byte 5 + uint8_t TimerStartHi : 8; + // Byte 6 + uint8_t TimerEndLo : 8; + // Byte 7 + uint8_t TimerEndHi : 3; + uint8_t TimerActiveDaysLo : 5; // Bitmap (LSBit is Sunday) + // Byte 8 + uint8_t TimerActiveDaysHi : 2; // Bitmap (LSBit is Sunday) + uint8_t Post1 : 1; // Unknown, always 1 + uint8_t Checksum : 5; + } timer; + struct Config { + uint8_t : 8; // Byte 0 {Pre1 | IrChannel | IrCommandType} + uint8_t Key : 8; // Byte 1 + uint8_t Value : 8; // Byte 2 + uint8_t Checksum : 8; // Byte 3 + } config; +}; +#pragma pack(pop) + +typedef enum _ArgoIRMessageType { + ArgoIRMessageType_AC_CONTROL = 0, + ArgoIRMessageType_IFEEL_TEMP_REPORT = 1, + ArgoIRMessageType_TIMER_COMMAND = 2, + ArgoIRMessageType_CONFIG_PARAM_SET = 3, +} ArgoIRMessageType; + +// raw byte length depends on message type +typedef enum _ArgoIRMessageLength { + ArgoIRMessageLength_AC_CONTROL = 6, + ArgoIRMessageLength_IFEEL_TEMP_REPORT = 2, + ArgoIRMessageLength_TIMER_COMMAND = 9, + ArgoIRMessageLength_CONFIG_PARAM_SET = 4, +} ArgoIRMessageLength; + +class ArgoUlisseClimate : public climate_ir::ClimateIR { + public: + ArgoUlisseClimate() + : climate_ir::ClimateIR( + ARGO_TEMP_MIN, ARGO_TEMP_MAX, 1.0f, true, true, + {climate::CLIMATE_FAN_AUTO, climate::CLIMATE_FAN_LOW, climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH}, + {climate::CLIMATE_SWING_OFF, climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_VERTICAL}, + { + climate::CLIMATE_PRESET_NONE, + climate::CLIMATE_PRESET_ECO, + climate::CLIMATE_PRESET_BOOST, + climate::CLIMATE_PRESET_SLEEP, + }) {} + + void setup() override; + + // Setters for the additional features (e.g. for template switches) + void set_ifeel(bool ifeel) { + this->ifeel_ = ifeel; + transmit_state(); + } + void set_light(bool light) { + this->light_ = light; + transmit_state(); + } + void set_filter(bool filter) { + this->filter_ = filter; + transmit_state(); + } + + bool get_ifeel() { return this->ifeel_; } + bool get_light() { return this->light_; } + bool get_filter() { return this->filter_; } + + protected: + void control(const climate::ClimateCall &call) override; + // Transmit via IR the state of this climate controller. + int32_t last_transmit_time_{}; + void transmit_state() override; + void transmit_ifeel_(); + uint8_t calc_checksum_(const ArgoProtocolWREM3 *data, size_t size); + climate::ClimateTraits traits() override; + uint8_t operation_mode_(); + uint8_t fan_speed_(); + uint8_t swing_mode_(); + uint8_t temperature_(); + uint8_t sensor_temperature_(); + uint8_t process_temperature_(float temperature); + + // booleans to handle the additional features + bool ifeel_{true}; + bool light_{true}; + bool filter_{true}; +}; + +} // namespace argo_ulisse +} // namespace esphome diff --git a/esphome/components/argo_ulisse/climate.py b/esphome/components/argo_ulisse/climate.py new file mode 100644 index 0000000000..7e3c1467b6 --- /dev/null +++ b/esphome/components/argo_ulisse/climate.py @@ -0,0 +1,18 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import climate_ir +from esphome.const import CONF_ID + +AUTO_LOAD = ["climate_ir"] + +argo_ulisse_ns = cg.esphome_ns.namespace("argo_ulisse") +ArgoUlisseClimate = argo_ulisse_ns.class_("ArgoUlisseClimate", climate_ir.ClimateIR) + +CONFIG_SCHEMA = climate_ir.CLIMATE_IR_WITH_RECEIVER_SCHEMA.extend( + {cv.GenerateID(): cv.declare_id(ArgoUlisseClimate)} +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await climate_ir.register_climate_ir(var, config)