[ecocomfort2] Add component for Fantini Cosmi VMC units via BLE (Part 1: hub + fan) (#17092)

Co-authored-by: gledian <gledina.kruja@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
gledian
2026-10-12 11:36:27 -10:00
committed by GitHub
co-authored by gledian Claude Opus 4.8 J. Nick Koston J. Nick Koston
parent 5a819f4b48
commit 93183c661e
11 changed files with 546 additions and 0 deletions
+1
View File
@@ -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
@@ -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
@@ -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<Ecocomfort2Hub> {
public:
/// Called after each successful C_OPER readback.
virtual void on_status() = 0;
};
} // namespace esphome::ecocomfort2
#endif // USE_ESP32
@@ -0,0 +1,146 @@
#include "esphome/core/defines.h"
#ifdef USE_ESP32
#include "ecocomfort2_fan.h"
#include "esphome/core/log.h"
#include <iterator>
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<uint8_t>(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
@@ -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
@@ -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
@@ -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<Ecocomfort2Client *, ECOCOMFORT2_CHILD_COUNT> children_;
#endif
uint16_t char_handle_oper_{0};
uint8_t mode_{OPER_OFF};
uint8_t speed_{SPEED_OFF};
};
} // namespace esphome::ecocomfort2
#endif // USE_ESP32
+23
View File
@@ -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)
+1
View File
@@ -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
+24
View File
@@ -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
@@ -0,0 +1,3 @@
packages:
ble: !include ../../test_build_components/common/ble/esp32-idf.yaml
ecocomfort2: !include common.yaml