Add config flow to generic thermostat (#119930)

Co-authored-by: Franck Nijhof <frenck@frenck.nl>
This commit is contained in:
Joakim Plate 2024-06-22 18:26:39 +02:00 committed by GitHub
parent 6a34e1b7ca
commit 4d982a9227
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 457 additions and 15 deletions

View File

@ -1,6 +1,25 @@
"""The generic_thermostat component."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
DOMAIN = "generic_thermostat"
PLATFORMS = [Platform.CLIMATE]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up from a config entry."""
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(config_entry_update_listener))
return True
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Update listener, called when the config entry options are changed."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
from collections.abc import Mapping
from datetime import datetime, timedelta
import logging
import math
@ -25,6 +26,7 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_TEMPERATURE,
@ -51,8 +53,7 @@ from homeassistant.core import (
callback,
)
from homeassistant.exceptions import ConditionError
from homeassistant.helpers import condition
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers import condition, config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import (
async_track_state_change_event,
@ -95,7 +96,7 @@ CONF_PRESETS = {
)
}
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
PLATFORM_SCHEMA_COMMON = vol.Schema(
{
vol.Required(CONF_HEATER): cv.entity_id,
vol.Required(CONF_SENSOR): cv.entity_id,
@ -111,15 +112,34 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
vol.Optional(CONF_INITIAL_HVAC_MODE): vol.In(
[HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF]
),
vol.Optional(CONF_PRECISION): vol.In(
[PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE]
vol.Optional(CONF_PRECISION): vol.All(
vol.Coerce(float),
vol.In([PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE]),
),
vol.Optional(CONF_TEMP_STEP): vol.In(
[PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE]
vol.Optional(CONF_TEMP_STEP): vol.All(
vol.In([PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE])
),
vol.Optional(CONF_UNIQUE_ID): cv.string,
**{vol.Optional(v): vol.Coerce(float) for v in CONF_PRESETS.values()},
}
).extend({vol.Optional(v): vol.Coerce(float) for (k, v) in CONF_PRESETS.items()})
)
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(PLATFORM_SCHEMA_COMMON.schema)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Initialize config entry."""
await _async_setup_config(
hass,
PLATFORM_SCHEMA_COMMON(dict(config_entry.options)),
config_entry.entry_id,
async_add_entities,
)
async def async_setup_platform(
@ -131,6 +151,18 @@ async def async_setup_platform(
"""Set up the generic thermostat platform."""
await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
await _async_setup_config(
hass, config, config.get(CONF_UNIQUE_ID), async_add_entities
)
async def _async_setup_config(
hass: HomeAssistant,
config: Mapping[str, Any],
unique_id: str | None,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the generic thermostat platform."""
name: str = config[CONF_NAME]
heater_entity_id: str = config[CONF_HEATER]
@ -150,7 +182,6 @@ async def async_setup_platform(
precision: float | None = config.get(CONF_PRECISION)
target_temperature_step: float | None = config.get(CONF_TEMP_STEP)
unit = hass.config.units.temperature_unit
unique_id: str | None = config.get(CONF_UNIQUE_ID)
async_add_entities(
[

View File

@ -0,0 +1,96 @@
"""Config flow for Generic hygrostat."""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any, cast
import voluptuous as vol
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.const import CONF_NAME, DEGREE
from homeassistant.helpers import selector
from homeassistant.helpers.schema_config_entry_flow import (
SchemaConfigFlowHandler,
SchemaFlowFormStep,
)
from .climate import (
CONF_AC_MODE,
CONF_COLD_TOLERANCE,
CONF_HEATER,
CONF_HOT_TOLERANCE,
CONF_MIN_DUR,
CONF_PRESETS,
CONF_SENSOR,
DEFAULT_TOLERANCE,
DOMAIN,
)
OPTIONS_SCHEMA = {
vol.Required(CONF_AC_MODE): selector.BooleanSelector(
selector.BooleanSelectorConfig(),
),
vol.Required(CONF_SENSOR): selector.EntitySelector(
selector.EntitySelectorConfig(
domain=SENSOR_DOMAIN, device_class=SensorDeviceClass.TEMPERATURE
)
),
vol.Required(CONF_HEATER): selector.EntitySelector(
selector.EntitySelectorConfig(domain=SWITCH_DOMAIN)
),
vol.Required(
CONF_COLD_TOLERANCE, default=DEFAULT_TOLERANCE
): selector.NumberSelector(
selector.NumberSelectorConfig(
mode=selector.NumberSelectorMode.BOX, unit_of_measurement=DEGREE, step=0.1
)
),
vol.Required(
CONF_HOT_TOLERANCE, default=DEFAULT_TOLERANCE
): selector.NumberSelector(
selector.NumberSelectorConfig(
mode=selector.NumberSelectorMode.BOX, unit_of_measurement=DEGREE, step=0.1
)
),
vol.Optional(CONF_MIN_DUR): selector.DurationSelector(
selector.DurationSelectorConfig(allow_negative=False)
),
}
PRESETS_SCHEMA = {
vol.Optional(v): selector.NumberSelector(
selector.NumberSelectorConfig(
mode=selector.NumberSelectorMode.BOX, unit_of_measurement=DEGREE
)
)
for v in CONF_PRESETS.values()
}
CONFIG_SCHEMA = {
vol.Required(CONF_NAME): selector.TextSelector(),
**OPTIONS_SCHEMA,
}
CONFIG_FLOW = {
"user": SchemaFlowFormStep(vol.Schema(CONFIG_SCHEMA), next_step="presets"),
"presets": SchemaFlowFormStep(vol.Schema(PRESETS_SCHEMA)),
}
OPTIONS_FLOW = {
"init": SchemaFlowFormStep(vol.Schema(OPTIONS_SCHEMA), next_step="presets"),
"presets": SchemaFlowFormStep(vol.Schema(PRESETS_SCHEMA)),
}
class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
"""Handle a config or options flow."""
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""
return cast(str, options["name"])

View File

@ -2,7 +2,9 @@
"domain": "generic_thermostat",
"name": "Generic Thermostat",
"codeowners": [],
"config_flow": true,
"dependencies": ["sensor", "switch"],
"documentation": "https://www.home-assistant.io/integrations/generic_thermostat",
"integration_type": "helper",
"iot_class": "local_polling"
}

View File

@ -1,4 +1,74 @@
{
"title": "Generic thermostat",
"config": {
"step": {
"user": {
"title": "Add generic thermostat helper",
"description": "Create a climate entity that controls the temperature via a switch and sensor.",
"data": {
"ac_mode": "Cooling mode",
"heater": "Actuator switch",
"target_sensor": "Temperature sensor",
"min_cycle_duration": "Minimum cycle duration",
"name": "[%key:common::config_flow::data::name%]",
"cold_tolerance": "Cold tolerance",
"hot_tolerance": "Hot tolerance"
},
"data_description": {
"ac_mode": "Set the actuator specified to be treated as a cooling device instead of a heating device.",
"heater": "Switch entity used to cool or heat depending on A/C mode.",
"target_sensor": "Temperature sensor that reflect the current temperature.",
"min_cycle_duration": "Set a minimum amount of time that the switch specified must be in its current state prior to being switched either off or on. This option will be ignored if the keep alive option is set.",
"cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor equals or goes below 24.5.",
"hot_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched off. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will stop when the sensor equals or goes above 25.5."
}
},
"presets": {
"title": "Temperature presets",
"data": {
"away_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]",
"comfort_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]",
"eco_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]",
"home_temp": "[%common::state::home%]",
"sleep_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::sleep%]",
"activity_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::activity%]"
}
}
}
},
"options": {
"step": {
"init": {
"data": {
"ac_mode": "[%key:component::generic_thermostat::config::step::user::data::ac_mode%]",
"heater": "[%key:component::generic_thermostat::config::step::user::data::heater%]",
"target_sensor": "[%key:component::generic_thermostat::config::step::user::data::target_sensor%]",
"min_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data::min_cycle_duration%]",
"cold_tolerance": "[%key:component::generic_thermostat::config::step::user::data::cold_tolerance%]",
"hot_tolerance": "[%key:component::generic_thermostat::config::step::user::data::hot_tolerance%]"
},
"data_description": {
"heater": "[%key:component::generic_thermostat::config::step::user::data_description::heater%]",
"target_sensor": "[%key:component::generic_thermostat::config::step::user::data_description::target_sensor%]",
"ac_mode": "[%key:component::generic_thermostat::config::step::user::data_description::ac_mode%]",
"min_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data_description::min_cycle_duration%]",
"cold_tolerance": "[%key:component::generic_thermostat::config::step::user::data_description::cold_tolerance%]",
"hot_tolerance": "[%key:component::generic_thermostat::config::step::user::data_description::hot_tolerance%]"
}
},
"presets": {
"title": "[%key:component::generic_thermostat::config::step::presets::title%]",
"data": {
"away_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]",
"comfort_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]",
"eco_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]",
"home_temp": "[%key:common::state::home%]",
"sleep_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::sleep%]",
"activity_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::activity%]"
}
}
}
},
"services": {
"reload": {
"name": "[%key:common::action::reload%]",

View File

@ -6,6 +6,7 @@ To update, run python3 -m script.hassfest
FLOWS = {
"helper": [
"derivative",
"generic_thermostat",
"group",
"integration",
"min_max",

View File

@ -2133,12 +2133,6 @@
"config_flow": false,
"iot_class": "local_polling"
},
"generic_thermostat": {
"name": "Generic Thermostat",
"integration_type": "hub",
"config_flow": false,
"iot_class": "local_polling"
},
"geniushub": {
"name": "Genius Hub",
"integration_type": "hub",
@ -7166,6 +7160,11 @@
"config_flow": true,
"iot_class": "calculated"
},
"generic_thermostat": {
"integration_type": "helper",
"config_flow": true,
"iot_class": "local_polling"
},
"group": {
"integration_type": "helper",
"config_flow": true,
@ -7266,6 +7265,7 @@
"filesize",
"garages_amsterdam",
"generic",
"generic_thermostat",
"google_travel_time",
"group",
"growatt_server",

View File

@ -0,0 +1,89 @@
# serializer version: 1
# name: test_config_flow[create_entry]
FlowResultSnapshot({
'result': ConfigEntrySnapshot({
'title': 'My thermostat',
}),
'title': 'My thermostat',
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
})
# ---
# name: test_config_flow[init]
FlowResultSnapshot({
'type': <FlowResultType.FORM: 'form'>,
})
# ---
# name: test_config_flow[presets]
FlowResultSnapshot({
'type': <FlowResultType.FORM: 'form'>,
})
# ---
# name: test_options[create_entry]
FlowResultSnapshot({
'result': True,
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
})
# ---
# name: test_options[init]
FlowResultSnapshot({
'type': <FlowResultType.FORM: 'form'>,
})
# ---
# name: test_options[presets]
FlowResultSnapshot({
'type': <FlowResultType.FORM: 'form'>,
})
# ---
# name: test_options[with_away]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 15.0,
'friendly_name': 'My thermostat',
'hvac_action': <HVACAction.OFF: 'off'>,
'hvac_modes': list([
<HVACMode.HEAT: 'heat'>,
<HVACMode.OFF: 'off'>,
]),
'max_temp': 35,
'min_temp': 7,
'preset_mode': 'none',
'preset_modes': list([
'none',
'away',
]),
'supported_features': <ClimateEntityFeature: 401>,
'target_temp_step': 0.1,
'temperature': 7,
}),
'context': <ANY>,
'entity_id': 'climate.my_thermostat',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_options[without_away]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 15.0,
'friendly_name': 'My thermostat',
'hvac_action': <HVACAction.OFF: 'off'>,
'hvac_modes': list([
<HVACMode.HEAT: 'heat'>,
<HVACMode.OFF: 'off'>,
]),
'max_temp': 35,
'min_temp': 7,
'supported_features': <ClimateEntityFeature: 385>,
'target_temp_step': 0.1,
'temperature': 7.0,
}),
'context': <ANY>,
'entity_id': 'climate.my_thermostat',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---

View File

@ -0,0 +1,134 @@
"""Test the generic hygrostat config flow."""
from unittest.mock import patch
from syrupy.assertion import SnapshotAssertion
from syrupy.filters import props
from homeassistant.components.climate import PRESET_AWAY
from homeassistant.components.generic_thermostat.climate import (
CONF_AC_MODE,
CONF_COLD_TOLERANCE,
CONF_HEATER,
CONF_HOT_TOLERANCE,
CONF_NAME,
CONF_PRESETS,
CONF_SENSOR,
DOMAIN,
)
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_UNIT_OF_MEASUREMENT,
STATE_OFF,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
SNAPSHOT_FLOW_PROPS = props("type", "title", "result", "error")
async def test_config_flow(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None:
"""Test the config flow."""
with patch(
"homeassistant.components.generic_thermostat.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result == snapshot(name="init", include=SNAPSHOT_FLOW_PROPS)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_NAME: "My thermostat",
CONF_HEATER: "switch.run",
CONF_SENSOR: "sensor.temperature",
CONF_AC_MODE: False,
CONF_COLD_TOLERANCE: 0.3,
CONF_HOT_TOLERANCE: 0.3,
},
)
assert result == snapshot(name="presets", include=SNAPSHOT_FLOW_PROPS)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_PRESETS[PRESET_AWAY]: 20,
},
)
assert result == snapshot(name="create_entry", include=SNAPSHOT_FLOW_PROPS)
await hass.async_block_till_done()
assert len(mock_setup_entry.mock_calls) == 1
config_entry = hass.config_entries.async_entries(DOMAIN)[0]
assert config_entry.data == {}
assert config_entry.title == "My thermostat"
async def test_options(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None:
"""Test reconfiguring."""
config_entry = MockConfigEntry(
data={},
domain=DOMAIN,
options={
CONF_NAME: "My thermostat",
CONF_HEATER: "switch.run",
CONF_SENSOR: "sensor.temperature",
CONF_AC_MODE: False,
CONF_COLD_TOLERANCE: 0.3,
CONF_HOT_TOLERANCE: 0.3,
CONF_PRESETS[PRESET_AWAY]: 20,
},
title="My dehumidifier",
)
config_entry.add_to_hass(hass)
hass.states.async_set(
"sensor.temperature",
"15",
{
ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS,
ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE,
},
)
hass.states.async_set("switch.run", STATE_OFF)
assert await hass.config_entries.async_setup(config_entry.entry_id)
# check that it is setup
await hass.async_block_till_done()
assert hass.states.get("climate.my_thermostat") == snapshot(name="with_away")
# remove away preset
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result == snapshot(name="init", include=SNAPSHOT_FLOW_PROPS)
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_HEATER: "switch.run",
CONF_SENSOR: "sensor.temperature",
CONF_AC_MODE: False,
CONF_COLD_TOLERANCE: 0.3,
CONF_HOT_TOLERANCE: 0.3,
},
)
assert result == snapshot(name="presets", include=SNAPSHOT_FLOW_PROPS)
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={},
)
assert result == snapshot(name="create_entry", include=SNAPSHOT_FLOW_PROPS)
# Check config entry is reloaded with new options
await hass.async_block_till_done()
assert hass.states.get("climate.my_thermostat") == snapshot(name="without_away")