Fix rachio webhook not being unregistered on unload (#73795)

This commit is contained in:
J. Nick Koston 2022-06-22 03:02:02 -05:00 committed by GitHub
parent 504f4a7acf
commit 4bfdc61045
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 89 additions and 55 deletions

View File

@ -17,6 +17,7 @@ from .device import RachioPerson
from .webhooks import ( from .webhooks import (
async_get_or_create_registered_webhook_id_and_url, async_get_or_create_registered_webhook_id_and_url,
async_register_webhook, async_register_webhook,
async_unregister_webhook,
) )
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -28,8 +29,8 @@ CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
if unload_ok: async_unregister_webhook(hass, entry)
hass.data[DOMAIN].pop(entry.entry_id) hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok return unload_ok
@ -59,10 +60,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Get the URL of this server # Get the URL of this server
rachio.webhook_auth = secrets.token_hex() rachio.webhook_auth = secrets.token_hex()
try: try:
( webhook_url = await async_get_or_create_registered_webhook_id_and_url(
webhook_id, hass, entry
webhook_url, )
) = await async_get_or_create_registered_webhook_id_and_url(hass, entry)
except cloud.CloudNotConnected as exc: except cloud.CloudNotConnected as exc:
# User has an active cloud subscription, but the connection to the cloud is down # User has an active cloud subscription, but the connection to the cloud is down
raise ConfigEntryNotReady from exc raise ConfigEntryNotReady from exc
@ -92,9 +92,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
) )
# Enable platform # Enable platform
hass.data.setdefault(DOMAIN, {}) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = person
hass.data[DOMAIN][entry.entry_id] = person async_register_webhook(hass, entry)
async_register_webhook(hass, webhook_id, entry.entry_id)
hass.config_entries.async_setup_platforms(entry, PLATFORMS) hass.config_entries.async_setup_platforms(entry, PLATFORMS)

View File

@ -9,6 +9,7 @@ from homeassistant.components.binary_sensor import (
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.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import ( from .const import (
@ -21,6 +22,7 @@ from .const import (
SIGNAL_RACHIO_RAIN_SENSOR_UPDATE, SIGNAL_RACHIO_RAIN_SENSOR_UPDATE,
STATUS_ONLINE, STATUS_ONLINE,
) )
from .device import RachioPerson
from .entity import RachioDevice from .entity import RachioDevice
from .webhooks import ( from .webhooks import (
SUBTYPE_COLD_REBOOT, SUBTYPE_COLD_REBOOT,
@ -41,12 +43,13 @@ async def async_setup_entry(
"""Set up the Rachio binary sensors.""" """Set up the Rachio binary sensors."""
entities = await hass.async_add_executor_job(_create_entities, hass, config_entry) entities = await hass.async_add_executor_job(_create_entities, hass, config_entry)
async_add_entities(entities) async_add_entities(entities)
_LOGGER.info("%d Rachio binary sensor(s) added", len(entities)) _LOGGER.debug("%d Rachio binary sensor(s) added", len(entities))
def _create_entities(hass, config_entry): def _create_entities(hass: HomeAssistant, config_entry: ConfigEntry) -> list[Entity]:
entities = [] entities: list[Entity] = []
for controller in hass.data[DOMAIN_RACHIO][config_entry.entry_id].controllers: person: RachioPerson = hass.data[DOMAIN_RACHIO][config_entry.entry_id]
for controller in person.controllers:
entities.append(RachioControllerOnlineBinarySensor(controller)) entities.append(RachioControllerOnlineBinarySensor(controller))
entities.append(RachioRainSensor(controller)) entities.append(RachioRainSensor(controller))
return entities return entities

View File

@ -67,3 +67,13 @@ SIGNAL_RACHIO_SCHEDULE_UPDATE = f"{SIGNAL_RACHIO_UPDATE}_schedule"
CONF_WEBHOOK_ID = "webhook_id" CONF_WEBHOOK_ID = "webhook_id"
CONF_CLOUDHOOK_URL = "cloudhook_url" CONF_CLOUDHOOK_URL = "cloudhook_url"
# Webhook callbacks
LISTEN_EVENT_TYPES = [
"DEVICE_STATUS_EVENT",
"ZONE_STATUS_EVENT",
"RAIN_DELAY_EVENT",
"RAIN_SENSOR_DETECTION_EVENT",
"SCHEDULE_STATUS_EVENT",
]
WEBHOOK_CONST_ID = "homeassistant.rachio:"

View File

@ -3,11 +3,14 @@ from __future__ import annotations
from http import HTTPStatus from http import HTTPStatus
import logging import logging
from typing import Any
from rachiopy import Rachio
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import ServiceCall from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
@ -26,12 +29,13 @@ from .const import (
KEY_STATUS, KEY_STATUS,
KEY_USERNAME, KEY_USERNAME,
KEY_ZONES, KEY_ZONES,
LISTEN_EVENT_TYPES,
MODEL_GENERATION_1, MODEL_GENERATION_1,
SERVICE_PAUSE_WATERING, SERVICE_PAUSE_WATERING,
SERVICE_RESUME_WATERING, SERVICE_RESUME_WATERING,
SERVICE_STOP_WATERING, SERVICE_STOP_WATERING,
WEBHOOK_CONST_ID,
) )
from .webhooks import LISTEN_EVENT_TYPES, WEBHOOK_CONST_ID
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -54,16 +58,16 @@ STOP_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_DEVICES): cv.string})
class RachioPerson: class RachioPerson:
"""Represent a Rachio user.""" """Represent a Rachio user."""
def __init__(self, rachio, config_entry): def __init__(self, rachio: Rachio, config_entry: ConfigEntry) -> None:
"""Create an object from the provided API instance.""" """Create an object from the provided API instance."""
# Use API token to get user ID # Use API token to get user ID
self.rachio = rachio self.rachio = rachio
self.config_entry = config_entry self.config_entry = config_entry
self.username = None self.username = None
self._id = None self._id: str | None = None
self._controllers = [] self._controllers: list[RachioIro] = []
async def async_setup(self, hass): async def async_setup(self, hass: HomeAssistant) -> None:
"""Create rachio devices and services.""" """Create rachio devices and services."""
await hass.async_add_executor_job(self._setup, hass) await hass.async_add_executor_job(self._setup, hass)
can_pause = False can_pause = False
@ -121,7 +125,7 @@ class RachioPerson:
schema=RESUME_SERVICE_SCHEMA, schema=RESUME_SERVICE_SCHEMA,
) )
def _setup(self, hass): def _setup(self, hass: HomeAssistant) -> None:
"""Rachio device setup.""" """Rachio device setup."""
rachio = self.rachio rachio = self.rachio
@ -139,7 +143,7 @@ class RachioPerson:
if int(data[0][KEY_STATUS]) != HTTPStatus.OK: if int(data[0][KEY_STATUS]) != HTTPStatus.OK:
raise ConfigEntryNotReady(f"API Error: {data}") raise ConfigEntryNotReady(f"API Error: {data}")
self.username = data[1][KEY_USERNAME] self.username = data[1][KEY_USERNAME]
devices = data[1][KEY_DEVICES] devices: list[dict[str, Any]] = data[1][KEY_DEVICES]
for controller in devices: for controller in devices:
webhooks = rachio.notification.get_device_webhook(controller[KEY_ID])[1] webhooks = rachio.notification.get_device_webhook(controller[KEY_ID])[1]
# The API does not provide a way to tell if a controller is shared # The API does not provide a way to tell if a controller is shared
@ -169,12 +173,12 @@ class RachioPerson:
_LOGGER.info('Using Rachio API as user "%s"', self.username) _LOGGER.info('Using Rachio API as user "%s"', self.username)
@property @property
def user_id(self) -> str: def user_id(self) -> str | None:
"""Get the user ID as defined by the Rachio API.""" """Get the user ID as defined by the Rachio API."""
return self._id return self._id
@property @property
def controllers(self) -> list: def controllers(self) -> list[RachioIro]:
"""Get a list of controllers managed by this account.""" """Get a list of controllers managed by this account."""
return self._controllers return self._controllers
@ -186,7 +190,13 @@ class RachioPerson:
class RachioIro: class RachioIro:
"""Represent a Rachio Iro.""" """Represent a Rachio Iro."""
def __init__(self, hass, rachio, data, webhooks): def __init__(
self,
hass: HomeAssistant,
rachio: Rachio,
data: dict[str, Any],
webhooks: list[dict[str, Any]],
) -> None:
"""Initialize a Rachio device.""" """Initialize a Rachio device."""
self.hass = hass self.hass = hass
self.rachio = rachio self.rachio = rachio
@ -199,10 +209,10 @@ class RachioIro:
self._schedules = data[KEY_SCHEDULES] self._schedules = data[KEY_SCHEDULES]
self._flex_schedules = data[KEY_FLEX_SCHEDULES] self._flex_schedules = data[KEY_FLEX_SCHEDULES]
self._init_data = data self._init_data = data
self._webhooks = webhooks self._webhooks: list[dict[str, Any]] = webhooks
_LOGGER.debug('%s has ID "%s"', self, self.controller_id) _LOGGER.debug('%s has ID "%s"', self, self.controller_id)
def setup(self): def setup(self) -> None:
"""Rachio Iro setup for webhooks.""" """Rachio Iro setup for webhooks."""
# Listen for all updates # Listen for all updates
self._init_webhooks() self._init_webhooks()
@ -226,7 +236,7 @@ class RachioIro:
or webhook[KEY_ID] == current_webhook_id or webhook[KEY_ID] == current_webhook_id
): ):
self.rachio.notification.delete(webhook[KEY_ID]) self.rachio.notification.delete(webhook[KEY_ID])
self._webhooks = None self._webhooks = []
_deinit_webhooks(None) _deinit_webhooks(None)
@ -306,9 +316,6 @@ class RachioIro:
_LOGGER.debug("Resuming watering on %s", self) _LOGGER.debug("Resuming watering on %s", self)
def is_invalid_auth_code(http_status_code): def is_invalid_auth_code(http_status_code: int) -> bool:
"""HTTP status codes that mean invalid auth.""" """HTTP status codes that mean invalid auth."""
if http_status_code in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): return http_status_code in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN)
return True
return False

View File

@ -4,25 +4,19 @@ from homeassistant.helpers import device_registry
from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.entity import DeviceInfo, Entity
from .const import DEFAULT_NAME, DOMAIN from .const import DEFAULT_NAME, DOMAIN
from .device import RachioIro
class RachioDevice(Entity): class RachioDevice(Entity):
"""Base class for rachio devices.""" """Base class for rachio devices."""
def __init__(self, controller): _attr_should_poll = False
def __init__(self, controller: RachioIro) -> None:
"""Initialize a Rachio device.""" """Initialize a Rachio device."""
super().__init__() super().__init__()
self._controller = controller self._controller = controller
self._attr_device_info = DeviceInfo(
@property
def should_poll(self) -> bool:
"""Declare that this entity pushes its state to HA."""
return False
@property
def device_info(self) -> DeviceInfo:
"""Return the device_info of the device."""
return DeviceInfo(
identifiers={ identifiers={
( (
DOMAIN, DOMAIN,

View File

@ -13,6 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.util.dt import as_timestamp, now, parse_datetime, utc_from_timestamp from homeassistant.util.dt import as_timestamp, now, parse_datetime, utc_from_timestamp
@ -52,6 +53,7 @@ from .const import (
SLOPE_SLIGHT, SLOPE_SLIGHT,
SLOPE_STEEP, SLOPE_STEEP,
) )
from .device import RachioPerson
from .entity import RachioDevice from .entity import RachioDevice
from .webhooks import ( from .webhooks import (
SUBTYPE_RAIN_DELAY_OFF, SUBTYPE_RAIN_DELAY_OFF,
@ -106,7 +108,7 @@ async def async_setup_entry(
has_flex_sched = True has_flex_sched = True
async_add_entities(entities) async_add_entities(entities)
_LOGGER.info("%d Rachio switch(es) added", len(entities)) _LOGGER.debug("%d Rachio switch(es) added", len(entities))
def start_multiple(service: ServiceCall) -> None: def start_multiple(service: ServiceCall) -> None:
"""Service to start multiple zones in sequence.""" """Service to start multiple zones in sequence."""
@ -154,9 +156,9 @@ async def async_setup_entry(
) )
def _create_entities(hass, config_entry): def _create_entities(hass: HomeAssistant, config_entry: ConfigEntry) -> list[Entity]:
entities = [] entities: list[Entity] = []
person = hass.data[DOMAIN_RACHIO][config_entry.entry_id] person: RachioPerson = hass.data[DOMAIN_RACHIO][config_entry.entry_id]
# Fetch the schedule once at startup # Fetch the schedule once at startup
# in order to avoid every zone doing it # in order to avoid every zone doing it
for controller in person.controllers: for controller in person.controllers:

View File

@ -1,9 +1,12 @@
"""Webhooks used by rachio.""" """Webhooks used by rachio."""
from __future__ import annotations
from aiohttp import web from aiohttp import web
from homeassistant.components import cloud, webhook from homeassistant.components import cloud, webhook
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import URL_API from homeassistant.const import URL_API
from homeassistant.core import callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import ( from .const import (
@ -18,6 +21,7 @@ from .const import (
SIGNAL_RACHIO_SCHEDULE_UPDATE, SIGNAL_RACHIO_SCHEDULE_UPDATE,
SIGNAL_RACHIO_ZONE_UPDATE, SIGNAL_RACHIO_ZONE_UPDATE,
) )
from .device import RachioPerson
# Device webhook values # Device webhook values
TYPE_CONTROLLER_STATUS = "DEVICE_STATUS" TYPE_CONTROLLER_STATUS = "DEVICE_STATUS"
@ -79,16 +83,22 @@ SIGNAL_MAP = {
@callback @callback
def async_register_webhook(hass, webhook_id, entry_id): def async_register_webhook(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Register a webhook.""" """Register a webhook."""
webhook_id: str = entry.data[CONF_WEBHOOK_ID]
async def _async_handle_rachio_webhook(hass, webhook_id, request): async def _async_handle_rachio_webhook(
hass: HomeAssistant, webhook_id: str, request: web.Request
) -> web.Response:
"""Handle webhook calls from the server.""" """Handle webhook calls from the server."""
person: RachioPerson = hass.data[DOMAIN][entry.entry_id]
data = await request.json() data = await request.json()
try: try:
auth = data.get(KEY_EXTERNAL_ID, "").split(":")[1] assert (
assert auth == hass.data[DOMAIN][entry_id].rachio.webhook_auth data.get(KEY_EXTERNAL_ID, "").split(":")[1]
== person.rachio.webhook_auth
)
except (AssertionError, IndexError): except (AssertionError, IndexError):
return web.Response(status=web.HTTPForbidden.status_code) return web.Response(status=web.HTTPForbidden.status_code)
@ -103,8 +113,17 @@ def async_register_webhook(hass, webhook_id, entry_id):
) )
async def async_get_or_create_registered_webhook_id_and_url(hass, entry): @callback
"""Generate webhook ID.""" def async_unregister_webhook(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Unregister a webhook."""
webhook_id: str = entry.data[CONF_WEBHOOK_ID]
webhook.async_unregister(hass, webhook_id)
async def async_get_or_create_registered_webhook_id_and_url(
hass: HomeAssistant, entry: ConfigEntry
) -> str:
"""Generate webhook url."""
config = entry.data.copy() config = entry.data.copy()
updated_config = False updated_config = False
@ -128,4 +147,4 @@ async def async_get_or_create_registered_webhook_id_and_url(hass, entry):
if updated_config: if updated_config:
hass.config_entries.async_update_entry(entry, data=config) hass.config_entries.async_update_entry(entry, data=config)
return webhook_id, webhook_url return webhook_url