1
0
mirror of https://github.com/esphome/esphome.git synced 2025-11-08 11:01:50 +00:00

Compare commits

...

10 Commits

Author SHA1 Message Date
J. Nick Koston
d43d23a4c7 preen 2025-10-30 10:27:27 -05:00
J. Nick Koston
7fef54ba79 preen 2025-10-30 10:26:11 -05:00
J. Nick Koston
68caee3226 preen 2025-10-30 10:24:20 -05:00
J. Nick Koston
9687ba3c36 Update esphome/components/thermostat/climate.py 2025-10-30 10:22:53 -05:00
J. Nick Koston
e2b3dec9d9 Merge branch 'climate_store_flash' into climate_store_flash_thermostat 2025-10-30 10:21:08 -05:00
J. Nick Koston
ca6e8e0cc5 preen 2025-10-30 10:15:10 -05:00
J. Nick Koston
c87b53a666 wip 2025-10-30 10:00:11 -05:00
J. Nick Koston
e1b6f84348 wip 2025-10-30 09:59:23 -05:00
J. Nick Koston
0433a84202 fix 2025-10-30 09:58:21 -05:00
J. Nick Koston
0d8ce7fdc9 wip 2025-10-30 09:54:16 -05:00
6 changed files with 139 additions and 2 deletions

View File

@@ -9,6 +9,8 @@ from esphome.const import (
CONF_COOL_DEADBAND,
CONF_COOL_MODE,
CONF_COOL_OVERRUN,
CONF_CUSTOM_FAN_MODES,
CONF_CUSTOM_PRESETS,
CONF_DEFAULT_MODE,
CONF_DEFAULT_TARGET_TEMPERATURE_HIGH,
CONF_DEFAULT_TARGET_TEMPERATURE_LOW,
@@ -658,6 +660,8 @@ CONFIG_SCHEMA = cv.All(
}
),
cv.Optional(CONF_PRESET): cv.ensure_list(PRESET_CONFIG_SCHEMA),
cv.Optional(CONF_CUSTOM_FAN_MODES): cv.ensure_list(cv.string_strict),
cv.Optional(CONF_CUSTOM_PRESETS): cv.ensure_list(cv.string_strict),
cv.Optional(CONF_ON_BOOT_RESTORE_FROM): validate_on_boot_restore_from,
cv.Optional(CONF_PRESET_CHANGE): automation.validate_automation(
single=True
@@ -1008,3 +1012,22 @@ async def to_code(config):
await automation.build_automation(
var.get_preset_change_trigger(), [], config[CONF_PRESET_CHANGE]
)
# Collect custom preset names from preset map (non-standard) and custom_presets list
custom_preset_names = [
preset_config[CONF_NAME]
for preset_config in config.get(CONF_PRESET, [])
if preset_config[CONF_NAME].upper() not in climate.CLIMATE_PRESETS
]
custom_preset_names.extend(config.get(CONF_CUSTOM_PRESETS, []))
if custom_preset_names:
cg.add(var.set_custom_presets(custom_preset_names))
# Collect custom fan modes (filter out standard enum fan modes)
custom_fan_modes = [
mode
for mode in config.get(CONF_CUSTOM_FAN_MODES, [])
if mode.upper() not in climate.CLIMATE_FAN_MODES
]
if custom_fan_modes:
cg.add(var.set_custom_fan_modes(custom_fan_modes))

View File

@@ -321,8 +321,12 @@ climate::ClimateTraits ThermostatClimate::traits() {
for (auto &it : this->preset_config_) {
traits.add_supported_preset(it.first);
}
for (auto &it : this->custom_preset_config_) {
traits.add_supported_custom_preset(it.first);
// Custom presets and fan modes are set directly from Python (includes both map entries and additional lists)
if (!this->additional_custom_presets_.empty()) {
traits.set_supported_custom_presets(this->additional_custom_presets_);
}
if (!this->additional_custom_fan_modes_.empty()) {
traits.set_supported_custom_fan_modes(this->additional_custom_fan_modes_);
}
return traits;
}
@@ -1248,6 +1252,14 @@ void ThermostatClimate::set_custom_preset_config(const std::string &name,
this->custom_preset_config_[name] = config;
}
void ThermostatClimate::set_custom_fan_modes(std::initializer_list<const char *> custom_fan_modes) {
this->additional_custom_fan_modes_ = custom_fan_modes;
}
void ThermostatClimate::set_custom_presets(std::initializer_list<const char *> custom_presets) {
this->additional_custom_presets_ = custom_presets;
}
ThermostatClimate::ThermostatClimate()
: cool_action_trigger_(new Trigger<>()),
supplemental_cool_action_trigger_(new Trigger<>()),

View File

@@ -133,6 +133,8 @@ class ThermostatClimate : public climate::Climate, public Component {
void set_preset_config(climate::ClimatePreset preset, const ThermostatClimateTargetTempConfig &config);
void set_custom_preset_config(const std::string &name, const ThermostatClimateTargetTempConfig &config);
void set_custom_fan_modes(std::initializer_list<const char *> custom_fan_modes);
void set_custom_presets(std::initializer_list<const char *> custom_presets);
Trigger<> *get_cool_action_trigger() const;
Trigger<> *get_supplemental_cool_action_trigger() const;
@@ -537,6 +539,10 @@ class ThermostatClimate : public climate::Climate, public Component {
std::map<climate::ClimatePreset, ThermostatClimateTargetTempConfig> preset_config_{};
/// The set of custom preset configurations this thermostat supports (eg. "My Custom Preset")
std::map<std::string, ThermostatClimateTargetTempConfig> custom_preset_config_{};
/// Additional custom fan modes to expose (beyond those with actions)
std::vector<const char *> additional_custom_fan_modes_{};
/// Additional custom presets to expose (beyond those in custom_preset_config_)
std::vector<const char *> additional_custom_presets_{};
};
} // namespace thermostat

View File

@@ -15,6 +15,12 @@ climate:
- name: Away
default_target_temperature_low: 16°C
default_target_temperature_high: 20°C
custom_fan_modes:
- "Custom Fan 1"
- "Custom Fan 2"
custom_presets:
- "Custom Preset 1"
- "Custom Preset 2"
idle_action:
- logger.log: idle_action
cool_action:

View File

@@ -0,0 +1,39 @@
esphome:
name: climate-custom-modes-test
host:
api:
logger:
sensor:
- platform: template
id: thermostat_sensor
lambda: "return 22.0;"
climate:
- platform: thermostat
id: test_thermostat
name: Test Thermostat Custom Modes
sensor: thermostat_sensor
preset:
- name: Away
default_target_temperature_low: 16°C
default_target_temperature_high: 20°C
custom_fan_modes:
- "Turbo"
- "Silent"
- "Sleep Mode"
custom_presets:
- "Eco Plus"
- "Comfort"
- "Vacation Mode"
idle_action:
- logger.log: idle_action
cool_action:
- logger.log: cool_action
heat_action:
- logger.log: heat_action
min_cooling_off_time: 10s
min_cooling_run_time: 10s
min_heating_off_time: 10s
min_heating_run_time: 10s
min_idle_time: 10s

View File

@@ -0,0 +1,51 @@
"""Integration test for climate custom fan modes and presets."""
from __future__ import annotations
from aioesphomeapi import ClimateInfo, ClimatePreset
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
@pytest.mark.asyncio
async def test_climate_custom_fan_modes_and_presets(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Test that custom fan modes and presets are properly exposed via API."""
async with run_compiled(yaml_config), api_client_connected() as client:
# Get entities and services
entities, services = await client.list_entities_services()
climate_infos = [e for e in entities if isinstance(e, ClimateInfo)]
assert len(climate_infos) == 1, "Expected exactly 1 climate entity"
test_climate = climate_infos[0]
# Verify custom fan modes are exposed
custom_fan_modes = test_climate.supported_custom_fan_modes
assert len(custom_fan_modes) == 3, (
f"Expected 3 custom fan modes, got {len(custom_fan_modes)}"
)
assert "Turbo" in custom_fan_modes, "Expected 'Turbo' in custom fan modes"
assert "Silent" in custom_fan_modes, "Expected 'Silent' in custom fan modes"
assert "Sleep Mode" in custom_fan_modes, (
"Expected 'Sleep Mode' in custom fan modes"
)
# Verify enum presets are exposed (from preset: config map)
assert ClimatePreset.AWAY in test_climate.supported_presets, (
"Expected AWAY in enum presets"
)
# Verify custom string presets are exposed (from custom_presets: config list)
custom_presets = test_climate.supported_custom_presets
assert len(custom_presets) == 3, (
f"Expected 3 custom presets, got {len(custom_presets)}: {custom_presets}"
)
assert "Eco Plus" in custom_presets, "Expected 'Eco Plus' in custom presets"
assert "Comfort" in custom_presets, "Expected 'Comfort' in custom presets"
assert "Vacation Mode" in custom_presets, (
"Expected 'Vacation Mode' in custom presets"
)