diff --git a/CODEOWNERS b/CODEOWNERS index d86109581c..6cc09bf245 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -209,6 +209,7 @@ esphome/components/nextion/sensor/* @senexcrenshaw esphome/components/nextion/switch/* @senexcrenshaw esphome/components/nextion/text_sensor/* @senexcrenshaw esphome/components/nfc/* @jesserockz +esphome/components/noblex/* @AGalfra esphome/components/number/* @esphome/core esphome/components/ota/* @esphome/core esphome/components/output/* @esphome/core diff --git a/esphome/components/noblex/__init__.py b/esphome/components/noblex/__init__.py new file mode 100644 index 0000000000..ceceff9df9 --- /dev/null +++ b/esphome/components/noblex/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@AGalfra"] diff --git a/esphome/components/noblex/climate.py b/esphome/components/noblex/climate.py new file mode 100644 index 0000000000..2025cb5d9e --- /dev/null +++ b/esphome/components/noblex/climate.py @@ -0,0 +1,20 @@ +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"] + +noblex_ns = cg.esphome_ns.namespace("noblex") +NoblexClimate = noblex_ns.class_("NoblexClimate", climate_ir.ClimateIR) + +CONFIG_SCHEMA = climate_ir.CLIMATE_IR_WITH_RECEIVER_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(NoblexClimate), + } +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await climate_ir.register_climate_ir(var, config) diff --git a/esphome/components/noblex/noblex.cpp b/esphome/components/noblex/noblex.cpp new file mode 100644 index 0000000000..3521745bdc --- /dev/null +++ b/esphome/components/noblex/noblex.cpp @@ -0,0 +1,309 @@ +#include "noblex.h" +#include "esphome/core/log.h" +#include "esphome/core/helpers.h" + +namespace esphome { +namespace noblex { + +static const char *const TAG = "noblex.climate"; + +const uint16_t NOBLEX_HEADER_MARK = 9000; +const uint16_t NOBLEX_HEADER_SPACE = 4500; +const uint16_t NOBLEX_BIT_MARK = 660; +const uint16_t NOBLEX_ONE_SPACE = 1640; +const uint16_t NOBLEX_ZERO_SPACE = 520; +const uint32_t NOBLEX_GAP = 20000; +const uint8_t NOBLEX_POWER = 0x10; + +using IRNoblexMode = enum IRNoblexMode { + IR_NOBLEX_MODE_AUTO = 0b000, + IR_NOBLEX_MODE_COOL = 0b100, + IR_NOBLEX_MODE_DRY = 0b010, + IR_NOBLEX_MODE_FAN = 0b110, + IR_NOBLEX_MODE_HEAT = 0b001, +}; + +using IRNoblexFan = enum IRNoblexFan { + IR_NOBLEX_FAN_AUTO = 0b00, + IR_NOBLEX_FAN_LOW = 0b10, + IR_NOBLEX_FAN_MEDIUM = 0b01, + IR_NOBLEX_FAN_HIGH = 0b11, +}; + +// Transmit via IR the state of this climate controller. +void NoblexClimate::transmit_state() { + uint8_t remote_state[8] = {0x80, 0x10, 0x00, 0x0A, 0x50, 0x00, 0x20, 0x00}; // OFF, COOL, 24C, FAN_AUTO + + auto powered_on = this->mode != climate::CLIMATE_MODE_OFF; + if (powered_on) { + remote_state[0] |= 0x10; // power bit + remote_state[2] = 0x02; + } + if (powered_on != this->powered_on_assumed) + this->powered_on_assumed = powered_on; + + auto temp = (uint8_t) roundf(clamp(this->target_temperature, NOBLEX_TEMP_MIN, NOBLEX_TEMP_MAX)); + remote_state[1] = reverse_bits(uint8_t((temp - NOBLEX_TEMP_MIN) & 0x0F)); + + switch (this->mode) { + case climate::CLIMATE_MODE_HEAT_COOL: + remote_state[0] |= (IRNoblexMode::IR_NOBLEX_MODE_AUTO << 5); + remote_state[1] = 0x90; // NOBLEX_TEMP_MAP 25C + break; + case climate::CLIMATE_MODE_COOL: + remote_state[0] |= (IRNoblexMode::IR_NOBLEX_MODE_COOL << 5); + break; + case climate::CLIMATE_MODE_DRY: + remote_state[0] |= (IRNoblexMode::IR_NOBLEX_MODE_DRY << 5); + break; + case climate::CLIMATE_MODE_FAN_ONLY: + remote_state[0] |= (IRNoblexMode::IR_NOBLEX_MODE_FAN << 5); + break; + case climate::CLIMATE_MODE_HEAT: + remote_state[0] |= (IRNoblexMode::IR_NOBLEX_MODE_HEAT << 5); + break; + case climate::CLIMATE_MODE_OFF: + default: + powered_on = false; + this->powered_on_assumed = powered_on; + remote_state[0] &= 0xEF; + remote_state[2] = 0x00; + break; + } + + switch (this->fan_mode.value()) { + case climate::CLIMATE_FAN_LOW: + remote_state[0] |= (IRNoblexFan::IR_NOBLEX_FAN_LOW << 2); + break; + case climate::CLIMATE_FAN_MEDIUM: + remote_state[0] |= (IRNoblexFan::IR_NOBLEX_FAN_MEDIUM << 2); + break; + case climate::CLIMATE_FAN_HIGH: + remote_state[0] |= (IRNoblexFan::IR_NOBLEX_FAN_HIGH << 2); + break; + case climate::CLIMATE_FAN_AUTO: + default: + remote_state[0] |= (IRNoblexFan::IR_NOBLEX_FAN_AUTO << 2); + break; + } + + switch (this->swing_mode) { + case climate::CLIMATE_SWING_VERTICAL: + remote_state[0] |= 0x02; + remote_state[4] = 0x58; + break; + case climate::CLIMATE_SWING_OFF: + default: + remote_state[0] &= 0xFD; + remote_state[4] = 0x50; + break; + } + + uint8_t crc = 0; + for (uint8_t i : remote_state) { + crc += reverse_bits(i); + } + crc = reverse_bits(uint8_t(crc & 0x0F)) >> 4; + + ESP_LOGD(TAG, "Sending noblex code: %02X%02X %02X%02X %02X%02X %02X%02X", remote_state[0], remote_state[1], + remote_state[2], remote_state[3], remote_state[4], remote_state[5], remote_state[6], remote_state[7]); + + ESP_LOGV(TAG, "CRC: %01X", crc); + + auto transmit = this->transmitter_->transmit(); + auto *data = transmit.get_data(); + data->set_carrier_frequency(38000); + + // Header + data->mark(NOBLEX_HEADER_MARK); + data->space(NOBLEX_HEADER_SPACE); + // Data (sent remote_state from the MSB to the LSB) + for (uint8_t i : remote_state) { + for (int8_t j = 7; j >= 0; j--) { + if ((i == 4) & (j == 4)) { + // Header intermediate + data->mark(NOBLEX_BIT_MARK); + data->space(NOBLEX_GAP); // gap en bit 36 + } else { + data->mark(NOBLEX_BIT_MARK); + bool bit = i & (1 << j); + data->space(bit ? NOBLEX_ONE_SPACE : NOBLEX_ZERO_SPACE); + } + } + } + // send crc + for (int8_t i = 3; i >= 0; i--) { + data->mark(NOBLEX_BIT_MARK); + bool bit = crc & (1 << i); + data->space(bit ? NOBLEX_ONE_SPACE : NOBLEX_ZERO_SPACE); + } + // Footer + data->mark(NOBLEX_BIT_MARK); + + transmit.perform(); +} // end transmit_state() + +// Handle received IR Buffer +bool NoblexClimate::on_receive(remote_base::RemoteReceiveData data) { + uint8_t remote_state[8] = {0}; + uint8_t crc = 0, crc_calculated = 0; + + if (!receiving_) { + // Validate header + if (data.expect_item(NOBLEX_HEADER_MARK, NOBLEX_HEADER_SPACE)) { + ESP_LOGV(TAG, "Header"); + receiving_ = true; + // Read first 36 bits + for (int i = 0; i < 5; i++) { + // Read bit + for (int j = 7; j >= 0; j--) { + if ((i == 4) & (j == 4)) { + remote_state[i] |= 1 << j; + // Header intermediate + ESP_LOGVV(TAG, "GAP"); + return false; + } else if (data.expect_item(NOBLEX_BIT_MARK, NOBLEX_ONE_SPACE)) { + remote_state[i] |= 1 << j; + } else if (!data.expect_item(NOBLEX_BIT_MARK, NOBLEX_ZERO_SPACE)) { + ESP_LOGVV(TAG, "Byte %d bit %d fail", i, j); + return false; + } + } + ESP_LOGV(TAG, "Byte %d %02X", i, remote_state[i]); + } + + } else { + ESP_LOGV(TAG, "Header fail"); + receiving_ = false; + return false; + } + + } else { + // Read the remaining 28 bits + for (int i = 4; i < 8; i++) { + // Read bit + for (int j = 7; j >= 0; j--) { + if ((i == 4) & (j >= 4)) { + // nothing + } else if (data.expect_item(NOBLEX_BIT_MARK, NOBLEX_ONE_SPACE)) { + remote_state[i] |= 1 << j; + } else if (!data.expect_item(NOBLEX_BIT_MARK, NOBLEX_ZERO_SPACE)) { + ESP_LOGVV(TAG, "Byte %d bit %d fail", i, j); + return false; + } + } + ESP_LOGV(TAG, "Byte %d %02X", i, remote_state[i]); + } + + // Read crc + for (int i = 3; i >= 0; i--) { + if (data.expect_item(NOBLEX_BIT_MARK, NOBLEX_ONE_SPACE)) { + crc |= 1 << i; + } else if (!data.expect_item(NOBLEX_BIT_MARK, NOBLEX_ZERO_SPACE)) { + ESP_LOGVV(TAG, "Bit %d CRC fail", i); + return false; + } + } + ESP_LOGV(TAG, "CRC %02X", crc); + + // Validate footer + if (!data.expect_mark(NOBLEX_BIT_MARK)) { + ESP_LOGV(TAG, "Footer fail"); + return false; + } + receiving_ = false; + } + + for (uint8_t i : remote_state) + crc_calculated += reverse_bits(i); + crc_calculated = reverse_bits(uint8_t(crc_calculated & 0x0F)) >> 4; + ESP_LOGVV(TAG, "CRC calc %02X", crc_calculated); + + if (crc != crc_calculated) { + ESP_LOGV(TAG, "CRC fail"); + return false; + } + + ESP_LOGD(TAG, "Received noblex code: %02X%02X %02X%02X %02X%02X %02X%02X", remote_state[0], remote_state[1], + remote_state[2], remote_state[3], remote_state[4], remote_state[5], remote_state[6], remote_state[7]); + + auto powered_on = false; + if ((remote_state[0] & NOBLEX_POWER) == NOBLEX_POWER) { + powered_on = true; + this->powered_on_assumed = powered_on; + } else { + powered_on = false; + this->powered_on_assumed = powered_on; + this->mode = climate::CLIMATE_MODE_OFF; + } + // powr on/off button + ESP_LOGV(TAG, "Power: %01X", powered_on); + + // Set received mode + if (powered_on_assumed) { + auto mode = (remote_state[0] & 0xE0) >> 5; + ESP_LOGV(TAG, "Mode: %02X", mode); + switch (mode) { + case IRNoblexMode::IR_NOBLEX_MODE_AUTO: + this->mode = climate::CLIMATE_MODE_HEAT_COOL; + break; + case IRNoblexMode::IR_NOBLEX_MODE_COOL: + this->mode = climate::CLIMATE_MODE_COOL; + break; + case IRNoblexMode::IR_NOBLEX_MODE_DRY: + this->mode = climate::CLIMATE_MODE_DRY; + break; + case IRNoblexMode::IR_NOBLEX_MODE_FAN: + this->mode = climate::CLIMATE_MODE_FAN_ONLY; + break; + case IRNoblexMode::IR_NOBLEX_MODE_HEAT: + this->mode = climate::CLIMATE_MODE_HEAT; + break; + } + } + + // Set received temp + uint8_t temp = remote_state[1]; + ESP_LOGVV(TAG, "Temperature Raw: %02X", temp); + + temp = 0x0F & reverse_bits(temp); + temp += NOBLEX_TEMP_MIN; + ESP_LOGV(TAG, "Temperature Climate: %u", temp); + this->target_temperature = temp; + + // Set received fan speed + auto fan = (remote_state[0] & 0x0C) >> 2; + ESP_LOGV(TAG, "Fan: %02X", fan); + switch (fan) { + case IRNoblexFan::IR_NOBLEX_FAN_HIGH: + this->fan_mode = climate::CLIMATE_FAN_HIGH; + break; + case IRNoblexFan::IR_NOBLEX_FAN_MEDIUM: + this->fan_mode = climate::CLIMATE_FAN_MEDIUM; + break; + case IRNoblexFan::IR_NOBLEX_FAN_LOW: + this->fan_mode = climate::CLIMATE_FAN_LOW; + break; + case IRNoblexFan::IR_NOBLEX_FAN_AUTO: + default: + this->fan_mode = climate::CLIMATE_FAN_AUTO; + break; + } + + // Set received swing status + if (remote_state[0] & 0x02) { + ESP_LOGV(TAG, "Swing vertical"); + this->swing_mode = climate::CLIMATE_SWING_VERTICAL; + } else { + ESP_LOGV(TAG, "Swing OFF"); + this->swing_mode = climate::CLIMATE_SWING_OFF; + } + + for (uint8_t &i : remote_state) + i = 0; + this->publish_state(); + return true; +} // end on_receive() + +} // namespace noblex +} // namespace esphome diff --git a/esphome/components/noblex/noblex.h b/esphome/components/noblex/noblex.h new file mode 100644 index 0000000000..a8e5f41547 --- /dev/null +++ b/esphome/components/noblex/noblex.h @@ -0,0 +1,47 @@ +#pragma once + +#include "esphome/components/climate_ir/climate_ir.h" + +namespace esphome { +namespace noblex { + +// Temperature +const uint8_t NOBLEX_TEMP_MIN = 16; // Celsius +const uint8_t NOBLEX_TEMP_MAX = 30; // Celsius + +class NoblexClimate : public climate_ir::ClimateIR { + public: + NoblexClimate() + : climate_ir::ClimateIR(NOBLEX_TEMP_MIN, NOBLEX_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}) {} + + void setup() override { + climate_ir::ClimateIR::setup(); + this->powered_on_assumed = this->mode != climate::CLIMATE_MODE_OFF; + } + + // Override control to change settings of the climate device. + void control(const climate::ClimateCall &call) override { + send_swing_cmd_ = call.get_swing_mode().has_value(); + // swing resets after unit powered off + if (call.get_mode().has_value() && *call.get_mode() == climate::CLIMATE_MODE_OFF) + this->swing_mode = climate::CLIMATE_SWING_OFF; + climate_ir::ClimateIR::control(call); + } + + // used to track when to send the power toggle command. + bool powered_on_assumed; + + protected: + /// Transmit via IR the state of this climate controller. + void transmit_state() override; + /// Handle received IR Buffer. + bool on_receive(remote_base::RemoteReceiveData data) override; + bool send_swing_cmd_{false}; + bool receiving_ = false; +}; + +} // namespace noblex +} // namespace esphome diff --git a/tests/test1.yaml b/tests/test1.yaml index 0a9867c0f9..a8421152ed 100644 --- a/tests/test1.yaml +++ b/tests/test1.yaml @@ -2303,6 +2303,11 @@ climate: heat_mode: extended - platform: whynter name: Whynter + - platform: noblex + name: AC Living + id: noblex_ac + sensor: ${sensorname}_sensor + receiver_id: rcvr - platform: gree name: GREE model: generic @@ -2939,6 +2944,7 @@ tm1651: dio_pin: GPIO23 remote_receiver: + id: rcvr pin: GPIO32 dump: all on_coolix: