Fix ring realtime events (#128083)

This commit is contained in:
Steven B 2024-10-11 16:17:32 +01:00 committed by Franck Nijhof
parent 571bfaf5d7
commit ee9525cc00
No known key found for this signature in database
GPG Key ID: D62583BA8AB11CA3
6 changed files with 135 additions and 49 deletions

View File

@ -10,13 +10,9 @@ 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 from homeassistant.const import APPLICATION_NAME, CONF_DEVICE_ID, CONF_TOKEN
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import ( from homeassistant.helpers import device_registry as dr, entity_registry as er
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 .const import CONF_LISTEN_CREDENTIALS, DOMAIN, PLATFORMS from .const import CONF_LISTEN_CREDENTIALS, DOMAIN, PLATFORMS
@ -38,18 +34,12 @@ class RingData:
type RingConfigEntry = ConfigEntry[RingData] type RingConfigEntry = ConfigEntry[RingData]
async def get_auth_agent_id(hass: HomeAssistant) -> tuple[str, str]: def get_auth_user_agent() -> str:
"""Return user-agent and hardware id for Auth instantiation. """Return user-agent for Auth instantiation.
user_agent will be the display name in the ring.com authorised devices. 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" return 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:
@ -69,13 +59,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: RingConfigEntry) -> bool
data={**entry.data, CONF_LISTEN_CREDENTIALS: token}, data={**entry.data, CONF_LISTEN_CREDENTIALS: token},
) )
user_agent, hardware_id = await get_auth_agent_id(hass) user_agent = get_auth_user_agent()
client_session = async_get_clientsession(hass) client_session = async_get_clientsession(hass)
auth = Auth( auth = Auth(
user_agent, user_agent,
entry.data[CONF_TOKEN], entry.data[CONF_TOKEN],
token_updater, token_updater,
hardware_id=hardware_id, hardware_id=entry.data[CONF_DEVICE_ID],
http_client_session=client_session, http_client_session=client_session,
) )
ring = Ring(auth) ring = Ring(auth)
@ -138,3 +128,25 @@ async def _migrate_old_unique_ids(hass: HomeAssistant, entry_id: str) -> None:
return None return None
await er.async_migrate_entries(hass, entry_id, _async_migrator) await er.async_migrate_entries(hass, entry_id, _async_migrator)
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate old config entry."""
entry_version = entry.version
entry_minor_version = entry.minor_version
new_minor_version = 2
if entry_version == 1 and entry_minor_version == 1:
_LOGGER.debug(
"Migrating from version %s.%s", entry_version, entry_minor_version
)
hardware_id = str(uuid.uuid4())
hass.config_entries.async_update_entry(
entry,
data={**entry.data, CONF_DEVICE_ID: hardware_id},
minor_version=new_minor_version,
)
_LOGGER.debug(
"Migration to version %s.%s complete", entry_version, new_minor_version
)
return True

View File

@ -3,18 +3,25 @@
from collections.abc import Mapping from collections.abc import Mapping
import logging import logging
from typing import Any from typing import Any
import uuid
from ring_doorbell import Auth, AuthenticationError, Requires2FAError 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 CONF_NAME, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.const import (
CONF_DEVICE_ID,
CONF_NAME,
CONF_PASSWORD,
CONF_TOKEN,
CONF_USERNAME,
)
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 . import get_auth_user_agent
from .const import CONF_2FA, DOMAIN from .const import CONF_2FA, CONF_CONFIG_ENTRY_MINOR_VERSION, DOMAIN
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -23,11 +30,15 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
) )
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str}) STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str})
STEP_RECONFIGURE_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, hardware_id: str, 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) user_agent = get_auth_user_agent()
auth = Auth( auth = Auth(
user_agent, user_agent,
http_client_session=async_get_clientsession(hass), http_client_session=async_get_clientsession(hass),
@ -52,8 +63,10 @@ class RingConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Ring.""" """Handle a config flow for Ring."""
VERSION = 1 VERSION = 1
MINOR_VERSION = CONF_CONFIG_ENTRY_MINOR_VERSION
user_pass: dict[str, Any] = {} user_pass: dict[str, Any] = {}
hardware_id: str | None = None
reauth_entry: ConfigEntry | None = None reauth_entry: ConfigEntry | None = None
async def async_step_user( async def async_step_user(
@ -64,8 +77,10 @@ class RingConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None: if user_input is not None:
await self.async_set_unique_id(user_input[CONF_USERNAME]) await self.async_set_unique_id(user_input[CONF_USERNAME])
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
if not self.hardware_id:
self.hardware_id = str(uuid.uuid4())
try: try:
token = await validate_input(self.hass, user_input) token = await validate_input(self.hass, self.hardware_id, user_input)
except Require2FA: except Require2FA:
self.user_pass = user_input self.user_pass = user_input
@ -78,7 +93,11 @@ class RingConfigFlow(ConfigFlow, domain=DOMAIN):
else: else:
return self.async_create_entry( return self.async_create_entry(
title=user_input[CONF_USERNAME], title=user_input[CONF_USERNAME],
data={CONF_USERNAME: user_input[CONF_USERNAME], CONF_TOKEN: token}, data={
CONF_DEVICE_ID: self.hardware_id,
CONF_USERNAME: user_input[CONF_USERNAME],
CONF_TOKEN: token,
},
) )
return self.async_show_form( return self.async_show_form(
@ -120,8 +139,13 @@ class RingConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input: if user_input:
user_input[CONF_USERNAME] = self.reauth_entry.data[CONF_USERNAME] user_input[CONF_USERNAME] = self.reauth_entry.data[CONF_USERNAME]
# Reauth will use the same hardware id and re-authorise an existing
# authorised device.
if not self.hardware_id:
self.hardware_id = self.reauth_entry.data[CONF_DEVICE_ID]
assert self.hardware_id
try: try:
token = await validate_input(self.hass, user_input) token = await validate_input(self.hass, self.hardware_id, user_input)
except Require2FA: except Require2FA:
self.user_pass = user_input self.user_pass = user_input
return await self.async_step_2fa() return await self.async_step_2fa()
@ -134,6 +158,7 @@ class RingConfigFlow(ConfigFlow, domain=DOMAIN):
data = { data = {
CONF_USERNAME: user_input[CONF_USERNAME], CONF_USERNAME: user_input[CONF_USERNAME],
CONF_TOKEN: token, CONF_TOKEN: token,
CONF_DEVICE_ID: self.hardware_id,
} }
self.hass.config_entries.async_update_entry( self.hass.config_entries.async_update_entry(
self.reauth_entry, data=data self.reauth_entry, data=data

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta from datetime import timedelta
from typing import Final
from homeassistant.const import Platform from homeassistant.const import Platform
@ -31,3 +32,5 @@ SCAN_INTERVAL = timedelta(minutes=1)
CONF_2FA = "2fa" CONF_2FA = "2fa"
CONF_LISTEN_CREDENTIALS = "listen_token" CONF_LISTEN_CREDENTIALS = "listen_token"
CONF_CONFIG_ENTRY_MINOR_VERSION: Final = 2

View File

@ -8,7 +8,8 @@ import pytest
import ring_doorbell import ring_doorbell
from homeassistant.components.ring import DOMAIN from homeassistant.components.ring import DOMAIN
from homeassistant.const import CONF_USERNAME from homeassistant.components.ring.const import CONF_CONFIG_ENTRY_MINOR_VERSION
from homeassistant.const import CONF_DEVICE_ID, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .device_mocks import get_devices_data, get_mock_devices from .device_mocks import get_devices_data, get_mock_devices
@ -16,6 +17,8 @@ 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
MOCK_HARDWARE_ID = "foo-bar"
@pytest.fixture @pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]: def mock_setup_entry() -> Generator[AsyncMock]:
@ -116,10 +119,13 @@ def mock_config_entry() -> MockConfigEntry:
title="Ring", title="Ring",
domain=DOMAIN, domain=DOMAIN,
data={ data={
CONF_DEVICE_ID: MOCK_HARDWARE_ID,
CONF_USERNAME: "foo@bar.com", CONF_USERNAME: "foo@bar.com",
"token": {"access_token": "mock-token"}, "token": {"access_token": "mock-token"},
}, },
unique_id="foo@bar.com", unique_id="foo@bar.com",
version=1,
minor_version=CONF_CONFIG_ENTRY_MINOR_VERSION,
) )

View File

@ -1,16 +1,18 @@
"""Test the Ring config flow.""" """Test the Ring config flow."""
from unittest.mock import AsyncMock, Mock from unittest.mock import AsyncMock, Mock, patch
import pytest import pytest
import ring_doorbell import ring_doorbell
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components.ring import DOMAIN from homeassistant.components.ring import DOMAIN
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_DEVICE_ID, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
from .conftest import MOCK_HARDWARE_ID
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@ -27,17 +29,19 @@ async def test_form(
assert result["type"] is FlowResultType.FORM assert result["type"] is FlowResultType.FORM
assert result["errors"] == {} assert result["errors"] == {}
result2 = await hass.config_entries.flow.async_configure( with patch("uuid.uuid4", return_value=MOCK_HARDWARE_ID):
result["flow_id"], result2 = await hass.config_entries.flow.async_configure(
{"username": "hello@home-assistant.io", "password": "test-password"}, result["flow_id"],
) {"username": "hello@home-assistant.io", "password": "test-password"},
await hass.async_block_till_done() )
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "hello@home-assistant.io" assert result2["title"] == "hello@home-assistant.io"
assert result2["data"] == { assert result2["data"] == {
"username": "hello@home-assistant.io", CONF_DEVICE_ID: MOCK_HARDWARE_ID,
"token": {"access_token": "mock-token"}, CONF_USERNAME: "hello@home-assistant.io",
CONF_TOKEN: {"access_token": "mock-token"},
} }
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
@ -80,13 +84,14 @@ async def test_form_2fa(
assert result["errors"] == {} assert result["errors"] == {}
mock_ring_auth.async_fetch_token.side_effect = ring_doorbell.Requires2FAError mock_ring_auth.async_fetch_token.side_effect = ring_doorbell.Requires2FAError
result2 = await hass.config_entries.flow.async_configure( with patch("uuid.uuid4", return_value=MOCK_HARDWARE_ID):
result["flow_id"], result2 = await hass.config_entries.flow.async_configure(
{ result["flow_id"],
CONF_USERNAME: "foo@bar.com", {
CONF_PASSWORD: "fake-password", CONF_USERNAME: "foo@bar.com",
}, CONF_PASSWORD: "fake-password",
) },
)
await hass.async_block_till_done() await hass.async_block_till_done()
mock_ring_auth.async_fetch_token.assert_called_once_with( mock_ring_auth.async_fetch_token.assert_called_once_with(
"foo@bar.com", "fake-password", None "foo@bar.com", "fake-password", None
@ -107,8 +112,9 @@ async def test_form_2fa(
assert result3["type"] is FlowResultType.CREATE_ENTRY assert result3["type"] is FlowResultType.CREATE_ENTRY
assert result3["title"] == "foo@bar.com" assert result3["title"] == "foo@bar.com"
assert result3["data"] == { assert result3["data"] == {
"username": "foo@bar.com", CONF_DEVICE_ID: MOCK_HARDWARE_ID,
"token": "new-foobar", CONF_USERNAME: "foo@bar.com",
CONF_TOKEN: "new-foobar",
} }
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
@ -154,8 +160,9 @@ async def test_reauth(
assert result3["type"] is FlowResultType.ABORT assert result3["type"] is FlowResultType.ABORT
assert result3["reason"] == "reauth_successful" assert result3["reason"] == "reauth_successful"
assert mock_added_config_entry.data == { assert mock_added_config_entry.data == {
"username": "foo@bar.com", CONF_DEVICE_ID: MOCK_HARDWARE_ID,
"token": "new-foobar", CONF_USERNAME: "foo@bar.com",
CONF_TOKEN: "new-foobar",
} }
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1
@ -216,8 +223,9 @@ async def test_reauth_error(
assert result3["type"] is FlowResultType.ABORT assert result3["type"] is FlowResultType.ABORT
assert result3["reason"] == "reauth_successful" assert result3["reason"] == "reauth_successful"
assert mock_added_config_entry.data == { assert mock_added_config_entry.data == {
"username": "foo@bar.com", CONF_DEVICE_ID: MOCK_HARDWARE_ID,
"token": "new-foobar", CONF_USERNAME: "foo@bar.com",
CONF_TOKEN: "new-foobar",
} }
assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1

View File

@ -1,5 +1,7 @@
"""The tests for the Ring component.""" """The tests for the Ring component."""
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
from ring_doorbell import AuthenticationError, Ring, RingError, RingTimeout from ring_doorbell import AuthenticationError, Ring, RingError, RingTimeout
@ -12,11 +14,12 @@ from homeassistant.components.ring import DOMAIN
from homeassistant.components.ring.const import CONF_LISTEN_CREDENTIALS, SCAN_INTERVAL from homeassistant.components.ring.const import CONF_LISTEN_CREDENTIALS, SCAN_INTERVAL
from homeassistant.components.ring.coordinator import RingEventListener 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_DEVICE_ID, CONF_TOKEN, CONF_USERNAME
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 homeassistant.setup import async_setup_component
from .conftest import MOCK_HARDWARE_ID
from .device_mocks import FRONT_DOOR_DEVICE_ID 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
@ -450,3 +453,32 @@ async def test_no_listen_start(
assert "Ring event listener failed to start after 10 seconds" in [ assert "Ring event listener failed to start after 10 seconds" in [
record.message for record in caplog.records if record.levelname == "WARNING" record.message for record in caplog.records if record.levelname == "WARNING"
] ]
async def test_migrate_create_device_id(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test migration creates new device id created."""
entry = MockConfigEntry(
title="Ring",
domain=DOMAIN,
data={
CONF_USERNAME: "foo@bar.com",
"token": {"access_token": "mock-token"},
},
unique_id="foo@bar.com",
version=1,
minor_version=1,
)
entry.add_to_hass(hass)
with patch("uuid.uuid4", return_value=MOCK_HARDWARE_ID):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.minor_version == 2
assert CONF_DEVICE_ID in entry.data
assert entry.data[CONF_DEVICE_ID] == MOCK_HARDWARE_ID
assert "Migration to version 1.2 complete" in caplog.text