diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index dca436d8e2a..638ccfd9133 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -5,9 +5,10 @@ import logging import voluptuous as vol -from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType from .const import ( @@ -23,6 +24,7 @@ from .const import ( CONF_ROOM_HINT, CONF_SECURE_DEVICES_PIN, CONF_SERVICE_ACCOUNT, + DATA_CONFIG, DEFAULT_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSED_DOMAINS, DOMAIN, @@ -37,6 +39,8 @@ _LOGGER = logging.getLogger(__name__) CONF_ALLOW_UNLOCK = "allow_unlock" +PLATFORMS = [Platform.BUTTON] + ENTITY_SCHEMA = vol.Schema( { vol.Optional(CONF_NAME): cv.string, @@ -95,11 +99,48 @@ async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: if DOMAIN not in yaml_config: return True - config = yaml_config[DOMAIN] + hass.data[DOMAIN] = {} + hass.data[DOMAIN][DATA_CONFIG] = yaml_config[DOMAIN] + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_PROJECT_ID: yaml_config[DOMAIN][CONF_PROJECT_ID]}, + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up from a config entry.""" + + config: ConfigType = {**hass.data[DOMAIN][DATA_CONFIG]} + + if entry.source == SOURCE_IMPORT: + # if project was changed, remove entry a new will be setup + if config[CONF_PROJECT_ID] != entry.data[CONF_PROJECT_ID]: + hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) + return False + + config.update(entry.data) + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, config[CONF_PROJECT_ID])}, + manufacturer="Google", + model="Google Assistant", + name=config[CONF_PROJECT_ID], + entry_type=dr.DeviceEntryType.SERVICE, + ) google_config = GoogleConfig(hass, config) await google_config.async_initialize() + hass.data[DOMAIN][entry.entry_id] = google_config + hass.http.register_view(GoogleAssistantView(google_config)) if google_config.should_report_state: @@ -123,4 +164,6 @@ async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: DOMAIN, SERVICE_REQUEST_SYNC, request_sync_service_handler ) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True diff --git a/homeassistant/components/google_assistant/button.py b/homeassistant/components/google_assistant/button.py new file mode 100644 index 00000000000..322a021053a --- /dev/null +++ b/homeassistant/components/google_assistant/button.py @@ -0,0 +1,53 @@ +"""Support for buttons.""" +from __future__ import annotations + +from homeassistant import config_entries +from homeassistant.components.button import ButtonEntity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import DeviceInfo, EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_PROJECT_ID, CONF_SERVICE_ACCOUNT, DATA_CONFIG, DOMAIN +from .http import GoogleConfig + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the platform.""" + yaml_config: ConfigType = hass.data[DOMAIN][DATA_CONFIG] + google_config: GoogleConfig = hass.data[DOMAIN][config_entry.entry_id] + + entities = [] + + if CONF_SERVICE_ACCOUNT in yaml_config: + entities.append(SyncButton(config_entry.data[CONF_PROJECT_ID], google_config)) + + async_add_entities(entities) + + +class SyncButton(ButtonEntity): + """Representation of a synchronization button.""" + + def __init__(self, project_id: str, google_config: GoogleConfig) -> None: + """Initialize button.""" + super().__init__() + self._google_config = google_config + self._attr_entity_category = EntityCategory.DIAGNOSTIC + self._attr_unique_id = f"{project_id}_sync" + self._attr_name = "Synchronize Devices" + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, project_id)}) + + async def async_press(self) -> None: + """Press the button.""" + assert self._context + agent_user_id = self._google_config.get_agent_user_id(self._context) + result = await self._google_config.async_sync_entities(agent_user_id) + if result != 200: + raise HomeAssistantError( + f"Unable to sync devices with result code: {result}, check log for more info." + ) diff --git a/homeassistant/components/google_assistant/config_flow.py b/homeassistant/components/google_assistant/config_flow.py new file mode 100644 index 00000000000..e8e0d9962f9 --- /dev/null +++ b/homeassistant/components/google_assistant/config_flow.py @@ -0,0 +1,19 @@ +"""Config flow for google assistant component.""" + +from homeassistant import config_entries + +from .const import CONF_PROJECT_ID, DOMAIN + + +class GoogleAssistantHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + + async def async_step_import(self, user_input): + """Import a config entry.""" + await self.async_set_unique_id(unique_id=user_input[CONF_PROJECT_ID]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_PROJECT_ID], data=user_input + ) diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 339cddae883..dbcf60ac098 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -40,6 +40,8 @@ CONF_ROOM_HINT = "room" CONF_SECURE_DEVICES_PIN = "secure_devices_pin" CONF_SERVICE_ACCOUNT = "service_account" +DATA_CONFIG = "config" + DEFAULT_EXPOSE_BY_DEFAULT = True DEFAULT_EXPOSED_DOMAINS = [ "alarm_control_panel", diff --git a/homeassistant/components/google_assistant/diagnostics.py b/homeassistant/components/google_assistant/diagnostics.py new file mode 100644 index 00000000000..01e17e0bcf8 --- /dev/null +++ b/homeassistant/components/google_assistant/diagnostics.py @@ -0,0 +1,41 @@ +"""Diagnostics support for Hue.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.components.diagnostics.const import REDACTED +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_SECURE_DEVICES_PIN, CONF_SERVICE_ACCOUNT, DATA_CONFIG, DOMAIN +from .http import GoogleConfig +from .smart_home import async_devices_sync_response, create_sync_response + +TO_REDACT = [ + "uuid", + "baseUrl", + "webhookId", + CONF_SERVICE_ACCOUNT, + CONF_SECURE_DEVICES_PIN, + CONF_API_KEY, +] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostic information.""" + data = hass.data[DOMAIN] + config: GoogleConfig = data[entry.entry_id] + yaml_config: ConfigType = data[DATA_CONFIG] + devices = await async_devices_sync_response(hass, config, REDACTED) + sync = create_sync_response(REDACTED, devices) + + return { + "config_entry": async_redact_data(entry.as_dict(), TO_REDACT), + "yaml_config": async_redact_data(yaml_config, TO_REDACT), + "sync": async_redact_data(sync, TO_REDACT), + } diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 932611390eb..6f81ddebdb4 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -160,7 +160,9 @@ class AbstractConfig(ABC): def get_local_webhook_id(self, agent_user_id): """Return the webhook ID to be used for actions for a given agent user id via the local SDK.""" - return self._store.agent_user_ids[agent_user_id][STORE_GOOGLE_LOCAL_WEBHOOK_ID] + if data := self._store.agent_user_ids.get(agent_user_id): + return data[STORE_GOOGLE_LOCAL_WEBHOOK_ID] + return None @abstractmethod def get_agent_user_id(self, context): diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 227b033bcaa..75a3fd76b9b 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -71,6 +71,24 @@ async def _process(hass, data, message): return {"requestId": data.request_id, "payload": result} +async def async_devices_sync_response(hass, config, agent_user_id): + """Generate the device serialization.""" + entities = async_get_entities(hass, config) + instance_uuid = await instance_id.async_get(hass) + devices = [] + + for entity in entities: + if not entity.should_expose(): + continue + + try: + devices.append(entity.sync_serialize(agent_user_id, instance_uuid)) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error serializing %s", entity.entity_id) + + return devices + + @HANDLERS.register("action.devices.SYNC") async def async_devices_sync(hass, data, payload): """Handle action.devices.SYNC request. @@ -86,19 +104,7 @@ async def async_devices_sync(hass, data, payload): agent_user_id = data.config.get_agent_user_id(data.context) await data.config.async_connect_agent_user(agent_user_id) - entities = async_get_entities(hass, data.config) - instance_uuid = await instance_id.async_get(hass) - devices = [] - - for entity in entities: - if not entity.should_expose(): - continue - - try: - devices.append(entity.sync_serialize(agent_user_id, instance_uuid)) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error serializing %s", entity.entity_id) - + devices = await async_devices_sync_response(hass, data.config, agent_user_id) response = create_sync_response(agent_user_id, devices) _LOGGER.debug("Syncing entities response: %s", response) diff --git a/tests/components/google_assistant/test_button.py b/tests/components/google_assistant/test_button.py new file mode 100644 index 00000000000..0783b70dff3 --- /dev/null +++ b/tests/components/google_assistant/test_button.py @@ -0,0 +1,55 @@ +"""Test buttons.""" + +from unittest.mock import patch + +from pytest import raises + +from homeassistant.components import google_assistant as ga +from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.setup import async_setup_component + +from .test_http import DUMMY_CONFIG + +from tests.common import MockUser + + +async def test_sync_button(hass: HomeAssistant, hass_owner_user: MockUser): + """Test sync button.""" + + await async_setup_component( + hass, + ga.DOMAIN, + {"google_assistant": DUMMY_CONFIG}, + ) + + await hass.async_block_till_done() + + state = hass.states.get("button.synchronize_devices") + assert state + + config_entry = hass.config_entries.async_entries("google_assistant")[0] + google_config: ga.GoogleConfig = hass.data[ga.DOMAIN][config_entry.entry_id] + + with patch.object(google_config, "async_sync_entities") as mock_sync_entities: + mock_sync_entities.return_value = 200 + context = Context(user_id=hass_owner_user.id) + await hass.services.async_call( + "button", + "press", + {"entity_id": "button.synchronize_devices"}, + blocking=True, + context=context, + ) + mock_sync_entities.assert_called_once_with(hass_owner_user.id) + + with raises(HomeAssistantError): + mock_sync_entities.return_value = 400 + + await hass.services.async_call( + "button", + "press", + {"entity_id": "button.synchronize_devices"}, + blocking=True, + context=context, + ) diff --git a/tests/components/google_assistant/test_diagnostics.py b/tests/components/google_assistant/test_diagnostics.py new file mode 100644 index 00000000000..13721c17f88 --- /dev/null +++ b/tests/components/google_assistant/test_diagnostics.py @@ -0,0 +1,109 @@ +"""Test diagnostics.""" + +from typing import Any +from unittest.mock import ANY + +from homeassistant import core, setup +from homeassistant.components import google_assistant as ga, switch +from homeassistant.setup import async_setup_component + +from .test_http import DUMMY_CONFIG + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_diagnostics(hass: core.HomeAssistant, hass_client: Any): + """Test diagnostics v1.""" + + await setup.async_setup_component( + hass, switch.DOMAIN, {"switch": [{"platform": "demo"}]} + ) + + await async_setup_component( + hass, + ga.DOMAIN, + {"google_assistant": DUMMY_CONFIG}, + ) + + config_entry = hass.config_entries.async_entries("google_assistant")[0] + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert result == { + "config_entry": { + "data": {"project_id": "1234"}, + "disabled_by": None, + "domain": "google_assistant", + "entry_id": ANY, + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "import", + "title": "1234", + "unique_id": "1234", + "version": 1, + }, + "sync": { + "agentUserId": "**REDACTED**", + "devices": [ + { + "attributes": {"commandOnlyOnOff": True}, + "id": "switch.decorative_lights", + "otherDeviceIds": [{"deviceId": "switch.decorative_lights"}], + "name": {"name": "Decorative Lights"}, + "traits": ["action.devices.traits.OnOff"], + "type": "action.devices.types.SWITCH", + "willReportState": False, + "customData": { + "baseUrl": "**REDACTED**", + "httpPort": 8123, + "httpSSL": False, + "proxyDeviceId": "**REDACTED**", + "uuid": "**REDACTED**", + "webhookId": None, + }, + }, + { + "attributes": {}, + "id": "switch.ac", + "otherDeviceIds": [{"deviceId": "switch.ac"}], + "name": {"name": "AC"}, + "traits": ["action.devices.traits.OnOff"], + "type": "action.devices.types.OUTLET", + "willReportState": False, + "customData": { + "baseUrl": "**REDACTED**", + "httpPort": 8123, + "httpSSL": False, + "proxyDeviceId": "**REDACTED**", + "uuid": "**REDACTED**", + "webhookId": None, + }, + }, + ], + }, + "yaml_config": { + "expose_by_default": True, + "exposed_domains": [ + "alarm_control_panel", + "binary_sensor", + "climate", + "cover", + "fan", + "group", + "humidifier", + "input_boolean", + "input_select", + "light", + "lock", + "media_player", + "scene", + "script", + "select", + "sensor", + "switch", + "vacuum", + ], + "project_id": "1234", + "report_state": False, + "service_account": "**REDACTED**", + }, + } diff --git a/tests/components/google_assistant/test_init.py b/tests/components/google_assistant/test_init.py index 69198b99aaa..bdd6932c91d 100644 --- a/tests/components/google_assistant/test_init.py +++ b/tests/components/google_assistant/test_init.py @@ -2,11 +2,47 @@ from http import HTTPStatus from homeassistant.components import google_assistant as ga -from homeassistant.core import Context +from homeassistant.core import Context, HomeAssistant from homeassistant.setup import async_setup_component from .test_http import DUMMY_CONFIG +from tests.common import MockConfigEntry + + +async def test_import(hass: HomeAssistant): + """Test import.""" + + await async_setup_component( + hass, + ga.DOMAIN, + {"google_assistant": DUMMY_CONFIG}, + ) + + entries = hass.config_entries.async_entries("google_assistant") + assert len(entries) == 1 + assert entries[0].data[ga.const.CONF_PROJECT_ID] == "1234" + + +async def test_import_changed(hass: HomeAssistant): + """Test import with changed project id.""" + + old_entry = MockConfigEntry( + domain=ga.DOMAIN, data={ga.const.CONF_PROJECT_ID: "4321"}, source="import" + ) + old_entry.add_to_hass(hass) + + await async_setup_component( + hass, + ga.DOMAIN, + {"google_assistant": DUMMY_CONFIG}, + ) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries("google_assistant") + assert len(entries) == 1 + assert entries[0].data[ga.const.CONF_PROJECT_ID] == "1234" + async def test_request_sync_service(aioclient_mock, hass): """Test that it posts to the request_sync url."""