diff --git a/esphome/components/airton/__init__.py b/esphome/components/airton/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/airton/airton.cpp b/esphome/components/airton/airton.cpp new file mode 100644 index 0000000000..834f05545a --- /dev/null +++ b/esphome/components/airton/airton.cpp @@ -0,0 +1,262 @@ +#include "airton.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace airton { + +static const char *const TAG = "airton.climate"; +uint8_t previous_mode = 0; + +void AirtonClimate::transmit_state() { + // Sampled valid state + // Power: On, Mode: 2 (Dry), Fan: 1 (Quiet), Temp: 20C, Swing(V): On, Econo: Off, Turbo: Off, Light: On, Health: On, + // Sleep: Off. 0x74C461041A11D3 + uint8_t remote_state[AIRTON_STATE_FRAME_SIZE] = {0}; + + // Header + remote_state[0] = 0xD3; + remote_state[1] = 0x11; + + remote_state[2] = 0; + remote_state[2] |= this->operation_mode_(); + remote_state[2] |= (this->fan_speed_() << 4); + remote_state[2] |= (this->turbo_control_() << 7); + + remote_state[3] = 0; + remote_state[3] |= this->temperature_(); + + remote_state[4] = 0; + remote_state[4] |= this->swing_mode_(); + + remote_state[5] = this->operation_settings_(); + + remote_state[6] = 0; + remote_state[6] |= this->checksum_(remote_state); + + ESP_LOGV(TAG, "Sending: %02X %02X %02X %02X %02X %02X %02X", remote_state[6], remote_state[5], remote_state[4], + remote_state[3], remote_state[2], remote_state[1], remote_state[0]); + + // Build payload inside 'data' + auto transmit = this->transmitter_->transmit(); + auto *data = transmit.get_data(); + data->set_carrier_frequency(AIRTON_IR_FREQUENCY); + + // Header + data->mark(AIRTON_HEADER_MARK); + data->space(AIRTON_HEADER_SPACE); + + // Data + for (uint8_t payload_byte : remote_state) { + for (uint8_t payload_bit_cursor = 0; payload_bit_cursor < 8; payload_bit_cursor++) { + data->mark(AIRTON_BIT_MARK); + bool bit = payload_byte & (1 << payload_bit_cursor); + data->space(bit ? AIRTON_ONE_SPACE : AIRTON_ZERO_SPACE); + } + } + + // Footer + data->mark(AIRTON_BIT_MARK); + data->space(AIRTON_MESSAGE_SPACE); + + transmit.perform(); +} + +uint8_t AirtonClimate::operation_mode_() { + uint8_t operating_mode = 0b1000; // First bit is for power state + switch (this->mode) { + case climate::CLIMATE_MODE_COOL: + operating_mode |= AIRTON_MODE_COOL; + break; + case climate::CLIMATE_MODE_DRY: + operating_mode |= AIRTON_MODE_DRY; + break; + case climate::CLIMATE_MODE_HEAT: + operating_mode |= AIRTON_MODE_HEAT; + break; + case climate::CLIMATE_MODE_HEAT_COOL: + operating_mode |= AIRTON_MODE_AUTO; + break; + case climate::CLIMATE_MODE_FAN_ONLY: + operating_mode |= AIRTON_MODE_FAN; + break; + case climate::CLIMATE_MODE_OFF: + default: + operating_mode = 0b0111 & this->previous_mode; // Set previous mode with power state bit off + } + this->previous_mode = operating_mode; + return operating_mode; +} + +uint16_t AirtonClimate::fan_speed_() { + uint16_t fan_speed; + switch (this->fan_mode.value()) { + case climate::CLIMATE_FAN_LOW: + fan_speed = AIRTON_FAN_1; + break; + case climate::CLIMATE_FAN_MEDIUM: + fan_speed = AIRTON_FAN_3; + break; + case climate::CLIMATE_FAN_HIGH: + fan_speed = AIRTON_FAN_5; + break; + case climate::CLIMATE_FAN_AUTO: + default: + fan_speed = AIRTON_FAN_AUTO; + } + return fan_speed; +} + +bool AirtonClimate::turbo_control_() { + bool turbo_control = 0; // My remote seems to always have this set to 0 + return turbo_control; +} + +uint8_t AirtonClimate::temperature_() { + // Force 20C degrees in Fan only mode + switch (this->mode) { + case climate::CLIMATE_MODE_HEAT_COOL: + // Fixed 25C setpoint in Auto mode + return 9; + default: + uint8_t temperature = (uint8_t) roundf(clamp(this->target_temperature, AIRTON_TEMP_MIN, AIRTON_TEMP_MAX)); + // Set correct temperature integer, offset by 16 + return temperature - 16; + } +} + +uint8_t AirtonClimate::swing_mode_() { + uint8_t swing_control = 0b01100000; + switch (this->swing_mode) { + case climate::CLIMATE_SWING_VERTICAL: + swing_control |= 1; + break; + default: + break; + } + return swing_control; +} + +// The bits of this packet's byte have the following meanings (from MSB to LSB) +// Light, Health, Unknown, HeatOn, Unknown, NotAutoOn, Sleep, Econo +uint8_t AirtonClimate::operation_settings_() { + uint8_t settings = 0; + if (this->mode == climate::CLIMATE_MODE_HEAT) { // Set heating bit if on the corresponding mode + settings |= (1 << 4); + } + settings |= 0b11000100; // Set Light, Health and NotAutoOn bits as per default + return settings; +} + +// From IRutils.h of IRremoteESP8266 library +uint8_t AirtonClimate::sumBytes_(const uint8_t *const start, const uint16_t length) { + uint8_t checksum = 0; + const uint8_t *ptr; + for (ptr = start; ptr - start < length; ptr++) + checksum += *ptr; + return checksum; +} +// From IRutils.h of IRremoteESP8266 library +uint8_t AirtonClimate::checksum_(const uint8_t *r_state) { + uint8_t checksum = (uint8_t) (0x7F - this->sumBytes_(r_state, 6)) ^ 0x2C; + return checksum; +} + +bool AirtonClimate::parse_state_frame_(const uint8_t frame[]) { + uint8_t mode = frame[2]; + if (mode & 0b00001000) { // Check if power state bit is set + switch (mode & 0b00000111) { // Mask anything but the least significant 3 bits + case AIRTON_MODE_COOL: + this->mode = climate::CLIMATE_MODE_COOL; + break; + case AIRTON_MODE_DRY: + this->mode = climate::CLIMATE_MODE_DRY; + break; + case AIRTON_MODE_HEAT: + this->mode = climate::CLIMATE_MODE_HEAT; + break; + case AIRTON_MODE_AUTO: + this->mode = climate::CLIMATE_MODE_HEAT_COOL; + break; + case AIRTON_MODE_FAN: + this->mode = climate::CLIMATE_MODE_FAN_ONLY; + break; + } + } else { + this->mode = climate::CLIMATE_MODE_OFF; + } + uint8_t temperature = frame[3]; + this->target_temperature = + (temperature & 0b00001111) + 16; // Mask the higher half of the byte (unused), add back the offset + + uint8_t fan_mode = (frame[2] & 0b01110000) >> 4; // Mask anything but bits 5-7, then shift them to the right + + uint8_t swing_mode = frame[4] & 0b00000001; // Mask anything but the LSB + if (swing_mode) { + this->swing_mode = climate::CLIMATE_SWING_VERTICAL; + } else { + this->swing_mode = climate::CLIMATE_SWING_OFF; + } + + switch (fan_mode) { + case AIRTON_FAN_1: + case AIRTON_FAN_2: + this->fan_mode = climate::CLIMATE_FAN_LOW; + break; + case AIRTON_FAN_3: + this->fan_mode = climate::CLIMATE_FAN_MEDIUM; + break; + case AIRTON_FAN_4: + case AIRTON_FAN_5: + this->fan_mode = climate::CLIMATE_FAN_HIGH; + break; + case AIRTON_FAN_AUTO: + this->fan_mode = climate::CLIMATE_FAN_AUTO; + break; + } + + this->publish_state(); + return true; +} + +bool AirtonClimate::on_receive(remote_base::RemoteReceiveData data) { + uint8_t remote_state[AIRTON_STATE_FRAME_SIZE] = {}; + // Check header encoding + if (!data.expect_item(AIRTON_HEADER_MARK, AIRTON_HEADER_SPACE)) { + ESP_LOGV(TAG, "Wrong header encoding detected!"); + return false; + } + + // Build state bytes array from raw data received + for (int i = 0; i < AIRTON_STATE_FRAME_SIZE; i++) { + for (int j = 0; j < 8; j++) { + if (data.expect_item(AIRTON_BIT_MARK, AIRTON_ONE_SPACE)) { + remote_state[i] |= 1 << j; + } else if (!data.expect_item(AIRTON_BIT_MARK, AIRTON_ZERO_SPACE)) { + ESP_LOGV(TAG, "Wrong modulation encoding for: Byte %d, bit %d", i, j); + return false; + } + } + } + + // Check header contents + if (remote_state[0] != 0xD3 || remote_state[1] != 0x11) { + ESP_LOGV(TAG, "Wrong header contents: %02X %02X", remote_state[1], remote_state[0]); + return false; + } + + // Verify received packet checksum + uint8_t checksum = this->checksum_(remote_state); + if (remote_state[AIRTON_STATE_FRAME_SIZE - 1] != checksum) { + ESP_LOGV(TAG, "Checksum error:\ncalculated - %02X\nreceived - %02X", checksum, + remote_state[AIRTON_STATE_FRAME_SIZE - 1]); + return false; + } + + // Parse the payload + ESP_LOGV(TAG, "Received: %02X %02X %02X %02X %02X %02X %02X", remote_state[6], remote_state[5], remote_state[4], + remote_state[3], remote_state[2], remote_state[1], remote_state[0]); + return this->parse_state_frame_(remote_state); +} + +} // namespace airton +} // namespace esphome diff --git a/esphome/components/airton/airton.h b/esphome/components/airton/airton.h new file mode 100644 index 0000000000..1942cc54f1 --- /dev/null +++ b/esphome/components/airton/airton.h @@ -0,0 +1,71 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" + +namespace esphome { +namespace airton { + +// Values for Airton SMVH09B-2A2A3NH IR Controllers +// Temperature +const uint8_t AIRTON_TEMP_MIN = 16; // Celsius +const uint8_t AIRTON_TEMP_MAX = 31; // Celsius + +// Modes +const uint8_t AIRTON_MODE_AUTO = 0b000; +const uint8_t AIRTON_MODE_COOL = 0b001; +const uint8_t AIRTON_MODE_HEAT = 0b100; +const uint8_t AIRTON_MODE_DRY = 0b010; +const uint8_t AIRTON_MODE_FAN = 0b011; + +// Fan Speed +const uint8_t AIRTON_FAN_AUTO = 0b000; +const uint8_t AIRTON_FAN_1 = 0b001; +const uint8_t AIRTON_FAN_2 = 0b010; +const uint8_t AIRTON_FAN_3 = 0b011; +const uint8_t AIRTON_FAN_4 = 0b100; +const uint8_t AIRTON_FAN_5 = 0b101; + +// IR Transmission +const uint32_t AIRTON_IR_FREQUENCY = 38000; +const uint32_t AIRTON_HEADER_MARK = 6630; +const uint32_t AIRTON_HEADER_SPACE = 3350; +const uint32_t AIRTON_BIT_MARK = 400; +const uint32_t AIRTON_ONE_SPACE = 1260; +const uint32_t AIRTON_ZERO_SPACE = 430; +const uint32_t AIRTON_MESSAGE_SPACE = 100000; + +// State Frame size +const uint8_t AIRTON_STATE_FRAME_SIZE = 7; + +class AirtonClimate : public climate_ir::ClimateIR { + public: + AirtonClimate() + : climate_ir::ClimateIR(AIRTON_TEMP_MIN, AIRTON_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_VERTICAL}) {} + + protected: + // Save the previous operation mode globally + uint8_t previous_mode; + + // IR transmission payload builder + void transmit_state() override; + + uint8_t operation_mode_(); + uint16_t fan_speed_(); + bool turbo_control_(); + uint8_t temperature_(); + uint8_t swing_mode_(); + uint8_t operation_settings_(); + + uint8_t sumBytes_(const uint8_t *const start, const uint16_t length); + uint8_t checksum_(const uint8_t *r_state); + + // IR receiver buffer + bool on_receive(remote_base::RemoteReceiveData data) override; + bool parse_state_frame_(const uint8_t frame[]); +}; + +} // namespace airton +} // namespace esphome diff --git a/esphome/components/airton/climate.py b/esphome/components/airton/climate.py new file mode 100644 index 0000000000..a16fe61079 --- /dev/null +++ b/esphome/components/airton/climate.py @@ -0,0 +1,20 @@ +import esphome.codegen as cg +from esphome.components import climate_ir +import esphome.config_validation as cv +from esphome.const import CONF_ID + +AUTO_LOAD = ["climate_ir"] + +airton_ns = cg.esphome_ns.namespace("airton") +AirtonClimate = airton_ns.class_("AirtonClimate", climate_ir.ClimateIR) + +CONFIG_SCHEMA = climate_ir.CLIMATE_IR_WITH_RECEIVER_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(AirtonClimate), + } +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await climate_ir.register_climate_ir(var, config) diff --git a/tests/components/airton/test.esp32-ard.yaml b/tests/components/airton/test.esp32-ard.yaml new file mode 100644 index 0000000000..6b8d2fb3d7 --- /dev/null +++ b/tests/components/airton/test.esp32-ard.yaml @@ -0,0 +1,25 @@ +climate: + - platform: airton + name: Airton Climate + transmitter_id: irblaster + receiver_id: recvr + sensor: airton_sensor + +remote_transmitter: + id: irblaster + pin: 2 + carrier_duty_percent: 50% + +remote_receiver: + id: recvr + pin: + number: 4 + inverted: true + tolerance: + type: percentage + value: 35% + +sensor: + - platform: template + id: airton_sensor + lambda: "return 21;" diff --git a/tests/components/airton/test.esp32-c3-ard.yaml b/tests/components/airton/test.esp32-c3-ard.yaml new file mode 100644 index 0000000000..6b8d2fb3d7 --- /dev/null +++ b/tests/components/airton/test.esp32-c3-ard.yaml @@ -0,0 +1,25 @@ +climate: + - platform: airton + name: Airton Climate + transmitter_id: irblaster + receiver_id: recvr + sensor: airton_sensor + +remote_transmitter: + id: irblaster + pin: 2 + carrier_duty_percent: 50% + +remote_receiver: + id: recvr + pin: + number: 4 + inverted: true + tolerance: + type: percentage + value: 35% + +sensor: + - platform: template + id: airton_sensor + lambda: "return 21;" diff --git a/tests/components/airton/test.esp32-c3-idf.yaml b/tests/components/airton/test.esp32-c3-idf.yaml new file mode 100644 index 0000000000..6b8d2fb3d7 --- /dev/null +++ b/tests/components/airton/test.esp32-c3-idf.yaml @@ -0,0 +1,25 @@ +climate: + - platform: airton + name: Airton Climate + transmitter_id: irblaster + receiver_id: recvr + sensor: airton_sensor + +remote_transmitter: + id: irblaster + pin: 2 + carrier_duty_percent: 50% + +remote_receiver: + id: recvr + pin: + number: 4 + inverted: true + tolerance: + type: percentage + value: 35% + +sensor: + - platform: template + id: airton_sensor + lambda: "return 21;" diff --git a/tests/components/airton/test.esp32-idf.yaml b/tests/components/airton/test.esp32-idf.yaml new file mode 100644 index 0000000000..6b8d2fb3d7 --- /dev/null +++ b/tests/components/airton/test.esp32-idf.yaml @@ -0,0 +1,25 @@ +climate: + - platform: airton + name: Airton Climate + transmitter_id: irblaster + receiver_id: recvr + sensor: airton_sensor + +remote_transmitter: + id: irblaster + pin: 2 + carrier_duty_percent: 50% + +remote_receiver: + id: recvr + pin: + number: 4 + inverted: true + tolerance: + type: percentage + value: 35% + +sensor: + - platform: template + id: airton_sensor + lambda: "return 21;" diff --git a/tests/components/airton/test.esp8266-ard.yaml b/tests/components/airton/test.esp8266-ard.yaml new file mode 100644 index 0000000000..6b8d2fb3d7 --- /dev/null +++ b/tests/components/airton/test.esp8266-ard.yaml @@ -0,0 +1,25 @@ +climate: + - platform: airton + name: Airton Climate + transmitter_id: irblaster + receiver_id: recvr + sensor: airton_sensor + +remote_transmitter: + id: irblaster + pin: 2 + carrier_duty_percent: 50% + +remote_receiver: + id: recvr + pin: + number: 4 + inverted: true + tolerance: + type: percentage + value: 35% + +sensor: + - platform: template + id: airton_sensor + lambda: "return 21;"