mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-30 22:53:59 +00:00 
			
		
		
		
	Anova ble component (#1752)
Co-authored-by: Ben Buxton <bb@cactii.net> Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
This commit is contained in:
		| @@ -15,6 +15,7 @@ esphome/components/ac_dimmer/* @glmnet | ||||
| esphome/components/adc/* @esphome/core | ||||
| esphome/components/addressable_light/* @justfalter | ||||
| esphome/components/animation/* @syndlex | ||||
| esphome/components/anova/* @buxtronix | ||||
| esphome/components/api/* @OttoWinter | ||||
| esphome/components/async_tcp/* @OttoWinter | ||||
| esphome/components/atc_mithermometer/* @ahpohl | ||||
|   | ||||
							
								
								
									
										0
									
								
								esphome/components/anova/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								esphome/components/anova/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										140
									
								
								esphome/components/anova/anova.cpp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										140
									
								
								esphome/components/anova/anova.cpp
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,140 @@ | ||||
| #include "anova.h" | ||||
| #include "esphome/core/log.h" | ||||
|  | ||||
| #ifdef ARDUINO_ARCH_ESP32 | ||||
|  | ||||
| namespace esphome { | ||||
| namespace anova { | ||||
|  | ||||
| static const char *TAG = "anova"; | ||||
|  | ||||
| using namespace esphome::climate; | ||||
|  | ||||
| void Anova::dump_config() { LOG_CLIMATE("", "Anova BLE Cooker", this); } | ||||
|  | ||||
| void Anova::setup() { | ||||
|   this->codec_ = new AnovaCodec(); | ||||
|   this->current_request_ = 0; | ||||
| } | ||||
|  | ||||
| void Anova::loop() {} | ||||
|  | ||||
| void Anova::control(const ClimateCall &call) { | ||||
|   if (call.get_mode().has_value()) { | ||||
|     ClimateMode mode = *call.get_mode(); | ||||
|     AnovaPacket *pkt; | ||||
|     switch (mode) { | ||||
|       case climate::CLIMATE_MODE_OFF: | ||||
|         pkt = this->codec_->get_stop_request(); | ||||
|         break; | ||||
|       case climate::CLIMATE_MODE_HEAT: | ||||
|         pkt = this->codec_->get_start_request(); | ||||
|         break; | ||||
|       default: | ||||
|         ESP_LOGW(TAG, "Unsupported mode: %d", mode); | ||||
|         return; | ||||
|     } | ||||
|     auto status = esp_ble_gattc_write_char(this->parent_->gattc_if, this->parent_->conn_id, this->char_handle_, | ||||
|                                            pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); | ||||
|     if (status) | ||||
|       ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str().c_str(), status); | ||||
|   } | ||||
|   if (call.get_target_temperature().has_value()) { | ||||
|     auto pkt = this->codec_->get_set_target_temp_request(*call.get_target_temperature()); | ||||
|     auto status = esp_ble_gattc_write_char(this->parent_->gattc_if, this->parent_->conn_id, this->char_handle_, | ||||
|                                            pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); | ||||
|     if (status) | ||||
|       ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str().c_str(), status); | ||||
|   } | ||||
| } | ||||
|  | ||||
| void Anova::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->current_temperature = NAN; | ||||
|       this->target_temperature = NAN; | ||||
|       this->publish_state(); | ||||
|       break; | ||||
|     } | ||||
|     case ESP_GATTC_SEARCH_CMPL_EVT: { | ||||
|       auto chr = this->parent_->get_characteristic(ANOVA_SERVICE_UUID, ANOVA_CHARACTERISTIC_UUID); | ||||
|       if (chr == nullptr) { | ||||
|         ESP_LOGW(TAG, "[%s] No control service found at device, not an Anova..?", this->get_name().c_str()); | ||||
|         break; | ||||
|       } | ||||
|       this->char_handle_ = chr->handle; | ||||
|  | ||||
|       auto status = esp_ble_gattc_register_for_notify(this->parent_->gattc_if, this->parent_->remote_bda, chr->handle); | ||||
|       if (status) { | ||||
|         ESP_LOGW(TAG, "[%s] esp_ble_gattc_register_for_notify failed, status=%d", this->get_name().c_str(), status); | ||||
|       } | ||||
|       break; | ||||
|     } | ||||
|     case ESP_GATTC_REG_FOR_NOTIFY_EVT: { | ||||
|       this->node_state = espbt::ClientState::Established; | ||||
|       this->current_request_ = 0; | ||||
|       this->update(); | ||||
|       break; | ||||
|     } | ||||
|     case ESP_GATTC_NOTIFY_EVT: { | ||||
|       if (param->notify.handle != this->char_handle_) | ||||
|         break; | ||||
|       this->codec_->decode(param->notify.value, param->notify.value_len); | ||||
|       if (this->codec_->has_target_temp()) { | ||||
|         this->target_temperature = this->codec_->target_temp_; | ||||
|       } | ||||
|       if (this->codec_->has_current_temp()) { | ||||
|         this->current_temperature = this->codec_->current_temp_; | ||||
|       } | ||||
|       if (this->codec_->has_running()) { | ||||
|         this->mode = this->codec_->running_ ? climate::CLIMATE_MODE_HEAT : climate::CLIMATE_MODE_OFF; | ||||
|       } | ||||
|       this->publish_state(); | ||||
|  | ||||
|       if (this->current_request_ > 0) { | ||||
|         AnovaPacket *pkt = nullptr; | ||||
|         switch (this->current_request_++) { | ||||
|           case 1: | ||||
|             pkt = this->codec_->get_read_target_temp_request(); | ||||
|             break; | ||||
|           case 2: | ||||
|             pkt = this->codec_->get_read_current_temp_request(); | ||||
|             break; | ||||
|           default: | ||||
|             this->current_request_ = 0; | ||||
|             break; | ||||
|         } | ||||
|         if (pkt != nullptr) { | ||||
|           auto status = | ||||
|               esp_ble_gattc_write_char(this->parent_->gattc_if, this->parent_->conn_id, this->char_handle_, pkt->length, | ||||
|                                        pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); | ||||
|           if (status) | ||||
|             ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str().c_str(), | ||||
|                      status); | ||||
|         } | ||||
|       } | ||||
|       break; | ||||
|     } | ||||
|     default: | ||||
|       break; | ||||
|   } | ||||
| } | ||||
|  | ||||
| void Anova::update() { | ||||
|   if (this->node_state != espbt::ClientState::Established) | ||||
|     return; | ||||
|  | ||||
|   if (this->current_request_ == 0) { | ||||
|     auto pkt = this->codec_->get_read_device_status_request(); | ||||
|     auto status = esp_ble_gattc_write_char(this->parent_->gattc_if, this->parent_->conn_id, this->char_handle_, | ||||
|                                            pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); | ||||
|     if (status) | ||||
|       ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str().c_str(), status); | ||||
|     this->current_request_++; | ||||
|   } | ||||
| } | ||||
|  | ||||
| }  // namespace anova | ||||
| }  // namespace esphome | ||||
|  | ||||
| #endif | ||||
							
								
								
									
										50
									
								
								esphome/components/anova/anova.h
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								esphome/components/anova/anova.h
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,50 @@ | ||||
| #pragma once | ||||
|  | ||||
| #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" | ||||
| #include "anova_base.h" | ||||
|  | ||||
| #ifdef ARDUINO_ARCH_ESP32 | ||||
|  | ||||
| #include <esp_gattc_api.h> | ||||
|  | ||||
| namespace esphome { | ||||
| namespace anova { | ||||
|  | ||||
| namespace espbt = esphome::esp32_ble_tracker; | ||||
|  | ||||
| static const uint16_t ANOVA_SERVICE_UUID = 0xFFE0; | ||||
| static const uint16_t ANOVA_CHARACTERISTIC_UUID = 0xFFE1; | ||||
|  | ||||
| class Anova : public climate::Climate, public esphome::ble_client::BLEClientNode, public PollingComponent { | ||||
|  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 dump_config() override; | ||||
|   float get_setup_priority() const override { return setup_priority::DATA; } | ||||
|   climate::ClimateTraits traits() { | ||||
|     auto traits = climate::ClimateTraits(); | ||||
|     traits.set_supports_current_temperature(true); | ||||
|     traits.set_supports_heat_mode(true); | ||||
|     traits.set_visual_min_temperature(25.0); | ||||
|     traits.set_visual_max_temperature(100.0); | ||||
|     traits.set_visual_temperature_step(0.1); | ||||
|     return traits; | ||||
|   } | ||||
|  | ||||
|  protected: | ||||
|   AnovaCodec *codec_; | ||||
|   void control(const climate::ClimateCall &call) override; | ||||
|   uint16_t char_handle_; | ||||
|   uint8_t current_request_; | ||||
| }; | ||||
|  | ||||
| }  // namespace anova | ||||
| }  // namespace esphome | ||||
|  | ||||
| #endif | ||||
							
								
								
									
										119
									
								
								esphome/components/anova/anova_base.cpp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								esphome/components/anova/anova_base.cpp
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,119 @@ | ||||
| #include "anova_base.h" | ||||
|  | ||||
| namespace esphome { | ||||
| namespace anova { | ||||
|  | ||||
| AnovaPacket *AnovaCodec::clean_packet_() { | ||||
|   this->packet_.length = strlen((char *) this->packet_.data); | ||||
|   this->packet_.data[this->packet_.length] = '\0'; | ||||
|   ESP_LOGV("anova", "SendPkt: %s\n", this->packet_.data); | ||||
|   return &this->packet_; | ||||
| } | ||||
|  | ||||
| AnovaPacket *AnovaCodec::get_read_device_status_request() { | ||||
|   this->current_query_ = READ_DEVICE_STATUS; | ||||
|   sprintf((char *) this->packet_.data, "%s", CMD_READ_DEVICE_STATUS); | ||||
|   return this->clean_packet_(); | ||||
| } | ||||
|  | ||||
| AnovaPacket *AnovaCodec::get_read_target_temp_request() { | ||||
|   this->current_query_ = READ_TARGET_TEMPERATURE; | ||||
|   sprintf((char *) this->packet_.data, "%s", CMD_READ_TARGET_TEMP); | ||||
|   return this->clean_packet_(); | ||||
| } | ||||
|  | ||||
| AnovaPacket *AnovaCodec::get_read_current_temp_request() { | ||||
|   this->current_query_ = READ_CURRENT_TEMPERATURE; | ||||
|   sprintf((char *) this->packet_.data, "%s", CMD_READ_CURRENT_TEMP); | ||||
|   return this->clean_packet_(); | ||||
| } | ||||
|  | ||||
| AnovaPacket *AnovaCodec::get_read_unit_request() { | ||||
|   this->current_query_ = READ_UNIT; | ||||
|   sprintf((char *) this->packet_.data, "%s", CMD_READ_UNIT); | ||||
|   return this->clean_packet_(); | ||||
| } | ||||
|  | ||||
| AnovaPacket *AnovaCodec::get_read_data_request() { | ||||
|   this->current_query_ = READ_DATA; | ||||
|   sprintf((char *) this->packet_.data, "%s", CMD_READ_DATA); | ||||
|   return this->clean_packet_(); | ||||
| } | ||||
|  | ||||
| AnovaPacket *AnovaCodec::get_set_target_temp_request(float temperature) { | ||||
|   this->current_query_ = SET_TARGET_TEMPERATURE; | ||||
|   sprintf((char *) this->packet_.data, CMD_SET_TARGET_TEMP, temperature); | ||||
|   return this->clean_packet_(); | ||||
| } | ||||
|  | ||||
| AnovaPacket *AnovaCodec::get_set_unit_request(char unit) { | ||||
|   this->current_query_ = SET_UNIT; | ||||
|   sprintf((char *) this->packet_.data, CMD_SET_TEMP_UNIT, unit); | ||||
|   return this->clean_packet_(); | ||||
| } | ||||
|  | ||||
| AnovaPacket *AnovaCodec::get_start_request() { | ||||
|   this->current_query_ = START; | ||||
|   sprintf((char *) this->packet_.data, CMD_START); | ||||
|   return this->clean_packet_(); | ||||
| } | ||||
|  | ||||
| AnovaPacket *AnovaCodec::get_stop_request() { | ||||
|   this->current_query_ = STOP; | ||||
|   sprintf((char *) this->packet_.data, CMD_STOP); | ||||
|   return this->clean_packet_(); | ||||
| } | ||||
|  | ||||
| void AnovaCodec::decode(const uint8_t *data, uint16_t length) { | ||||
|   memset(this->buf_, 0, 32); | ||||
|   strncpy(this->buf_, (char *) data, length); | ||||
|   ESP_LOGV("anova", "Received: %s\n", this->buf_); | ||||
|   this->has_target_temp_ = this->has_current_temp_ = this->has_unit_ = this->has_running_ = false; | ||||
|   switch (this->current_query_) { | ||||
|     case READ_DEVICE_STATUS: { | ||||
|       if (!strncmp(this->buf_, "stopped", 7)) { | ||||
|         this->has_running_ = true; | ||||
|         this->running_ = false; | ||||
|       } | ||||
|       if (!strncmp(this->buf_, "running", 7)) { | ||||
|         this->has_running_ = true; | ||||
|         this->running_ = true; | ||||
|       } | ||||
|       break; | ||||
|     } | ||||
|     case START: { | ||||
|       if (!strncmp(this->buf_, "start", 5)) { | ||||
|         this->has_running_ = true; | ||||
|         this->running_ = true; | ||||
|       } | ||||
|       break; | ||||
|     } | ||||
|     case STOP: { | ||||
|       if (!strncmp(this->buf_, "stop", 4)) { | ||||
|         this->has_running_ = true; | ||||
|         this->running_ = false; | ||||
|       } | ||||
|       break; | ||||
|     } | ||||
|     case READ_TARGET_TEMPERATURE: { | ||||
|       this->target_temp_ = strtof(this->buf_, nullptr); | ||||
|       this->has_target_temp_ = true; | ||||
|       break; | ||||
|     } | ||||
|     case SET_TARGET_TEMPERATURE: { | ||||
|       this->target_temp_ = strtof(this->buf_, nullptr); | ||||
|       this->has_target_temp_ = true; | ||||
|       break; | ||||
|     } | ||||
|     case READ_CURRENT_TEMPERATURE: { | ||||
|       this->current_temp_ = strtof(this->buf_, nullptr); | ||||
|       this->has_current_temp_ = true; | ||||
|       break; | ||||
|     } | ||||
|     default: | ||||
|       break; | ||||
|   } | ||||
| } | ||||
|  | ||||
| }  // namespace anova | ||||
| }  // namespace esphome | ||||
							
								
								
									
										79
									
								
								esphome/components/anova/anova_base.h
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										79
									
								
								esphome/components/anova/anova_base.h
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,79 @@ | ||||
| #pragma once | ||||
|  | ||||
| #include "esphome/core/helpers.h" | ||||
| #include "esphome/core/log.h" | ||||
|  | ||||
| namespace esphome { | ||||
| namespace anova { | ||||
|  | ||||
| enum CurrentQuery { | ||||
|   NONE, | ||||
|   READ_DEVICE_STATUS, | ||||
|   READ_TARGET_TEMPERATURE, | ||||
|   READ_CURRENT_TEMPERATURE, | ||||
|   READ_DATA, | ||||
|   READ_UNIT, | ||||
|   SET_TARGET_TEMPERATURE, | ||||
|   SET_UNIT, | ||||
|   START, | ||||
|   STOP, | ||||
| }; | ||||
|  | ||||
| struct AnovaPacket { | ||||
|   uint16_t length; | ||||
|   uint8_t data[24]; | ||||
| }; | ||||
|  | ||||
| #define CMD_READ_DEVICE_STATUS "status\r" | ||||
| #define CMD_READ_TARGET_TEMP "read set temp\r" | ||||
| #define CMD_READ_CURRENT_TEMP "read temp\r" | ||||
| #define CMD_READ_UNIT "read unit\r" | ||||
| #define CMD_READ_DATA "read data\r" | ||||
| #define CMD_SET_TARGET_TEMP "set temp %.1f\r" | ||||
| #define CMD_SET_TEMP_UNIT "set unit %c\r" | ||||
|  | ||||
| #define CMD_START "start\r" | ||||
| #define CMD_STOP "stop\r" | ||||
|  | ||||
| class AnovaCodec { | ||||
|  public: | ||||
|   AnovaPacket *get_read_device_status_request(); | ||||
|   AnovaPacket *get_read_target_temp_request(); | ||||
|   AnovaPacket *get_read_current_temp_request(); | ||||
|   AnovaPacket *get_read_data_request(); | ||||
|   AnovaPacket *get_read_unit_request(); | ||||
|  | ||||
|   AnovaPacket *get_set_target_temp_request(float temperature); | ||||
|   AnovaPacket *get_set_unit_request(char unit); | ||||
|  | ||||
|   AnovaPacket *get_start_request(); | ||||
|   AnovaPacket *get_stop_request(); | ||||
|  | ||||
|   void decode(const uint8_t *data, uint16_t length); | ||||
|   bool has_target_temp() { return this->has_target_temp_; } | ||||
|   bool has_current_temp() { return this->has_current_temp_; } | ||||
|   bool has_unit() { return this->has_unit_; } | ||||
|   bool has_running() { return this->has_running_; } | ||||
|  | ||||
|   union { | ||||
|     float target_temp_; | ||||
|     float current_temp_; | ||||
|     char unit_; | ||||
|     bool running_; | ||||
|   }; | ||||
|  | ||||
|  protected: | ||||
|   AnovaPacket *clean_packet_(); | ||||
|   AnovaPacket packet_; | ||||
|  | ||||
|   bool has_target_temp_; | ||||
|   bool has_current_temp_; | ||||
|   bool has_unit_; | ||||
|   bool has_running_; | ||||
|   char buf_[32]; | ||||
|  | ||||
|   CurrentQuery current_query_; | ||||
| }; | ||||
|  | ||||
| }  // namespace anova | ||||
| }  // namespace esphome | ||||
							
								
								
									
										25
									
								
								esphome/components/anova/climate.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								esphome/components/anova/climate.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| 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 = ["@buxtronix"] | ||||
| DEPENDENCIES = ["ble_client"] | ||||
|  | ||||
| anova_ns = cg.esphome_ns.namespace("anova") | ||||
| Anova = anova_ns.class_( | ||||
|     "Anova", climate.Climate, ble_client.BLEClientNode, cg.PollingComponent | ||||
| ) | ||||
|  | ||||
| CONFIG_SCHEMA = ( | ||||
|     climate.CLIMATE_SCHEMA.extend({cv.GenerateID(): cv.declare_id(Anova)}) | ||||
|     .extend(ble_client.BLE_CLIENT_SCHEMA) | ||||
|     .extend(cv.polling_component_schema("60s")) | ||||
| ) | ||||
|  | ||||
|  | ||||
| def to_code(config): | ||||
|     var = cg.new_Pvariable(config[CONF_ID]) | ||||
|     yield cg.register_component(var, config) | ||||
|     yield climate.register_climate(var, config) | ||||
|     yield ble_client.register_ble_node(var, config) | ||||
| @@ -1526,6 +1526,26 @@ 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' | ||||
|   - platform: anova | ||||
|     name: Anova cooker | ||||
|     ble_client_id: ble_blah | ||||
|  | ||||
| midea_dongle: | ||||
|   uart_id: uart0 | ||||
|   strength_icon: true | ||||
|  | ||||
| switch: | ||||
|   - platform: gpio | ||||
|   | ||||
		Reference in New Issue
	
	Block a user