2018-03-08 22:39:10 +00:00
|
|
|
"""Helper classes for Google Assistant integration."""
|
2019-04-18 05:37:39 +00:00
|
|
|
from asyncio import gather
|
|
|
|
from collections.abc import Mapping
|
2019-05-29 15:39:12 +00:00
|
|
|
from typing import List
|
2018-03-08 22:39:10 +00:00
|
|
|
|
2019-04-18 05:37:39 +00:00
|
|
|
from homeassistant.core import Context, callback
|
|
|
|
from homeassistant.const import (
|
|
|
|
CONF_NAME, STATE_UNAVAILABLE, ATTR_SUPPORTED_FEATURES,
|
2019-05-29 15:39:12 +00:00
|
|
|
ATTR_DEVICE_CLASS, CLOUD_NEVER_EXPOSED_ENTITIES
|
2019-04-18 05:37:39 +00:00
|
|
|
)
|
2018-03-08 22:39:10 +00:00
|
|
|
|
2019-04-18 05:37:39 +00:00
|
|
|
from . import trait
|
|
|
|
from .const import (
|
|
|
|
DOMAIN_TO_GOOGLE_TYPES, CONF_ALIASES, ERR_FUNCTION_NOT_SUPPORTED,
|
2019-05-29 15:39:12 +00:00
|
|
|
DEVICE_CLASS_TO_GOOGLE_TYPES, CONF_ROOM_HINT
|
2019-04-18 05:37:39 +00:00
|
|
|
)
|
|
|
|
from .error import SmartHomeError
|
2018-03-08 22:39:10 +00:00
|
|
|
|
|
|
|
|
2019-06-21 09:17:21 +00:00
|
|
|
class AbstractConfig:
|
2018-03-08 22:39:10 +00:00
|
|
|
"""Hold the configuration for Google Assistant."""
|
|
|
|
|
2019-06-21 09:17:21 +00:00
|
|
|
@property
|
|
|
|
def agent_user_id(self):
|
|
|
|
"""Return Agent User Id to use for query responses."""
|
|
|
|
return None
|
2019-03-06 04:00:53 +00:00
|
|
|
|
2019-06-21 09:17:21 +00:00
|
|
|
@property
|
|
|
|
def entity_config(self):
|
|
|
|
"""Return entity config."""
|
|
|
|
return {}
|
|
|
|
|
|
|
|
@property
|
|
|
|
def secure_devices_pin(self):
|
|
|
|
"""Return entity config."""
|
|
|
|
return None
|
|
|
|
|
|
|
|
def should_expose(self, state) -> bool:
|
|
|
|
"""Return if entity should be exposed."""
|
|
|
|
raise NotImplementedError
|
2019-04-28 07:42:06 +00:00
|
|
|
|
2019-05-29 15:39:12 +00:00
|
|
|
def should_2fa(self, state):
|
|
|
|
"""If an entity should have 2FA checked."""
|
2019-06-21 09:17:21 +00:00
|
|
|
# pylint: disable=no-self-use
|
|
|
|
return True
|
2019-05-29 15:39:12 +00:00
|
|
|
|
2019-03-06 04:00:53 +00:00
|
|
|
|
|
|
|
class RequestData:
|
|
|
|
"""Hold data associated with a particular request."""
|
|
|
|
|
|
|
|
def __init__(self, config, user_id, request_id):
|
|
|
|
"""Initialize the request data."""
|
|
|
|
self.config = config
|
|
|
|
self.request_id = request_id
|
|
|
|
self.context = Context(user_id=user_id)
|
2019-04-18 05:37:39 +00:00
|
|
|
|
|
|
|
|
|
|
|
def get_google_type(domain, device_class):
|
|
|
|
"""Google type based on domain and device class."""
|
|
|
|
typ = DEVICE_CLASS_TO_GOOGLE_TYPES.get((domain, device_class))
|
|
|
|
|
|
|
|
return typ if typ is not None else DOMAIN_TO_GOOGLE_TYPES[domain]
|
|
|
|
|
|
|
|
|
|
|
|
class GoogleEntity:
|
|
|
|
"""Adaptation of Entity expressed in Google's terms."""
|
|
|
|
|
|
|
|
def __init__(self, hass, config, state):
|
|
|
|
"""Initialize a Google entity."""
|
|
|
|
self.hass = hass
|
|
|
|
self.config = config
|
|
|
|
self.state = state
|
|
|
|
self._traits = None
|
|
|
|
|
|
|
|
@property
|
|
|
|
def entity_id(self):
|
|
|
|
"""Return entity ID."""
|
|
|
|
return self.state.entity_id
|
|
|
|
|
|
|
|
@callback
|
|
|
|
def traits(self):
|
|
|
|
"""Return traits for entity."""
|
|
|
|
if self._traits is not None:
|
|
|
|
return self._traits
|
|
|
|
|
|
|
|
state = self.state
|
|
|
|
domain = state.domain
|
|
|
|
features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
|
|
|
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
|
|
|
|
|
|
|
|
self._traits = [Trait(self.hass, state, self.config)
|
|
|
|
for Trait in trait.TRAITS
|
|
|
|
if Trait.supported(domain, features, device_class)]
|
|
|
|
return self._traits
|
|
|
|
|
2019-05-29 15:39:12 +00:00
|
|
|
@callback
|
|
|
|
def is_supported(self) -> bool:
|
|
|
|
"""Return if the entity is supported by Google."""
|
|
|
|
return self.state.state != STATE_UNAVAILABLE and bool(self.traits())
|
|
|
|
|
|
|
|
@callback
|
|
|
|
def might_2fa(self) -> bool:
|
|
|
|
"""Return if the entity might encounter 2FA."""
|
|
|
|
state = self.state
|
|
|
|
domain = state.domain
|
|
|
|
features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
|
|
|
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
|
|
|
|
|
|
|
|
return any(trait.might_2fa(domain, features, device_class)
|
|
|
|
for trait in self.traits())
|
|
|
|
|
2019-04-18 05:37:39 +00:00
|
|
|
async def sync_serialize(self):
|
|
|
|
"""Serialize entity for a SYNC response.
|
|
|
|
|
|
|
|
https://developers.google.com/actions/smarthome/create-app#actiondevicessync
|
|
|
|
"""
|
|
|
|
state = self.state
|
|
|
|
|
|
|
|
entity_config = self.config.entity_config.get(state.entity_id, {})
|
|
|
|
name = (entity_config.get(CONF_NAME) or state.name).strip()
|
|
|
|
domain = state.domain
|
|
|
|
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
|
|
|
|
|
|
|
|
traits = self.traits()
|
|
|
|
|
|
|
|
device_type = get_google_type(domain,
|
|
|
|
device_class)
|
|
|
|
|
|
|
|
device = {
|
|
|
|
'id': state.entity_id,
|
|
|
|
'name': {
|
|
|
|
'name': name
|
|
|
|
},
|
|
|
|
'attributes': {},
|
|
|
|
'traits': [trait.name for trait in traits],
|
|
|
|
'willReportState': False,
|
|
|
|
'type': device_type,
|
|
|
|
}
|
|
|
|
|
|
|
|
# use aliases
|
|
|
|
aliases = entity_config.get(CONF_ALIASES)
|
|
|
|
if aliases:
|
|
|
|
device['name']['nicknames'] = aliases
|
|
|
|
|
|
|
|
for trt in traits:
|
|
|
|
device['attributes'].update(trt.sync_attributes())
|
|
|
|
|
|
|
|
room = entity_config.get(CONF_ROOM_HINT)
|
|
|
|
if room:
|
|
|
|
device['roomHint'] = room
|
|
|
|
return device
|
|
|
|
|
|
|
|
dev_reg, ent_reg, area_reg = await gather(
|
|
|
|
self.hass.helpers.device_registry.async_get_registry(),
|
|
|
|
self.hass.helpers.entity_registry.async_get_registry(),
|
|
|
|
self.hass.helpers.area_registry.async_get_registry(),
|
|
|
|
)
|
|
|
|
|
|
|
|
entity_entry = ent_reg.async_get(state.entity_id)
|
|
|
|
if not (entity_entry and entity_entry.device_id):
|
|
|
|
return device
|
|
|
|
|
|
|
|
device_entry = dev_reg.devices.get(entity_entry.device_id)
|
|
|
|
if not (device_entry and device_entry.area_id):
|
|
|
|
return device
|
|
|
|
|
|
|
|
area_entry = area_reg.areas.get(device_entry.area_id)
|
|
|
|
if area_entry and area_entry.name:
|
|
|
|
device['roomHint'] = area_entry.name
|
|
|
|
|
|
|
|
return device
|
|
|
|
|
|
|
|
@callback
|
|
|
|
def query_serialize(self):
|
|
|
|
"""Serialize entity for a QUERY response.
|
|
|
|
|
|
|
|
https://developers.google.com/actions/smarthome/create-app#actiondevicesquery
|
|
|
|
"""
|
|
|
|
state = self.state
|
|
|
|
|
|
|
|
if state.state == STATE_UNAVAILABLE:
|
|
|
|
return {'online': False}
|
|
|
|
|
|
|
|
attrs = {'online': True}
|
|
|
|
|
|
|
|
for trt in self.traits():
|
|
|
|
deep_update(attrs, trt.query_attributes())
|
|
|
|
|
|
|
|
return attrs
|
|
|
|
|
2019-04-19 21:50:21 +00:00
|
|
|
async def execute(self, data, command_payload):
|
2019-04-18 05:37:39 +00:00
|
|
|
"""Execute a command.
|
|
|
|
|
|
|
|
https://developers.google.com/actions/smarthome/create-app#actiondevicesexecute
|
|
|
|
"""
|
2019-04-19 21:50:21 +00:00
|
|
|
command = command_payload['command']
|
|
|
|
params = command_payload.get('params', {})
|
|
|
|
challenge = command_payload.get('challenge', {})
|
2019-04-18 05:37:39 +00:00
|
|
|
executed = False
|
|
|
|
for trt in self.traits():
|
|
|
|
if trt.can_execute(command, params):
|
2019-04-19 21:50:21 +00:00
|
|
|
await trt.execute(command, data, params, challenge)
|
2019-04-18 05:37:39 +00:00
|
|
|
executed = True
|
|
|
|
break
|
|
|
|
|
|
|
|
if not executed:
|
|
|
|
raise SmartHomeError(
|
|
|
|
ERR_FUNCTION_NOT_SUPPORTED,
|
|
|
|
'Unable to execute {} for {}'.format(command,
|
|
|
|
self.state.entity_id))
|
|
|
|
|
|
|
|
@callback
|
|
|
|
def async_update(self):
|
|
|
|
"""Update the entity with latest info from Home Assistant."""
|
|
|
|
self.state = self.hass.states.get(self.entity_id)
|
|
|
|
|
|
|
|
if self._traits is None:
|
|
|
|
return
|
|
|
|
|
|
|
|
for trt in self._traits:
|
|
|
|
trt.state = self.state
|
|
|
|
|
|
|
|
|
|
|
|
def deep_update(target, source):
|
|
|
|
"""Update a nested dictionary with another nested dictionary."""
|
|
|
|
for key, value in source.items():
|
|
|
|
if isinstance(value, Mapping):
|
|
|
|
target[key] = deep_update(target.get(key, {}), value)
|
|
|
|
else:
|
|
|
|
target[key] = value
|
|
|
|
return target
|
2019-05-29 15:39:12 +00:00
|
|
|
|
|
|
|
|
|
|
|
@callback
|
|
|
|
def async_get_entities(hass, config) -> List[GoogleEntity]:
|
|
|
|
"""Return all entities that are supported by Google."""
|
|
|
|
entities = []
|
|
|
|
for state in hass.states.async_all():
|
|
|
|
if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
|
|
|
|
continue
|
|
|
|
|
|
|
|
entity = GoogleEntity(hass, config, state)
|
|
|
|
|
|
|
|
if entity.is_supported():
|
|
|
|
entities.append(entity)
|
|
|
|
|
|
|
|
return entities
|