Allow multiple configuration entries for nest integration (#73720)
* Add multiple config entry support for Nest * Set a config entry unique id based on nest project id * Add missing translations and remove untested committed * Remove unnecessary translation * Remove dead code * Remove old handling to avoid duplicate error logs
This commit is contained in:
parent
a96aa64dd1
commit
cf9cab900e
13 changed files with 161 additions and 116 deletions
|
@ -77,9 +77,6 @@ from .media_source import (
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
DATA_NEST_UNAVAILABLE = "nest_unavailable"
|
|
||||||
|
|
||||||
NEST_SETUP_NOTIFICATION = "nest_setup"
|
|
||||||
|
|
||||||
SENSOR_SCHEMA = vol.Schema(
|
SENSOR_SCHEMA = vol.Schema(
|
||||||
{vol.Optional(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list)}
|
{vol.Optional(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list)}
|
||||||
|
@ -179,13 +176,16 @@ class SignalUpdateCallback:
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up Nest from a config entry with dispatch between old/new flows."""
|
"""Set up Nest from a config entry with dispatch between old/new flows."""
|
||||||
|
|
||||||
config_mode = config_flow.get_config_mode(hass)
|
config_mode = config_flow.get_config_mode(hass)
|
||||||
if config_mode == config_flow.ConfigMode.LEGACY:
|
if config_mode == config_flow.ConfigMode.LEGACY:
|
||||||
return await async_setup_legacy_entry(hass, entry)
|
return await async_setup_legacy_entry(hass, entry)
|
||||||
|
|
||||||
if config_mode == config_flow.ConfigMode.SDM:
|
if config_mode == config_flow.ConfigMode.SDM:
|
||||||
await async_import_config(hass, entry)
|
await async_import_config(hass, entry)
|
||||||
|
elif entry.unique_id != entry.data[CONF_PROJECT_ID]:
|
||||||
|
hass.config_entries.async_update_entry(
|
||||||
|
entry, unique_id=entry.data[CONF_PROJECT_ID]
|
||||||
|
)
|
||||||
|
|
||||||
subscriber = await api.new_subscriber(hass, entry)
|
subscriber = await api.new_subscriber(hass, entry)
|
||||||
if not subscriber:
|
if not subscriber:
|
||||||
|
@ -205,31 +205,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
try:
|
try:
|
||||||
await subscriber.start_async()
|
await subscriber.start_async()
|
||||||
except AuthException as err:
|
except AuthException as err:
|
||||||
_LOGGER.debug("Subscriber authentication error: %s", err)
|
raise ConfigEntryAuthFailed(
|
||||||
raise ConfigEntryAuthFailed from err
|
f"Subscriber authentication error: {str(err)}"
|
||||||
|
) from err
|
||||||
except ConfigurationException as err:
|
except ConfigurationException as err:
|
||||||
_LOGGER.error("Configuration error: %s", err)
|
_LOGGER.error("Configuration error: %s", err)
|
||||||
subscriber.stop_async()
|
subscriber.stop_async()
|
||||||
return False
|
return False
|
||||||
except SubscriberException as err:
|
except SubscriberException as err:
|
||||||
if DATA_NEST_UNAVAILABLE not in hass.data[DOMAIN]:
|
|
||||||
_LOGGER.error("Subscriber error: %s", err)
|
|
||||||
hass.data[DOMAIN][DATA_NEST_UNAVAILABLE] = True
|
|
||||||
subscriber.stop_async()
|
subscriber.stop_async()
|
||||||
raise ConfigEntryNotReady from err
|
raise ConfigEntryNotReady(f"Subscriber error: {str(err)}") from err
|
||||||
|
|
||||||
try:
|
try:
|
||||||
device_manager = await subscriber.async_get_device_manager()
|
device_manager = await subscriber.async_get_device_manager()
|
||||||
except ApiException as err:
|
except ApiException as err:
|
||||||
if DATA_NEST_UNAVAILABLE not in hass.data[DOMAIN]:
|
|
||||||
_LOGGER.error("Device manager error: %s", err)
|
|
||||||
hass.data[DOMAIN][DATA_NEST_UNAVAILABLE] = True
|
|
||||||
subscriber.stop_async()
|
subscriber.stop_async()
|
||||||
raise ConfigEntryNotReady from err
|
raise ConfigEntryNotReady(f"Device manager error: {str(err)}") from err
|
||||||
|
|
||||||
hass.data[DOMAIN].pop(DATA_NEST_UNAVAILABLE, None)
|
hass.data[DOMAIN][entry.entry_id] = {
|
||||||
hass.data[DOMAIN][DATA_SUBSCRIBER] = subscriber
|
DATA_SUBSCRIBER: subscriber,
|
||||||
hass.data[DOMAIN][DATA_DEVICE_MANAGER] = device_manager
|
DATA_DEVICE_MANAGER: device_manager,
|
||||||
|
}
|
||||||
|
|
||||||
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
|
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
|
||||||
|
|
||||||
|
@ -252,7 +248,9 @@ async def async_import_config(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
CONF_SUBSCRIBER_ID_IMPORTED: True, # Don't delete user managed subscriber
|
CONF_SUBSCRIBER_ID_IMPORTED: True, # Don't delete user managed subscriber
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
hass.config_entries.async_update_entry(entry, data=new_data)
|
hass.config_entries.async_update_entry(
|
||||||
|
entry, data=new_data, unique_id=new_data[CONF_PROJECT_ID]
|
||||||
|
)
|
||||||
|
|
||||||
if entry.data["auth_implementation"] == INSTALLED_AUTH_DOMAIN:
|
if entry.data["auth_implementation"] == INSTALLED_AUTH_DOMAIN:
|
||||||
# App Auth credentials have been deprecated and must be re-created
|
# App Auth credentials have been deprecated and must be re-created
|
||||||
|
@ -288,13 +286,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
# Legacy API
|
# Legacy API
|
||||||
return True
|
return True
|
||||||
_LOGGER.debug("Stopping nest subscriber")
|
_LOGGER.debug("Stopping nest subscriber")
|
||||||
subscriber = hass.data[DOMAIN][DATA_SUBSCRIBER]
|
subscriber = hass.data[DOMAIN][entry.entry_id][DATA_SUBSCRIBER]
|
||||||
subscriber.stop_async()
|
subscriber.stop_async()
|
||||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
if unload_ok:
|
if unload_ok:
|
||||||
hass.data[DOMAIN].pop(DATA_SUBSCRIBER)
|
hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
hass.data[DOMAIN].pop(DATA_DEVICE_MANAGER)
|
|
||||||
hass.data[DOMAIN].pop(DATA_NEST_UNAVAILABLE, None)
|
|
||||||
|
|
||||||
return unload_ok
|
return unload_ok
|
||||||
|
|
||||||
|
|
|
@ -45,7 +45,9 @@ async def async_setup_sdm_entry(
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the cameras."""
|
"""Set up the cameras."""
|
||||||
|
|
||||||
device_manager: DeviceManager = hass.data[DOMAIN][DATA_DEVICE_MANAGER]
|
device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][
|
||||||
|
DATA_DEVICE_MANAGER
|
||||||
|
]
|
||||||
entities = []
|
entities = []
|
||||||
for device in device_manager.devices.values():
|
for device in device_manager.devices.values():
|
||||||
if (
|
if (
|
||||||
|
|
|
@ -82,7 +82,9 @@ async def async_setup_sdm_entry(
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the client entities."""
|
"""Set up the client entities."""
|
||||||
|
|
||||||
device_manager: DeviceManager = hass.data[DOMAIN][DATA_DEVICE_MANAGER]
|
device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][
|
||||||
|
DATA_DEVICE_MANAGER
|
||||||
|
]
|
||||||
entities = []
|
entities = []
|
||||||
for device in device_manager.devices.values():
|
for device in device_manager.devices.values():
|
||||||
if ThermostatHvacTrait.NAME in device.traits:
|
if ThermostatHvacTrait.NAME in device.traits:
|
||||||
|
|
|
@ -276,10 +276,6 @@ class NestFlowHandler(
|
||||||
if self.config_mode == ConfigMode.LEGACY:
|
if self.config_mode == ConfigMode.LEGACY:
|
||||||
return await self.async_step_init(user_input)
|
return await self.async_step_init(user_input)
|
||||||
self._data[DATA_SDM] = {}
|
self._data[DATA_SDM] = {}
|
||||||
# Reauth will update an existing entry
|
|
||||||
entries = self._async_current_entries()
|
|
||||||
if entries and self.source != SOURCE_REAUTH:
|
|
||||||
return self.async_abort(reason="single_instance_allowed")
|
|
||||||
if self.source == SOURCE_REAUTH:
|
if self.source == SOURCE_REAUTH:
|
||||||
return await super().async_step_user(user_input)
|
return await super().async_step_user(user_input)
|
||||||
# Application Credentials setup needs information from the user
|
# Application Credentials setup needs information from the user
|
||||||
|
@ -339,13 +335,16 @@ class NestFlowHandler(
|
||||||
"""Collect device access project from user input."""
|
"""Collect device access project from user input."""
|
||||||
errors = {}
|
errors = {}
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
if user_input[CONF_PROJECT_ID] == self._data[CONF_CLOUD_PROJECT_ID]:
|
project_id = user_input[CONF_PROJECT_ID]
|
||||||
|
if project_id == self._data[CONF_CLOUD_PROJECT_ID]:
|
||||||
_LOGGER.error(
|
_LOGGER.error(
|
||||||
"Device Access Project ID and Cloud Project ID must not be the same, see documentation"
|
"Device Access Project ID and Cloud Project ID must not be the same, see documentation"
|
||||||
)
|
)
|
||||||
errors[CONF_PROJECT_ID] = "wrong_project_id"
|
errors[CONF_PROJECT_ID] = "wrong_project_id"
|
||||||
else:
|
else:
|
||||||
self._data.update(user_input)
|
self._data.update(user_input)
|
||||||
|
await self.async_set_unique_id(project_id)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
return await super().async_step_user()
|
return await super().async_step_user()
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
|
@ -465,13 +464,11 @@ class NestFlowHandler(
|
||||||
async def async_step_finish(self, data: dict[str, Any] | None = None) -> FlowResult:
|
async def async_step_finish(self, data: dict[str, Any] | None = None) -> FlowResult:
|
||||||
"""Create an entry for the SDM flow."""
|
"""Create an entry for the SDM flow."""
|
||||||
assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API"
|
assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API"
|
||||||
await self.async_set_unique_id(DOMAIN)
|
# Update existing config entry when in the reauth flow.
|
||||||
# Update existing config entry when in the reauth flow. This
|
|
||||||
# integration only supports one config entry so remove any prior entries
|
|
||||||
# added before the "single_instance_allowed" check was added
|
|
||||||
if entry := self._async_reauth_entry():
|
if entry := self._async_reauth_entry():
|
||||||
self.hass.config_entries.async_update_entry(
|
self.hass.config_entries.async_update_entry(
|
||||||
entry, data=self._data, unique_id=DOMAIN
|
entry,
|
||||||
|
data=self._data,
|
||||||
)
|
)
|
||||||
await self.hass.config_entries.async_reload(entry.entry_id)
|
await self.hass.config_entries.async_reload(entry.entry_id)
|
||||||
return self.async_abort(reason="reauth_successful")
|
return self.async_abort(reason="reauth_successful")
|
||||||
|
|
|
@ -2,12 +2,16 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
from google_nest_sdm.device import Device
|
from google_nest_sdm.device import Device
|
||||||
from google_nest_sdm.device_traits import InfoTrait
|
from google_nest_sdm.device_traits import InfoTrait
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers import device_registry as dr
|
||||||
from homeassistant.helpers.entity import DeviceInfo
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DATA_DEVICE_MANAGER, DOMAIN
|
||||||
|
|
||||||
DEVICE_TYPE_MAP: dict[str, str] = {
|
DEVICE_TYPE_MAP: dict[str, str] = {
|
||||||
"sdm.devices.types.CAMERA": "Camera",
|
"sdm.devices.types.CAMERA": "Camera",
|
||||||
|
@ -66,3 +70,27 @@ class NestDeviceInfo:
|
||||||
names = [name for id, name in items]
|
names = [name for id, name in items]
|
||||||
return " ".join(names)
|
return " ".join(names)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_nest_devices(hass: HomeAssistant) -> Mapping[str, Device]:
|
||||||
|
"""Return a mapping of all nest devices for all config entries."""
|
||||||
|
devices = {}
|
||||||
|
for entry_id in hass.data[DOMAIN]:
|
||||||
|
if not (device_manager := hass.data[DOMAIN][entry_id].get(DATA_DEVICE_MANAGER)):
|
||||||
|
continue
|
||||||
|
devices.update(
|
||||||
|
{device.name: device for device in device_manager.devices.values()}
|
||||||
|
)
|
||||||
|
return devices
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_nest_devices_by_device_id(hass: HomeAssistant) -> Mapping[str, Device]:
|
||||||
|
"""Return a mapping of all nest devices by home assistant device id, for all config entries."""
|
||||||
|
device_registry = dr.async_get(hass)
|
||||||
|
devices = {}
|
||||||
|
for nest_device_id, device in async_nest_devices(hass).items():
|
||||||
|
if device_entry := device_registry.async_get_device({(DOMAIN, nest_device_id)}):
|
||||||
|
devices[device_entry.id] = device
|
||||||
|
return devices
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
"""Provides device automations for Nest."""
|
"""Provides device automations for Nest."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from google_nest_sdm.device_manager import DeviceManager
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components.automation import (
|
from homeassistant.components.automation import (
|
||||||
|
@ -14,11 +13,11 @@ from homeassistant.components.device_automation.exceptions import (
|
||||||
)
|
)
|
||||||
from homeassistant.components.homeassistant.triggers import event as event_trigger
|
from homeassistant.components.homeassistant.triggers import event as event_trigger
|
||||||
from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE
|
from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE
|
||||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
|
||||||
from homeassistant.helpers import device_registry as dr
|
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
from .const import DATA_DEVICE_MANAGER, DOMAIN
|
from .const import DOMAIN
|
||||||
|
from .device_info import async_nest_devices_by_device_id
|
||||||
from .events import DEVICE_TRAIT_TRIGGER_MAP, NEST_EVENT
|
from .events import DEVICE_TRAIT_TRIGGER_MAP, NEST_EVENT
|
||||||
|
|
||||||
DEVICE = "device"
|
DEVICE = "device"
|
||||||
|
@ -32,43 +31,18 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def async_get_nest_device_id(hass: HomeAssistant, device_id: str) -> str | None:
|
|
||||||
"""Get the nest API device_id from the HomeAssistant device_id."""
|
|
||||||
device_registry = dr.async_get(hass)
|
|
||||||
if device := device_registry.async_get(device_id):
|
|
||||||
for (domain, unique_id) in device.identifiers:
|
|
||||||
if domain == DOMAIN:
|
|
||||||
return unique_id
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def async_get_device_trigger_types(
|
|
||||||
hass: HomeAssistant, nest_device_id: str
|
|
||||||
) -> list[str]:
|
|
||||||
"""List event triggers supported for a Nest device."""
|
|
||||||
device_manager: DeviceManager = hass.data[DOMAIN][DATA_DEVICE_MANAGER]
|
|
||||||
if not (nest_device := device_manager.devices.get(nest_device_id)):
|
|
||||||
raise InvalidDeviceAutomationConfig(f"Nest device not found {nest_device_id}")
|
|
||||||
|
|
||||||
# Determine the set of event types based on the supported device traits
|
|
||||||
trigger_types = [
|
|
||||||
trigger_type
|
|
||||||
for trait in nest_device.traits
|
|
||||||
if (trigger_type := DEVICE_TRAIT_TRIGGER_MAP.get(trait))
|
|
||||||
]
|
|
||||||
return trigger_types
|
|
||||||
|
|
||||||
|
|
||||||
async def async_get_triggers(
|
async def async_get_triggers(
|
||||||
hass: HomeAssistant, device_id: str
|
hass: HomeAssistant, device_id: str
|
||||||
) -> list[dict[str, str]]:
|
) -> list[dict[str, str]]:
|
||||||
"""List device triggers for a Nest device."""
|
"""List device triggers for a Nest device."""
|
||||||
nest_device_id = async_get_nest_device_id(hass, device_id)
|
devices = async_nest_devices_by_device_id(hass)
|
||||||
if not nest_device_id:
|
if not (device := devices.get(device_id)):
|
||||||
raise InvalidDeviceAutomationConfig(f"Device not found {device_id}")
|
raise InvalidDeviceAutomationConfig(f"Device not found {device_id}")
|
||||||
trigger_types = async_get_device_trigger_types(hass, nest_device_id)
|
trigger_types = [
|
||||||
|
trigger_type
|
||||||
|
for trait in device.traits
|
||||||
|
if (trigger_type := DEVICE_TRAIT_TRIGGER_MAP.get(trait))
|
||||||
|
]
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
CONF_PLATFORM: DEVICE,
|
CONF_PLATFORM: DEVICE,
|
||||||
|
|
|
@ -27,10 +27,15 @@ def _async_get_nest_devices(
|
||||||
if DATA_SDM not in config_entry.data:
|
if DATA_SDM not in config_entry.data:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
if DATA_DEVICE_MANAGER not in hass.data[DOMAIN]:
|
if (
|
||||||
|
config_entry.entry_id not in hass.data[DOMAIN]
|
||||||
|
or DATA_DEVICE_MANAGER not in hass.data[DOMAIN][config_entry.entry_id]
|
||||||
|
):
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
device_manager: DeviceManager = hass.data[DOMAIN][DATA_DEVICE_MANAGER]
|
device_manager: DeviceManager = hass.data[DOMAIN][config_entry.entry_id][
|
||||||
|
DATA_DEVICE_MANAGER
|
||||||
|
]
|
||||||
return device_manager.devices
|
return device_manager.devices
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -25,7 +25,6 @@ import os
|
||||||
|
|
||||||
from google_nest_sdm.camera_traits import CameraClipPreviewTrait, CameraEventImageTrait
|
from google_nest_sdm.camera_traits import CameraClipPreviewTrait, CameraEventImageTrait
|
||||||
from google_nest_sdm.device import Device
|
from google_nest_sdm.device import Device
|
||||||
from google_nest_sdm.device_manager import DeviceManager
|
|
||||||
from google_nest_sdm.event import EventImageType, ImageEventBase
|
from google_nest_sdm.event import EventImageType, ImageEventBase
|
||||||
from google_nest_sdm.event_media import (
|
from google_nest_sdm.event_media import (
|
||||||
ClipPreviewSession,
|
ClipPreviewSession,
|
||||||
|
@ -57,8 +56,8 @@ from homeassistant.helpers.storage import Store
|
||||||
from homeassistant.helpers.template import DATE_STR_FORMAT
|
from homeassistant.helpers.template import DATE_STR_FORMAT
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from .const import DATA_DEVICE_MANAGER, DOMAIN
|
from .const import DOMAIN
|
||||||
from .device_info import NestDeviceInfo
|
from .device_info import NestDeviceInfo, async_nest_devices_by_device_id
|
||||||
from .events import EVENT_NAME_MAP, MEDIA_SOURCE_EVENT_TITLE_MAP
|
from .events import EVENT_NAME_MAP, MEDIA_SOURCE_EVENT_TITLE_MAP
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
@ -271,21 +270,13 @@ async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
|
||||||
@callback
|
@callback
|
||||||
def async_get_media_source_devices(hass: HomeAssistant) -> Mapping[str, Device]:
|
def async_get_media_source_devices(hass: HomeAssistant) -> Mapping[str, Device]:
|
||||||
"""Return a mapping of device id to eligible Nest event media devices."""
|
"""Return a mapping of device id to eligible Nest event media devices."""
|
||||||
if DATA_DEVICE_MANAGER not in hass.data[DOMAIN]:
|
devices = async_nest_devices_by_device_id(hass)
|
||||||
# Integration unloaded, or is legacy nest integration
|
return {
|
||||||
return {}
|
device_id: device
|
||||||
device_manager: DeviceManager = hass.data[DOMAIN][DATA_DEVICE_MANAGER]
|
for device_id, device in devices.items()
|
||||||
device_registry = dr.async_get(hass)
|
if CameraEventImageTrait.NAME in device.traits
|
||||||
devices = {}
|
or CameraClipPreviewTrait.NAME in device.traits
|
||||||
for device in device_manager.devices.values():
|
}
|
||||||
if not (
|
|
||||||
CameraEventImageTrait.NAME in device.traits
|
|
||||||
or CameraClipPreviewTrait.NAME in device.traits
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
if device_entry := device_registry.async_get_device({(DOMAIN, device.name)}):
|
|
||||||
devices[device_entry.id] = device
|
|
||||||
return devices
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
|
@ -36,7 +36,9 @@ async def async_setup_sdm_entry(
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the sensors."""
|
"""Set up the sensors."""
|
||||||
|
|
||||||
device_manager: DeviceManager = hass.data[DOMAIN][DATA_DEVICE_MANAGER]
|
device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][
|
||||||
|
DATA_DEVICE_MANAGER
|
||||||
|
]
|
||||||
entities: list[SensorEntity] = []
|
entities: list[SensorEntity] = []
|
||||||
for device in device_manager.devices.values():
|
for device in device_manager.devices.values():
|
||||||
if TemperatureTrait.NAME in device.traits:
|
if TemperatureTrait.NAME in device.traits:
|
||||||
|
|
|
@ -69,7 +69,7 @@
|
||||||
"subscriber_error": "Unknown subscriber error, see logs"
|
"subscriber_error": "Unknown subscriber error, see logs"
|
||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
|
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||||
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||||
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
|
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
|
||||||
"unknown_authorize_url_generation": "[%key:common::config_flow::abort::unknown_authorize_url_generation%]",
|
"unknown_authorize_url_generation": "[%key:common::config_flow::abort::unknown_authorize_url_generation%]",
|
||||||
|
|
|
@ -24,6 +24,7 @@ from homeassistant.setup import async_setup_component
|
||||||
|
|
||||||
from .common import (
|
from .common import (
|
||||||
DEVICE_ID,
|
DEVICE_ID,
|
||||||
|
PROJECT_ID,
|
||||||
SUBSCRIBER_ID,
|
SUBSCRIBER_ID,
|
||||||
TEST_CONFIG_APP_CREDS,
|
TEST_CONFIG_APP_CREDS,
|
||||||
TEST_CONFIG_YAML_ONLY,
|
TEST_CONFIG_YAML_ONLY,
|
||||||
|
@ -213,11 +214,18 @@ def config(
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config_entry_unique_id() -> str:
|
||||||
|
"""Fixture to set ConfigEntry unique id."""
|
||||||
|
return PROJECT_ID
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def config_entry(
|
def config_entry(
|
||||||
subscriber_id: str | None,
|
subscriber_id: str | None,
|
||||||
auth_implementation: str | None,
|
auth_implementation: str | None,
|
||||||
nest_test_config: NestTestConfig,
|
nest_test_config: NestTestConfig,
|
||||||
|
config_entry_unique_id: str,
|
||||||
) -> MockConfigEntry | None:
|
) -> MockConfigEntry | None:
|
||||||
"""Fixture that sets up the ConfigEntry for the test."""
|
"""Fixture that sets up the ConfigEntry for the test."""
|
||||||
if nest_test_config.config_entry_data is None:
|
if nest_test_config.config_entry_data is None:
|
||||||
|
@ -229,7 +237,7 @@ def config_entry(
|
||||||
else:
|
else:
|
||||||
del data[CONF_SUBSCRIBER_ID]
|
del data[CONF_SUBSCRIBER_ID]
|
||||||
data["auth_implementation"] = auth_implementation
|
data["auth_implementation"] = auth_implementation
|
||||||
return MockConfigEntry(domain=DOMAIN, data=data)
|
return MockConfigEntry(domain=DOMAIN, data=data, unique_id=config_entry_unique_id)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
|
|
|
@ -76,8 +76,8 @@ class OAuthFixture:
|
||||||
assert result.get("type") == "form"
|
assert result.get("type") == "form"
|
||||||
assert result.get("step_id") == "device_project"
|
assert result.get("step_id") == "device_project"
|
||||||
|
|
||||||
result = await self.async_configure(result, {"project_id": PROJECT_ID})
|
result = await self.async_configure(result, {"project_id": project_id})
|
||||||
await self.async_oauth_web_flow(result)
|
await self.async_oauth_web_flow(result, project_id=project_id)
|
||||||
|
|
||||||
async def async_oauth_web_flow(self, result: dict, project_id=PROJECT_ID) -> None:
|
async def async_oauth_web_flow(self, result: dict, project_id=PROJECT_ID) -> None:
|
||||||
"""Invoke the oauth flow for Web Auth with fake responses."""
|
"""Invoke the oauth flow for Web Auth with fake responses."""
|
||||||
|
@ -404,7 +404,7 @@ async def test_web_reauth(hass, oauth, setup_platform, config_entry):
|
||||||
entry = await oauth.async_finish_setup(result)
|
entry = await oauth.async_finish_setup(result)
|
||||||
# Verify existing tokens are replaced
|
# Verify existing tokens are replaced
|
||||||
entry.data["token"].pop("expires_at")
|
entry.data["token"].pop("expires_at")
|
||||||
assert entry.unique_id == DOMAIN
|
assert entry.unique_id == PROJECT_ID
|
||||||
assert entry.data["token"] == {
|
assert entry.data["token"] == {
|
||||||
"refresh_token": "mock-refresh-token",
|
"refresh_token": "mock-refresh-token",
|
||||||
"access_token": "mock-access-token",
|
"access_token": "mock-access-token",
|
||||||
|
@ -415,25 +415,51 @@ async def test_web_reauth(hass, oauth, setup_platform, config_entry):
|
||||||
assert entry.data.get("subscriber_id") == orig_subscriber_id # Not updated
|
assert entry.data.get("subscriber_id") == orig_subscriber_id # Not updated
|
||||||
|
|
||||||
|
|
||||||
async def test_single_config_entry(hass, setup_platform):
|
async def test_multiple_config_entries(hass, oauth, setup_platform):
|
||||||
"""Test that only a single config entry is allowed."""
|
"""Verify config flow can be started when existing config entry exists."""
|
||||||
await setup_platform()
|
await setup_platform()
|
||||||
|
|
||||||
|
entries = hass.config_entries.async_entries(DOMAIN)
|
||||||
|
assert len(entries) == 1
|
||||||
|
|
||||||
result = await hass.config_entries.flow.async_init(
|
result = await hass.config_entries.flow.async_init(
|
||||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
)
|
)
|
||||||
assert result["type"] == "abort"
|
await oauth.async_app_creds_flow(result, project_id="project-id-2")
|
||||||
assert result["reason"] == "single_instance_allowed"
|
entry = await oauth.async_finish_setup(result)
|
||||||
|
assert entry.title == "Mock Title"
|
||||||
|
assert "token" in entry.data
|
||||||
|
|
||||||
|
entries = hass.config_entries.async_entries(DOMAIN)
|
||||||
|
assert len(entries) == 2
|
||||||
|
|
||||||
|
|
||||||
async def test_unexpected_existing_config_entries(
|
async def test_duplicate_config_entries(hass, oauth, setup_platform):
|
||||||
|
"""Verify that config entries must be for unique projects."""
|
||||||
|
await setup_platform()
|
||||||
|
|
||||||
|
entries = hass.config_entries.async_entries(DOMAIN)
|
||||||
|
assert len(entries) == 1
|
||||||
|
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
assert result.get("type") == "form"
|
||||||
|
assert result.get("step_id") == "cloud_project"
|
||||||
|
|
||||||
|
result = await oauth.async_configure(result, {"cloud_project_id": CLOUD_PROJECT_ID})
|
||||||
|
assert result.get("type") == "form"
|
||||||
|
assert result.get("step_id") == "device_project"
|
||||||
|
|
||||||
|
result = await oauth.async_configure(result, {"project_id": PROJECT_ID})
|
||||||
|
assert result.get("type") == "abort"
|
||||||
|
assert result.get("reason") == "already_configured"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reauth_multiple_config_entries(
|
||||||
hass, oauth, setup_platform, config_entry
|
hass, oauth, setup_platform, config_entry
|
||||||
):
|
):
|
||||||
"""Test Nest reauthentication with multiple existing config entries."""
|
"""Test Nest reauthentication with multiple existing config entries."""
|
||||||
# Note that this case will not happen in the future since only a single
|
|
||||||
# instance is now allowed, but this may have been allowed in the past.
|
|
||||||
# On reauth, only one entry is kept and the others are deleted.
|
|
||||||
|
|
||||||
await setup_platform()
|
await setup_platform()
|
||||||
|
|
||||||
old_entry = MockConfigEntry(
|
old_entry = MockConfigEntry(
|
||||||
|
@ -461,7 +487,7 @@ async def test_unexpected_existing_config_entries(
|
||||||
entries = hass.config_entries.async_entries(DOMAIN)
|
entries = hass.config_entries.async_entries(DOMAIN)
|
||||||
assert len(entries) == 2
|
assert len(entries) == 2
|
||||||
entry = entries[0]
|
entry = entries[0]
|
||||||
assert entry.unique_id == DOMAIN
|
assert entry.unique_id == PROJECT_ID
|
||||||
entry.data["token"].pop("expires_at")
|
entry.data["token"].pop("expires_at")
|
||||||
assert entry.data["token"] == {
|
assert entry.data["token"] == {
|
||||||
"refresh_token": "mock-refresh-token",
|
"refresh_token": "mock-refresh-token",
|
||||||
|
@ -540,7 +566,7 @@ async def test_app_auth_yaml_reauth(hass, oauth, setup_platform, config_entry):
|
||||||
# Verify existing tokens are replaced
|
# Verify existing tokens are replaced
|
||||||
entry = await oauth.async_finish_setup(result, {"code": "1234"})
|
entry = await oauth.async_finish_setup(result, {"code": "1234"})
|
||||||
entry.data["token"].pop("expires_at")
|
entry.data["token"].pop("expires_at")
|
||||||
assert entry.unique_id == DOMAIN
|
assert entry.unique_id == PROJECT_ID
|
||||||
assert entry.data["token"] == {
|
assert entry.data["token"] == {
|
||||||
"refresh_token": "mock-refresh-token",
|
"refresh_token": "mock-refresh-token",
|
||||||
"access_token": "mock-access-token",
|
"access_token": "mock-access-token",
|
||||||
|
@ -570,7 +596,7 @@ async def test_web_auth_yaml_reauth(hass, oauth, setup_platform, config_entry):
|
||||||
# Verify existing tokens are replaced
|
# Verify existing tokens are replaced
|
||||||
entry = await oauth.async_finish_setup(result, {"code": "1234"})
|
entry = await oauth.async_finish_setup(result, {"code": "1234"})
|
||||||
entry.data["token"].pop("expires_at")
|
entry.data["token"].pop("expires_at")
|
||||||
assert entry.unique_id == DOMAIN
|
assert entry.unique_id == PROJECT_ID
|
||||||
assert entry.data["token"] == {
|
assert entry.data["token"] == {
|
||||||
"refresh_token": "mock-refresh-token",
|
"refresh_token": "mock-refresh-token",
|
||||||
"access_token": "mock-access-token",
|
"access_token": "mock-access-token",
|
||||||
|
@ -599,7 +625,7 @@ async def test_pubsub_subscription_strip_whitespace(
|
||||||
assert entry.title == "Import from configuration.yaml"
|
assert entry.title == "Import from configuration.yaml"
|
||||||
assert "token" in entry.data
|
assert "token" in entry.data
|
||||||
entry.data["token"].pop("expires_at")
|
entry.data["token"].pop("expires_at")
|
||||||
assert entry.unique_id == DOMAIN
|
assert entry.unique_id == PROJECT_ID
|
||||||
assert entry.data["token"] == {
|
assert entry.data["token"] == {
|
||||||
"refresh_token": "mock-refresh-token",
|
"refresh_token": "mock-refresh-token",
|
||||||
"access_token": "mock-access-token",
|
"access_token": "mock-access-token",
|
||||||
|
@ -643,7 +669,7 @@ async def test_pubsub_subscriber_config_entry_reauth(
|
||||||
# Entering an updated access token refreshs the config entry.
|
# Entering an updated access token refreshs the config entry.
|
||||||
entry = await oauth.async_finish_setup(result, {"code": "1234"})
|
entry = await oauth.async_finish_setup(result, {"code": "1234"})
|
||||||
entry.data["token"].pop("expires_at")
|
entry.data["token"].pop("expires_at")
|
||||||
assert entry.unique_id == DOMAIN
|
assert entry.unique_id == PROJECT_ID
|
||||||
assert entry.data["token"] == {
|
assert entry.data["token"] == {
|
||||||
"refresh_token": "mock-refresh-token",
|
"refresh_token": "mock-refresh-token",
|
||||||
"access_token": "mock-access-token",
|
"access_token": "mock-access-token",
|
||||||
|
|
|
@ -103,18 +103,18 @@ async def test_setup_configuration_failure(
|
||||||
|
|
||||||
@pytest.mark.parametrize("subscriber_side_effect", [SubscriberException()])
|
@pytest.mark.parametrize("subscriber_side_effect", [SubscriberException()])
|
||||||
async def test_setup_susbcriber_failure(
|
async def test_setup_susbcriber_failure(
|
||||||
hass, error_caplog, failing_subscriber, setup_base_platform
|
hass, warning_caplog, failing_subscriber, setup_base_platform
|
||||||
):
|
):
|
||||||
"""Test configuration error."""
|
"""Test configuration error."""
|
||||||
await setup_base_platform()
|
await setup_base_platform()
|
||||||
assert "Subscriber error:" in error_caplog.text
|
assert "Subscriber error:" in warning_caplog.text
|
||||||
|
|
||||||
entries = hass.config_entries.async_entries(DOMAIN)
|
entries = hass.config_entries.async_entries(DOMAIN)
|
||||||
assert len(entries) == 1
|
assert len(entries) == 1
|
||||||
assert entries[0].state is ConfigEntryState.SETUP_RETRY
|
assert entries[0].state is ConfigEntryState.SETUP_RETRY
|
||||||
|
|
||||||
|
|
||||||
async def test_setup_device_manager_failure(hass, error_caplog, setup_base_platform):
|
async def test_setup_device_manager_failure(hass, warning_caplog, setup_base_platform):
|
||||||
"""Test device manager api failure."""
|
"""Test device manager api failure."""
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.nest.api.GoogleNestSubscriber.start_async"
|
"homeassistant.components.nest.api.GoogleNestSubscriber.start_async"
|
||||||
|
@ -124,8 +124,7 @@ async def test_setup_device_manager_failure(hass, error_caplog, setup_base_platf
|
||||||
):
|
):
|
||||||
await setup_base_platform()
|
await setup_base_platform()
|
||||||
|
|
||||||
assert len(error_caplog.messages) == 1
|
assert "Device manager error:" in warning_caplog.text
|
||||||
assert "Device manager error:" in error_caplog.text
|
|
||||||
|
|
||||||
entries = hass.config_entries.async_entries(DOMAIN)
|
entries = hass.config_entries.async_entries(DOMAIN)
|
||||||
assert len(entries) == 1
|
assert len(entries) == 1
|
||||||
|
@ -273,3 +272,18 @@ async def test_remove_entry_delete_subscriber_failure(
|
||||||
|
|
||||||
entries = hass.config_entries.async_entries(DOMAIN)
|
entries = hass.config_entries.async_entries(DOMAIN)
|
||||||
assert not entries
|
assert not entries
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("config_entry_unique_id", [DOMAIN, None])
|
||||||
|
async def test_migrate_unique_id(
|
||||||
|
hass, error_caplog, setup_platform, config_entry, config_entry_unique_id
|
||||||
|
):
|
||||||
|
"""Test successful setup."""
|
||||||
|
|
||||||
|
assert config_entry.state is ConfigEntryState.NOT_LOADED
|
||||||
|
assert config_entry.unique_id == config_entry_unique_id
|
||||||
|
|
||||||
|
await setup_platform()
|
||||||
|
|
||||||
|
assert config_entry.state is ConfigEntryState.LOADED
|
||||||
|
assert config_entry.unique_id == PROJECT_ID
|
||||||
|
|
Loading…
Add table
Reference in a new issue