Skip to content
Snippets Groups Projects
Commit bc13c9db authored by Frantz's avatar Frantz Committed by Paulus Schoutsen
Browse files

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
parent 183e0543
No related branches found
No related tags found
No related merge requests found
...@@ -14,13 +14,14 @@ from homeassistant.util.unit_system import UnitSystem # NOQA ...@@ -14,13 +14,14 @@ from homeassistant.util.unit_system import UnitSystem # NOQA
from homeassistant.util.decorator import Registry from homeassistant.util.decorator import Registry
from homeassistant.const import ( 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, STATE_OFF, SERVICE_TURN_OFF, SERVICE_TURN_ON,
TEMP_FAHRENHEIT, TEMP_CELSIUS, TEMP_FAHRENHEIT, TEMP_CELSIUS,
CONF_NAME, CONF_TYPE CONF_NAME, CONF_TYPE
) )
from homeassistant.components import ( 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 from homeassistant.util.unit_system import METRIC_SYSTEM
...@@ -67,6 +68,23 @@ MAPPING_COMPONENT = { ...@@ -67,6 +68,23 @@ MAPPING_COMPONENT = {
} # type: Dict[str, list] } # 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: class Config:
"""Hold the configuration for Google Assistant.""" """Hold the configuration for Google Assistant."""
...@@ -80,8 +98,9 @@ class Config: ...@@ -80,8 +98,9 @@ class Config:
def entity_to_device(entity: Entity, config: Config, units: UnitSystem): def entity_to_device(entity: Entity, config: Config, units: UnitSystem):
"""Convert a hass entity into an google actions device.""" """Convert a hass entity into an google actions device."""
entity_config = config.entity_config.get(entity.entity_id, {}) entity_config = config.entity_config.get(entity.entity_id, {})
google_domain = entity_config.get(CONF_TYPE)
class_data = MAPPING_COMPONENT.get( class_data = MAPPING_COMPONENT.get(
entity_config.get(CONF_TYPE) or entity.domain) google_domain or entity.domain)
if class_data is None: if class_data is None:
return None return None
...@@ -138,16 +157,75 @@ def entity_to_device(entity: Entity, config: Config, units: UnitSystem): ...@@ -138,16 +157,75 @@ def entity_to_device(entity: Entity, config: Config, units: UnitSystem):
'F' if units.temperature_unit == TEMP_FAHRENHEIT else 'C', 'F' if units.temperature_unit == TEMP_FAHRENHEIT else 'C',
} }
_LOGGER.debug('Thermostat attributes %s', device['attributes']) _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 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.""" """Take an entity and return a properly formatted device object."""
def celsius(deg: Optional[float]) -> Optional[float]: def celsius(deg: Optional[float]) -> Optional[float]:
"""Convert a float to Celsius and rounds to one decimal place.""" """Convert a float to Celsius and rounds to one decimal place."""
if deg is None: if deg is None:
return None return None
return round(METRIC_SYSTEM.temperature(deg, units.temperature_unit), 1) 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: if entity.domain == climate.DOMAIN:
mode = entity.attributes.get(climate.ATTR_OPERATION_MODE).lower() mode = entity.attributes.get(climate.ATTR_OPERATION_MODE).lower()
if mode not in CLIMATE_SUPPORTED_MODES: if mode not in CLIMATE_SUPPORTED_MODES:
...@@ -317,7 +395,7 @@ def async_handle_message(hass, config, message): ...@@ -317,7 +395,7 @@ def async_handle_message(hass, config, message):
@HANDLERS.register('action.devices.SYNC') @HANDLERS.register('action.devices.SYNC')
@asyncio.coroutine @asyncio.coroutine
def async_devices_sync(hass, config, payload): def async_devices_sync(hass, config: Config, payload):
"""Handle action.devices.SYNC request.""" """Handle action.devices.SYNC request."""
devices = [] devices = []
for entity in hass.states.async_all(): for entity in hass.states.async_all():
...@@ -354,7 +432,10 @@ def async_devices_query(hass, config, payload): ...@@ -354,7 +432,10 @@ def async_devices_query(hass, config, payload):
# If we can't find a state, the device is offline # If we can't find a state, the device is offline
devices[devid] = {'online': False} 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} return {'devices': devices}
......
...@@ -230,4 +230,20 @@ DEMO_DEVICES = [{ ...@@ -230,4 +230,20 @@ DEMO_DEVICES = [{
'traits': ['action.devices.traits.TemperatureSetting'], 'traits': ['action.devices.traits.TemperatureSetting'],
'type': 'action.devices.types.THERMOSTAT', 'type': 'action.devices.types.THERMOSTAT',
'willReportState': False '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
}] }]
...@@ -8,7 +8,7 @@ import pytest ...@@ -8,7 +8,7 @@ import pytest
from homeassistant import core, const, setup from homeassistant import core, const, setup
from homeassistant.components import ( 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.components import google_assistant as ga
from homeassistant.util.unit_system import IMPERIAL_SYSTEM from homeassistant.util.unit_system import IMPERIAL_SYSTEM
...@@ -43,6 +43,14 @@ def assistant_client(loop, hass, test_client): ...@@ -43,6 +43,14 @@ def assistant_client(loop, hass, test_client):
}, },
'switch.decorative_lights': { 'switch.decorative_lights': {
'type': 'light' '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): ...@@ -53,7 +61,7 @@ def assistant_client(loop, hass, test_client):
@pytest.fixture @pytest.fixture
def hass_fixture(loop, hass): 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) # We need to do this to get access to homeassistant/turn_(on,off)
loop.run_until_complete(async_setup(hass, {core.DOMAIN: {}})) loop.run_until_complete(async_setup(hass, {core.DOMAIN: {}}))
...@@ -97,6 +105,13 @@ def hass_fixture(loop, hass): ...@@ -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 return hass
...@@ -194,6 +209,8 @@ def test_query_climate_request(hass_fixture, assistant_client): ...@@ -194,6 +209,8 @@ def test_query_climate_request(hass_fixture, assistant_client):
{'id': 'climate.hvac'}, {'id': 'climate.hvac'},
{'id': 'climate.heatpump'}, {'id': 'climate.heatpump'},
{'id': 'climate.ecobee'}, {'id': 'climate.ecobee'},
{'id': 'sensor.outside_temperature'},
{'id': 'sensor.outside_humidity'}
] ]
} }
}] }]
...@@ -223,6 +240,12 @@ def test_query_climate_request(hass_fixture, assistant_client): ...@@ -223,6 +240,12 @@ def test_query_climate_request(hass_fixture, assistant_client):
'thermostatTemperatureAmbient': 22, 'thermostatTemperatureAmbient': 22,
'thermostatMode': 'cool', 'thermostatMode': 'cool',
'thermostatHumidityAmbient': 54, '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): ...@@ -242,6 +265,7 @@ def test_query_climate_request_f(hass_fixture, assistant_client):
{'id': 'climate.hvac'}, {'id': 'climate.hvac'},
{'id': 'climate.heatpump'}, {'id': 'climate.heatpump'},
{'id': 'climate.ecobee'}, {'id': 'climate.ecobee'},
{'id': 'sensor.outside_temperature'}
] ]
} }
}] }]
...@@ -271,6 +295,9 @@ def test_query_climate_request_f(hass_fixture, assistant_client): ...@@ -271,6 +295,9 @@ def test_query_climate_request_f(hass_fixture, assistant_client):
'thermostatTemperatureAmbient': -5.6, 'thermostatTemperatureAmbient': -5.6,
'thermostatMode': 'cool', 'thermostatMode': 'cool',
'thermostatHumidityAmbient': 54, 'thermostatHumidityAmbient': 54,
},
'sensor.outside_temperature': {
'thermostatTemperatureAmbient': -9.1
} }
} }
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment