From 13d3f4c3b2ec7f2a40c8316e7bf41d3148582d44 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sat, 17 Sep 2022 15:01:57 -0600 Subject: [PATCH] Replace Guardian `disable_ap` and `enable_ap` services with a switch (#75034) * Starter buttons * Ready to go * Replace Guardian `disable_ap` and `enable_ap` services with a switch * Clean up how actions are stored * Make similar to buttons * Remove service definitions * Docstring * Docstring * flake8 * Add repairs item * Add a repairs issue to notify of removed entity * Add entity replacement strategy * Fix repairs import * Update deprecation version * Remove breaking change * Include future breaking change version * Naming --- homeassistant/components/guardian/__init__.py | 14 +++ .../components/guardian/binary_sensor.py | 22 +++- .../components/guardian/services.yaml | 44 ++++---- .../components/guardian/strings.json | 13 ++- homeassistant/components/guardian/switch.py | 103 ++++++++++++++---- .../components/guardian/translations/en.json | 13 ++- homeassistant/components/guardian/util.py | 61 ++++++++++- 7 files changed, 220 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index a3cc7a0031b..eccf845fcce 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -238,11 +238,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @call_with_data async def async_disable_ap(call: ServiceCall, data: GuardianData) -> None: """Disable the onboard AP.""" + async_log_deprecated_service_call( + hass, + call, + "switch.turn_off", + f"switch.guardian_valve_controller_{entry.data[CONF_UID]}_onboard_ap", + "2022.12.0", + ) await data.client.wifi.disable_ap() @call_with_data async def async_enable_ap(call: ServiceCall, data: GuardianData) -> None: """Enable the onboard AP.""" + async_log_deprecated_service_call( + hass, + call, + "switch.turn_on", + f"switch.guardian_valve_controller_{entry.data[CONF_UID]}_onboard_ap", + "2022.12.0", + ) await data.client.wifi.enable_ap() @call_with_data diff --git a/homeassistant/components/guardian/binary_sensor.py b/homeassistant/components/guardian/binary_sensor.py index 766e5d961e8..6425ecd46a6 100644 --- a/homeassistant/components/guardian/binary_sensor.py +++ b/homeassistant/components/guardian/binary_sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations from dataclasses import dataclass from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, @@ -27,7 +28,11 @@ from .const import ( DOMAIN, SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED, ) -from .util import GuardianDataUpdateCoordinator +from .util import ( + EntityDomainReplacementStrategy, + GuardianDataUpdateCoordinator, + async_finish_entity_domain_replacements, +) ATTR_CONNECTED_CLIENTS = "connected_clients" @@ -79,6 +84,21 @@ async def async_setup_entry( ) -> None: """Set up Guardian switches based on a config entry.""" data: GuardianData = hass.data[DOMAIN][entry.entry_id] + uid = entry.data[CONF_UID] + + async_finish_entity_domain_replacements( + hass, + entry, + ( + EntityDomainReplacementStrategy( + BINARY_SENSOR_DOMAIN, + f"{uid}_ap_enabled", + f"switch.guardian_valve_controller_{uid}_onboard_ap", + "2022.12.0", + remove_old_entity=False, + ), + ), + ) @callback def add_new_paired_sensor(uid: str) -> None: diff --git a/homeassistant/components/guardian/services.yaml b/homeassistant/components/guardian/services.yaml index 61cf709a31c..0707abb6978 100644 --- a/homeassistant/components/guardian/services.yaml +++ b/homeassistant/components/guardian/services.yaml @@ -1,26 +1,4 @@ # Describes the format for available Elexa Guardians services -disable_ap: - name: Disable AP - description: Disable the device's onboard access point. - fields: - device_id: - name: Valve Controller - description: The valve controller whose AP should be disabled - required: true - selector: - device: - integration: guardian -enable_ap: - name: Enable AP - description: Enable the device's onboard access point. - fields: - device_id: - name: Valve Controller - description: The valve controller whose AP should be enabled - required: true - selector: - device: - integration: guardian pair_sensor: name: Pair Sensor description: Add a new paired sensor to the valve controller. @@ -39,6 +17,28 @@ pair_sensor: example: 5410EC688BCF selector: text: +reboot: + name: Reboot + description: Reboot the device. + fields: + device_id: + name: Valve Controller + description: The valve controller to reboot + required: true + selector: + device: + integration: guardian +reset_valve_diagnostics: + name: Reset Valve Diagnostics + description: Fully (and irrecoverably) reset all valve diagnostics. + fields: + device_id: + name: Valve Controller + description: The valve controller whose diagnostics should be reset + required: true + selector: + device: + integration: guardian unpair_sensor: name: Unpair Sensor description: Remove a paired sensor from the valve controller. diff --git a/homeassistant/components/guardian/strings.json b/homeassistant/components/guardian/strings.json index 1665cf9f678..b173051a860 100644 --- a/homeassistant/components/guardian/strings.json +++ b/homeassistant/components/guardian/strings.json @@ -25,7 +25,18 @@ "step": { "confirm": { "title": "The {deprecated_service} service is being removed", - "description": "Update any automations or scripts that use this service to instead use the `{alternate_service}` service with a target entity ID of `{alternate_target}`. Then, click SUBMIT below to mark this issue as resolved." + "description": "Update any automations or scripts that use this service to instead use the `{alternate_service}` service with a target entity ID of `{alternate_target}`." + } + } + } + }, + "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": "This entity has been replaced by `{replacement_entity_id}`." } } } diff --git a/homeassistant/components/guardian/switch.py b/homeassistant/components/guardian/switch.py index 4e100ce4fe4..5471d2471e8 100644 --- a/homeassistant/components/guardian/switch.py +++ b/homeassistant/components/guardian/switch.py @@ -1,41 +1,86 @@ """Switches for the Elexa Guardian integration.""" from __future__ import annotations +from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any +from aioguardian import Client from aioguardian.errors import GuardianError from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import GuardianData, ValveControllerEntity, ValveControllerEntityDescription -from .const import API_VALVE_STATUS, DOMAIN +from .const import API_VALVE_STATUS, API_WIFI_STATUS, DOMAIN ATTR_AVG_CURRENT = "average_current" +ATTR_CONNECTED_CLIENTS = "connected_clients" ATTR_INST_CURRENT = "instantaneous_current" ATTR_INST_CURRENT_DDT = "instantaneous_current_ddt" +ATTR_STATION_CONNECTED = "station_connected" ATTR_TRAVEL_COUNT = "travel_count" +SWITCH_KIND_ONBOARD_AP = "onboard_ap" SWITCH_KIND_VALVE = "valve" +@dataclass +class SwitchDescriptionMixin: + """Define an entity description mixin for Guardian switches.""" + + off_action: Callable[[Client], Awaitable] + on_action: Callable[[Client], Awaitable] + + @dataclass class ValveControllerSwitchDescription( - SwitchEntityDescription, ValveControllerEntityDescription + SwitchEntityDescription, ValveControllerEntityDescription, SwitchDescriptionMixin ): """Describe a Guardian valve controller switch.""" +async def _async_disable_ap(client: Client) -> None: + """Disable the onboard AP.""" + await client.wifi.disable_ap() + + +async def _async_enable_ap(client: Client) -> None: + """Enable the onboard AP.""" + await client.wifi.enable_ap() + + +async def _async_close_valve(client: Client) -> None: + """Close the valve.""" + await client.valve.close() + + +async def _async_open_valve(client: Client) -> None: + """Open the valve.""" + await client.valve.open() + + VALVE_CONTROLLER_DESCRIPTIONS = ( + ValveControllerSwitchDescription( + key=SWITCH_KIND_ONBOARD_AP, + name="Onboard AP", + icon="mdi:wifi", + entity_category=EntityCategory.CONFIG, + api_category=API_WIFI_STATUS, + off_action=_async_disable_ap, + on_action=_async_enable_ap, + ), ValveControllerSwitchDescription( key=SWITCH_KIND_VALVE, name="Valve controller", icon="mdi:water", api_category=API_VALVE_STATUS, + off_action=_async_close_valve, + on_action=_async_open_valve, ), ) @@ -53,9 +98,7 @@ async def async_setup_entry( class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): - """Define a switch to open/close the Guardian valve.""" - - entity_description: ValveControllerSwitchDescription + """Define a switch related to a Guardian valve controller.""" ON_STATES = { "start_opening", @@ -64,6 +107,8 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): "opened", } + entity_description: ValveControllerSwitchDescription + def __init__( self, entry: ConfigEntry, @@ -73,42 +118,54 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity): """Initialize.""" super().__init__(entry, data.valve_controller_coordinators, description) - self._attr_is_on = True self._client = data.client @callback def _async_update_from_latest_data(self) -> None: """Update the entity.""" - self._attr_is_on = self.coordinator.data["state"] in self.ON_STATES - self._attr_extra_state_attributes.update( - { - ATTR_AVG_CURRENT: self.coordinator.data["average_current"], - ATTR_INST_CURRENT: self.coordinator.data["instantaneous_current"], - ATTR_INST_CURRENT_DDT: self.coordinator.data[ - "instantaneous_current_ddt" - ], - ATTR_TRAVEL_COUNT: self.coordinator.data["travel_count"], - } - ) + if self.entity_description.key == SWITCH_KIND_ONBOARD_AP: + self._attr_extra_state_attributes.update( + { + ATTR_CONNECTED_CLIENTS: self.coordinator.data.get("ap_clients"), + ATTR_STATION_CONNECTED: self.coordinator.data["station_connected"], + } + ) + self._attr_is_on = self.coordinator.data["ap_enabled"] + elif self.entity_description.key == SWITCH_KIND_VALVE: + self._attr_is_on = self.coordinator.data["state"] in self.ON_STATES + self._attr_extra_state_attributes.update( + { + ATTR_AVG_CURRENT: self.coordinator.data["average_current"], + ATTR_INST_CURRENT: self.coordinator.data["instantaneous_current"], + ATTR_INST_CURRENT_DDT: self.coordinator.data[ + "instantaneous_current_ddt" + ], + ATTR_TRAVEL_COUNT: self.coordinator.data["travel_count"], + } + ) async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the valve off (closed).""" + """Turn the switch off.""" try: async with self._client: - await self._client.valve.close() + await self.entity_description.off_action(self._client) except GuardianError as err: - raise HomeAssistantError(f"Error while closing the valve: {err}") from err + raise HomeAssistantError( + f'Error while turning "{self.entity_id}" off: {err}' + ) from err self._attr_is_on = False self.async_write_ha_state() async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the valve on (open).""" + """Turn the switch on.""" try: async with self._client: - await self._client.valve.open() + await self.entity_description.on_action(self._client) except GuardianError as err: - raise HomeAssistantError(f"Error while opening the valve: {err}") from err + raise HomeAssistantError( + f'Error while turning "{self.entity_id}" on: {err}' + ) from err self._attr_is_on = True self.async_write_ha_state() diff --git a/homeassistant/components/guardian/translations/en.json b/homeassistant/components/guardian/translations/en.json index ad6d0a4b7dc..99bf2e67b4e 100644 --- a/homeassistant/components/guardian/translations/en.json +++ b/homeassistant/components/guardian/translations/en.json @@ -23,12 +23,23 @@ "fix_flow": { "step": { "confirm": { - "description": "Update any automations or scripts that use this service to instead use the `{alternate_service}` service with a target entity ID of `{alternate_target}`. Then, click SUBMIT below to mark this issue as resolved.", + "description": "Update any automations or scripts that use this service to instead use the `{alternate_service}` service with a target entity ID of `{alternate_target}`.", "title": "The {deprecated_service} service is being removed" } } }, "title": "The {deprecated_service} service is being removed" + }, + "replaced_old_entity": { + "fix_flow": { + "step": { + "confirm": { + "description": "This entity has been replaced by `{replacement_entity_id}`.", + "title": "The {old_entity_id} entity will be removed" + } + } + }, + "title": "The {old_entity_id} entity will be removed" } } } \ No newline at end of file diff --git a/homeassistant/components/guardian/util.py b/homeassistant/components/guardian/util.py index c88d6762e51..9966435e7b0 100644 --- a/homeassistant/components/guardian/util.py +++ b/homeassistant/components/guardian/util.py @@ -2,7 +2,8 @@ from __future__ import annotations import asyncio -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, cast @@ -11,16 +12,72 @@ from aioguardian.errors import GuardianError 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 +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import LOGGER +from .const import DOMAIN, LOGGER DEFAULT_UPDATE_INTERVAL = timedelta(seconds=30) SIGNAL_REBOOT_REQUESTED = "guardian_reboot_requested_{0}" +@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) + + class GuardianDataUpdateCoordinator(DataUpdateCoordinator[dict]): """Define an extended DataUpdateCoordinator with some Guardian goodies."""