diff --git a/CODEOWNERS b/CODEOWNERS index c630db7948..0349920a1e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -197,6 +197,7 @@ esphome/components/lilygo_t5_47/touchscreen/* @jesserockz esphome/components/lock/* @esphome/core esphome/components/logger/* @esphome/core esphome/components/ltr390/* @sjtrny +esphome/components/madoka/* @Petapton esphome/components/matrix_keypad/* @ssieb esphome/components/max31865/* @DAVe3283 esphome/components/max44009/* @berfenger diff --git a/esphome/components/madoka/__init__.py b/esphome/components/madoka/__init__.py new file mode 100644 index 0000000000..072a86fb6a --- /dev/null +++ b/esphome/components/madoka/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@Petapton"] diff --git a/esphome/components/madoka/climate.py b/esphome/components/madoka/climate.py new file mode 100644 index 0000000000..bd50afdc26 --- /dev/null +++ b/esphome/components/madoka/climate.py @@ -0,0 +1,29 @@ +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.components import climate, ble_client +from esphome.const import CONF_ID + +CODEOWNERS = ["@Petapton"] +DEPENDENCIES = ["ble_client"] + +madoka_ns = cg.esphome_ns.namespace("madoka") +Madoka = madoka_ns.class_( + "Madoka", climate.Climate, ble_client.BLEClientNode, cg.PollingComponent +) + +CONFIG_SCHEMA = ( + climate.CLIMATE_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(Madoka), + } + ) + .extend(ble_client.BLE_CLIENT_SCHEMA) + .extend(cv.polling_component_schema("10s")) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await climate.register_climate(var, config) + await ble_client.register_ble_node(var, config) diff --git a/esphome/components/madoka/madoka.cpp b/esphome/components/madoka/madoka.cpp new file mode 100644 index 0000000000..3c1dbfd403 --- /dev/null +++ b/esphome/components/madoka/madoka.cpp @@ -0,0 +1,413 @@ +#include "madoka.h" + +#include "esphome/core/log.h" +#include + +#ifdef USE_ESP32 + +namespace esphome { +namespace madoka { + +using namespace esphome::climate; + +void Madoka::dump_config() { LOG_CLIMATE(TAG, "Daikin Madoka Climate Controller", this); } + +void Madoka::setup() { this->receive_semaphore_ = xSemaphoreCreateMutex(); } + +void Madoka::loop() { + chunk chk = {}; + if (xSemaphoreTake(this->receive_semaphore_, 0L)) { + if (!this->received_chunks_.empty()) { + chk = this->received_chunks_.front(); + this->received_chunks_.pop(); + } + xSemaphoreGive(this->receive_semaphore_); + if (!chk.empty()) { + this->process_incoming_chunk_(chk); + } + } + if (this->should_update_) { + this->should_update_ = false; + this->update(); + } +} + +void Madoka::control(const ClimateCall &call) { + if (this->node_state != espbt::ClientState::ESTABLISHED) + return; + if (call.get_mode().has_value()) { + ClimateMode mode = *call.get_mode(); + std::vector pkt; + uint8_t mode_out = 255, status_out = 0; + switch (mode) { + case climate::CLIMATE_MODE_OFF: + status_out = 0; + break; + case climate::CLIMATE_MODE_HEAT_COOL: + status_out = 1; + mode_out = 2; + break; + case climate::CLIMATE_MODE_COOL: + status_out = 1; + mode_out = 3; + break; + case climate::CLIMATE_MODE_HEAT: + status_out = 1; + mode_out = 4; + break; + case climate::CLIMATE_MODE_FAN_ONLY: + status_out = 1; + mode_out = 0; + break; + case climate::CLIMATE_MODE_DRY: + status_out = 1; + mode_out = 1; + break; + default: + ESP_LOGW(TAG, "Unsupported mode: %d", mode); + break; + } + ESP_LOGD(TAG, "status: %d, mode: %d", status_out, mode_out); + if (mode_out != 255) { + this->query_(0x4030, message({0x20, 0x01, (uint8_t) mode_out}), 600); + } + this->query_(0x4020, message({0x20, 0x01, (uint8_t) status_out}), 200); + } + if (call.get_target_temperature_low().has_value() && call.get_target_temperature_high().has_value()) { + uint16_t target_low = *call.get_target_temperature_low() * 128; + uint16_t target_high = *call.get_target_temperature_high() * 128; + this->query_(0x4040, + message({0x20, 0x02, (uint8_t) ((target_high >> 8) & 0xFF), (uint8_t) (target_high & 0xFF), 0x21, 0x02, + (uint8_t) ((target_low >> 8) & 0xFF), (uint8_t) (target_low & 0xFF)}), + 400); + } + if (call.get_fan_mode().has_value()) { + uint8_t fan_mode = call.get_fan_mode().value(); + uint8_t fan_mode_out = 255; + switch (fan_mode) { + case climate::CLIMATE_FAN_AUTO: + fan_mode_out = 0; + break; + case climate::CLIMATE_FAN_LOW: + fan_mode_out = 1; + break; + case climate::CLIMATE_FAN_MEDIUM: + fan_mode_out = 3; + break; + case climate::CLIMATE_FAN_HIGH: + fan_mode_out = 5; + break; + default: + ESP_LOGW(TAG, "Unsupported fan mode: %d", fan_mode); + break; + } + if (fan_mode_out != 255) { + this->query_(0x4050, message({0x20, 0x01, (uint8_t) fan_mode_out, 0x21, 0x01, (uint8_t) fan_mode_out}), 200); + } + } + this->should_update_ = true; +} + +void Madoka::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *param) { + switch (event) { + case ESP_GAP_BLE_SEC_REQ_EVT: + esp_ble_gap_security_rsp(param->ble_security.ble_req.bd_addr, true); + break; + case ESP_GAP_BLE_NC_REQ_EVT: + esp_ble_confirm_reply(param->ble_security.ble_req.bd_addr, true); + ESP_LOGI(TAG, "ESP_GAP_BLE_NC_REQ_EVT, the passkey Notify number:%d", param->ble_security.key_notif.passkey); + break; + case ESP_GAP_BLE_AUTH_CMPL_EVT: { + if (!param->ble_security.auth_cmpl.success) { + ESP_LOGE(TAG, "Authentication failed, status: 0x%x", param->ble_security.auth_cmpl.fail_reason); + break; + } + auto *nfy = this->parent_->get_characteristic(MADOKA_SERVICE_UUID, NOTIFY_CHARACTERISTIC_UUID); + auto *wwr = this->parent_->get_characteristic(MADOKA_SERVICE_UUID, WWR_CHARACTERISTIC_UUID); + if (nfy == nullptr || wwr == nullptr) { + ESP_LOGW(TAG, "[%s] No control service found at device, not a Daikin Madoka..?", this->get_name().c_str()); + break; + } + this->notify_handle_ = nfy->handle; + this->wwr_handle_ = wwr->handle; + + auto status = esp_ble_gattc_register_for_notify(this->parent_->get_gattc_if(), this->parent_->get_remote_bda(), + nfy->handle); + if (status) { + ESP_LOGW(TAG, "[%s] esp_ble_gattc_register_for_notify failed, status=%d", this->get_name().c_str(), status); + } + break; + } + default: + break; + } +} + +void Madoka::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: { + this->node_state = espbt::ClientState::IDLE; // ?? + this->current_temperature = NAN; + this->target_temperature = NAN; + this->publish_state(); + break; + } + case ESP_GATTC_WRITE_DESCR_EVT: + if (param->write.status != ESP_GATT_OK) { + if (param->write.status == ESP_GATT_INSUF_AUTHENTICATION) { + ESP_LOGE(TAG, "Insufficient authentication"); + } else { + ESP_LOGE(TAG, "Failed writing characteristic descriptor, status = 0x%x", param->write.status); + } + } + break; + case ESP_GATTC_SEARCH_CMPL_EVT: { + esp_ble_set_encryption(this->parent_->get_remote_bda(), ESP_BLE_SEC_ENCRYPT_MITM); + break; + } + case ESP_GATTC_REG_FOR_NOTIFY_EVT: { + this->node_state = espbt::ClientState::ESTABLISHED; // ?? + break; + } + case ESP_GATTC_NOTIFY_EVT: { + if (param->notify.handle != this->notify_handle_) { + ESP_LOGW(TAG, "Different notify handle"); + break; + } + chunk chk = chunk(param->notify.value, param->notify.value + param->notify.value_len); + xSemaphoreTake(this->receive_semaphore_, portMAX_DELAY); + this->received_chunks_.push(chk); + xSemaphoreGive(this->receive_semaphore_); + break; + } + default: + break; + } +} + +void Madoka::update() { + ESP_LOGD(TAG, "Got update request..."); + if (this->node_state != espbt::ClientState::ESTABLISHED) { + ESP_LOGD(TAG, "...but device is disconnected"); + return; + } + + std::vector all_cmds({0x0020, 0x0030, 0x0040, 0x0050, 0x0110}); + for (auto cmd : all_cmds) { + this->query_(cmd, message({0x00, 0x00}), 50); + } +} + +bool validate_buffer(message buffer) { return buffer[0] == buffer.size(); } + +void Madoka::process_incoming_chunk_(chunk chk) { + if (chk.size() < 2) { + ESP_LOGI(TAG, "Chunk discarded: invalid length."); + return; + } + uint8_t chunk_id = chk[0]; + message stripped(chk.begin() + 1, chk.end()); + if (chunk_id == 0 && validate_buffer(stripped)) { + this->parse_cb_(stripped); + return; + } + if (this->pending_chunks_.count(chunk_id)) { + ESP_LOGE(TAG, "Another packet with the same chunk ID is already in the buffer."); + ESP_LOGD(TAG, "Chunk ID: %d.", chunk_id); + return; + } + this->pending_chunks_[chunk_id] = chk; + + if (this->pending_chunks_.size() != this->pending_chunks_.rbegin()->first + 1) { + ESP_LOGW(TAG, "Buffer is missing packets"); + return; + } + + message msg; + int lim = this->pending_chunks_.size(); + for (int i = 0; i < lim; i++) { + msg.insert(msg.end(), this->pending_chunks_[i].begin() + 1, this->pending_chunks_[i].end()); + } + if (validate_buffer(msg)) { + this->pending_chunks_.clear(); + this->parse_cb_(msg); + } +} + +std::vector Madoka::split_payload_(message msg) { + std::vector result; + size_t len = msg.size(); + result.push_back(chunk({0x00, (uint8_t) (len + 1)})); + result[0].insert(result[0].end(), msg.begin(), min(msg.begin() + (MAX_CHUNK_SIZE - 2), msg.end())); + int i = 0; + for (i = 1; i < len / (MAX_CHUNK_SIZE - 1); i++) { // from second to second-last + result.emplace_back(msg.begin() + ((MAX_CHUNK_SIZE - 1) * i - 1), + msg.begin() + ((MAX_CHUNK_SIZE - 1) * (i + 1) - 1)); + } + if (len > 18) { + i++; + result.emplace_back(msg.begin() + ((MAX_CHUNK_SIZE - 1) * i), msg.end()); + } + return result; +} + +message Madoka::prepare_message_(uint16_t cmd, message args) { + message result({0x00, (uint8_t) ((cmd >> 8) & 0xFF), (uint8_t) (cmd & 0xFF)}); + result.insert(result.end(), args.begin(), args.end()); + return result; +} + +void Madoka::query_(uint16_t cmd, message args, int t_d) { + message payload = this->prepare_message_(cmd, std::move(args)); + + if (this->node_state != espbt::ClientState::ESTABLISHED) { + return; + } + std::vector chunks = this->split_payload_(payload); + + for (auto chk : chunks) { + esp_err_t status; + for (int j = 0; j < BLE_SEND_MAX_RETRIES; j++) { + status = esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->wwr_handle_, + chk.size(), &chk[0], ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); + if (!status) { + break; + } + ESP_LOGD(TAG, "[%s] esp_ble_gattc_write_char failed (%d of %d), status=%d", this->parent_->address_str().c_str(), + j + 1, BLE_SEND_MAX_RETRIES, status); + } + if (status) { + ESP_LOGE(TAG, "[%s] Command could not be sent, last status=%d", this->parent_->address_str().c_str(), status); + return; + } + } + delay(t_d); +} + +void Madoka::parse_cb_(message msg) { + uint16_t function_id = msg[2] << 8 | msg[3]; + uint8_t i = 4; + uint8_t message_size = msg.size(); + + switch (function_id) { + case 0x0020: + while (i < message_size) { + uint8_t argument_id = msg[i++]; + uint8_t len = msg[i++]; + if (argument_id == 0x20) { + message val(msg.begin() + i, msg.begin() + i + len); + this->cur_status_.status = val[0]; + } + i += len; + } + case 0x0030: + while (i < message_size) { + uint8_t argument_id = msg[i++]; + uint8_t len = msg[i++]; + if (argument_id == 0x20) { + message val(msg.begin() + i, msg.begin() + i + len); + this->cur_status_.mode = val[0]; + } + i += len; + } + default: + break; + } + switch (function_id) { + case 0x0020: + case 0x0030: + // ESP_LOGI(TAG, "status: %d, mode: %d", this->cur_status_.status, this->cur_status_.mode); + if (this->cur_status_.status) { + switch (this->cur_status_.mode) { + case 0: + this->mode = climate::CLIMATE_MODE_FAN_ONLY; + break; + case 1: + this->mode = climate::CLIMATE_MODE_DRY; + break; + case 2: + this->mode = climate::CLIMATE_MODE_HEAT_COOL; + break; + case 3: + this->mode = climate::CLIMATE_MODE_COOL; + break; + case 4: + this->mode = climate::CLIMATE_MODE_HEAT; + break; + } + } else { + this->mode = climate::CLIMATE_MODE_OFF; + } + break; + case 0x0040: + while (i < message_size) { + uint8_t argument_id = msg[i++]; + uint8_t len = msg[i++]; + switch (argument_id) { + case 0x20: { + message val(msg.begin() + i, msg.begin() + i + len); + this->target_temperature_high = (float) (val[0] << 8 | val[1]) / 128; + break; + } + case 0x21: { + message val(msg.begin() + i, msg.begin() + i + len); + this->target_temperature_low = (float) (val[0] << 8 | val[1]) / 128; + break; + } + } + i += len; + } + break; + case 0x0050: { + uint8_t fan_mode = 255; + while (i < message_size) { + uint8_t argument_id = msg[i++]; + uint8_t len = msg[i++]; + if (this->cur_status_.mode == 1) { + } else if ((argument_id == 0x21 && len == 1 && this->cur_status_.mode == 4) || + (argument_id == 0x20 && len == 1 && this->cur_status_.mode != 4)) { + fan_mode = msg[i]; + } + i += len; + } + switch (fan_mode) { + case 0: + this->fan_mode = climate::CLIMATE_FAN_AUTO; + break; + case 1: + this->fan_mode = climate::CLIMATE_FAN_LOW; + break; + case 2: + case 3: + case 4: + this->fan_mode = climate::CLIMATE_FAN_MEDIUM; + break; + case 5: + this->fan_mode = climate::CLIMATE_FAN_HIGH; + default: + break; + } + break; + } + case 0x0110: + while (i < message_size) { + uint8_t argument_id = msg[i++]; + uint8_t len = msg[i++]; + if (argument_id == 0x40) { + message val(msg.begin() + i, msg.begin() + i + len); + this->current_temperature = val[0]; + } + i += len; + } + break; + default: + break; + } + + this->publish_state(); +} + +} // namespace madoka +} // namespace esphome + +#endif diff --git a/esphome/components/madoka/madoka.h b/esphome/components/madoka/madoka.h new file mode 100644 index 0000000000..b89263b0e0 --- /dev/null +++ b/esphome/components/madoka/madoka.h @@ -0,0 +1,114 @@ +#pragma once + +#include +#include +#include +#include + +#include "esphome/core/component.h" +#include "esphome/components/ble_client/ble_client.h" +#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" +#include "esphome/components/climate/climate.h" + +// #define USE_ESP32 + +#ifdef USE_ESP32 + +#include + +static const uint8_t MAX_CHUNK_SIZE = 20; +static const uint8_t BLE_SEND_MAX_RETRIES = 5; + +namespace esphome { +namespace madoka { + +static const char *const TAG = "madoka"; + +using chunk = std::vector; +using message = std::vector; + +struct Setpoint { + uint16_t cooling; + uint16_t heating; +}; + +struct FanSpeed { + uint8_t cooling; + uint8_t heating; +}; + +struct SensorReading { + uint8_t indoor; + uint8_t outdoor; +}; + +struct Status { + bool status; + uint8_t mode; +}; + +namespace espbt = esphome::esp32_ble_tracker; + +#define TO_ESPBTUUID(x) espbt::ESPBTUUID::from_raw(std::string(x)) + +#define MADOKA_SERVICE_UUID TO_ESPBTUUID("2141e110-213a-11e6-b67b-9e71128cae77") +#define NOTIFY_CHARACTERISTIC_UUID TO_ESPBTUUID("2141e111-213a-11e6-b67b-9e71128cae77") +#define WWR_CHARACTERISTIC_UUID TO_ESPBTUUID("2141e112-213a-11e6-b67b-9e71128cae77") + +class Madoka : public climate::Climate, public esphome::ble_client::BLEClientNode, public PollingComponent { + protected: + bool should_update_ = false; + std::queue received_chunks_ = {}; + std::map pending_chunks_ = {}; + uint16_t notify_handle_; + uint16_t wwr_handle_; + SemaphoreHandle_t receive_semaphore_ = nullptr; + Status cur_status_; + + std::vector split_payload_(message msg); + message prepare_message_(uint16_t cmd, message args); + void query_(uint16_t cmd, message args, int t_d); + void parse_cb_(message msg); + void process_incoming_chunk_(chunk chk); + + void control(const climate::ClimateCall &call) override; + + public: + void setup() override; + void loop() override; + void update() override; + 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; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::DATA; } + climate::ClimateTraits traits() override { + auto traits = climate::ClimateTraits(); + traits.set_supported_modes({ + climate::CLIMATE_MODE_OFF, + climate::CLIMATE_MODE_HEAT_COOL, + climate::CLIMATE_MODE_COOL, + climate::CLIMATE_MODE_HEAT, + climate::CLIMATE_MODE_FAN_ONLY, + climate::CLIMATE_MODE_DRY, + }); + traits.set_supported_fan_modes({ + climate::CLIMATE_FAN_LOW, + climate::CLIMATE_FAN_MEDIUM, + climate::CLIMATE_FAN_HIGH, + climate::CLIMATE_FAN_AUTO, + }); + traits.set_visual_min_temperature(16); + traits.set_visual_max_temperature(32); + traits.set_visual_temperature_step(1); + traits.set_supports_two_point_target_temperature(true); + traits.set_supports_current_temperature(true); + return traits; + } + void set_unit_of_measurement(const char *); +}; + +} // namespace madoka +} // namespace esphome + +#endif