diff --git a/.coveragerc b/.coveragerc index ca2a0a5010b..6cff79350d2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1015,6 +1015,7 @@ omit = homeassistant/components/rainmachine/binary_sensor.py homeassistant/components/rainmachine/button.py homeassistant/components/rainmachine/model.py + homeassistant/components/rainmachine/select.py homeassistant/components/rainmachine/sensor.py homeassistant/components/rainmachine/switch.py homeassistant/components/rainmachine/update.py diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index 4d19dbc7bfc..ff52b74ab16 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -58,6 +58,7 @@ CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) PLATFORMS = [ Platform.BINARY_SENSOR, Platform.BUTTON, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.UPDATE, diff --git a/homeassistant/components/rainmachine/select.py b/homeassistant/components/rainmachine/select.py new file mode 100644 index 00000000000..82dddfb8f3a --- /dev/null +++ b/homeassistant/components/rainmachine/select.py @@ -0,0 +1,154 @@ +"""Support for RainMachine selects.""" +from __future__ import annotations + +from dataclasses import dataclass + +from regenmaschine.errors import RainMachineError + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_UNIT_SYSTEM_IMPERIAL +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import RainMachineData, RainMachineEntity +from .const import DATA_RESTRICTIONS_UNIVERSAL, DOMAIN +from .model import ( + RainMachineEntityDescription, + RainMachineEntityDescriptionMixinDataKey, +) +from .util import key_exists + + +@dataclass +class RainMachineSelectDescription( + SelectEntityDescription, + RainMachineEntityDescription, + RainMachineEntityDescriptionMixinDataKey, +): + """Describe a generic RainMachine select.""" + + +@dataclass +class FreezeProtectionSelectOption: + """Define an option for a freeze selection select.""" + + api_value: float + imperial_label: str + metric_label: str + + +@dataclass +class FreezeProtectionTemperatureMixin: + """Define an entity description mixin to include an options list.""" + + options: list[FreezeProtectionSelectOption] + + +@dataclass +class FreezeProtectionSelectDescription( + RainMachineSelectDescription, FreezeProtectionTemperatureMixin +): + """Describe a freeze protection temperature select.""" + + +TYPE_FREEZE_PROTECTION_TEMPERATURE = "freeze_protection_temperature" + +SELECT_DESCRIPTIONS = ( + FreezeProtectionSelectDescription( + key=TYPE_FREEZE_PROTECTION_TEMPERATURE, + name="Freeze protection temperature", + icon="mdi:thermometer", + entity_category=EntityCategory.CONFIG, + api_category=DATA_RESTRICTIONS_UNIVERSAL, + data_key="freezeProtectTemp", + options=[ + FreezeProtectionSelectOption( + api_value=0.0, + imperial_label="32°F", + metric_label="0°C", + ), + FreezeProtectionSelectOption( + api_value=2.0, + imperial_label="35.6°F", + metric_label="2°C", + ), + FreezeProtectionSelectOption( + api_value=5.0, + imperial_label="41°F", + metric_label="5°C", + ), + FreezeProtectionSelectOption( + api_value=10.0, + imperial_label="50°F", + metric_label="10°C", + ), + ], + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up RainMachine selects based on a config entry.""" + data: RainMachineData = hass.data[DOMAIN][entry.entry_id] + + entity_map = { + TYPE_FREEZE_PROTECTION_TEMPERATURE: FreezeProtectionTemperatureSelect, + } + + async_add_entities( + entity_map[description.key](entry, data, description, hass.config.units.name) + for description in SELECT_DESCRIPTIONS + if ( + (coordinator := data.coordinators[description.api_category]) is not None + and coordinator.data + and key_exists(coordinator.data, description.data_key) + ) + ) + + +class FreezeProtectionTemperatureSelect(RainMachineEntity, SelectEntity): + """Define a RainMachine select.""" + + entity_description: FreezeProtectionSelectDescription + + def __init__( + self, + entry: ConfigEntry, + data: RainMachineData, + description: FreezeProtectionSelectDescription, + unit_system: str, + ) -> None: + """Initialize.""" + super().__init__(entry, data, description) + + self._api_value_to_label_map = {} + self._label_to_api_value_map = {} + + for option in description.options: + if unit_system == CONF_UNIT_SYSTEM_IMPERIAL: + label = option.imperial_label + else: + label = option.metric_label + self._api_value_to_label_map[option.api_value] = label + self._label_to_api_value_map[label] = option.api_value + + self._attr_options = list(self._label_to_api_value_map) + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + try: + await self._data.controller.restrictions.set_universal( + {self.entity_description.data_key: self._label_to_api_value_map[option]} + ) + except RainMachineError as err: + raise ValueError(f"Error while setting {self.name}: {err}") from err + + @callback + def update_from_latest_data(self) -> None: + """Update the entity when new data is received.""" + raw_value = self.coordinator.data[self.entity_description.data_key] + self._attr_current_option = self._api_value_to_label_map[raw_value] diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index 32364e08199..97772c6033a 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -6,6 +6,7 @@ from datetime import datetime, timedelta from typing import Any, cast from homeassistant.components.sensor import ( + DOMAIN as SENSOR_DOMAIN, RestoreSensor, SensorDeviceClass, SensorEntity, @@ -32,7 +33,13 @@ from .model import ( RainMachineEntityDescriptionMixinDataKey, RainMachineEntityDescriptionMixinUid, ) -from .util import RUN_STATE_MAP, RunStates, key_exists +from .util import ( + RUN_STATE_MAP, + EntityDomainReplacementStrategy, + RunStates, + async_finish_entity_domain_replacements, + key_exists, +) DEFAULT_ZONE_COMPLETION_TIME_WOBBLE_TOLERANCE = timedelta(seconds=5) @@ -127,6 +134,20 @@ async def async_setup_entry( """Set up RainMachine sensors based on a config entry.""" data: RainMachineData = hass.data[DOMAIN][entry.entry_id] + async_finish_entity_domain_replacements( + hass, + entry, + ( + EntityDomainReplacementStrategy( + SENSOR_DOMAIN, + f"{data.controller.mac}_freeze_protect_temp", + f"select.{data.controller.name.lower()}_freeze_protect_temperature", + breaks_in_ha_version="2022.12.0", + remove_old_entity=False, + ), + ), + ) + api_category_sensor_map = { DATA_PROVISION_SETTINGS: ProvisionSettingsSensor, DATA_RESTRICTIONS_UNIVERSAL: UniversalRestrictionsSensor, diff --git a/homeassistant/components/rainmachine/strings.json b/homeassistant/components/rainmachine/strings.json index 7634c0a69c5..95b92e99294 100644 --- a/homeassistant/components/rainmachine/strings.json +++ b/homeassistant/components/rainmachine/strings.json @@ -27,5 +27,18 @@ } } } + }, + "issues": { + "replaced_old_entity": { + "title": "The {old_entity_id} entity will be removed", + "fix_flow": { + "step": { + "confirm": { + "title": "The {old_entity_id} entity will be removed", + "description": "Update any automations or scripts that use this entity to instead use `{replacement_entity_id}`." + } + } + } + } } } diff --git a/homeassistant/components/rainmachine/translations/en.json b/homeassistant/components/rainmachine/translations/en.json index 9369eeae4c8..3e5d824ee08 100644 --- a/homeassistant/components/rainmachine/translations/en.json +++ b/homeassistant/components/rainmachine/translations/en.json @@ -18,6 +18,19 @@ } } }, + "issues": { + "replaced_old_entity": { + "fix_flow": { + "step": { + "confirm": { + "description": "Update any automations or scripts that use this entity to instead use `{replacement_entity_id}`.", + "title": "The {old_entity_id} entity will be removed" + } + } + }, + "title": "The {old_entity_id} entity will be removed" + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/rainmachine/util.py b/homeassistant/components/rainmachine/util.py index dc772690ec5..3c66d530cf4 100644 --- a/homeassistant/components/rainmachine/util.py +++ b/homeassistant/components/rainmachine/util.py @@ -1,20 +1,23 @@ """Define RainMachine utilities.""" from __future__ import annotations -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Iterable +from dataclasses import dataclass from datetime import timedelta from typing import Any from homeassistant.backports.enum import StrEnum from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import LOGGER +from .const import DOMAIN, LOGGER SIGNAL_REBOOT_COMPLETED = "rainmachine_reboot_completed_{0}" SIGNAL_REBOOT_REQUESTED = "rainmachine_reboot_requested_{0}" @@ -35,6 +38,60 @@ RUN_STATE_MAP = { } +@dataclass +class EntityDomainReplacementStrategy: + """Define an entity replacement.""" + + old_domain: str + old_unique_id: str + replacement_entity_id: str + breaks_in_ha_version: str + remove_old_entity: bool = True + + +@callback +def async_finish_entity_domain_replacements( + hass: HomeAssistant, + entry: ConfigEntry, + entity_replacement_strategies: Iterable[EntityDomainReplacementStrategy], +) -> None: + """Remove old entities and create a repairs issue with info on their replacement.""" + ent_reg = entity_registry.async_get(hass) + for strategy in entity_replacement_strategies: + try: + [registry_entry] = [ + registry_entry + for registry_entry in ent_reg.entities.values() + if registry_entry.config_entry_id == entry.entry_id + and registry_entry.domain == strategy.old_domain + and registry_entry.unique_id == strategy.old_unique_id + ] + except ValueError: + continue + + old_entity_id = registry_entry.entity_id + translation_key = "replaced_old_entity" + + async_create_issue( + hass, + DOMAIN, + f"{translation_key}_{old_entity_id}", + breaks_in_ha_version=strategy.breaks_in_ha_version, + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key=translation_key, + translation_placeholders={ + "old_entity_id": old_entity_id, + "replacement_entity_id": strategy.replacement_entity_id, + }, + ) + + if strategy.remove_old_entity: + LOGGER.info('Removing old entity: "%s"', old_entity_id) + ent_reg.async_remove(old_entity_id) + + def key_exists(data: dict[str, Any], search_key: str) -> bool: """Return whether a key exists in a nested dict.""" for key, value in data.items():