mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 21:27:38 +00:00
Fix ring notifications (#124879)
* Enable ring event listener to fix missing notifications * Fix pylint test CI fail * Reinstate binary sensor and add deprecation issues * Add tests * Update post review * Remove PropertyMock * Update post review * Split out adding event platform
This commit is contained in:
parent
5405279273
commit
b3d6f8861f
@ -5,18 +5,23 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
import uuid
|
||||||
|
|
||||||
from ring_doorbell import Auth, Ring, RingDevices
|
from ring_doorbell import Auth, Ring, RingDevices
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import APPLICATION_NAME, CONF_TOKEN, __version__
|
from homeassistant.const import APPLICATION_NAME, CONF_TOKEN
|
||||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
from homeassistant.helpers import (
|
||||||
|
device_registry as dr,
|
||||||
|
entity_registry as er,
|
||||||
|
instance_id,
|
||||||
|
)
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||||
|
|
||||||
from .const import DOMAIN, PLATFORMS
|
from .const import CONF_LISTEN_CREDENTIALS, DOMAIN, PLATFORMS
|
||||||
from .coordinator import RingDataCoordinator, RingNotificationsCoordinator
|
from .coordinator import RingDataCoordinator, RingListenCoordinator
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -28,12 +33,26 @@ class RingData:
|
|||||||
api: Ring
|
api: Ring
|
||||||
devices: RingDevices
|
devices: RingDevices
|
||||||
devices_coordinator: RingDataCoordinator
|
devices_coordinator: RingDataCoordinator
|
||||||
notifications_coordinator: RingNotificationsCoordinator
|
listen_coordinator: RingListenCoordinator
|
||||||
|
|
||||||
|
|
||||||
type RingConfigEntry = ConfigEntry[RingData]
|
type RingConfigEntry = ConfigEntry[RingData]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_auth_agent_id(hass: HomeAssistant) -> tuple[str, str]:
|
||||||
|
"""Return user-agent and hardware id for Auth instantiation.
|
||||||
|
|
||||||
|
user_agent will be the display name in the ring.com authorised devices.
|
||||||
|
hardware_id will uniquely describe the authorised HA device.
|
||||||
|
"""
|
||||||
|
user_agent = f"{APPLICATION_NAME}/{DOMAIN}-integration"
|
||||||
|
|
||||||
|
# Generate a new uuid from the instance_uuid to keep the HA one private
|
||||||
|
instance_uuid = uuid.UUID(hex=await instance_id.async_get(hass))
|
||||||
|
hardware_id = str(uuid.uuid5(instance_uuid, user_agent))
|
||||||
|
return user_agent, hardware_id
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool:
|
||||||
"""Set up a config entry."""
|
"""Set up a config entry."""
|
||||||
|
|
||||||
@ -44,26 +63,39 @@ async def async_setup_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool
|
|||||||
data={**entry.data, CONF_TOKEN: token},
|
data={**entry.data, CONF_TOKEN: token},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def listen_credentials_updater(token: dict[str, Any]) -> None:
|
||||||
|
"""Handle from async context when token is updated."""
|
||||||
|
hass.config_entries.async_update_entry(
|
||||||
|
entry,
|
||||||
|
data={**entry.data, CONF_LISTEN_CREDENTIALS: token},
|
||||||
|
)
|
||||||
|
|
||||||
|
user_agent, hardware_id = await get_auth_agent_id(hass)
|
||||||
|
client_session = async_get_clientsession(hass)
|
||||||
auth = Auth(
|
auth = Auth(
|
||||||
f"{APPLICATION_NAME}/{__version__}",
|
user_agent,
|
||||||
entry.data[CONF_TOKEN],
|
entry.data[CONF_TOKEN],
|
||||||
token_updater,
|
token_updater,
|
||||||
http_client_session=async_get_clientsession(hass),
|
hardware_id=hardware_id,
|
||||||
|
http_client_session=client_session,
|
||||||
)
|
)
|
||||||
ring = Ring(auth)
|
ring = Ring(auth)
|
||||||
|
|
||||||
await _migrate_old_unique_ids(hass, entry.entry_id)
|
await _migrate_old_unique_ids(hass, entry.entry_id)
|
||||||
|
|
||||||
devices_coordinator = RingDataCoordinator(hass, ring)
|
devices_coordinator = RingDataCoordinator(hass, ring)
|
||||||
notifications_coordinator = RingNotificationsCoordinator(hass, ring)
|
listen_credentials = entry.data.get(CONF_LISTEN_CREDENTIALS)
|
||||||
|
listen_coordinator = RingListenCoordinator(
|
||||||
|
hass, ring, listen_credentials, listen_credentials_updater
|
||||||
|
)
|
||||||
|
|
||||||
await devices_coordinator.async_config_entry_first_refresh()
|
await devices_coordinator.async_config_entry_first_refresh()
|
||||||
await notifications_coordinator.async_config_entry_first_refresh()
|
|
||||||
|
|
||||||
entry.runtime_data = RingData(
|
entry.runtime_data = RingData(
|
||||||
api=ring,
|
api=ring,
|
||||||
devices=ring.devices(),
|
devices=ring.devices(),
|
||||||
devices_coordinator=devices_coordinator,
|
devices_coordinator=devices_coordinator,
|
||||||
notifications_coordinator=notifications_coordinator,
|
listen_coordinator=listen_coordinator,
|
||||||
)
|
)
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
@ -91,7 +123,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool
|
|||||||
)
|
)
|
||||||
for loaded_entry in hass.config_entries.async_loaded_entries(DOMAIN):
|
for loaded_entry in hass.config_entries.async_loaded_entries(DOMAIN):
|
||||||
await loaded_entry.runtime_data.devices_coordinator.async_refresh()
|
await loaded_entry.runtime_data.devices_coordinator.async_refresh()
|
||||||
await loaded_entry.runtime_data.notifications_coordinator.async_refresh()
|
|
||||||
|
|
||||||
# register service
|
# register service
|
||||||
hass.services.async_register(DOMAIN, "update", async_refresh_all)
|
hass.services.async_register(DOMAIN, "update", async_refresh_all)
|
||||||
|
@ -2,46 +2,62 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable, Mapping
|
from collections.abc import Mapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any, Generic
|
||||||
|
|
||||||
from ring_doorbell import Ring, RingEvent, RingGeneric
|
from ring_doorbell import RingCapability, RingEvent
|
||||||
|
from ring_doorbell.const import KIND_DING, KIND_MOTION
|
||||||
|
|
||||||
from homeassistant.components.binary_sensor import (
|
from homeassistant.components.binary_sensor import (
|
||||||
BinarySensorDeviceClass,
|
BinarySensorDeviceClass,
|
||||||
BinarySensorEntity,
|
BinarySensorEntity,
|
||||||
BinarySensorEntityDescription,
|
BinarySensorEntityDescription,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.const import Platform
|
||||||
|
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.event import async_call_at
|
||||||
|
|
||||||
from . import RingConfigEntry
|
from . import RingConfigEntry
|
||||||
from .coordinator import RingNotificationsCoordinator
|
from .coordinator import RingListenCoordinator
|
||||||
from .entity import RingBaseEntity
|
from .entity import (
|
||||||
|
DeprecatedInfo,
|
||||||
|
RingBaseEntity,
|
||||||
|
RingDeviceT,
|
||||||
|
RingEntityDescription,
|
||||||
|
async_check_create_deprecated,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class RingBinarySensorEntityDescription(BinarySensorEntityDescription):
|
class RingBinarySensorEntityDescription(
|
||||||
|
BinarySensorEntityDescription, RingEntityDescription, Generic[RingDeviceT]
|
||||||
|
):
|
||||||
"""Describes Ring binary sensor entity."""
|
"""Describes Ring binary sensor entity."""
|
||||||
|
|
||||||
exists_fn: Callable[[RingGeneric], bool]
|
capability: RingCapability
|
||||||
|
|
||||||
|
|
||||||
BINARY_SENSOR_TYPES: tuple[RingBinarySensorEntityDescription, ...] = (
|
BINARY_SENSOR_TYPES: tuple[RingBinarySensorEntityDescription, ...] = (
|
||||||
RingBinarySensorEntityDescription(
|
RingBinarySensorEntityDescription(
|
||||||
key="ding",
|
key=KIND_DING,
|
||||||
translation_key="ding",
|
translation_key=KIND_DING,
|
||||||
device_class=BinarySensorDeviceClass.OCCUPANCY,
|
device_class=BinarySensorDeviceClass.OCCUPANCY,
|
||||||
exists_fn=lambda device: device.family
|
capability=RingCapability.DING,
|
||||||
in {"doorbots", "authorized_doorbots", "other"},
|
deprecated_info=DeprecatedInfo(
|
||||||
|
new_platform=Platform.EVENT, breaks_in_ha_version="2025.4.0"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
RingBinarySensorEntityDescription(
|
RingBinarySensorEntityDescription(
|
||||||
key="motion",
|
key=KIND_MOTION,
|
||||||
|
translation_key=KIND_MOTION,
|
||||||
device_class=BinarySensorDeviceClass.MOTION,
|
device_class=BinarySensorDeviceClass.MOTION,
|
||||||
exists_fn=lambda device: device.family
|
capability=RingCapability.MOTION_DETECTION,
|
||||||
in {"doorbots", "authorized_doorbots", "stickup_cams"},
|
deprecated_info=DeprecatedInfo(
|
||||||
|
new_platform=Platform.EVENT, breaks_in_ha_version="2025.4.0"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -53,70 +69,84 @@ async def async_setup_entry(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Ring binary sensors from a config entry."""
|
"""Set up the Ring binary sensors from a config entry."""
|
||||||
ring_data = entry.runtime_data
|
ring_data = entry.runtime_data
|
||||||
|
listen_coordinator = ring_data.listen_coordinator
|
||||||
|
|
||||||
entities = [
|
async_add_entities(
|
||||||
RingBinarySensor(
|
RingBinarySensor(device, listen_coordinator, description)
|
||||||
ring_data.api,
|
|
||||||
device,
|
|
||||||
ring_data.notifications_coordinator,
|
|
||||||
description,
|
|
||||||
)
|
|
||||||
for description in BINARY_SENSOR_TYPES
|
for description in BINARY_SENSOR_TYPES
|
||||||
for device in ring_data.devices.all_devices
|
for device in ring_data.devices.all_devices
|
||||||
if description.exists_fn(device)
|
if device.has_capability(description.capability)
|
||||||
]
|
and async_check_create_deprecated(
|
||||||
|
hass,
|
||||||
async_add_entities(entities)
|
Platform.BINARY_SENSOR,
|
||||||
|
f"{device.id}-{description.key}",
|
||||||
|
description,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RingBinarySensor(
|
class RingBinarySensor(
|
||||||
RingBaseEntity[RingNotificationsCoordinator], BinarySensorEntity
|
RingBaseEntity[RingListenCoordinator, RingDeviceT], BinarySensorEntity
|
||||||
):
|
):
|
||||||
"""A binary sensor implementation for Ring device."""
|
"""A binary sensor implementation for Ring device."""
|
||||||
|
|
||||||
_active_alert: RingEvent | None = None
|
_active_alert: RingEvent | None = None
|
||||||
entity_description: RingBinarySensorEntityDescription
|
RingBinarySensorEntityDescription[RingDeviceT]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
ring: Ring,
|
device: RingDeviceT,
|
||||||
device: RingGeneric,
|
coordinator: RingListenCoordinator,
|
||||||
coordinator: RingNotificationsCoordinator,
|
description: RingBinarySensorEntityDescription[RingDeviceT],
|
||||||
description: RingBinarySensorEntityDescription,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize a sensor for Ring device."""
|
"""Initialize a binary sensor for Ring device."""
|
||||||
super().__init__(
|
super().__init__(
|
||||||
device,
|
device,
|
||||||
coordinator,
|
coordinator,
|
||||||
)
|
)
|
||||||
self.entity_description = description
|
self.entity_description = description
|
||||||
self._ring = ring
|
|
||||||
self._attr_unique_id = f"{device.id}-{description.key}"
|
self._attr_unique_id = f"{device.id}-{description.key}"
|
||||||
self._update_alert()
|
self._attr_is_on = False
|
||||||
|
self._active_alert: RingEvent | None = None
|
||||||
|
self._cancel_callback: CALLBACK_TYPE | None = None
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _handle_coordinator_update(self, _: Any = None) -> None:
|
def _async_handle_event(self, alert: RingEvent) -> None:
|
||||||
"""Call update method."""
|
"""Handle the event."""
|
||||||
self._update_alert()
|
self._attr_is_on = True
|
||||||
super()._handle_coordinator_update()
|
self._active_alert = alert
|
||||||
|
loop = self.hass.loop
|
||||||
|
when = loop.time() + alert.expires_in
|
||||||
|
if self._cancel_callback:
|
||||||
|
self._cancel_callback()
|
||||||
|
self._cancel_callback = async_call_at(self.hass, self._async_cancel_event, when)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _update_alert(self) -> None:
|
def _async_cancel_event(self, _now: Any) -> None:
|
||||||
"""Update active alert."""
|
"""Clear the event."""
|
||||||
self._active_alert = next(
|
self._cancel_callback = None
|
||||||
(
|
self._attr_is_on = False
|
||||||
alert
|
self._active_alert = None
|
||||||
for alert in self._ring.active_alerts()
|
self.async_write_ha_state()
|
||||||
if alert["kind"] == self.entity_description.key
|
|
||||||
and alert["doorbot_id"] == self._device.id
|
def _get_coordinator_alert(self) -> RingEvent | None:
|
||||||
),
|
return self.coordinator.alerts.get(
|
||||||
None,
|
(self._device.device_api_id, self.entity_description.key)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _handle_coordinator_update(self) -> None:
|
||||||
|
if alert := self._get_coordinator_alert():
|
||||||
|
self._async_handle_event(alert)
|
||||||
|
super()._handle_coordinator_update()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_on(self) -> bool:
|
def available(self) -> bool:
|
||||||
"""Return True if the binary sensor is on."""
|
"""Return if entity is available."""
|
||||||
return self._active_alert is not None
|
return self.coordinator.event_listener.started
|
||||||
|
|
||||||
|
async def async_update(self) -> None:
|
||||||
|
"""All updates are passive."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self) -> Mapping[str, Any] | None:
|
def extra_state_attributes(self) -> Mapping[str, Any] | None:
|
||||||
@ -127,9 +157,9 @@ class RingBinarySensor(
|
|||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
assert isinstance(attrs, dict)
|
assert isinstance(attrs, dict)
|
||||||
attrs["state"] = self._active_alert["state"]
|
attrs["state"] = self._active_alert.state
|
||||||
now = self._active_alert.get("now")
|
now = self._active_alert.now
|
||||||
expires_in = self._active_alert.get("expires_in")
|
expires_in = self._active_alert.expires_in
|
||||||
assert now and expires_in
|
assert now and expires_in
|
||||||
attrs["expires_at"] = datetime.fromtimestamp(now + expires_in).isoformat()
|
attrs["expires_at"] = datetime.fromtimestamp(now + expires_in).isoformat()
|
||||||
|
|
||||||
|
@ -8,17 +8,12 @@ from ring_doorbell import Auth, AuthenticationError, Requires2FAError
|
|||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
|
from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
|
||||||
from homeassistant.const import (
|
from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
|
||||||
APPLICATION_NAME,
|
|
||||||
CONF_PASSWORD,
|
|
||||||
CONF_TOKEN,
|
|
||||||
CONF_USERNAME,
|
|
||||||
__version__ as ha_version,
|
|
||||||
)
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
|
from . import get_auth_agent_id
|
||||||
from .const import CONF_2FA, DOMAIN
|
from .const import CONF_2FA, DOMAIN
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@ -32,9 +27,11 @@ STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str})
|
|||||||
async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, Any]:
|
async def validate_input(hass: HomeAssistant, data: dict[str, str]) -> dict[str, Any]:
|
||||||
"""Validate the user input allows us to connect."""
|
"""Validate the user input allows us to connect."""
|
||||||
|
|
||||||
|
user_agent, hardware_id = await get_auth_agent_id(hass)
|
||||||
auth = Auth(
|
auth = Auth(
|
||||||
f"{APPLICATION_NAME}/{ha_version}",
|
user_agent,
|
||||||
http_client_session=async_get_clientsession(hass),
|
http_client_session=async_get_clientsession(hass),
|
||||||
|
hardware_id=hardware_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -26,6 +26,6 @@ PLATFORMS = [
|
|||||||
|
|
||||||
|
|
||||||
SCAN_INTERVAL = timedelta(minutes=1)
|
SCAN_INTERVAL = timedelta(minutes=1)
|
||||||
NOTIFICATIONS_SCAN_INTERVAL = timedelta(seconds=5)
|
|
||||||
|
|
||||||
CONF_2FA = "2fa"
|
CONF_2FA = "2fa"
|
||||||
|
CONF_LISTEN_CREDENTIALS = "listen_token"
|
||||||
|
@ -3,15 +3,28 @@
|
|||||||
from asyncio import TaskGroup
|
from asyncio import TaskGroup
|
||||||
from collections.abc import Callable, Coroutine
|
from collections.abc import Callable, Coroutine
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from ring_doorbell import AuthenticationError, Ring, RingDevices, RingError, RingTimeout
|
from ring_doorbell import (
|
||||||
|
AuthenticationError,
|
||||||
|
Ring,
|
||||||
|
RingDevices,
|
||||||
|
RingError,
|
||||||
|
RingEvent,
|
||||||
|
RingTimeout,
|
||||||
|
)
|
||||||
|
from ring_doorbell.listen import RingEventListener
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant import config_entries
|
||||||
|
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import (
|
||||||
|
BaseDataUpdateCoordinatorProtocol,
|
||||||
|
DataUpdateCoordinator,
|
||||||
|
UpdateFailed,
|
||||||
|
)
|
||||||
|
|
||||||
from .const import NOTIFICATIONS_SCAN_INTERVAL, SCAN_INTERVAL
|
from .const import SCAN_INTERVAL
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -91,19 +104,112 @@ class RingDataCoordinator(DataUpdateCoordinator[RingDevices]):
|
|||||||
return devices
|
return devices
|
||||||
|
|
||||||
|
|
||||||
class RingNotificationsCoordinator(DataUpdateCoordinator[None]):
|
class RingListenCoordinator(BaseDataUpdateCoordinatorProtocol):
|
||||||
"""Global notifications coordinator."""
|
"""Global notifications coordinator."""
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, ring_api: Ring) -> None:
|
config_entry: config_entries.ConfigEntry
|
||||||
"""Initialize my coordinator."""
|
|
||||||
super().__init__(
|
|
||||||
hass,
|
|
||||||
logger=_LOGGER,
|
|
||||||
name="active dings",
|
|
||||||
update_interval=NOTIFICATIONS_SCAN_INTERVAL,
|
|
||||||
)
|
|
||||||
self.ring_api: Ring = ring_api
|
|
||||||
|
|
||||||
async def _async_update_data(self) -> None:
|
def __init__(
|
||||||
"""Fetch data from API endpoint."""
|
self,
|
||||||
await _call_api(self.hass, self.ring_api.async_update_dings)
|
hass: HomeAssistant,
|
||||||
|
ring_api: Ring,
|
||||||
|
listen_credentials: dict[str, Any] | None,
|
||||||
|
listen_credentials_updater: Callable[[dict[str, Any]], None],
|
||||||
|
) -> None:
|
||||||
|
"""Initialize my coordinator."""
|
||||||
|
self.hass = hass
|
||||||
|
self.logger = _LOGGER
|
||||||
|
self.ring_api: Ring = ring_api
|
||||||
|
self.event_listener = RingEventListener(
|
||||||
|
ring_api, listen_credentials, listen_credentials_updater
|
||||||
|
)
|
||||||
|
self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {}
|
||||||
|
self._listen_callback_id: int | None = None
|
||||||
|
|
||||||
|
config_entry = config_entries.current_entry.get()
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
assert config_entry
|
||||||
|
self.config_entry = config_entry
|
||||||
|
self.start_timeout = 10
|
||||||
|
self.config_entry.async_on_unload(self.async_shutdown)
|
||||||
|
self.index_alerts()
|
||||||
|
|
||||||
|
def index_alerts(self) -> None:
|
||||||
|
"Index the active alerts."
|
||||||
|
self.alerts = {
|
||||||
|
(alert.doorbot_id, alert.kind): alert
|
||||||
|
for alert in self.ring_api.active_alerts()
|
||||||
|
}
|
||||||
|
|
||||||
|
async def async_shutdown(self) -> None:
|
||||||
|
"""Cancel any scheduled call, and ignore new runs."""
|
||||||
|
if self.event_listener.started:
|
||||||
|
await self._async_stop_listen()
|
||||||
|
|
||||||
|
async def _async_stop_listen(self) -> None:
|
||||||
|
self.logger.debug("Stopped ring listener")
|
||||||
|
await self.event_listener.stop()
|
||||||
|
self.logger.debug("Stopped ring listener")
|
||||||
|
|
||||||
|
async def _async_start_listen(self) -> None:
|
||||||
|
"""Start listening for realtime events."""
|
||||||
|
self.logger.debug("Starting ring listener.")
|
||||||
|
await self.event_listener.start(
|
||||||
|
timeout=self.start_timeout,
|
||||||
|
)
|
||||||
|
if self.event_listener.started is True:
|
||||||
|
self.logger.debug("Started ring listener")
|
||||||
|
else:
|
||||||
|
self.logger.warning(
|
||||||
|
"Ring event listener failed to start after %s seconds",
|
||||||
|
self.start_timeout,
|
||||||
|
)
|
||||||
|
self._listen_callback_id = self.event_listener.add_notification_callback(
|
||||||
|
self._on_event
|
||||||
|
)
|
||||||
|
self.index_alerts()
|
||||||
|
# Update the listeners so they switch from Unavailable to Unknown
|
||||||
|
self._async_update_listeners()
|
||||||
|
|
||||||
|
def _on_event(self, event: RingEvent) -> None:
|
||||||
|
self.logger.debug("Ring event received: %s", event)
|
||||||
|
self.index_alerts()
|
||||||
|
self._async_update_listeners(event.doorbot_id)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_update_listeners(self, doorbot_id: int | None = None) -> None:
|
||||||
|
"""Update all registered listeners."""
|
||||||
|
for update_callback, device_api_id in list(self._listeners.values()):
|
||||||
|
if not doorbot_id or device_api_id == doorbot_id:
|
||||||
|
update_callback()
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_add_listener(
|
||||||
|
self, update_callback: CALLBACK_TYPE, context: Any = None
|
||||||
|
) -> Callable[[], None]:
|
||||||
|
"""Listen for data updates."""
|
||||||
|
start_listen = not self._listeners
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def remove_listener() -> None:
|
||||||
|
"""Remove update listener."""
|
||||||
|
self._listeners.pop(remove_listener)
|
||||||
|
if not self._listeners:
|
||||||
|
self.config_entry.async_create_task(
|
||||||
|
self.hass,
|
||||||
|
self._async_stop_listen(),
|
||||||
|
"Ring event listener stop",
|
||||||
|
eager_start=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._listeners[remove_listener] = (update_callback, context)
|
||||||
|
|
||||||
|
# This is the first listener, start the event listener.
|
||||||
|
if start_listen:
|
||||||
|
self.config_entry.async_create_task(
|
||||||
|
self.hass,
|
||||||
|
self._async_start_listen(),
|
||||||
|
"Ring event listener start",
|
||||||
|
eager_start=True,
|
||||||
|
)
|
||||||
|
return remove_listener
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
"""Base class for Ring entity."""
|
"""Base class for Ring entity."""
|
||||||
|
|
||||||
from collections.abc import Callable, Coroutine
|
from collections.abc import Callable, Coroutine
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import Any, Concatenate, Generic, cast
|
from typing import Any, Concatenate, Generic, cast
|
||||||
|
|
||||||
from ring_doorbell import (
|
from ring_doorbell import (
|
||||||
@ -12,22 +13,46 @@ from ring_doorbell import (
|
|||||||
)
|
)
|
||||||
from typing_extensions import TypeVar
|
from typing_extensions import TypeVar
|
||||||
|
|
||||||
from homeassistant.core import callback
|
from homeassistant.components.automation import automations_with_entity
|
||||||
|
from homeassistant.components.script import scripts_with_entity
|
||||||
|
from homeassistant.const import Platform
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
from homeassistant.helpers import entity_registry as er
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.entity import EntityDescription
|
||||||
|
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||||
|
from homeassistant.helpers.update_coordinator import (
|
||||||
|
BaseCoordinatorEntity,
|
||||||
|
CoordinatorEntity,
|
||||||
|
)
|
||||||
|
|
||||||
from .const import ATTRIBUTION, DOMAIN
|
from .const import ATTRIBUTION, DOMAIN
|
||||||
from .coordinator import RingDataCoordinator, RingNotificationsCoordinator
|
from .coordinator import RingDataCoordinator, RingListenCoordinator
|
||||||
|
|
||||||
RingDeviceT = TypeVar("RingDeviceT", bound=RingGeneric, default=RingGeneric)
|
RingDeviceT = TypeVar("RingDeviceT", bound=RingGeneric, default=RingGeneric)
|
||||||
|
|
||||||
_RingCoordinatorT = TypeVar(
|
_RingCoordinatorT = TypeVar(
|
||||||
"_RingCoordinatorT",
|
"_RingCoordinatorT",
|
||||||
bound=(RingDataCoordinator | RingNotificationsCoordinator),
|
bound=(RingDataCoordinator | RingListenCoordinator),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class DeprecatedInfo:
|
||||||
|
"""Class to define deprecation info for deprecated entities."""
|
||||||
|
|
||||||
|
new_platform: Platform
|
||||||
|
breaks_in_ha_version: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, kw_only=True)
|
||||||
|
class RingEntityDescription(EntityDescription):
|
||||||
|
"""Base class for a ring entity description."""
|
||||||
|
|
||||||
|
deprecated_info: DeprecatedInfo | None = None
|
||||||
|
|
||||||
|
|
||||||
def exception_wrap[_RingBaseEntityT: RingBaseEntity[Any, Any], **_P, _R](
|
def exception_wrap[_RingBaseEntityT: RingBaseEntity[Any, Any], **_P, _R](
|
||||||
async_func: Callable[Concatenate[_RingBaseEntityT, _P], Coroutine[Any, Any, _R]],
|
async_func: Callable[Concatenate[_RingBaseEntityT, _P], Coroutine[Any, Any, _R]],
|
||||||
) -> Callable[Concatenate[_RingBaseEntityT, _P], Coroutine[Any, Any, _R]]:
|
) -> Callable[Concatenate[_RingBaseEntityT, _P], Coroutine[Any, Any, _R]]:
|
||||||
@ -51,8 +76,66 @@ def exception_wrap[_RingBaseEntityT: RingBaseEntity[Any, Any], **_P, _R](
|
|||||||
return _wrap
|
return _wrap
|
||||||
|
|
||||||
|
|
||||||
|
def async_check_create_deprecated(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
platform: Platform,
|
||||||
|
unique_id: str,
|
||||||
|
entity_description: RingEntityDescription,
|
||||||
|
) -> bool:
|
||||||
|
"""Return true if the entitty should be created based on the deprecated_info.
|
||||||
|
|
||||||
|
If deprecated_info is not defined will return true.
|
||||||
|
If entity not yet created will return false.
|
||||||
|
If entity disabled will delete it and return false.
|
||||||
|
Otherwise will return true and create issues for scripts or automations.
|
||||||
|
"""
|
||||||
|
if not entity_description.deprecated_info:
|
||||||
|
return True
|
||||||
|
|
||||||
|
ent_reg = er.async_get(hass)
|
||||||
|
entity_id = ent_reg.async_get_entity_id(
|
||||||
|
platform,
|
||||||
|
DOMAIN,
|
||||||
|
unique_id,
|
||||||
|
)
|
||||||
|
if not entity_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
entity_entry = ent_reg.async_get(entity_id)
|
||||||
|
assert entity_entry
|
||||||
|
if entity_entry.disabled:
|
||||||
|
# If the entity exists and is disabled then we want to remove
|
||||||
|
# the entity so that the user is just using the new entity.
|
||||||
|
ent_reg.async_remove(entity_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check for issues that need to be created
|
||||||
|
entity_automations = automations_with_entity(hass, entity_id)
|
||||||
|
entity_scripts = scripts_with_entity(hass, entity_id)
|
||||||
|
if entity_automations or entity_scripts:
|
||||||
|
deprecated_info = entity_description.deprecated_info
|
||||||
|
for item in entity_automations + entity_scripts:
|
||||||
|
async_create_issue(
|
||||||
|
hass,
|
||||||
|
DOMAIN,
|
||||||
|
f"deprecated_entity_{entity_id}_{item}",
|
||||||
|
breaks_in_ha_version=deprecated_info.breaks_in_ha_version,
|
||||||
|
is_fixable=False,
|
||||||
|
is_persistent=False,
|
||||||
|
severity=IssueSeverity.WARNING,
|
||||||
|
translation_key="deprecated_entity",
|
||||||
|
translation_placeholders={
|
||||||
|
"entity": entity_id,
|
||||||
|
"info": item,
|
||||||
|
"platform": platform,
|
||||||
|
"new_platform": deprecated_info.new_platform,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
class RingBaseEntity(
|
class RingBaseEntity(
|
||||||
CoordinatorEntity[_RingCoordinatorT], Generic[_RingCoordinatorT, RingDeviceT]
|
BaseCoordinatorEntity[_RingCoordinatorT], Generic[_RingCoordinatorT, RingDeviceT]
|
||||||
):
|
):
|
||||||
"""Base implementation for Ring device."""
|
"""Base implementation for Ring device."""
|
||||||
|
|
||||||
@ -77,7 +160,7 @@ class RingBaseEntity(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class RingEntity(RingBaseEntity[RingDataCoordinator, RingDeviceT]):
|
class RingEntity(RingBaseEntity[RingDataCoordinator, RingDeviceT], CoordinatorEntity):
|
||||||
"""Implementation for Ring devices."""
|
"""Implementation for Ring devices."""
|
||||||
|
|
||||||
def _get_coordinator_data(self) -> RingDevices:
|
def _get_coordinator_data(self) -> RingDevices:
|
||||||
|
@ -25,6 +25,7 @@ from homeassistant.const import (
|
|||||||
PERCENTAGE,
|
PERCENTAGE,
|
||||||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||||
EntityCategory,
|
EntityCategory,
|
||||||
|
Platform,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
@ -32,7 +33,13 @@ from homeassistant.helpers.typing import StateType
|
|||||||
|
|
||||||
from . import RingConfigEntry
|
from . import RingConfigEntry
|
||||||
from .coordinator import RingDataCoordinator
|
from .coordinator import RingDataCoordinator
|
||||||
from .entity import RingDeviceT, RingEntity
|
from .entity import (
|
||||||
|
DeprecatedInfo,
|
||||||
|
RingDeviceT,
|
||||||
|
RingEntity,
|
||||||
|
RingEntityDescription,
|
||||||
|
async_check_create_deprecated,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
@ -49,6 +56,12 @@ async def async_setup_entry(
|
|||||||
for description in SENSOR_TYPES
|
for description in SENSOR_TYPES
|
||||||
for device in ring_data.devices.all_devices
|
for device in ring_data.devices.all_devices
|
||||||
if description.exists_fn(device)
|
if description.exists_fn(device)
|
||||||
|
and async_check_create_deprecated(
|
||||||
|
hass,
|
||||||
|
Platform.SENSOR,
|
||||||
|
f"{device.id}-{description.key}",
|
||||||
|
description,
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
async_add_entities(entities)
|
async_add_entities(entities)
|
||||||
@ -120,7 +133,9 @@ def _get_last_event_attrs(
|
|||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class RingSensorEntityDescription(SensorEntityDescription, Generic[RingDeviceT]):
|
class RingSensorEntityDescription(
|
||||||
|
SensorEntityDescription, RingEntityDescription, Generic[RingDeviceT]
|
||||||
|
):
|
||||||
"""Describes Ring sensor entity."""
|
"""Describes Ring sensor entity."""
|
||||||
|
|
||||||
value_fn: Callable[[RingDeviceT], StateType] = lambda _: True
|
value_fn: Callable[[RingDeviceT], StateType] = lambda _: True
|
||||||
@ -172,6 +187,9 @@ SENSOR_TYPES: tuple[RingSensorEntityDescription[Any], ...] = (
|
|||||||
)
|
)
|
||||||
else None,
|
else None,
|
||||||
exists_fn=lambda device: device.has_capability(RingCapability.HISTORY),
|
exists_fn=lambda device: device.has_capability(RingCapability.HISTORY),
|
||||||
|
deprecated_info=DeprecatedInfo(
|
||||||
|
new_platform=Platform.EVENT, breaks_in_ha_version="2025.4.0"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
RingSensorEntityDescription[RingGeneric](
|
RingSensorEntityDescription[RingGeneric](
|
||||||
key="last_motion",
|
key="last_motion",
|
||||||
@ -188,6 +206,9 @@ SENSOR_TYPES: tuple[RingSensorEntityDescription[Any], ...] = (
|
|||||||
)
|
)
|
||||||
else None,
|
else None,
|
||||||
exists_fn=lambda device: device.has_capability(RingCapability.HISTORY),
|
exists_fn=lambda device: device.has_capability(RingCapability.HISTORY),
|
||||||
|
deprecated_info=DeprecatedInfo(
|
||||||
|
new_platform=Platform.EVENT, breaks_in_ha_version="2025.4.0"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
RingSensorEntityDescription[RingDoorBell | RingChime](
|
RingSensorEntityDescription[RingDoorBell | RingChime](
|
||||||
key="volume",
|
key="volume",
|
||||||
|
@ -35,6 +35,17 @@
|
|||||||
"binary_sensor": {
|
"binary_sensor": {
|
||||||
"ding": {
|
"ding": {
|
||||||
"name": "Ding"
|
"name": "Ding"
|
||||||
|
},
|
||||||
|
"motion": {
|
||||||
|
"name": "Motion"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"event": {
|
||||||
|
"ding": {
|
||||||
|
"name": "Ding"
|
||||||
|
},
|
||||||
|
"intercom_unlock": {
|
||||||
|
"name": "Intercom unlock"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"button": {
|
"button": {
|
||||||
@ -104,6 +115,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"deprecated_entity": {
|
||||||
|
"title": "Detected deprecated `{platform}` entity usage",
|
||||||
|
"description": "We detected that entity `{entity}` is being used in `{info}`\n\nWe have created a new `{new_platform}` entity and you should migrate `{info}` to use this new entity.\n\nWhen you are done migrating `{info}` and are ready to have the deprecated `{entity}` entity removed, disable the entity and restart Home Assistant."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN
|
||||||
from homeassistant.components.ring import DOMAIN
|
from homeassistant.components.ring import DOMAIN
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
@ -18,3 +19,18 @@ async def setup_platform(hass: HomeAssistant, platform: Platform) -> None:
|
|||||||
with patch("homeassistant.components.ring.PLATFORMS", [platform]):
|
with patch("homeassistant.components.ring.PLATFORMS", [platform]):
|
||||||
assert await async_setup_component(hass, DOMAIN, {})
|
assert await async_setup_component(hass, DOMAIN, {})
|
||||||
await hass.async_block_till_done(wait_background_tasks=True)
|
await hass.async_block_till_done(wait_background_tasks=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def setup_automation(hass: HomeAssistant, alias: str, entity_id: str) -> None:
|
||||||
|
"""Set up an automation for tests."""
|
||||||
|
assert await async_setup_component(
|
||||||
|
hass,
|
||||||
|
AUTOMATION_DOMAIN,
|
||||||
|
{
|
||||||
|
AUTOMATION_DOMAIN: {
|
||||||
|
"alias": alias,
|
||||||
|
"trigger": {"platform": "state", "entity_id": entity_id, "to": "on"},
|
||||||
|
"action": {"action": "notify.notify", "metadata": {}, "data": {}},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
@ -11,7 +11,7 @@ from homeassistant.components.ring import DOMAIN
|
|||||||
from homeassistant.const import CONF_USERNAME
|
from homeassistant.const import CONF_USERNAME
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
from .device_mocks import get_active_alerts, get_devices_data, get_mock_devices
|
from .device_mocks import get_devices_data, get_mock_devices
|
||||||
|
|
||||||
from tests.common import MockConfigEntry
|
from tests.common import MockConfigEntry
|
||||||
from tests.components.light.conftest import mock_light_profiles # noqa: F401
|
from tests.components.light.conftest import mock_light_profiles # noqa: F401
|
||||||
@ -103,7 +103,7 @@ def mock_ring_client(mock_ring_auth, mock_ring_devices):
|
|||||||
mock_client = create_autospec(ring_doorbell.Ring)
|
mock_client = create_autospec(ring_doorbell.Ring)
|
||||||
mock_client.return_value.devices_data = get_devices_data()
|
mock_client.return_value.devices_data = get_devices_data()
|
||||||
mock_client.return_value.devices.return_value = mock_ring_devices
|
mock_client.return_value.devices.return_value = mock_ring_devices
|
||||||
mock_client.return_value.active_alerts.side_effect = get_active_alerts
|
mock_client.return_value.active_alerts.return_value = []
|
||||||
|
|
||||||
with patch("homeassistant.components.ring.Ring", new=mock_client):
|
with patch("homeassistant.components.ring.Ring", new=mock_client):
|
||||||
yield mock_client.return_value
|
yield mock_client.return_value
|
||||||
@ -135,3 +135,14 @@ async def mock_added_config_entry(
|
|||||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
return mock_config_entry
|
return mock_config_entry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def mock_ring_event_listener_class():
|
||||||
|
"""Fixture to mock the ring event listener."""
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.ring.coordinator.RingEventListener", autospec=True
|
||||||
|
) as mock_ring_listener:
|
||||||
|
mock_ring_listener.return_value.started = True
|
||||||
|
yield mock_ring_listener
|
||||||
|
@ -7,9 +7,7 @@ Each device entry in the devices.json will have a MagicMock instead of the RingO
|
|||||||
Mocks the api calls on the devices such as history() and health().
|
Mocks the api calls on the devices such as history() and health().
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from copy import deepcopy
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from time import time
|
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
from ring_doorbell import (
|
from ring_doorbell import (
|
||||||
@ -30,7 +28,10 @@ DOORBOT_HISTORY = load_json_value_fixture("doorbot_history.json", DOMAIN)
|
|||||||
INTERCOM_HISTORY = load_json_value_fixture("intercom_history.json", DOMAIN)
|
INTERCOM_HISTORY = load_json_value_fixture("intercom_history.json", DOMAIN)
|
||||||
DOORBOT_HEALTH = load_json_value_fixture("doorbot_health_attrs.json", DOMAIN)
|
DOORBOT_HEALTH = load_json_value_fixture("doorbot_health_attrs.json", DOMAIN)
|
||||||
CHIME_HEALTH = load_json_value_fixture("chime_health_attrs.json", DOMAIN)
|
CHIME_HEALTH = load_json_value_fixture("chime_health_attrs.json", DOMAIN)
|
||||||
DEVICE_ALERTS = load_json_value_fixture("ding_active.json", DOMAIN)
|
|
||||||
|
FRONT_DOOR_DEVICE_ID = 987654
|
||||||
|
INGRESS_DEVICE_ID = 185036587
|
||||||
|
FRONT_DEVICE_ID = 765432
|
||||||
|
|
||||||
|
|
||||||
def get_mock_devices():
|
def get_mock_devices():
|
||||||
@ -54,14 +55,6 @@ def get_devices_data():
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_active_alerts():
|
|
||||||
"""Return active alerts set to now."""
|
|
||||||
dings_fixture = deepcopy(DEVICE_ALERTS)
|
|
||||||
for ding in dings_fixture:
|
|
||||||
ding["now"] = time()
|
|
||||||
return dings_fixture
|
|
||||||
|
|
||||||
|
|
||||||
DEVICE_TYPES = {
|
DEVICE_TYPES = {
|
||||||
"doorbots": RingDoorBell,
|
"doorbots": RingDoorBell,
|
||||||
"authorized_doorbots": RingDoorBell,
|
"authorized_doorbots": RingDoorBell,
|
||||||
@ -76,6 +69,7 @@ DEVICE_CAPABILITIES = {
|
|||||||
RingCapability.VOLUME,
|
RingCapability.VOLUME,
|
||||||
RingCapability.MOTION_DETECTION,
|
RingCapability.MOTION_DETECTION,
|
||||||
RingCapability.VIDEO,
|
RingCapability.VIDEO,
|
||||||
|
RingCapability.DING,
|
||||||
RingCapability.HISTORY,
|
RingCapability.HISTORY,
|
||||||
],
|
],
|
||||||
RingStickUpCam: [
|
RingStickUpCam: [
|
||||||
@ -88,7 +82,7 @@ DEVICE_CAPABILITIES = {
|
|||||||
RingCapability.LIGHT,
|
RingCapability.LIGHT,
|
||||||
],
|
],
|
||||||
RingChime: [RingCapability.VOLUME],
|
RingChime: [RingCapability.VOLUME],
|
||||||
RingOther: [RingCapability.OPEN, RingCapability.HISTORY],
|
RingOther: [RingCapability.OPEN, RingCapability.HISTORY, RingCapability.DING],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,24 +1,196 @@
|
|||||||
"""The tests for the Ring binary sensor platform."""
|
"""The tests for the Ring binary sensor platform."""
|
||||||
|
|
||||||
from homeassistant.const import Platform
|
import time
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from freezegun.api import FrozenDateTimeFactory
|
||||||
|
import pytest
|
||||||
|
from ring_doorbell import Ring
|
||||||
|
|
||||||
|
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
|
||||||
|
from homeassistant.components.ring.binary_sensor import RingEvent
|
||||||
|
from homeassistant.components.ring.const import DOMAIN
|
||||||
|
from homeassistant.components.ring.coordinator import RingEventListener
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import STATE_OFF, STATE_ON, Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers import entity_registry as er, issue_registry as ir
|
||||||
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
from .common import setup_platform
|
from .common import setup_automation
|
||||||
|
from .device_mocks import FRONT_DOOR_DEVICE_ID, INGRESS_DEVICE_ID
|
||||||
|
|
||||||
|
from tests.common import async_fire_time_changed
|
||||||
|
|
||||||
|
|
||||||
async def test_binary_sensor(hass: HomeAssistant, mock_ring_client) -> None:
|
@pytest.mark.parametrize(
|
||||||
|
("device_id", "device_name", "alert_kind", "device_class"),
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
FRONT_DOOR_DEVICE_ID,
|
||||||
|
"front_door",
|
||||||
|
"motion",
|
||||||
|
"motion",
|
||||||
|
id="front_door_motion",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
FRONT_DOOR_DEVICE_ID,
|
||||||
|
"front_door",
|
||||||
|
"ding",
|
||||||
|
"occupancy",
|
||||||
|
id="front_door_ding",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
INGRESS_DEVICE_ID, "ingress", "ding", "occupancy", id="ingress_ding"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_binary_sensor(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: ConfigEntry,
|
||||||
|
mock_ring_client: Ring,
|
||||||
|
mock_ring_event_listener_class: RingEventListener,
|
||||||
|
entity_registry: er.EntityRegistry,
|
||||||
|
freezer: FrozenDateTimeFactory,
|
||||||
|
device_id: int,
|
||||||
|
device_name: str,
|
||||||
|
alert_kind: str,
|
||||||
|
device_class: str,
|
||||||
|
) -> None:
|
||||||
"""Test the Ring binary sensors."""
|
"""Test the Ring binary sensors."""
|
||||||
await setup_platform(hass, Platform.BINARY_SENSOR)
|
# Create the entity so it is not ignored by the deprecation check
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
motion_state = hass.states.get("binary_sensor.front_door_motion")
|
entity_id = f"binary_sensor.{device_name}_{alert_kind}"
|
||||||
assert motion_state is not None
|
unique_id = f"{device_id}-{alert_kind}"
|
||||||
assert motion_state.state == "on"
|
|
||||||
assert motion_state.attributes["device_class"] == "motion"
|
|
||||||
|
|
||||||
front_ding_state = hass.states.get("binary_sensor.front_door_ding")
|
entity_registry.async_get_or_create(
|
||||||
assert front_ding_state is not None
|
domain=BINARY_SENSOR_DOMAIN,
|
||||||
assert front_ding_state.state == "off"
|
platform=DOMAIN,
|
||||||
|
unique_id=unique_id,
|
||||||
|
suggested_object_id=f"{device_name}_{alert_kind}",
|
||||||
|
config_entry=mock_config_entry,
|
||||||
|
)
|
||||||
|
with patch("homeassistant.components.ring.PLATFORMS", [Platform.BINARY_SENSOR]):
|
||||||
|
assert await async_setup_component(hass, DOMAIN, {})
|
||||||
|
|
||||||
ingress_ding_state = hass.states.get("binary_sensor.ingress_ding")
|
on_event_cb = mock_ring_event_listener_class.return_value.add_notification_callback.call_args.args[
|
||||||
assert ingress_ding_state is not None
|
0
|
||||||
assert ingress_ding_state.state == "off"
|
]
|
||||||
|
|
||||||
|
# Default state is set to off
|
||||||
|
|
||||||
|
state = hass.states.get(entity_id)
|
||||||
|
assert state is not None
|
||||||
|
assert state.state == STATE_OFF
|
||||||
|
assert state.attributes["device_class"] == device_class
|
||||||
|
|
||||||
|
# A new alert sets to on
|
||||||
|
event = RingEvent(
|
||||||
|
1234546, device_id, "Foo", "Bar", time.time(), 180, kind=alert_kind, state=None
|
||||||
|
)
|
||||||
|
mock_ring_client.active_alerts.return_value = [event]
|
||||||
|
on_event_cb(event)
|
||||||
|
state = hass.states.get(entity_id)
|
||||||
|
assert state is not None
|
||||||
|
assert state.state == STATE_ON
|
||||||
|
|
||||||
|
# Test that another event resets the expiry callback
|
||||||
|
freezer.tick(60)
|
||||||
|
async_fire_time_changed(hass)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
event = RingEvent(
|
||||||
|
1234546, device_id, "Foo", "Bar", time.time(), 180, kind=alert_kind, state=None
|
||||||
|
)
|
||||||
|
mock_ring_client.active_alerts.return_value = [event]
|
||||||
|
on_event_cb(event)
|
||||||
|
state = hass.states.get(entity_id)
|
||||||
|
assert state is not None
|
||||||
|
assert state.state == STATE_ON
|
||||||
|
|
||||||
|
freezer.tick(120)
|
||||||
|
async_fire_time_changed(hass)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
state = hass.states.get(entity_id)
|
||||||
|
assert state is not None
|
||||||
|
assert state.state == STATE_ON
|
||||||
|
|
||||||
|
# Test the second alert has expired
|
||||||
|
freezer.tick(60)
|
||||||
|
async_fire_time_changed(hass)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
state = hass.states.get(entity_id)
|
||||||
|
assert state is not None
|
||||||
|
assert state.state == STATE_OFF
|
||||||
|
|
||||||
|
|
||||||
|
async def test_binary_sensor_not_exists_with_deprecation(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: ConfigEntry,
|
||||||
|
mock_ring_client: Ring,
|
||||||
|
entity_registry: er.EntityRegistry,
|
||||||
|
) -> None:
|
||||||
|
"""Test the deprecated Ring binary sensors are deleted or raise issues."""
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
entity_id = "binary_sensor.front_door_motion"
|
||||||
|
|
||||||
|
assert not hass.states.get(entity_id)
|
||||||
|
with patch("homeassistant.components.ring.PLATFORMS", [Platform.BINARY_SENSOR]):
|
||||||
|
assert await async_setup_component(hass, DOMAIN, {})
|
||||||
|
|
||||||
|
assert not entity_registry.async_get(entity_id)
|
||||||
|
assert not er.async_entries_for_config_entry(
|
||||||
|
entity_registry, mock_config_entry.entry_id
|
||||||
|
)
|
||||||
|
assert not hass.states.get(entity_id)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("entity_disabled", "entity_has_automations"),
|
||||||
|
[
|
||||||
|
pytest.param(False, False, id="without-automations"),
|
||||||
|
pytest.param(False, True, id="with-automations"),
|
||||||
|
pytest.param(True, False, id="disabled"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_binary_sensor_exists_with_deprecation(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
mock_config_entry: ConfigEntry,
|
||||||
|
mock_ring_client: Ring,
|
||||||
|
entity_registry: er.EntityRegistry,
|
||||||
|
issue_registry: ir.IssueRegistry,
|
||||||
|
entity_disabled: bool,
|
||||||
|
entity_has_automations: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Test the deprecated Ring binary sensors are deleted or raise issues."""
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
entity_id = "binary_sensor.front_door_motion"
|
||||||
|
unique_id = f"{FRONT_DOOR_DEVICE_ID}-motion"
|
||||||
|
issue_id = f"deprecated_entity_{entity_id}_automation.test_automation"
|
||||||
|
|
||||||
|
if entity_has_automations:
|
||||||
|
await setup_automation(hass, "test_automation", entity_id)
|
||||||
|
|
||||||
|
entity = entity_registry.async_get_or_create(
|
||||||
|
domain=BINARY_SENSOR_DOMAIN,
|
||||||
|
platform=DOMAIN,
|
||||||
|
unique_id=unique_id,
|
||||||
|
suggested_object_id="front_door_motion",
|
||||||
|
config_entry=mock_config_entry,
|
||||||
|
disabled_by=er.RegistryEntryDisabler.USER if entity_disabled else None,
|
||||||
|
)
|
||||||
|
assert entity.entity_id == entity_id
|
||||||
|
assert not hass.states.get(entity_id)
|
||||||
|
with patch("homeassistant.components.ring.PLATFORMS", [Platform.BINARY_SENSOR]):
|
||||||
|
assert await async_setup_component(hass, DOMAIN, {})
|
||||||
|
|
||||||
|
entity = entity_registry.async_get(entity_id)
|
||||||
|
# entity and state will be none if removed from registry
|
||||||
|
assert (entity is None) == entity_disabled
|
||||||
|
assert (hass.states.get(entity_id) is None) == entity_disabled
|
||||||
|
|
||||||
|
assert (
|
||||||
|
issue_registry.async_get_issue(DOMAIN, issue_id) is not None
|
||||||
|
) == entity_has_automations
|
||||||
|
@ -2,19 +2,23 @@
|
|||||||
|
|
||||||
from freezegun.api import FrozenDateTimeFactory
|
from freezegun.api import FrozenDateTimeFactory
|
||||||
import pytest
|
import pytest
|
||||||
from ring_doorbell import AuthenticationError, RingError, RingTimeout
|
from ring_doorbell import AuthenticationError, Ring, RingError, RingTimeout
|
||||||
|
|
||||||
from homeassistant.components import ring
|
from homeassistant.components import ring
|
||||||
|
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
|
||||||
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
|
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
|
||||||
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
|
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
|
||||||
from homeassistant.components.ring import DOMAIN
|
from homeassistant.components.ring import DOMAIN
|
||||||
from homeassistant.components.ring.const import SCAN_INTERVAL
|
from homeassistant.components.ring.const import CONF_LISTEN_CREDENTIALS, SCAN_INTERVAL
|
||||||
|
from homeassistant.components.ring.coordinator import RingEventListener
|
||||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
|
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
|
||||||
from homeassistant.const import CONF_TOKEN, CONF_USERNAME
|
from homeassistant.const import CONF_TOKEN, CONF_USERNAME
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import entity_registry as er, issue_registry as ir
|
from homeassistant.helpers import entity_registry as er, issue_registry as ir
|
||||||
from homeassistant.setup import async_setup_component
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
|
from .device_mocks import FRONT_DOOR_DEVICE_ID
|
||||||
|
|
||||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||||
|
|
||||||
|
|
||||||
@ -413,3 +417,63 @@ async def test_token_updated(
|
|||||||
async_fire_time_changed(hass)
|
async_fire_time_changed(hass)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert mock_config_entry.data[CONF_TOKEN] == {"access_token": "new-mock-token"}
|
assert mock_config_entry.data[CONF_TOKEN] == {"access_token": "new-mock-token"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_listen_token_updated(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
freezer: FrozenDateTimeFactory,
|
||||||
|
mock_config_entry: MockConfigEntry,
|
||||||
|
mock_ring_client,
|
||||||
|
mock_ring_event_listener_class,
|
||||||
|
) -> None:
|
||||||
|
"""Test that the listener token value is updated in the config entry.
|
||||||
|
|
||||||
|
This simulates the api calling the callback.
|
||||||
|
"""
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||||
|
|
||||||
|
assert mock_ring_event_listener_class.call_count == 1
|
||||||
|
token_updater = mock_ring_event_listener_class.call_args.args[2]
|
||||||
|
|
||||||
|
assert mock_config_entry.data.get(CONF_LISTEN_CREDENTIALS) is None
|
||||||
|
token_updater({"listen_access_token": "mock-token"})
|
||||||
|
assert mock_config_entry.data.get(CONF_LISTEN_CREDENTIALS) == {
|
||||||
|
"listen_access_token": "mock-token"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_no_listen_start(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
entity_registry: er.EntityRegistry,
|
||||||
|
mock_ring_event_listener_class: type[RingEventListener],
|
||||||
|
mock_ring_client: Ring,
|
||||||
|
) -> None:
|
||||||
|
"""Test behaviour if listener doesn't start."""
|
||||||
|
mock_entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
version=1,
|
||||||
|
data={"username": "foo", "token": {}},
|
||||||
|
)
|
||||||
|
# Create a binary sensor entity so it is not ignored by the deprecation check
|
||||||
|
# and the listener will start
|
||||||
|
entity_registry.async_get_or_create(
|
||||||
|
domain=BINARY_SENSOR_DOMAIN,
|
||||||
|
platform=DOMAIN,
|
||||||
|
unique_id=f"{FRONT_DOOR_DEVICE_ID}-motion",
|
||||||
|
suggested_object_id=f"{FRONT_DOOR_DEVICE_ID}_motion",
|
||||||
|
config_entry=mock_entry,
|
||||||
|
)
|
||||||
|
mock_ring_event_listener_class.do_not_start = True
|
||||||
|
|
||||||
|
mock_ring_event_listener_class.return_value.started = False
|
||||||
|
|
||||||
|
mock_entry.add_to_hass(hass)
|
||||||
|
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert "Ring event listener failed to start after 10 seconds" in [
|
||||||
|
record.message for record in caplog.records if record.levelname == "WARNING"
|
||||||
|
]
|
||||||
|
@ -1,17 +1,26 @@
|
|||||||
"""The tests for the Ring sensor platform."""
|
"""The tests for the Ring sensor platform."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from freezegun.api import FrozenDateTimeFactory
|
from freezegun.api import FrozenDateTimeFactory
|
||||||
import pytest
|
import pytest
|
||||||
|
from ring_doorbell import Ring
|
||||||
|
|
||||||
from homeassistant.components.ring.const import SCAN_INTERVAL
|
from homeassistant.components.ring.const import DOMAIN, SCAN_INTERVAL
|
||||||
from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass
|
from homeassistant.components.sensor import (
|
||||||
|
ATTR_STATE_CLASS,
|
||||||
|
DOMAIN as SENSOR_DOMAIN,
|
||||||
|
SensorStateClass,
|
||||||
|
)
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
from .common import setup_platform
|
from .common import setup_platform
|
||||||
|
from .device_mocks import FRONT_DEVICE_ID, FRONT_DOOR_DEVICE_ID, INGRESS_DEVICE_ID
|
||||||
|
|
||||||
from tests.common import async_fire_time_changed
|
from tests.common import async_fire_time_changed
|
||||||
|
|
||||||
@ -107,13 +116,23 @@ async def test_health_sensor(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("device_name", "sensor_name", "expected_value"),
|
("device_id", "device_name", "sensor_name", "expected_value"),
|
||||||
[
|
[
|
||||||
("front_door", "last_motion", "2017-03-05T15:03:40+00:00"),
|
(
|
||||||
("front_door", "last_ding", "2018-03-05T15:03:40+00:00"),
|
FRONT_DOOR_DEVICE_ID,
|
||||||
("front_door", "last_activity", "2018-03-05T15:03:40+00:00"),
|
"front_door",
|
||||||
("front", "last_motion", "2017-03-05T15:03:40+00:00"),
|
"last_motion",
|
||||||
("ingress", "last_activity", "2024-02-02T11:21:24+00:00"),
|
"2017-03-05T15:03:40+00:00",
|
||||||
|
),
|
||||||
|
(FRONT_DOOR_DEVICE_ID, "front_door", "last_ding", "2018-03-05T15:03:40+00:00"),
|
||||||
|
(
|
||||||
|
FRONT_DOOR_DEVICE_ID,
|
||||||
|
"front_door",
|
||||||
|
"last_activity",
|
||||||
|
"2018-03-05T15:03:40+00:00",
|
||||||
|
),
|
||||||
|
(FRONT_DEVICE_ID, "front", "last_motion", "2017-03-05T15:03:40+00:00"),
|
||||||
|
(INGRESS_DEVICE_ID, "ingress", "last_activity", "2024-02-02T11:21:24+00:00"),
|
||||||
],
|
],
|
||||||
ids=[
|
ids=[
|
||||||
"doorbell-motion",
|
"doorbell-motion",
|
||||||
@ -125,14 +144,31 @@ async def test_health_sensor(
|
|||||||
)
|
)
|
||||||
async def test_history_sensor(
|
async def test_history_sensor(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mock_ring_client,
|
mock_ring_client: Ring,
|
||||||
|
mock_config_entry: ConfigEntry,
|
||||||
|
entity_registry: er.EntityRegistry,
|
||||||
freezer: FrozenDateTimeFactory,
|
freezer: FrozenDateTimeFactory,
|
||||||
device_name,
|
device_id: int,
|
||||||
sensor_name,
|
device_name: str,
|
||||||
expected_value,
|
sensor_name: str,
|
||||||
|
expected_value: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test the Ring sensors."""
|
"""Test the Ring sensors."""
|
||||||
await setup_platform(hass, "sensor")
|
# Create the entity so it is not ignored by the deprecation check
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
entity_id = f"sensor.{device_name}_{sensor_name}"
|
||||||
|
unique_id = f"{device_id}-{sensor_name}"
|
||||||
|
|
||||||
|
entity_registry.async_get_or_create(
|
||||||
|
domain=SENSOR_DOMAIN,
|
||||||
|
platform=DOMAIN,
|
||||||
|
unique_id=unique_id,
|
||||||
|
suggested_object_id=f"{device_name}_{sensor_name}",
|
||||||
|
config_entry=mock_config_entry,
|
||||||
|
)
|
||||||
|
with patch("homeassistant.components.ring.PLATFORMS", [Platform.SENSOR]):
|
||||||
|
assert await async_setup_component(hass, DOMAIN, {})
|
||||||
|
|
||||||
entity_id = f"sensor.{device_name}_{sensor_name}"
|
entity_id = f"sensor.{device_name}_{sensor_name}"
|
||||||
sensor_state = hass.states.get(entity_id)
|
sensor_state = hass.states.get(entity_id)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user