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/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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
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 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,
|
||||||
|
|
|
@ -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": {
|
"options": {
|
||||||
"step": {
|
"step": {
|
||||||
"init": {
|
"init": {
|
||||||
|
|
|
@ -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():
|
||||||
|
|
Loading…
Add table
Reference in a new issue