mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-31 15:12:06 +00:00 
			
		
		
		
	Merge branch 'dev' into mqtt_reduce_json_assign
This commit is contained in:
		| @@ -661,11 +661,12 @@ uint16_t APIConnection::try_send_climate_info(EntityBase *entity, APIConnection | ||||
|   ListEntitiesClimateResponse msg; | ||||
|   auto traits = climate->get_traits(); | ||||
|   // Flags set for backward compatibility, deprecated in 2025.11.0 | ||||
|   msg.supports_current_temperature = traits.get_supports_current_temperature(); | ||||
|   msg.supports_current_humidity = traits.get_supports_current_humidity(); | ||||
|   msg.supports_two_point_target_temperature = traits.get_supports_two_point_target_temperature(); | ||||
|   msg.supports_target_humidity = traits.get_supports_target_humidity(); | ||||
|   msg.supports_action = traits.get_supports_action(); | ||||
|   msg.supports_current_temperature = traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); | ||||
|   msg.supports_current_humidity = traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY); | ||||
|   msg.supports_two_point_target_temperature = traits.has_feature_flags( | ||||
|       climate::CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | climate::CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE); | ||||
|   msg.supports_target_humidity = traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY); | ||||
|   msg.supports_action = traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION); | ||||
|   // Current feature flags and other supported parameters | ||||
|   msg.feature_flags = traits.get_feature_flags(); | ||||
|   msg.supported_modes = &traits.get_supported_modes_for_api_(); | ||||
|   | ||||
| @@ -41,7 +41,7 @@ CONFIG_SCHEMA = cv.All( | ||||
|     cv.Schema( | ||||
|         { | ||||
|             cv.GenerateID(): cv.declare_id(BME680BSECComponent), | ||||
|             cv.Optional(CONF_TEMPERATURE_OFFSET, default=0): cv.temperature, | ||||
|             cv.Optional(CONF_TEMPERATURE_OFFSET, default=0): cv.temperature_delta, | ||||
|             cv.Optional(CONF_IAQ_MODE, default="STATIC"): cv.enum( | ||||
|                 IAQ_MODE_OPTIONS, upper=True | ||||
|             ), | ||||
|   | ||||
| @@ -139,7 +139,7 @@ CONFIG_SCHEMA_BASE = ( | ||||
|             cv.Optional(CONF_SUPPLY_VOLTAGE, default="3.3V"): cv.enum( | ||||
|                 VOLTAGE_OPTIONS, upper=True | ||||
|             ), | ||||
|             cv.Optional(CONF_TEMPERATURE_OFFSET, default=0): cv.temperature, | ||||
|             cv.Optional(CONF_TEMPERATURE_OFFSET, default=0): cv.temperature_delta, | ||||
|             cv.Optional( | ||||
|                 CONF_STATE_SAVE_INTERVAL, default="6hours" | ||||
|             ): cv.positive_time_period_minutes, | ||||
|   | ||||
| @@ -17,11 +17,11 @@ void MQTTClimateComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCo | ||||
|   // NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson | ||||
|   auto traits = this->device_->get_traits(); | ||||
|   // current_temperature_topic | ||||
|   if (traits.get_supports_current_temperature()) { | ||||
|   if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE)) { | ||||
|     root[MQTT_CURRENT_TEMPERATURE_TOPIC] = this->get_current_temperature_state_topic(); | ||||
|   } | ||||
|   // current_humidity_topic | ||||
|   if (traits.get_supports_current_humidity()) { | ||||
|   if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY)) { | ||||
|     root[MQTT_CURRENT_HUMIDITY_TOPIC] = this->get_current_humidity_state_topic(); | ||||
|   } | ||||
|   // mode_command_topic | ||||
| @@ -45,7 +45,8 @@ void MQTTClimateComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCo | ||||
|   if (traits.supports_mode(CLIMATE_MODE_HEAT_COOL)) | ||||
|     modes.add("heat_cool"); | ||||
|  | ||||
|   if (traits.get_supports_two_point_target_temperature()) { | ||||
|   if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | | ||||
|                                climate::CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) { | ||||
|     // temperature_low_command_topic | ||||
|     root[MQTT_TEMPERATURE_LOW_COMMAND_TOPIC] = this->get_target_temperature_low_command_topic(); | ||||
|     // temperature_low_state_topic | ||||
| @@ -61,7 +62,7 @@ void MQTTClimateComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCo | ||||
|     root[MQTT_TEMPERATURE_STATE_TOPIC] = this->get_target_temperature_state_topic(); | ||||
|   } | ||||
|  | ||||
|   if (traits.get_supports_target_humidity()) { | ||||
|   if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY)) { | ||||
|     // target_humidity_command_topic | ||||
|     root[MQTT_TARGET_HUMIDITY_COMMAND_TOPIC] = this->get_target_humidity_command_topic(); | ||||
|     // target_humidity_state_topic | ||||
| @@ -109,7 +110,7 @@ void MQTTClimateComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryCo | ||||
|       presets.add(preset); | ||||
|   } | ||||
|  | ||||
|   if (traits.get_supports_action()) { | ||||
|   if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION)) { | ||||
|     // action_topic | ||||
|     root[MQTT_ACTION_TOPIC] = this->get_action_state_topic(); | ||||
|   } | ||||
| @@ -174,7 +175,8 @@ void MQTTClimateComponent::setup() { | ||||
|     call.perform(); | ||||
|   }); | ||||
|  | ||||
|   if (traits.get_supports_two_point_target_temperature()) { | ||||
|   if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | | ||||
|                                climate::CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) { | ||||
|     this->subscribe(this->get_target_temperature_low_command_topic(), | ||||
|                     [this](const std::string &topic, const std::string &payload) { | ||||
|                       auto val = parse_number<float>(payload); | ||||
| @@ -211,7 +213,7 @@ void MQTTClimateComponent::setup() { | ||||
|                     }); | ||||
|   } | ||||
|  | ||||
|   if (traits.get_supports_target_humidity()) { | ||||
|   if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY)) { | ||||
|     this->subscribe(this->get_target_humidity_command_topic(), | ||||
|                     [this](const std::string &topic, const std::string &payload) { | ||||
|                       auto val = parse_number<float>(payload); | ||||
| @@ -290,12 +292,14 @@ bool MQTTClimateComponent::publish_state_() { | ||||
|     success = false; | ||||
|   int8_t target_accuracy = traits.get_target_temperature_accuracy_decimals(); | ||||
|   int8_t current_accuracy = traits.get_current_temperature_accuracy_decimals(); | ||||
|   if (traits.get_supports_current_temperature() && !std::isnan(this->device_->current_temperature)) { | ||||
|   if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE) && | ||||
|       !std::isnan(this->device_->current_temperature)) { | ||||
|     std::string payload = value_accuracy_to_string(this->device_->current_temperature, current_accuracy); | ||||
|     if (!this->publish(this->get_current_temperature_state_topic(), payload)) | ||||
|       success = false; | ||||
|   } | ||||
|   if (traits.get_supports_two_point_target_temperature()) { | ||||
|   if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | | ||||
|                                climate::CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) { | ||||
|     std::string payload = value_accuracy_to_string(this->device_->target_temperature_low, target_accuracy); | ||||
|     if (!this->publish(this->get_target_temperature_low_state_topic(), payload)) | ||||
|       success = false; | ||||
| @@ -308,12 +312,14 @@ bool MQTTClimateComponent::publish_state_() { | ||||
|       success = false; | ||||
|   } | ||||
|  | ||||
|   if (traits.get_supports_current_humidity() && !std::isnan(this->device_->current_humidity)) { | ||||
|   if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY) && | ||||
|       !std::isnan(this->device_->current_humidity)) { | ||||
|     std::string payload = value_accuracy_to_string(this->device_->current_humidity, 0); | ||||
|     if (!this->publish(this->get_current_humidity_state_topic(), payload)) | ||||
|       success = false; | ||||
|   } | ||||
|   if (traits.get_supports_target_humidity() && !std::isnan(this->device_->target_humidity)) { | ||||
|   if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY) && | ||||
|       !std::isnan(this->device_->target_humidity)) { | ||||
|     std::string payload = value_accuracy_to_string(this->device_->target_humidity, 0); | ||||
|     if (!this->publish(this->get_target_humidity_state_topic(), payload)) | ||||
|       success = false; | ||||
| @@ -357,7 +363,7 @@ bool MQTTClimateComponent::publish_state_() { | ||||
|       success = false; | ||||
|   } | ||||
|  | ||||
|   if (traits.get_supports_action()) { | ||||
|   if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION)) { | ||||
|     const char *payload; | ||||
|     switch (this->device_->action) { | ||||
|       case CLIMATE_ACTION_OFF: | ||||
|   | ||||
| @@ -916,7 +916,7 @@ void PrometheusHandler::climate_row_(AsyncResponseStream *stream, climate::Clima | ||||
|   auto min_temp_value = value_accuracy_to_string(traits.get_visual_min_temperature(), target_accuracy); | ||||
|   climate_value_row_(stream, obj, area, node, friendly_name, min_temp, min_temp_value); | ||||
|   // now check optional traits | ||||
|   if (traits.get_supports_current_temperature()) { | ||||
|   if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE)) { | ||||
|     std::string current_temp = "current_temperature"; | ||||
|     if (std::isnan(obj->current_temperature)) { | ||||
|       climate_failed_row_(stream, obj, area, node, friendly_name, current_temp, true); | ||||
| @@ -927,7 +927,7 @@ void PrometheusHandler::climate_row_(AsyncResponseStream *stream, climate::Clima | ||||
|       climate_failed_row_(stream, obj, area, node, friendly_name, current_temp, false); | ||||
|     } | ||||
|   } | ||||
|   if (traits.get_supports_current_humidity()) { | ||||
|   if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY)) { | ||||
|     std::string current_humidity = "current_humidity"; | ||||
|     if (std::isnan(obj->current_humidity)) { | ||||
|       climate_failed_row_(stream, obj, area, node, friendly_name, current_humidity, true); | ||||
| @@ -938,7 +938,7 @@ void PrometheusHandler::climate_row_(AsyncResponseStream *stream, climate::Clima | ||||
|       climate_failed_row_(stream, obj, area, node, friendly_name, current_humidity, false); | ||||
|     } | ||||
|   } | ||||
|   if (traits.get_supports_target_humidity()) { | ||||
|   if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY)) { | ||||
|     std::string target_humidity = "target_humidity"; | ||||
|     if (std::isnan(obj->target_humidity)) { | ||||
|       climate_failed_row_(stream, obj, area, node, friendly_name, target_humidity, true); | ||||
| @@ -949,7 +949,8 @@ void PrometheusHandler::climate_row_(AsyncResponseStream *stream, climate::Clima | ||||
|       climate_failed_row_(stream, obj, area, node, friendly_name, target_humidity, false); | ||||
|     } | ||||
|   } | ||||
|   if (traits.get_supports_two_point_target_temperature()) { | ||||
|   if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | | ||||
|                                climate::CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) { | ||||
|     std::string target_temp_low = "target_temperature_low"; | ||||
|     auto target_temp_low_value = value_accuracy_to_string(obj->target_temperature_low, target_accuracy); | ||||
|     climate_value_row_(stream, obj, area, node, friendly_name, target_temp_low, target_temp_low_value); | ||||
| @@ -961,7 +962,7 @@ void PrometheusHandler::climate_row_(AsyncResponseStream *stream, climate::Clima | ||||
|     auto target_temp_value = value_accuracy_to_string(obj->target_temperature, target_accuracy); | ||||
|     climate_value_row_(stream, obj, area, node, friendly_name, target_temp, target_temp_value); | ||||
|   } | ||||
|   if (traits.get_supports_action()) { | ||||
|   if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION)) { | ||||
|     std::string climate_trait_category = "action"; | ||||
|     const auto *climate_trait_value = climate::climate_action_to_string(obj->action); | ||||
|     climate_setting_row_(stream, obj, area, node, friendly_name, climate_trait_category, climate_trait_value); | ||||
|   | ||||
| @@ -81,7 +81,7 @@ CONFIG_SCHEMA = ( | ||||
|                 cv.int_range(min=0, max=0xFFFF, max_included=False), | ||||
|             ), | ||||
|             cv.Optional(CONF_AMBIENT_PRESSURE_COMPENSATION): cv.pressure, | ||||
|             cv.Optional(CONF_TEMPERATURE_OFFSET, default="4°C"): cv.temperature, | ||||
|             cv.Optional(CONF_TEMPERATURE_OFFSET, default="4°C"): cv.temperature_delta, | ||||
|             cv.Optional(CONF_AMBIENT_PRESSURE_COMPENSATION_SOURCE): cv.use_id( | ||||
|                 sensor.Sensor | ||||
|             ), | ||||
|   | ||||
| @@ -71,9 +71,14 @@ from esphome.const import ( | ||||
|     CONF_VISUAL, | ||||
| ) | ||||
|  | ||||
| CONF_PRESET_CHANGE = "preset_change" | ||||
| CONF_DEFAULT_PRESET = "default_preset" | ||||
| CONF_HUMIDITY_CONTROL_DEHUMIDIFY_ACTION = "humidity_control_dehumidify_action" | ||||
| CONF_HUMIDITY_CONTROL_HUMIDIFY_ACTION = "humidity_control_humidify_action" | ||||
| CONF_HUMIDITY_CONTROL_OFF_ACTION = "humidity_control_off_action" | ||||
| CONF_HUMIDITY_HYSTERESIS = "humidity_hysteresis" | ||||
| CONF_ON_BOOT_RESTORE_FROM = "on_boot_restore_from" | ||||
| CONF_PRESET_CHANGE = "preset_change" | ||||
| CONF_TARGET_HUMIDITY_CHANGE_ACTION = "target_humidity_change_action" | ||||
|  | ||||
| CODEOWNERS = ["@kbx81"] | ||||
|  | ||||
| @@ -241,6 +246,14 @@ def validate_thermostat(config): | ||||
|             CONF_MAX_HEATING_RUN_TIME, | ||||
|             CONF_SUPPLEMENTAL_HEATING_ACTION, | ||||
|         ], | ||||
|         CONF_HUMIDITY_CONTROL_DEHUMIDIFY_ACTION: [ | ||||
|             CONF_HUMIDITY_CONTROL_OFF_ACTION, | ||||
|             CONF_HUMIDITY_SENSOR, | ||||
|         ], | ||||
|         CONF_HUMIDITY_CONTROL_HUMIDIFY_ACTION: [ | ||||
|             CONF_HUMIDITY_CONTROL_OFF_ACTION, | ||||
|             CONF_HUMIDITY_SENSOR, | ||||
|         ], | ||||
|     } | ||||
|     for config_trigger, req_triggers in requirements.items(): | ||||
|         for req_trigger in req_triggers: | ||||
| @@ -338,7 +351,7 @@ def validate_thermostat(config): | ||||
|     # Warn about using the removed CONF_DEFAULT_MODE and advise users | ||||
|     if CONF_DEFAULT_MODE in config and config[CONF_DEFAULT_MODE] is not None: | ||||
|         raise cv.Invalid( | ||||
|             f"{CONF_DEFAULT_MODE} is no longer valid. Please switch to using presets and specify a {CONF_DEFAULT_PRESET}." | ||||
|             f"{CONF_DEFAULT_MODE} is no longer valid. Please switch to using presets and specify a {CONF_DEFAULT_PRESET}" | ||||
|         ) | ||||
|  | ||||
|     default_mode = config[CONF_DEFAULT_MODE] | ||||
| @@ -588,9 +601,24 @@ CONFIG_SCHEMA = cv.All( | ||||
|             cv.Optional(CONF_SWING_VERTICAL_ACTION): automation.validate_automation( | ||||
|                 single=True | ||||
|             ), | ||||
|             cv.Optional( | ||||
|                 CONF_TARGET_HUMIDITY_CHANGE_ACTION | ||||
|             ): automation.validate_automation(single=True), | ||||
|             cv.Optional( | ||||
|                 CONF_TARGET_TEMPERATURE_CHANGE_ACTION | ||||
|             ): automation.validate_automation(single=True), | ||||
|             cv.Exclusive( | ||||
|                 CONF_HUMIDITY_CONTROL_DEHUMIDIFY_ACTION, | ||||
|                 group_of_exclusion="humidity_control", | ||||
|             ): automation.validate_automation(single=True), | ||||
|             cv.Exclusive( | ||||
|                 CONF_HUMIDITY_CONTROL_HUMIDIFY_ACTION, | ||||
|                 group_of_exclusion="humidity_control", | ||||
|             ): automation.validate_automation(single=True), | ||||
|             cv.Optional( | ||||
|                 CONF_HUMIDITY_CONTROL_OFF_ACTION | ||||
|             ): automation.validate_automation(single=True), | ||||
|             cv.Optional(CONF_HUMIDITY_HYSTERESIS, default=1.0): cv.percentage, | ||||
|             cv.Optional(CONF_DEFAULT_MODE, default=None): cv.valid, | ||||
|             cv.Optional(CONF_DEFAULT_PRESET): cv.templatable(cv.string), | ||||
|             cv.Optional(CONF_DEFAULT_TARGET_TEMPERATURE_HIGH): cv.temperature, | ||||
| @@ -882,12 +910,39 @@ async def to_code(config): | ||||
|             config[CONF_SWING_VERTICAL_ACTION], | ||||
|         ) | ||||
|         cg.add(var.set_supports_swing_mode_vertical(True)) | ||||
|     if CONF_TARGET_HUMIDITY_CHANGE_ACTION in config: | ||||
|         await automation.build_automation( | ||||
|             var.get_humidity_change_trigger(), | ||||
|             [], | ||||
|             config[CONF_TARGET_HUMIDITY_CHANGE_ACTION], | ||||
|         ) | ||||
|     if CONF_TARGET_TEMPERATURE_CHANGE_ACTION in config: | ||||
|         await automation.build_automation( | ||||
|             var.get_temperature_change_trigger(), | ||||
|             [], | ||||
|             config[CONF_TARGET_TEMPERATURE_CHANGE_ACTION], | ||||
|         ) | ||||
|     if CONF_HUMIDITY_CONTROL_DEHUMIDIFY_ACTION in config: | ||||
|         cg.add(var.set_supports_dehumidification(True)) | ||||
|         await automation.build_automation( | ||||
|             var.get_humidity_control_dehumidify_action_trigger(), | ||||
|             [], | ||||
|             config[CONF_HUMIDITY_CONTROL_DEHUMIDIFY_ACTION], | ||||
|         ) | ||||
|     if CONF_HUMIDITY_CONTROL_HUMIDIFY_ACTION in config: | ||||
|         cg.add(var.set_supports_humidification(True)) | ||||
|         await automation.build_automation( | ||||
|             var.get_humidity_control_humidify_action_trigger(), | ||||
|             [], | ||||
|             config[CONF_HUMIDITY_CONTROL_HUMIDIFY_ACTION], | ||||
|         ) | ||||
|     if CONF_HUMIDITY_CONTROL_OFF_ACTION in config: | ||||
|         await automation.build_automation( | ||||
|             var.get_humidity_control_off_action_trigger(), | ||||
|             [], | ||||
|             config[CONF_HUMIDITY_CONTROL_OFF_ACTION], | ||||
|         ) | ||||
|     cg.add(var.set_humidity_hysteresis(config[CONF_HUMIDITY_HYSTERESIS])) | ||||
|  | ||||
|     if CONF_PRESET in config: | ||||
|         for preset_config in config[CONF_PRESET]: | ||||
|   | ||||
| @@ -32,6 +32,7 @@ void ThermostatClimate::setup() { | ||||
|   if (this->humidity_sensor_ != nullptr) { | ||||
|     this->humidity_sensor_->add_on_state_callback([this](float state) { | ||||
|       this->current_humidity = state; | ||||
|       this->switch_to_humidity_control_action_(this->compute_humidity_control_action_()); | ||||
|       this->publish_state(); | ||||
|     }); | ||||
|     this->current_humidity = this->humidity_sensor_->state; | ||||
| @@ -84,6 +85,8 @@ void ThermostatClimate::refresh() { | ||||
|   this->switch_to_supplemental_action_(this->compute_supplemental_action_()); | ||||
|   this->switch_to_fan_mode_(this->fan_mode.value(), false); | ||||
|   this->switch_to_swing_mode_(this->swing_mode, false); | ||||
|   this->switch_to_humidity_control_action_(this->compute_humidity_control_action_()); | ||||
|   this->check_humidity_change_trigger_(); | ||||
|   this->check_temperature_change_trigger_(); | ||||
|   this->publish_state(); | ||||
| } | ||||
| @@ -129,6 +132,11 @@ bool ThermostatClimate::hysteresis_valid() { | ||||
|   return true; | ||||
| } | ||||
|  | ||||
| bool ThermostatClimate::humidity_hysteresis_valid() { | ||||
|   return !std::isnan(this->humidity_hysteresis_) && this->humidity_hysteresis_ >= 0.0f && | ||||
|          this->humidity_hysteresis_ < 100.0f; | ||||
| } | ||||
|  | ||||
| bool ThermostatClimate::limit_setpoints_for_heat_cool() { | ||||
|   return this->mode == climate::CLIMATE_MODE_HEAT_COOL || | ||||
|          (this->mode == climate::CLIMATE_MODE_AUTO && this->supports_heat_cool_); | ||||
| @@ -189,6 +197,16 @@ void ThermostatClimate::validate_target_temperature_high() { | ||||
|   } | ||||
| } | ||||
|  | ||||
| void ThermostatClimate::validate_target_humidity() { | ||||
|   if (std::isnan(this->target_humidity)) { | ||||
|     this->target_humidity = | ||||
|         (this->get_traits().get_visual_max_humidity() - this->get_traits().get_visual_min_humidity()) / 2.0f; | ||||
|   } else { | ||||
|     this->target_humidity = clamp<float>(this->target_humidity, this->get_traits().get_visual_min_humidity(), | ||||
|                                          this->get_traits().get_visual_max_humidity()); | ||||
|   } | ||||
| } | ||||
|  | ||||
| void ThermostatClimate::control(const climate::ClimateCall &call) { | ||||
|   bool target_temperature_high_changed = false; | ||||
|  | ||||
| @@ -235,6 +253,10 @@ void ThermostatClimate::control(const climate::ClimateCall &call) { | ||||
|       this->validate_target_temperature(); | ||||
|     } | ||||
|   } | ||||
|   if (call.get_target_humidity().has_value()) { | ||||
|     this->target_humidity = call.get_target_humidity().value(); | ||||
|     this->validate_target_humidity(); | ||||
|   } | ||||
|   // make any changes happen | ||||
|   this->refresh(); | ||||
| } | ||||
| @@ -250,6 +272,9 @@ climate::ClimateTraits ThermostatClimate::traits() { | ||||
|   if (this->humidity_sensor_ != nullptr) | ||||
|     traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY); | ||||
|  | ||||
|   if (this->supports_humidification_ || this->supports_dehumidification_) | ||||
|     traits.add_feature_flags(climate::CLIMATE_SUPPORTS_TARGET_HUMIDITY); | ||||
|  | ||||
|   if (this->supports_auto_) | ||||
|     traits.add_supported_mode(climate::CLIMATE_MODE_AUTO); | ||||
|   if (this->supports_heat_cool_) | ||||
| @@ -423,6 +448,28 @@ climate::ClimateAction ThermostatClimate::compute_supplemental_action_() { | ||||
|   return target_action; | ||||
| } | ||||
|  | ||||
| HumidificationAction ThermostatClimate::compute_humidity_control_action_() { | ||||
|   auto target_action = THERMOSTAT_HUMIDITY_CONTROL_ACTION_OFF; | ||||
|   // if hysteresis value or current_humidity is not valid, we go to OFF | ||||
|   if (std::isnan(this->current_humidity) || !this->humidity_hysteresis_valid()) { | ||||
|     return THERMOSTAT_HUMIDITY_CONTROL_ACTION_OFF; | ||||
|   } | ||||
|  | ||||
|   // ensure set point is valid before computing the action | ||||
|   this->validate_target_humidity(); | ||||
|   // everything has been validated so we can now safely compute the action | ||||
|   if (this->dehumidification_required_() && this->humidification_required_()) { | ||||
|     // this is bad and should never happen, so just stop. | ||||
|     // target_action = THERMOSTAT_HUMIDITY_CONTROL_ACTION_OFF; | ||||
|   } else if (this->supports_dehumidification_ && this->dehumidification_required_()) { | ||||
|     target_action = THERMOSTAT_HUMIDITY_CONTROL_ACTION_DEHUMIDIFY; | ||||
|   } else if (this->supports_humidification_ && this->humidification_required_()) { | ||||
|     target_action = THERMOSTAT_HUMIDITY_CONTROL_ACTION_HUMIDIFY; | ||||
|   } | ||||
|  | ||||
|   return target_action; | ||||
| } | ||||
|  | ||||
| void ThermostatClimate::switch_to_action_(climate::ClimateAction action, bool publish_state) { | ||||
|   // setup_complete_ helps us ensure an action is called immediately after boot | ||||
|   if ((action == this->action) && this->setup_complete_) { | ||||
| @@ -596,6 +643,44 @@ void ThermostatClimate::trigger_supplemental_action_() { | ||||
|   } | ||||
| } | ||||
|  | ||||
| void ThermostatClimate::switch_to_humidity_control_action_(HumidificationAction action) { | ||||
|   // setup_complete_ helps us ensure an action is called immediately after boot | ||||
|   if ((action == this->humidification_action_) && this->setup_complete_) { | ||||
|     // already in target mode | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   Trigger<> *trig = this->humidity_control_off_action_trigger_; | ||||
|   switch (action) { | ||||
|     case THERMOSTAT_HUMIDITY_CONTROL_ACTION_OFF: | ||||
|       // trig = this->humidity_control_off_action_trigger_; | ||||
|       ESP_LOGVV(TAG, "Switching to HUMIDIFICATION_OFF action"); | ||||
|       break; | ||||
|     case THERMOSTAT_HUMIDITY_CONTROL_ACTION_DEHUMIDIFY: | ||||
|       trig = this->humidity_control_dehumidify_action_trigger_; | ||||
|       ESP_LOGVV(TAG, "Switching to DEHUMIDIFY action"); | ||||
|       break; | ||||
|     case THERMOSTAT_HUMIDITY_CONTROL_ACTION_HUMIDIFY: | ||||
|       trig = this->humidity_control_humidify_action_trigger_; | ||||
|       ESP_LOGVV(TAG, "Switching to HUMIDIFY action"); | ||||
|       break; | ||||
|     case THERMOSTAT_HUMIDITY_CONTROL_ACTION_NONE: | ||||
|     default: | ||||
|       action = THERMOSTAT_HUMIDITY_CONTROL_ACTION_OFF; | ||||
|       // trig = this->humidity_control_off_action_trigger_; | ||||
|   } | ||||
|  | ||||
|   if (this->prev_humidity_control_trigger_ != nullptr) { | ||||
|     this->prev_humidity_control_trigger_->stop_action(); | ||||
|     this->prev_humidity_control_trigger_ = nullptr; | ||||
|   } | ||||
|   this->humidification_action_ = action; | ||||
|   this->prev_humidity_control_trigger_ = trig; | ||||
|   if (trig != nullptr) { | ||||
|     trig->trigger(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| void ThermostatClimate::switch_to_fan_mode_(climate::ClimateFanMode fan_mode, bool publish_state) { | ||||
|   // setup_complete_ helps us ensure an action is called immediately after boot | ||||
|   if ((fan_mode == this->prev_fan_mode_) && this->setup_complete_) { | ||||
| @@ -887,6 +972,20 @@ void ThermostatClimate::idle_on_timer_callback_() { | ||||
|   this->switch_to_supplemental_action_(this->compute_supplemental_action_()); | ||||
| } | ||||
|  | ||||
| void ThermostatClimate::check_humidity_change_trigger_() { | ||||
|   if ((this->prev_target_humidity_ == this->target_humidity) && this->setup_complete_) { | ||||
|     return;  // nothing changed, no reason to trigger | ||||
|   } else { | ||||
|     // save the new temperature so we can check it again later; the trigger will fire below | ||||
|     this->prev_target_humidity_ = this->target_humidity; | ||||
|   } | ||||
|   // trigger the action | ||||
|   Trigger<> *trig = this->humidity_change_trigger_; | ||||
|   if (trig != nullptr) { | ||||
|     trig->trigger(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| void ThermostatClimate::check_temperature_change_trigger_() { | ||||
|   if (this->supports_two_points_) { | ||||
|     // setup_complete_ helps us ensure an action is called immediately after boot | ||||
| @@ -996,6 +1095,32 @@ bool ThermostatClimate::supplemental_heating_required_() { | ||||
|           (this->supplemental_action_ == climate::CLIMATE_ACTION_HEATING)); | ||||
| } | ||||
|  | ||||
| bool ThermostatClimate::dehumidification_required_() { | ||||
|   if (this->current_humidity > this->target_humidity + this->humidity_hysteresis_) { | ||||
|     // if the current humidity exceeds the target + hysteresis, dehumidification is required | ||||
|     return true; | ||||
|   } else if (this->current_humidity < this->target_humidity - this->humidity_hysteresis_) { | ||||
|     // if the current humidity is less than the target - hysteresis, dehumidification should stop | ||||
|     return false; | ||||
|   } | ||||
|   // if we get here, the current humidity is between target + hysteresis and target - hysteresis, | ||||
|   //  so the action should not change | ||||
|   return this->humidification_action_ == THERMOSTAT_HUMIDITY_CONTROL_ACTION_DEHUMIDIFY; | ||||
| } | ||||
|  | ||||
| bool ThermostatClimate::humidification_required_() { | ||||
|   if (this->current_humidity < this->target_humidity - this->humidity_hysteresis_) { | ||||
|     // if the current humidity is below the target - hysteresis, humidification is required | ||||
|     return true; | ||||
|   } else if (this->current_humidity > this->target_humidity + this->humidity_hysteresis_) { | ||||
|     // if the current humidity is above the target + hysteresis, humidification should stop | ||||
|     return false; | ||||
|   } | ||||
|   // if we get here, the current humidity is between target - hysteresis and target + hysteresis, | ||||
|   //  so the action should not change | ||||
|   return this->humidification_action_ == THERMOSTAT_HUMIDITY_CONTROL_ACTION_HUMIDIFY; | ||||
| } | ||||
|  | ||||
| void ThermostatClimate::dump_preset_config_(const char *preset_name, const ThermostatClimateTargetTempConfig &config) { | ||||
|   if (this->supports_heat_) { | ||||
|     ESP_LOGCONFIG(TAG, "      Default Target Temperature Low: %.1f°C", | ||||
| @@ -1152,8 +1277,12 @@ ThermostatClimate::ThermostatClimate() | ||||
|       swing_mode_off_trigger_(new Trigger<>()), | ||||
|       swing_mode_horizontal_trigger_(new Trigger<>()), | ||||
|       swing_mode_vertical_trigger_(new Trigger<>()), | ||||
|       humidity_change_trigger_(new Trigger<>()), | ||||
|       temperature_change_trigger_(new Trigger<>()), | ||||
|       preset_change_trigger_(new Trigger<>()) {} | ||||
|       preset_change_trigger_(new Trigger<>()), | ||||
|       humidity_control_dehumidify_action_trigger_(new Trigger<>()), | ||||
|       humidity_control_humidify_action_trigger_(new Trigger<>()), | ||||
|       humidity_control_off_action_trigger_(new Trigger<>()) {} | ||||
|  | ||||
| void ThermostatClimate::set_default_preset(const std::string &custom_preset) { | ||||
|   this->default_custom_preset_ = custom_preset; | ||||
| @@ -1217,6 +1346,9 @@ void ThermostatClimate::set_sensor(sensor::Sensor *sensor) { this->sensor_ = sen | ||||
| void ThermostatClimate::set_humidity_sensor(sensor::Sensor *humidity_sensor) { | ||||
|   this->humidity_sensor_ = humidity_sensor; | ||||
| } | ||||
| void ThermostatClimate::set_humidity_hysteresis(float humidity_hysteresis) { | ||||
|   this->humidity_hysteresis_ = std::clamp<float>(humidity_hysteresis, 0.0f, 100.0f); | ||||
| } | ||||
| void ThermostatClimate::set_use_startup_delay(bool use_startup_delay) { this->use_startup_delay_ = use_startup_delay; } | ||||
| void ThermostatClimate::set_supports_heat_cool(bool supports_heat_cool) { | ||||
|   this->supports_heat_cool_ = supports_heat_cool; | ||||
| @@ -1284,6 +1416,18 @@ void ThermostatClimate::set_supports_swing_mode_vertical(bool supports_swing_mod | ||||
| void ThermostatClimate::set_supports_two_points(bool supports_two_points) { | ||||
|   this->supports_two_points_ = supports_two_points; | ||||
| } | ||||
| void ThermostatClimate::set_supports_dehumidification(bool supports_dehumidification) { | ||||
|   this->supports_dehumidification_ = supports_dehumidification; | ||||
|   if (supports_dehumidification) { | ||||
|     this->supports_humidification_ = false; | ||||
|   } | ||||
| } | ||||
| void ThermostatClimate::set_supports_humidification(bool supports_humidification) { | ||||
|   this->supports_humidification_ = supports_humidification; | ||||
|   if (supports_humidification) { | ||||
|     this->supports_dehumidification_ = false; | ||||
|   } | ||||
| } | ||||
|  | ||||
| Trigger<> *ThermostatClimate::get_cool_action_trigger() const { return this->cool_action_trigger_; } | ||||
| Trigger<> *ThermostatClimate::get_supplemental_cool_action_trigger() const { | ||||
| @@ -1317,8 +1461,18 @@ Trigger<> *ThermostatClimate::get_swing_mode_both_trigger() const { return this- | ||||
| Trigger<> *ThermostatClimate::get_swing_mode_off_trigger() const { return this->swing_mode_off_trigger_; } | ||||
| Trigger<> *ThermostatClimate::get_swing_mode_horizontal_trigger() const { return this->swing_mode_horizontal_trigger_; } | ||||
| Trigger<> *ThermostatClimate::get_swing_mode_vertical_trigger() const { return this->swing_mode_vertical_trigger_; } | ||||
| Trigger<> *ThermostatClimate::get_humidity_change_trigger() const { return this->humidity_change_trigger_; } | ||||
| Trigger<> *ThermostatClimate::get_temperature_change_trigger() const { return this->temperature_change_trigger_; } | ||||
| Trigger<> *ThermostatClimate::get_preset_change_trigger() const { return this->preset_change_trigger_; } | ||||
| Trigger<> *ThermostatClimate::get_humidity_control_dehumidify_action_trigger() const { | ||||
|   return this->humidity_control_dehumidify_action_trigger_; | ||||
| } | ||||
| Trigger<> *ThermostatClimate::get_humidity_control_humidify_action_trigger() const { | ||||
|   return this->humidity_control_humidify_action_trigger_; | ||||
| } | ||||
| Trigger<> *ThermostatClimate::get_humidity_control_off_action_trigger() const { | ||||
|   return this->humidity_control_off_action_trigger_; | ||||
| } | ||||
|  | ||||
| void ThermostatClimate::dump_config() { | ||||
|   LOG_CLIMATE("", "Thermostat", this); | ||||
| @@ -1422,7 +1576,12 @@ void ThermostatClimate::dump_config() { | ||||
|                 "    OFF: %s\n" | ||||
|                 "    HORIZONTAL: %s\n" | ||||
|                 "    VERTICAL: %s\n" | ||||
|                 "  Supports TWO SET POINTS: %s", | ||||
|                 "  Supports TWO SET POINTS: %s\n" | ||||
|                 "  Supported Humidity Parameters:\n" | ||||
|                 "    CURRENT: %s\n" | ||||
|                 "    TARGET: %s\n" | ||||
|                 "    DEHUMIDIFICATION: %s\n" | ||||
|                 "    HUMIDIFICATION: %s", | ||||
|                 YESNO(this->supports_fan_mode_on_), YESNO(this->supports_fan_mode_off_), | ||||
|                 YESNO(this->supports_fan_mode_auto_), YESNO(this->supports_fan_mode_low_), | ||||
|                 YESNO(this->supports_fan_mode_medium_), YESNO(this->supports_fan_mode_high_), | ||||
| @@ -1430,7 +1589,10 @@ void ThermostatClimate::dump_config() { | ||||
|                 YESNO(this->supports_fan_mode_diffuse_), YESNO(this->supports_fan_mode_quiet_), | ||||
|                 YESNO(this->supports_swing_mode_both_), YESNO(this->supports_swing_mode_off_), | ||||
|                 YESNO(this->supports_swing_mode_horizontal_), YESNO(this->supports_swing_mode_vertical_), | ||||
|                 YESNO(this->supports_two_points_)); | ||||
|                 YESNO(this->supports_two_points_), | ||||
|                 YESNO(this->get_traits().has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY)), | ||||
|                 YESNO(this->supports_dehumidification_ || this->supports_humidification_), | ||||
|                 YESNO(this->supports_dehumidification_), YESNO(this->supports_humidification_)); | ||||
|  | ||||
|   if (!this->preset_config_.empty()) { | ||||
|     ESP_LOGCONFIG(TAG, "  Supported PRESETS:"); | ||||
|   | ||||
| @@ -13,6 +13,13 @@ | ||||
| namespace esphome { | ||||
| namespace thermostat { | ||||
|  | ||||
| enum HumidificationAction : uint8_t { | ||||
|   THERMOSTAT_HUMIDITY_CONTROL_ACTION_OFF = 0, | ||||
|   THERMOSTAT_HUMIDITY_CONTROL_ACTION_DEHUMIDIFY = 1, | ||||
|   THERMOSTAT_HUMIDITY_CONTROL_ACTION_HUMIDIFY = 2, | ||||
|   THERMOSTAT_HUMIDITY_CONTROL_ACTION_NONE, | ||||
| }; | ||||
|  | ||||
| enum ThermostatClimateTimerIndex : uint8_t { | ||||
|   THERMOSTAT_TIMER_COOLING_MAX_RUN_TIME = 0, | ||||
|   THERMOSTAT_TIMER_COOLING_OFF = 1, | ||||
| @@ -90,6 +97,7 @@ class ThermostatClimate : public climate::Climate, public Component { | ||||
|   void set_idle_minimum_time_in_sec(uint32_t time); | ||||
|   void set_sensor(sensor::Sensor *sensor); | ||||
|   void set_humidity_sensor(sensor::Sensor *humidity_sensor); | ||||
|   void set_humidity_hysteresis(float humidity_hysteresis); | ||||
|   void set_use_startup_delay(bool use_startup_delay); | ||||
|   void set_supports_auto(bool supports_auto); | ||||
|   void set_supports_heat_cool(bool supports_heat_cool); | ||||
| @@ -115,6 +123,8 @@ class ThermostatClimate : public climate::Climate, public Component { | ||||
|   void set_supports_swing_mode_horizontal(bool supports_swing_mode_horizontal); | ||||
|   void set_supports_swing_mode_off(bool supports_swing_mode_off); | ||||
|   void set_supports_swing_mode_vertical(bool supports_swing_mode_vertical); | ||||
|   void set_supports_dehumidification(bool supports_dehumidification); | ||||
|   void set_supports_humidification(bool supports_humidification); | ||||
|   void set_supports_two_points(bool supports_two_points); | ||||
|  | ||||
|   void set_preset_config(climate::ClimatePreset preset, const ThermostatClimateTargetTempConfig &config); | ||||
| @@ -148,8 +158,12 @@ class ThermostatClimate : public climate::Climate, public Component { | ||||
|   Trigger<> *get_swing_mode_horizontal_trigger() const; | ||||
|   Trigger<> *get_swing_mode_off_trigger() const; | ||||
|   Trigger<> *get_swing_mode_vertical_trigger() const; | ||||
|   Trigger<> *get_humidity_change_trigger() const; | ||||
|   Trigger<> *get_temperature_change_trigger() const; | ||||
|   Trigger<> *get_preset_change_trigger() const; | ||||
|   Trigger<> *get_humidity_control_dehumidify_action_trigger() const; | ||||
|   Trigger<> *get_humidity_control_humidify_action_trigger() const; | ||||
|   Trigger<> *get_humidity_control_off_action_trigger() const; | ||||
|   /// Get current hysteresis values | ||||
|   float cool_deadband(); | ||||
|   float cool_overrun(); | ||||
| @@ -166,11 +180,13 @@ class ThermostatClimate : public climate::Climate, public Component { | ||||
|   climate::ClimateFanMode locked_fan_mode(); | ||||
|   /// Set point and hysteresis validation | ||||
|   bool hysteresis_valid();               // returns true if valid | ||||
|   bool humidity_hysteresis_valid();      // returns true if valid | ||||
|   bool limit_setpoints_for_heat_cool();  // returns true if set points should be further limited within visual range | ||||
|   void validate_target_temperature(); | ||||
|   void validate_target_temperatures(bool pin_target_temperature_high); | ||||
|   void validate_target_temperature_low(); | ||||
|   void validate_target_temperature_high(); | ||||
|   void validate_target_humidity(); | ||||
|  | ||||
|  protected: | ||||
|   /// Override control to change settings of the climate device. | ||||
| @@ -192,11 +208,13 @@ class ThermostatClimate : public climate::Climate, public Component { | ||||
|   /// Re-compute the required action of this climate controller. | ||||
|   climate::ClimateAction compute_action_(bool ignore_timers = false); | ||||
|   climate::ClimateAction compute_supplemental_action_(); | ||||
|   HumidificationAction compute_humidity_control_action_(); | ||||
|  | ||||
|   /// Switch the climate device to the given climate action. | ||||
|   void switch_to_action_(climate::ClimateAction action, bool publish_state = true); | ||||
|   void switch_to_supplemental_action_(climate::ClimateAction action); | ||||
|   void trigger_supplemental_action_(); | ||||
|   void switch_to_humidity_control_action_(HumidificationAction action); | ||||
|  | ||||
|   /// Switch the climate device to the given climate fan mode. | ||||
|   void switch_to_fan_mode_(climate::ClimateFanMode fan_mode, bool publish_state = true); | ||||
| @@ -207,6 +225,9 @@ class ThermostatClimate : public climate::Climate, public Component { | ||||
|   /// Switch the climate device to the given climate swing mode. | ||||
|   void switch_to_swing_mode_(climate::ClimateSwingMode swing_mode, bool publish_state = true); | ||||
|  | ||||
|   /// Check if the humidity change trigger should be called. | ||||
|   void check_humidity_change_trigger_(); | ||||
|  | ||||
|   /// Check if the temperature change trigger should be called. | ||||
|   void check_temperature_change_trigger_(); | ||||
|  | ||||
| @@ -243,6 +264,8 @@ class ThermostatClimate : public climate::Climate, public Component { | ||||
|   bool heating_required_(); | ||||
|   bool supplemental_cooling_required_(); | ||||
|   bool supplemental_heating_required_(); | ||||
|   bool dehumidification_required_(); | ||||
|   bool humidification_required_(); | ||||
|  | ||||
|   void dump_preset_config_(const char *preset_name, const ThermostatClimateTargetTempConfig &config); | ||||
|  | ||||
| @@ -259,6 +282,9 @@ class ThermostatClimate : public climate::Climate, public Component { | ||||
|   /// The current supplemental action | ||||
|   climate::ClimateAction supplemental_action_{climate::CLIMATE_ACTION_OFF}; | ||||
|  | ||||
|   /// The current humidification action | ||||
|   HumidificationAction humidification_action_{THERMOSTAT_HUMIDITY_CONTROL_ACTION_NONE}; | ||||
|  | ||||
|   /// Default standard preset to use on start up | ||||
|   climate::ClimatePreset default_preset_{}; | ||||
|  | ||||
| @@ -321,6 +347,12 @@ class ThermostatClimate : public climate::Climate, public Component { | ||||
|   /// A false value means that the controller has no such support. | ||||
|   bool supports_two_points_{false}; | ||||
|  | ||||
|   /// Whether the controller supports dehumidification and/or humidification | ||||
|   /// | ||||
|   /// A false value means that the controller has no such support. | ||||
|   bool supports_dehumidification_{false}; | ||||
|   bool supports_humidification_{false}; | ||||
|  | ||||
|   /// Flags indicating if maximum allowable run time was exceeded | ||||
|   bool cooling_max_runtime_exceeded_{false}; | ||||
|   bool heating_max_runtime_exceeded_{false}; | ||||
| @@ -331,9 +363,10 @@ class ThermostatClimate : public climate::Climate, public Component { | ||||
|   /// setup_complete_ blocks modifying/resetting the temps immediately after boot | ||||
|   bool setup_complete_{false}; | ||||
|  | ||||
|   /// Store previously-known temperatures | ||||
|   /// Store previously-known humidity and temperatures | ||||
|   /// | ||||
|   /// These are used to determine when the temperature change trigger/action needs to be called | ||||
|   /// These are used to determine when a temperature/humidity has changed | ||||
|   float prev_target_humidity_{NAN}; | ||||
|   float prev_target_temperature_{NAN}; | ||||
|   float prev_target_temperature_low_{NAN}; | ||||
|   float prev_target_temperature_high_{NAN}; | ||||
| @@ -347,6 +380,9 @@ class ThermostatClimate : public climate::Climate, public Component { | ||||
|   float heating_deadband_{0}; | ||||
|   float heating_overrun_{0}; | ||||
|  | ||||
|   /// Hysteresis values used for computing humidification action | ||||
|   float humidity_hysteresis_{0}; | ||||
|  | ||||
|   /// Maximum allowable temperature deltas before engaging supplemental cooling/heating actions | ||||
|   float supplemental_cool_delta_{0}; | ||||
|   float supplemental_heat_delta_{0}; | ||||
| @@ -448,12 +484,24 @@ class ThermostatClimate : public climate::Climate, public Component { | ||||
|   /// The trigger to call when the controller should switch the swing mode to "vertical". | ||||
|   Trigger<> *swing_mode_vertical_trigger_{nullptr}; | ||||
|  | ||||
|   /// The trigger to call when the target humidity changes. | ||||
|   Trigger<> *humidity_change_trigger_{nullptr}; | ||||
|  | ||||
|   /// The trigger to call when the target temperature(s) change(es). | ||||
|   Trigger<> *temperature_change_trigger_{nullptr}; | ||||
|  | ||||
|   /// The trigger to call when the preset mode changes | ||||
|   Trigger<> *preset_change_trigger_{nullptr}; | ||||
|  | ||||
|   /// The trigger to call when dehumidification is required | ||||
|   Trigger<> *humidity_control_dehumidify_action_trigger_{nullptr}; | ||||
|  | ||||
|   /// The trigger to call when humidification is required | ||||
|   Trigger<> *humidity_control_humidify_action_trigger_{nullptr}; | ||||
|  | ||||
|   /// The trigger to call when (de)humidification should stop | ||||
|   Trigger<> *humidity_control_off_action_trigger_{nullptr}; | ||||
|  | ||||
|   /// A reference to the trigger that was previously active. | ||||
|   /// | ||||
|   /// This is so that the previous trigger can be stopped before enabling a new one | ||||
| @@ -462,6 +510,7 @@ class ThermostatClimate : public climate::Climate, public Component { | ||||
|   Trigger<> *prev_fan_mode_trigger_{nullptr}; | ||||
|   Trigger<> *prev_mode_trigger_{nullptr}; | ||||
|   Trigger<> *prev_swing_mode_trigger_{nullptr}; | ||||
|   Trigger<> *prev_humidity_control_trigger_{nullptr}; | ||||
|  | ||||
|   /// Default custom preset to use on start up | ||||
|   std::string default_custom_preset_{}; | ||||
|   | ||||
| @@ -1325,7 +1325,7 @@ std::string WebServer::climate_json(climate::Climate *obj, JsonDetail start_conf | ||||
|   root["max_temp"] = value_accuracy_to_string(traits.get_visual_max_temperature(), target_accuracy); | ||||
|   root["min_temp"] = value_accuracy_to_string(traits.get_visual_min_temperature(), target_accuracy); | ||||
|   root["step"] = traits.get_visual_target_temperature_step(); | ||||
|   if (traits.get_supports_action()) { | ||||
|   if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION)) { | ||||
|     root["action"] = PSTR_LOCAL(climate_action_to_string(obj->action)); | ||||
|     root["state"] = root["action"]; | ||||
|     has_state = true; | ||||
| @@ -1345,14 +1345,15 @@ std::string WebServer::climate_json(climate::Climate *obj, JsonDetail start_conf | ||||
|   if (traits.get_supports_swing_modes()) { | ||||
|     root["swing_mode"] = PSTR_LOCAL(climate_swing_mode_to_string(obj->swing_mode)); | ||||
|   } | ||||
|   if (traits.get_supports_current_temperature()) { | ||||
|   if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE)) { | ||||
|     if (!std::isnan(obj->current_temperature)) { | ||||
|       root["current_temperature"] = value_accuracy_to_string(obj->current_temperature, current_accuracy); | ||||
|     } else { | ||||
|       root["current_temperature"] = "NA"; | ||||
|     } | ||||
|   } | ||||
|   if (traits.get_supports_two_point_target_temperature()) { | ||||
|   if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | | ||||
|                                climate::CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) { | ||||
|     root["target_temperature_low"] = value_accuracy_to_string(obj->target_temperature_low, target_accuracy); | ||||
|     root["target_temperature_high"] = value_accuracy_to_string(obj->target_temperature_high, target_accuracy); | ||||
|     if (!has_state) { | ||||
|   | ||||
| @@ -1058,7 +1058,8 @@ class DownloadBinaryRequestHandler(BaseHandler): | ||||
|             "download", | ||||
|             f"{storage_json.name}-{file_name}", | ||||
|         ) | ||||
|         path = storage_json.firmware_bin_path.with_name(file_name) | ||||
|  | ||||
|         path = storage_json.firmware_bin_path.parent.joinpath(file_name) | ||||
|  | ||||
|         if not path.is_file(): | ||||
|             args = ["esphome", "idedata", settings.rel_path(configuration)] | ||||
|   | ||||
| @@ -12,7 +12,7 @@ platformio==6.1.18  # When updating platformio, also update /docker/Dockerfile | ||||
| esptool==5.1.0 | ||||
| click==8.1.7 | ||||
| esphome-dashboard==20251013.0 | ||||
| aioesphomeapi==42.0.0 | ||||
| aioesphomeapi==42.2.0 | ||||
| zeroconf==0.148.0 | ||||
| puremagic==1.30 | ||||
| ruamel.yaml==0.18.15 # dashboard_import | ||||
|   | ||||
| @@ -8,14 +8,12 @@ sensor: | ||||
|     lambda: |- | ||||
|       if (millis() > 10000) { | ||||
|         return 0.6; | ||||
|       } else { | ||||
|         return 0.0; | ||||
|       } | ||||
|       return 0.0; | ||||
|   - platform: template | ||||
|     id: template_temperature | ||||
|     lambda: |- | ||||
|       if (millis() > 10000) { | ||||
|         return 42.0; | ||||
|       } else { | ||||
|         return 0.0; | ||||
|       } | ||||
|       return 0.0; | ||||
|   | ||||
| @@ -5,9 +5,8 @@ sensor: | ||||
|     lambda: |- | ||||
|       if (millis() > 10000) { | ||||
|         return 42.0; | ||||
|       } else { | ||||
|         return 0.0; | ||||
|       } | ||||
|       return 0.0; | ||||
|     update_interval: 15s | ||||
|  | ||||
| binary_sensor: | ||||
|   | ||||
| @@ -23,9 +23,8 @@ binary_sensor: | ||||
|       - lambda: |- | ||||
|           if (id(some_binary_sensor).state) { | ||||
|             return x; | ||||
|           } else { | ||||
|             return {}; | ||||
|           } | ||||
|           return {}; | ||||
|       - settle: 100ms | ||||
|       - timeout: 10s | ||||
|  | ||||
|   | ||||
| @@ -4,25 +4,22 @@ binary_sensor: | ||||
|     lambda: |- | ||||
|       if (millis() > 10000) { | ||||
|         return true; | ||||
|       } else { | ||||
|         return false; | ||||
|       } | ||||
|       return false; | ||||
|   - platform: template | ||||
|     id: bin2 | ||||
|     lambda: |- | ||||
|       if (millis() > 20000) { | ||||
|         return true; | ||||
|       } else { | ||||
|         return false; | ||||
|       } | ||||
|       return false; | ||||
|   - platform: template | ||||
|     id: bin3 | ||||
|     lambda: |- | ||||
|       if (millis() > 30000) { | ||||
|         return true; | ||||
|       } else { | ||||
|         return false; | ||||
|       } | ||||
|       return false; | ||||
|  | ||||
| sensor: | ||||
|   - platform: binary_sensor_map | ||||
|   | ||||
| @@ -4,17 +4,15 @@ sensor: | ||||
|     lambda: |- | ||||
|       if (millis() > 10000) { | ||||
|         return 0.6; | ||||
|       } else { | ||||
|         return 0.0; | ||||
|       } | ||||
|       return 0.0; | ||||
|   - platform: template | ||||
|     id: template_temperature2 | ||||
|     lambda: |- | ||||
|       if (millis() > 20000) { | ||||
|         return 0.8; | ||||
|       } else { | ||||
|         return 0.0; | ||||
|       } | ||||
|       return 0.0; | ||||
|   - platform: combination | ||||
|     type: kalman | ||||
|     name: Kalman-filtered temperature | ||||
|   | ||||
| @@ -4,9 +4,8 @@ binary_sensor: | ||||
|     lambda: |- | ||||
|       if (millis() > 10000) { | ||||
|         return true; | ||||
|       } else { | ||||
|         return false; | ||||
|       } | ||||
|       return false; | ||||
|  | ||||
| sensor: | ||||
|   - platform: duty_time | ||||
|   | ||||
| @@ -4,9 +4,8 @@ binary_sensor: | ||||
|     lambda: |- | ||||
|       if (millis() > 10000) { | ||||
|         return true; | ||||
|       } else { | ||||
|         return false; | ||||
|       } | ||||
|       return false; | ||||
|  | ||||
| switch: | ||||
|   - platform: template | ||||
|   | ||||
| @@ -17,9 +17,8 @@ lock: | ||||
|     lambda: |- | ||||
|       if (millis() > 10000) { | ||||
|         return LOCK_STATE_LOCKED; | ||||
|       } else { | ||||
|         return LOCK_STATE_UNLOCKED; | ||||
|       } | ||||
|       return LOCK_STATE_UNLOCKED; | ||||
|     optimistic: true | ||||
|     assumed_state: false | ||||
|     on_unlock: | ||||
|   | ||||
| @@ -72,10 +72,9 @@ binary_sensor: | ||||
|       if (id(template_sens).state > 30) { | ||||
|         // Garage Door is open. | ||||
|         return true; | ||||
|       } else { | ||||
|         // Garage Door is closed. | ||||
|         return false; | ||||
|       } | ||||
|       // Garage Door is closed. | ||||
|       return false; | ||||
|     on_state: | ||||
|       - mqtt.publish: | ||||
|           topic: some/topic/binary_sensor | ||||
| @@ -217,9 +216,8 @@ cover: | ||||
|     lambda: |- | ||||
|       if (id(some_binary_sensor).state) { | ||||
|         return COVER_OPEN; | ||||
|       } else { | ||||
|         return COVER_CLOSED; | ||||
|       } | ||||
|       return COVER_CLOSED; | ||||
|     open_action: | ||||
|       - logger.log: open_action | ||||
|     close_action: | ||||
| @@ -321,9 +319,8 @@ lock: | ||||
|     lambda: |- | ||||
|       if (id(some_binary_sensor).state) { | ||||
|         return LOCK_STATE_LOCKED; | ||||
|       } else { | ||||
|         return LOCK_STATE_UNLOCKED; | ||||
|       } | ||||
|       return LOCK_STATE_UNLOCKED; | ||||
|     lock_action: | ||||
|       - logger.log: lock_action | ||||
|     unlock_action: | ||||
| @@ -360,9 +357,8 @@ sensor: | ||||
|     lambda: |- | ||||
|       if (id(some_binary_sensor).state) { | ||||
|         return 42.0; | ||||
|       } else { | ||||
|         return 0.0; | ||||
|       } | ||||
|       return 0.0; | ||||
|     update_interval: 60s | ||||
|     on_value: | ||||
|       - mqtt.publish: | ||||
| @@ -390,9 +386,8 @@ switch: | ||||
|     lambda: |- | ||||
|       if (id(some_binary_sensor).state) { | ||||
|         return true; | ||||
|       } else { | ||||
|         return false; | ||||
|       } | ||||
|       return false; | ||||
|     turn_on_action: | ||||
|       - logger.log: turn_on_action | ||||
|     turn_off_action: | ||||
| @@ -436,9 +431,8 @@ valve: | ||||
|     lambda: |- | ||||
|       if (id(some_binary_sensor).state) { | ||||
|         return VALVE_OPEN; | ||||
|       } else { | ||||
|         return VALVE_CLOSED; | ||||
|       } | ||||
|       return VALVE_CLOSED; | ||||
|  | ||||
| alarm_control_panel: | ||||
|   - platform: template | ||||
|   | ||||
| @@ -27,9 +27,8 @@ sensor: | ||||
|     lambda: |- | ||||
|       if (millis() > 10000) { | ||||
|         return 42.0; | ||||
|       } else { | ||||
|         return 0.0; | ||||
|       } | ||||
|       return 0.0; | ||||
|     update_interval: 60s | ||||
|  | ||||
| climate: | ||||
|   | ||||
| @@ -35,9 +35,8 @@ sensor: | ||||
|     lambda: |- | ||||
|       if (millis() > 10000) { | ||||
|         return 42.0; | ||||
|       } else { | ||||
|         return 0.0; | ||||
|       } | ||||
|       return 0.0; | ||||
|     update_interval: 60s | ||||
|  | ||||
| text_sensor: | ||||
| @@ -49,9 +48,8 @@ text_sensor: | ||||
|     lambda: |- | ||||
|       if (millis() > 10000) { | ||||
|         return {"Hello World"}; | ||||
|       } else { | ||||
|         return {"Goodbye (cruel) World"}; | ||||
|       } | ||||
|       return {"Goodbye (cruel) World"}; | ||||
|     update_interval: 60s | ||||
|  | ||||
| binary_sensor: | ||||
| @@ -60,9 +58,8 @@ binary_sensor: | ||||
|     lambda: |- | ||||
|       if (millis() > 10000) { | ||||
|         return true; | ||||
|       } else { | ||||
|         return false; | ||||
|       } | ||||
|       return false; | ||||
|  | ||||
| switch: | ||||
|   - platform: template | ||||
| @@ -70,9 +67,8 @@ switch: | ||||
|     lambda: |- | ||||
|       if (millis() > 10000) { | ||||
|         return true; | ||||
|       } else { | ||||
|         return false; | ||||
|       } | ||||
|       return false; | ||||
|     optimistic: true | ||||
|  | ||||
| fan: | ||||
| @@ -85,9 +81,8 @@ cover: | ||||
|     lambda: |- | ||||
|       if (millis() > 10000) { | ||||
|         return COVER_OPEN; | ||||
|       } else { | ||||
|         return COVER_CLOSED; | ||||
|       } | ||||
|       return COVER_CLOSED; | ||||
|  | ||||
| lock: | ||||
|   - platform: template | ||||
| @@ -95,9 +90,8 @@ lock: | ||||
|     lambda: |- | ||||
|       if (millis() > 10000) { | ||||
|         return LOCK_STATE_LOCKED; | ||||
|       } else { | ||||
|         return LOCK_STATE_UNLOCKED; | ||||
|       } | ||||
|       return LOCK_STATE_UNLOCKED; | ||||
|     optimistic: true | ||||
|  | ||||
| select: | ||||
|   | ||||
| @@ -59,9 +59,8 @@ binary_sensor: | ||||
|       - lambda: |- | ||||
|           if (id(other_binary_sensor).state) { | ||||
|             return x; | ||||
|           } else { | ||||
|             return {}; | ||||
|           } | ||||
|           return {}; | ||||
|       - settle: 500ms | ||||
|       - timeout: 5s | ||||
|  | ||||
| @@ -72,9 +71,8 @@ sensor: | ||||
|     lambda: |- | ||||
|       if (id(some_binary_sensor).state) { | ||||
|         return 42.0; | ||||
|       } else { | ||||
|         return 0.0; | ||||
|       } | ||||
|       return 0.0; | ||||
|     update_interval: 60s | ||||
|     filters: | ||||
|       - calibrate_linear: | ||||
| @@ -183,9 +181,8 @@ switch: | ||||
|     lambda: |- | ||||
|       if (id(some_binary_sensor).state) { | ||||
|         return true; | ||||
|       } else { | ||||
|         return false; | ||||
|       } | ||||
|       return false; | ||||
|     turn_on_action: | ||||
|       - logger.log: "turn_on_action" | ||||
|     turn_off_action: | ||||
| @@ -203,9 +200,8 @@ cover: | ||||
|     lambda: |- | ||||
|       if (id(some_binary_sensor).state) { | ||||
|         return COVER_OPEN; | ||||
|       } else { | ||||
|         return COVER_CLOSED; | ||||
|       } | ||||
|       return COVER_CLOSED; | ||||
|     open_action: | ||||
|       - logger.log: open_action | ||||
|     close_action: | ||||
| @@ -238,9 +234,8 @@ lock: | ||||
|     lambda: |- | ||||
|       if (id(some_binary_sensor).state) { | ||||
|         return LOCK_STATE_LOCKED; | ||||
|       } else { | ||||
|         return LOCK_STATE_UNLOCKED; | ||||
|       } | ||||
|       return LOCK_STATE_UNLOCKED; | ||||
|     lock_action: | ||||
|       - logger.log: lock_action | ||||
|     unlock_action: | ||||
| @@ -255,9 +250,8 @@ valve: | ||||
|     lambda: |- | ||||
|       if (id(some_binary_sensor).state) { | ||||
|         return VALVE_OPEN; | ||||
|       } else { | ||||
|         return VALVE_CLOSED; | ||||
|       } | ||||
|       return VALVE_CLOSED; | ||||
|     open_action: | ||||
|       - logger.log: open_action | ||||
|     close_action: | ||||
|   | ||||
| @@ -69,6 +69,11 @@ climate: | ||||
|       - logger.log: swing_vertical_action | ||||
|     swing_both_action: | ||||
|       - logger.log: swing_both_action | ||||
|     humidity_control_humidify_action: | ||||
|       - logger.log: humidity_control_humidify_action | ||||
|     humidity_control_off_action: | ||||
|       - logger.log: humidity_control_off_action | ||||
|     humidity_hysteresis: 1.0 | ||||
|     startup_delay: true | ||||
|     supplemental_cooling_delta: 2.0 | ||||
|     cool_deadband: 0.5 | ||||
|   | ||||
| @@ -17,10 +17,10 @@ sensor: | ||||
|       name: HLW8012 Voltage | ||||
|     power: | ||||
|       name: HLW8012 Power | ||||
|       id: hlw8012_power | ||||
|       id: total_daily_energy_hlw8012_power | ||||
|     energy: | ||||
|       name: HLW8012 Energy | ||||
|       id: hlw8012_energy | ||||
|       id: total_daily_energy_hlw8012_energy | ||||
|     update_interval: 15s | ||||
|     current_resistor: 0.001 ohm | ||||
|     voltage_divider: 2351 | ||||
| @@ -29,4 +29,4 @@ sensor: | ||||
|     model: hlw8012 | ||||
|   - platform: total_daily_energy | ||||
|     name: HLW8012 Total Daily Energy | ||||
|     power_id: hlw8012_power | ||||
|     power_id: total_daily_energy_hlw8012_power | ||||
|   | ||||
| @@ -35,6 +35,26 @@ from esphome.zeroconf import DiscoveredImport | ||||
| from .common import get_fixture_path | ||||
|  | ||||
|  | ||||
| def get_build_path(base_path: Path, device_name: str) -> Path: | ||||
|     """Get the build directory path for a device. | ||||
|  | ||||
|     This is a test helper that constructs the standard ESPHome build directory | ||||
|     structure. Note: This helper does NOT perform path traversal sanitization | ||||
|     because it's only used in tests where we control the inputs. The actual | ||||
|     web_server.py code handles sanitization in DownloadBinaryRequestHandler.get() | ||||
|     via file_name.replace("..", "").lstrip("/"). | ||||
|  | ||||
|     Args: | ||||
|         base_path: The base temporary path (typically tmp_path from pytest) | ||||
|         device_name: The name of the device (should not contain path separators | ||||
|                      in production use, but tests may use it for specific scenarios) | ||||
|  | ||||
|     Returns: | ||||
|         Path to the build directory (.esphome/build/device_name) | ||||
|     """ | ||||
|     return base_path / ".esphome" / "build" / device_name | ||||
|  | ||||
|  | ||||
| class DashboardTestHelper: | ||||
|     def __init__(self, io_loop: IOLoop, client: AsyncHTTPClient, port: int) -> None: | ||||
|         self.io_loop = io_loop | ||||
| @@ -417,6 +437,180 @@ async def test_download_binary_handler_idedata_fallback( | ||||
|     assert response.body == b"bootloader content" | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| @pytest.mark.usefixtures("mock_ext_storage_path") | ||||
| async def test_download_binary_handler_subdirectory_file( | ||||
|     dashboard: DashboardTestHelper, | ||||
|     tmp_path: Path, | ||||
|     mock_storage_json: MagicMock, | ||||
| ) -> None: | ||||
|     """Test the DownloadBinaryRequestHandler.get with file in subdirectory (nRF52 case). | ||||
|  | ||||
|     This is a regression test for issue #11343 where the Path migration broke | ||||
|     downloads for nRF52 firmware files in subdirectories like 'zephyr/zephyr.uf2'. | ||||
|  | ||||
|     The issue was that with_name() doesn't accept path separators: | ||||
|     - Before: path = storage_json.firmware_bin_path.with_name(file_name) | ||||
|       ValueError: Invalid name 'zephyr/zephyr.uf2' | ||||
|     - After: path = storage_json.firmware_bin_path.parent.joinpath(file_name) | ||||
|       Works correctly with subdirectory paths | ||||
|     """ | ||||
|     # Create a fake nRF52 build structure with firmware in subdirectory | ||||
|     build_dir = get_build_path(tmp_path, "nrf52-device") | ||||
|     zephyr_dir = build_dir / "zephyr" | ||||
|     zephyr_dir.mkdir(parents=True) | ||||
|  | ||||
|     # Create the main firmware binary (would be in build root) | ||||
|     firmware_file = build_dir / "firmware.bin" | ||||
|     firmware_file.write_bytes(b"main firmware") | ||||
|  | ||||
|     # Create the UF2 file in zephyr subdirectory (nRF52 specific) | ||||
|     uf2_file = zephyr_dir / "zephyr.uf2" | ||||
|     uf2_file.write_bytes(b"nRF52 UF2 firmware content") | ||||
|  | ||||
|     # Mock storage JSON | ||||
|     mock_storage = Mock() | ||||
|     mock_storage.name = "nrf52-device" | ||||
|     mock_storage.firmware_bin_path = firmware_file | ||||
|     mock_storage_json.load.return_value = mock_storage | ||||
|  | ||||
|     # Request the UF2 file with subdirectory path | ||||
|     response = await dashboard.fetch( | ||||
|         "/download.bin?configuration=nrf52-device.yaml&file=zephyr/zephyr.uf2", | ||||
|         method="GET", | ||||
|     ) | ||||
|     assert response.code == 200 | ||||
|     assert response.body == b"nRF52 UF2 firmware content" | ||||
|     assert response.headers["Content-Type"] == "application/octet-stream" | ||||
|     assert "attachment" in response.headers["Content-Disposition"] | ||||
|     # Download name should be device-name + full file path | ||||
|     assert "nrf52-device-zephyr/zephyr.uf2" in response.headers["Content-Disposition"] | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| @pytest.mark.usefixtures("mock_ext_storage_path") | ||||
| async def test_download_binary_handler_subdirectory_file_url_encoded( | ||||
|     dashboard: DashboardTestHelper, | ||||
|     tmp_path: Path, | ||||
|     mock_storage_json: MagicMock, | ||||
| ) -> None: | ||||
|     """Test the DownloadBinaryRequestHandler.get with URL-encoded subdirectory path. | ||||
|  | ||||
|     Verifies that URL-encoded paths (e.g., zephyr%2Fzephyr.uf2) are correctly | ||||
|     decoded and handled, and that custom download names work with subdirectories. | ||||
|     """ | ||||
|     # Create a fake build structure with firmware in subdirectory | ||||
|     build_dir = get_build_path(tmp_path, "test") | ||||
|     zephyr_dir = build_dir / "zephyr" | ||||
|     zephyr_dir.mkdir(parents=True) | ||||
|  | ||||
|     firmware_file = build_dir / "firmware.bin" | ||||
|     firmware_file.write_bytes(b"content") | ||||
|  | ||||
|     uf2_file = zephyr_dir / "zephyr.uf2" | ||||
|     uf2_file.write_bytes(b"content") | ||||
|  | ||||
|     # Mock storage JSON | ||||
|     mock_storage = Mock() | ||||
|     mock_storage.name = "test_device" | ||||
|     mock_storage.firmware_bin_path = firmware_file | ||||
|     mock_storage_json.load.return_value = mock_storage | ||||
|  | ||||
|     # Request with URL-encoded path and custom download name | ||||
|     response = await dashboard.fetch( | ||||
|         "/download.bin?configuration=test.yaml&file=zephyr%2Fzephyr.uf2&download=custom_name.bin", | ||||
|         method="GET", | ||||
|     ) | ||||
|     assert response.code == 200 | ||||
|     assert "custom_name.bin" in response.headers["Content-Disposition"] | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| @pytest.mark.usefixtures("mock_ext_storage_path") | ||||
| @pytest.mark.parametrize( | ||||
|     "attack_path", | ||||
|     [ | ||||
|         pytest.param("../../../secrets.yaml", id="basic_traversal"), | ||||
|         pytest.param("..%2F..%2F..%2Fsecrets.yaml", id="url_encoded"), | ||||
|         pytest.param("zephyr/../../../secrets.yaml", id="traversal_with_prefix"), | ||||
|         pytest.param("/etc/passwd", id="absolute_path"), | ||||
|         pytest.param("//etc/passwd", id="double_slash_absolute"), | ||||
|         pytest.param("....//secrets.yaml", id="multiple_dots"), | ||||
|     ], | ||||
| ) | ||||
| async def test_download_binary_handler_path_traversal_protection( | ||||
|     dashboard: DashboardTestHelper, | ||||
|     tmp_path: Path, | ||||
|     mock_storage_json: MagicMock, | ||||
|     attack_path: str, | ||||
| ) -> None: | ||||
|     """Test that DownloadBinaryRequestHandler prevents path traversal attacks. | ||||
|  | ||||
|     Verifies that attempts to use '..' in file paths are sanitized to prevent | ||||
|     accessing files outside the build directory. Tests multiple attack vectors. | ||||
|     """ | ||||
|     # Create build structure | ||||
|     build_dir = get_build_path(tmp_path, "test") | ||||
|     build_dir.mkdir(parents=True) | ||||
|     firmware_file = build_dir / "firmware.bin" | ||||
|     firmware_file.write_bytes(b"firmware content") | ||||
|  | ||||
|     # Create a sensitive file outside the build directory that should NOT be accessible | ||||
|     sensitive_file = tmp_path / "secrets.yaml" | ||||
|     sensitive_file.write_bytes(b"secret: my_secret_password") | ||||
|  | ||||
|     # Mock storage JSON | ||||
|     mock_storage = Mock() | ||||
|     mock_storage.name = "test_device" | ||||
|     mock_storage.firmware_bin_path = firmware_file | ||||
|     mock_storage_json.load.return_value = mock_storage | ||||
|  | ||||
|     # Attempt path traversal attack - should be blocked | ||||
|     with pytest.raises(HTTPClientError) as exc_info: | ||||
|         await dashboard.fetch( | ||||
|             f"/download.bin?configuration=test.yaml&file={attack_path}", | ||||
|             method="GET", | ||||
|         ) | ||||
|     # Should get 404 (file not found after sanitization) or 500 (idedata fails) | ||||
|     assert exc_info.value.code in (404, 500) | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| @pytest.mark.usefixtures("mock_ext_storage_path") | ||||
| async def test_download_binary_handler_multiple_subdirectory_levels( | ||||
|     dashboard: DashboardTestHelper, | ||||
|     tmp_path: Path, | ||||
|     mock_storage_json: MagicMock, | ||||
| ) -> None: | ||||
|     """Test downloading files from multiple subdirectory levels. | ||||
|  | ||||
|     Verifies that joinpath correctly handles multi-level paths like 'build/output/firmware.bin'. | ||||
|     """ | ||||
|     # Create nested directory structure | ||||
|     build_dir = get_build_path(tmp_path, "test") | ||||
|     nested_dir = build_dir / "build" / "output" | ||||
|     nested_dir.mkdir(parents=True) | ||||
|  | ||||
|     firmware_file = build_dir / "firmware.bin" | ||||
|     firmware_file.write_bytes(b"main") | ||||
|  | ||||
|     nested_file = nested_dir / "firmware.bin" | ||||
|     nested_file.write_bytes(b"nested firmware content") | ||||
|  | ||||
|     # Mock storage JSON | ||||
|     mock_storage = Mock() | ||||
|     mock_storage.name = "test_device" | ||||
|     mock_storage.firmware_bin_path = firmware_file | ||||
|     mock_storage_json.load.return_value = mock_storage | ||||
|  | ||||
|     response = await dashboard.fetch( | ||||
|         "/download.bin?configuration=test.yaml&file=build/output/firmware.bin", | ||||
|         method="GET", | ||||
|     ) | ||||
|     assert response.code == 200 | ||||
|     assert response.body == b"nested firmware content" | ||||
|  | ||||
|  | ||||
| @pytest.mark.asyncio | ||||
| async def test_edit_request_handler_post_invalid_file( | ||||
|     dashboard: DashboardTestHelper, | ||||
|   | ||||
| @@ -34,10 +34,9 @@ binary_sensor: | ||||
|           ESP_LOGD("test", "Button ON at %u", now); | ||||
|         } | ||||
|         return true; | ||||
|       } else { | ||||
|         // Only log state change | ||||
|         if (id(ir_remote_button).state) { | ||||
|           ESP_LOGD("test", "Button OFF at %u", now); | ||||
|         } | ||||
|         return false; | ||||
|       } | ||||
|       // Only log state change | ||||
|       if (id(ir_remote_button).state) { | ||||
|         ESP_LOGD("test", "Button OFF at %u", now); | ||||
|       } | ||||
|       return false; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user