feat: Add Argo Ulisse A/C IR remote

tested on Argo Ulisse 13 DCI ECO (WREM-3 remote)
This commit is contained in:
yoziru 2024-08-04 20:59:54 +02:00
parent 61c6581123
commit 743e487dd4
4 changed files with 461 additions and 0 deletions

View file

@ -0,0 +1 @@
CODEOWNERS = ["@yoziru"]

View file

@ -0,0 +1,239 @@
#include "argo_ulisse.h"
#include <cmath> // 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<float>(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<float>(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

View file

@ -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

View file

@ -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)