diff --git a/homeassistant/components/alexa/auth.py b/homeassistant/components/alexa/auth.py index d4633d938ed50..9f87a6d954e95 100644 --- a/homeassistant/components/alexa/auth.py +++ b/homeassistant/components/alexa/auth.py @@ -56,6 +56,11 @@ async def async_do_auth(self, accept_grant_code): return await self._async_request_new_token(lwa_params) + @callback + def async_invalidate_access_token(self): + """Invalidate access token.""" + self._prefs[STORAGE_ACCESS_TOKEN] = None + async def async_get_access_token(self): """Perform access token or token refresh request.""" async with self._get_token_lock: diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index dfb97cd9db25f..d769f797da1bf 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -11,6 +11,7 @@ STATE_ON, STATE_UNAVAILABLE, STATE_UNLOCKED, + STATE_UNKNOWN, ) import homeassistant.components.climate.const as climate from homeassistant.components import light, fan, cover @@ -443,7 +444,17 @@ def get_property(self, name): if self.entity.domain == climate.DOMAIN: unit = self.hass.config.units.temperature_unit temp = self.entity.attributes.get(climate.ATTR_CURRENT_TEMPERATURE) - return {"value": float(temp), "scale": API_TEMP_UNITS[unit]} + + if temp in (STATE_UNAVAILABLE, STATE_UNKNOWN): + return None + + try: + temp = float(temp) + except ValueError: + _LOGGER.warning("Invalid temp value %s for %s", temp, self.entity.entity_id) + return None + + return {"value": temp, "scale": API_TEMP_UNITS[unit]} class AlexaContactSensor(AlexaCapibility): @@ -591,4 +602,12 @@ def get_property(self, name): if temp is None: return None - return {"value": float(temp), "scale": API_TEMP_UNITS[unit]} + try: + temp = float(temp) + except ValueError: + _LOGGER.warning( + "Invalid temp value %s for %s in %s", temp, name, self.entity.entity_id + ) + return None + + return {"value": temp, "scale": API_TEMP_UNITS[unit]} diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py index a22ebbcd30d43..f98337d71c56a 100644 --- a/homeassistant/components/alexa/config.py +++ b/homeassistant/components/alexa/config.py @@ -1,4 +1,6 @@ """Config helpers for Alexa.""" +from homeassistant.core import callback + from .state_report import async_enable_proactive_mode @@ -55,11 +57,17 @@ async def async_disable_proactive_mode(self): unsub_func() self._unsub_proactive_report = None + @callback def should_expose(self, entity_id): """If an entity should be exposed.""" # pylint: disable=no-self-use return False + @callback + def async_invalidate_access_token(self): + """Invalidate access token.""" + raise NotImplementedError + async def async_get_access_token(self): """Get an access token.""" raise NotImplementedError diff --git a/homeassistant/components/alexa/smart_home_http.py b/homeassistant/components/alexa/smart_home_http.py index 7fdd4e3000a3e..ada00e8a32682 100644 --- a/homeassistant/components/alexa/smart_home_http.py +++ b/homeassistant/components/alexa/smart_home_http.py @@ -57,6 +57,11 @@ def should_expose(self, entity_id): """If an entity should be exposed.""" return self._config[CONF_FILTER](entity_id) + @core.callback + def async_invalidate_access_token(self): + """Invalidate access token.""" + self._auth.async_invalidate_access_token() + async def async_get_access_token(self): """Get an access token.""" return await self._auth.async_get_access_token() diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index 7e8428899776e..1e22d5fc09f23 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -51,7 +51,9 @@ async def async_entity_state_listener(changed_entity, old_state, new_state): ) -async def async_send_changereport_message(hass, config, alexa_entity): +async def async_send_changereport_message( + hass, config, alexa_entity, *, invalidate_access_token=True +): """Send a ChangeReport message for an Alexa entity. https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#report-state-with-changereport-events @@ -88,21 +90,30 @@ async def async_send_changereport_message(hass, config, alexa_entity): except (asyncio.TimeoutError, aiohttp.ClientError): _LOGGER.error("Timeout sending report to Alexa.") - return None + return response_text = await response.text() _LOGGER.debug("Sent: %s", json.dumps(message_serialized)) _LOGGER.debug("Received (%s): %s", response.status, response_text) - if response.status != 202: - response_json = json.loads(response_text) - _LOGGER.error( - "Error when sending ChangeReport to Alexa: %s: %s", - response_json["payload"]["code"], - response_json["payload"]["description"], + if response.status == 202 and not invalidate_access_token: + return + + response_json = json.loads(response_text) + + if response_json["payload"]["code"] == "INVALID_ACCESS_TOKEN_EXCEPTION": + config.async_invalidate_access_token() + return await async_send_changereport_message( + hass, config, alexa_entity, invalidate_access_token=False ) + _LOGGER.error( + "Error when sending ChangeReport to Alexa: %s: %s", + response_json["payload"]["code"], + response_json["payload"]["description"], + ) + async def async_send_add_or_update_message(hass, config, entity_ids): """Send an AddOrUpdateReport message for entities. diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index d31bcfdfc40e6..a1432f196bf5d 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -7,6 +7,7 @@ import async_timeout from hass_nabucasa import cloud_api +from homeassistant.core import callback from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.helpers import entity_registry from homeassistant.helpers.event import async_call_later @@ -95,9 +96,14 @@ def should_expose(self, entity_id): entity_config = entity_configs.get(entity_id, {}) return entity_config.get(PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE) + @callback + def async_invalidate_access_token(self): + """Invalidate access token.""" + self._token_valid = None + async def async_get_access_token(self): """Get an access token.""" - if self._token_valid is not None and self._token_valid < utcnow(): + if self._token_valid is not None and self._token_valid > utcnow(): return self._token resp = await cloud_api.async_alexa_access_token(self._cloud) diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index 650c02857509d..306a4fbf83924 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -157,8 +157,12 @@ async def _create_entry(self): async def _update_entry(self, entry, host): """Update existing entry.""" + if entry.data[CONF_HOST] == host: + return self.async_abort(reason="already_configured") + entry.data[CONF_HOST] = host self.hass.config_entries.async_update_entry(entry) + return self.async_abort(reason="updated_instance") async def async_step_ssdp(self, discovery_info): """Handle a discovered deCONZ bridge.""" @@ -175,8 +179,7 @@ async def async_step_ssdp(self, discovery_info): if uuid in gateways: entry = gateways[uuid].config_entry - await self._update_entry(entry, discovery_info[CONF_HOST]) - return self.async_abort(reason="updated_instance") + return await self._update_entry(entry, discovery_info[CONF_HOST]) bridgeid = discovery_info[ATTR_SERIAL] if any( @@ -224,8 +227,7 @@ async def async_step_hassio(self, user_input=None): if bridgeid in gateway_entries: entry = gateway_entries[bridgeid] - await self._update_entry(entry, user_input[CONF_HOST]) - return self.async_abort(reason="updated_instance") + return await self._update_entry(entry, user_input[CONF_HOST]) self._hassio_discovery = user_input diff --git a/homeassistant/components/demo/fan.py b/homeassistant/components/demo/fan.py index cdeed5dbfec69..ab8a6f3fae9dc 100644 --- a/homeassistant/components/demo/fan.py +++ b/homeassistant/components/demo/fan.py @@ -34,13 +34,13 @@ def __init__(self, hass, name: str, supported_features: int) -> None: self._supported_features = supported_features self._speed = STATE_OFF self.oscillating = None - self.direction = None + self._direction = None self._name = name if supported_features & SUPPORT_OSCILLATE: self.oscillating = False if supported_features & SUPPORT_DIRECTION: - self.direction = "forward" + self._direction = "forward" @property def name(self) -> str: @@ -80,7 +80,7 @@ def set_speed(self, speed: str) -> None: def set_direction(self, direction: str) -> None: """Set the direction of the fan.""" - self.direction = direction + self._direction = direction self.schedule_update_ha_state() def oscillate(self, oscillating: bool) -> None: @@ -91,7 +91,7 @@ def oscillate(self, oscillating: bool) -> None: @property def current_direction(self) -> str: """Fan direction.""" - return self.direction + return self._direction @property def supported_features(self) -> int: diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index f5edfe5bb5996..50d698f733656 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -54,7 +54,7 @@ "speed": ATTR_SPEED, "speed_list": ATTR_SPEED_LIST, "oscillating": ATTR_OSCILLATING, - "direction": ATTR_DIRECTION, + "current_direction": ATTR_DIRECTION, } # type: dict FAN_SET_SPEED_SCHEMA = ENTITY_SERVICE_SCHEMA.extend( diff --git a/homeassistant/components/google_maps/device_tracker.py b/homeassistant/components/google_maps/device_tracker.py index 2149e40e5045f..2b5550860ee5b 100644 --- a/homeassistant/components/google_maps/device_tracker.py +++ b/homeassistant/components/google_maps/device_tracker.py @@ -52,7 +52,7 @@ def __init__(self, hass, config: ConfigType, see) -> None: self.see = see self.username = config[CONF_USERNAME] self.max_gps_accuracy = config[CONF_MAX_GPS_ACCURACY] - self.scan_interval = config.get(CONF_SCAN_INTERVAL) or timedelta(60) + self.scan_interval = config.get(CONF_SCAN_INTERVAL) or timedelta(seconds=60) credfile = "{}.{}".format( hass.config.path(CREDENTIALS_FILE), slugify(self.username) diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index c3d5a4d878fd9..7fd8c4d9b3cea 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -243,7 +243,7 @@ def oscillating(self): return self._oscillating @property - def direction(self): + def current_direction(self): """Return the oscillation state.""" return self._direction diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json index 8d47d8a0173ba..9c83056f6aca8 100644 --- a/homeassistant/components/tuya/manifest.json +++ b/homeassistant/components/tuya/manifest.json @@ -3,7 +3,7 @@ "name": "Tuya", "documentation": "https://www.home-assistant.io/components/tuya", "requirements": [ - "tuyaha==0.0.3" + "tuyaha==0.0.4" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/const.py b/homeassistant/const.py index 2f2546378dbb5..6d195da991e90 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 98 -PATCH_VERSION = "1" +PATCH_VERSION = "2" __short_version__ = "{}.{}".format(MAJOR_VERSION, MINOR_VERSION) __version__ = "{}.{}".format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 6, 0) diff --git a/requirements_all.txt b/requirements_all.txt index e749340a8cb2c..2bf7e6a841cc0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1857,7 +1857,7 @@ tplink==0.2.1 transmissionrpc==0.11 # homeassistant.components.tuya -tuyaha==0.0.3 +tuyaha==0.0.4 # homeassistant.components.twentemilieu twentemilieu==0.1.0 diff --git a/tests/components/alexa/__init__.py b/tests/components/alexa/__init__.py index f853c4ef848cb..48406a11aef1f 100644 --- a/tests/components/alexa/__init__.py +++ b/tests/components/alexa/__init__.py @@ -171,6 +171,12 @@ def __init__(self, properties): """Initialize class.""" self.properties = properties + def assert_not_has_property(self, namespace, name): + """Assert a property does not exist.""" + for prop in self.properties: + if prop["namespace"] == namespace and prop["name"] == name: + assert False, "Property %s:%s exists" + def assert_equal(self, namespace, name, value): """Assert a property is equal to a given value.""" for prop in self.properties: diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index f8ad3f57c420e..357e0e3026d47 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -1,7 +1,15 @@ """Test Alexa capabilities.""" import pytest -from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + TEMP_CELSIUS, + STATE_LOCKED, + STATE_UNLOCKED, + STATE_UNKNOWN, + STATE_UNAVAILABLE, +) +from homeassistant.components import climate from homeassistant.components.alexa import smart_home from tests.common import async_mock_service @@ -368,3 +376,47 @@ async def test_report_cover_percentage_state(hass): properties = await reported_properties(hass, "cover.closed") properties.assert_equal("Alexa.PercentageController", "percentage", 0) + + +async def test_temperature_sensor_sensor(hass): + """Test TemperatureSensor reports sensor temperature correctly.""" + for bad_value in (STATE_UNKNOWN, STATE_UNAVAILABLE, "not-number"): + hass.states.async_set( + "sensor.temp_living_room", + bad_value, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}, + ) + + properties = await reported_properties(hass, "sensor.temp_living_room") + properties.assert_not_has_property("Alexa.TemperatureSensor", "temperature") + + hass.states.async_set( + "sensor.temp_living_room", "34", {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS} + ) + properties = await reported_properties(hass, "sensor.temp_living_room") + properties.assert_equal( + "Alexa.TemperatureSensor", "temperature", {"value": 34.0, "scale": "CELSIUS"} + ) + + +async def test_temperature_sensor_climate(hass): + """Test TemperatureSensor reports climate temperature correctly.""" + for bad_value in (STATE_UNKNOWN, STATE_UNAVAILABLE, "not-number"): + hass.states.async_set( + "climate.downstairs", + climate.HVAC_MODE_HEAT, + {climate.ATTR_CURRENT_TEMPERATURE: bad_value}, + ) + + properties = await reported_properties(hass, "climate.downstairs") + properties.assert_not_has_property("Alexa.TemperatureSensor", "temperature") + + hass.states.async_set( + "climate.downstairs", + climate.HVAC_MODE_HEAT, + {climate.ATTR_CURRENT_TEMPERATURE: 34}, + ) + properties = await reported_properties(hass, "climate.downstairs") + properties.assert_equal( + "Alexa.TemperatureSensor", "temperature", {"value": 34.0, "scale": "CELSIUS"} + ) diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index 22d8c64c3b0f8..c8e84016a28a9 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -1,6 +1,6 @@ """Test Alexa config.""" import contextlib -from unittest.mock import patch +from unittest.mock import patch, Mock from homeassistant.components.cloud import ALEXA_SCHEMA, alexa_config from homeassistant.util.dt import utcnow @@ -43,6 +43,42 @@ async def test_alexa_config_report_state(hass, cloud_prefs): assert conf.is_reporting_states is False +async def test_alexa_config_invalidate_token(hass, cloud_prefs, aioclient_mock): + """Test Alexa config should expose using prefs.""" + aioclient_mock.post( + "http://example/alexa_token", + json={ + "access_token": "mock-token", + "event_endpoint": "http://example.com/alexa_endpoint", + "expires_in": 30, + }, + ) + conf = alexa_config.AlexaConfig( + hass, + ALEXA_SCHEMA({}), + cloud_prefs, + Mock( + alexa_access_token_url="http://example/alexa_token", + run_executor=Mock(side_effect=mock_coro), + websession=hass.helpers.aiohttp_client.async_get_clientsession(), + ), + ) + + token = await conf.async_get_access_token() + assert token == "mock-token" + assert len(aioclient_mock.mock_calls) == 1 + + token = await conf.async_get_access_token() + assert token == "mock-token" + assert len(aioclient_mock.mock_calls) == 1 + assert conf._token_valid is not None + conf.async_invalidate_access_token() + assert conf._token_valid is None + token = await conf.async_get_access_token() + assert token == "mock-token" + assert len(aioclient_mock.mock_calls) == 2 + + @contextlib.contextmanager def patch_sync_helper(): """Patch sync helper. diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py index 8165c9df080dc..ea3abead02870 100644 --- a/tests/components/deconz/test_config_flow.py +++ b/tests/components/deconz/test_config_flow.py @@ -336,6 +336,24 @@ async def test_hassio_update_instance(hass): assert entry.data[config_flow.CONF_HOST] == "mock-deconz" +async def test_hassio_dont_update_instance(hass): + """Test we can update an existing config entry.""" + entry = MockConfigEntry( + domain=config_flow.DOMAIN, + data={config_flow.CONF_BRIDGEID: "id", config_flow.CONF_HOST: "1.2.3.4"}, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + data={config_flow.CONF_HOST: "1.2.3.4", config_flow.CONF_SERIAL: "id"}, + context={"source": "hassio"}, + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + async def test_hassio_confirm(hass): """Test we can finish a config flow.""" result = await hass.config_entries.flow.async_init(