Allow exposing sensors as temperature or humidity 'climate' devices to Google Assistant (#11095)

* Allow exposing sensors as temperature or humidity as 'climate' to Google Assistant

* Fixed hound

* Fixed hound

* Handled correctly unit of measurement to fix humidity

* Fixed temperature conversion for Google climate components

* Fixed temperature conversion for Google climate components

* Fixed indentation

* Fixed hound

* Fixed tests

* Fixed conversion and unit tests

* Fix sync for custom unit temperature

* Implemented requested changes

* Fix hound

* Fix linting errors

* Added success tests for sensors as climate

* Fix lint errors
This commit is contained in:
Frantz 2018-01-23 09:23:33 +02:00 committed by Paulus Schoutsen
parent 183e0543b4
commit bc13c9db83
3 changed files with 132 additions and 8 deletions

View File

@ -14,13 +14,14 @@ from homeassistant.util.unit_system import UnitSystem # NOQA
from homeassistant.util.decorator import Registry
from homeassistant.const import (
ATTR_SUPPORTED_FEATURES, ATTR_ENTITY_ID,
ATTR_SUPPORTED_FEATURES, ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT,
STATE_OFF, SERVICE_TURN_OFF, SERVICE_TURN_ON,
TEMP_FAHRENHEIT, TEMP_CELSIUS,
CONF_NAME, CONF_TYPE
)
from homeassistant.components import (
switch, light, cover, media_player, group, fan, scene, script, climate
switch, light, cover, media_player, group, fan, scene, script, climate,
sensor
)
from homeassistant.util.unit_system import METRIC_SYSTEM
@ -67,6 +68,23 @@ MAPPING_COMPONENT = {
} # type: Dict[str, list]
"""Error code used for SmartHomeError class."""
ERROR_NOT_SUPPORTED = "notSupported"
class SmartHomeError(Exception):
"""Google Assistant Smart Home errors."""
def __init__(self, code, msg):
"""Log error code."""
super(SmartHomeError, self).__init__(msg)
_LOGGER.error(
"An error has ocurred in Google SmartHome: %s."
"Error code: %s", msg, code
)
self.code = code
class Config:
"""Hold the configuration for Google Assistant."""
@ -80,8 +98,9 @@ class Config:
def entity_to_device(entity: Entity, config: Config, units: UnitSystem):
"""Convert a hass entity into an google actions device."""
entity_config = config.entity_config.get(entity.entity_id, {})
google_domain = entity_config.get(CONF_TYPE)
class_data = MAPPING_COMPONENT.get(
entity_config.get(CONF_TYPE) or entity.domain)
google_domain or entity.domain)
if class_data is None:
return None
@ -138,16 +157,75 @@ def entity_to_device(entity: Entity, config: Config, units: UnitSystem):
'F' if units.temperature_unit == TEMP_FAHRENHEIT else 'C',
}
_LOGGER.debug('Thermostat attributes %s', device['attributes'])
if entity.domain == sensor.DOMAIN:
if google_domain == climate.DOMAIN:
unit_of_measurement = entity.attributes.get(
ATTR_UNIT_OF_MEASUREMENT,
units.temperature_unit
)
device['attributes'] = {
'thermostatTemperatureUnit':
'F' if unit_of_measurement == TEMP_FAHRENHEIT else 'C',
}
_LOGGER.debug('Sensor attributes %s', device['attributes'])
return device
def query_device(entity: Entity, units: UnitSystem) -> dict:
def query_device(entity: Entity, config: Config, units: UnitSystem) -> dict:
"""Take an entity and return a properly formatted device object."""
def celsius(deg: Optional[float]) -> Optional[float]:
"""Convert a float to Celsius and rounds to one decimal place."""
if deg is None:
return None
return round(METRIC_SYSTEM.temperature(deg, units.temperature_unit), 1)
if entity.domain == sensor.DOMAIN:
entity_config = config.entity_config.get(entity.entity_id, {})
google_domain = entity_config.get(CONF_TYPE)
if google_domain == climate.DOMAIN:
# check if we have a string value to convert it to number
value = entity.state
if isinstance(entity.state, str):
try:
value = float(value)
except ValueError:
value = None
if value is None:
raise SmartHomeError(
ERROR_NOT_SUPPORTED,
"Invalid value {} for the climate sensor"
.format(entity.state)
)
# detect if we report temperature or humidity
unit_of_measurement = entity.attributes.get(
ATTR_UNIT_OF_MEASUREMENT,
units.temperature_unit
)
if unit_of_measurement in [TEMP_FAHRENHEIT, TEMP_CELSIUS]:
value = celsius(value)
attr = 'thermostatTemperatureAmbient'
elif unit_of_measurement == '%':
attr = 'thermostatHumidityAmbient'
else:
raise SmartHomeError(
ERROR_NOT_SUPPORTED,
"Unit {} is not supported by the climate sensor"
.format(unit_of_measurement)
)
return {attr: value}
raise SmartHomeError(
ERROR_NOT_SUPPORTED,
"Sensor type {} is not supported".format(google_domain)
)
if entity.domain == climate.DOMAIN:
mode = entity.attributes.get(climate.ATTR_OPERATION_MODE).lower()
if mode not in CLIMATE_SUPPORTED_MODES:
@ -317,7 +395,7 @@ def async_handle_message(hass, config, message):
@HANDLERS.register('action.devices.SYNC')
@asyncio.coroutine
def async_devices_sync(hass, config, payload):
def async_devices_sync(hass, config: Config, payload):
"""Handle action.devices.SYNC request."""
devices = []
for entity in hass.states.async_all():
@ -354,7 +432,10 @@ def async_devices_query(hass, config, payload):
# If we can't find a state, the device is offline
devices[devid] = {'online': False}
devices[devid] = query_device(state, hass.config.units)
try:
devices[devid] = query_device(state, config, hass.config.units)
except SmartHomeError as error:
devices[devid] = {'errorCode': error.code}
return {'devices': devices}

View File

@ -230,4 +230,20 @@ DEMO_DEVICES = [{
'traits': ['action.devices.traits.TemperatureSetting'],
'type': 'action.devices.types.THERMOSTAT',
'willReportState': False
}, {
'id': 'sensor.outside_temperature',
'name': {
'name': 'Outside Temperature'
},
'traits': ['action.devices.traits.TemperatureSetting'],
'type': 'action.devices.types.THERMOSTAT',
'willReportState': False
}, {
'id': 'sensor.outside_humidity',
'name': {
'name': 'Outside Humidity'
},
'traits': ['action.devices.traits.TemperatureSetting'],
'type': 'action.devices.types.THERMOSTAT',
'willReportState': False
}]

View File

@ -8,7 +8,7 @@ import pytest
from homeassistant import core, const, setup
from homeassistant.components import (
fan, cover, light, switch, climate, async_setup, media_player)
fan, cover, light, switch, climate, async_setup, media_player, sensor)
from homeassistant.components import google_assistant as ga
from homeassistant.util.unit_system import IMPERIAL_SYSTEM
@ -43,6 +43,14 @@ def assistant_client(loop, hass, test_client):
},
'switch.decorative_lights': {
'type': 'light'
},
'sensor.outside_humidity': {
'type': 'climate',
'expose': True
},
'sensor.outside_temperature': {
'type': 'climate',
'expose': True
}
}
}
@ -53,7 +61,7 @@ def assistant_client(loop, hass, test_client):
@pytest.fixture
def hass_fixture(loop, hass):
"""Set up a HOme Assistant instance for these tests."""
"""Set up a Home Assistant instance for these tests."""
# We need to do this to get access to homeassistant/turn_(on,off)
loop.run_until_complete(async_setup(hass, {core.DOMAIN: {}}))
@ -97,6 +105,13 @@ def hass_fixture(loop, hass):
}]
}))
loop.run_until_complete(
setup.async_setup_component(hass, sensor.DOMAIN, {
'sensor': [{
'platform': 'demo'
}]
}))
return hass
@ -194,6 +209,8 @@ def test_query_climate_request(hass_fixture, assistant_client):
{'id': 'climate.hvac'},
{'id': 'climate.heatpump'},
{'id': 'climate.ecobee'},
{'id': 'sensor.outside_temperature'},
{'id': 'sensor.outside_humidity'}
]
}
}]
@ -223,6 +240,12 @@ def test_query_climate_request(hass_fixture, assistant_client):
'thermostatTemperatureAmbient': 22,
'thermostatMode': 'cool',
'thermostatHumidityAmbient': 54,
},
'sensor.outside_temperature': {
'thermostatTemperatureAmbient': 15.6
},
'sensor.outside_humidity': {
'thermostatHumidityAmbient': 54.0
}
}
@ -242,6 +265,7 @@ def test_query_climate_request_f(hass_fixture, assistant_client):
{'id': 'climate.hvac'},
{'id': 'climate.heatpump'},
{'id': 'climate.ecobee'},
{'id': 'sensor.outside_temperature'}
]
}
}]
@ -271,6 +295,9 @@ def test_query_climate_request_f(hass_fixture, assistant_client):
'thermostatTemperatureAmbient': -5.6,
'thermostatMode': 'cool',
'thermostatHumidityAmbient': 54,
},
'sensor.outside_temperature': {
'thermostatTemperatureAmbient': -9.1
}
}