mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-31 15:12:06 +00:00 
			
		
		
		
	Refactor BedJet climate into Hub component (#3522)
This commit is contained in:
		| @@ -1 +1,52 @@ | ||||
| import esphome.codegen as cg | ||||
| import esphome.config_validation as cv | ||||
| from esphome.components import ble_client, time | ||||
| from esphome.const import ( | ||||
|     CONF_ID, | ||||
|     CONF_RECEIVE_TIMEOUT, | ||||
|     CONF_TIME_ID, | ||||
| ) | ||||
|  | ||||
| CODEOWNERS = ["@jhansche"] | ||||
| DEPENDENCIES = ["ble_client"] | ||||
| MULTI_CONF = True | ||||
| CONF_BEDJET_ID = "bedjet_id" | ||||
|  | ||||
| bedjet_ns = cg.esphome_ns.namespace("bedjet") | ||||
| BedJetHub = bedjet_ns.class_("BedJetHub", ble_client.BLEClientNode, cg.PollingComponent) | ||||
|  | ||||
| CONFIG_SCHEMA = ( | ||||
|     cv.COMPONENT_SCHEMA.extend( | ||||
|         { | ||||
|             cv.GenerateID(): cv.declare_id(BedJetHub), | ||||
|             cv.Optional(CONF_TIME_ID): cv.use_id(time.RealTimeClock), | ||||
|             cv.Optional( | ||||
|                 CONF_RECEIVE_TIMEOUT, default="0s" | ||||
|             ): cv.positive_time_period_milliseconds, | ||||
|         } | ||||
|     ) | ||||
|     .extend(ble_client.BLE_CLIENT_SCHEMA) | ||||
|     .extend(cv.polling_component_schema("15s")) | ||||
| ) | ||||
|  | ||||
| BEDJET_CLIENT_SCHEMA = cv.Schema( | ||||
|     { | ||||
|         cv.Required(CONF_BEDJET_ID): cv.use_id(BedJetHub), | ||||
|     } | ||||
| ) | ||||
|  | ||||
|  | ||||
| async def register_bedjet_child(var, config): | ||||
|     parent = await cg.get_variable(config[CONF_BEDJET_ID]) | ||||
|     cg.add(parent.register_child(var)) | ||||
|  | ||||
|  | ||||
| async def to_code(config): | ||||
|     var = cg.new_Pvariable(config[CONF_ID]) | ||||
|     await cg.register_component(var, config) | ||||
|     await ble_client.register_ble_node(var, config) | ||||
|     if CONF_TIME_ID in config: | ||||
|         time_ = await cg.get_variable(config[CONF_TIME_ID]) | ||||
|         cg.add(var.set_time_id(time_)) | ||||
|     if CONF_RECEIVE_TIMEOUT in config: | ||||
|         cg.add(var.set_status_timeout(config[CONF_RECEIVE_TIMEOUT])) | ||||
|   | ||||
| @@ -1,675 +0,0 @@ | ||||
| #include "bedjet.h" | ||||
| #include "esphome/core/log.h" | ||||
|  | ||||
| #ifdef USE_ESP32 | ||||
|  | ||||
| namespace esphome { | ||||
| namespace bedjet { | ||||
|  | ||||
| using namespace esphome::climate; | ||||
|  | ||||
| /// Converts a BedJet temp step into degrees Celsius. | ||||
| float bedjet_temp_to_c(const uint8_t temp) { | ||||
|   // BedJet temp is "C*2"; to get C, divide by 2. | ||||
|   return temp / 2.0f; | ||||
| } | ||||
|  | ||||
| /// Converts a BedJet fan step to a speed percentage, in the range of 5% to 100%. | ||||
| uint8_t bedjet_fan_step_to_speed(const uint8_t fan) { | ||||
|   //  0 =  5% | ||||
|   // 19 = 100% | ||||
|   return 5 * fan + 5; | ||||
| } | ||||
|  | ||||
| static const std::string *bedjet_fan_step_to_fan_mode(const uint8_t fan_step) { | ||||
|   if (fan_step >= 0 && fan_step <= 19) | ||||
|     return &BEDJET_FAN_STEP_NAME_STRINGS[fan_step]; | ||||
|   return nullptr; | ||||
| } | ||||
|  | ||||
| static uint8_t bedjet_fan_speed_to_step(const std::string &fan_step_percent) { | ||||
|   for (int i = 0; i < sizeof(BEDJET_FAN_STEP_NAME_STRINGS); i++) { | ||||
|     if (fan_step_percent == BEDJET_FAN_STEP_NAME_STRINGS[i]) { | ||||
|       return i; | ||||
|     } | ||||
|   } | ||||
|   return -1; | ||||
| } | ||||
|  | ||||
| static BedjetButton heat_button(BedjetHeatMode mode) { | ||||
|   BedjetButton btn = BTN_HEAT; | ||||
|   if (mode == HEAT_MODE_EXTENDED) { | ||||
|     btn = BTN_EXTHT; | ||||
|   } | ||||
|   return btn; | ||||
| } | ||||
|  | ||||
| void Bedjet::upgrade_firmware() { | ||||
|   auto *pkt = this->codec_->get_button_request(MAGIC_UPDATE); | ||||
|   auto status = this->write_bedjet_packet_(pkt); | ||||
|  | ||||
|   if (status) { | ||||
|     ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str().c_str(), status); | ||||
|   } | ||||
| } | ||||
|  | ||||
| void Bedjet::dump_config() { | ||||
|   LOG_CLIMATE("", "BedJet Climate", this); | ||||
|   auto traits = this->get_traits(); | ||||
|  | ||||
|   ESP_LOGCONFIG(TAG, "  Supported modes:"); | ||||
|   for (auto mode : traits.get_supported_modes()) { | ||||
|     ESP_LOGCONFIG(TAG, "   - %s", LOG_STR_ARG(climate_mode_to_string(mode))); | ||||
|   } | ||||
|  | ||||
|   ESP_LOGCONFIG(TAG, "  Supported fan modes:"); | ||||
|   for (const auto &mode : traits.get_supported_fan_modes()) { | ||||
|     ESP_LOGCONFIG(TAG, "   - %s", LOG_STR_ARG(climate_fan_mode_to_string(mode))); | ||||
|   } | ||||
|   for (const auto &mode : traits.get_supported_custom_fan_modes()) { | ||||
|     ESP_LOGCONFIG(TAG, "   - %s (c)", mode.c_str()); | ||||
|   } | ||||
|  | ||||
|   ESP_LOGCONFIG(TAG, "  Supported presets:"); | ||||
|   for (auto preset : traits.get_supported_presets()) { | ||||
|     ESP_LOGCONFIG(TAG, "   - %s", LOG_STR_ARG(climate_preset_to_string(preset))); | ||||
|   } | ||||
|   for (const auto &preset : traits.get_supported_custom_presets()) { | ||||
|     ESP_LOGCONFIG(TAG, "   - %s (c)", preset.c_str()); | ||||
|   } | ||||
| } | ||||
|  | ||||
| void Bedjet::setup() { | ||||
|   this->codec_ = make_unique<BedjetCodec>(); | ||||
|  | ||||
|   // restore set points | ||||
|   auto restore = this->restore_state_(); | ||||
|   if (restore.has_value()) { | ||||
|     ESP_LOGI(TAG, "Restored previous saved state."); | ||||
|     restore->apply(this); | ||||
|   } else { | ||||
|     // Initial status is unknown until we connect | ||||
|     this->reset_state_(); | ||||
|   } | ||||
|  | ||||
| #ifdef USE_TIME | ||||
|   this->setup_time_(); | ||||
| #endif | ||||
| } | ||||
|  | ||||
| /** Resets states to defaults. */ | ||||
| void Bedjet::reset_state_() { | ||||
|   this->mode = climate::CLIMATE_MODE_OFF; | ||||
|   this->action = climate::CLIMATE_ACTION_IDLE; | ||||
|   this->target_temperature = NAN; | ||||
|   this->current_temperature = NAN; | ||||
|   this->preset.reset(); | ||||
|   this->custom_preset.reset(); | ||||
|   this->publish_state(); | ||||
| } | ||||
|  | ||||
| void Bedjet::loop() {} | ||||
|  | ||||
| void Bedjet::control(const ClimateCall &call) { | ||||
|   ESP_LOGD(TAG, "Received Bedjet::control"); | ||||
|   if (this->node_state != espbt::ClientState::ESTABLISHED) { | ||||
|     ESP_LOGW(TAG, "Not connected, cannot handle control call yet."); | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   if (call.get_mode().has_value()) { | ||||
|     ClimateMode mode = *call.get_mode(); | ||||
|     BedjetPacket *pkt; | ||||
|     switch (mode) { | ||||
|       case climate::CLIMATE_MODE_OFF: | ||||
|         pkt = this->codec_->get_button_request(BTN_OFF); | ||||
|         break; | ||||
|       case climate::CLIMATE_MODE_HEAT: | ||||
|         pkt = this->codec_->get_button_request(heat_button(this->heating_mode_)); | ||||
|         break; | ||||
|       case climate::CLIMATE_MODE_FAN_ONLY: | ||||
|         pkt = this->codec_->get_button_request(BTN_COOL); | ||||
|         break; | ||||
|       case climate::CLIMATE_MODE_DRY: | ||||
|         pkt = this->codec_->get_button_request(BTN_DRY); | ||||
|         break; | ||||
|       default: | ||||
|         ESP_LOGW(TAG, "Unsupported mode: %d", mode); | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     auto status = this->write_bedjet_packet_(pkt); | ||||
|  | ||||
|     if (status) { | ||||
|       ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str().c_str(), status); | ||||
|     } else { | ||||
|       this->force_refresh_ = true; | ||||
|       this->mode = mode; | ||||
|       // We're using (custom) preset for Turbo, EXT HT, & M1-3 presets, so changing climate mode will clear those | ||||
|       this->custom_preset.reset(); | ||||
|       this->preset.reset(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   if (call.get_target_temperature().has_value()) { | ||||
|     auto target_temp = *call.get_target_temperature(); | ||||
|     auto *pkt = this->codec_->get_set_target_temp_request(target_temp); | ||||
|     auto status = this->write_bedjet_packet_(pkt); | ||||
|  | ||||
|     if (status) { | ||||
|       ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str().c_str(), status); | ||||
|     } else { | ||||
|       this->target_temperature = target_temp; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   if (call.get_preset().has_value()) { | ||||
|     ClimatePreset preset = *call.get_preset(); | ||||
|     BedjetPacket *pkt; | ||||
|  | ||||
|     if (preset == climate::CLIMATE_PRESET_BOOST) { | ||||
|       pkt = this->codec_->get_button_request(BTN_TURBO); | ||||
|     } else { | ||||
|       ESP_LOGW(TAG, "Unsupported preset: %d", preset); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     auto status = this->write_bedjet_packet_(pkt); | ||||
|     if (status) { | ||||
|       ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str().c_str(), status); | ||||
|     } else { | ||||
|       // We use BOOST preset for TURBO mode, which is a short-lived/high-heat mode. | ||||
|       this->mode = climate::CLIMATE_MODE_HEAT; | ||||
|       this->preset = preset; | ||||
|       this->custom_preset.reset(); | ||||
|       this->force_refresh_ = true; | ||||
|     } | ||||
|   } else if (call.get_custom_preset().has_value()) { | ||||
|     std::string preset = *call.get_custom_preset(); | ||||
|     BedjetPacket *pkt; | ||||
|  | ||||
|     if (preset == "M1") { | ||||
|       pkt = this->codec_->get_button_request(BTN_M1); | ||||
|     } else if (preset == "M2") { | ||||
|       pkt = this->codec_->get_button_request(BTN_M2); | ||||
|     } else if (preset == "M3") { | ||||
|       pkt = this->codec_->get_button_request(BTN_M3); | ||||
|     } else if (preset == "LTD HT") { | ||||
|       pkt = this->codec_->get_button_request(BTN_HEAT); | ||||
|     } else if (preset == "EXT HT") { | ||||
|       pkt = this->codec_->get_button_request(BTN_EXTHT); | ||||
|     } else { | ||||
|       ESP_LOGW(TAG, "Unsupported preset: %s", preset.c_str()); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     auto status = this->write_bedjet_packet_(pkt); | ||||
|     if (status) { | ||||
|       ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str().c_str(), status); | ||||
|     } else { | ||||
|       this->force_refresh_ = true; | ||||
|       this->custom_preset = preset; | ||||
|       this->preset.reset(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   if (call.get_fan_mode().has_value()) { | ||||
|     // Climate fan mode only supports low/med/high, but the BedJet supports 5-100% increments. | ||||
|     // We can still support a ClimateCall that requests low/med/high, and just translate it to a step increment here. | ||||
|     auto fan_mode = *call.get_fan_mode(); | ||||
|     BedjetPacket *pkt; | ||||
|     if (fan_mode == climate::CLIMATE_FAN_LOW) { | ||||
|       pkt = this->codec_->get_set_fan_speed_request(3 /* = 20% */); | ||||
|     } else if (fan_mode == climate::CLIMATE_FAN_MEDIUM) { | ||||
|       pkt = this->codec_->get_set_fan_speed_request(9 /* = 50% */); | ||||
|     } else if (fan_mode == climate::CLIMATE_FAN_HIGH) { | ||||
|       pkt = this->codec_->get_set_fan_speed_request(14 /* = 75% */); | ||||
|     } else { | ||||
|       ESP_LOGW(TAG, "[%s] Unsupported fan mode: %s", this->get_name().c_str(), | ||||
|                LOG_STR_ARG(climate_fan_mode_to_string(fan_mode))); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     auto status = this->write_bedjet_packet_(pkt); | ||||
|     if (status) { | ||||
|       ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str().c_str(), status); | ||||
|     } else { | ||||
|       this->force_refresh_ = true; | ||||
|     } | ||||
|   } else if (call.get_custom_fan_mode().has_value()) { | ||||
|     auto fan_mode = *call.get_custom_fan_mode(); | ||||
|     auto fan_step = bedjet_fan_speed_to_step(fan_mode); | ||||
|     if (fan_step >= 0 && fan_step <= 19) { | ||||
|       ESP_LOGV(TAG, "[%s] Converted fan mode %s to bedjet fan step %d", this->get_name().c_str(), fan_mode.c_str(), | ||||
|                fan_step); | ||||
|       // The index should represent the fan_step index. | ||||
|       BedjetPacket *pkt = this->codec_->get_set_fan_speed_request(fan_step); | ||||
|       auto status = this->write_bedjet_packet_(pkt); | ||||
|       if (status) { | ||||
|         ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str().c_str(), status); | ||||
|       } else { | ||||
|         this->force_refresh_ = true; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| void Bedjet::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_LOGV(TAG, "Disconnected: reason=%d", param->disconnect.reason); | ||||
|       this->status_set_warning(); | ||||
|       break; | ||||
|     } | ||||
|     case ESP_GATTC_SEARCH_CMPL_EVT: { | ||||
|       auto *chr = this->parent_->get_characteristic(BEDJET_SERVICE_UUID, BEDJET_COMMAND_UUID); | ||||
|       if (chr == nullptr) { | ||||
|         ESP_LOGW(TAG, "[%s] No control service found at device, not a BedJet..?", this->get_name().c_str()); | ||||
|         break; | ||||
|       } | ||||
|       this->char_handle_cmd_ = chr->handle; | ||||
|  | ||||
|       chr = this->parent_->get_characteristic(BEDJET_SERVICE_UUID, BEDJET_STATUS_UUID); | ||||
|       if (chr == nullptr) { | ||||
|         ESP_LOGW(TAG, "[%s] No status service found at device, not a BedJet..?", this->get_name().c_str()); | ||||
|         break; | ||||
|       } | ||||
|  | ||||
|       this->char_handle_status_ = chr->handle; | ||||
|       // We also need to obtain the config descriptor for this handle. | ||||
|       // Otherwise once we set node_state=Established, the parent will flush all handles/descriptors, and we won't be | ||||
|       // able to look it up. | ||||
|       auto *descr = this->parent_->get_config_descriptor(this->char_handle_status_); | ||||
|       if (descr == nullptr) { | ||||
|         ESP_LOGW(TAG, "No config descriptor for status handle 0x%x. Will not be able to receive status notifications", | ||||
|                  this->char_handle_status_); | ||||
|       } else if (descr->uuid.get_uuid().len != ESP_UUID_LEN_16 || | ||||
|                  descr->uuid.get_uuid().uuid.uuid16 != ESP_GATT_UUID_CHAR_CLIENT_CONFIG) { | ||||
|         ESP_LOGW(TAG, "Config descriptor 0x%x (uuid %s) is not a client config char uuid", this->char_handle_status_, | ||||
|                  descr->uuid.to_string().c_str()); | ||||
|       } else { | ||||
|         this->config_descr_status_ = descr->handle; | ||||
|       } | ||||
|  | ||||
|       chr = this->parent_->get_characteristic(BEDJET_SERVICE_UUID, BEDJET_NAME_UUID); | ||||
|       if (chr != nullptr) { | ||||
|         this->char_handle_name_ = chr->handle; | ||||
|         auto status = esp_ble_gattc_read_char(this->parent_->gattc_if, this->parent_->conn_id, this->char_handle_name_, | ||||
|                                               ESP_GATT_AUTH_REQ_NONE); | ||||
|         if (status) { | ||||
|           ESP_LOGI(TAG, "[%s] Unable to read name characteristic: %d", this->get_name().c_str(), status); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       ESP_LOGD(TAG, "Services complete: obtained char handles."); | ||||
|       this->node_state = espbt::ClientState::ESTABLISHED; | ||||
|  | ||||
|       this->set_notify_(true); | ||||
|  | ||||
| #ifdef USE_TIME | ||||
|       if (this->time_id_.has_value()) { | ||||
|         this->send_local_time(); | ||||
|       } | ||||
| #endif | ||||
|       break; | ||||
|     } | ||||
|     case ESP_GATTC_WRITE_DESCR_EVT: { | ||||
|       if (param->write.status != ESP_GATT_OK) { | ||||
|         // ESP_GATT_INVALID_ATTR_LEN | ||||
|         ESP_LOGW(TAG, "Error writing descr at handle 0x%04d, status=%d", param->write.handle, param->write.status); | ||||
|         break; | ||||
|       } | ||||
|       // [16:44:44][V][bedjet:279]: [JOENJET] Register for notify event success: h=0x002a s=0 | ||||
|       // This might be the enable-notify descriptor? (or disable-notify) | ||||
|       ESP_LOGV(TAG, "[%s] Write to handle 0x%04x status=%d", this->get_name().c_str(), param->write.handle, | ||||
|                param->write.status); | ||||
|       break; | ||||
|     } | ||||
|     case ESP_GATTC_WRITE_CHAR_EVT: { | ||||
|       if (param->write.status != ESP_GATT_OK) { | ||||
|         ESP_LOGW(TAG, "Error writing char at handle 0x%04d, status=%d", param->write.handle, param->write.status); | ||||
|         break; | ||||
|       } | ||||
|       if (param->write.handle == this->char_handle_cmd_) { | ||||
|         if (this->force_refresh_) { | ||||
|           // Command write was successful. Publish the pending state, hoping that notify will kick in. | ||||
|           this->publish_state(); | ||||
|         } | ||||
|       } | ||||
|       break; | ||||
|     } | ||||
|     case ESP_GATTC_READ_CHAR_EVT: { | ||||
|       if (param->read.conn_id != this->parent_->conn_id) | ||||
|         break; | ||||
|       if (param->read.status != ESP_GATT_OK) { | ||||
|         ESP_LOGW(TAG, "Error reading char at handle %d, status=%d", param->read.handle, param->read.status); | ||||
|         break; | ||||
|       } | ||||
|       if (param->read.handle == this->char_handle_status_) { | ||||
|         // This is the additional packet that doesn't fit in the notify packet. | ||||
|         this->codec_->decode_extra(param->read.value, param->read.value_len); | ||||
|       } else if (param->read.handle == this->char_handle_name_) { | ||||
|         // The data should represent the name. | ||||
|         if (param->read.status == ESP_GATT_OK && param->read.value_len > 0) { | ||||
|           std::string bedjet_name(reinterpret_cast<char const *>(param->read.value), param->read.value_len); | ||||
|           // this->set_name(bedjet_name); | ||||
|           ESP_LOGV(TAG, "[%s] Got BedJet name: '%s'", this->get_name().c_str(), bedjet_name.c_str()); | ||||
|         } | ||||
|       } | ||||
|       break; | ||||
|     } | ||||
|     case ESP_GATTC_REG_FOR_NOTIFY_EVT: { | ||||
|       // This event means that ESP received the request to enable notifications on the client side. But we also have to | ||||
|       // tell the server that we want it to send notifications. Normally BLEClient parent would handle this | ||||
|       // automatically, but as soon as we set our status to Established, the parent is going to purge all the | ||||
|       // service/char/descriptor handles, and then get_config_descriptor() won't work anymore. There's no way to disable | ||||
|       // the BLEClient parent behavior, so our only option is to write the handle anyway, and hope a double-write | ||||
|       // doesn't break anything. | ||||
|  | ||||
|       if (param->reg_for_notify.handle != this->char_handle_status_) { | ||||
|         ESP_LOGW(TAG, "[%s] Register for notify on unexpected handle 0x%04x, expecting 0x%04x", | ||||
|                  this->get_name().c_str(), param->reg_for_notify.handle, this->char_handle_status_); | ||||
|         break; | ||||
|       } | ||||
|  | ||||
|       this->write_notify_config_descriptor_(true); | ||||
|       this->last_notify_ = 0; | ||||
|       this->force_refresh_ = true; | ||||
|       break; | ||||
|     } | ||||
|     case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: { | ||||
|       // This event is not handled by the parent BLEClient, so we need to do this either way. | ||||
|       if (param->unreg_for_notify.handle != this->char_handle_status_) { | ||||
|         ESP_LOGW(TAG, "[%s] Unregister for notify on unexpected handle 0x%04x, expecting 0x%04x", | ||||
|                  this->get_name().c_str(), param->unreg_for_notify.handle, this->char_handle_status_); | ||||
|         break; | ||||
|       } | ||||
|  | ||||
|       this->write_notify_config_descriptor_(false); | ||||
|       this->last_notify_ = 0; | ||||
|       // Now we wait until the next update() poll to re-register notify... | ||||
|       break; | ||||
|     } | ||||
|     case ESP_GATTC_NOTIFY_EVT: { | ||||
|       if (param->notify.handle != this->char_handle_status_) { | ||||
|         ESP_LOGW(TAG, "[%s] Unexpected notify handle, wanted %04X, got %04X", this->get_name().c_str(), | ||||
|                  this->char_handle_status_, param->notify.handle); | ||||
|         break; | ||||
|       } | ||||
|  | ||||
|       // FIXME: notify events come in every ~200-300 ms, which is too fast to be helpful. So we | ||||
|       //  throttle the updates to once every MIN_NOTIFY_THROTTLE (5 seconds). | ||||
|       //  Another idea would be to keep notify off by default, and use update() as an opportunity to turn on | ||||
|       //  notify to get enough data to update status, then turn off notify again. | ||||
|  | ||||
|       uint32_t now = millis(); | ||||
|       auto delta = now - this->last_notify_; | ||||
|  | ||||
|       if (this->last_notify_ == 0 || delta > MIN_NOTIFY_THROTTLE || this->force_refresh_) { | ||||
|         bool needs_extra = this->codec_->decode_notify(param->notify.value, param->notify.value_len); | ||||
|         this->last_notify_ = now; | ||||
|  | ||||
|         if (needs_extra) { | ||||
|           // this means the packet was partial, so read the status characteristic to get the second part. | ||||
|           auto status = esp_ble_gattc_read_char(this->parent_->gattc_if, this->parent_->conn_id, | ||||
|                                                 this->char_handle_status_, ESP_GATT_AUTH_REQ_NONE); | ||||
|           if (status) { | ||||
|             ESP_LOGI(TAG, "[%s] Unable to read extended status packet", this->get_name().c_str()); | ||||
|           } | ||||
|         } | ||||
|  | ||||
|         if (this->force_refresh_) { | ||||
|           // If we requested an immediate update, do that now. | ||||
|           this->update(); | ||||
|           this->force_refresh_ = false; | ||||
|         } | ||||
|       } | ||||
|       break; | ||||
|     } | ||||
|     default: | ||||
|       ESP_LOGVV(TAG, "[%s] gattc unhandled event: enum=%d", this->get_name().c_str(), event); | ||||
|       break; | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** Reimplementation of BLEClient.gattc_event_handler() for ESP_GATTC_REG_FOR_NOTIFY_EVT. | ||||
|  * | ||||
|  * This is a copy of ble_client's automatic handling of `ESP_GATTC_REG_FOR_NOTIFY_EVT`, in order | ||||
|  * to undo the same on unregister. It also allows us to maintain the config descriptor separately, | ||||
|  * since the parent BLEClient is going to purge all descriptors once we set our connection status | ||||
|  * to `Established`. | ||||
|  */ | ||||
| uint8_t Bedjet::write_notify_config_descriptor_(bool enable) { | ||||
|   auto handle = this->config_descr_status_; | ||||
|   if (handle == 0) { | ||||
|     ESP_LOGW(TAG, "No descriptor found for notify of handle 0x%x", this->char_handle_status_); | ||||
|     return -1; | ||||
|   } | ||||
|  | ||||
|   // NOTE: BLEClient uses `uint8_t*` of length 1, but BLE spec requires 16 bits. | ||||
|   uint8_t notify_en[] = {0, 0}; | ||||
|   notify_en[0] = enable; | ||||
|   auto status = | ||||
|       esp_ble_gattc_write_char_descr(this->parent_->gattc_if, this->parent_->conn_id, handle, sizeof(notify_en), | ||||
|                                      ¬ify_en[0], ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE); | ||||
|   if (status) { | ||||
|     ESP_LOGW(TAG, "esp_ble_gattc_write_char_descr error, status=%d", status); | ||||
|     return status; | ||||
|   } | ||||
|   ESP_LOGD(TAG, "[%s] wrote notify=%s to status config 0x%04x", this->get_name().c_str(), enable ? "true" : "false", | ||||
|            handle); | ||||
|   return ESP_GATT_OK; | ||||
| } | ||||
|  | ||||
| #ifdef USE_TIME | ||||
| /** Attempts to sync the local time (via `time_id`) to the BedJet device. */ | ||||
| void Bedjet::send_local_time() { | ||||
|   if (this->time_id_.has_value()) { | ||||
|     auto *time_id = *this->time_id_; | ||||
|     time::ESPTime now = time_id->now(); | ||||
|     if (now.is_valid()) { | ||||
|       this->set_clock(now.hour, now.minute); | ||||
|       ESP_LOGD(TAG, "Using time component to set BedJet clock: %d:%02d", now.hour, now.minute); | ||||
|     } | ||||
|   } else { | ||||
|     ESP_LOGI(TAG, "`time_id` is not configured: will not sync BedJet clock."); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** Initializes time sync callbacks to support syncing current time to the BedJet. */ | ||||
| void Bedjet::setup_time_() { | ||||
|   if (this->time_id_.has_value()) { | ||||
|     this->send_local_time(); | ||||
|     auto *time_id = *this->time_id_; | ||||
|     time_id->add_on_time_sync_callback([this] { this->send_local_time(); }); | ||||
|   } else { | ||||
|     ESP_LOGI(TAG, "`time_id` is not configured: will not sync BedJet clock."); | ||||
|   } | ||||
| } | ||||
| #endif | ||||
|  | ||||
| /** Attempt to set the BedJet device's clock to the specified time. */ | ||||
| void Bedjet::set_clock(uint8_t hour, uint8_t minute) { | ||||
|   if (this->node_state != espbt::ClientState::ESTABLISHED) { | ||||
|     ESP_LOGV(TAG, "[%s] Not connected, cannot send time.", this->get_name().c_str()); | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   BedjetPacket *pkt = this->codec_->get_set_time_request(hour, minute); | ||||
|   auto status = this->write_bedjet_packet_(pkt); | ||||
|   if (status) { | ||||
|     ESP_LOGW(TAG, "Failed setting BedJet clock: %d", status); | ||||
|   } else { | ||||
|     ESP_LOGD(TAG, "[%s] BedJet clock set to: %d:%02d", this->get_name().c_str(), hour, minute); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** Writes one BedjetPacket to the BLE client on the BEDJET_COMMAND_UUID. */ | ||||
| uint8_t Bedjet::write_bedjet_packet_(BedjetPacket *pkt) { | ||||
|   if (this->node_state != espbt::ClientState::ESTABLISHED) { | ||||
|     if (!this->parent_->enabled) { | ||||
|       ESP_LOGI(TAG, "[%s] Cannot write packet: Not connected, enabled=false", this->get_name().c_str()); | ||||
|     } else { | ||||
|       ESP_LOGW(TAG, "[%s] Cannot write packet: Not connected", this->get_name().c_str()); | ||||
|     } | ||||
|     return -1; | ||||
|   } | ||||
|   auto status = esp_ble_gattc_write_char(this->parent_->gattc_if, this->parent_->conn_id, this->char_handle_cmd_, | ||||
|                                          pkt->data_length + 1, (uint8_t *) &pkt->command, ESP_GATT_WRITE_TYPE_NO_RSP, | ||||
|                                          ESP_GATT_AUTH_REQ_NONE); | ||||
|   return status; | ||||
| } | ||||
|  | ||||
| /** Configures the local ESP BLE client to register (`true`) or unregister (`false`) for status notifications. */ | ||||
| uint8_t Bedjet::set_notify_(const bool enable) { | ||||
|   uint8_t status; | ||||
|   if (enable) { | ||||
|     status = esp_ble_gattc_register_for_notify(this->parent_->gattc_if, this->parent_->remote_bda, | ||||
|                                                this->char_handle_status_); | ||||
|     if (status) { | ||||
|       ESP_LOGW(TAG, "[%s] esp_ble_gattc_register_for_notify failed, status=%d", this->get_name().c_str(), status); | ||||
|     } | ||||
|   } else { | ||||
|     status = esp_ble_gattc_unregister_for_notify(this->parent_->gattc_if, this->parent_->remote_bda, | ||||
|                                                  this->char_handle_status_); | ||||
|     if (status) { | ||||
|       ESP_LOGW(TAG, "[%s] esp_ble_gattc_unregister_for_notify failed, status=%d", this->get_name().c_str(), status); | ||||
|     } | ||||
|   } | ||||
|   ESP_LOGV(TAG, "[%s] set_notify: enable=%d; result=%d", this->get_name().c_str(), enable, status); | ||||
|   return status; | ||||
| } | ||||
|  | ||||
| /** Attempts to update the climate device from the last received BedjetStatusPacket. | ||||
|  * | ||||
|  * @return `true` if the status has been applied; `false` if there is nothing to apply. | ||||
|  */ | ||||
| bool Bedjet::update_status_() { | ||||
|   if (!this->codec_->has_status()) | ||||
|     return false; | ||||
|  | ||||
|   BedjetStatusPacket status = *this->codec_->get_status_packet(); | ||||
|  | ||||
|   auto converted_temp = bedjet_temp_to_c(status.target_temp_step); | ||||
|   if (converted_temp > 0) | ||||
|     this->target_temperature = converted_temp; | ||||
|   converted_temp = bedjet_temp_to_c(status.ambient_temp_step); | ||||
|   if (converted_temp > 0) | ||||
|     this->current_temperature = converted_temp; | ||||
|  | ||||
|   const auto *fan_mode_name = bedjet_fan_step_to_fan_mode(status.fan_step); | ||||
|   if (fan_mode_name != nullptr) { | ||||
|     this->custom_fan_mode = *fan_mode_name; | ||||
|   } | ||||
|  | ||||
|   // TODO: Get biorhythm data to determine which preset (M1-3) is running, if any. | ||||
|   switch (status.mode) { | ||||
|     case MODE_WAIT:  // Biorhythm "wait" step: device is idle | ||||
|     case MODE_STANDBY: | ||||
|       this->mode = climate::CLIMATE_MODE_OFF; | ||||
|       this->action = climate::CLIMATE_ACTION_IDLE; | ||||
|       this->fan_mode = climate::CLIMATE_FAN_OFF; | ||||
|       this->custom_preset.reset(); | ||||
|       this->preset.reset(); | ||||
|       break; | ||||
|  | ||||
|     case MODE_HEAT: | ||||
|       this->mode = climate::CLIMATE_MODE_HEAT; | ||||
|       this->action = climate::CLIMATE_ACTION_HEATING; | ||||
|       this->preset.reset(); | ||||
|       if (this->heating_mode_ == HEAT_MODE_EXTENDED) { | ||||
|         this->set_custom_preset_("LTD HT"); | ||||
|       } else { | ||||
|         this->custom_preset.reset(); | ||||
|       } | ||||
|       break; | ||||
|  | ||||
|     case MODE_EXTHT: | ||||
|       this->mode = climate::CLIMATE_MODE_HEAT; | ||||
|       this->action = climate::CLIMATE_ACTION_HEATING; | ||||
|       this->preset.reset(); | ||||
|       if (this->heating_mode_ == HEAT_MODE_EXTENDED) { | ||||
|         this->custom_preset.reset(); | ||||
|       } else { | ||||
|         this->set_custom_preset_("EXT HT"); | ||||
|       } | ||||
|       break; | ||||
|  | ||||
|     case MODE_COOL: | ||||
|       this->mode = climate::CLIMATE_MODE_FAN_ONLY; | ||||
|       this->action = climate::CLIMATE_ACTION_COOLING; | ||||
|       this->custom_preset.reset(); | ||||
|       this->preset.reset(); | ||||
|       break; | ||||
|  | ||||
|     case MODE_DRY: | ||||
|       this->mode = climate::CLIMATE_MODE_DRY; | ||||
|       this->action = climate::CLIMATE_ACTION_DRYING; | ||||
|       this->custom_preset.reset(); | ||||
|       this->preset.reset(); | ||||
|       break; | ||||
|  | ||||
|     case MODE_TURBO: | ||||
|       this->preset = climate::CLIMATE_PRESET_BOOST; | ||||
|       this->custom_preset.reset(); | ||||
|       this->mode = climate::CLIMATE_MODE_HEAT; | ||||
|       this->action = climate::CLIMATE_ACTION_HEATING; | ||||
|       break; | ||||
|  | ||||
|     default: | ||||
|       ESP_LOGW(TAG, "[%s] Unexpected mode: 0x%02X", this->get_name().c_str(), status.mode); | ||||
|       break; | ||||
|   } | ||||
|  | ||||
|   if (this->is_valid_()) { | ||||
|     this->publish_state(); | ||||
|     this->codec_->clear_status(); | ||||
|     this->status_clear_warning(); | ||||
|   } | ||||
|  | ||||
|   return true; | ||||
| } | ||||
|  | ||||
| void Bedjet::update() { | ||||
|   ESP_LOGV(TAG, "[%s] update()", this->get_name().c_str()); | ||||
|  | ||||
|   if (this->node_state != espbt::ClientState::ESTABLISHED) { | ||||
|     if (!this->parent()->enabled) { | ||||
|       ESP_LOGD(TAG, "[%s] Not connected, because enabled=false", this->get_name().c_str()); | ||||
|     } else { | ||||
|       // Possibly still trying to connect. | ||||
|       ESP_LOGD(TAG, "[%s] Not connected, enabled=true", this->get_name().c_str()); | ||||
|     } | ||||
|  | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   auto result = this->update_status_(); | ||||
|   if (!result) { | ||||
|     uint32_t now = millis(); | ||||
|     uint32_t diff = now - this->last_notify_; | ||||
|  | ||||
|     if (this->last_notify_ == 0) { | ||||
|       // This means we're connected and haven't received a notification, so it likely means that the BedJet is off. | ||||
|       // However, it could also mean that it's running, but failing to send notifications. | ||||
|       // We can try to unregister for notifications now, and then re-register, hoping to clear it up... | ||||
|       // But how do we know for sure which state we're in, and how do we actually clear out the buggy state? | ||||
|  | ||||
|       ESP_LOGI(TAG, "[%s] Still waiting for first GATT notify event.", this->get_name().c_str()); | ||||
|       this->set_notify_(false); | ||||
|     } else if (diff > NOTIFY_WARN_THRESHOLD) { | ||||
|       ESP_LOGW(TAG, "[%s] Last GATT notify was %d seconds ago.", this->get_name().c_str(), diff / 1000); | ||||
|     } | ||||
|  | ||||
|     if (this->timeout_ > 0 && diff > this->timeout_ && this->parent()->enabled) { | ||||
|       ESP_LOGW(TAG, "[%s] Timed out after %d sec. Retrying...", this->get_name().c_str(), this->timeout_); | ||||
|       this->parent()->set_enabled(false); | ||||
|       this->parent()->set_enabled(true); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| }  // namespace bedjet | ||||
| }  // namespace esphome | ||||
|  | ||||
| #endif | ||||
							
								
								
									
										23
									
								
								esphome/components/bedjet/bedjet_child.h
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								esphome/components/bedjet/bedjet_child.h
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| #pragma once | ||||
|  | ||||
| #include "bedjet_codec.h" | ||||
| #include "esphome/core/helpers.h" | ||||
|  | ||||
| namespace esphome { | ||||
| namespace bedjet { | ||||
|  | ||||
| // Forward declare BedJetHub | ||||
| class BedJetHub; | ||||
|  | ||||
| class BedJetClient : public Parented<BedJetHub> { | ||||
|  public: | ||||
|   virtual void on_status(const BedjetStatusPacket *data) = 0; | ||||
|   virtual void on_bedjet_state(bool is_ready) = 0; | ||||
|  | ||||
|  protected: | ||||
|   friend BedJetHub; | ||||
|   virtual std::string describe() = 0; | ||||
| }; | ||||
|  | ||||
| }  // namespace bedjet | ||||
| }  // namespace esphome | ||||
							
								
								
									
										354
									
								
								esphome/components/bedjet/bedjet_climate.cpp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										354
									
								
								esphome/components/bedjet/bedjet_climate.cpp
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,354 @@ | ||||
| #include "bedjet_climate.h" | ||||
| #include "esphome/core/log.h" | ||||
|  | ||||
| #ifdef USE_ESP32 | ||||
|  | ||||
| namespace esphome { | ||||
| namespace bedjet { | ||||
|  | ||||
| using namespace esphome::climate; | ||||
|  | ||||
| /// Converts a BedJet temp step into degrees Celsius. | ||||
| float bedjet_temp_to_c(const uint8_t temp) { | ||||
|   // BedJet temp is "C*2"; to get C, divide by 2. | ||||
|   return temp / 2.0f; | ||||
| } | ||||
|  | ||||
| static const std::string *bedjet_fan_step_to_fan_mode(const uint8_t fan_step) { | ||||
|   if (fan_step <= 19) | ||||
|     return &BEDJET_FAN_STEP_NAME_STRINGS[fan_step]; | ||||
|   return nullptr; | ||||
| } | ||||
|  | ||||
| static uint8_t bedjet_fan_speed_to_step(const std::string &fan_step_percent) { | ||||
|   for (int i = 0; i < sizeof(BEDJET_FAN_STEP_NAME_STRINGS); i++) { | ||||
|     if (fan_step_percent == BEDJET_FAN_STEP_NAME_STRINGS[i]) { | ||||
|       return i; | ||||
|     } | ||||
|   } | ||||
|   return -1; | ||||
| } | ||||
|  | ||||
| static inline BedjetButton heat_button(BedjetHeatMode mode) { | ||||
|   return mode == HEAT_MODE_EXTENDED ? BTN_EXTHT : BTN_HEAT; | ||||
| } | ||||
|  | ||||
| std::string BedJetClimate::describe() { return "BedJet Climate"; } | ||||
|  | ||||
| void BedJetClimate::dump_config() { | ||||
|   LOG_CLIMATE("", "BedJet Climate", this); | ||||
|   auto traits = this->get_traits(); | ||||
|  | ||||
|   ESP_LOGCONFIG(TAG, "  Supported modes:"); | ||||
|   for (auto mode : traits.get_supported_modes()) { | ||||
|     ESP_LOGCONFIG(TAG, "   - %s", LOG_STR_ARG(climate_mode_to_string(mode))); | ||||
|   } | ||||
|   if (this->heating_mode_ == HEAT_MODE_EXTENDED) { | ||||
|     ESP_LOGCONFIG(TAG, "   - BedJet heating mode: EXT HT"); | ||||
|   } else { | ||||
|     ESP_LOGCONFIG(TAG, "   - BedJet heating mode: HEAT"); | ||||
|   } | ||||
|  | ||||
|   ESP_LOGCONFIG(TAG, "  Supported fan modes:"); | ||||
|   for (const auto &mode : traits.get_supported_fan_modes()) { | ||||
|     ESP_LOGCONFIG(TAG, "   - %s", LOG_STR_ARG(climate_fan_mode_to_string(mode))); | ||||
|   } | ||||
|   for (const auto &mode : traits.get_supported_custom_fan_modes()) { | ||||
|     ESP_LOGCONFIG(TAG, "   - %s (c)", mode.c_str()); | ||||
|   } | ||||
|  | ||||
|   ESP_LOGCONFIG(TAG, "  Supported presets:"); | ||||
|   for (auto preset : traits.get_supported_presets()) { | ||||
|     ESP_LOGCONFIG(TAG, "   - %s", LOG_STR_ARG(climate_preset_to_string(preset))); | ||||
|   } | ||||
|   for (const auto &preset : traits.get_supported_custom_presets()) { | ||||
|     ESP_LOGCONFIG(TAG, "   - %s (c)", preset.c_str()); | ||||
|   } | ||||
| } | ||||
|  | ||||
| void BedJetClimate::setup() { | ||||
|   // restore set points | ||||
|   auto restore = this->restore_state_(); | ||||
|   if (restore.has_value()) { | ||||
|     ESP_LOGI(TAG, "Restored previous saved state."); | ||||
|     restore->apply(this); | ||||
|   } else { | ||||
|     // Initial status is unknown until we connect | ||||
|     this->reset_state_(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** Resets states to defaults. */ | ||||
| void BedJetClimate::reset_state_() { | ||||
|   this->mode = CLIMATE_MODE_OFF; | ||||
|   this->action = CLIMATE_ACTION_IDLE; | ||||
|   this->target_temperature = NAN; | ||||
|   this->current_temperature = NAN; | ||||
|   this->preset.reset(); | ||||
|   this->custom_preset.reset(); | ||||
|   this->publish_state(); | ||||
| } | ||||
|  | ||||
| void BedJetClimate::loop() {} | ||||
|  | ||||
| void BedJetClimate::control(const ClimateCall &call) { | ||||
|   ESP_LOGD(TAG, "Received BedJetClimate::control"); | ||||
|   if (!this->parent_->is_connected()) { | ||||
|     ESP_LOGW(TAG, "Not connected, cannot handle control call yet."); | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   if (call.get_mode().has_value()) { | ||||
|     ClimateMode mode = *call.get_mode(); | ||||
|     bool button_result; | ||||
|     switch (mode) { | ||||
|       case CLIMATE_MODE_OFF: | ||||
|         button_result = this->parent_->button_off(); | ||||
|         break; | ||||
|       case CLIMATE_MODE_HEAT: | ||||
|         button_result = this->parent_->send_button(heat_button(this->heating_mode_)); | ||||
|         break; | ||||
|       case CLIMATE_MODE_FAN_ONLY: | ||||
|         button_result = this->parent_->button_cool(); | ||||
|         break; | ||||
|       case CLIMATE_MODE_DRY: | ||||
|         button_result = this->parent_->button_dry(); | ||||
|         break; | ||||
|       default: | ||||
|         ESP_LOGW(TAG, "Unsupported mode: %d", mode); | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     if (button_result) { | ||||
|       this->mode = mode; | ||||
|       // We're using (custom) preset for Turbo, EXT HT, & M1-3 presets, so changing climate mode will clear those | ||||
|       this->custom_preset.reset(); | ||||
|       this->preset.reset(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   if (call.get_target_temperature().has_value()) { | ||||
|     auto target_temp = *call.get_target_temperature(); | ||||
|     auto result = this->parent_->set_target_temp(target_temp); | ||||
|  | ||||
|     if (result) { | ||||
|       this->target_temperature = target_temp; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   if (call.get_preset().has_value()) { | ||||
|     ClimatePreset preset = *call.get_preset(); | ||||
|     bool result; | ||||
|  | ||||
|     if (preset == CLIMATE_PRESET_BOOST) { | ||||
|       // We use BOOST preset for TURBO mode, which is a short-lived/high-heat mode. | ||||
|       result = this->parent_->button_turbo(); | ||||
|  | ||||
|       if (result) { | ||||
|         this->mode = CLIMATE_MODE_HEAT; | ||||
|         this->preset = CLIMATE_PRESET_BOOST; | ||||
|         this->custom_preset.reset(); | ||||
|       } | ||||
|     } else if (preset == CLIMATE_PRESET_NONE && this->preset.has_value()) { | ||||
|       if (this->mode == CLIMATE_MODE_HEAT && this->preset == CLIMATE_PRESET_BOOST) { | ||||
|         // We were in heat mode with Boost preset, and now preset is set to None, so revert to normal heat. | ||||
|         result = this->parent_->send_button(heat_button(this->heating_mode_)); | ||||
|         if (result) { | ||||
|           this->preset.reset(); | ||||
|           this->custom_preset.reset(); | ||||
|         } | ||||
|       } else { | ||||
|         ESP_LOGD(TAG, "Ignoring preset '%s' call; with current mode '%s' and preset '%s'", | ||||
|                  LOG_STR_ARG(climate_preset_to_string(preset)), LOG_STR_ARG(climate_mode_to_string(this->mode)), | ||||
|                  LOG_STR_ARG(climate_preset_to_string(this->preset.value_or(CLIMATE_PRESET_NONE)))); | ||||
|       } | ||||
|     } else { | ||||
|       ESP_LOGW(TAG, "Unsupported preset: %d", preset); | ||||
|       return; | ||||
|     } | ||||
|   } else if (call.get_custom_preset().has_value()) { | ||||
|     std::string preset = *call.get_custom_preset(); | ||||
|     bool result; | ||||
|  | ||||
|     if (preset == "M1") { | ||||
|       result = this->parent_->button_memory1(); | ||||
|     } else if (preset == "M2") { | ||||
|       result = this->parent_->button_memory2(); | ||||
|     } else if (preset == "M3") { | ||||
|       result = this->parent_->button_memory3(); | ||||
|     } else if (preset == "LTD HT") { | ||||
|       result = this->parent_->button_heat(); | ||||
|     } else if (preset == "EXT HT") { | ||||
|       result = this->parent_->button_ext_heat(); | ||||
|     } else { | ||||
|       ESP_LOGW(TAG, "Unsupported preset: %s", preset.c_str()); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (result) { | ||||
|       this->custom_preset = preset; | ||||
|       this->preset.reset(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   if (call.get_fan_mode().has_value()) { | ||||
|     // Climate fan mode only supports low/med/high, but the BedJet supports 5-100% increments. | ||||
|     // We can still support a ClimateCall that requests low/med/high, and just translate it to a step increment here. | ||||
|     auto fan_mode = *call.get_fan_mode(); | ||||
|     bool result; | ||||
|     if (fan_mode == CLIMATE_FAN_LOW) { | ||||
|       result = this->parent_->set_fan_speed(20); | ||||
|     } else if (fan_mode == CLIMATE_FAN_MEDIUM) { | ||||
|       result = this->parent_->set_fan_speed(50); | ||||
|     } else if (fan_mode == CLIMATE_FAN_HIGH) { | ||||
|       result = this->parent_->set_fan_speed(75); | ||||
|     } else { | ||||
|       ESP_LOGW(TAG, "[%s] Unsupported fan mode: %s", this->get_name().c_str(), | ||||
|                LOG_STR_ARG(climate_fan_mode_to_string(fan_mode))); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (result) { | ||||
|       this->fan_mode = fan_mode; | ||||
|       this->custom_fan_mode.reset(); | ||||
|     } | ||||
|   } else if (call.get_custom_fan_mode().has_value()) { | ||||
|     auto fan_mode = *call.get_custom_fan_mode(); | ||||
|     auto fan_index = bedjet_fan_speed_to_step(fan_mode); | ||||
|     if (fan_index <= 19) { | ||||
|       ESP_LOGV(TAG, "[%s] Converted fan mode %s to bedjet fan step %d", this->get_name().c_str(), fan_mode.c_str(), | ||||
|                fan_index); | ||||
|       bool result = this->parent_->set_fan_index(fan_index); | ||||
|       if (result) { | ||||
|         this->custom_fan_mode = fan_mode; | ||||
|         this->fan_mode.reset(); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| void BedJetClimate::on_bedjet_state(bool is_ready) {} | ||||
|  | ||||
| void BedJetClimate::on_status(const BedjetStatusPacket *data) { | ||||
|   ESP_LOGV(TAG, "[%s] Handling on_status with data=%p", this->get_name().c_str(), (void *) data); | ||||
|  | ||||
|   auto converted_temp = bedjet_temp_to_c(data->target_temp_step); | ||||
|   if (converted_temp > 0) | ||||
|     this->target_temperature = converted_temp; | ||||
|  | ||||
|   converted_temp = bedjet_temp_to_c(data->ambient_temp_step); | ||||
|   if (converted_temp > 0) | ||||
|     this->current_temperature = converted_temp; | ||||
|  | ||||
|   const auto *fan_mode_name = bedjet_fan_step_to_fan_mode(data->fan_step); | ||||
|   if (fan_mode_name != nullptr) { | ||||
|     this->custom_fan_mode = *fan_mode_name; | ||||
|   } | ||||
|  | ||||
|   // TODO: Get biorhythm data to determine which preset (M1-3) is running, if any. | ||||
|   switch (data->mode) { | ||||
|     case MODE_WAIT:  // Biorhythm "wait" step: device is idle | ||||
|     case MODE_STANDBY: | ||||
|       this->mode = CLIMATE_MODE_OFF; | ||||
|       this->action = CLIMATE_ACTION_IDLE; | ||||
|       this->fan_mode = CLIMATE_FAN_OFF; | ||||
|       this->custom_preset.reset(); | ||||
|       this->preset.reset(); | ||||
|       break; | ||||
|  | ||||
|     case MODE_HEAT: | ||||
|       this->mode = CLIMATE_MODE_HEAT; | ||||
|       this->action = CLIMATE_ACTION_HEATING; | ||||
|       this->preset.reset(); | ||||
|       if (this->heating_mode_ == HEAT_MODE_EXTENDED) { | ||||
|         this->set_custom_preset_("LTD HT"); | ||||
|       } else { | ||||
|         this->custom_preset.reset(); | ||||
|       } | ||||
|       break; | ||||
|  | ||||
|     case MODE_EXTHT: | ||||
|       this->mode = CLIMATE_MODE_HEAT; | ||||
|       this->action = CLIMATE_ACTION_HEATING; | ||||
|       this->preset.reset(); | ||||
|       if (this->heating_mode_ == HEAT_MODE_EXTENDED) { | ||||
|         this->custom_preset.reset(); | ||||
|       } else { | ||||
|         this->set_custom_preset_("EXT HT"); | ||||
|       } | ||||
|       break; | ||||
|  | ||||
|     case MODE_COOL: | ||||
|       this->mode = CLIMATE_MODE_FAN_ONLY; | ||||
|       this->action = CLIMATE_ACTION_COOLING; | ||||
|       this->custom_preset.reset(); | ||||
|       this->preset.reset(); | ||||
|       break; | ||||
|  | ||||
|     case MODE_DRY: | ||||
|       this->mode = CLIMATE_MODE_DRY; | ||||
|       this->action = CLIMATE_ACTION_DRYING; | ||||
|       this->custom_preset.reset(); | ||||
|       this->preset.reset(); | ||||
|       break; | ||||
|  | ||||
|     case MODE_TURBO: | ||||
|       this->preset = CLIMATE_PRESET_BOOST; | ||||
|       this->custom_preset.reset(); | ||||
|       this->mode = CLIMATE_MODE_HEAT; | ||||
|       this->action = CLIMATE_ACTION_HEATING; | ||||
|       break; | ||||
|  | ||||
|     default: | ||||
|       ESP_LOGW(TAG, "[%s] Unexpected mode: 0x%02X", this->get_name().c_str(), data->mode); | ||||
|       break; | ||||
|   } | ||||
|  | ||||
|   ESP_LOGV(TAG, "[%s] After on_status, new mode=%s", this->get_name().c_str(), | ||||
|            LOG_STR_ARG(climate_mode_to_string(this->mode))); | ||||
|   // FIXME: compare new state to previous state. | ||||
|   this->publish_state(); | ||||
| } | ||||
|  | ||||
| /** Attempts to update the climate device from the last received BedjetStatusPacket. | ||||
|  * | ||||
|  * This will be called from #on_status() when the parent dispatches new status packets, | ||||
|  * and from #update() when the polling interval is triggered. | ||||
|  * | ||||
|  * @return `true` if the status has been applied; `false` if there is nothing to apply. | ||||
|  */ | ||||
| bool BedJetClimate::update_status_() { | ||||
|   if (!this->parent_->is_connected()) | ||||
|     return false; | ||||
|   if (!this->parent_->has_status()) | ||||
|     return false; | ||||
|  | ||||
|   auto *status = this->parent_->get_status_packet(); | ||||
|  | ||||
|   if (status == nullptr) | ||||
|     return false; | ||||
|  | ||||
|   this->on_status(status); | ||||
|  | ||||
|   if (this->is_valid_()) { | ||||
|     // TODO: only if state changed? | ||||
|     this->publish_state(); | ||||
|     this->status_clear_warning(); | ||||
|     return true; | ||||
|   } | ||||
|  | ||||
|   return false; | ||||
| } | ||||
|  | ||||
| void BedJetClimate::update() { | ||||
|   ESP_LOGD(TAG, "[%s] update()", this->get_name().c_str()); | ||||
|   // TODO: if the hub component is already polling, do we also need to include polling? | ||||
|   //  We're already going to get on_status() at the hub's polling interval. | ||||
|   auto result = this->update_status_(); | ||||
|   ESP_LOGD(TAG, "[%s] update_status result=%s", this->get_name().c_str(), result ? "true" : "false"); | ||||
| } | ||||
|  | ||||
| }  // namespace bedjet | ||||
| }  // namespace esphome | ||||
|  | ||||
| #endif | ||||
| @@ -1,53 +1,34 @@ | ||||
| #pragma once | ||||
| 
 | ||||
| #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 "esphome/core/component.h" | ||||
| #include "esphome/core/defines.h" | ||||
| #include "esphome/core/hal.h" | ||||
| #include "bedjet_base.h" | ||||
| 
 | ||||
| #ifdef USE_TIME | ||||
| #include "esphome/components/time/real_time_clock.h" | ||||
| #endif | ||||
| #include "bedjet_child.h" | ||||
| #include "bedjet_codec.h" | ||||
| #include "bedjet_hub.h" | ||||
| 
 | ||||
| #ifdef USE_ESP32 | ||||
| 
 | ||||
| #include <esp_gattc_api.h> | ||||
| 
 | ||||
| namespace esphome { | ||||
| namespace bedjet { | ||||
| 
 | ||||
| namespace espbt = esphome::esp32_ble_tracker; | ||||
| 
 | ||||
| static const espbt::ESPBTUUID BEDJET_SERVICE_UUID = espbt::ESPBTUUID::from_raw("00001000-bed0-0080-aa55-4265644a6574"); | ||||
| static const espbt::ESPBTUUID BEDJET_STATUS_UUID = espbt::ESPBTUUID::from_raw("00002000-bed0-0080-aa55-4265644a6574"); | ||||
| static const espbt::ESPBTUUID BEDJET_COMMAND_UUID = espbt::ESPBTUUID::from_raw("00002004-bed0-0080-aa55-4265644a6574"); | ||||
| static const espbt::ESPBTUUID BEDJET_NAME_UUID = espbt::ESPBTUUID::from_raw("00002001-bed0-0080-aa55-4265644a6574"); | ||||
| 
 | ||||
| class Bedjet : public climate::Climate, public esphome::ble_client::BLEClientNode, public PollingComponent { | ||||
| class BedJetClimate : public climate::Climate, public BedJetClient, 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::AFTER_WIFI; } | ||||
| 
 | ||||
| #ifdef USE_TIME | ||||
|   void set_time_id(time::RealTimeClock *time_id) { this->time_id_ = time_id; } | ||||
|   void send_local_time(); | ||||
| #endif | ||||
|   void set_clock(uint8_t hour, uint8_t minute); | ||||
|   void set_status_timeout(uint32_t timeout) { this->timeout_ = timeout; } | ||||
|   /* BedJetClient status update */ | ||||
|   void on_status(const BedjetStatusPacket *data) override; | ||||
|   void on_bedjet_state(bool is_ready) override; | ||||
|   std::string describe() override; | ||||
| 
 | ||||
|   /** Sets the default strategy to use for climate::CLIMATE_MODE_HEAT. */ | ||||
|   void set_heating_mode(BedjetHeatMode mode) { this->heating_mode_ = mode; } | ||||
| 
 | ||||
|   /** Attempts to check for and apply firmware updates. */ | ||||
|   void upgrade_firmware(); | ||||
| 
 | ||||
|   climate::ClimateTraits traits() override { | ||||
|     auto traits = climate::ClimateTraits(); | ||||
|     traits.set_supports_action(true); | ||||
| @@ -92,20 +73,8 @@ class Bedjet : public climate::Climate, public esphome::ble_client::BLEClientNod | ||||
|  protected: | ||||
|   void control(const climate::ClimateCall &call) override; | ||||
| 
 | ||||
| #ifdef USE_TIME | ||||
|   void setup_time_(); | ||||
|   optional<time::RealTimeClock *> time_id_{}; | ||||
| #endif | ||||
| 
 | ||||
|   uint32_t timeout_{DEFAULT_STATUS_TIMEOUT}; | ||||
|   BedjetHeatMode heating_mode_ = HEAT_MODE_HEAT; | ||||
| 
 | ||||
|   static const uint32_t MIN_NOTIFY_THROTTLE = 5000; | ||||
|   static const uint32_t NOTIFY_WARN_THRESHOLD = 300000; | ||||
|   static const uint32_t DEFAULT_STATUS_TIMEOUT = 900000; | ||||
| 
 | ||||
|   uint8_t set_notify_(bool enable); | ||||
|   uint8_t write_bedjet_packet_(BedjetPacket *pkt); | ||||
|   void reset_state_(); | ||||
|   bool update_status_(); | ||||
| 
 | ||||
| @@ -114,17 +83,6 @@ class Bedjet : public climate::Climate, public esphome::ble_client::BLEClientNod | ||||
|     return !std::isnan(this->current_temperature) && !std::isnan(this->target_temperature) && | ||||
|            this->current_temperature > 1 && this->target_temperature > 1; | ||||
|   } | ||||
| 
 | ||||
|   uint32_t last_notify_ = 0; | ||||
|   bool force_refresh_ = false; | ||||
| 
 | ||||
|   std::unique_ptr<BedjetCodec> codec_; | ||||
|   uint16_t char_handle_cmd_; | ||||
|   uint16_t char_handle_name_; | ||||
|   uint16_t char_handle_status_; | ||||
|   uint16_t config_descr_status_; | ||||
| 
 | ||||
|   uint8_t write_notify_config_descriptor_(bool enable); | ||||
| }; | ||||
| 
 | ||||
| }  // namespace bedjet
 | ||||
| @@ -1,4 +1,4 @@ | ||||
| #include "bedjet_base.h" | ||||
| #include "bedjet_codec.h" | ||||
| #include <cstdio> | ||||
| #include <cstring> | ||||
| 
 | ||||
| @@ -48,7 +48,16 @@ BedjetPacket *BedjetCodec::get_set_fan_speed_request(const uint8_t fan_step) { | ||||
| 
 | ||||
| /** Returns a BedjetPacket that will set the device's current time. */ | ||||
| BedjetPacket *BedjetCodec::get_set_time_request(const uint8_t hour, const uint8_t minute) { | ||||
|   this->packet_.command = CMD_SET_TIME; | ||||
|   this->packet_.command = CMD_SET_CLOCK; | ||||
|   this->packet_.data_length = 2; | ||||
|   this->packet_.data[0] = hour; | ||||
|   this->packet_.data[1] = minute; | ||||
|   return this->clean_packet_(); | ||||
| } | ||||
| 
 | ||||
| /** Returns a BedjetPacket that will set the device's remaining runtime. */ | ||||
| BedjetPacket *BedjetCodec::get_set_runtime_remaining_request(const uint8_t hour, const uint8_t minute) { | ||||
|   this->packet_.command = CMD_SET_RUNTIME; | ||||
|   this->packet_.data_length = 2; | ||||
|   this->packet_.data[0] = hour; | ||||
|   this->packet_.data[1] = minute; | ||||
| @@ -57,17 +66,17 @@ BedjetPacket *BedjetCodec::get_set_time_request(const uint8_t hour, const uint8_ | ||||
| 
 | ||||
| /** Decodes the extra bytes that were received after being notified with a partial packet. */ | ||||
| void BedjetCodec::decode_extra(const uint8_t *data, uint16_t length) { | ||||
|   ESP_LOGV(TAG, "Received extra: %d bytes: %d %d %d %d", length, data[1], data[2], data[3], data[4]); | ||||
|   ESP_LOGVV(TAG, "Received extra: %d bytes: %d %d %d %d", length, data[1], data[2], data[3], data[4]); | ||||
|   uint8_t offset = this->last_buffer_size_; | ||||
|   if (offset > 0 && length + offset <= sizeof(BedjetStatusPacket)) { | ||||
|     memcpy(((uint8_t *) (&this->buf_)) + offset, data, length); | ||||
|     ESP_LOGV(TAG, | ||||
|              "Extra bytes: skip1=0x%08x, skip2=0x%04x, skip3=0x%02x; update phase=0x%02x, " | ||||
|              "flags=BedjetFlags <conn=%c, leds=%c, units=%c, mute=%c, others=%02x>", | ||||
|              this->buf_._skip_1_, this->buf_._skip_2_, this->buf_._skip_3_, this->buf_.update_phase, | ||||
|              this->buf_.flags & 0x20 ? '1' : '0', this->buf_.flags & 0x10 ? '1' : '0', | ||||
|              this->buf_.flags & 0x04 ? '1' : '0', this->buf_.flags & 0x01 ? '1' : '0', | ||||
|              this->buf_.flags & ~(0x20 | 0x10 | 0x04 | 0x01)); | ||||
|     ESP_LOGVV(TAG, | ||||
|               "Extra bytes: skip1=0x%08x, skip2=0x%04x, skip3=0x%02x; update phase=0x%02x, " | ||||
|               "flags=BedjetFlags <conn=%c, leds=%c, units=%c, mute=%c; packed=%02x>", | ||||
|               this->buf_.unused_1, this->buf_.unused_2, this->buf_.unused_3, this->buf_.update_phase, | ||||
|               this->buf_.flags.conn_test_passed ? '1' : '0', this->buf_.flags.leds_enabled ? '1' : '0', | ||||
|               this->buf_.flags.units_setup ? '1' : '0', this->buf_.flags.beeps_muted ? '1' : '0', | ||||
|               this->buf_.flags_packed); | ||||
|   } else { | ||||
|     ESP_LOGI(TAG, "Could not determine where to append to, last offset=%d, max size=%u, new size would be %d", offset, | ||||
|              sizeof(BedjetStatusPacket), length + offset); | ||||
| @@ -82,8 +91,6 @@ bool BedjetCodec::decode_notify(const uint8_t *data, uint16_t length) { | ||||
|   ESP_LOGV(TAG, "Received: %d bytes: %d %d %d %d", length, data[1], data[2], data[3], data[4]); | ||||
| 
 | ||||
|   if (data[1] == PACKET_FORMAT_V3_HOME && data[3] == PACKET_TYPE_STATUS) { | ||||
|     this->status_packet_.reset(); | ||||
| 
 | ||||
|     // Clear old buffer
 | ||||
|     memset(&this->buf_, 0, sizeof(BedjetStatusPacket)); | ||||
|     // Copy new data into buffer
 | ||||
| @@ -91,23 +98,24 @@ bool BedjetCodec::decode_notify(const uint8_t *data, uint16_t length) { | ||||
|     this->last_buffer_size_ = length; | ||||
| 
 | ||||
|     // TODO: validate the packet checksum?
 | ||||
|     if (this->buf_.mode >= 0 && this->buf_.mode < 7 && this->buf_.target_temp_step >= 38 && | ||||
|         this->buf_.target_temp_step <= 86 && this->buf_.actual_temp_step > 1 && this->buf_.actual_temp_step <= 100 && | ||||
|         this->buf_.ambient_temp_step > 1 && this->buf_.ambient_temp_step <= 100) { | ||||
|     if (this->buf_.mode < 7 && this->buf_.target_temp_step >= 38 && this->buf_.target_temp_step <= 86 && | ||||
|         this->buf_.actual_temp_step > 1 && this->buf_.actual_temp_step <= 100 && this->buf_.ambient_temp_step > 1 && | ||||
|         this->buf_.ambient_temp_step <= 100) { | ||||
|       // and save it for the update() loop
 | ||||
|       this->status_packet_ = this->buf_; | ||||
|       return this->buf_.is_partial == 1; | ||||
|       this->status_packet_ = &this->buf_; | ||||
|       return this->buf_.is_partial; | ||||
|     } else { | ||||
|       this->status_packet_ = nullptr; | ||||
|       // TODO: log a warning if we detect that we connected to a non-V3 device.
 | ||||
|       ESP_LOGW(TAG, "Received potentially invalid packet (len %d):", length); | ||||
|     } | ||||
|   } else if (data[1] == PACKET_FORMAT_DEBUG || data[3] == PACKET_TYPE_DEBUG) { | ||||
|     // We don't actually know the packet format for this. Dump packets to log, in case a pattern presents itself.
 | ||||
|     ESP_LOGV(TAG, | ||||
|              "received DEBUG packet: set1=%01fF, set2=%01fF, air=%01fF;  [7]=%d, [8]=%d, [9]=%d, [10]=%d, [11]=%d, " | ||||
|              "[12]=%d, [-1]=%d", | ||||
|              bedjet_temp_to_f(data[4]), bedjet_temp_to_f(data[5]), bedjet_temp_to_f(data[6]), data[7], data[8], data[9], | ||||
|              data[10], data[11], data[12], data[length - 1]); | ||||
|     ESP_LOGVV(TAG, | ||||
|               "received DEBUG packet: set1=%01fF, set2=%01fF, air=%01fF;  [7]=%d, [8]=%d, [9]=%d, [10]=%d, [11]=%d, " | ||||
|               "[12]=%d, [-1]=%d", | ||||
|               bedjet_temp_to_f(data[4]), bedjet_temp_to_f(data[5]), bedjet_temp_to_f(data[6]), data[7], data[8], | ||||
|               data[9], data[10], data[11], data[12], data[length - 1]); | ||||
| 
 | ||||
|     if (this->has_status()) { | ||||
|       this->status_packet_->ambient_temp_step = data[6]; | ||||
| @@ -119,5 +127,35 @@ bool BedjetCodec::decode_notify(const uint8_t *data, uint16_t length) { | ||||
|   return false; | ||||
| } | ||||
| 
 | ||||
| /** @return `true` if the new packet is meaningfully different from the last seen packet. */ | ||||
| bool BedjetCodec::compare(const uint8_t *data, uint16_t length) { | ||||
|   if (data == nullptr) { | ||||
|     return false; | ||||
|   } | ||||
| 
 | ||||
|   if (length < 17) { | ||||
|     // New packet looks small, skip it.
 | ||||
|     return false; | ||||
|   } | ||||
| 
 | ||||
|   if (this->buf_.packet_format != PACKET_FORMAT_V3_HOME || | ||||
|       this->buf_.packet_type != PACKET_TYPE_STATUS) {  // No last seen packet, so take the new one.
 | ||||
|     return true; | ||||
|   } | ||||
| 
 | ||||
|   if (data[1] != PACKET_FORMAT_V3_HOME || data[3] != PACKET_TYPE_STATUS) {  // New packet is not a v3 status, skip it.
 | ||||
|     return false; | ||||
|   } | ||||
| 
 | ||||
|   // Now coerce it to a status packet and compare some key fields
 | ||||
|   const BedjetStatusPacket *test = reinterpret_cast<const BedjetStatusPacket *>(data); | ||||
|   // These are fields that will only change due to explicit action.
 | ||||
|   // That is why we do not check ambient or actual temp here, because those are environmental.
 | ||||
|   bool explicit_fields_changed = this->buf_.mode != test->mode || this->buf_.fan_step != test->fan_step || | ||||
|                                  this->buf_.target_temp_step != test->target_temp_step; | ||||
| 
 | ||||
|   return explicit_fields_changed; | ||||
| } | ||||
| 
 | ||||
| }  // namespace bedjet
 | ||||
| }  // namespace esphome
 | ||||
| @@ -14,18 +14,6 @@ struct BedjetPacket { | ||||
|   uint8_t data[2]; | ||||
| }; | ||||
| 
 | ||||
| struct BedjetFlags { | ||||
|   /* uint8_t */ | ||||
|   int a_ : 1;                // 0x80
 | ||||
|   int b_ : 1;                // 0x40
 | ||||
|   int conn_test_passed : 1;  ///< (0x20) Bit is set `1` if the last connection test passed.
 | ||||
|   int leds_enabled : 1;      ///< (0x10) Bit is set `1` if the LEDs on the device are enabled.
 | ||||
|   int c_ : 1;                // 0x08
 | ||||
|   int units_setup : 1;       ///< (0x04) Bit is set `1` if the device's units have been configured.
 | ||||
|   int d_ : 1;                // 0x02
 | ||||
|   int beeps_muted : 1;       ///< (0x01) Bit is set `1` if the device's sound output is muted.
 | ||||
| } __attribute__((packed)); | ||||
| 
 | ||||
| enum BedjetPacketFormat : uint8_t { | ||||
|   PACKET_FORMAT_DEBUG = 0x05,    //  5
 | ||||
|   PACKET_FORMAT_V3_HOME = 0x56,  // 86
 | ||||
| @@ -36,15 +24,25 @@ enum BedjetPacketType : uint8_t { | ||||
|   PACKET_TYPE_DEBUG = 0x2, | ||||
| }; | ||||
| 
 | ||||
| enum BedjetNotification : uint8_t { | ||||
|   NOTIFY_NONE = 0,                    ///< No notification pending
 | ||||
|   NOTIFY_FILTER = 1,                  ///< Clean Filter / Please check BedJet air filter and clean if necessary.
 | ||||
|   NOTIFY_UPDATE = 2,                  ///< Firmware Update / A newer version of firmware is available.
 | ||||
|   NOTIFY_UPDATE_FAIL = 3,             ///< Firmware Update / Unable to connect to the firmware update server.
 | ||||
|   NOTIFY_BIO_FAIL_CLOCK_NOT_SET = 4,  ///< The specified sequence cannot be run because the clock is not set
 | ||||
|   NOTIFY_BIO_FAIL_TOO_LONG = 5,  ///< The specified sequence cannot be run because it contains steps that would be too
 | ||||
|                                  ///< long running from the current time.
 | ||||
|   // Note: after handling a notification, send MAGIC_NOTIFY_ACK
 | ||||
| }; | ||||
| 
 | ||||
| /** The format of a BedJet V3 status packet. */ | ||||
| struct BedjetStatusPacket { | ||||
|   // [0]
 | ||||
|   uint8_t is_partial : 8;  ///< `1` indicates that this is a partial packet, and more data can be read directly from the
 | ||||
|                            ///< characteristic.
 | ||||
|   bool is_partial : 8;  ///< `1` indicates that this is a partial packet, and more data can be read directly from the
 | ||||
|                         ///< characteristic.
 | ||||
|   BedjetPacketFormat packet_format : 8;  ///< BedjetPacketFormat::PACKET_FORMAT_V3_HOME for BedJet V3 status packet
 | ||||
|                                          ///< format. BedjetPacketFormat::PACKET_FORMAT_DEBUG for debugging packets.
 | ||||
|   uint8_t | ||||
|       expecting_length : 8;  ///< The expected total length of the status packet after merging the additional packet.
 | ||||
|   uint8_t expecting_length : 8;      ///< The expected total length of the status packet after merging the extra packet.
 | ||||
|   BedjetPacketType packet_type : 8;  ///< Typically BedjetPacketType::PACKET_TYPE_STATUS for BedJet V3 status packet.
 | ||||
| 
 | ||||
|   // [4]
 | ||||
| @@ -77,11 +75,26 @@ struct BedjetStatusPacket { | ||||
|   uint8_t shutdown_reason : 8;    ///< The reason for the last device shutdown.
 | ||||
| 
 | ||||
|   // [19-25]; the initial partial packet cuts off here after [19]
 | ||||
|   // Skip 7 bytes?
 | ||||
|   uint32_t _skip_1_ : 32;  // Unknown 19-22 = 0x01810112
 | ||||
| 
 | ||||
|   uint16_t _skip_2_ : 16;  // Unknown 23-24 = 0x1310
 | ||||
|   uint8_t _skip_3_ : 8;    // Unknown 25 = 0x00
 | ||||
|   uint8_t unused_1 : 8;  // Unknown [19] = 0x01
 | ||||
|   uint8_t unused_2 : 8;  // Unknown [20] = 0x81
 | ||||
|   uint8_t unused_3 : 8;  // Unknown [21] = 0x01
 | ||||
| 
 | ||||
|   // [22]: 0x2=is_dual_zone, ...?
 | ||||
|   struct { | ||||
|     int unused_1 : 1;       // 0x80
 | ||||
|     int unused_2 : 1;       // 0x40
 | ||||
|     int unused_3 : 1;       // 0x20
 | ||||
|     int unused_4 : 1;       // 0x10
 | ||||
|     int unused_5 : 1;       // 0x8
 | ||||
|     int unused_6 : 1;       // 0x4
 | ||||
|     bool is_dual_zone : 1;  /// Is part of a Dual Zone configuration
 | ||||
|     int unused_7 : 1;       // 0x1
 | ||||
|   } dual_zone_flags; | ||||
| 
 | ||||
|   uint8_t unused_4 : 8;  // Unknown 23-24 = 0x1310
 | ||||
|   uint8_t unused_5 : 8;  // Unknown 23-24 = 0x1310
 | ||||
|   uint8_t unused_6 : 8;  // Unknown 25 = 0x00
 | ||||
| 
 | ||||
|   // [26]
 | ||||
|   //   0x18(24) = "Connection test has completed OK"
 | ||||
| @@ -89,10 +102,27 @@ struct BedjetStatusPacket { | ||||
|   uint8_t update_phase : 8;  ///< The current status/phase of a firmware update.
 | ||||
| 
 | ||||
|   // [27]
 | ||||
|   // FIXME: cannot nest packed struct of matching length here?
 | ||||
|   /* BedjetFlags */ uint8_t flags : 8;  /// See BedjetFlags for the packed byte flags.
 | ||||
|   // [28-31]; 20+11 bytes
 | ||||
|   uint32_t _skip_4_ : 32;  // Unknown
 | ||||
|   union { | ||||
|     uint8_t flags_packed; | ||||
|     struct { | ||||
|       /* uint8_t */ | ||||
|       int unused_1 : 1;           // 0x80
 | ||||
|       int unused_2 : 1;           // 0x40
 | ||||
|       bool conn_test_passed : 1;  ///< (0x20) Bit is set `1` if the last connection test passed.
 | ||||
|       bool leds_enabled : 1;      ///< (0x10) Bit is set `1` if the LEDs on the device are enabled.
 | ||||
|       int unused_3 : 1;           // 0x08
 | ||||
|       bool units_setup : 1;       ///< (0x04) Bit is set `1` if the device's units have been configured.
 | ||||
|       int unused_4 : 1;           // 0x02
 | ||||
|       bool beeps_muted : 1;       ///< (0x01) Bit is set `1` if the device's sound output is muted.
 | ||||
|     } __attribute__((packed)) flags; | ||||
|   }; | ||||
| 
 | ||||
|   // [28] = (biorhythm?) sequence step
 | ||||
|   uint8_t bio_sequence_step : 8;  /// Biorhythm sequence step number
 | ||||
|   // [29] = notify_code:
 | ||||
|   BedjetNotification notify_code : 8;  /// See BedjetNotification
 | ||||
| 
 | ||||
|   uint16_t unused_7 : 16;  // Unknown
 | ||||
| 
 | ||||
| } __attribute__((packed)); | ||||
| 
 | ||||
| @@ -127,7 +157,7 @@ struct BedjetStatusPacket { | ||||
|  * - Set current time | ||||
|  *   The BedJet needs to have its clock set properly in order to run the biorhythm programs, which might | ||||
|  *   contain time-of-day based step rules. | ||||
|  *   - BedjetPacket#command = BedjetCommand::CMD_SET_TIME | ||||
|  *   - BedjetPacket#command = BedjetCommand::CMD_SET_CLOCK | ||||
|  *   - BedjetPacket#data [0] is hours, [1] is minutes | ||||
|  */ | ||||
| class BedjetCodec { | ||||
| @@ -136,13 +166,15 @@ class BedjetCodec { | ||||
|   BedjetPacket *get_set_target_temp_request(float temperature); | ||||
|   BedjetPacket *get_set_fan_speed_request(uint8_t fan_step); | ||||
|   BedjetPacket *get_set_time_request(uint8_t hour, uint8_t minute); | ||||
|   BedjetPacket *get_set_runtime_remaining_request(uint8_t hour, uint8_t minute); | ||||
| 
 | ||||
|   bool decode_notify(const uint8_t *data, uint16_t length); | ||||
|   void decode_extra(const uint8_t *data, uint16_t length); | ||||
|   bool compare(const uint8_t *data, uint16_t length); | ||||
| 
 | ||||
|   inline bool has_status() { return this->status_packet_.has_value(); } | ||||
|   const optional<BedjetStatusPacket> &get_status_packet() const { return this->status_packet_; } | ||||
|   void clear_status() { this->status_packet_.reset(); } | ||||
|   inline bool has_status() { return this->status_packet_ != nullptr; } | ||||
|   const BedjetStatusPacket *get_status_packet() const { return this->status_packet_; } | ||||
|   void clear_status() { this->status_packet_ = nullptr; } | ||||
| 
 | ||||
|  protected: | ||||
|   BedjetPacket *clean_packet_(); | ||||
| @@ -151,7 +183,7 @@ class BedjetCodec { | ||||
| 
 | ||||
|   BedjetPacket packet_; | ||||
| 
 | ||||
|   optional<BedjetStatusPacket> status_packet_; | ||||
|   BedjetStatusPacket *status_packet_; | ||||
|   BedjetStatusPacket buf_; | ||||
| }; | ||||
| 
 | ||||
| @@ -7,6 +7,14 @@ namespace bedjet { | ||||
|  | ||||
| static const char *const TAG = "bedjet"; | ||||
|  | ||||
| /// Converts a BedJet fan step to a speed percentage, in the range of 5% to 100%. | ||||
| inline static uint8_t bedjet_fan_step_to_speed(const uint8_t fan) { | ||||
|   //  0 =  5% | ||||
|   // 19 = 100% | ||||
|   return 5 * fan + 5; | ||||
| } | ||||
| inline static uint8_t bedjet_fan_speed_to_index(const uint8_t speed) { return speed / 5 - 1; } | ||||
|  | ||||
| enum BedjetMode : uint8_t { | ||||
|   /// BedJet is Off | ||||
|   MODE_STANDBY = 0, | ||||
| @@ -62,14 +70,17 @@ enum BedjetButton : uint8_t { | ||||
|   MAGIC_CONNTEST = 0x42, | ||||
|   /// Request a firmware update. This will also restart the Bedjet. | ||||
|   MAGIC_UPDATE = 0x43, | ||||
|   /// Acknowledge notification handled. See BedjetNotify | ||||
|   MAGIC_NOTIFY_ACK = 0x52, | ||||
| }; | ||||
|  | ||||
| enum BedjetCommand : uint8_t { | ||||
|   CMD_BUTTON = 0x1, | ||||
|   CMD_SET_RUNTIME = 0x2, | ||||
|   CMD_SET_TEMP = 0x3, | ||||
|   CMD_STATUS = 0x6, | ||||
|   CMD_SET_FAN = 0x7, | ||||
|   CMD_SET_TIME = 0x8, | ||||
|   CMD_SET_CLOCK = 0x8, | ||||
| }; | ||||
|  | ||||
| #define BEDJET_FAN_STEP_NAMES_ \ | ||||
|   | ||||
							
								
								
									
										559
									
								
								esphome/components/bedjet/bedjet_hub.cpp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										559
									
								
								esphome/components/bedjet/bedjet_hub.cpp
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,559 @@ | ||||
| #include "bedjet_hub.h" | ||||
| #include "bedjet_child.h" | ||||
| #include "bedjet_const.h" | ||||
|  | ||||
| namespace esphome { | ||||
| namespace bedjet { | ||||
|  | ||||
| static const LogString *bedjet_button_to_string(BedjetButton button) { | ||||
|   switch (button) { | ||||
|     case BTN_OFF: | ||||
|       return LOG_STR("OFF"); | ||||
|     case BTN_COOL: | ||||
|       return LOG_STR("COOL"); | ||||
|     case BTN_HEAT: | ||||
|       return LOG_STR("HEAT"); | ||||
|     case BTN_EXTHT: | ||||
|       return LOG_STR("EXT HT"); | ||||
|     case BTN_TURBO: | ||||
|       return LOG_STR("TURBO"); | ||||
|     case BTN_DRY: | ||||
|       return LOG_STR("DRY"); | ||||
|     case BTN_M1: | ||||
|       return LOG_STR("M1"); | ||||
|     case BTN_M2: | ||||
|       return LOG_STR("M2"); | ||||
|     case BTN_M3: | ||||
|       return LOG_STR("M3"); | ||||
|     default: | ||||
|       return LOG_STR("unknown"); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /* Public */ | ||||
|  | ||||
| void BedJetHub::upgrade_firmware() { | ||||
|   auto *pkt = this->codec_->get_button_request(MAGIC_UPDATE); | ||||
|   auto status = this->write_bedjet_packet_(pkt); | ||||
|  | ||||
|   if (status) { | ||||
|     ESP_LOGW(TAG, "[%s] MAGIC_UPDATE button failed, status=%d", this->get_name().c_str(), status); | ||||
|   } | ||||
| } | ||||
|  | ||||
| bool BedJetHub::button_heat() { return this->send_button(BTN_HEAT); } | ||||
| bool BedJetHub::button_ext_heat() { return this->send_button(BTN_EXTHT); } | ||||
| bool BedJetHub::button_turbo() { return this->send_button(BTN_TURBO); } | ||||
| bool BedJetHub::button_cool() { return this->send_button(BTN_COOL); } | ||||
| bool BedJetHub::button_dry() { return this->send_button(BTN_DRY); } | ||||
| bool BedJetHub::button_off() { return this->send_button(BTN_OFF); } | ||||
| bool BedJetHub::button_memory1() { return this->send_button(BTN_M1); } | ||||
| bool BedJetHub::button_memory2() { return this->send_button(BTN_M2); } | ||||
| bool BedJetHub::button_memory3() { return this->send_button(BTN_M3); } | ||||
|  | ||||
| bool BedJetHub::set_fan_index(uint8_t fan_speed_index) { | ||||
|   if (fan_speed_index > 19) { | ||||
|     ESP_LOGW(TAG, "Invalid fan speed index %d, expecting 0-19.", fan_speed_index); | ||||
|     return false; | ||||
|   } | ||||
|  | ||||
|   auto *pkt = this->codec_->get_set_fan_speed_request(fan_speed_index); | ||||
|   auto status = this->write_bedjet_packet_(pkt); | ||||
|  | ||||
|   if (status) { | ||||
|     ESP_LOGW(TAG, "[%s] writing fan speed failed, status=%d", this->get_name().c_str(), status); | ||||
|   } | ||||
|   return status == 0; | ||||
| } | ||||
|  | ||||
| uint8_t BedJetHub::get_fan_index() { | ||||
|   auto *status = this->codec_->get_status_packet(); | ||||
|   if (status != nullptr) { | ||||
|     return status->fan_step; | ||||
|   } | ||||
|   return 0; | ||||
| } | ||||
|  | ||||
| bool BedJetHub::set_target_temp(float temp_c) { | ||||
|   auto *pkt = this->codec_->get_set_target_temp_request(temp_c); | ||||
|   auto status = this->write_bedjet_packet_(pkt); | ||||
|  | ||||
|   if (status) { | ||||
|     ESP_LOGW(TAG, "[%s] writing target temp failed, status=%d", this->get_name().c_str(), status); | ||||
|   } | ||||
|   return status == 0; | ||||
| } | ||||
|  | ||||
| bool BedJetHub::set_time_remaining(uint8_t hours, uint8_t mins) { | ||||
|   // FIXME: this may fail depending on current mode or other restrictions enforced by the unit. | ||||
|   auto *pkt = this->codec_->get_set_runtime_remaining_request(hours, mins); | ||||
|   auto status = this->write_bedjet_packet_(pkt); | ||||
|  | ||||
|   if (status) { | ||||
|     ESP_LOGW(TAG, "[%s] writing remaining runtime failed, status=%d", this->get_name().c_str(), status); | ||||
|   } | ||||
|   return status == 0; | ||||
| } | ||||
|  | ||||
| bool BedJetHub::send_button(BedjetButton button) { | ||||
|   auto *pkt = this->codec_->get_button_request(button); | ||||
|   auto status = this->write_bedjet_packet_(pkt); | ||||
|  | ||||
|   if (status) { | ||||
|     ESP_LOGW(TAG, "[%s] writing button %s failed, status=%d", this->get_name().c_str(), | ||||
|              LOG_STR_ARG(bedjet_button_to_string(button)), status); | ||||
|   } else { | ||||
|     ESP_LOGD(TAG, "[%s] writing button %s success", this->get_name().c_str(), | ||||
|              LOG_STR_ARG(bedjet_button_to_string(button))); | ||||
|   } | ||||
|   return status == 0; | ||||
| } | ||||
|  | ||||
| uint16_t BedJetHub::get_time_remaining() { | ||||
|   auto *status = this->codec_->get_status_packet(); | ||||
|   if (status != nullptr) { | ||||
|     return status->time_remaining_secs + status->time_remaining_mins * 60 + status->time_remaining_hrs * 3600; | ||||
|   } | ||||
|   return 0; | ||||
| } | ||||
|  | ||||
| /* Bluetooth/GATT */ | ||||
|  | ||||
| uint8_t BedJetHub::write_bedjet_packet_(BedjetPacket *pkt) { | ||||
|   if (!this->is_connected()) { | ||||
|     if (!this->parent_->enabled) { | ||||
|       ESP_LOGI(TAG, "[%s] Cannot write packet: Not connected, enabled=false", this->get_name().c_str()); | ||||
|     } else { | ||||
|       ESP_LOGW(TAG, "[%s] Cannot write packet: Not connected", this->get_name().c_str()); | ||||
|     } | ||||
|     return -1; | ||||
|   } | ||||
|   auto status = esp_ble_gattc_write_char(this->parent_->gattc_if, this->parent_->conn_id, this->char_handle_cmd_, | ||||
|                                          pkt->data_length + 1, (uint8_t *) &pkt->command, ESP_GATT_WRITE_TYPE_NO_RSP, | ||||
|                                          ESP_GATT_AUTH_REQ_NONE); | ||||
|   return status; | ||||
| } | ||||
|  | ||||
| /** Configures the local ESP BLE client to register (`true`) or unregister (`false`) for status notifications. */ | ||||
| uint8_t BedJetHub::set_notify_(const bool enable) { | ||||
|   uint8_t status; | ||||
|   if (enable) { | ||||
|     status = esp_ble_gattc_register_for_notify(this->parent_->gattc_if, this->parent_->remote_bda, | ||||
|                                                this->char_handle_status_); | ||||
|     if (status) { | ||||
|       ESP_LOGW(TAG, "[%s] esp_ble_gattc_register_for_notify failed, status=%d", this->get_name().c_str(), status); | ||||
|     } | ||||
|   } else { | ||||
|     status = esp_ble_gattc_unregister_for_notify(this->parent_->gattc_if, this->parent_->remote_bda, | ||||
|                                                  this->char_handle_status_); | ||||
|     if (status) { | ||||
|       ESP_LOGW(TAG, "[%s] esp_ble_gattc_unregister_for_notify failed, status=%d", this->get_name().c_str(), status); | ||||
|     } | ||||
|   } | ||||
|   ESP_LOGV(TAG, "[%s] set_notify: enable=%d; result=%d", this->get_name().c_str(), enable, status); | ||||
|   return status; | ||||
| } | ||||
|  | ||||
| bool BedJetHub::discover_characteristics_() { | ||||
|   bool result = true; | ||||
|   esphome::ble_client::BLECharacteristic *chr; | ||||
|  | ||||
|   if (!this->char_handle_cmd_) { | ||||
|     chr = this->parent_->get_characteristic(BEDJET_SERVICE_UUID, BEDJET_COMMAND_UUID); | ||||
|     if (chr == nullptr) { | ||||
|       ESP_LOGW(TAG, "[%s] No control service found at device, not a BedJet..?", this->get_name().c_str()); | ||||
|       result = false; | ||||
|     } else { | ||||
|       this->char_handle_cmd_ = chr->handle; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   if (!this->char_handle_status_) { | ||||
|     chr = this->parent_->get_characteristic(BEDJET_SERVICE_UUID, BEDJET_STATUS_UUID); | ||||
|     if (chr == nullptr) { | ||||
|       ESP_LOGW(TAG, "[%s] No status service found at device, not a BedJet..?", this->get_name().c_str()); | ||||
|       result = false; | ||||
|     } else { | ||||
|       this->char_handle_status_ = chr->handle; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   if (!this->config_descr_status_) { | ||||
|     // We also need to obtain the config descriptor for this handle. | ||||
|     // Otherwise once we set node_state=Established, the parent will flush all handles/descriptors, and we won't be | ||||
|     // able to look it up. | ||||
|     auto *descr = this->parent_->get_config_descriptor(this->char_handle_status_); | ||||
|     if (descr == nullptr) { | ||||
|       ESP_LOGW(TAG, "No config descriptor for status handle 0x%x. Will not be able to receive status notifications", | ||||
|                this->char_handle_status_); | ||||
|       result = false; | ||||
|     } else if (descr->uuid.get_uuid().len != ESP_UUID_LEN_16 || | ||||
|                descr->uuid.get_uuid().uuid.uuid16 != ESP_GATT_UUID_CHAR_CLIENT_CONFIG) { | ||||
|       ESP_LOGW(TAG, "Config descriptor 0x%x (uuid %s) is not a client config char uuid", this->char_handle_status_, | ||||
|                descr->uuid.to_string().c_str()); | ||||
|       result = false; | ||||
|     } else { | ||||
|       this->config_descr_status_ = descr->handle; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   if (!this->char_handle_name_) { | ||||
|     chr = this->parent_->get_characteristic(BEDJET_SERVICE_UUID, BEDJET_NAME_UUID); | ||||
|     if (chr == nullptr) { | ||||
|       ESP_LOGW(TAG, "[%s] No name service found at device, not a BedJet..?", this->get_name().c_str()); | ||||
|       result = false; | ||||
|     } else { | ||||
|       this->char_handle_name_ = chr->handle; | ||||
|       auto status = esp_ble_gattc_read_char(this->parent_->gattc_if, this->parent_->conn_id, this->char_handle_name_, | ||||
|                                             ESP_GATT_AUTH_REQ_NONE); | ||||
|       if (status) { | ||||
|         ESP_LOGI(TAG, "[%s] Unable to read name characteristic: %d", this->get_name().c_str(), status); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   ESP_LOGI(TAG, "[%s] Discovered service characteristics: ", this->get_name().c_str()); | ||||
|   ESP_LOGI(TAG, "     - Command char: 0x%x", this->char_handle_cmd_); | ||||
|   ESP_LOGI(TAG, "     - Status char: 0x%x", this->char_handle_status_); | ||||
|   ESP_LOGI(TAG, "       - config descriptor: 0x%x", this->config_descr_status_); | ||||
|   ESP_LOGI(TAG, "     - Name char: 0x%x", this->char_handle_name_); | ||||
|  | ||||
|   return result; | ||||
| } | ||||
|  | ||||
| void BedJetHub::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_LOGV(TAG, "Disconnected: reason=%d", param->disconnect.reason); | ||||
|       this->status_set_warning(); | ||||
|       this->dispatch_state_(false); | ||||
|       break; | ||||
|     } | ||||
|     case ESP_GATTC_OPEN_EVT: { | ||||
|       // FIXME: bug in BLEClient | ||||
|       this->parent_->conn_id = param->open.conn_id; | ||||
|       this->open_conn_id_ = param->open.conn_id; | ||||
|       break; | ||||
|     } | ||||
|  | ||||
|     case ESP_GATTC_CONNECT_EVT: { | ||||
|       if (this->parent_->conn_id != param->connect.conn_id && this->open_conn_id_ != 0xff) { | ||||
|         // FIXME: bug in BLEClient | ||||
|         ESP_LOGW(TAG, "[%s] CONNECT_EVT unexpected conn_id; open=%d, parent=%d, param=%d", this->get_name().c_str(), | ||||
|                  this->open_conn_id_, this->parent_->conn_id, param->connect.conn_id); | ||||
|         this->parent_->conn_id = this->open_conn_id_; | ||||
|       } | ||||
|       break; | ||||
|     } | ||||
|     case ESP_GATTC_SEARCH_CMPL_EVT: { | ||||
|       auto result = this->discover_characteristics_(); | ||||
|  | ||||
|       if (result) { | ||||
|         ESP_LOGD(TAG, "[%s] Services complete: obtained char handles.", this->get_name().c_str()); | ||||
|         this->node_state = espbt::ClientState::ESTABLISHED; | ||||
|         this->set_notify_(true); | ||||
|  | ||||
| #ifdef USE_TIME | ||||
|         if (this->time_id_.has_value()) { | ||||
|           this->send_local_time(); | ||||
|         } | ||||
| #endif | ||||
|  | ||||
|         this->dispatch_state_(true); | ||||
|       } else { | ||||
|         ESP_LOGW(TAG, "[%s] Failed discovering service characteristics.", this->get_name().c_str()); | ||||
|         this->parent()->set_enabled(false); | ||||
|         this->status_set_warning(); | ||||
|         this->dispatch_state_(false); | ||||
|       } | ||||
|       break; | ||||
|     } | ||||
|     case ESP_GATTC_WRITE_DESCR_EVT: { | ||||
|       if (param->write.status != ESP_GATT_OK) { | ||||
|         if (param->write.status == ESP_GATT_INVALID_ATTR_LEN) { | ||||
|           // This probably means that our hack for notify_en (8 bit vs 16 bit) didn't work right. | ||||
|           // Should we try to fall back to BLEClient's way? | ||||
|           ESP_LOGW(TAG, "[%s] Invalid attr length writing descr at handle 0x%04d, status=%d", this->get_name().c_str(), | ||||
|                    param->write.handle, param->write.status); | ||||
|         } else { | ||||
|           ESP_LOGW(TAG, "[%s] Error writing descr at handle 0x%04d, status=%d", this->get_name().c_str(), | ||||
|                    param->write.handle, param->write.status); | ||||
|         } | ||||
|         break; | ||||
|       } | ||||
|       ESP_LOGD(TAG, "[%s] Write to handle 0x%04x status=%d", this->get_name().c_str(), param->write.handle, | ||||
|                param->write.status); | ||||
|       break; | ||||
|     } | ||||
|     case ESP_GATTC_WRITE_CHAR_EVT: { | ||||
|       if (param->write.status != ESP_GATT_OK) { | ||||
|         ESP_LOGW(TAG, "Error writing char at handle 0x%04d, status=%d", param->write.handle, param->write.status); | ||||
|         break; | ||||
|       } | ||||
|       if (param->write.handle == this->char_handle_cmd_) { | ||||
|         if (this->force_refresh_) { | ||||
|           // Command write was successful. Publish the pending state, hoping that notify will kick in. | ||||
|           // FIXME: better to wait until we know the status has changed | ||||
|           this->dispatch_status_(); | ||||
|         } | ||||
|       } | ||||
|       break; | ||||
|     } | ||||
|     case ESP_GATTC_READ_CHAR_EVT: { | ||||
|       if (param->read.conn_id != this->parent_->conn_id) | ||||
|         break; | ||||
|       if (param->read.status != ESP_GATT_OK) { | ||||
|         ESP_LOGW(TAG, "Error reading char at handle %d, status=%d", param->read.handle, param->read.status); | ||||
|         break; | ||||
|       } | ||||
|  | ||||
|       if (param->read.handle == this->char_handle_status_) { | ||||
|         // This is the additional packet that doesn't fit in the notify packet. | ||||
|         this->codec_->decode_extra(param->read.value, param->read.value_len); | ||||
|         this->status_packet_ready_(); | ||||
|       } else if (param->read.handle == this->char_handle_name_) { | ||||
|         // The data should represent the name. | ||||
|         if (param->read.status == ESP_GATT_OK && param->read.value_len > 0) { | ||||
|           std::string bedjet_name(reinterpret_cast<char const *>(param->read.value), param->read.value_len); | ||||
|           ESP_LOGV(TAG, "[%s] Got BedJet name: '%s'", this->get_name().c_str(), bedjet_name.c_str()); | ||||
|           this->set_name_(bedjet_name); | ||||
|         } | ||||
|       } | ||||
|       break; | ||||
|     } | ||||
|     case ESP_GATTC_REG_FOR_NOTIFY_EVT: { | ||||
|       // This event means that ESP received the request to enable notifications on the client side. But we also have to | ||||
|       // tell the server that we want it to send notifications. Normally BLEClient parent would handle this | ||||
|       // automatically, but as soon as we set our status to Established, the parent is going to purge all the | ||||
|       // service/char/descriptor handles, and then get_config_descriptor() won't work anymore. There's no way to disable | ||||
|       // the BLEClient parent behavior, so our only option is to write the handle anyway, and hope a double-write | ||||
|       // doesn't break anything. | ||||
|  | ||||
|       if (param->reg_for_notify.handle != this->char_handle_status_) { | ||||
|         ESP_LOGW(TAG, "[%s] Register for notify on unexpected handle 0x%04x, expecting 0x%04x", | ||||
|                  this->get_name().c_str(), param->reg_for_notify.handle, this->char_handle_status_); | ||||
|         break; | ||||
|       } | ||||
|  | ||||
|       this->write_notify_config_descriptor_(true); | ||||
|       this->last_notify_ = 0; | ||||
|       this->force_refresh_ = true; | ||||
|       break; | ||||
|     } | ||||
|     case ESP_GATTC_UNREG_FOR_NOTIFY_EVT: { | ||||
|       // This event is not handled by the parent BLEClient, so we need to do this either way. | ||||
|       if (param->unreg_for_notify.handle != this->char_handle_status_) { | ||||
|         ESP_LOGW(TAG, "[%s] Unregister for notify on unexpected handle 0x%04x, expecting 0x%04x", | ||||
|                  this->get_name().c_str(), param->unreg_for_notify.handle, this->char_handle_status_); | ||||
|         break; | ||||
|       } | ||||
|  | ||||
|       this->write_notify_config_descriptor_(false); | ||||
|       this->last_notify_ = 0; | ||||
|       // Now we wait until the next update() poll to re-register notify... | ||||
|       break; | ||||
|     } | ||||
|     case ESP_GATTC_NOTIFY_EVT: { | ||||
|       if (this->processing_) | ||||
|         break; | ||||
|  | ||||
|       if (param->notify.conn_id != this->parent_->conn_id) { | ||||
|         ESP_LOGW(TAG, "[%s] Received notify event for unexpected parent conn: expect %x, got %x", | ||||
|                  this->get_name().c_str(), this->parent_->conn_id, param->notify.conn_id); | ||||
|         // FIXME: bug in BLEClient holding wrong conn_id. | ||||
|       } | ||||
|  | ||||
|       if (param->notify.handle != this->char_handle_status_) { | ||||
|         ESP_LOGW(TAG, "[%s] Unexpected notify handle, wanted %04X, got %04X", this->get_name().c_str(), | ||||
|                  this->char_handle_status_, param->notify.handle); | ||||
|         break; | ||||
|       } | ||||
|  | ||||
|       // FIXME: notify events come in every ~200-300 ms, which is too fast to be helpful. So we | ||||
|       //  throttle the updates to once every MIN_NOTIFY_THROTTLE (5 seconds). | ||||
|       //  Another idea would be to keep notify off by default, and use update() as an opportunity to turn on | ||||
|       //  notify to get enough data to update status, then turn off notify again. | ||||
|  | ||||
|       uint32_t now = millis(); | ||||
|       auto delta = now - this->last_notify_; | ||||
|  | ||||
|       if (!this->force_refresh_ && this->codec_->compare(param->notify.value, param->notify.value_len)) { | ||||
|         // If the packet is meaningfully different, trigger children as well | ||||
|         this->force_refresh_ = true; | ||||
|         ESP_LOGV(TAG, "[%s] Incoming packet indicates a significant change.", this->get_name().c_str()); | ||||
|       } | ||||
|  | ||||
|       if (this->last_notify_ == 0 || delta > MIN_NOTIFY_THROTTLE || this->force_refresh_) { | ||||
|         // Set reentrant flag to prevent processing multiple packets. | ||||
|         this->processing_ = true; | ||||
|         ESP_LOGVV(TAG, "[%s] Decoding packet: last=%d, delta=%d, force=%s", this->get_name().c_str(), | ||||
|                   this->last_notify_, delta, this->force_refresh_ ? "y" : "n"); | ||||
|         bool needs_extra = this->codec_->decode_notify(param->notify.value, param->notify.value_len); | ||||
|  | ||||
|         if (needs_extra) { | ||||
|           // This means the packet was partial, so read the status characteristic to get the second part. | ||||
|           // Ideally this will complete quickly. We won't process additional notification events until it does. | ||||
|           auto status = esp_ble_gattc_read_char(this->parent_->gattc_if, this->parent_->conn_id, | ||||
|                                                 this->char_handle_status_, ESP_GATT_AUTH_REQ_NONE); | ||||
|           if (status) { | ||||
|             ESP_LOGI(TAG, "[%s] Unable to read extended status packet", this->get_name().c_str()); | ||||
|           } | ||||
|         } else { | ||||
|           this->status_packet_ready_(); | ||||
|         } | ||||
|       } | ||||
|       break; | ||||
|     } | ||||
|     default: | ||||
|       ESP_LOGVV(TAG, "[%s] gattc unhandled event: enum=%d", this->get_name().c_str(), event); | ||||
|       break; | ||||
|   } | ||||
| } | ||||
|  | ||||
| inline void BedJetHub::status_packet_ready_() { | ||||
|   this->last_notify_ = millis(); | ||||
|   this->processing_ = false; | ||||
|  | ||||
|   if (this->force_refresh_) { | ||||
|     // If we requested an immediate update, do that now. | ||||
|     this->update(); | ||||
|     this->force_refresh_ = false; | ||||
|   } | ||||
| } | ||||
|  | ||||
| /** Reimplementation of BLEClient.gattc_event_handler() for ESP_GATTC_REG_FOR_NOTIFY_EVT. | ||||
|  * | ||||
|  * This is a copy of ble_client's automatic handling of `ESP_GATTC_REG_FOR_NOTIFY_EVT`, in order | ||||
|  * to undo the same on unregister. It also allows us to maintain the config descriptor separately, | ||||
|  * since the parent BLEClient is going to purge all descriptors once we set our connection status | ||||
|  * to `Established`. | ||||
|  */ | ||||
| uint8_t BedJetHub::write_notify_config_descriptor_(bool enable) { | ||||
|   auto handle = this->config_descr_status_; | ||||
|   if (handle == 0) { | ||||
|     ESP_LOGW(TAG, "No descriptor found for notify of handle 0x%x", this->char_handle_status_); | ||||
|     return -1; | ||||
|   } | ||||
|  | ||||
|   // NOTE: BLEClient uses `uint8_t*` of length 1, but BLE spec requires 16 bits. | ||||
|   uint16_t notify_en = enable ? 1 : 0; | ||||
|   auto status = | ||||
|       esp_ble_gattc_write_char_descr(this->parent_->gattc_if, this->parent_->conn_id, handle, sizeof(notify_en), | ||||
|                                      (uint8_t *) ¬ify_en, ESP_GATT_WRITE_TYPE_RSP, ESP_GATT_AUTH_REQ_NONE); | ||||
|   if (status) { | ||||
|     ESP_LOGW(TAG, "esp_ble_gattc_write_char_descr error, status=%d", status); | ||||
|     return status; | ||||
|   } | ||||
|   ESP_LOGD(TAG, "[%s] wrote notify=%s to status config 0x%04x, for conn %d", this->get_name().c_str(), | ||||
|            enable ? "true" : "false", handle, this->parent_->conn_id); | ||||
|   return ESP_GATT_OK; | ||||
| } | ||||
|  | ||||
| /* Time Component */ | ||||
|  | ||||
| #ifdef USE_TIME | ||||
| void BedJetHub::send_local_time() { | ||||
|   if (this->time_id_.has_value()) { | ||||
|     auto *time_id = *this->time_id_; | ||||
|     time::ESPTime now = time_id->now(); | ||||
|     if (now.is_valid()) { | ||||
|       this->set_clock(now.hour, now.minute); | ||||
|       ESP_LOGD(TAG, "Using time component to set BedJet clock: %d:%02d", now.hour, now.minute); | ||||
|     } | ||||
|   } else { | ||||
|     ESP_LOGI(TAG, "`time_id` is not configured: will not sync BedJet clock."); | ||||
|   } | ||||
| } | ||||
|  | ||||
| void BedJetHub::setup_time_() { | ||||
|   if (this->time_id_.has_value()) { | ||||
|     this->send_local_time(); | ||||
|     auto *time_id = *this->time_id_; | ||||
|     time_id->add_on_time_sync_callback([this] { this->send_local_time(); }); | ||||
|   } else { | ||||
|     ESP_LOGI(TAG, "`time_id` is not configured: will not sync BedJet clock."); | ||||
|   } | ||||
| } | ||||
| #endif | ||||
|  | ||||
| void BedJetHub::set_clock(uint8_t hour, uint8_t minute) { | ||||
|   if (!this->is_connected()) { | ||||
|     ESP_LOGV(TAG, "[%s] Not connected, cannot send time.", this->get_name().c_str()); | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   BedjetPacket *pkt = this->codec_->get_set_time_request(hour, minute); | ||||
|   auto status = this->write_bedjet_packet_(pkt); | ||||
|   if (status) { | ||||
|     ESP_LOGW(TAG, "Failed setting BedJet clock: %d", status); | ||||
|   } else { | ||||
|     ESP_LOGD(TAG, "[%s] BedJet clock set to: %d:%02d", this->get_name().c_str(), hour, minute); | ||||
|   } | ||||
| } | ||||
|  | ||||
| /* Internal */ | ||||
|  | ||||
| void BedJetHub::loop() {} | ||||
| void BedJetHub::update() { this->dispatch_status_(); } | ||||
|  | ||||
| void BedJetHub::dump_config() { | ||||
|   ESP_LOGCONFIG(TAG, "BedJet Hub '%s'", this->get_name().c_str()); | ||||
|   ESP_LOGCONFIG(TAG, "  ble_client.app_id: %d", this->parent()->app_id); | ||||
|   ESP_LOGCONFIG(TAG, "  ble_client.conn_id: %d", this->parent()->conn_id); | ||||
|   LOG_UPDATE_INTERVAL(this) | ||||
|   ESP_LOGCONFIG(TAG, "  Child components (%d):", this->children_.size()); | ||||
|   for (auto *child : this->children_) { | ||||
|     ESP_LOGCONFIG(TAG, "    - %s", child->describe().c_str()); | ||||
|   } | ||||
| } | ||||
|  | ||||
| void BedJetHub::dispatch_state_(bool is_ready) { | ||||
|   for (auto *child : this->children_) { | ||||
|     child->on_bedjet_state(is_ready); | ||||
|   } | ||||
| } | ||||
|  | ||||
| void BedJetHub::dispatch_status_() { | ||||
|   auto *status = this->codec_->get_status_packet(); | ||||
|  | ||||
|   if (!this->is_connected()) { | ||||
|     ESP_LOGD(TAG, "[%s] Not connected, will not send status.", this->get_name().c_str()); | ||||
|   } else if (status != nullptr) { | ||||
|     ESP_LOGD(TAG, "[%s] Notifying %d children of latest status @%p.", this->get_name().c_str(), this->children_.size(), | ||||
|              status); | ||||
|     for (auto *child : this->children_) { | ||||
|       child->on_status(status); | ||||
|     } | ||||
|   } else { | ||||
|     uint32_t now = millis(); | ||||
|     uint32_t diff = now - this->last_notify_; | ||||
|  | ||||
|     if (this->last_notify_ == 0) { | ||||
|       // This means we're connected and haven't received a notification, so it likely means that the BedJet is off. | ||||
|       // However, it could also mean that it's running, but failing to send notifications. | ||||
|       // We can try to unregister for notifications now, and then re-register, hoping to clear it up... | ||||
|       // But how do we know for sure which state we're in, and how do we actually clear out the buggy state? | ||||
|  | ||||
|       ESP_LOGI(TAG, "[%s] Still waiting for first GATT notify event.", this->get_name().c_str()); | ||||
|     } else if (diff > NOTIFY_WARN_THRESHOLD) { | ||||
|       ESP_LOGW(TAG, "[%s] Last GATT notify was %d seconds ago.", this->get_name().c_str(), diff / 1000); | ||||
|     } | ||||
|  | ||||
|     if (this->timeout_ > 0 && diff > this->timeout_ && this->parent()->enabled) { | ||||
|       ESP_LOGW(TAG, "[%s] Timed out after %d sec. Retrying...", this->get_name().c_str(), this->timeout_); | ||||
|       // set_enabled(false) will only close the connection if state != IDLE. | ||||
|       this->parent()->set_state(espbt::ClientState::CONNECTING); | ||||
|       this->parent()->set_enabled(false); | ||||
|       this->parent()->set_enabled(true); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| void BedJetHub::register_child(BedJetClient *obj) { | ||||
|   this->children_.push_back(obj); | ||||
|   obj->set_parent(this); | ||||
| } | ||||
|  | ||||
| }  // namespace bedjet | ||||
| }  // namespace esphome | ||||
							
								
								
									
										178
									
								
								esphome/components/bedjet/bedjet_hub.h
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										178
									
								
								esphome/components/bedjet/bedjet_hub.h
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,178 @@ | ||||
| #pragma once | ||||
|  | ||||
| #include "esphome/components/ble_client/ble_client.h" | ||||
| #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" | ||||
| #include "esphome/core/component.h" | ||||
| #include "esphome/core/defines.h" | ||||
| #include "esphome/core/hal.h" | ||||
| #include "bedjet_child.h" | ||||
| #include "bedjet_codec.h" | ||||
|  | ||||
| #ifdef USE_TIME | ||||
| #include "esphome/components/time/real_time_clock.h" | ||||
| #endif | ||||
|  | ||||
| #ifdef USE_ESP32 | ||||
|  | ||||
| #include <esp_gattc_api.h> | ||||
|  | ||||
| namespace esphome { | ||||
| namespace bedjet { | ||||
|  | ||||
| namespace espbt = esphome::esp32_ble_tracker; | ||||
|  | ||||
| // Forward declare BedJetClient | ||||
| class BedJetClient; | ||||
|  | ||||
| static const espbt::ESPBTUUID BEDJET_SERVICE_UUID = espbt::ESPBTUUID::from_raw("00001000-bed0-0080-aa55-4265644a6574"); | ||||
| static const espbt::ESPBTUUID BEDJET_STATUS_UUID = espbt::ESPBTUUID::from_raw("00002000-bed0-0080-aa55-4265644a6574"); | ||||
| static const espbt::ESPBTUUID BEDJET_COMMAND_UUID = espbt::ESPBTUUID::from_raw("00002004-bed0-0080-aa55-4265644a6574"); | ||||
| static const espbt::ESPBTUUID BEDJET_NAME_UUID = espbt::ESPBTUUID::from_raw("00002001-bed0-0080-aa55-4265644a6574"); | ||||
|  | ||||
| /** | ||||
|  * Hub component connecting to the BedJet device over Bluetooth. | ||||
|  */ | ||||
| class BedJetHub : public esphome::ble_client::BLEClientNode, public PollingComponent { | ||||
|  public: | ||||
|   /* BedJet functionality exposed to `BedJetClient` children and/or accessible from action lambdas. */ | ||||
|  | ||||
|   /** Attempts to check for and apply firmware updates. */ | ||||
|   void upgrade_firmware(); | ||||
|  | ||||
|   /** Press the OFF button. */ | ||||
|   bool button_off(); | ||||
|   /** Press the HEAT button. */ | ||||
|   bool button_heat(); | ||||
|   /** Press the EXT HT button. */ | ||||
|   bool button_ext_heat(); | ||||
|   /** Press the TURBO button. */ | ||||
|   bool button_turbo(); | ||||
|   /** Press the COOL button. */ | ||||
|   bool button_cool(); | ||||
|   /** Press the DRY button. */ | ||||
|   bool button_dry(); | ||||
|   /** Press the M1 (memory recall) button. */ | ||||
|   bool button_memory1(); | ||||
|   /** Press the M2 (memory recall) button. */ | ||||
|   bool button_memory2(); | ||||
|   /** Press the M3 (memory recall) button. */ | ||||
|   bool button_memory3(); | ||||
|  | ||||
|   /** Send the `button`. */ | ||||
|   bool send_button(BedjetButton button); | ||||
|  | ||||
|   /** Set the target temperature to `temp_c` in °C. */ | ||||
|   bool set_target_temp(float temp_c); | ||||
|  | ||||
|   /** Set the fan speed to a stepped index in the range 0-19. */ | ||||
|   bool set_fan_index(uint8_t fan_speed_index); | ||||
|  | ||||
|   /** Set the fan speed to a percent in the range 5% - 100%, at 5% increments. */ | ||||
|   bool set_fan_speed(uint8_t fan_speed_pct) { return this->set_fan_index(bedjet_fan_speed_to_index(fan_speed_pct)); } | ||||
|  | ||||
|   /** Return the fan speed index, in the range 0-19. */ | ||||
|   uint8_t get_fan_index(); | ||||
|  | ||||
|   /** Return the fan speed as a percent in the range 5%-100%. */ | ||||
|   uint8_t get_fan_speed() { return bedjet_fan_step_to_speed(this->get_fan_index()); } | ||||
|  | ||||
|   /** Set the operational runtime remaining. | ||||
|    * | ||||
|    * The unit establishes and enforces runtime limits for some modes, so this call is not guaranteed to succeed. | ||||
|    */ | ||||
|   bool set_time_remaining(uint8_t hours, uint8_t mins); | ||||
|  | ||||
|   /** Return the remaining runtime, in seconds. */ | ||||
|   uint16_t get_time_remaining(); | ||||
|  | ||||
|   /** @return `true` if the `BLEClient::node_state` is `ClientState::ESTABLISHED`. */ | ||||
|   bool is_connected() { return this->node_state == espbt::ClientState::ESTABLISHED; } | ||||
|  | ||||
|   bool has_status() { return this->codec_->has_status(); } | ||||
|   const BedjetStatusPacket *get_status_packet() const { return this->codec_->get_status_packet(); } | ||||
|  | ||||
|   /** Register a `BedJetClient` child component. */ | ||||
|   void register_child(BedJetClient *obj); | ||||
|  | ||||
|   /** Set the status timeout. | ||||
|    * | ||||
|    * This is the max time to wait for a status update before the connection is presumed unusable. | ||||
|    */ | ||||
|   void set_status_timeout(uint32_t timeout) { this->timeout_ = timeout; } | ||||
|  | ||||
| #ifdef USE_TIME | ||||
|   /** Set the `time::RealTimeClock` implementation. */ | ||||
|   void set_time_id(time::RealTimeClock *time_id) { this->time_id_ = time_id; } | ||||
|   /** Attempts to sync the local time (via `time_id`) to the BedJet device. */ | ||||
|   void send_local_time(); | ||||
| #endif | ||||
|   /** Attempt to set the BedJet device's clock to the specified time. */ | ||||
|   void set_clock(uint8_t hour, uint8_t minute); | ||||
|  | ||||
|   /* Component overrides */ | ||||
|  | ||||
|   void loop() override; | ||||
|   void update() override; | ||||
|   void dump_config() override; | ||||
|   void setup() override { this->codec_ = make_unique<BedjetCodec>(); } | ||||
|   float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } | ||||
|  | ||||
|   /** @return The BedJet's configured name, or the MAC address if not discovered yet. */ | ||||
|   std::string get_name() { | ||||
|     if (this->name_.empty()) { | ||||
|       return this->parent_->address_str(); | ||||
|     } else { | ||||
|       return this->name_; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /* BLEClient overrides */ | ||||
|  | ||||
|   void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, | ||||
|                            esp_ble_gattc_cb_param_t *param) override; | ||||
|  | ||||
|  protected: | ||||
|   std::vector<BedJetClient *> children_; | ||||
|   void dispatch_status_(); | ||||
|   void dispatch_state_(bool is_ready); | ||||
|  | ||||
| #ifdef USE_TIME | ||||
|   /** Initializes time sync callbacks to support syncing current time to the BedJet. */ | ||||
|   void setup_time_(); | ||||
|   optional<time::RealTimeClock *> time_id_{}; | ||||
| #endif | ||||
|  | ||||
|   uint32_t timeout_{DEFAULT_STATUS_TIMEOUT}; | ||||
|   static const uint32_t MIN_NOTIFY_THROTTLE = 15000; | ||||
|   static const uint32_t NOTIFY_WARN_THRESHOLD = 300000; | ||||
|   static const uint32_t DEFAULT_STATUS_TIMEOUT = 900000; | ||||
|  | ||||
|   uint8_t set_notify_(bool enable); | ||||
|   /** Send the `BedjetPacket` to the device. */ | ||||
|   uint8_t write_bedjet_packet_(BedjetPacket *pkt); | ||||
|   void set_name_(const std::string &name) { this->name_ = name; } | ||||
|  | ||||
|   std::string name_; | ||||
|  | ||||
|   uint32_t last_notify_ = 0; | ||||
|   inline void status_packet_ready_(); | ||||
|   bool force_refresh_ = false; | ||||
|   bool processing_ = false; | ||||
|  | ||||
|   std::unique_ptr<BedjetCodec> codec_; | ||||
|  | ||||
|   bool discover_characteristics_(); | ||||
|   uint16_t char_handle_cmd_; | ||||
|   uint16_t char_handle_name_; | ||||
|   uint16_t char_handle_status_; | ||||
|   uint16_t config_descr_status_; | ||||
|  | ||||
|   uint8_t open_conn_id_ = -1; | ||||
|  | ||||
|   uint8_t write_notify_config_descriptor_(bool enable); | ||||
| }; | ||||
|  | ||||
| }  // namespace bedjet | ||||
| }  // namespace esphome | ||||
|  | ||||
| #endif | ||||
| @@ -1,19 +1,26 @@ | ||||
| import logging | ||||
|  | ||||
| import esphome.codegen as cg | ||||
| import esphome.config_validation as cv | ||||
| from esphome.components import climate, ble_client, time | ||||
| from esphome.components import climate, ble_client | ||||
| from esphome.const import ( | ||||
|     CONF_HEAT_MODE, | ||||
|     CONF_ID, | ||||
|     CONF_RECEIVE_TIMEOUT, | ||||
|     CONF_TIME_ID, | ||||
| ) | ||||
| from . import ( | ||||
|     BEDJET_CLIENT_SCHEMA, | ||||
|     register_bedjet_child, | ||||
| ) | ||||
|  | ||||
| _LOGGER = logging.getLogger(__name__) | ||||
| CODEOWNERS = ["@jhansche"] | ||||
| DEPENDENCIES = ["ble_client"] | ||||
|  | ||||
| bedjet_ns = cg.esphome_ns.namespace("bedjet") | ||||
| Bedjet = bedjet_ns.class_( | ||||
|     "Bedjet", climate.Climate, ble_client.BLEClientNode, cg.PollingComponent | ||||
| BedJetClimate = bedjet_ns.class_( | ||||
|     "BedJetClimate", climate.Climate, ble_client.BLEClientNode, cg.PollingComponent | ||||
| ) | ||||
| BedjetHeatMode = bedjet_ns.enum("BedjetHeatMode") | ||||
| BEDJET_HEAT_MODES = { | ||||
| @@ -24,18 +31,30 @@ BEDJET_HEAT_MODES = { | ||||
| CONFIG_SCHEMA = ( | ||||
|     climate.CLIMATE_SCHEMA.extend( | ||||
|         { | ||||
|             cv.GenerateID(): cv.declare_id(Bedjet), | ||||
|             cv.GenerateID(): cv.declare_id(BedJetClimate), | ||||
|             cv.Optional(CONF_HEAT_MODE, default="heat"): cv.enum( | ||||
|                 BEDJET_HEAT_MODES, lower=True | ||||
|             ), | ||||
|             cv.Optional(CONF_TIME_ID): cv.use_id(time.RealTimeClock), | ||||
|             cv.Optional( | ||||
|                 CONF_RECEIVE_TIMEOUT, default="0s" | ||||
|             ): cv.positive_time_period_milliseconds, | ||||
|         } | ||||
|     ) | ||||
|     .extend(ble_client.BLE_CLIENT_SCHEMA) | ||||
|     .extend(cv.polling_component_schema("30s")) | ||||
|     .extend(cv.polling_component_schema("60s")) | ||||
|     .extend( | ||||
|         # TODO: remove compat layer. | ||||
|         { | ||||
|             cv.Optional(ble_client.CONF_BLE_CLIENT_ID): cv.invalid( | ||||
|                 "The 'ble_client_id' option has been removed. Please migrate " | ||||
|                 "to the new `bedjet_id` option in the `bedjet` component.\n" | ||||
|                 "See https://esphome.io/components/climate/bedjet.html" | ||||
|             ), | ||||
|             cv.Optional(CONF_TIME_ID): cv.invalid( | ||||
|                 "The 'time_id' option has been moved to the `bedjet` component." | ||||
|             ), | ||||
|             cv.Optional(CONF_RECEIVE_TIMEOUT): cv.invalid( | ||||
|                 "The 'receive_timeout' option has been moved to the `bedjet` component." | ||||
|             ), | ||||
|         } | ||||
|     ) | ||||
|     .extend(BEDJET_CLIENT_SCHEMA) | ||||
| ) | ||||
|  | ||||
|  | ||||
| @@ -43,10 +62,6 @@ 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) | ||||
|     await register_bedjet_child(var, config) | ||||
|  | ||||
|     cg.add(var.set_heating_mode(config[CONF_HEAT_MODE])) | ||||
|     if CONF_TIME_ID in config: | ||||
|         time_ = await cg.get_variable(config[CONF_TIME_ID]) | ||||
|         cg.add(var.set_time_id(time_)) | ||||
|     if CONF_RECEIVE_TIMEOUT in config: | ||||
|         cg.add(var.set_status_timeout(config[CONF_RECEIVE_TIMEOUT])) | ||||
|   | ||||
| @@ -301,6 +301,10 @@ ble_client: | ||||
|         - switch.turn_on: ble1_status | ||||
|   - mac_address: C4:4F:33:11:22:33 | ||||
|     id: my_bedjet_ble_client | ||||
| bedjet: | ||||
|   - ble_client_id: my_bedjet_ble_client | ||||
|     id: my_bedjet_client | ||||
|     time_id: sntp_time | ||||
| mcp23s08: | ||||
|   - id: "mcp23s08_hub" | ||||
|     cs_pin: GPIO12 | ||||
| @@ -1891,7 +1895,7 @@ climate: | ||||
|     icon: mdi:stove | ||||
|   - platform: bedjet | ||||
|     name: My Bedjet | ||||
|     ble_client_id: my_bedjet_ble_client | ||||
|     bedjet_id: my_bedjet_client | ||||
|     heat_mode: extended | ||||
|  | ||||
| script: | ||||
|   | ||||
		Reference in New Issue
	
	Block a user