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:
parent
9692cdaf2d
commit
48744bfd68
7 changed files with 263 additions and 3 deletions
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
154
homeassistant/components/rainmachine/select.py
Normal file
154
homeassistant/components/rainmachine/select.py
Normal 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]
|
|
@ -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,
|
||||
|
|
|
@ -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}`."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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():
|
||||
|
|
Loading…
Add table
Reference in a new issue