Add strict typing to ring integration (#115276)

This commit is contained in:
Steven B 2024-04-11 09:10:56 +01:00 committed by GitHub
parent 3546ca386f
commit 6954fcc8ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 384 additions and 359 deletions

View File

@ -363,6 +363,7 @@ homeassistant.components.rest_command.*
homeassistant.components.rfxtrx.*
homeassistant.components.rhasspy.*
homeassistant.components.ridwell.*
homeassistant.components.ring.*
homeassistant.components.rituals_perfume_genie.*
homeassistant.components.roku.*
homeassistant.components.romy.*

View File

@ -2,10 +2,12 @@
from __future__ import annotations
from dataclasses import dataclass
from functools import partial
import logging
from typing import Any, cast
from ring_doorbell import Auth, Ring
from ring_doorbell import Auth, Ring, RingDevices
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import APPLICATION_NAME, CONF_TOKEN, __version__
@ -13,23 +15,26 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from .const import (
DOMAIN,
PLATFORMS,
RING_API,
RING_DEVICES,
RING_DEVICES_COORDINATOR,
RING_NOTIFICATIONS_COORDINATOR,
)
from .const import DOMAIN, PLATFORMS
from .coordinator import RingDataCoordinator, RingNotificationsCoordinator
_LOGGER = logging.getLogger(__name__)
@dataclass
class RingData:
"""Class to support type hinting of ring data collection."""
api: Ring
devices: RingDevices
devices_coordinator: RingDataCoordinator
notifications_coordinator: RingNotificationsCoordinator
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
def token_updater(token):
def token_updater(token: dict[str, Any]) -> None:
"""Handle from sync context when token is updated."""
hass.loop.call_soon_threadsafe(
partial(
@ -51,12 +56,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await devices_coordinator.async_config_entry_first_refresh()
await notifications_coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
RING_API: ring,
RING_DEVICES: ring.devices(),
RING_DEVICES_COORDINATOR: devices_coordinator,
RING_NOTIFICATIONS_COORDINATOR: notifications_coordinator,
}
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = RingData(
api=ring,
devices=ring.devices(),
devices_coordinator=devices_coordinator,
notifications_coordinator=notifications_coordinator,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@ -83,8 +88,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
for info in hass.data[DOMAIN].values():
await info[RING_DEVICES_COORDINATOR].async_refresh()
await info[RING_NOTIFICATIONS_COORDINATOR].async_refresh()
ring_data = cast(RingData, info)
await ring_data.devices_coordinator.async_refresh()
await ring_data.notifications_coordinator.async_refresh()
# register service
hass.services.async_register(DOMAIN, "update", async_refresh_all)
@ -121,8 +127,9 @@ async def _migrate_old_unique_ids(hass: HomeAssistant, entry_id: str) -> None:
@callback
def _async_migrator(entity_entry: er.RegistryEntry) -> dict[str, str] | None:
# Old format for camera and light was int
if isinstance(entity_entry.unique_id, int):
new_unique_id = str(entity_entry.unique_id)
unique_id = cast(str | int, entity_entry.unique_id)
if isinstance(unique_id, int):
new_unique_id = str(unique_id)
if existing_entity_id := entity_registry.async_get_entity_id(
entity_entry.domain, entity_entry.platform, new_unique_id
):

View File

@ -2,10 +2,13 @@
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from ring_doorbell import Ring, RingEvent, RingGeneric
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
@ -15,29 +18,32 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, RING_API, RING_DEVICES, RING_NOTIFICATIONS_COORDINATOR
from . import RingData
from .const import DOMAIN
from .coordinator import RingNotificationsCoordinator
from .entity import RingEntity
from .entity import RingBaseEntity
@dataclass(frozen=True, kw_only=True)
class RingBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describes Ring binary sensor entity."""
category: list[str]
exists_fn: Callable[[RingGeneric], bool]
BINARY_SENSOR_TYPES: tuple[RingBinarySensorEntityDescription, ...] = (
RingBinarySensorEntityDescription(
key="ding",
translation_key="ding",
category=["doorbots", "authorized_doorbots", "other"],
device_class=BinarySensorDeviceClass.OCCUPANCY,
exists_fn=lambda device: device.family
in {"doorbots", "authorized_doorbots", "other"},
),
RingBinarySensorEntityDescription(
key="motion",
category=["doorbots", "authorized_doorbots", "stickup_cams"],
device_class=BinarySensorDeviceClass.MOTION,
exists_fn=lambda device: device.family
in {"doorbots", "authorized_doorbots", "stickup_cams"},
),
)
@ -48,34 +54,36 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Ring binary sensors from a config entry."""
ring = hass.data[DOMAIN][config_entry.entry_id][RING_API]
devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES]
notifications_coordinator: RingNotificationsCoordinator = hass.data[DOMAIN][
config_entry.entry_id
][RING_NOTIFICATIONS_COORDINATOR]
ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id]
entities = [
RingBinarySensor(ring, device, notifications_coordinator, description)
for device_type in ("doorbots", "authorized_doorbots", "stickup_cams", "other")
RingBinarySensor(
ring_data.api,
device,
ring_data.notifications_coordinator,
description,
)
for description in BINARY_SENSOR_TYPES
if device_type in description.category
for device in devices[device_type]
for device in ring_data.devices.all_devices
if description.exists_fn(device)
]
async_add_entities(entities)
class RingBinarySensor(RingEntity, BinarySensorEntity):
class RingBinarySensor(
RingBaseEntity[RingNotificationsCoordinator], BinarySensorEntity
):
"""A binary sensor implementation for Ring device."""
_active_alert: dict[str, Any] | None = None
_active_alert: RingEvent | None = None
entity_description: RingBinarySensorEntityDescription
def __init__(
self,
ring,
device,
coordinator,
ring: Ring,
device: RingGeneric,
coordinator: RingNotificationsCoordinator,
description: RingBinarySensorEntityDescription,
) -> None:
"""Initialize a sensor for Ring device."""
@ -89,13 +97,13 @@ class RingBinarySensor(RingEntity, BinarySensorEntity):
self._update_alert()
@callback
def _handle_coordinator_update(self, _=None):
def _handle_coordinator_update(self, _: Any = None) -> None:
"""Call update method."""
self._update_alert()
super()._handle_coordinator_update()
@callback
def _update_alert(self):
def _update_alert(self) -> None:
"""Update active alert."""
self._active_alert = next(
(
@ -108,21 +116,23 @@ class RingBinarySensor(RingEntity, BinarySensorEntity):
)
@property
def is_on(self):
def is_on(self) -> bool:
"""Return True if the binary sensor is on."""
return self._active_alert is not None
@property
def extra_state_attributes(self):
def extra_state_attributes(self) -> Mapping[str, Any] | None:
"""Return the state attributes."""
attrs = super().extra_state_attributes
if self._active_alert is None:
return attrs
assert isinstance(attrs, dict)
attrs["state"] = self._active_alert["state"]
attrs["expires_at"] = datetime.fromtimestamp(
self._active_alert.get("now") + self._active_alert.get("expires_in")
).isoformat()
now = self._active_alert.get("now")
expires_in = self._active_alert.get("expires_in")
assert now and expires_in
attrs["expires_at"] = datetime.fromtimestamp(now + expires_in).isoformat()
return attrs

View File

@ -2,12 +2,15 @@
from __future__ import annotations
from ring_doorbell import RingOther
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR
from . import RingData
from .const import DOMAIN
from .coordinator import RingDataCoordinator
from .entity import RingEntity, exception_wrap
@ -22,14 +25,12 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Create the buttons for the Ring devices."""
devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES]
devices_coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][
RING_DEVICES_COORDINATOR
]
ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id]
devices_coordinator = ring_data.devices_coordinator
async_add_entities(
RingDoorButton(device, devices_coordinator, BUTTON_DESCRIPTION)
for device in devices["other"]
for device in ring_data.devices.other
if device.has_capability("open")
)
@ -37,10 +38,12 @@ async def async_setup_entry(
class RingDoorButton(RingEntity, ButtonEntity):
"""Creates a button to open the ring intercom door."""
_device: RingOther
def __init__(
self,
device,
coordinator,
device: RingOther,
coordinator: RingDataCoordinator,
description: ButtonEntityDescription,
) -> None:
"""Initialize the button."""
@ -52,6 +55,6 @@ class RingDoorButton(RingEntity, ButtonEntity):
self._attr_unique_id = f"{device.id}-{description.key}"
@exception_wrap
def press(self):
def press(self) -> None:
"""Open the door."""
self._device.open_door()

View File

@ -3,11 +3,12 @@
from __future__ import annotations
from datetime import timedelta
from itertools import chain
import logging
from typing import Optional
from typing import Any
from aiohttp import web
from haffmpeg.camera import CameraMjpeg
from ring_doorbell import RingDoorBell
from homeassistant.components import ffmpeg
from homeassistant.components.camera import Camera
@ -17,7 +18,8 @@ from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR
from . import RingData
from .const import DOMAIN
from .coordinator import RingDataCoordinator
from .entity import RingEntity, exception_wrap
@ -33,20 +35,15 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up a Ring Door Bell and StickUp Camera."""
devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES]
devices_coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][
RING_DEVICES_COORDINATOR
]
ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id]
devices_coordinator = ring_data.devices_coordinator
ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass)
cams = []
for camera in chain(
devices["doorbots"], devices["authorized_doorbots"], devices["stickup_cams"]
):
if not camera.has_subscription:
continue
cams.append(RingCam(camera, devices_coordinator, ffmpeg_manager))
cams = [
RingCam(camera, devices_coordinator, ffmpeg_manager)
for camera in ring_data.devices.video_devices
if camera.has_subscription
]
async_add_entities(cams)
@ -55,28 +52,34 @@ class RingCam(RingEntity, Camera):
"""An implementation of a Ring Door Bell camera."""
_attr_name = None
_device: RingDoorBell
def __init__(self, device, coordinator, ffmpeg_manager):
def __init__(
self,
device: RingDoorBell,
coordinator: RingDataCoordinator,
ffmpeg_manager: ffmpeg.FFmpegManager,
) -> None:
"""Initialize a Ring Door Bell camera."""
super().__init__(device, coordinator)
Camera.__init__(self)
self._ffmpeg_manager = ffmpeg_manager
self._last_event = None
self._last_video_id = None
self._video_url = None
self._image = None
self._last_event: dict[str, Any] | None = None
self._last_video_id: int | None = None
self._video_url: str | None = None
self._image: bytes | None = None
self._expires_at = dt_util.utcnow() - FORCE_REFRESH_INTERVAL
self._attr_unique_id = str(device.id)
if device.has_capability(MOTION_DETECTION_CAPABILITY):
self._attr_motion_detection_enabled = device.motion_detection
@callback
def _handle_coordinator_update(self):
def _handle_coordinator_update(self) -> None:
"""Call update method."""
history_data: Optional[list]
if not (history_data := self._get_coordinator_history()):
return
self._device = self._get_coordinator_data().get_video_device(
self._device.device_api_id
)
history_data = self._device.last_history
if history_data:
self._last_event = history_data[0]
self.async_schedule_update_ha_state(True)
@ -89,7 +92,7 @@ class RingCam(RingEntity, Camera):
self.async_write_ha_state()
@property
def extra_state_attributes(self):
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
return {
"video_url": self._video_url,
@ -100,7 +103,7 @@ class RingCam(RingEntity, Camera):
self, width: int | None = None, height: int | None = None
) -> bytes | None:
"""Return a still image response from the camera."""
if self._image is None and self._video_url:
if self._image is None and self._video_url is not None:
image = await ffmpeg.async_get_image(
self.hass,
self._video_url,
@ -113,10 +116,12 @@ class RingCam(RingEntity, Camera):
return self._image
async def handle_async_mjpeg_stream(self, request):
async def handle_async_mjpeg_stream(
self, request: web.Request
) -> web.StreamResponse | None:
"""Generate an HTTP MJPEG stream from the camera."""
if self._video_url is None:
return
return None
stream = CameraMjpeg(self._ffmpeg_manager.binary)
await stream.open_camera(self._video_url)
@ -132,7 +137,7 @@ class RingCam(RingEntity, Camera):
finally:
await stream.close()
async def async_update(self):
async def async_update(self) -> None:
"""Update camera entity and refresh attributes."""
if (
self._device.has_capability(MOTION_DETECTION_CAPABILITY)
@ -160,11 +165,14 @@ class RingCam(RingEntity, Camera):
self._expires_at = FORCE_REFRESH_INTERVAL + utcnow
@exception_wrap
def _get_video(self):
return self._device.recording_url(self._last_event["id"])
def _get_video(self) -> str | None:
if self._last_event is None:
return None
assert (event_id := self._last_event.get("id")) and isinstance(event_id, int)
return self._device.recording_url(event_id)
@exception_wrap
def _set_motion_detection_enabled(self, new_state):
def _set_motion_detection_enabled(self, new_state: bool) -> None:
if not self._device.has_capability(MOTION_DETECTION_CAPABILITY):
_LOGGER.error(
"Entity %s does not have motion detection capability", self.entity_id

View File

@ -28,7 +28,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str})
async def validate_input(hass: HomeAssistant, data):
async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, Any]:
"""Validate the user input allows us to connect."""
auth = Auth(f"{APPLICATION_NAME}/{ha_version}")
@ -56,9 +56,11 @@ class RingConfigFlow(ConfigFlow, domain=DOMAIN):
user_pass: dict[str, Any] = {}
reauth_entry: ConfigEntry | None = None
async def async_step_user(self, user_input=None):
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors = {}
errors: dict[str, str] = {}
if user_input is not None:
try:
token = await validate_input(self.hass, user_input)
@ -82,7 +84,9 @@ class RingConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
async def async_step_2fa(self, user_input=None):
async def async_step_2fa(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle 2fa step."""
if user_input:
if self.reauth_entry:
@ -110,7 +114,7 @@ class RingConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
errors = {}
errors: dict[str, str] = {}
assert self.reauth_entry is not None
if user_input:

View File

@ -28,10 +28,4 @@ PLATFORMS = [
SCAN_INTERVAL = timedelta(minutes=1)
NOTIFICATIONS_SCAN_INTERVAL = timedelta(seconds=5)
RING_API = "api"
RING_DEVICES = "devices"
RING_DEVICES_COORDINATOR = "device_data"
RING_NOTIFICATIONS_COORDINATOR = "dings_data"
CONF_2FA = "2fa"

View File

@ -2,11 +2,10 @@
from asyncio import TaskGroup
from collections.abc import Callable
from dataclasses import dataclass
import logging
from typing import Any, Optional
from typing import TypeVar, TypeVarTuple
from ring_doorbell import AuthenticationError, Ring, RingError, RingGeneric, RingTimeout
from ring_doorbell import AuthenticationError, Ring, RingDevices, RingError, RingTimeout
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
@ -16,10 +15,13 @@ from .const import NOTIFICATIONS_SCAN_INTERVAL, SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
_R = TypeVar("_R")
_Ts = TypeVarTuple("_Ts")
async def _call_api(
hass: HomeAssistant, target: Callable[..., Any], *args, msg_suffix: str = ""
):
hass: HomeAssistant, target: Callable[[*_Ts], _R], *args: *_Ts, msg_suffix: str = ""
) -> _R:
try:
return await hass.async_add_executor_job(target, *args)
except AuthenticationError as err:
@ -34,15 +36,7 @@ async def _call_api(
raise UpdateFailed(f"Error communicating with API{msg_suffix}: {err}") from err
@dataclass
class RingDeviceData:
"""RingDeviceData."""
device: RingGeneric
history: Optional[list] = None
class RingDataCoordinator(DataUpdateCoordinator[dict[int, RingDeviceData]]):
class RingDataCoordinator(DataUpdateCoordinator[RingDevices]):
"""Base class for device coordinators."""
def __init__(
@ -60,45 +54,39 @@ class RingDataCoordinator(DataUpdateCoordinator[dict[int, RingDeviceData]]):
self.ring_api: Ring = ring_api
self.first_call: bool = True
async def _async_update_data(self):
async def _async_update_data(self) -> RingDevices:
"""Fetch data from API endpoint."""
update_method: str = "update_data" if self.first_call else "update_devices"
await _call_api(self.hass, getattr(self.ring_api, update_method))
self.first_call = False
data: dict[str, RingDeviceData] = {}
devices: dict[str : list[RingGeneric]] = self.ring_api.devices()
devices: RingDevices = self.ring_api.devices()
subscribed_device_ids = set(self.async_contexts())
for device_type in devices:
for device in devices[device_type]:
# Don't update all devices in the ring api, only those that set
# their device id as context when they subscribed.
if device.id in subscribed_device_ids:
data[device.id] = RingDeviceData(device=device)
try:
history_task = None
async with TaskGroup() as tg:
if device.has_capability("history"):
history_task = tg.create_task(
_call_api(
self.hass,
lambda device: device.history(limit=10),
device,
msg_suffix=f" for device {device.name}", # device_id is the mac
)
)
for device in devices.all_devices:
# Don't update all devices in the ring api, only those that set
# their device id as context when they subscribed.
if device.id in subscribed_device_ids:
try:
async with TaskGroup() as tg:
if device.has_capability("history"):
tg.create_task(
_call_api(
self.hass,
device.update_health_data,
msg_suffix=f" for device {device.name}",
lambda device: device.history(limit=10),
device,
msg_suffix=f" for device {device.name}", # device_id is the mac
)
)
if history_task:
data[device.id].history = history_task.result()
except ExceptionGroup as eg:
raise eg.exceptions[0] # noqa: B904
tg.create_task(
_call_api(
self.hass,
device.update_health_data,
msg_suffix=f" for device {device.name}",
)
)
except ExceptionGroup as eg:
raise eg.exceptions[0] # noqa: B904
return data
return devices
class RingNotificationsCoordinator(DataUpdateCoordinator[None]):
@ -114,6 +102,6 @@ class RingNotificationsCoordinator(DataUpdateCoordinator[None]):
)
self.ring_api: Ring = ring_api
async def _async_update_data(self):
async def _async_update_data(self) -> None:
"""Fetch data from API endpoint."""
await _call_api(self.hass, self.ring_api.update_dings)

View File

@ -4,12 +4,11 @@ from __future__ import annotations
from typing import Any
from ring_doorbell import Ring
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from . import RingData
from .const import DOMAIN
TO_REDACT = {
@ -33,11 +32,12 @@ async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
ring: Ring = hass.data[DOMAIN][entry.entry_id]["api"]
ring_data: RingData = hass.data[DOMAIN][entry.entry_id]
devices_data = ring_data.api.devices_data
devices_raw = [
ring.devices_data[device_type][device_id]
for device_type in ring.devices_data
for device_id in ring.devices_data[device_type]
devices_data[device_type][device_id]
for device_type in devices_data
for device_id in devices_data[device_type]
]
return async_redact_data(
{"device_data": devices_raw},

View File

@ -3,7 +3,13 @@
from collections.abc import Callable
from typing import Any, Concatenate, ParamSpec, TypeVar
from ring_doorbell import AuthenticationError, RingError, RingGeneric, RingTimeout
from ring_doorbell import (
AuthenticationError,
RingDevices,
RingError,
RingGeneric,
RingTimeout,
)
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
@ -11,26 +17,23 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ATTRIBUTION, DOMAIN
from .coordinator import (
RingDataCoordinator,
RingDeviceData,
RingNotificationsCoordinator,
)
from .coordinator import RingDataCoordinator, RingNotificationsCoordinator
_RingCoordinatorT = TypeVar(
"_RingCoordinatorT",
bound=(RingDataCoordinator | RingNotificationsCoordinator),
)
_T = TypeVar("_T", bound="RingEntity")
_RingBaseEntityT = TypeVar("_RingBaseEntityT", bound="RingBaseEntity[Any]")
_R = TypeVar("_R")
_P = ParamSpec("_P")
def exception_wrap(
func: Callable[Concatenate[_T, _P], Any],
) -> Callable[Concatenate[_T, _P], Any]:
func: Callable[Concatenate[_RingBaseEntityT, _P], _R],
) -> Callable[Concatenate[_RingBaseEntityT, _P], _R]:
"""Define a wrapper to catch exceptions and raise HomeAssistant errors."""
def _wrap(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None:
def _wrap(self: _RingBaseEntityT, *args: _P.args, **kwargs: _P.kwargs) -> _R:
try:
return func(self, *args, **kwargs)
except AuthenticationError as err:
@ -50,7 +53,7 @@ def exception_wrap(
return _wrap
class RingEntity(CoordinatorEntity[_RingCoordinatorT]):
class RingBaseEntity(CoordinatorEntity[_RingCoordinatorT]):
"""Base implementation for Ring device."""
_attr_attribution = ATTRIBUTION
@ -73,29 +76,16 @@ class RingEntity(CoordinatorEntity[_RingCoordinatorT]):
name=device.name,
)
def _get_coordinator_device_data(self) -> RingDeviceData | None:
if (data := self.coordinator.data) and (
device_data := data.get(self._device.id)
):
return device_data
return None
def _get_coordinator_device(self) -> RingGeneric | None:
if (device_data := self._get_coordinator_device_data()) and (
device := device_data.device
):
return device
return None
class RingEntity(RingBaseEntity[RingDataCoordinator]):
"""Implementation for Ring devices."""
def _get_coordinator_history(self) -> list | None:
if (device_data := self._get_coordinator_device_data()) and (
history := device_data.history
):
return history
return None
def _get_coordinator_data(self) -> RingDevices:
return self.coordinator.data
@callback
def _handle_coordinator_update(self) -> None:
if device := self._get_coordinator_device():
self._device = device
self._device = self._get_coordinator_data().get_device(
self._device.device_api_id
)
super()._handle_coordinator_update()

View File

@ -1,6 +1,7 @@
"""Component providing HA switch support for Ring Door Bell/Chimes."""
from datetime import timedelta
from enum import StrEnum, auto
import logging
from typing import Any
@ -12,7 +13,8 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
import homeassistant.util.dt as dt_util
from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR
from . import RingData
from .const import DOMAIN
from .coordinator import RingDataCoordinator
from .entity import RingEntity, exception_wrap
@ -26,8 +28,12 @@ _LOGGER = logging.getLogger(__name__)
SKIP_UPDATES_DELAY = timedelta(seconds=5)
ON_STATE = "on"
OFF_STATE = "off"
class OnOffState(StrEnum):
"""Enum for allowed on off states."""
ON = auto()
OFF = auto()
async def async_setup_entry(
@ -36,14 +42,12 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Create the lights for the Ring devices."""
devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES]
devices_coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][
RING_DEVICES_COORDINATOR
]
ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id]
devices_coordinator = ring_data.devices_coordinator
async_add_entities(
RingLight(device, devices_coordinator)
for device in devices["stickup_cams"]
for device in ring_data.devices.stickup_cams
if device.has_capability("light")
)
@ -55,37 +59,41 @@ class RingLight(RingEntity, LightEntity):
_attr_supported_color_modes = {ColorMode.ONOFF}
_attr_translation_key = "light"
def __init__(self, device, coordinator):
_device: RingStickUpCam
def __init__(
self, device: RingStickUpCam, coordinator: RingDataCoordinator
) -> None:
"""Initialize the light."""
super().__init__(device, coordinator)
self._attr_unique_id = str(device.id)
self._attr_is_on = device.lights == ON_STATE
self._attr_is_on = device.lights == OnOffState.ON
self._no_updates_until = dt_util.utcnow()
@callback
def _handle_coordinator_update(self):
def _handle_coordinator_update(self) -> None:
"""Call update method."""
if self._no_updates_until > dt_util.utcnow():
return
if (device := self._get_coordinator_device()) and isinstance(
device, RingStickUpCam
):
self._attr_is_on = device.lights == ON_STATE
device = self._get_coordinator_data().get_stickup_cam(
self._device.device_api_id
)
self._attr_is_on = device.lights == OnOffState.ON
super()._handle_coordinator_update()
@exception_wrap
def _set_light(self, new_state):
def _set_light(self, new_state: OnOffState) -> None:
"""Update light state, and causes Home Assistant to correctly update."""
self._device.lights = new_state
self._attr_is_on = new_state == ON_STATE
self._attr_is_on = new_state == OnOffState.ON
self._no_updates_until = dt_util.utcnow() + SKIP_UPDATES_DELAY
self.schedule_update_ha_state()
def turn_on(self, **kwargs: Any) -> None:
"""Turn the light on for 30 seconds."""
self._set_light(ON_STATE)
self._set_light(OnOffState.ON)
def turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
self._set_light(OFF_STATE)
self._set_light(OnOffState.OFF)

View File

@ -2,10 +2,19 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from typing import Any, Generic, cast
from ring_doorbell import RingGeneric
from ring_doorbell import (
RingCapability,
RingChime,
RingDoorBell,
RingEventKind,
RingGeneric,
RingOther,
)
from typing_extensions import TypeVar
from homeassistant.components.sensor import (
SensorDeviceClass,
@ -21,11 +30,15 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR
from . import RingData
from .const import DOMAIN
from .coordinator import RingDataCoordinator
from .entity import RingEntity
_RingDeviceT = TypeVar("_RingDeviceT", bound=RingGeneric, default=RingGeneric)
async def async_setup_entry(
hass: HomeAssistant,
@ -33,209 +46,193 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up a sensor for a Ring device."""
devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES]
devices_coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][
RING_DEVICES_COORDINATOR
]
ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id]
devices_coordinator = ring_data.devices_coordinator
entities = [
description.cls(device, devices_coordinator, description)
for device_type in (
"chimes",
"doorbots",
"authorized_doorbots",
"stickup_cams",
"other",
)
RingSensor(device, devices_coordinator, description)
for description in SENSOR_TYPES
if device_type in description.category
for device in devices[device_type]
if not (device_type == "battery" and device.battery_life is None)
for device in ring_data.devices.all_devices
if description.exists_fn(device)
]
async_add_entities(entities)
class RingSensor(RingEntity, SensorEntity):
class RingSensor(RingEntity, SensorEntity, Generic[_RingDeviceT]):
"""A sensor implementation for Ring device."""
entity_description: RingSensorEntityDescription
entity_description: RingSensorEntityDescription[_RingDeviceT]
_device: _RingDeviceT
def __init__(
self,
device: RingGeneric,
coordinator: RingDataCoordinator,
description: RingSensorEntityDescription,
description: RingSensorEntityDescription[_RingDeviceT],
) -> None:
"""Initialize a sensor for Ring device."""
super().__init__(device, coordinator)
self.entity_description = description
self._attr_unique_id = f"{device.id}-{description.key}"
@property
def native_value(self):
"""Return the state of the sensor."""
sensor_type = self.entity_description.key
if sensor_type == "volume":
return self._device.volume
if sensor_type == "doorbell_volume":
return self._device.doorbell_volume
if sensor_type == "mic_volume":
return self._device.mic_volume
if sensor_type == "voice_volume":
return self._device.voice_volume
if sensor_type == "battery":
return self._device.battery_life
class HealthDataRingSensor(RingSensor):
"""Ring sensor that relies on health data."""
# These sensors are data hungry and not useful. Disable by default.
_attr_entity_registry_enabled_default = False
@property
def native_value(self):
"""Return the state of the sensor."""
sensor_type = self.entity_description.key
if sensor_type == "wifi_signal_category":
return self._device.wifi_signal_category
if sensor_type == "wifi_signal_strength":
return self._device.wifi_signal_strength
class HistoryRingSensor(RingSensor):
"""Ring sensor that relies on history data."""
_latest_event: dict[str, Any] | None = None
self._attr_entity_registry_enabled_default = (
description.entity_registry_enabled_default
)
self._attr_native_value = self.entity_description.value_fn(self._device)
@callback
def _handle_coordinator_update(self):
def _handle_coordinator_update(self) -> None:
"""Call update method."""
if not (history_data := self._get_coordinator_history()):
return
kind = self.entity_description.kind
found = None
if kind is None:
found = history_data[0]
else:
for entry in history_data:
if entry["kind"] == kind:
found = entry
break
if not found:
return
self._latest_event = found
self._device = cast(
_RingDeviceT,
self._get_coordinator_data().get_device(self._device.device_api_id),
)
# History values can drop off the last 10 events so only update
# the value if it's not None
if native_value := self.entity_description.value_fn(self._device):
self._attr_native_value = native_value
if extra_attrs := self.entity_description.extra_state_attributes_fn(
self._device
):
self._attr_extra_state_attributes = extra_attrs
super()._handle_coordinator_update()
@property
def native_value(self):
"""Return the state of the sensor."""
if self._latest_event is None:
return None
return self._latest_event["created_at"]
def _get_last_event(
history_data: list[dict[str, Any]], kind: RingEventKind | None
) -> dict[str, Any] | None:
if not history_data:
return None
if kind is None:
return history_data[0]
for entry in history_data:
if entry["kind"] == kind.value:
return entry
return None
@property
def extra_state_attributes(self):
"""Return the state attributes."""
attrs = super().extra_state_attributes
if self._latest_event:
attrs["created_at"] = self._latest_event["created_at"]
attrs["answered"] = self._latest_event["answered"]
attrs["recording_status"] = self._latest_event["recording"]["status"]
attrs["category"] = self._latest_event["kind"]
return attrs
def _get_last_event_attrs(
history_data: list[dict[str, Any]], kind: RingEventKind | None
) -> dict[str, Any] | None:
if last_event := _get_last_event(history_data, kind):
return {
"created_at": last_event.get("created_at"),
"answered": last_event.get("answered"),
"recording_status": last_event.get("recording", {}).get("status"),
"category": last_event.get("kind"),
}
return None
@dataclass(frozen=True, kw_only=True)
class RingSensorEntityDescription(SensorEntityDescription):
class RingSensorEntityDescription(SensorEntityDescription, Generic[_RingDeviceT]):
"""Describes Ring sensor entity."""
category: list[str]
cls: type[RingSensor]
kind: str | None = None
value_fn: Callable[[_RingDeviceT], StateType] = lambda _: True
exists_fn: Callable[[RingGeneric], bool] = lambda _: True
extra_state_attributes_fn: Callable[[_RingDeviceT], dict[str, Any] | None] = (
lambda _: None
)
SENSOR_TYPES: tuple[RingSensorEntityDescription, ...] = (
RingSensorEntityDescription(
# For some reason mypy doesn't properly type check the default TypeVar value here
# so for now the [RingGeneric] subscript needs to be specified.
# Once https://github.com/python/mypy/issues/14851 is closed this should hopefully
# be fixed and the [RingGeneric] subscript can be removed.
# https://github.com/home-assistant/core/pull/115276#discussion_r1560106576
SENSOR_TYPES: tuple[RingSensorEntityDescription[Any], ...] = (
RingSensorEntityDescription[RingGeneric](
key="battery",
category=["doorbots", "authorized_doorbots", "stickup_cams", "other"],
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
cls=RingSensor,
value_fn=lambda device: device.battery_life,
exists_fn=lambda device: device.family != "chimes",
),
RingSensorEntityDescription(
RingSensorEntityDescription[RingGeneric](
key="last_activity",
translation_key="last_activity",
category=["doorbots", "authorized_doorbots", "stickup_cams", "other"],
device_class=SensorDeviceClass.TIMESTAMP,
cls=HistoryRingSensor,
value_fn=lambda device: last_event.get("created_at")
if (last_event := _get_last_event(device.last_history, None))
else None,
extra_state_attributes_fn=lambda device: last_event_attrs
if (last_event_attrs := _get_last_event_attrs(device.last_history, None))
else None,
exists_fn=lambda device: device.has_capability(RingCapability.HISTORY),
),
RingSensorEntityDescription(
RingSensorEntityDescription[RingGeneric](
key="last_ding",
translation_key="last_ding",
category=["doorbots", "authorized_doorbots", "other"],
kind="ding",
device_class=SensorDeviceClass.TIMESTAMP,
cls=HistoryRingSensor,
value_fn=lambda device: last_event.get("created_at")
if (last_event := _get_last_event(device.last_history, RingEventKind.DING))
else None,
extra_state_attributes_fn=lambda device: last_event_attrs
if (
last_event_attrs := _get_last_event_attrs(
device.last_history, RingEventKind.DING
)
)
else None,
exists_fn=lambda device: device.has_capability(RingCapability.HISTORY),
),
RingSensorEntityDescription(
RingSensorEntityDescription[RingGeneric](
key="last_motion",
translation_key="last_motion",
category=["doorbots", "authorized_doorbots", "stickup_cams"],
kind="motion",
device_class=SensorDeviceClass.TIMESTAMP,
cls=HistoryRingSensor,
value_fn=lambda device: last_event.get("created_at")
if (last_event := _get_last_event(device.last_history, RingEventKind.MOTION))
else None,
extra_state_attributes_fn=lambda device: last_event_attrs
if (
last_event_attrs := _get_last_event_attrs(
device.last_history, RingEventKind.MOTION
)
)
else None,
exists_fn=lambda device: device.has_capability(RingCapability.HISTORY),
),
RingSensorEntityDescription(
RingSensorEntityDescription[RingDoorBell | RingChime](
key="volume",
translation_key="volume",
category=["chimes", "doorbots", "authorized_doorbots", "stickup_cams"],
cls=RingSensor,
value_fn=lambda device: device.volume,
exists_fn=lambda device: isinstance(device, (RingDoorBell, RingChime)),
),
RingSensorEntityDescription(
RingSensorEntityDescription[RingOther](
key="doorbell_volume",
translation_key="doorbell_volume",
category=["other"],
cls=RingSensor,
value_fn=lambda device: device.doorbell_volume,
exists_fn=lambda device: isinstance(device, RingOther),
),
RingSensorEntityDescription(
RingSensorEntityDescription[RingOther](
key="mic_volume",
translation_key="mic_volume",
category=["other"],
cls=RingSensor,
value_fn=lambda device: device.mic_volume,
exists_fn=lambda device: isinstance(device, RingOther),
),
RingSensorEntityDescription(
RingSensorEntityDescription[RingOther](
key="voice_volume",
translation_key="voice_volume",
category=["other"],
cls=RingSensor,
value_fn=lambda device: device.voice_volume,
exists_fn=lambda device: isinstance(device, RingOther),
),
RingSensorEntityDescription(
RingSensorEntityDescription[RingGeneric](
key="wifi_signal_category",
translation_key="wifi_signal_category",
category=["chimes", "doorbots", "authorized_doorbots", "stickup_cams", "other"],
entity_category=EntityCategory.DIAGNOSTIC,
cls=HealthDataRingSensor,
entity_registry_enabled_default=False,
value_fn=lambda device: device.wifi_signal_category,
),
RingSensorEntityDescription(
RingSensorEntityDescription[RingGeneric](
key="wifi_signal_strength",
translation_key="wifi_signal_strength",
category=["chimes", "doorbots", "authorized_doorbots", "stickup_cams", "other"],
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
entity_category=EntityCategory.DIAGNOSTIC,
cls=HealthDataRingSensor,
entity_registry_enabled_default=False,
value_fn=lambda device: device.wifi_signal_strength,
),
)

View File

@ -1,15 +1,17 @@
"""Component providing HA Siren support for Ring Chimes."""
import logging
from typing import Any
from ring_doorbell.const import CHIME_TEST_SOUND_KINDS, KIND_DING
from ring_doorbell import RingChime, RingEventKind
from homeassistant.components.siren import ATTR_TONE, SirenEntity, SirenEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR
from . import RingData
from .const import DOMAIN
from .coordinator import RingDataCoordinator
from .entity import RingEntity, exception_wrap
@ -22,32 +24,33 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Create the sirens for the Ring devices."""
devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES]
coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][
RING_DEVICES_COORDINATOR
]
ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id]
devices_coordinator = ring_data.devices_coordinator
async_add_entities(
RingChimeSiren(device, coordinator) for device in devices["chimes"]
RingChimeSiren(device, devices_coordinator)
for device in ring_data.devices.chimes
)
class RingChimeSiren(RingEntity, SirenEntity):
"""Creates a siren to play the test chimes of a Chime device."""
_attr_available_tones = list(CHIME_TEST_SOUND_KINDS)
_attr_available_tones = [RingEventKind.DING.value, RingEventKind.MOTION.value]
_attr_supported_features = SirenEntityFeature.TURN_ON | SirenEntityFeature.TONES
_attr_translation_key = "siren"
def __init__(self, device, coordinator: RingDataCoordinator) -> None:
_device: RingChime
def __init__(self, device: RingChime, coordinator: RingDataCoordinator) -> None:
"""Initialize a Ring Chime siren."""
super().__init__(device, coordinator)
# Entity class attributes
self._attr_unique_id = f"{self._device.id}-siren"
@exception_wrap
def turn_on(self, **kwargs):
def turn_on(self, **kwargs: Any) -> None:
"""Play the test sound on a Ring Chime device."""
tone = kwargs.get(ATTR_TONE) or KIND_DING
tone = kwargs.get(ATTR_TONE) or RingEventKind.DING.value
self._device.test_sound(kind=tone)

View File

@ -4,7 +4,7 @@ from datetime import timedelta
import logging
from typing import Any
from ring_doorbell import RingGeneric, RingStickUpCam
from ring_doorbell import RingStickUpCam
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
@ -12,7 +12,8 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
import homeassistant.util.dt as dt_util
from .const import DOMAIN, RING_DEVICES, RING_DEVICES_COORDINATOR
from . import RingData
from .const import DOMAIN
from .coordinator import RingDataCoordinator
from .entity import RingEntity, exception_wrap
@ -33,14 +34,12 @@ async def async_setup_entry(
async_add_entities: AddEntitiesCallback,
) -> None:
"""Create the switches for the Ring devices."""
devices = hass.data[DOMAIN][config_entry.entry_id][RING_DEVICES]
coordinator: RingDataCoordinator = hass.data[DOMAIN][config_entry.entry_id][
RING_DEVICES_COORDINATOR
]
ring_data: RingData = hass.data[DOMAIN][config_entry.entry_id]
devices_coordinator = ring_data.devices_coordinator
async_add_entities(
SirenSwitch(device, coordinator)
for device in devices["stickup_cams"]
SirenSwitch(device, devices_coordinator)
for device in ring_data.devices.stickup_cams
if device.has_capability("siren")
)
@ -48,8 +47,10 @@ async def async_setup_entry(
class BaseRingSwitch(RingEntity, SwitchEntity):
"""Represents a switch for controlling an aspect of a ring device."""
_device: RingStickUpCam
def __init__(
self, device: RingGeneric, coordinator: RingDataCoordinator, device_type: str
self, device: RingStickUpCam, coordinator: RingDataCoordinator, device_type: str
) -> None:
"""Initialize the switch."""
super().__init__(device, coordinator)
@ -62,26 +63,27 @@ class SirenSwitch(BaseRingSwitch):
_attr_translation_key = "siren"
def __init__(self, device, coordinator: RingDataCoordinator) -> None:
def __init__(
self, device: RingStickUpCam, coordinator: RingDataCoordinator
) -> None:
"""Initialize the switch for a device with a siren."""
super().__init__(device, coordinator, "siren")
self._no_updates_until = dt_util.utcnow()
self._attr_is_on = device.siren > 0
@callback
def _handle_coordinator_update(self):
def _handle_coordinator_update(self) -> None:
"""Call update method."""
if self._no_updates_until > dt_util.utcnow():
return
if (device := self._get_coordinator_device()) and isinstance(
device, RingStickUpCam
):
self._attr_is_on = device.siren > 0
device = self._get_coordinator_data().get_stickup_cam(
self._device.device_api_id
)
self._attr_is_on = device.siren > 0
super()._handle_coordinator_update()
@exception_wrap
def _set_switch(self, new_state):
def _set_switch(self, new_state: int) -> None:
"""Update switch state, and causes Home Assistant to correctly update."""
self._device.siren = new_state

View File

@ -3391,6 +3391,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.ring.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.rituals_perfume_genie.*]
check_untyped_defs = true
disallow_incomplete_defs = true

View File

@ -15,4 +15,4 @@ async def setup_platform(hass, platform):
)
with patch("homeassistant.components.ring.PLATFORMS", [platform]):
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
await hass.async_block_till_done(wait_background_tasks=True)