diff --git a/homeassistant/components/mobile_app/binary_sensor.py b/homeassistant/components/mobile_app/binary_sensor.py index fd8545b1f98..69ecb913c98 100644 --- a/homeassistant/components/mobile_app/binary_sensor.py +++ b/homeassistant/components/mobile_app/binary_sensor.py @@ -75,9 +75,8 @@ class MobileAppBinarySensor(MobileAppEntity, BinarySensorEntity): """Return the state of the binary sensor.""" return self._config[ATTR_SENSOR_STATE] - @callback - def async_restore_last_state(self, last_state): + async def async_restore_last_state(self, last_state): """Restore previous state.""" - super().async_restore_last_state(last_state) + await super().async_restore_last_state(last_state) self._config[ATTR_SENSOR_STATE] = last_state.state == STATE_ON diff --git a/homeassistant/components/mobile_app/entity.py b/homeassistant/components/mobile_app/entity.py index d4c4374b8d9..3a2f038a0af 100644 --- a/homeassistant/components/mobile_app/entity.py +++ b/homeassistant/components/mobile_app/entity.py @@ -43,10 +43,9 @@ class MobileAppEntity(RestoreEntity): if (state := await self.async_get_last_state()) is None: return - self.async_restore_last_state(state) + await self.async_restore_last_state(state) - @callback - def async_restore_last_state(self, last_state): + async def async_restore_last_state(self, last_state): """Restore previous state.""" self._config[ATTR_SENSOR_STATE] = last_state.state self._config[ATTR_SENSOR_ATTRIBUTES] = { diff --git a/homeassistant/components/mobile_app/sensor.py b/homeassistant/components/mobile_app/sensor.py index d7cfc9545f6..ef7dd122496 100644 --- a/homeassistant/components/mobile_app/sensor.py +++ b/homeassistant/components/mobile_app/sensor.py @@ -3,9 +3,9 @@ from __future__ import annotations from typing import Any -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.components.sensor import RestoreSensor, SensorDeviceClass from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_WEBHOOK_ID, STATE_UNKNOWN +from homeassistant.const import CONF_WEBHOOK_ID, STATE_UNKNOWN, TEMP_CELSIUS from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -27,6 +27,7 @@ from .const import ( DOMAIN, ) from .entity import MobileAppEntity +from .webhook import _extract_sensor_unique_id async def async_setup_entry( @@ -73,9 +74,30 @@ async def async_setup_entry( ) -class MobileAppSensor(MobileAppEntity, SensorEntity): +class MobileAppSensor(MobileAppEntity, RestoreSensor): """Representation of an mobile app sensor.""" + async def async_restore_last_state(self, last_state): + """Restore previous state.""" + + await super().async_restore_last_state(last_state) + + if not (last_sensor_data := await self.async_get_last_sensor_data()): + # Workaround to handle migration to RestoreSensor, can be removed + # in HA Core 2023.4 + self._config[ATTR_SENSOR_STATE] = None + webhook_id = self._entry.data[CONF_WEBHOOK_ID] + sensor_unique_id = _extract_sensor_unique_id(webhook_id, self.unique_id) + if ( + self.device_class == SensorDeviceClass.TEMPERATURE + and sensor_unique_id == "battery_temperature" + ): + self._config[ATTR_SENSOR_UOM] = TEMP_CELSIUS + return + + self._config[ATTR_SENSOR_STATE] = last_sensor_data.native_value + self._config[ATTR_SENSOR_UOM] = last_sensor_data.native_unit_of_measurement + @property def native_value(self): """Return the state of the sensor.""" diff --git a/tests/components/mobile_app/test_sensor.py b/tests/components/mobile_app/test_sensor.py index c0f7f126a49..930fb522c4c 100644 --- a/tests/components/mobile_app/test_sensor.py +++ b/tests/components/mobile_app/test_sensor.py @@ -1,15 +1,34 @@ """Entity tests for mobile_app.""" from http import HTTPStatus +from unittest.mock import patch import pytest from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.const import PERCENTAGE, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import ( + PERCENTAGE, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM -async def test_sensor(hass, create_registrations, webhook_client): +@pytest.mark.parametrize( + "unit_system, state_unit, state1, state2", + ( + (METRIC_SYSTEM, TEMP_CELSIUS, "100", "123"), + (IMPERIAL_SYSTEM, TEMP_FAHRENHEIT, "212", "253"), + ), +) +async def test_sensor( + hass, create_registrations, webhook_client, unit_system, state_unit, state1, state2 +): """Test that sensors can be registered and updated.""" + hass.config.units = unit_system + webhook_id = create_registrations[1]["webhook_id"] webhook_url = f"/api/webhook/{webhook_id}" @@ -19,15 +38,15 @@ async def test_sensor(hass, create_registrations, webhook_client): "type": "register_sensor", "data": { "attributes": {"foo": "bar"}, - "device_class": "battery", + "device_class": "temperature", "icon": "mdi:battery", - "name": "Battery State", + "name": "Battery Temperature", "state": 100, "type": "sensor", "entity_category": "diagnostic", - "unique_id": "battery_state", + "unique_id": "battery_temp", "state_class": "total", - "unit_of_measurement": PERCENTAGE, + "unit_of_measurement": TEMP_CELSIUS, }, }, ) @@ -38,20 +57,23 @@ async def test_sensor(hass, create_registrations, webhook_client): assert json == {"success": True} await hass.async_block_till_done() - entity = hass.states.get("sensor.test_1_battery_state") + entity = hass.states.get("sensor.test_1_battery_temperature") assert entity is not None - assert entity.attributes["device_class"] == "battery" + assert entity.attributes["device_class"] == "temperature" assert entity.attributes["icon"] == "mdi:battery" - assert entity.attributes["unit_of_measurement"] == PERCENTAGE + # unit of temperature sensor is automatically converted to the system UoM + assert entity.attributes["unit_of_measurement"] == state_unit assert entity.attributes["foo"] == "bar" assert entity.attributes["state_class"] == "total" assert entity.domain == "sensor" - assert entity.name == "Test 1 Battery State" - assert entity.state == "100" + assert entity.name == "Test 1 Battery Temperature" + assert entity.state == state1 assert ( - er.async_get(hass).async_get("sensor.test_1_battery_state").entity_category + er.async_get(hass) + .async_get("sensor.test_1_battery_temperature") + .entity_category == "diagnostic" ) @@ -64,7 +86,7 @@ async def test_sensor(hass, create_registrations, webhook_client): "icon": "mdi:battery-unknown", "state": 123, "type": "sensor", - "unique_id": "battery_state", + "unique_id": "battery_temp", }, # This invalid data should not invalidate whole request {"type": "sensor", "unique_id": "invalid_state", "invalid": "data"}, @@ -77,8 +99,8 @@ async def test_sensor(hass, create_registrations, webhook_client): json = await update_resp.json() assert json["invalid_state"]["success"] is False - updated_entity = hass.states.get("sensor.test_1_battery_state") - assert updated_entity.state == "123" + updated_entity = hass.states.get("sensor.test_1_battery_temperature") + assert updated_entity.state == state2 assert "foo" not in updated_entity.attributes dev_reg = dr.async_get(hass) @@ -88,16 +110,120 @@ async def test_sensor(hass, create_registrations, webhook_client): config_entry = hass.config_entries.async_entries("mobile_app")[1] await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() - unloaded_entity = hass.states.get("sensor.test_1_battery_state") + unloaded_entity = hass.states.get("sensor.test_1_battery_temperature") assert unloaded_entity.state == STATE_UNAVAILABLE await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - restored_entity = hass.states.get("sensor.test_1_battery_state") + restored_entity = hass.states.get("sensor.test_1_battery_temperature") assert restored_entity.state == updated_entity.state assert restored_entity.attributes == updated_entity.attributes +@pytest.mark.parametrize( + "unique_id, unit_system, state_unit, state1, state2", + ( + ("battery_temperature", METRIC_SYSTEM, TEMP_CELSIUS, "100", "123"), + ("battery_temperature", IMPERIAL_SYSTEM, TEMP_FAHRENHEIT, "212", "253"), + # The unique_id doesn't match that of the mobile app's battery temperature sensor + ("battery_temp", IMPERIAL_SYSTEM, TEMP_FAHRENHEIT, "212", "123"), + ), +) +async def test_sensor_migration( + hass, + create_registrations, + webhook_client, + unique_id, + unit_system, + state_unit, + state1, + state2, +): + """Test migration to RestoreSensor.""" + hass.config.units = unit_system + + webhook_id = create_registrations[1]["webhook_id"] + webhook_url = f"/api/webhook/{webhook_id}" + + reg_resp = await webhook_client.post( + webhook_url, + json={ + "type": "register_sensor", + "data": { + "attributes": {"foo": "bar"}, + "device_class": "temperature", + "icon": "mdi:battery", + "name": "Battery Temperature", + "state": 100, + "type": "sensor", + "entity_category": "diagnostic", + "unique_id": unique_id, + "state_class": "total", + "unit_of_measurement": TEMP_CELSIUS, + }, + }, + ) + + assert reg_resp.status == HTTPStatus.CREATED + + json = await reg_resp.json() + assert json == {"success": True} + await hass.async_block_till_done() + + entity = hass.states.get("sensor.test_1_battery_temperature") + assert entity is not None + + assert entity.attributes["device_class"] == "temperature" + assert entity.attributes["icon"] == "mdi:battery" + # unit of temperature sensor is automatically converted to the system UoM + assert entity.attributes["unit_of_measurement"] == state_unit + assert entity.attributes["foo"] == "bar" + assert entity.attributes["state_class"] == "total" + assert entity.domain == "sensor" + assert entity.name == "Test 1 Battery Temperature" + assert entity.state == state1 + + # Reload to verify state is restored + config_entry = hass.config_entries.async_entries("mobile_app")[1] + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + unloaded_entity = hass.states.get("sensor.test_1_battery_temperature") + assert unloaded_entity.state == STATE_UNAVAILABLE + + # Simulate migration to RestoreSensor + with patch( + "homeassistant.helpers.restore_state.RestoreEntity.async_get_last_extra_data", + return_value=None, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + restored_entity = hass.states.get("sensor.test_1_battery_temperature") + assert restored_entity.state == "unknown" + assert restored_entity.attributes == entity.attributes + + # Test unit conversion is working + update_resp = await webhook_client.post( + webhook_url, + json={ + "type": "update_sensor_states", + "data": [ + { + "icon": "mdi:battery-unknown", + "state": 123, + "type": "sensor", + "unique_id": unique_id, + }, + ], + }, + ) + + assert update_resp.status == HTTPStatus.OK + + updated_entity = hass.states.get("sensor.test_1_battery_temperature") + assert updated_entity.state == state2 + assert "foo" not in updated_entity.attributes + + async def test_sensor_must_register(hass, create_registrations, webhook_client): """Test that sensors must be registered before updating.""" webhook_id = create_registrations[1]["webhook_id"]