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
This commit is contained in:
Aaron Bach 2022-09-17 15:01:57 -06:00 committed by GitHub
parent 1f410e884a
commit 13d3f4c3b2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 220 additions and 50 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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,12 +118,20 @@ 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."""
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(
{
@ -92,23 +145,27 @@ class ValveControllerSwitch(ValveControllerEntity, SwitchEntity):
)
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()

View file

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

View file

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