Google Assistant diagnostics and synchronization (#73574)
* Add config flow import for local google assistant * Add diagnostic with sync response * Add button for device syncpull/74071/head
parent
320fa25a99
commit
992ceb1a09
|
@ -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
|
||||
|
|
|
@ -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."
|
||||
)
|
|
@ -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
|
||||
)
|
|
@ -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",
|
||||
|
|
|
@ -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),
|
||||
}
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
)
|
|
@ -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**",
|
||||
},
|
||||
}
|
|
@ -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."""
|
||||
|
|
Loading…
Reference in New Issue