Google Assistant diagnostics and synchronization (#73574)

* Add config flow import for local google assistant
* Add diagnostic with sync response
* Add button for device sync
pull/74071/head
Joakim Plate 2022-06-27 20:24:15 +02:00 committed by GitHub
parent 320fa25a99
commit 992ceb1a09
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 384 additions and 18 deletions

View File

@ -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

View File

@ -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."
)

View File

@ -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
)

View File

@ -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",

View File

@ -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),
}

View File

@ -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):

View File

@ -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)

View File

@ -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,
)

View File

@ -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**",
},
}

View File

@ -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."""