From f34c9b33fc5280e09768d1fedc13c6c6478e69c7 Mon Sep 17 00:00:00 2001 From: "Sergey V. DUDANOV" Date: Thu, 18 Mar 2021 00:27:50 +0400 Subject: [PATCH] Midea climate support (#1328) * Added support for Midea IoT climate devices via UART interface (USB-dongle) * Fixed lint checks * Fixed lint checks * CODEOWNERS update * Clang-format * Clang-format * Add network device notification message support (show WiFi sign on devices) * Make wifi_signal_sensor optional component * Some optimization * Optimizations and code formatting * Fixed lint checks * Fixed lint checks * Fixed sign error * Code changes * Network notify repeat every 10 min * Added log messages * Fixed lint checks * Refactoring: MideaClimate => MideaAC * Using enums instead literals in Midea states * Enum changed to be more correct * Shrink notify frame to 32 bytes * Fixed lint checks * Change notify frame appliance type to common broadcast * Control optimization * Fixed control error * Control command now don't reset others uncontrollable properties of device * Fixed lint checks * Some optimization * on_receive callback give const Frame * Fix control * Fixes * Some minor changes * Fixed lint error * No dependency from wifi_signal sensor for stretched WiFi icon. New option: stretched_icon instead wifi_signal_id. * Fix option name * Added export of outdoor temperature as sensor value * Fixed lint errors * Fixed pylint error * Minor fix * Fix temperature overflow in some cases * Added answer on QueryNetwork command from appliance. Now don't wait for ack on 0x0D command. * Fix lint error * Added humidity setpoint optional sensor * Added boolean options 'swing_horizontal' and 'swing_both' * Added debug frame output * Added debug frame output * Fix lints error * Some debug output optimization * Fix lint check * Some code optimization: adding templates * Fix lint error * Added sensors device classes * Python code reformatted with black formatter * RX frame debug message RX frame debug message now prints before checking * Remove CRC check for receiving frames * Added experimental power usage option * Added power usage option * Fixed lint errors * Major changes. See esp-docs. * Added tests in test4.yaml * Added tests in test1.yaml * Added wifi dependency * Fix test1.yaml * Some fix :) * One more refactoring * One more refactoring * One more refactoring --- CODEOWNERS | 2 + esphome/components/midea_ac/__init__.py | 0 esphome/components/midea_ac/climate.py | 69 ++++++++ esphome/components/midea_ac/midea_climate.cpp | 110 ++++++++++++ esphome/components/midea_ac/midea_climate.h | 47 +++++ esphome/components/midea_ac/midea_frame.cpp | 160 ++++++++++++++++++ esphome/components/midea_ac/midea_frame.h | 137 +++++++++++++++ esphome/components/midea_dongle/__init__.py | 30 ++++ .../components/midea_dongle/midea_dongle.cpp | 98 +++++++++++ .../components/midea_dongle/midea_dongle.h | 56 ++++++ .../components/midea_dongle/midea_frame.cpp | 95 +++++++++++ esphome/components/midea_dongle/midea_frame.h | 104 ++++++++++++ tests/test1.yaml | 17 ++ 13 files changed, 925 insertions(+) create mode 100644 esphome/components/midea_ac/__init__.py create mode 100644 esphome/components/midea_ac/climate.py create mode 100644 esphome/components/midea_ac/midea_climate.cpp create mode 100644 esphome/components/midea_ac/midea_climate.h create mode 100644 esphome/components/midea_ac/midea_frame.cpp create mode 100644 esphome/components/midea_ac/midea_frame.h create mode 100644 esphome/components/midea_dongle/__init__.py create mode 100644 esphome/components/midea_dongle/midea_dongle.cpp create mode 100644 esphome/components/midea_dongle/midea_dongle.h create mode 100644 esphome/components/midea_dongle/midea_frame.cpp create mode 100644 esphome/components/midea_dongle/midea_frame.h diff --git a/CODEOWNERS b/CODEOWNERS index 4fdfb21569..47cd5f59ca 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -55,6 +55,8 @@ esphome/components/mcp23x17_base/* @jesserockz esphome/components/mcp23xxx_base/* @jesserockz esphome/components/mcp2515/* @danielschramm @mvturnho esphome/components/mcp9808/* @k7hpn +esphome/components/midea_ac/* @dudanov +esphome/components/midea_dongle/* @dudanov esphome/components/network/* @esphome/core esphome/components/nfc/* @jesserockz esphome/components/ota/* @esphome/core diff --git a/esphome/components/midea_ac/__init__.py b/esphome/components/midea_ac/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/midea_ac/climate.py b/esphome/components/midea_ac/climate.py new file mode 100644 index 0000000000..94aed91d4c --- /dev/null +++ b/esphome/components/midea_ac/climate.py @@ -0,0 +1,69 @@ +from esphome.components import climate, sensor +import esphome.config_validation as cv +import esphome.codegen as cg +from esphome.const import ( + CONF_ID, + UNIT_CELSIUS, + UNIT_PERCENT, + UNIT_WATT, + ICON_THERMOMETER, + ICON_POWER, + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + ICON_WATER_PERCENT, + DEVICE_CLASS_HUMIDITY, +) +from esphome.components.midea_dongle import CONF_MIDEA_DONGLE_ID, MideaDongle + +AUTO_LOAD = ["climate", "sensor", "midea_dongle"] +CODEOWNERS = ["@dudanov"] + +CONF_BEEPER = "beeper" +CONF_SWING_HORIZONTAL = "swing_horizontal" +CONF_SWING_BOTH = "swing_both" +CONF_OUTDOOR_TEMPERATURE = "outdoor_temperature" +CONF_POWER_USAGE = "power_usage" +CONF_HUMIDITY_SETPOINT = "humidity_setpoint" +midea_ac_ns = cg.esphome_ns.namespace("midea_ac") +MideaAC = midea_ac_ns.class_("MideaAC", climate.Climate, cg.Component) + +CONFIG_SCHEMA = cv.All( + climate.CLIMATE_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(MideaAC), + cv.GenerateID(CONF_MIDEA_DONGLE_ID): cv.use_id(MideaDongle), + cv.Optional(CONF_BEEPER, default=False): cv.boolean, + cv.Optional(CONF_SWING_HORIZONTAL, default=False): cv.boolean, + cv.Optional(CONF_SWING_BOTH, default=False): cv.boolean, + cv.Optional(CONF_OUTDOOR_TEMPERATURE): sensor.sensor_schema( + UNIT_CELSIUS, ICON_THERMOMETER, 0, DEVICE_CLASS_TEMPERATURE + ), + cv.Optional(CONF_POWER_USAGE): sensor.sensor_schema( + UNIT_WATT, ICON_POWER, 0, DEVICE_CLASS_POWER + ), + cv.Optional(CONF_HUMIDITY_SETPOINT): sensor.sensor_schema( + UNIT_PERCENT, ICON_WATER_PERCENT, 0, DEVICE_CLASS_HUMIDITY + ), + } + ).extend(cv.COMPONENT_SCHEMA) +) + + +def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + yield cg.register_component(var, config) + yield climate.register_climate(var, config) + paren = yield cg.get_variable(config[CONF_MIDEA_DONGLE_ID]) + cg.add(var.set_midea_dongle_parent(paren)) + cg.add(var.set_beeper_feedback(config[CONF_BEEPER])) + cg.add(var.set_swing_horizontal(config[CONF_SWING_HORIZONTAL])) + cg.add(var.set_swing_both(config[CONF_SWING_BOTH])) + if CONF_OUTDOOR_TEMPERATURE in config: + sens = yield sensor.new_sensor(config[CONF_OUTDOOR_TEMPERATURE]) + cg.add(var.set_outdoor_temperature_sensor(sens)) + if CONF_POWER_USAGE in config: + sens = yield sensor.new_sensor(config[CONF_POWER_USAGE]) + cg.add(var.set_power_sensor(sens)) + if CONF_HUMIDITY_SETPOINT in config: + sens = yield sensor.new_sensor(config[CONF_HUMIDITY_SETPOINT]) + cg.add(var.set_humidity_setpoint_sensor(sens)) diff --git a/esphome/components/midea_ac/midea_climate.cpp b/esphome/components/midea_ac/midea_climate.cpp new file mode 100644 index 0000000000..8a74251696 --- /dev/null +++ b/esphome/components/midea_ac/midea_climate.cpp @@ -0,0 +1,110 @@ +#include "esphome/core/log.h" +#include "midea_climate.h" + +namespace esphome { +namespace midea_ac { + +static const char *TAG = "midea_ac"; + +static void set_sensor(sensor::Sensor *sensor, float value) { + if (sensor != nullptr && (!sensor->has_state() || sensor->get_raw_state() != value)) + sensor->publish_state(value); +} + +template void set_property(T &property, T value, bool &flag) { + if (property != value) { + property = value; + flag = true; + } +} + +void MideaAC::on_frame(const midea_dongle::Frame &frame) { + const auto p = frame.as(); + if (p.has_power_info()) { + set_sensor(this->power_sensor_, p.get_power_usage()); + return; + } else if (!p.has_properties()) { + ESP_LOGW(TAG, "RX: frame has unknown type"); + return; + } + if (p.get_type() == midea_dongle::MideaMessageType::DEVICE_CONTROL) { + ESP_LOGD(TAG, "RX: control frame"); + this->ctrl_request_ = false; + } else { + ESP_LOGD(TAG, "RX: query frame"); + } + if (this->ctrl_request_) + return; + this->cmd_frame_.set_properties(p); // copy properties from response + bool need_publish = false; + set_property(this->mode, p.get_mode(), need_publish); + set_property(this->target_temperature, p.get_target_temp(), need_publish); + set_property(this->current_temperature, p.get_indoor_temp(), need_publish); + set_property(this->fan_mode, p.get_fan_mode(), need_publish); + set_property(this->swing_mode, p.get_swing_mode(), need_publish); + if (need_publish) + this->publish_state(); + set_sensor(this->outdoor_sensor_, p.get_outdoor_temp()); + set_sensor(this->humidity_sensor_, p.get_humidity_setpoint()); +} + +void MideaAC::on_update() { + if (this->ctrl_request_) { + ESP_LOGD(TAG, "TX: control"); + this->parent_->write_frame(this->cmd_frame_); + } else { + ESP_LOGD(TAG, "TX: query"); + if (this->power_sensor_ == nullptr || this->request_num_++ % 32) + this->parent_->write_frame(this->query_frame_); + else + this->parent_->write_frame(this->power_frame_); + } +} + +void MideaAC::control(const climate::ClimateCall &call) { + if (call.get_mode().has_value() && call.get_mode().value() != this->mode) { + this->cmd_frame_.set_mode(call.get_mode().value()); + this->ctrl_request_ = true; + } + if (call.get_target_temperature().has_value() && call.get_target_temperature().value() != this->target_temperature) { + this->cmd_frame_.set_target_temp(call.get_target_temperature().value()); + this->ctrl_request_ = true; + } + if (call.get_fan_mode().has_value() && call.get_fan_mode().value() != this->fan_mode) { + this->cmd_frame_.set_fan_mode(call.get_fan_mode().value()); + this->ctrl_request_ = true; + } + if (call.get_swing_mode().has_value() && call.get_swing_mode().value() != this->swing_mode) { + this->cmd_frame_.set_swing_mode(call.get_swing_mode().value()); + this->ctrl_request_ = true; + } + if (this->ctrl_request_) { + this->cmd_frame_.set_beeper_feedback(this->beeper_feedback_); + this->cmd_frame_.finalize(); + } +} + +climate::ClimateTraits MideaAC::traits() { + auto traits = climate::ClimateTraits(); + traits.set_visual_min_temperature(17); + traits.set_visual_max_temperature(30); + traits.set_visual_temperature_step(0.5); + traits.set_supports_auto_mode(true); + traits.set_supports_cool_mode(true); + traits.set_supports_dry_mode(true); + traits.set_supports_heat_mode(true); + traits.set_supports_fan_only_mode(true); + traits.set_supports_fan_mode_auto(true); + traits.set_supports_fan_mode_low(true); + traits.set_supports_fan_mode_medium(true); + traits.set_supports_fan_mode_high(true); + traits.set_supports_swing_mode_off(true); + traits.set_supports_swing_mode_vertical(true); + traits.set_supports_swing_mode_horizontal(this->traits_swing_horizontal_); + traits.set_supports_swing_mode_both(this->traits_swing_both_); + traits.set_supports_current_temperature(true); + return traits; +} + +} // namespace midea_ac +} // namespace esphome diff --git a/esphome/components/midea_ac/midea_climate.h b/esphome/components/midea_ac/midea_climate.h new file mode 100644 index 0000000000..f08350b252 --- /dev/null +++ b/esphome/components/midea_ac/midea_climate.h @@ -0,0 +1,47 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/midea_dongle/midea_dongle.h" +#include "esphome/components/climate/climate.h" +#include "midea_frame.h" + +namespace esphome { +namespace midea_ac { + +class MideaAC : public midea_dongle::MideaAppliance, public climate::Climate, public Component { + public: + float get_setup_priority() const override { return setup_priority::LATE; } + void on_frame(const midea_dongle::Frame &frame) override; + void on_update() override; + void setup() override { this->parent_->set_appliance(this); } + void set_midea_dongle_parent(midea_dongle::MideaDongle *parent) { this->parent_ = parent; } + void set_outdoor_temperature_sensor(sensor::Sensor *sensor) { this->outdoor_sensor_ = sensor; } + void set_humidity_setpoint_sensor(sensor::Sensor *sensor) { this->humidity_sensor_ = sensor; } + void set_power_sensor(sensor::Sensor *sensor) { this->power_sensor_ = sensor; } + void set_beeper_feedback(bool state) { this->beeper_feedback_ = state; } + void set_swing_horizontal(bool state) { this->traits_swing_horizontal_ = state; } + void set_swing_both(bool state) { this->traits_swing_both_ = state; } + + protected: + /// Override control to change settings of the climate device. + void control(const climate::ClimateCall &call) override; + /// Return the traits of this controller. + climate::ClimateTraits traits() override; + + const QueryFrame query_frame_; + const PowerQueryFrame power_frame_; + CommandFrame cmd_frame_; + midea_dongle::MideaDongle *parent_{nullptr}; + sensor::Sensor *outdoor_sensor_{nullptr}; + sensor::Sensor *humidity_sensor_{nullptr}; + sensor::Sensor *power_sensor_{nullptr}; + uint8_t request_num_{0}; + bool ctrl_request_{false}; + bool beeper_feedback_{false}; + bool traits_swing_horizontal_{false}; + bool traits_swing_both_{false}; +}; + +} // namespace midea_ac +} // namespace esphome diff --git a/esphome/components/midea_ac/midea_frame.cpp b/esphome/components/midea_ac/midea_frame.cpp new file mode 100644 index 0000000000..2d9be1bdc5 --- /dev/null +++ b/esphome/components/midea_ac/midea_frame.cpp @@ -0,0 +1,160 @@ +#include "midea_frame.h" + +namespace esphome { +namespace midea_ac { + +const uint8_t QueryFrame::INIT[] = {0xAA, 0x22, 0xAC, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x03, 0x41, 0x00, + 0x00, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x84, 0x68}; + +const uint8_t PowerQueryFrame::INIT[] = {0xAA, 0x22, 0xAC, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x03, 0x41, 0x21, + 0x01, 0x44, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00, 0x17, 0x6A}; + +const uint8_t CommandFrame::INIT[] = {0xAA, 0x22, 0xAC, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x02, 0x40, 0x00, + 0x00, 0x00, 0x7F, 0x7F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + +float PropertiesFrame::get_target_temp() const { + float temp = static_cast((this->pbuf_[12] & 0x0F) + 16); + if (this->pbuf_[12] & 0x10) + temp += 0.5; + return temp; +} + +void PropertiesFrame::set_target_temp(float temp) { + uint8_t tmp = static_cast(temp * 16.0) + 4; + tmp = ((tmp & 8) << 1) | (tmp >> 4); + this->pbuf_[12] &= ~0x1F; + this->pbuf_[12] |= tmp; +} + +static float i16tof(int16_t in) { return static_cast(in - 50) / 2.0; } +float PropertiesFrame::get_indoor_temp() const { return i16tof(this->pbuf_[21]); } +float PropertiesFrame::get_outdoor_temp() const { return i16tof(this->pbuf_[22]); } +float PropertiesFrame::get_humidity_setpoint() const { return static_cast(this->pbuf_[29] & 0x7F); } + +climate::ClimateMode PropertiesFrame::get_mode() const { + if (!this->get_power_()) + return climate::CLIMATE_MODE_OFF; + switch (this->pbuf_[12] >> 5) { + case MIDEA_MODE_AUTO: + return climate::CLIMATE_MODE_AUTO; + case MIDEA_MODE_COOL: + return climate::CLIMATE_MODE_COOL; + case MIDEA_MODE_DRY: + return climate::CLIMATE_MODE_DRY; + case MIDEA_MODE_HEAT: + return climate::CLIMATE_MODE_HEAT; + case MIDEA_MODE_FAN_ONLY: + return climate::CLIMATE_MODE_FAN_ONLY; + default: + return climate::CLIMATE_MODE_OFF; + } +} + +void PropertiesFrame::set_mode(climate::ClimateMode mode) { + uint8_t m; + switch (mode) { + case climate::CLIMATE_MODE_AUTO: + m = MIDEA_MODE_AUTO; + break; + case climate::CLIMATE_MODE_COOL: + m = MIDEA_MODE_COOL; + break; + case climate::CLIMATE_MODE_DRY: + m = MIDEA_MODE_DRY; + break; + case climate::CLIMATE_MODE_HEAT: + m = MIDEA_MODE_HEAT; + break; + case climate::CLIMATE_MODE_FAN_ONLY: + m = MIDEA_MODE_FAN_ONLY; + break; + default: + this->set_power_(false); + return; + } + this->set_power_(true); + this->pbuf_[12] &= ~0xE0; + this->pbuf_[12] |= m << 5; +} + +climate::ClimateFanMode PropertiesFrame::get_fan_mode() const { + switch (this->pbuf_[13]) { + case MIDEA_FAN_LOW: + return climate::CLIMATE_FAN_LOW; + case MIDEA_FAN_MEDIUM: + return climate::CLIMATE_FAN_MEDIUM; + case MIDEA_FAN_HIGH: + return climate::CLIMATE_FAN_HIGH; + default: + return climate::CLIMATE_FAN_AUTO; + } +} + +void PropertiesFrame::set_fan_mode(climate::ClimateFanMode mode) { + uint8_t m; + switch (mode) { + case climate::CLIMATE_FAN_LOW: + m = MIDEA_FAN_LOW; + break; + case climate::CLIMATE_FAN_MEDIUM: + m = MIDEA_FAN_MEDIUM; + break; + case climate::CLIMATE_FAN_HIGH: + m = MIDEA_FAN_HIGH; + break; + default: + m = MIDEA_FAN_AUTO; + break; + } + this->pbuf_[13] = m; +} + +climate::ClimateSwingMode PropertiesFrame::get_swing_mode() const { + switch (this->pbuf_[17] & 0x0F) { + case MIDEA_SWING_VERTICAL: + return climate::CLIMATE_SWING_VERTICAL; + case MIDEA_SWING_HORIZONTAL: + return climate::CLIMATE_SWING_HORIZONTAL; + case MIDEA_SWING_BOTH: + return climate::CLIMATE_SWING_BOTH; + default: + return climate::CLIMATE_SWING_OFF; + } +} + +void PropertiesFrame::set_swing_mode(climate::ClimateSwingMode mode) { + uint8_t m; + switch (mode) { + case climate::CLIMATE_SWING_VERTICAL: + m = MIDEA_SWING_VERTICAL; + break; + case climate::CLIMATE_SWING_HORIZONTAL: + m = MIDEA_SWING_HORIZONTAL; + break; + case climate::CLIMATE_SWING_BOTH: + m = MIDEA_SWING_BOTH; + break; + default: + m = MIDEA_SWING_OFF; + break; + } + this->pbuf_[17] = 0x30 | m; +} + +float PropertiesFrame::get_power_usage() const { + uint32_t power = 0; + const uint8_t *ptr = this->pbuf_ + 28; + for (uint32_t weight = 1;; weight *= 10, ptr--) { + power += (*ptr % 16) * weight; + weight *= 10; + power += (*ptr / 16) * weight; + if (weight == 100000) + return static_cast(power) * 0.1; + } +} + +} // namespace midea_ac +} // namespace esphome diff --git a/esphome/components/midea_ac/midea_frame.h b/esphome/components/midea_ac/midea_frame.h new file mode 100644 index 0000000000..e07a5bf946 --- /dev/null +++ b/esphome/components/midea_ac/midea_frame.h @@ -0,0 +1,137 @@ +#pragma once +#include "esphome/components/climate/climate.h" +#include "esphome/components/midea_dongle/midea_frame.h" + +namespace esphome { +namespace midea_ac { + +/// Enum for all modes a Midea device can be in. +enum MideaMode : uint8_t { + /// The Midea device is set to automatically change the heating/cooling cycle + MIDEA_MODE_AUTO = 1, + /// The Midea device is manually set to cool mode (not in auto mode!) + MIDEA_MODE_COOL = 2, + /// The Midea device is manually set to dry mode + MIDEA_MODE_DRY = 3, + /// The Midea device is manually set to heat mode (not in auto mode!) + MIDEA_MODE_HEAT = 4, + /// The Midea device is manually set to fan only mode + MIDEA_MODE_FAN_ONLY = 5, +}; + +/// Enum for all modes a Midea fan can be in +enum MideaFanMode : uint8_t { + /// The fan mode is set to Auto + MIDEA_FAN_AUTO = 102, + /// The fan mode is set to Low + MIDEA_FAN_LOW = 40, + /// The fan mode is set to Medium + MIDEA_FAN_MEDIUM = 60, + /// The fan mode is set to High + MIDEA_FAN_HIGH = 80, +}; + +/// Enum for all modes a Midea swing can be in +enum MideaSwingMode : uint8_t { + /// The sing mode is set to Off + MIDEA_SWING_OFF = 0b0000, + /// The fan mode is set to Both + MIDEA_SWING_BOTH = 0b1111, + /// The fan mode is set to Vertical + MIDEA_SWING_VERTICAL = 0b1100, + /// The fan mode is set to Horizontal + MIDEA_SWING_HORIZONTAL = 0b0011, +}; + +class PropertiesFrame : public midea_dongle::BaseFrame { + public: + PropertiesFrame() = delete; + PropertiesFrame(uint8_t *data) : BaseFrame(data) {} + PropertiesFrame(const Frame &frame) : BaseFrame(frame) {} + + bool has_properties() const { + return this->has_response_type(0xC0) && (this->has_type(0x03) || this->has_type(0x02)); + } + + bool has_power_info() const { return this->has_response_type(0xC1); } + + /* TARGET TEMPERATURE */ + + float get_target_temp() const; + void set_target_temp(float temp); + + /* MODE */ + climate::ClimateMode get_mode() const; + void set_mode(climate::ClimateMode mode); + + /* FAN SPEED */ + climate::ClimateFanMode get_fan_mode() const; + void set_fan_mode(climate::ClimateFanMode mode); + + /* SWING MODE */ + climate::ClimateSwingMode get_swing_mode() const; + void set_swing_mode(climate::ClimateSwingMode mode); + + /* INDOOR TEMPERATURE */ + float get_indoor_temp() const; + + /* OUTDOOR TEMPERATURE */ + float get_outdoor_temp() const; + + /* HUMIDITY SETPOINT */ + float get_humidity_setpoint() const; + + /* ECO MODE */ + bool get_eco_mode() const { return this->pbuf_[19]; } + void set_eco_mode(bool state) { this->set_bytemask_(19, 0xFF, state); } + + /* SLEEP MODE */ + bool get_sleep_mode() const { return this->pbuf_[20] & 0x01; } + void set_sleep_mode(bool state) { this->set_bytemask_(20, 0x01, state); } + + /* TURBO MODE */ + bool get_turbo_mode() const { return this->pbuf_[20] & 0x02; } + void set_turbo_mode(bool state) { this->set_bytemask_(20, 0x02, state); } + + /* POWER USAGE */ + float get_power_usage() const; + + /// Set properties from another frame + void set_properties(const PropertiesFrame &p) { memcpy(this->pbuf_ + 11, p.data() + 11, 10); } + + protected: + /* POWER */ + bool get_power_() const { return this->pbuf_[11] & 0x01; } + void set_power_(bool state) { this->set_bytemask_(11, 0x01, state); } +}; + +// Query state frame (read-only) +class QueryFrame : public midea_dongle::StaticFrame { + public: + QueryFrame() : StaticFrame(FPSTR(this->INIT)) {} + + private: + static const uint8_t PROGMEM INIT[]; +}; + +// Power query state frame (read-only) +class PowerQueryFrame : public midea_dongle::StaticFrame { + public: + PowerQueryFrame() : StaticFrame(FPSTR(this->INIT)) {} + + private: + static const uint8_t PROGMEM INIT[]; +}; + +// Command frame +class CommandFrame : public midea_dongle::StaticFrame { + public: + CommandFrame() : StaticFrame(FPSTR(this->INIT)) {} + void set_beeper_feedback(bool state) { this->set_bytemask_(11, 0x40, state); } + + private: + static const uint8_t PROGMEM INIT[]; +}; + +} // namespace midea_ac +} // namespace esphome diff --git a/esphome/components/midea_dongle/__init__.py b/esphome/components/midea_dongle/__init__.py new file mode 100644 index 0000000000..3efeb2661d --- /dev/null +++ b/esphome/components/midea_dongle/__init__.py @@ -0,0 +1,30 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import uart +from esphome.const import CONF_ID + +DEPENDENCIES = ["wifi", "uart"] +CODEOWNERS = ["@dudanov"] + +midea_dongle_ns = cg.esphome_ns.namespace("midea_dongle") +MideaDongle = midea_dongle_ns.class_("MideaDongle", cg.Component, uart.UARTDevice) + +CONF_MIDEA_DONGLE_ID = "midea_dongle_id" +CONF_STRENGTH_ICON = "strength_icon" +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(MideaDongle), + cv.Optional(CONF_STRENGTH_ICON, default=False): cv.boolean, + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(uart.UART_DEVICE_SCHEMA) +) + + +def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + yield cg.register_component(var, config) + yield uart.register_uart_device(var, config) + cg.add(var.use_strength_icon(config[CONF_STRENGTH_ICON])) diff --git a/esphome/components/midea_dongle/midea_dongle.cpp b/esphome/components/midea_dongle/midea_dongle.cpp new file mode 100644 index 0000000000..8ddaba1cb6 --- /dev/null +++ b/esphome/components/midea_dongle/midea_dongle.cpp @@ -0,0 +1,98 @@ +#include "midea_dongle.h" +#include "esphome/core/log.h" +#include "esphome/core/helpers.h" + +namespace esphome { +namespace midea_dongle { + +static const char *TAG = "midea_dongle"; + +void MideaDongle::loop() { + while (this->available()) { + const uint8_t rx = this->read(); + if (this->idx_ <= OFFSET_LENGTH) { + if (this->idx_ == OFFSET_LENGTH) { + if (rx <= OFFSET_BODY || rx >= sizeof(this->buf_)) { + this->reset_(); + continue; + } + this->cnt_ = rx; + } else if (rx != SYNC_BYTE) { + continue; + } + } + this->buf_[this->idx_++] = rx; + if (--this->cnt_) + continue; + this->reset_(); + const BaseFrame frame(this->buf_); + ESP_LOGD(TAG, "RX: %s", frame.to_string().c_str()); + if (!frame.is_valid()) { + ESP_LOGW(TAG, "RX: frame check failed!"); + continue; + } + if (frame.get_type() == QUERY_NETWORK) { + this->notify_.set_type(QUERY_NETWORK); + this->need_notify_ = true; + continue; + } + if (this->appliance_ != nullptr) + this->appliance_->on_frame(frame); + } +} + +void MideaDongle::update() { + const bool is_conn = WiFi.isConnected(); + uint8_t wifi_strength = 0; + if (!this->rssi_timer_) { + if (is_conn) + wifi_strength = 4; + } else if (is_conn) { + if (--this->rssi_timer_) { + wifi_strength = this->notify_.get_signal_strength(); + } else { + this->rssi_timer_ = 60; + const long dbm = WiFi.RSSI(); + if (dbm > -63) + wifi_strength = 4; + else if (dbm > -75) + wifi_strength = 3; + else if (dbm > -88) + wifi_strength = 2; + else if (dbm > -100) + wifi_strength = 1; + } + } else { + this->rssi_timer_ = 1; + } + if (this->notify_.is_connected() != is_conn) { + this->notify_.set_connected(is_conn); + this->need_notify_ = true; + } + if (this->notify_.get_signal_strength() != wifi_strength) { + this->notify_.set_signal_strength(wifi_strength); + this->need_notify_ = true; + } + if (!--this->notify_timer_) { + this->notify_.set_type(NETWORK_NOTIFY); + this->need_notify_ = true; + } + if (this->need_notify_) { + ESP_LOGD(TAG, "TX: notify WiFi STA %s, signal strength %d", is_conn ? "connected" : "not connected", wifi_strength); + this->need_notify_ = false; + this->notify_timer_ = 600; + this->notify_.finalize(); + this->write_frame(this->notify_); + return; + } + if (this->appliance_ != nullptr) + this->appliance_->on_update(); +} + +void MideaDongle::write_frame(const Frame &frame) { + this->write_array(frame.data(), frame.size()); + ESP_LOGD(TAG, "TX: %s", frame.to_string().c_str()); +} + +} // namespace midea_dongle +} // namespace esphome diff --git a/esphome/components/midea_dongle/midea_dongle.h b/esphome/components/midea_dongle/midea_dongle.h new file mode 100644 index 0000000000..a7dfb9cf25 --- /dev/null +++ b/esphome/components/midea_dongle/midea_dongle.h @@ -0,0 +1,56 @@ +#pragma once +#include "esphome/core/component.h" +#include "esphome/components/wifi/wifi_component.h" +#include "esphome/components/uart/uart.h" +#include "midea_frame.h" + +namespace esphome { +namespace midea_dongle { + +enum MideaApplianceType : uint8_t { DEHUMIDIFIER = 0xA1, AIR_CONDITIONER = 0xAC, BROADCAST = 0xFF }; +enum MideaMessageType : uint8_t { + DEVICE_CONTROL = 0x02, + DEVICE_QUERY = 0x03, + NETWORK_NOTIFY = 0x0D, + QUERY_NETWORK = 0x63, +}; + +struct MideaAppliance { + /// Calling on update event + virtual void on_update() = 0; + /// Calling on frame receive event + virtual void on_frame(const Frame &frame) = 0; +}; + +class MideaDongle : public PollingComponent, public uart::UARTDevice { + public: + MideaDongle() : PollingComponent(1000) {} + float get_setup_priority() const override { return setup_priority::LATE; } + void update() override; + void loop() override; + void set_appliance(MideaAppliance *app) { this->appliance_ = app; } + void use_strength_icon(bool state) { this->rssi_timer_ = state; } + void write_frame(const Frame &frame); + + protected: + MideaAppliance *appliance_{nullptr}; + NotifyFrame notify_; + unsigned notify_timer_{1}; + // Buffer + uint8_t buf_[36]; + // Index + uint8_t idx_{0}; + // Reverse receive counter + uint8_t cnt_{2}; + uint8_t rssi_timer_{0}; + bool need_notify_{false}; + + // Reset receiver state + void reset_() { + this->idx_ = 0; + this->cnt_ = 2; + } +}; + +} // namespace midea_dongle +} // namespace esphome diff --git a/esphome/components/midea_dongle/midea_frame.cpp b/esphome/components/midea_dongle/midea_frame.cpp new file mode 100644 index 0000000000..acb3feee5f --- /dev/null +++ b/esphome/components/midea_dongle/midea_frame.cpp @@ -0,0 +1,95 @@ +#include "midea_frame.h" + +namespace esphome { +namespace midea_dongle { + +const uint8_t BaseFrame::CRC_TABLE[] = { + 0x00, 0x5E, 0xBC, 0xE2, 0x61, 0x3F, 0xDD, 0x83, 0xC2, 0x9C, 0x7E, 0x20, 0xA3, 0xFD, 0x1F, 0x41, 0x9D, 0xC3, 0x21, + 0x7F, 0xFC, 0xA2, 0x40, 0x1E, 0x5F, 0x01, 0xE3, 0xBD, 0x3E, 0x60, 0x82, 0xDC, 0x23, 0x7D, 0x9F, 0xC1, 0x42, 0x1C, + 0xFE, 0xA0, 0xE1, 0xBF, 0x5D, 0x03, 0x80, 0xDE, 0x3C, 0x62, 0xBE, 0xE0, 0x02, 0x5C, 0xDF, 0x81, 0x63, 0x3D, 0x7C, + 0x22, 0xC0, 0x9E, 0x1D, 0x43, 0xA1, 0xFF, 0x46, 0x18, 0xFA, 0xA4, 0x27, 0x79, 0x9B, 0xC5, 0x84, 0xDA, 0x38, 0x66, + 0xE5, 0xBB, 0x59, 0x07, 0xDB, 0x85, 0x67, 0x39, 0xBA, 0xE4, 0x06, 0x58, 0x19, 0x47, 0xA5, 0xFB, 0x78, 0x26, 0xC4, + 0x9A, 0x65, 0x3B, 0xD9, 0x87, 0x04, 0x5A, 0xB8, 0xE6, 0xA7, 0xF9, 0x1B, 0x45, 0xC6, 0x98, 0x7A, 0x24, 0xF8, 0xA6, + 0x44, 0x1A, 0x99, 0xC7, 0x25, 0x7B, 0x3A, 0x64, 0x86, 0xD8, 0x5B, 0x05, 0xE7, 0xB9, 0x8C, 0xD2, 0x30, 0x6E, 0xED, + 0xB3, 0x51, 0x0F, 0x4E, 0x10, 0xF2, 0xAC, 0x2F, 0x71, 0x93, 0xCD, 0x11, 0x4F, 0xAD, 0xF3, 0x70, 0x2E, 0xCC, 0x92, + 0xD3, 0x8D, 0x6F, 0x31, 0xB2, 0xEC, 0x0E, 0x50, 0xAF, 0xF1, 0x13, 0x4D, 0xCE, 0x90, 0x72, 0x2C, 0x6D, 0x33, 0xD1, + 0x8F, 0x0C, 0x52, 0xB0, 0xEE, 0x32, 0x6C, 0x8E, 0xD0, 0x53, 0x0D, 0xEF, 0xB1, 0xF0, 0xAE, 0x4C, 0x12, 0x91, 0xCF, + 0x2D, 0x73, 0xCA, 0x94, 0x76, 0x28, 0xAB, 0xF5, 0x17, 0x49, 0x08, 0x56, 0xB4, 0xEA, 0x69, 0x37, 0xD5, 0x8B, 0x57, + 0x09, 0xEB, 0xB5, 0x36, 0x68, 0x8A, 0xD4, 0x95, 0xCB, 0x29, 0x77, 0xF4, 0xAA, 0x48, 0x16, 0xE9, 0xB7, 0x55, 0x0B, + 0x88, 0xD6, 0x34, 0x6A, 0x2B, 0x75, 0x97, 0xC9, 0x4A, 0x14, 0xF6, 0xA8, 0x74, 0x2A, 0xC8, 0x96, 0x15, 0x4B, 0xA9, + 0xF7, 0xB6, 0xE8, 0x0A, 0x54, 0xD7, 0x89, 0x6B, 0x35}; + +const uint8_t NotifyFrame::INIT[] = {0xAA, 0x1F, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x0D, 0x01, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0x01, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; + +bool BaseFrame::is_valid() const { return /*this->has_valid_crc_() &&*/ this->has_valid_cs_(); } + +void BaseFrame::finalize() { + this->update_crc_(); + this->update_cs_(); +} + +void BaseFrame::update_crc_() { + uint8_t crc = 0; + uint8_t *ptr = this->pbuf_ + OFFSET_BODY; + uint8_t len = this->length_() - OFFSET_BODY; + while (--len) + crc = pgm_read_byte(BaseFrame::CRC_TABLE + (crc ^ *ptr++)); + *ptr = crc; +} + +void BaseFrame::update_cs_() { + uint8_t cs = 0; + uint8_t *ptr = this->pbuf_ + OFFSET_LENGTH; + uint8_t len = this->length_(); + while (--len) + cs -= *ptr++; + *ptr = cs; +} + +bool BaseFrame::has_valid_crc_() const { + uint8_t crc = 0; + uint8_t len = this->length_() - OFFSET_BODY; + const uint8_t *ptr = this->pbuf_ + OFFSET_BODY; + for (; len; ptr++, len--) + crc = pgm_read_byte(BaseFrame::CRC_TABLE + (crc ^ *ptr)); + return !crc; +} + +bool BaseFrame::has_valid_cs_() const { + uint8_t cs = 0; + uint8_t len = this->length_(); + const uint8_t *ptr = this->pbuf_ + OFFSET_LENGTH; + for (; len; ptr++, len--) + cs -= *ptr; + return !cs; +} + +void BaseFrame::set_bytemask_(uint8_t idx, uint8_t mask, bool state) { + uint8_t *dst = this->pbuf_ + idx; + if (state) + *dst |= mask; + else + *dst &= ~mask; +} + +static char u4hex(uint8_t num) { return num + ((num < 10) ? '0' : ('A' - 10)); } + +String Frame::to_string() const { + String ret; + char buf[4]; + buf[2] = ' '; + buf[3] = '\0'; + ret.reserve(3 * 36); + const uint8_t *it = this->data(); + for (size_t i = 0; i < this->size(); i++, it++) { + buf[0] = u4hex(*it >> 4); + buf[1] = u4hex(*it & 15); + ret.concat(buf); + } + return ret; +} + +} // namespace midea_dongle +} // namespace esphome diff --git a/esphome/components/midea_dongle/midea_frame.h b/esphome/components/midea_dongle/midea_frame.h new file mode 100644 index 0000000000..ce89cc636e --- /dev/null +++ b/esphome/components/midea_dongle/midea_frame.h @@ -0,0 +1,104 @@ +#pragma once +#include "esphome/core/component.h" + +namespace esphome { +namespace midea_dongle { + +static const uint8_t OFFSET_START = 0; +static const uint8_t OFFSET_LENGTH = 1; +static const uint8_t OFFSET_APPTYPE = 2; +static const uint8_t OFFSET_BODY = 10; +static const uint8_t SYNC_BYTE = 0xAA; + +class Frame { + public: + Frame() = delete; + Frame(uint8_t *data) : pbuf_(data) {} + Frame(const Frame &frame) : pbuf_(frame.data()) {} + + // Frame buffer + uint8_t *data() const { return this->pbuf_; } + // Frame size + uint8_t size() const { return this->length_() + OFFSET_LENGTH; } + uint8_t app_type() const { return this->pbuf_[OFFSET_APPTYPE]; } + + template typename std::enable_if::value, T>::type as() const { + return T(*this); + } + String to_string() const; + + protected: + uint8_t *pbuf_; + uint8_t length_() const { return this->pbuf_[OFFSET_LENGTH]; } +}; + +class BaseFrame : public Frame { + public: + BaseFrame() = delete; + BaseFrame(uint8_t *data) : Frame(data) {} + BaseFrame(const Frame &frame) : Frame(frame) {} + + // Check for valid + bool is_valid() const; + // Prepare for sending to device + void finalize(); + uint8_t get_type() const { return this->pbuf_[9]; } + void set_type(uint8_t value) { this->pbuf_[9] = value; } + bool has_response_type(uint8_t type) const { return this->resp_type_() == type; } + bool has_type(uint8_t type) const { return this->get_type() == type; } + + protected: + static const uint8_t PROGMEM CRC_TABLE[256]; + void set_bytemask_(uint8_t idx, uint8_t mask, bool state); + uint8_t resp_type_() const { return this->pbuf_[OFFSET_BODY]; } + bool has_valid_crc_() const; + bool has_valid_cs_() const; + void update_crc_(); + void update_cs_(); +}; + +template class StaticFrame : public T { + public: + // Default constructor + StaticFrame() : T(this->buf_) {} + // Copy constructor + StaticFrame(const Frame &src) : T(this->buf_) { + if (src.length_() < sizeof(this->buf_)) { + memcpy(this->buf_, src.data(), src.length_() + OFFSET_LENGTH); + } + } + // Constructor for RAM data + StaticFrame(const uint8_t *src) : T(this->buf_) { + const uint8_t len = src[OFFSET_LENGTH]; + if (len < sizeof(this->buf_)) { + memcpy(this->buf_, src, len + OFFSET_LENGTH); + } + } + // Constructor for PROGMEM data + StaticFrame(const __FlashStringHelper *pgm) : T(this->buf_) { + const uint8_t *src = reinterpret_cast(pgm); + const uint8_t len = pgm_read_byte(src + OFFSET_LENGTH); + if (len < sizeof(this->buf_)) { + memcpy_P(this->buf_, src, len + OFFSET_LENGTH); + } + } + + protected: + uint8_t buf_[buf_size]; +}; + +// Device network notification frame +class NotifyFrame : public midea_dongle::StaticFrame { + public: + NotifyFrame() : StaticFrame(FPSTR(NotifyFrame::INIT)) {} + void set_signal_strength(uint8_t value) { this->pbuf_[12] = value; } + uint8_t get_signal_strength() const { return this->pbuf_[12]; } + void set_connected(bool state) { this->pbuf_[18] = state ? 0 : 1; } + bool is_connected() const { return !this->pbuf_[18]; } + + private: + static const uint8_t PROGMEM INIT[]; +}; + +} // namespace midea_dongle +} // namespace esphome diff --git a/tests/test1.yaml b/tests/test1.yaml index b50d5eeef7..a064bc0ed8 100644 --- a/tests/test1.yaml +++ b/tests/test1.yaml @@ -1475,6 +1475,23 @@ climate: name: Toshiba Climate - platform: hitachi_ac344 name: Hitachi Climate + - platform: midea_ac + visual: + min_temperature: 18 °C + max_temperature: 25 °C + temperature_step: 0.1 °C + name: "Electrolux EACS" + beeper: true + outdoor_temperature: + name: "Temp" + power_usage: + name: "Power" + humidity_setpoint: + name: "Hum" + +midea_dongle: + uart_id: uart0 + strength_icon: true switch: - platform: gpio