diff --git a/CODEOWNERS b/CODEOWNERS index 2d3f8af71d0..9de83996659 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -160,6 +160,7 @@ esphome/components/ds248x/* @tomwellnitz esphome/components/ds3231/* @linkedupbits esphome/components/dsmr/* @glmnet @PolarGoose esphome/components/duty_time/* @dudanov +esphome/components/ecocomfort2/* @gledian esphome/components/ee895/* @Stock-M esphome/components/ektf2232/touchscreen/* @jesserockz esphome/components/emc2101/* @ellull diff --git a/esphome/components/ecocomfort2/__init__.py b/esphome/components/ecocomfort2/__init__.py new file mode 100644 index 00000000000..472ac8f7cf8 --- /dev/null +++ b/esphome/components/ecocomfort2/__init__.py @@ -0,0 +1,72 @@ +import esphome.codegen as cg +from esphome.components import ble_client, esp32_ble +from esphome.components.esp32_ble import ( + CONF_AUTH_REQ_MODE, + CONF_IO_CAPABILITY, + CONF_MAX_KEY_SIZE, +) +import esphome.config_validation as cv +from esphome.const import CONF_ID +from esphome.cpp_generator import MockObj +import esphome.final_validate as fv +from esphome.types import ConfigType + +DOMAIN = "ecocomfort2" +CONF_ECOCOMFORT2_ID = "ecocomfort2_id" + +CODEOWNERS = ["@gledian"] +DEPENDENCIES = ["ble_client"] +MULTI_CONF = True + +ecocomfort2_ns = cg.esphome_ns.namespace(DOMAIN) +Ecocomfort2Hub = ecocomfort2_ns.class_( + "Ecocomfort2Hub", ble_client.BLEClientNode, cg.PollingComponent +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(Ecocomfort2Hub), + } + ) + .extend(ble_client.BLE_CLIENT_SCHEMA) + .extend(cv.polling_component_schema("30s")) +) + +ECOCOMFORT2_CLIENT_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_ECOCOMFORT2_ID): cv.use_id(Ecocomfort2Hub), + } +) + +_request_child_slot = cg.slot_counter("ECOCOMFORT2_CHILD_COUNT") + + +async def register_ecocomfort2_child(var: MockObj, config: ConfigType) -> None: + parent = await cg.get_variable(config[CONF_ECOCOMFORT2_ID]) + _request_child_slot(str(parent)) + cg.add(parent.register_child(var)) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await ble_client.register_ble_node(var, config) + + +def _final_validate(config: ConfigType) -> ConfigType: + # The unit only talks over an encrypted, bonded link + ble_conf = fv.full_config.get()[esp32_ble.DOMAIN] + if ( + ble_conf[CONF_IO_CAPABILITY] != "none" + or ble_conf.get(CONF_AUTH_REQ_MODE) != "sc_bond" + or ble_conf.get(CONF_MAX_KEY_SIZE) != 16 + ): + raise cv.Invalid( + "ecocomfort2 needs esp32_ble configured with io_capability: none, " + "auth_req_mode: sc_bond and max_key_size: 16" + ) + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate diff --git a/esphome/components/ecocomfort2/ecocomfort2_child.h b/esphome/components/ecocomfort2/ecocomfort2_child.h new file mode 100644 index 00000000000..b6d2fc87cb4 --- /dev/null +++ b/esphome/components/ecocomfort2/ecocomfort2_child.h @@ -0,0 +1,20 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_ESP32 + +#include "esphome/core/helpers.h" + +namespace esphome::ecocomfort2 { + +class Ecocomfort2Hub; + +class Ecocomfort2Client : public Parented { + public: + /// Called after each successful C_OPER readback. + virtual void on_status() = 0; +}; + +} // namespace esphome::ecocomfort2 + +#endif // USE_ESP32 diff --git a/esphome/components/ecocomfort2/ecocomfort2_fan.cpp b/esphome/components/ecocomfort2/ecocomfort2_fan.cpp new file mode 100644 index 00000000000..c9d64892c48 --- /dev/null +++ b/esphome/components/ecocomfort2/ecocomfort2_fan.cpp @@ -0,0 +1,146 @@ +#include "esphome/core/defines.h" +#ifdef USE_ESP32 +#include "ecocomfort2_fan.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::ecocomfort2 { + +ESPHOME_LOG_TAG(TAG, "ecocomfort2.fan"); + +static constexpr int SPEED_COUNT = SPEED_VEL3; + +struct Preset { + const char *name; + uint8_t mode; + bool auto_mode; +}; + +// Order matters: the fan core persists the preset as an index into this list. +static constexpr Preset PRESETS[] = { + {"In", OPER_IN, false}, + {"Out", OPER_OUT, false}, + {"In-Out", OPER_IN_OUT, false}, + {"Sensor", OPER_SENSOR_OR_AUTO, false}, + {"Auto", OPER_SENSOR_OR_AUTO, true}, +}; +static constexpr const Preset &PRESET_IN_OUT = PRESETS[2]; +static constexpr const Preset &PRESET_SENSOR = PRESETS[3]; + +static const Preset *find_preset(const char *name) { + for (const auto &preset : PRESETS) { + if (preset.name == name) + return &preset; + } + return nullptr; +} + +static const Preset *find_preset(uint8_t mode, bool auto_mode) { + for (const auto &preset : PRESETS) { + if (preset.mode == mode && preset.auto_mode == auto_mode) + return &preset; + } + return nullptr; +} + +void Ecocomfort2Fan::setup() { + this->set_supported_preset_modes( + {PRESETS[0].name, PRESETS[1].name, PRESETS[2].name, PRESETS[3].name, PRESETS[4].name}); + // Restore the last preset and speed so turning on resumes them; on/off comes from the device. + auto restore = this->restore_state_(); + if (restore.has_value()) { + this->speed = restore->speed; + if (restore->preset_mode < std::size(PRESETS)) + this->set_preset_mode_(PRESETS[restore->preset_mode].name); + } +} + +void Ecocomfort2Fan::dump_config() { LOG_FAN("", "Ecocomfort2 Fan", this); } + +fan::FanTraits Ecocomfort2Fan::get_traits() { + fan::FanTraits traits; + traits.set_speed(true); + traits.set_supported_speed_count(SPEED_COUNT); + this->wire_preset_modes_(traits); + return traits; +} + +void Ecocomfort2Fan::control(const fan::FanCall &call) { + if (!this->parent_->is_ready()) { + ESP_LOGW(TAG, "Not ready, cannot handle control call"); + return; + } + + // Any call that does not turn the fan off turns it on + auto state = call.get_state(); + this->state = !state.has_value() || *state; + // The core clears the preset in a speed call; here the preset is the air flow mode, so keep it. + if (call.has_preset_mode()) + this->set_preset_mode_(call.get_preset_mode()); + + const Preset *preset = find_preset(this->get_preset_mode().c_str()); + if (preset == nullptr) { + preset = &PRESET_IN_OUT; + this->set_preset_mode_(preset->name); + } + auto speed = call.get_speed(); + if (speed.has_value()) { + this->speed = *speed; + // A manual speed leaves automatic speed control + if (preset->auto_mode) { + preset = &PRESET_SENSOR; + this->set_preset_mode_(preset->name); + } + } + this->speed = clamp(this->speed, 1, SPEED_COUNT); + + if (!this->state) { + this->parent_->write_oper(OPER_OFF, SPEED_OFF); + } else { + this->parent_->write_oper(preset->mode, preset->auto_mode ? FLAG_AUTO : static_cast(this->speed)); + } + this->publish_state(); +} + +void Ecocomfort2Fan::on_status() { + uint8_t mode = this->parent_->get_mode(); + uint8_t raw_speed = this->parent_->get_speed(); + + // Off keeps the preset and speed so turning on resumes them + if (mode == OPER_OFF) { + if (!this->state) + return; + this->state = false; + this->publish_state(); + return; + } + + int new_speed = this->speed; + uint8_t base_speed = raw_speed & SPEED_MASK; + if (raw_speed & FLAG_BOOST) { + new_speed = SPEED_COUNT; + } else if (raw_speed & FLAG_NIGHT) { + new_speed = SPEED_SLEEP; + } else if (base_speed >= SPEED_SLEEP && base_speed <= SPEED_VEL3) { + new_speed = base_speed; + } + const Preset *preset = find_preset(mode, (raw_speed & FLAG_AUTO) != 0); + const char *new_preset = preset != nullptr ? preset->name : nullptr; + bool same_preset = new_preset == nullptr ? !this->has_preset_mode() : this->get_preset_mode().c_str() == new_preset; + if (this->state && this->speed == new_speed && same_preset) + return; + + this->state = true; + this->speed = new_speed; + if (new_preset != nullptr) { + this->set_preset_mode_(new_preset); + } else { + this->clear_preset_mode_(); + } + this->publish_state(); +} + +} // namespace esphome::ecocomfort2 + +#endif // USE_ESP32 diff --git a/esphome/components/ecocomfort2/ecocomfort2_fan.h b/esphome/components/ecocomfort2/ecocomfort2_fan.h new file mode 100644 index 00000000000..81af55699fe --- /dev/null +++ b/esphome/components/ecocomfort2/ecocomfort2_fan.h @@ -0,0 +1,26 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_ESP32 + +#include "esphome/core/component.h" +#include "esphome/components/fan/fan.h" +#include "ecocomfort2_child.h" +#include "ecocomfort2_hub.h" + +namespace esphome::ecocomfort2 { + +class Ecocomfort2Fan : public fan::Fan, public Ecocomfort2Client, public Component { + public: + void setup() override; + void dump_config() override; + fan::FanTraits get_traits() override; + void on_status() override; + + protected: + void control(const fan::FanCall &call) override; +}; + +} // namespace esphome::ecocomfort2 + +#endif // USE_ESP32 diff --git a/esphome/components/ecocomfort2/ecocomfort2_hub.cpp b/esphome/components/ecocomfort2/ecocomfort2_hub.cpp new file mode 100644 index 00000000000..a20fdd9112c --- /dev/null +++ b/esphome/components/ecocomfort2/ecocomfort2_hub.cpp @@ -0,0 +1,154 @@ +#include "esphome/core/defines.h" +#ifdef USE_ESP32 +#include "ecocomfort2_hub.h" +#include "esphome/core/log.h" + +namespace esphome::ecocomfort2 { + +ESPHOME_LOG_TAG(TAG, "ecocomfort2"); + +static const espbt::ESPBTUUID SERVICE_UUID = espbt::ESPBTUUID::from_raw("f4b827c3-e660-4bc8-bdf6-3c8e9b845e0d"); +static const espbt::ESPBTUUID C_OPER_UUID = espbt::ESPBTUUID::from_raw("b9d6f678-bc0d-4a73-90c8-60b0f07301f1"); + +void Ecocomfort2Hub::update() { + if (!this->is_ready()) { + ESP_LOGD(TAG, "[%s] Not ready, skipping update", this->parent_->address_str()); + return; + } + auto status = esp_ble_gattc_read_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), + this->char_handle_oper_, ESP_GATT_AUTH_REQ_NONE); + if (status != ESP_GATT_OK) { + ESP_LOGW(TAG, "[%s] C_OPER read request failed, status=%d", this->parent_->address_str(), status); + } +} + +void Ecocomfort2Hub::dump_config() { + ESP_LOGCONFIG(TAG, + "Ecocomfort2 Hub:\n" + " Address: %s", + this->parent_->address_str()); + LOG_UPDATE_INTERVAL(this); +} + +void Ecocomfort2Hub::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, + esp_ble_gattc_cb_param_t *param) { + switch (event) { + case ESP_GATTC_DISCONNECT_EVT: { + ESP_LOGD(TAG, "[%s] Disconnected: reason=%d", this->parent_->address_str(), param->disconnect.reason); + this->cancel_timeout(AUTH_TIMEOUT_ID); + this->cancel_timeout(READBACK_TIMEOUT_ID); + this->status_set_warning(); + this->char_handle_oper_ = 0; + break; + } + case ESP_GATTC_SEARCH_CMPL_EVT: { + // Keep the handle, not the characteristic: ble_client frees its services once all nodes are established. + auto *chr = this->parent_->get_characteristic(SERVICE_UUID, C_OPER_UUID); + if (chr == nullptr) { + ESP_LOGW(TAG, "[%s] No C_OPER characteristic found, disconnecting", this->parent_->address_str()); + this->parent_->disconnect(); + break; + } + this->char_handle_oper_ = chr->handle; + this->node_state = espbt::ClientState::ESTABLISHED; + this->status_set_warning(); + + // A bonded device may already have re-encrypted the link before discovery finished. + if (this->parent_->is_paired()) { + this->on_paired_(); + break; + } + ESP_LOGI(TAG, "[%s] Requesting BLE encryption", this->parent_->address_str()); + if (this->parent_->pair() != ESP_OK) { + ESP_LOGW(TAG, "[%s] BLE pair request failed, disconnecting", this->parent_->address_str()); + this->parent_->disconnect(); + break; + } + this->set_timeout(AUTH_TIMEOUT_ID, AUTH_TIMEOUT_MS, [this]() { + ESP_LOGW(TAG, "[%s] BLE authentication timed out, disconnecting", this->parent_->address_str()); + this->parent_->disconnect(); + }); + break; + } + case ESP_GATTC_READ_CHAR_EVT: { + if (param->read.conn_id != this->parent_->get_conn_id() || param->read.handle != this->char_handle_oper_) { + break; + } + if (param->read.status == ESP_GATT_INSUF_ENCRYPTION || param->read.status == ESP_GATT_INSUF_AUTHENTICATION) { + // ble_client reconnects and pairs again + ESP_LOGW(TAG, "[%s] Encryption lost, disconnecting", this->parent_->address_str()); + this->parent_->disconnect(); + break; + } + if (param->read.status != ESP_GATT_OK) { + ESP_LOGW(TAG, "[%s] C_OPER read failed, status=%d", this->parent_->address_str(), param->read.status); + break; + } + if (param->read.value_len < 2) { + ESP_LOGW(TAG, "[%s] C_OPER too short: %u bytes", this->parent_->address_str(), param->read.value_len); + break; + } + this->mode_ = param->read.value[0]; + this->speed_ = param->read.value[1]; + ESP_LOGD(TAG, "[%s] C_OPER: mode=0x%02x, speed=0x%02x", this->parent_->address_str(), this->mode_, this->speed_); +#ifdef ECOCOMFORT2_CHILD_COUNT + for (auto *child : this->children_) { + child->on_status(); + } +#endif + break; + } + default: + break; + } +} + +void Ecocomfort2Hub::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { + if (event != ESP_GAP_BLE_AUTH_CMPL_EVT || !this->parent_->check_addr(param->ble_security.auth_cmpl.bd_addr) || + this->node_state != espbt::ClientState::ESTABLISHED) { + return; + } + if (!param->ble_security.auth_cmpl.success) { + this->cancel_timeout(AUTH_TIMEOUT_ID); + ESP_LOGW(TAG, "[%s] BLE authentication failed, reason=%d, disconnecting", this->parent_->address_str(), + param->ble_security.auth_cmpl.fail_reason); + this->parent_->disconnect(); + return; + } + this->on_paired_(); +} + +void Ecocomfort2Hub::on_paired_() { + this->cancel_timeout(AUTH_TIMEOUT_ID); + this->status_clear_warning(); + ESP_LOGI(TAG, "[%s] Encryption confirmed, device ready", this->parent_->address_str()); + this->update(); +} + +void Ecocomfort2Hub::write_oper(uint8_t mode, uint8_t speed) { + if (!this->is_ready()) { + ESP_LOGW(TAG, "[%s] Not ready, cannot write C_OPER", this->parent_->address_str()); + return; + } + uint8_t data[2]{mode, speed}; + ESP_LOGD(TAG, "[%s] Writing C_OPER: mode=0x%02x, speed=0x%02x", this->parent_->address_str(), mode, speed); + auto status = + esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_oper_, + sizeof(data), data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); + if (status != ESP_GATT_OK) { + ESP_LOGW(TAG, "[%s] C_OPER write failed, status=%d", this->parent_->address_str(), status); + return; + } + this->set_timeout(READBACK_TIMEOUT_ID, READBACK_DELAY_MS, [this]() { this->update(); }); +} + +#ifdef ECOCOMFORT2_CHILD_COUNT +void Ecocomfort2Hub::register_child(Ecocomfort2Client *obj) { + this->children_.push_back(obj); + obj->set_parent(this); +} +#endif + +} // namespace esphome::ecocomfort2 + +#endif // USE_ESP32 diff --git a/esphome/components/ecocomfort2/ecocomfort2_hub.h b/esphome/components/ecocomfort2/ecocomfort2_hub.h new file mode 100644 index 00000000000..5a609d9283e --- /dev/null +++ b/esphome/components/ecocomfort2/ecocomfort2_hub.h @@ -0,0 +1,76 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_ESP32 + +#include "esphome/components/ble_client/ble_client.h" +#include "esphome/core/component.h" +#include "esphome/core/helpers.h" +#include "ecocomfort2_child.h" + +namespace esphome::ecocomfort2 { + +namespace espbt = esphome::esp32_ble_tracker; + +// C_OPER byte 0: operating mode +static constexpr uint8_t OPER_OFF = 0x00; +static constexpr uint8_t OPER_IN = 0x01; +static constexpr uint8_t OPER_OUT = 0x02; +static constexpr uint8_t OPER_IN_OUT = 0x03; +static constexpr uint8_t OPER_SENSOR_OR_AUTO = 0x04; + +// C_OPER byte 1: speed in the low nibble, flags in the high nibble +static constexpr uint8_t SPEED_OFF = 0x00; +static constexpr uint8_t SPEED_SLEEP = 0x01; +static constexpr uint8_t SPEED_VEL1 = 0x02; +static constexpr uint8_t SPEED_VEL2 = 0x03; +static constexpr uint8_t SPEED_VEL3 = 0x04; +static constexpr uint8_t SPEED_MASK = 0x0F; +static constexpr uint8_t FLAG_AUTO = 0x10; +static constexpr uint8_t FLAG_BOOST = 0x40; +static constexpr uint8_t FLAG_NIGHT = 0x80; + +/** Hub component for Ecocomfort 2.0 VMC via BLE. */ +class Ecocomfort2Hub : public esphome::ble_client::BLEClientNode, public PollingComponent { + public: + void update() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::BLUETOOTH; } + + void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, + esp_ble_gattc_cb_param_t *param) override; + void gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) override; + +#ifdef ECOCOMFORT2_CHILD_COUNT + void register_child(Ecocomfort2Client *obj); +#endif + + /// True once the link is established and paired. + bool is_ready() const { return this->node_state == espbt::ClientState::ESTABLISHED && this->parent_->is_paired(); } + + /// Write C_OPER (mode byte, speed/flags byte) and read it back shortly after. + void write_oper(uint8_t mode, uint8_t speed); + + /// Raw C_OPER values from the last readback. + uint8_t get_mode() const { return this->mode_; } + uint8_t get_speed() const { return this->speed_; } + + protected: + static constexpr uint32_t AUTH_TIMEOUT_ID = 0; + static constexpr uint32_t READBACK_TIMEOUT_ID = 1; + static constexpr uint32_t AUTH_TIMEOUT_MS = 10000; + static constexpr uint32_t READBACK_DELAY_MS = 300; + + void on_paired_(); + +#ifdef ECOCOMFORT2_CHILD_COUNT + StaticVector children_; +#endif + uint16_t char_handle_oper_{0}; + uint8_t mode_{OPER_OFF}; + uint8_t speed_{SPEED_OFF}; +}; + +} // namespace esphome::ecocomfort2 + +#endif // USE_ESP32 diff --git a/esphome/components/ecocomfort2/fan.py b/esphome/components/ecocomfort2/fan.py new file mode 100644 index 00000000000..e3dd8e09386 --- /dev/null +++ b/esphome/components/ecocomfort2/fan.py @@ -0,0 +1,23 @@ +import esphome.codegen as cg +from esphome.components import fan +import esphome.config_validation as cv +from esphome.types import ConfigType + +from . import ECOCOMFORT2_CLIENT_SCHEMA, ecocomfort2_ns, register_ecocomfort2_child + +CODEOWNERS = ["@gledian"] +DEPENDENCIES = ["ecocomfort2"] + +Ecocomfort2Fan = ecocomfort2_ns.class_("Ecocomfort2Fan", fan.Fan, cg.Component) + +CONFIG_SCHEMA = ( + fan.fan_schema(Ecocomfort2Fan) + .extend(cv.COMPONENT_SCHEMA) + .extend(ECOCOMFORT2_CLIENT_SCHEMA) +) + + +async def to_code(config: ConfigType) -> None: + var = await fan.new_fan(config) + await cg.register_component(var, config) + await register_ecocomfort2_child(var, config) diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 943458e3f64..a085cf9c0ac 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -430,6 +430,7 @@ #define ESPHOME_ESP32_BLE_GAP_SCAN_EVENT_HANDLER_COUNT 1 #define ESPHOME_ESP32_BLE_GATTC_EVENT_HANDLER_COUNT 1 #define ESPHOME_ESP32_BLE_GATTS_EVENT_HANDLER_COUNT 1 +#define ECOCOMFORT2_CHILD_COUNT 1 #define ESPHOME_ESP32_BLE_BLE_STATUS_EVENT_HANDLER_COUNT 2 #define ESPHOME_ESP32_BLE_EXTENDED_AUTH_PARAMS #define ESPHOME_LOOP_TASK_STACK_SIZE 8192 diff --git a/tests/components/ecocomfort2/common.yaml b/tests/components/ecocomfort2/common.yaml new file mode 100644 index 00000000000..b4951e804f8 --- /dev/null +++ b/tests/components/ecocomfort2/common.yaml @@ -0,0 +1,24 @@ +esp32_ble: + io_capability: none + auth_req_mode: sc_bond + max_key_size: 16 + +ble_client: + - mac_address: 01:02:03:04:05:06 + id: ecocomfort2_ble_1 + - mac_address: 01:02:03:04:05:07 + id: ecocomfort2_ble_2 + +ecocomfort2: + - id: ecocomfort2_hub_1 + ble_client_id: ecocomfort2_ble_1 + - id: ecocomfort2_hub_2 + ble_client_id: ecocomfort2_ble_2 + +fan: + - platform: ecocomfort2 + name: Ecocomfort2 Fan 1 + ecocomfort2_id: ecocomfort2_hub_1 + - platform: ecocomfort2 + name: Ecocomfort2 Fan 2 + ecocomfort2_id: ecocomfort2_hub_2 diff --git a/tests/components/ecocomfort2/test.esp32-idf.yaml b/tests/components/ecocomfort2/test.esp32-idf.yaml new file mode 100644 index 00000000000..956cd9c8135 --- /dev/null +++ b/tests/components/ecocomfort2/test.esp32-idf.yaml @@ -0,0 +1,3 @@ +packages: + ble: !include ../../test_build_components/common/ble/esp32-idf.yaml + ecocomfort2: !include common.yaml