From 5b5d3fa9b393dee4df06fbe0d84ca77d1df572b5 Mon Sep 17 00:00:00 2001 From: Ryan Wagoner Date: Tue, 17 Feb 2026 14:25:49 -0500 Subject: [PATCH 1/7] Add climate preset support to web server REST API The web server's handle_climate_request() parsed mode, fan_mode, and swing_mode but did not parse preset, making it impossible to set climate presets via the REST API or web UI. Also fix the JSON response to always include the presets and custom_presets arrays when the climate entity supports them, rather than only when a preset is currently active. This matches how swing_modes is already handled and ensures the frontend can render preset controls before one is selected. --- esphome/components/web_server/web_server.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 3acd2d2119..e89578a196 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1480,6 +1480,7 @@ void WebServer::handle_climate_request(AsyncWebServerRequest *request, const Url parse_string_param_(request, ESPHOME_F("mode"), call, &decltype(call)::set_mode); parse_string_param_(request, ESPHOME_F("fan_mode"), call, &decltype(call)::set_fan_mode); parse_string_param_(request, ESPHOME_F("swing_mode"), call, &decltype(call)::set_swing_mode); + parse_string_param_(request, ESPHOME_F("preset"), call, &decltype(call)::set_preset); // Parse temperature parameters // static_cast needed to disambiguate overloaded setters (float vs optional) @@ -1536,12 +1537,12 @@ std::string WebServer::climate_json_(climate::Climate *obj, JsonDetail start_con for (auto swing_mode : traits.get_supported_swing_modes()) opt.add(PSTR_LOCAL(climate::climate_swing_mode_to_string(swing_mode))); } - if (traits.get_supports_presets() && obj->preset.has_value()) { + if (traits.get_supports_presets()) { JsonArray opt = root[ESPHOME_F("presets")].to(); for (climate::ClimatePreset m : traits.get_supported_presets()) opt.add(PSTR_LOCAL(climate::climate_preset_to_string(m))); } - if (!traits.get_supported_custom_presets().empty() && obj->has_custom_preset()) { + if (!traits.get_supported_custom_presets().empty()) { JsonArray opt = root[ESPHOME_F("custom_presets")].to(); for (auto const &custom_preset : traits.get_supported_custom_presets()) opt.add(custom_preset); From 38fc007a6ad0d90bc013cabbea91194d11f3c766 Mon Sep 17 00:00:00 2001 From: Ryan Wagoner Date: Tue, 17 Feb 2026 22:22:22 -0500 Subject: [PATCH 2/7] Always include preset/custom_preset in climate state JSON When a preset is cleared, the field was omitted entirely from the JSON. Since the frontend uses Object.assign to merge state updates, the old preset value was never removed. Now we always send the field (empty string when no preset is active) so the UI updates correctly. --- esphome/components/web_server/web_server.cpp | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index e89578a196..727aeb8314 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1568,11 +1568,16 @@ std::string WebServer::climate_json_(climate::Climate *obj, JsonDetail start_con if (!traits.get_supported_custom_fan_modes().empty() && obj->has_custom_fan_mode()) { root[ESPHOME_F("custom_fan_mode")] = obj->get_custom_fan_mode(); } - if (traits.get_supports_presets() && obj->preset.has_value()) { - root[ESPHOME_F("preset")] = PSTR_LOCAL(climate_preset_to_string(obj->preset.value())); + if (traits.get_supports_presets()) { + root[ESPHOME_F("preset")] = + obj->preset.has_value() ? PSTR_LOCAL(climate_preset_to_string(obj->preset.value())) : ""; } - if (!traits.get_supported_custom_presets().empty() && obj->has_custom_preset()) { - root[ESPHOME_F("custom_preset")] = obj->get_custom_preset(); + if (!traits.get_supported_custom_presets().empty()) { + if (obj->has_custom_preset()) { + root[ESPHOME_F("custom_preset")] = obj->get_custom_preset(); + } else { + root[ESPHOME_F("custom_preset")] = ""; + } } if (traits.get_supports_swing_modes()) { root[ESPHOME_F("swing_mode")] = PSTR_LOCAL(climate_swing_mode_to_string(obj->swing_mode)); From 4fe2fe80650e3efe80130c1f1bfa22900a7a9660 Mon Sep 17 00:00:00 2001 From: Ryan Wagoner Date: Wed, 18 Feb 2026 13:54:12 -0500 Subject: [PATCH 3/7] Always include fan_mode/custom_fan_mode in climate JSON and fix fan_modes trait check Apply Option A (always include with empty string fallback) to fan_mode and custom_fan_mode fields, matching the existing preset pattern. This prevents stale values when SSE updates use Object.assign(). Also fix pre-existing bug where fan_modes list was gated on custom fan modes instead of supports_fan_modes. --- esphome/components/web_server/web_server.cpp | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 727aeb8314..1288fb882a 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1521,7 +1521,7 @@ std::string WebServer::climate_json_(climate::Climate *obj, JsonDetail start_con JsonArray opt = root[ESPHOME_F("modes")].to(); for (climate::ClimateMode m : traits.get_supported_modes()) opt.add(PSTR_LOCAL(climate::climate_mode_to_string(m))); - if (!traits.get_supported_custom_fan_modes().empty()) { + if (traits.get_supports_fan_modes()) { JsonArray opt = root[ESPHOME_F("fan_modes")].to(); for (climate::ClimateFanMode m : traits.get_supported_fan_modes()) opt.add(PSTR_LOCAL(climate::climate_fan_mode_to_string(m))); @@ -1562,11 +1562,16 @@ std::string WebServer::climate_json_(climate::Climate *obj, JsonDetail start_con root[ESPHOME_F("state")] = root[ESPHOME_F("action")]; has_state = true; } - if (traits.get_supports_fan_modes() && obj->fan_mode.has_value()) { - root[ESPHOME_F("fan_mode")] = PSTR_LOCAL(climate_fan_mode_to_string(obj->fan_mode.value())); + if (traits.get_supports_fan_modes()) { + root[ESPHOME_F("fan_mode")] = + obj->fan_mode.has_value() ? PSTR_LOCAL(climate_fan_mode_to_string(obj->fan_mode.value())) : ""; } - if (!traits.get_supported_custom_fan_modes().empty() && obj->has_custom_fan_mode()) { - root[ESPHOME_F("custom_fan_mode")] = obj->get_custom_fan_mode(); + if (!traits.get_supported_custom_fan_modes().empty()) { + if (obj->has_custom_fan_mode()) { + root[ESPHOME_F("custom_fan_mode")] = obj->get_custom_fan_mode(); + } else { + root[ESPHOME_F("custom_fan_mode")] = ""; + } } if (traits.get_supports_presets()) { root[ESPHOME_F("preset")] = From 0cadca9c473bcbc64965534c75d60d23ac0ca102 Mon Sep 17 00:00:00 2001 From: Ryan Wagoner Date: Wed, 18 Feb 2026 14:11:25 -0500 Subject: [PATCH 4/7] Default fan_mode to AUTO when supported but unset Empty string is not a valid fan_mode value. When the climate supports fan modes but no value has been set yet, default to AUTO. --- esphome/components/web_server/web_server.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 1288fb882a..47598456c3 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1564,7 +1564,8 @@ std::string WebServer::climate_json_(climate::Climate *obj, JsonDetail start_con } if (traits.get_supports_fan_modes()) { root[ESPHOME_F("fan_mode")] = - obj->fan_mode.has_value() ? PSTR_LOCAL(climate_fan_mode_to_string(obj->fan_mode.value())) : ""; + obj->fan_mode.has_value() ? PSTR_LOCAL(climate_fan_mode_to_string(obj->fan_mode.value())) + : PSTR_LOCAL(climate_fan_mode_to_string(climate::CLIMATE_FAN_AUTO)); } if (!traits.get_supported_custom_fan_modes().empty()) { if (obj->has_custom_fan_mode()) { From 16deb55acbe826fc69a02f66da677ecdd6b9e7ce Mon Sep 17 00:00:00 2001 From: Ryan Wagoner Date: Wed, 18 Feb 2026 14:17:23 -0500 Subject: [PATCH 5/7] Add current_humidity to climate JSON when supported Include current_humidity field in climate state JSON when the climate device supports CLIMATE_SUPPORTS_CURRENT_HUMIDITY. --- esphome/components/web_server/web_server.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 47598456c3..f52c85eb5a 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1594,6 +1594,11 @@ std::string WebServer::climate_json_(climate::Climate *obj, JsonDetail start_con ? "NA" : (value_accuracy_to_buf(temp_buf, obj->current_temperature, current_accuracy), temp_buf); } + if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY)) { + root[ESPHOME_F("current_humidity")] = + std::isnan(obj->current_humidity) ? "NA" + : (value_accuracy_to_buf(temp_buf, obj->current_humidity, 0), temp_buf); + } if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | climate::CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) { root[ESPHOME_F("target_temperature_low")] = From fe6cec6a5db3900789c2d5ad1fa3d5fd8471c4ad Mon Sep 17 00:00:00 2001 From: Ryan Wagoner Date: Wed, 18 Feb 2026 14:32:40 -0500 Subject: [PATCH 6/7] Fix fan_mode default when custom fan mode is active Only default fan_mode to AUTO when no custom fan mode is set. When a custom fan mode is active, send empty fan_mode to avoid the frontend showing AUTO instead of the custom mode. --- esphome/components/web_server/web_server.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index f52c85eb5a..daddd19a4a 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1563,9 +1563,13 @@ std::string WebServer::climate_json_(climate::Climate *obj, JsonDetail start_con has_state = true; } if (traits.get_supports_fan_modes()) { - root[ESPHOME_F("fan_mode")] = - obj->fan_mode.has_value() ? PSTR_LOCAL(climate_fan_mode_to_string(obj->fan_mode.value())) - : PSTR_LOCAL(climate_fan_mode_to_string(climate::CLIMATE_FAN_AUTO)); + if (obj->fan_mode.has_value()) { + root[ESPHOME_F("fan_mode")] = PSTR_LOCAL(climate_fan_mode_to_string(obj->fan_mode.value())); + } else if (!obj->has_custom_fan_mode()) { + root[ESPHOME_F("fan_mode")] = PSTR_LOCAL(climate_fan_mode_to_string(climate::CLIMATE_FAN_AUTO)); + } else { + root[ESPHOME_F("fan_mode")] = ""; + } } if (!traits.get_supported_custom_fan_modes().empty()) { if (obj->has_custom_fan_mode()) { From e27698f0639c736a0f20ee771b91ca3144da8514 Mon Sep 17 00:00:00 2001 From: Ryan Wagoner Date: Wed, 18 Feb 2026 16:17:00 -0500 Subject: [PATCH 7/7] Fix clang-format for current_humidity ternary expression --- esphome/components/web_server/web_server.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index daddd19a4a..eb18bbdb1f 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -1599,9 +1599,9 @@ std::string WebServer::climate_json_(climate::Climate *obj, JsonDetail start_con : (value_accuracy_to_buf(temp_buf, obj->current_temperature, current_accuracy), temp_buf); } if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY)) { - root[ESPHOME_F("current_humidity")] = - std::isnan(obj->current_humidity) ? "NA" - : (value_accuracy_to_buf(temp_buf, obj->current_humidity, 0), temp_buf); + root[ESPHOME_F("current_humidity")] = std::isnan(obj->current_humidity) + ? "NA" + : (value_accuracy_to_buf(temp_buf, obj->current_humidity, 0), temp_buf); } if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | climate::CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) {