diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 1c348ea07826..ecbe8d708476 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -3,13 +3,13 @@ from homeassistant import config_entries from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.components.webhook import async_register as webhook_register from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.typing import ConfigType, HomeAssistantType -from .const import (ATTR_APP_COMPONENT, ATTR_DEVICE_ID, ATTR_DEVICE_NAME, - ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION, +from .const import (ATTR_DEVICE_ID, ATTR_DEVICE_NAME, ATTR_MANUFACTURER, + ATTR_MODEL, ATTR_OS_VERSION, DATA_BINARY_SENSOR, DATA_CONFIG_ENTRIES, DATA_DELETED_IDS, DATA_DEVICES, - DATA_STORE, DOMAIN, STORAGE_KEY, STORAGE_VERSION) + DATA_SENSOR, DATA_STORE, DOMAIN, STORAGE_KEY, + STORAGE_VERSION) from .http_api import RegistrationsView from .webhook import handle_webhook @@ -22,19 +22,25 @@ REQUIREMENTS = ['PyNaCl==1.3.0'] async def async_setup(hass: HomeAssistantType, config: ConfigType): """Set up the mobile app component.""" - hass.data[DOMAIN] = { - DATA_CONFIG_ENTRIES: {}, DATA_DELETED_IDS: [], DATA_DEVICES: {}, - } - store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) app_config = await store.async_load() if app_config is None: app_config = { - DATA_CONFIG_ENTRIES: {}, DATA_DELETED_IDS: [], DATA_DEVICES: {}, + DATA_BINARY_SENSOR: {}, + DATA_CONFIG_ENTRIES: {}, + DATA_DELETED_IDS: [], + DATA_DEVICES: {}, + DATA_SENSOR: {} } - hass.data[DOMAIN] = app_config - hass.data[DOMAIN][DATA_STORE] = store + hass.data[DOMAIN] = { + DATA_BINARY_SENSOR: app_config.get(DATA_BINARY_SENSOR, {}), + DATA_CONFIG_ENTRIES: {}, + DATA_DELETED_IDS: app_config.get(DATA_DELETED_IDS, []), + DATA_DEVICES: {}, + DATA_SENSOR: app_config.get(DATA_SENSOR, {}), + DATA_STORE: store, + } hass.http.register_view(RegistrationsView()) register_websocket_handlers(hass) @@ -79,9 +85,11 @@ async def async_setup_entry(hass, entry): webhook_register(hass, DOMAIN, registration_name, webhook_id, handle_webhook) - if ATTR_APP_COMPONENT in registration: - load_platform(hass, registration[ATTR_APP_COMPONENT], DOMAIN, {}, - {DOMAIN: {}}) + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, + DATA_BINARY_SENSOR)) + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, DATA_SENSOR)) return True diff --git a/homeassistant/components/mobile_app/binary_sensor.py b/homeassistant/components/mobile_app/binary_sensor.py new file mode 100644 index 000000000000..289a50584c92 --- /dev/null +++ b/homeassistant/components/mobile_app/binary_sensor.py @@ -0,0 +1,54 @@ +"""Binary sensor platform for mobile_app.""" +from functools import partial + +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.core import callback +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import (ATTR_SENSOR_STATE, + ATTR_SENSOR_TYPE_BINARY_SENSOR as ENTITY_TYPE, + DATA_DEVICES, DOMAIN) + +from .entity import MobileAppEntity + +DEPENDENCIES = ['mobile_app'] + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up mobile app binary sensor from a config entry.""" + entities = list() + + webhook_id = config_entry.data[CONF_WEBHOOK_ID] + + for config in hass.data[DOMAIN][ENTITY_TYPE].values(): + if config[CONF_WEBHOOK_ID] != webhook_id: + continue + + device = hass.data[DOMAIN][DATA_DEVICES][webhook_id] + + entities.append(MobileAppBinarySensor(config, device, config_entry)) + + async_add_entities(entities) + + @callback + def handle_sensor_registration(webhook_id, data): + if data[CONF_WEBHOOK_ID] != webhook_id: + return + + device = hass.data[DOMAIN][DATA_DEVICES][data[CONF_WEBHOOK_ID]] + + async_add_entities([MobileAppBinarySensor(data, device, config_entry)]) + + async_dispatcher_connect(hass, + '{}_{}_register'.format(DOMAIN, ENTITY_TYPE), + partial(handle_sensor_registration, webhook_id)) + + +class MobileAppBinarySensor(MobileAppEntity, BinarySensorDevice): + """Representation of an mobile app binary sensor.""" + + @property + def is_on(self): + """Return the state of the binary sensor.""" + return self._config[ATTR_SENSOR_STATE] diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index 3ba029fec0e9..d38df31b214d 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -1,6 +1,9 @@ """Constants for mobile_app.""" import voluptuous as vol +from homeassistant.components.binary_sensor import (DEVICE_CLASSES as + BINARY_SENSOR_CLASSES) +from homeassistant.components.sensor import DEVICE_CLASSES as SENSOR_CLASSES from homeassistant.components.device_tracker import (ATTR_BATTERY, ATTR_GPS, ATTR_GPS_ACCURACY, @@ -17,9 +20,11 @@ CONF_CLOUDHOOK_URL = 'cloudhook_url' CONF_SECRET = 'secret' CONF_USER_ID = 'user_id' +DATA_BINARY_SENSOR = 'binary_sensor' DATA_CONFIG_ENTRIES = 'config_entries' DATA_DELETED_IDS = 'deleted_ids' DATA_DEVICES = 'devices' +DATA_SENSOR = 'sensor' DATA_STORE = 'store' ATTR_APP_COMPONENT = 'app_component' @@ -54,16 +59,22 @@ ATTR_WEBHOOK_TYPE = 'type' ERR_ENCRYPTION_REQUIRED = 'encryption_required' ERR_INVALID_COMPONENT = 'invalid_component' +ERR_SENSOR_NOT_REGISTERED = 'not_registered' +ERR_SENSOR_DUPLICATE_UNIQUE_ID = 'duplicate_unique_id' WEBHOOK_TYPE_CALL_SERVICE = 'call_service' WEBHOOK_TYPE_FIRE_EVENT = 'fire_event' +WEBHOOK_TYPE_REGISTER_SENSOR = 'register_sensor' WEBHOOK_TYPE_RENDER_TEMPLATE = 'render_template' WEBHOOK_TYPE_UPDATE_LOCATION = 'update_location' WEBHOOK_TYPE_UPDATE_REGISTRATION = 'update_registration' +WEBHOOK_TYPE_UPDATE_SENSOR_STATES = 'update_sensor_states' WEBHOOK_TYPES = [WEBHOOK_TYPE_CALL_SERVICE, WEBHOOK_TYPE_FIRE_EVENT, - WEBHOOK_TYPE_RENDER_TEMPLATE, WEBHOOK_TYPE_UPDATE_LOCATION, - WEBHOOK_TYPE_UPDATE_REGISTRATION] + WEBHOOK_TYPE_REGISTER_SENSOR, WEBHOOK_TYPE_RENDER_TEMPLATE, + WEBHOOK_TYPE_UPDATE_LOCATION, + WEBHOOK_TYPE_UPDATE_REGISTRATION, + WEBHOOK_TYPE_UPDATE_SENSOR_STATES] REGISTRATION_SCHEMA = vol.Schema({ @@ -91,7 +102,7 @@ UPDATE_REGISTRATION_SCHEMA = vol.Schema({ WEBHOOK_PAYLOAD_SCHEMA = vol.Schema({ vol.Required(ATTR_WEBHOOK_TYPE): cv.string, # vol.In(WEBHOOK_TYPES) - vol.Required(ATTR_WEBHOOK_DATA, default={}): dict, + vol.Required(ATTR_WEBHOOK_DATA, default={}): vol.Any(dict, list), vol.Optional(ATTR_WEBHOOK_ENCRYPTED, default=False): cv.boolean, vol.Optional(ATTR_WEBHOOK_ENCRYPTED_DATA): cv.string, }) @@ -125,10 +136,49 @@ UPDATE_LOCATION_SCHEMA = vol.Schema({ vol.Optional(ATTR_VERTICAL_ACCURACY): cv.positive_int, }) +ATTR_SENSOR_ATTRIBUTES = 'attributes' +ATTR_SENSOR_DEVICE_CLASS = 'device_class' +ATTR_SENSOR_ICON = 'icon' +ATTR_SENSOR_NAME = 'name' +ATTR_SENSOR_STATE = 'state' +ATTR_SENSOR_TYPE = 'type' +ATTR_SENSOR_TYPE_BINARY_SENSOR = 'binary_sensor' +ATTR_SENSOR_TYPE_SENSOR = 'sensor' +ATTR_SENSOR_UNIQUE_ID = 'unique_id' +ATTR_SENSOR_UOM = 'unit_of_measurement' + +SENSOR_TYPES = [ATTR_SENSOR_TYPE_BINARY_SENSOR, ATTR_SENSOR_TYPE_SENSOR] + +COMBINED_CLASSES = sorted(set(BINARY_SENSOR_CLASSES + SENSOR_CLASSES)) + +SIGNAL_SENSOR_UPDATE = DOMAIN + '_sensor_update' + +REGISTER_SENSOR_SCHEMA = vol.Schema({ + vol.Optional(ATTR_SENSOR_ATTRIBUTES, default={}): dict, + vol.Optional(ATTR_SENSOR_DEVICE_CLASS): vol.All(vol.Lower, + vol.In(COMBINED_CLASSES)), + vol.Required(ATTR_SENSOR_NAME): cv.string, + vol.Required(ATTR_SENSOR_TYPE): vol.In(SENSOR_TYPES), + vol.Required(ATTR_SENSOR_UNIQUE_ID): cv.string, + vol.Required(ATTR_SENSOR_UOM): cv.string, + vol.Required(ATTR_SENSOR_STATE): vol.Any(bool, str, int, float), + vol.Optional(ATTR_SENSOR_ICON, default='mdi:cellphone'): cv.icon, +}) + +UPDATE_SENSOR_STATE_SCHEMA = vol.All(cv.ensure_list, [vol.Schema({ + vol.Optional(ATTR_SENSOR_ATTRIBUTES, default={}): dict, + vol.Optional(ATTR_SENSOR_ICON, default='mdi:cellphone'): cv.icon, + vol.Required(ATTR_SENSOR_STATE): vol.Any(bool, str, int, float), + vol.Required(ATTR_SENSOR_TYPE): vol.In(SENSOR_TYPES), + vol.Required(ATTR_SENSOR_UNIQUE_ID): cv.string, +})]) + WEBHOOK_SCHEMAS = { WEBHOOK_TYPE_CALL_SERVICE: CALL_SERVICE_SCHEMA, WEBHOOK_TYPE_FIRE_EVENT: FIRE_EVENT_SCHEMA, + WEBHOOK_TYPE_REGISTER_SENSOR: REGISTER_SENSOR_SCHEMA, WEBHOOK_TYPE_RENDER_TEMPLATE: RENDER_TEMPLATE_SCHEMA, WEBHOOK_TYPE_UPDATE_LOCATION: UPDATE_LOCATION_SCHEMA, WEBHOOK_TYPE_UPDATE_REGISTRATION: UPDATE_REGISTRATION_SCHEMA, + WEBHOOK_TYPE_UPDATE_SENSOR_STATES: UPDATE_SENSOR_STATE_SCHEMA, } diff --git a/homeassistant/components/mobile_app/entity.py b/homeassistant/components/mobile_app/entity.py new file mode 100644 index 000000000000..05736b3a689b --- /dev/null +++ b/homeassistant/components/mobile_app/entity.py @@ -0,0 +1,98 @@ +"""A entity class for mobile_app.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import (ATTR_DEVICE_ID, ATTR_DEVICE_NAME, ATTR_MANUFACTURER, + ATTR_MODEL, ATTR_OS_VERSION, ATTR_SENSOR_ATTRIBUTES, + ATTR_SENSOR_DEVICE_CLASS, ATTR_SENSOR_ICON, + ATTR_SENSOR_NAME, ATTR_SENSOR_TYPE, ATTR_SENSOR_UNIQUE_ID, + DOMAIN, SIGNAL_SENSOR_UPDATE) + + +class MobileAppEntity(Entity): + """Representation of an mobile app entity.""" + + def __init__(self, config: dict, device: DeviceEntry, entry: ConfigEntry): + """Initialize the sensor.""" + self._config = config + self._device = device + self._entry = entry + self._registration = entry.data + self._sensor_id = "{}_{}".format(self._registration[CONF_WEBHOOK_ID], + config[ATTR_SENSOR_UNIQUE_ID]) + self._entity_type = config[ATTR_SENSOR_TYPE] + self.unsub_dispatcher = None + + async def async_added_to_hass(self): + """Register callbacks.""" + self.unsub_dispatcher = async_dispatcher_connect(self.hass, + SIGNAL_SENSOR_UPDATE, + self._handle_update) + + async def async_will_remove_from_hass(self): + """Disconnect dispatcher listener when removed.""" + if self.unsub_dispatcher is not None: + self.unsub_dispatcher() + + @property + def should_poll(self) -> bool: + """Declare that this entity pushes its state to HA.""" + return False + + @property + def name(self): + """Return the name of the mobile app sensor.""" + return self._config[ATTR_SENSOR_NAME] + + @property + def device_class(self): + """Return the device class.""" + return self._config.get(ATTR_SENSOR_DEVICE_CLASS) + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + return self._config[ATTR_SENSOR_ATTRIBUTES] + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return self._config[ATTR_SENSOR_ICON] + + @property + def unique_id(self): + """Return the unique ID of this sensor.""" + return self._sensor_id + + @property + def device_info(self): + """Return device registry information for this entity.""" + return { + 'identifiers': { + (ATTR_DEVICE_ID, self._registration[ATTR_DEVICE_ID]), + (CONF_WEBHOOK_ID, self._registration[CONF_WEBHOOK_ID]) + }, + 'manufacturer': self._registration[ATTR_MANUFACTURER], + 'model': self._registration[ATTR_MODEL], + 'device_name': self._registration[ATTR_DEVICE_NAME], + 'sw_version': self._registration[ATTR_OS_VERSION], + 'config_entries': self._device.config_entries + } + + async def async_update(self): + """Get the latest state of the sensor.""" + data = self.hass.data[DOMAIN] + try: + self._config = data[self._entity_type][self._sensor_id] + except KeyError: + return + + @callback + def _handle_update(self, data): + """Handle async event updates.""" + self._config = data + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/mobile_app/helpers.py b/homeassistant/components/mobile_app/helpers.py index 5ec3b99b2917..60bd8b4e1d6b 100644 --- a/homeassistant/components/mobile_app/helpers.py +++ b/homeassistant/components/mobile_app/helpers.py @@ -11,7 +11,8 @@ from homeassistant.helpers.typing import HomeAssistantType from .const import (ATTR_APP_DATA, ATTR_APP_ID, ATTR_APP_NAME, ATTR_APP_VERSION, ATTR_DEVICE_NAME, ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION, ATTR_SUPPORTS_ENCRYPTION, - CONF_SECRET, CONF_USER_ID, DATA_DELETED_IDS, DOMAIN) + CONF_SECRET, CONF_USER_ID, DATA_BINARY_SENSOR, + DATA_DELETED_IDS, DATA_SENSOR, DOMAIN) _LOGGER = logging.getLogger(__name__) @@ -123,7 +124,9 @@ def safe_registration(registration: Dict) -> Dict: def savable_state(hass: HomeAssistantType) -> Dict: """Return a clean object containing things that should be saved.""" return { + DATA_BINARY_SENSOR: hass.data[DOMAIN][DATA_BINARY_SENSOR], DATA_DELETED_IDS: hass.data[DOMAIN][DATA_DELETED_IDS], + DATA_SENSOR: hass.data[DOMAIN][DATA_SENSOR], } diff --git a/homeassistant/components/mobile_app/sensor.py b/homeassistant/components/mobile_app/sensor.py new file mode 100644 index 000000000000..c6a53ce57ec7 --- /dev/null +++ b/homeassistant/components/mobile_app/sensor.py @@ -0,0 +1,58 @@ +"""Sensor platform for mobile_app.""" +from functools import partial + +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import (ATTR_SENSOR_STATE, + ATTR_SENSOR_TYPE_SENSOR as ENTITY_TYPE, + ATTR_SENSOR_UOM, DATA_DEVICES, DOMAIN) + +from .entity import MobileAppEntity + +DEPENDENCIES = ['mobile_app'] + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up mobile app sensor from a config entry.""" + entities = list() + + webhook_id = config_entry.data[CONF_WEBHOOK_ID] + + for config in hass.data[DOMAIN][ENTITY_TYPE].values(): + if config[CONF_WEBHOOK_ID] != webhook_id: + continue + + device = hass.data[DOMAIN][DATA_DEVICES][webhook_id] + + entities.append(MobileAppSensor(config, device, config_entry)) + + async_add_entities(entities) + + @callback + def handle_sensor_registration(webhook_id, data): + if data[CONF_WEBHOOK_ID] != webhook_id: + return + + device = hass.data[DOMAIN][DATA_DEVICES][data[CONF_WEBHOOK_ID]] + + async_add_entities([MobileAppSensor(data, device, config_entry)]) + + async_dispatcher_connect(hass, + '{}_{}_register'.format(DOMAIN, ENTITY_TYPE), + partial(handle_sensor_registration, webhook_id)) + + +class MobileAppSensor(MobileAppEntity): + """Representation of an mobile app sensor.""" + + @property + def state(self): + """Return the state of the sensor.""" + return self._config[ATTR_SENSOR_STATE] + + @property + def unit_of_measurement(self): + """Return the unit of measurement this sensor expresses itself in.""" + return self._config[ATTR_SENSOR_UOM] diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 1fab29160b7d..aafa6046d110 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -10,30 +10,38 @@ from homeassistant.components.device_tracker import (ATTR_ATTRIBUTES, SERVICE_SEE as DT_SEE) from homeassistant.const import (ATTR_DOMAIN, ATTR_SERVICE, ATTR_SERVICE_DATA, - CONF_WEBHOOK_ID, HTTP_BAD_REQUEST) + CONF_WEBHOOK_ID, HTTP_BAD_REQUEST, + HTTP_CREATED) from homeassistant.core import EventOrigin -from homeassistant.exceptions import (ServiceNotFound, TemplateError) +from homeassistant.exceptions import (HomeAssistantError, + ServiceNotFound, TemplateError) from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.template import attach from homeassistant.helpers.typing import HomeAssistantType from .const import (ATTR_ALTITUDE, ATTR_BATTERY, ATTR_COURSE, ATTR_DEVICE_ID, ATTR_DEVICE_NAME, ATTR_EVENT_DATA, ATTR_EVENT_TYPE, ATTR_GPS, ATTR_GPS_ACCURACY, ATTR_LOCATION_NAME, - ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION, ATTR_SPEED, + ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION, + ATTR_SENSOR_TYPE, ATTR_SENSOR_UNIQUE_ID, ATTR_SPEED, ATTR_SUPPORTS_ENCRYPTION, ATTR_TEMPLATE, ATTR_TEMPLATE_VARIABLES, ATTR_VERTICAL_ACCURACY, ATTR_WEBHOOK_DATA, ATTR_WEBHOOK_ENCRYPTED, ATTR_WEBHOOK_ENCRYPTED_DATA, ATTR_WEBHOOK_TYPE, - CONF_SECRET, DATA_CONFIG_ENTRIES, DATA_DELETED_IDS, DOMAIN, - ERR_ENCRYPTION_REQUIRED, WEBHOOK_PAYLOAD_SCHEMA, + CONF_SECRET, DATA_CONFIG_ENTRIES, DATA_DELETED_IDS, + DATA_STORE, DOMAIN, ERR_ENCRYPTION_REQUIRED, + ERR_SENSOR_DUPLICATE_UNIQUE_ID, ERR_SENSOR_NOT_REGISTERED, + SIGNAL_SENSOR_UPDATE, WEBHOOK_PAYLOAD_SCHEMA, WEBHOOK_SCHEMAS, WEBHOOK_TYPE_CALL_SERVICE, - WEBHOOK_TYPE_FIRE_EVENT, WEBHOOK_TYPE_RENDER_TEMPLATE, - WEBHOOK_TYPE_UPDATE_LOCATION, - WEBHOOK_TYPE_UPDATE_REGISTRATION) + WEBHOOK_TYPE_FIRE_EVENT, WEBHOOK_TYPE_REGISTER_SENSOR, + WEBHOOK_TYPE_RENDER_TEMPLATE, WEBHOOK_TYPE_UPDATE_LOCATION, + WEBHOOK_TYPE_UPDATE_REGISTRATION, + WEBHOOK_TYPE_UPDATE_SENSOR_STATES) + from .helpers import (_decrypt_payload, empty_okay_response, error_response, - registration_context, safe_registration, + registration_context, safe_registration, savable_state, webhook_response) @@ -79,6 +87,10 @@ async def handle_webhook(hass: HomeAssistantType, webhook_id: str, enc_data = req_data[ATTR_WEBHOOK_ENCRYPTED_DATA] webhook_payload = _decrypt_payload(registration[CONF_SECRET], enc_data) + if webhook_type not in WEBHOOK_SCHEMAS: + _LOGGER.error('Received invalid webhook type: %s', webhook_type) + return empty_okay_response() + try: data = WEBHOOK_SCHEMAS[webhook_type](webhook_payload) except vol.Invalid as ex: @@ -172,3 +184,80 @@ async def handle_webhook(hass: HomeAssistantType, webhook_id: str, return webhook_response(safe_registration(new_registration), registration=registration, headers=headers) + + if webhook_type == WEBHOOK_TYPE_REGISTER_SENSOR: + entity_type = data[ATTR_SENSOR_TYPE] + + unique_id = data[ATTR_SENSOR_UNIQUE_ID] + + unique_store_key = "{}_{}".format(webhook_id, unique_id) + + if unique_store_key in hass.data[DOMAIN][entity_type]: + _LOGGER.error("Refusing to re-register existing sensor %s!", + unique_id) + return error_response(ERR_SENSOR_DUPLICATE_UNIQUE_ID, + "{} {} already exists!".format(entity_type, + unique_id), + status=409) + + data[CONF_WEBHOOK_ID] = webhook_id + + hass.data[DOMAIN][entity_type][unique_store_key] = data + + try: + await hass.data[DOMAIN][DATA_STORE].async_save(savable_state(hass)) + except HomeAssistantError as ex: + _LOGGER.error("Error registering sensor: %s", ex) + return empty_okay_response() + + register_signal = '{}_{}_register'.format(DOMAIN, + data[ATTR_SENSOR_TYPE]) + async_dispatcher_send(hass, register_signal, data) + + return webhook_response({"status": "registered"}, + registration=registration, status=HTTP_CREATED, + headers=headers) + + if webhook_type == WEBHOOK_TYPE_UPDATE_SENSOR_STATES: + resp = {} + for sensor in data: + entity_type = sensor[ATTR_SENSOR_TYPE] + + unique_id = sensor[ATTR_SENSOR_UNIQUE_ID] + + unique_store_key = "{}_{}".format(webhook_id, unique_id) + + if unique_store_key not in hass.data[DOMAIN][entity_type]: + _LOGGER.error("Refusing to update non-registered sensor: %s", + unique_store_key) + err_msg = '{} {} is not registered'.format(entity_type, + unique_id) + resp[unique_id] = { + 'success': False, + 'error': { + 'code': ERR_SENSOR_NOT_REGISTERED, + 'message': err_msg + } + } + continue + + entry = hass.data[DOMAIN][entity_type][unique_store_key] + + new_state = {**entry, **sensor} + + hass.data[DOMAIN][entity_type][unique_store_key] = new_state + + safe = savable_state(hass) + + try: + await hass.data[DOMAIN][DATA_STORE].async_save(safe) + except HomeAssistantError as ex: + _LOGGER.error("Error updating mobile_app registration: %s", ex) + return empty_okay_response() + + async_dispatcher_send(hass, SIGNAL_SENSOR_UPDATE, new_state) + + resp[unique_id] = {"status": "okay"} + + return webhook_response(resp, registration=registration, + headers=headers) diff --git a/tests/components/mobile_app/__init__.py b/tests/components/mobile_app/__init__.py index bed275a534d6..cf617ff05289 100644 --- a/tests/components/mobile_app/__init__.py +++ b/tests/components/mobile_app/__init__.py @@ -6,9 +6,9 @@ from tests.common import mock_device_registry from homeassistant.setup import async_setup_component -from homeassistant.components.mobile_app.const import (DATA_CONFIG_ENTRIES, +from homeassistant.components.mobile_app.const import (DATA_BINARY_SENSOR, DATA_DELETED_IDS, - DATA_DEVICES, + DATA_SENSOR, DOMAIN, STORAGE_KEY, STORAGE_VERSION) @@ -48,7 +48,9 @@ async def webhook_client(hass, aiohttp_client, hass_storage, hass_admin_user): hass_storage[STORAGE_KEY] = { 'version': STORAGE_VERSION, 'data': { - DATA_CONFIG_ENTRIES: {}, DATA_DELETED_IDS: [], DATA_DEVICES: {}, + DATA_BINARY_SENSOR: {}, + DATA_DELETED_IDS: [], + DATA_SENSOR: {} } } diff --git a/tests/components/mobile_app/test_entity.py b/tests/components/mobile_app/test_entity.py new file mode 100644 index 000000000000..d8cb91a8bc61 --- /dev/null +++ b/tests/components/mobile_app/test_entity.py @@ -0,0 +1,137 @@ +"""Entity tests for mobile_app.""" +# pylint: disable=redefined-outer-name,unused-import +import logging + +from . import (authed_api_client, create_registrations, # noqa: F401 + webhook_client) # noqa: F401 + +_LOGGER = logging.getLogger(__name__) + + +async def test_sensor(hass, create_registrations, webhook_client): # noqa: F401, F811, E501 + """Test that sensors can be registered and updated.""" + webhook_id = create_registrations[1]['webhook_id'] + webhook_url = '/api/webhook/{}'.format(webhook_id) + + reg_resp = await webhook_client.post( + webhook_url, + json={ + 'type': 'register_sensor', + 'data': { + 'attributes': { + 'foo': 'bar' + }, + 'device_class': 'battery', + 'icon': 'mdi:battery', + 'name': 'Battery State', + 'state': 100, + 'type': 'sensor', + 'unique_id': 'battery_state', + 'unit_of_measurement': '%' + } + } + ) + + assert reg_resp.status == 201 + + json = await reg_resp.json() + assert json == {'status': 'registered'} + + # 3 because we require device_tracker which adds zone.home and + # group.all_devices + assert len(hass.states.async_all()) == 3 + + entity = hass.states.async_all()[2] + + assert entity.attributes['device_class'] == 'battery' + assert entity.attributes['icon'] == 'mdi:battery' + assert entity.attributes['unit_of_measurement'] == '%' + assert entity.attributes['foo'] == 'bar' + assert entity.domain == 'sensor' + assert entity.name == 'Battery State' + assert entity.state == '100' + + update_resp = await webhook_client.post( + webhook_url, + json={ + 'type': 'update_sensor_states', + 'data': [ + { + 'icon': 'mdi:battery-unknown', + 'state': 123, + 'type': 'sensor', + 'unique_id': 'battery_state' + } + ] + } + ) + + assert update_resp.status == 200 + + updated_entity = hass.states.async_all()[2] + + assert updated_entity.state == '123' + + +async def test_sensor_must_register(hass, create_registrations, # noqa: F401, F811, E501 + webhook_client): # noqa: F401, F811, E501 + """Test that sensors must be registered before updating.""" + webhook_id = create_registrations[1]['webhook_id'] + webhook_url = '/api/webhook/{}'.format(webhook_id) + resp = await webhook_client.post( + webhook_url, + json={ + 'type': 'update_sensor_states', + 'data': [ + { + 'state': 123, + 'type': 'sensor', + 'unique_id': 'battery_state' + } + ] + } + ) + + assert resp.status == 200 + + json = await resp.json() + assert json['battery_state']['success'] is False + assert json['battery_state']['error']['code'] == 'not_registered' + + +async def test_sensor_id_no_dupes(hass, create_registrations, # noqa: F401, F811, E501 + webhook_client): # noqa: F401, F811, E501 + """Test that sensors must have a unique ID.""" + webhook_id = create_registrations[1]['webhook_id'] + webhook_url = '/api/webhook/{}'.format(webhook_id) + + payload = { + 'type': 'register_sensor', + 'data': { + 'attributes': { + 'foo': 'bar' + }, + 'device_class': 'battery', + 'icon': 'mdi:battery', + 'name': 'Battery State', + 'state': 100, + 'type': 'sensor', + 'unique_id': 'battery_state', + 'unit_of_measurement': '%' + } + } + + reg_resp = await webhook_client.post(webhook_url, json=payload) + + assert reg_resp.status == 201 + + reg_json = await reg_resp.json() + assert reg_json == {'status': 'registered'} + + dupe_resp = await webhook_client.post(webhook_url, json=payload) + + assert dupe_resp.status == 409 + + dupe_json = await dupe_resp.json() + assert dupe_json['success'] is False + assert dupe_json['error']['code'] == 'duplicate_unique_id'