Replace RainMachine freeze protection temperature sensor with a select (#76484)

* Migrate two RainMachine binary sensors to config-category switches

* Removal

* Replace RainMachine freeze protection temperature sensor with a select

* Fix CI

* Show options in current unit system

* Have message include what entity is replacing this sensor

* Don't define a method for every dataclass instance

* Add issue registry through helper

* Breaking change -> deprecation

* Naming

* Translations

* Remove extraneous list

* Don't swallow exception

* Don't be prematurely defensive

* Better Repairs instructions
This commit is contained in:
Aaron Bach 2022-09-22 13:19:33 -06:00 committed by GitHub
parent 9692cdaf2d
commit 48744bfd68
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 263 additions and 3 deletions

View file

@ -1015,6 +1015,7 @@ omit =
homeassistant/components/rainmachine/binary_sensor.py homeassistant/components/rainmachine/binary_sensor.py
homeassistant/components/rainmachine/button.py homeassistant/components/rainmachine/button.py
homeassistant/components/rainmachine/model.py homeassistant/components/rainmachine/model.py
homeassistant/components/rainmachine/select.py
homeassistant/components/rainmachine/sensor.py homeassistant/components/rainmachine/sensor.py
homeassistant/components/rainmachine/switch.py homeassistant/components/rainmachine/switch.py
homeassistant/components/rainmachine/update.py homeassistant/components/rainmachine/update.py

View file

@ -58,6 +58,7 @@ CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
PLATFORMS = [ PLATFORMS = [
Platform.BINARY_SENSOR, Platform.BINARY_SENSOR,
Platform.BUTTON, Platform.BUTTON,
Platform.SELECT,
Platform.SENSOR, Platform.SENSOR,
Platform.SWITCH, Platform.SWITCH,
Platform.UPDATE, Platform.UPDATE,

View file

@ -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]

View file

@ -6,6 +6,7 @@ from datetime import datetime, timedelta
from typing import Any, cast from typing import Any, cast
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
DOMAIN as SENSOR_DOMAIN,
RestoreSensor, RestoreSensor,
SensorDeviceClass, SensorDeviceClass,
SensorEntity, SensorEntity,
@ -32,7 +33,13 @@ from .model import (
RainMachineEntityDescriptionMixinDataKey, RainMachineEntityDescriptionMixinDataKey,
RainMachineEntityDescriptionMixinUid, 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) 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.""" """Set up RainMachine sensors based on a config entry."""
data: RainMachineData = hass.data[DOMAIN][entry.entry_id] 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 = { api_category_sensor_map = {
DATA_PROVISION_SETTINGS: ProvisionSettingsSensor, DATA_PROVISION_SETTINGS: ProvisionSettingsSensor,
DATA_RESTRICTIONS_UNIVERSAL: UniversalRestrictionsSensor, DATA_RESTRICTIONS_UNIVERSAL: UniversalRestrictionsSensor,

View file

@ -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}`."
}
}
}
}
} }
} }

View file

@ -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": { "options": {
"step": { "step": {
"init": { "init": {

View file

@ -1,20 +1,23 @@
"""Define RainMachine utilities.""" """Define RainMachine utilities."""
from __future__ import annotations 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 datetime import timedelta
from typing import Any from typing import Any
from homeassistant.backports.enum import StrEnum from homeassistant.backports.enum import StrEnum
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry
from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.dispatcher import (
async_dispatcher_connect, async_dispatcher_connect,
async_dispatcher_send, async_dispatcher_send,
) )
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator 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_COMPLETED = "rainmachine_reboot_completed_{0}"
SIGNAL_REBOOT_REQUESTED = "rainmachine_reboot_requested_{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: def key_exists(data: dict[str, Any], search_key: str) -> bool:
"""Return whether a key exists in a nested dict.""" """Return whether a key exists in a nested dict."""
for key, value in data.items(): for key, value in data.items():