From 2f3c2f5f4d713ba954237e426340f41eb3e782c0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 19 Feb 2021 19:44:15 -1000 Subject: [PATCH] Add support for using a single endpoint for rest data (#46711) --- homeassistant/components/rest/__init__.py | 172 ++++++++- .../components/rest/binary_sensor.py | 155 ++------ homeassistant/components/rest/const.py | 20 ++ homeassistant/components/rest/entity.py | 89 +++++ homeassistant/components/rest/notify.py | 5 - homeassistant/components/rest/schema.py | 99 +++++ homeassistant/components/rest/sensor.py | 172 ++------- homeassistant/components/rest/switch.py | 7 - tests/components/rest/test_init.py | 340 ++++++++++++++++++ tests/components/rest/test_notify.py | 4 + tests/components/rest/test_sensor.py | 32 ++ tests/components/rest/test_switch.py | 22 +- tests/fixtures/rest/configuration_empty.yaml | 0 .../rest/configuration_invalid.notyaml | 2 + .../rest/configuration_top_level.yaml | 12 + 15 files changed, 858 insertions(+), 273 deletions(-) create mode 100644 homeassistant/components/rest/const.py create mode 100644 homeassistant/components/rest/entity.py create mode 100644 homeassistant/components/rest/schema.py create mode 100644 tests/components/rest/test_init.py create mode 100644 tests/fixtures/rest/configuration_empty.yaml create mode 100644 tests/fixtures/rest/configuration_invalid.notyaml create mode 100644 tests/fixtures/rest/configuration_top_level.yaml diff --git a/homeassistant/components/rest/__init__.py b/homeassistant/components/rest/__init__.py index 69bc6172341..ebeddcfd7c7 100644 --- a/homeassistant/components/rest/__init__.py +++ b/homeassistant/components/rest/__init__.py @@ -1,4 +1,174 @@ """The rest component.""" -DOMAIN = "rest" +import asyncio +import logging + +import httpx +import voluptuous as vol + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import ( + CONF_AUTHENTICATION, + CONF_HEADERS, + CONF_METHOD, + CONF_PARAMS, + CONF_PASSWORD, + CONF_PAYLOAD, + CONF_RESOURCE, + CONF_RESOURCE_TEMPLATE, + CONF_SCAN_INTERVAL, + CONF_TIMEOUT, + CONF_USERNAME, + CONF_VERIFY_SSL, + HTTP_DIGEST_AUTHENTICATION, + SERVICE_RELOAD, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import discovery +from homeassistant.helpers.entity_component import ( + DEFAULT_SCAN_INTERVAL, + EntityComponent, +) +from homeassistant.helpers.reload import async_reload_integration_platforms +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import COORDINATOR, DOMAIN, PLATFORM_IDX, REST, REST_IDX +from .data import RestData +from .schema import CONFIG_SCHEMA # noqa:F401 pylint: disable=unused-import + +_LOGGER = logging.getLogger(__name__) + PLATFORMS = ["binary_sensor", "notify", "sensor", "switch"] +COORDINATOR_AWARE_PLATFORMS = [SENSOR_DOMAIN, BINARY_SENSOR_DOMAIN] + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the rest platforms.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + _async_setup_shared_data(hass) + + async def reload_service_handler(service): + """Remove all user-defined groups and load new ones from config.""" + conf = await component.async_prepare_reload() + if conf is None: + return + await async_reload_integration_platforms(hass, DOMAIN, PLATFORMS) + _async_setup_shared_data(hass) + await _async_process_config(hass, conf) + + hass.services.async_register( + DOMAIN, SERVICE_RELOAD, reload_service_handler, schema=vol.Schema({}) + ) + + return await _async_process_config(hass, config) + + +@callback +def _async_setup_shared_data(hass: HomeAssistant): + """Create shared data for platform config and rest coordinators.""" + hass.data[DOMAIN] = {platform: {} for platform in COORDINATOR_AWARE_PLATFORMS} + + +async def _async_process_config(hass, config) -> bool: + """Process rest configuration.""" + if DOMAIN not in config: + return True + + refresh_tasks = [] + load_tasks = [] + for rest_idx, conf in enumerate(config[DOMAIN]): + scan_interval = conf.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + resource_template = conf.get(CONF_RESOURCE_TEMPLATE) + rest = create_rest_data_from_config(hass, conf) + coordinator = _wrap_rest_in_coordinator( + hass, rest, resource_template, scan_interval + ) + refresh_tasks.append(coordinator.async_refresh()) + hass.data[DOMAIN][rest_idx] = {REST: rest, COORDINATOR: coordinator} + + for platform_domain in COORDINATOR_AWARE_PLATFORMS: + if platform_domain not in conf: + continue + + for platform_idx, platform_conf in enumerate(conf[platform_domain]): + hass.data[DOMAIN][platform_domain][platform_idx] = platform_conf + + load = discovery.async_load_platform( + hass, + platform_domain, + DOMAIN, + {REST_IDX: rest_idx, PLATFORM_IDX: platform_idx}, + config, + ) + load_tasks.append(load) + + if refresh_tasks: + await asyncio.gather(*refresh_tasks) + + if load_tasks: + await asyncio.gather(*load_tasks) + + return True + + +async def async_get_config_and_coordinator(hass, platform_domain, discovery_info): + """Get the config and coordinator for the platform from discovery.""" + shared_data = hass.data[DOMAIN][discovery_info[REST_IDX]] + conf = hass.data[DOMAIN][platform_domain][discovery_info[PLATFORM_IDX]] + coordinator = shared_data[COORDINATOR] + rest = shared_data[REST] + if rest.data is None: + await coordinator.async_request_refresh() + return conf, coordinator, rest + + +def _wrap_rest_in_coordinator(hass, rest, resource_template, update_interval): + """Wrap a DataUpdateCoordinator around the rest object.""" + if resource_template: + + async def _async_refresh_with_resource_template(): + rest.set_url(resource_template.async_render(parse_result=False)) + await rest.async_update() + + update_method = _async_refresh_with_resource_template + else: + update_method = rest.async_update + + return DataUpdateCoordinator( + hass, + _LOGGER, + name="rest data", + update_method=update_method, + update_interval=update_interval, + ) + + +def create_rest_data_from_config(hass, config): + """Create RestData from config.""" + resource = config.get(CONF_RESOURCE) + resource_template = config.get(CONF_RESOURCE_TEMPLATE) + method = config.get(CONF_METHOD) + payload = config.get(CONF_PAYLOAD) + verify_ssl = config.get(CONF_VERIFY_SSL) + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + headers = config.get(CONF_HEADERS) + params = config.get(CONF_PARAMS) + timeout = config.get(CONF_TIMEOUT) + + if resource_template is not None: + resource_template.hass = hass + resource = resource_template.async_render(parse_result=False) + + if username and password: + if config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION: + auth = httpx.DigestAuth(username, password) + else: + auth = (username, password) + else: + auth = None + + return RestData( + hass, method, resource, auth, headers, params, payload, verify_ssl, timeout + ) diff --git a/homeassistant/components/rest/binary_sensor.py b/homeassistant/components/rest/binary_sensor.py index 49c10354c51..9692f5b9339 100644 --- a/homeassistant/components/rest/binary_sensor.py +++ b/homeassistant/components/rest/binary_sensor.py @@ -1,64 +1,27 @@ """Support for RESTful binary sensors.""" -import httpx import voluptuous as vol from homeassistant.components.binary_sensor import ( - DEVICE_CLASSES_SCHEMA, + DOMAIN as BINARY_SENSOR_DOMAIN, PLATFORM_SCHEMA, BinarySensorEntity, ) from homeassistant.const import ( - CONF_AUTHENTICATION, CONF_DEVICE_CLASS, CONF_FORCE_UPDATE, - CONF_HEADERS, - CONF_METHOD, CONF_NAME, - CONF_PARAMS, - CONF_PASSWORD, - CONF_PAYLOAD, CONF_RESOURCE, CONF_RESOURCE_TEMPLATE, - CONF_TIMEOUT, - CONF_USERNAME, CONF_VALUE_TEMPLATE, - CONF_VERIFY_SSL, - HTTP_BASIC_AUTHENTICATION, - HTTP_DIGEST_AUTHENTICATION, ) from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.reload import async_setup_reload_service -from . import DOMAIN, PLATFORMS -from .data import DEFAULT_TIMEOUT, RestData +from . import async_get_config_and_coordinator, create_rest_data_from_config +from .entity import RestEntity +from .schema import BINARY_SENSOR_SCHEMA, RESOURCE_SCHEMA -DEFAULT_METHOD = "GET" -DEFAULT_NAME = "REST Binary Sensor" -DEFAULT_VERIFY_SSL = True -DEFAULT_FORCE_UPDATE = False - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Exclusive(CONF_RESOURCE, CONF_RESOURCE): cv.url, - vol.Exclusive(CONF_RESOURCE_TEMPLATE, CONF_RESOURCE): cv.template, - vol.Optional(CONF_AUTHENTICATION): vol.In( - [HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION] - ), - vol.Optional(CONF_HEADERS): {cv.string: cv.string}, - vol.Optional(CONF_PARAMS): {cv.string: cv.string}, - vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): vol.In(["POST", "GET"]), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_PAYLOAD): cv.string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, - vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - } -) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({**RESOURCE_SCHEMA, **BINARY_SENSOR_SCHEMA}) PLATFORM_SCHEMA = vol.All( cv.has_at_least_one_key(CONF_RESOURCE, CONF_RESOURCE_TEMPLATE), PLATFORM_SCHEMA @@ -67,51 +30,34 @@ PLATFORM_SCHEMA = vol.All( async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the REST binary sensor.""" - - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - - name = config.get(CONF_NAME) - resource = config.get(CONF_RESOURCE) - resource_template = config.get(CONF_RESOURCE_TEMPLATE) - method = config.get(CONF_METHOD) - payload = config.get(CONF_PAYLOAD) - verify_ssl = config.get(CONF_VERIFY_SSL) - timeout = config.get(CONF_TIMEOUT) - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - headers = config.get(CONF_HEADERS) - params = config.get(CONF_PARAMS) - device_class = config.get(CONF_DEVICE_CLASS) - value_template = config.get(CONF_VALUE_TEMPLATE) - force_update = config.get(CONF_FORCE_UPDATE) - - if resource_template is not None: - resource_template.hass = hass - resource = resource_template.async_render(parse_result=False) - - if value_template is not None: - value_template.hass = hass - - if username and password: - if config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION: - auth = httpx.DigestAuth(username, password) - else: - auth = (username, password) + # Must update the sensor now (including fetching the rest resource) to + # ensure it's updating its state. + if discovery_info is not None: + conf, coordinator, rest = await async_get_config_and_coordinator( + hass, BINARY_SENSOR_DOMAIN, discovery_info + ) else: - auth = None - - rest = RestData( - hass, method, resource, auth, headers, params, payload, verify_ssl, timeout - ) - await rest.async_update() + conf = config + coordinator = None + rest = create_rest_data_from_config(hass, conf) + await rest.async_update() if rest.data is None: raise PlatformNotReady + name = conf.get(CONF_NAME) + device_class = conf.get(CONF_DEVICE_CLASS) + value_template = conf.get(CONF_VALUE_TEMPLATE) + force_update = conf.get(CONF_FORCE_UPDATE) + resource_template = conf.get(CONF_RESOURCE_TEMPLATE) + + if value_template is not None: + value_template.hass = hass + async_add_entities( [ RestBinarySensor( - hass, + coordinator, rest, name, device_class, @@ -123,12 +69,12 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= ) -class RestBinarySensor(BinarySensorEntity): +class RestBinarySensor(RestEntity, BinarySensorEntity): """Representation of a REST binary sensor.""" def __init__( self, - hass, + coordinator, rest, name, device_class, @@ -137,36 +83,23 @@ class RestBinarySensor(BinarySensorEntity): resource_template, ): """Initialize a REST binary sensor.""" - self._hass = hass - self.rest = rest - self._name = name - self._device_class = device_class + super().__init__( + coordinator, rest, name, device_class, resource_template, force_update + ) self._state = False self._previous_data = None self._value_template = value_template - self._force_update = force_update - self._resource_template = resource_template - - @property - def name(self): - """Return the name of the binary sensor.""" - return self._name - - @property - def device_class(self): - """Return the class of this sensor.""" - return self._device_class - - @property - def available(self): - """Return the availability of this sensor.""" - return self.rest.data is not None + self._is_on = None @property def is_on(self): """Return true if the binary sensor is on.""" + return self._is_on + + def _update_from_rest_data(self): + """Update state from the rest data.""" if self.rest.data is None: - return False + self._is_on = False response = self.rest.data @@ -176,20 +109,8 @@ class RestBinarySensor(BinarySensorEntity): ) try: - return bool(int(response)) + self._is_on = bool(int(response)) except ValueError: - return {"true": True, "on": True, "open": True, "yes": True}.get( + self._is_on = {"true": True, "on": True, "open": True, "yes": True}.get( response.lower(), False ) - - @property - def force_update(self): - """Force update.""" - return self._force_update - - async def async_update(self): - """Get the latest data from REST API and updates the state.""" - if self._resource_template is not None: - self.rest.set_url(self._resource_template.async_render(parse_result=False)) - - await self.rest.async_update() diff --git a/homeassistant/components/rest/const.py b/homeassistant/components/rest/const.py new file mode 100644 index 00000000000..31216b65968 --- /dev/null +++ b/homeassistant/components/rest/const.py @@ -0,0 +1,20 @@ +"""The rest component constants.""" + +DOMAIN = "rest" + +DEFAULT_METHOD = "GET" +DEFAULT_VERIFY_SSL = True +DEFAULT_FORCE_UPDATE = False + +DEFAULT_BINARY_SENSOR_NAME = "REST Binary Sensor" +DEFAULT_SENSOR_NAME = "REST Sensor" +CONF_JSON_ATTRS = "json_attributes" +CONF_JSON_ATTRS_PATH = "json_attributes_path" + +REST_IDX = "rest_idx" +PLATFORM_IDX = "platform_idx" + +COORDINATOR = "coordinator" +REST = "rest" + +METHODS = ["POST", "GET"] diff --git a/homeassistant/components/rest/entity.py b/homeassistant/components/rest/entity.py new file mode 100644 index 00000000000..acfe5a2dfc5 --- /dev/null +++ b/homeassistant/components/rest/entity.py @@ -0,0 +1,89 @@ +"""The base entity for the rest component.""" + +from abc import abstractmethod +from typing import Any + +from homeassistant.core import callback +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .data import RestData + + +class RestEntity(Entity): + """A class for entities using DataUpdateCoordinator or rest data directly.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator[Any], + rest: RestData, + name, + device_class, + resource_template, + force_update, + ) -> None: + """Create the entity that may have a coordinator.""" + self.coordinator = coordinator + self.rest = rest + self._name = name + self._device_class = device_class + self._resource_template = resource_template + self._force_update = force_update + super().__init__() + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def device_class(self): + """Return the class of this sensor.""" + return self._device_class + + @property + def force_update(self): + """Force update.""" + return self._force_update + + @property + def should_poll(self) -> bool: + """Poll only if we do noty have a coordinator.""" + return not self.coordinator + + @property + def available(self): + """Return the availability of this sensor.""" + if self.coordinator and not self.coordinator.last_update_success: + return False + return self.rest.data is not None + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._update_from_rest_data() + if self.coordinator: + self.async_on_remove( + self.coordinator.async_add_listener(self._handle_coordinator_update) + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_from_rest_data() + self.async_write_ha_state() + + async def async_update(self): + """Get the latest data from REST API and update the state.""" + if self.coordinator: + await self.coordinator.async_request_refresh() + return + + if self._resource_template is not None: + self.rest.set_url(self._resource_template.async_render(parse_result=False)) + await self.rest.async_update() + self._update_from_rest_data() + + @abstractmethod + def _update_from_rest_data(self): + """Update state from the rest data.""" diff --git a/homeassistant/components/rest/notify.py b/homeassistant/components/rest/notify.py index f15df428640..198e5b06c52 100644 --- a/homeassistant/components/rest/notify.py +++ b/homeassistant/components/rest/notify.py @@ -29,11 +29,8 @@ from homeassistant.const import ( HTTP_OK, ) import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.reload import setup_reload_service from homeassistant.helpers.template import Template -from . import DOMAIN, PLATFORMS - CONF_DATA = "data" CONF_DATA_TEMPLATE = "data_template" CONF_MESSAGE_PARAMETER_NAME = "message_param_name" @@ -73,8 +70,6 @@ _LOGGER = logging.getLogger(__name__) def get_service(hass, config, discovery_info=None): """Get the RESTful notification service.""" - setup_reload_service(hass, DOMAIN, PLATFORMS) - resource = config.get(CONF_RESOURCE) method = config.get(CONF_METHOD) headers = config.get(CONF_HEADERS) diff --git a/homeassistant/components/rest/schema.py b/homeassistant/components/rest/schema.py new file mode 100644 index 00000000000..bedd02d272a --- /dev/null +++ b/homeassistant/components/rest/schema.py @@ -0,0 +1,99 @@ +"""The rest component schemas.""" + +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASSES_SCHEMA as BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, + DOMAIN as BINARY_SENSOR_DOMAIN, +) +from homeassistant.components.sensor import ( + DEVICE_CLASSES_SCHEMA as SENSOR_DEVICE_CLASSES_SCHEMA, + DOMAIN as SENSOR_DOMAIN, +) +from homeassistant.const import ( + CONF_AUTHENTICATION, + CONF_DEVICE_CLASS, + CONF_FORCE_UPDATE, + CONF_HEADERS, + CONF_METHOD, + CONF_NAME, + CONF_PARAMS, + CONF_PASSWORD, + CONF_PAYLOAD, + CONF_RESOURCE, + CONF_RESOURCE_TEMPLATE, + CONF_SCAN_INTERVAL, + CONF_TIMEOUT, + CONF_UNIT_OF_MEASUREMENT, + CONF_USERNAME, + CONF_VALUE_TEMPLATE, + CONF_VERIFY_SSL, + HTTP_BASIC_AUTHENTICATION, + HTTP_DIGEST_AUTHENTICATION, +) +import homeassistant.helpers.config_validation as cv + +from .const import ( + CONF_JSON_ATTRS, + CONF_JSON_ATTRS_PATH, + DEFAULT_BINARY_SENSOR_NAME, + DEFAULT_FORCE_UPDATE, + DEFAULT_METHOD, + DEFAULT_SENSOR_NAME, + DEFAULT_VERIFY_SSL, + DOMAIN, + METHODS, +) +from .data import DEFAULT_TIMEOUT + +RESOURCE_SCHEMA = { + vol.Exclusive(CONF_RESOURCE, CONF_RESOURCE): cv.url, + vol.Exclusive(CONF_RESOURCE_TEMPLATE, CONF_RESOURCE): cv.template, + vol.Optional(CONF_AUTHENTICATION): vol.In( + [HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION] + ), + vol.Optional(CONF_HEADERS): vol.Schema({cv.string: cv.string}), + vol.Optional(CONF_PARAMS): vol.Schema({cv.string: cv.string}), + vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): vol.In(METHODS), + vol.Optional(CONF_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PAYLOAD): cv.string, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, +} + +SENSOR_SCHEMA = { + vol.Optional(CONF_NAME, default=DEFAULT_SENSOR_NAME): cv.string, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_JSON_ATTRS, default=[]): cv.ensure_list_csv, + vol.Optional(CONF_JSON_ATTRS_PATH): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, +} + +BINARY_SENSOR_SCHEMA = { + vol.Optional(CONF_NAME, default=DEFAULT_BINARY_SENSOR_NAME): cv.string, + vol.Optional(CONF_DEVICE_CLASS): BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, +} + + +COMBINED_SCHEMA = vol.Schema( + { + vol.Optional(CONF_SCAN_INTERVAL): cv.time_period, + **RESOURCE_SCHEMA, + vol.Optional(SENSOR_DOMAIN): vol.All( + cv.ensure_list, [vol.Schema(SENSOR_SCHEMA)] + ), + vol.Optional(BINARY_SENSOR_DOMAIN): vol.All( + cv.ensure_list, [vol.Schema(BINARY_SENSOR_SCHEMA)] + ), + } +) + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.All(cv.ensure_list, [COMBINED_SCHEMA])}, + extra=vol.ALLOW_EXTRA, +) diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index 85d79b6b331..0699d9dc07c 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -3,76 +3,31 @@ import json import logging from xml.parsers.expat import ExpatError -import httpx from jsonpath import jsonpath import voluptuous as vol import xmltodict -from homeassistant.components.sensor import DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA from homeassistant.const import ( - CONF_AUTHENTICATION, CONF_DEVICE_CLASS, CONF_FORCE_UPDATE, - CONF_HEADERS, - CONF_METHOD, CONF_NAME, - CONF_PARAMS, - CONF_PASSWORD, - CONF_PAYLOAD, CONF_RESOURCE, CONF_RESOURCE_TEMPLATE, - CONF_TIMEOUT, CONF_UNIT_OF_MEASUREMENT, - CONF_USERNAME, CONF_VALUE_TEMPLATE, - CONF_VERIFY_SSL, - HTTP_BASIC_AUTHENTICATION, - HTTP_DIGEST_AUTHENTICATION, ) from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.reload import async_setup_reload_service -from . import DOMAIN, PLATFORMS -from .data import DEFAULT_TIMEOUT, RestData +from . import async_get_config_and_coordinator, create_rest_data_from_config +from .const import CONF_JSON_ATTRS, CONF_JSON_ATTRS_PATH +from .entity import RestEntity +from .schema import RESOURCE_SCHEMA, SENSOR_SCHEMA _LOGGER = logging.getLogger(__name__) -DEFAULT_METHOD = "GET" -DEFAULT_NAME = "REST Sensor" -DEFAULT_VERIFY_SSL = True -DEFAULT_FORCE_UPDATE = False - - -CONF_JSON_ATTRS = "json_attributes" -CONF_JSON_ATTRS_PATH = "json_attributes_path" -METHODS = ["POST", "GET"] - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Exclusive(CONF_RESOURCE, CONF_RESOURCE): cv.url, - vol.Exclusive(CONF_RESOURCE_TEMPLATE, CONF_RESOURCE): cv.template, - vol.Optional(CONF_AUTHENTICATION): vol.In( - [HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION] - ), - vol.Optional(CONF_HEADERS): vol.Schema({cv.string: cv.string}), - vol.Optional(CONF_PARAMS): vol.Schema({cv.string: cv.string}), - vol.Optional(CONF_JSON_ATTRS, default=[]): cv.ensure_list_csv, - vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): vol.In(METHODS), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_PAYLOAD): cv.string, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_JSON_ATTRS_PATH): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, - vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - } -) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({**RESOURCE_SCHEMA, **SENSOR_SCHEMA}) PLATFORM_SCHEMA = vol.All( cv.has_at_least_one_key(CONF_RESOURCE, CONF_RESOURCE_TEMPLATE), PLATFORM_SCHEMA @@ -81,55 +36,37 @@ PLATFORM_SCHEMA = vol.All( async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the RESTful sensor.""" - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - - name = config.get(CONF_NAME) - resource = config.get(CONF_RESOURCE) - resource_template = config.get(CONF_RESOURCE_TEMPLATE) - method = config.get(CONF_METHOD) - payload = config.get(CONF_PAYLOAD) - verify_ssl = config.get(CONF_VERIFY_SSL) - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - headers = config.get(CONF_HEADERS) - params = config.get(CONF_PARAMS) - unit = config.get(CONF_UNIT_OF_MEASUREMENT) - device_class = config.get(CONF_DEVICE_CLASS) - value_template = config.get(CONF_VALUE_TEMPLATE) - json_attrs = config.get(CONF_JSON_ATTRS) - json_attrs_path = config.get(CONF_JSON_ATTRS_PATH) - force_update = config.get(CONF_FORCE_UPDATE) - timeout = config.get(CONF_TIMEOUT) - - if value_template is not None: - value_template.hass = hass - - if resource_template is not None: - resource_template.hass = hass - resource = resource_template.async_render(parse_result=False) - - if username and password: - if config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION: - auth = httpx.DigestAuth(username, password) - else: - auth = (username, password) + # Must update the sensor now (including fetching the rest resource) to + # ensure it's updating its state. + if discovery_info is not None: + conf, coordinator, rest = await async_get_config_and_coordinator( + hass, SENSOR_DOMAIN, discovery_info + ) else: - auth = None - rest = RestData( - hass, method, resource, auth, headers, params, payload, verify_ssl, timeout - ) - - await rest.async_update() + conf = config + coordinator = None + rest = create_rest_data_from_config(hass, conf) + await rest.async_update() if rest.data is None: raise PlatformNotReady - # Must update the sensor now (including fetching the rest resource) to - # ensure it's updating its state. + name = conf.get(CONF_NAME) + unit = conf.get(CONF_UNIT_OF_MEASUREMENT) + device_class = conf.get(CONF_DEVICE_CLASS) + json_attrs = conf.get(CONF_JSON_ATTRS) + json_attrs_path = conf.get(CONF_JSON_ATTRS_PATH) + value_template = conf.get(CONF_VALUE_TEMPLATE) + force_update = conf.get(CONF_FORCE_UPDATE) + resource_template = conf.get(CONF_RESOURCE_TEMPLATE) + + if value_template is not None: + value_template.hass = hass + async_add_entities( [ RestSensor( - hass, + coordinator, rest, name, unit, @@ -144,12 +81,12 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= ) -class RestSensor(Entity): +class RestSensor(RestEntity): """Implementation of a REST sensor.""" def __init__( self, - hass, + coordinator, rest, name, unit_of_measurement, @@ -161,60 +98,30 @@ class RestSensor(Entity): json_attrs_path, ): """Initialize the REST sensor.""" - self._hass = hass - self.rest = rest - self._name = name + super().__init__( + coordinator, rest, name, device_class, resource_template, force_update + ) self._state = None self._unit_of_measurement = unit_of_measurement - self._device_class = device_class self._value_template = value_template self._json_attrs = json_attrs self._attributes = None - self._force_update = force_update - self._resource_template = resource_template self._json_attrs_path = json_attrs_path - @property - def name(self): - """Return the name of the sensor.""" - return self._name - @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement - @property - def device_class(self): - """Return the class of this sensor.""" - return self._device_class - - @property - def available(self): - """Return if the sensor data are available.""" - return self.rest.data is not None - @property def state(self): """Return the state of the device.""" return self._state @property - def force_update(self): - """Force update.""" - return self._force_update - - async def async_update(self): - """Get the latest data from REST API and update the state.""" - if self._resource_template is not None: - self.rest.set_url(self._resource_template.async_render(parse_result=False)) - - await self.rest.async_update() - self._update_from_rest_data() - - async def async_added_to_hass(self): - """Ensure the data from the initial update is reflected in the state.""" - self._update_from_rest_data() + def device_state_attributes(self): + """Return the state attributes.""" + return self._attributes def _update_from_rest_data(self): """Update state from the rest data.""" @@ -273,8 +180,3 @@ class RestSensor(Entity): ) self._state = value - - @property - def device_state_attributes(self): - """Return the state attributes.""" - return self._attributes diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index ea480d549f3..e8ae1dee015 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -22,12 +22,8 @@ from homeassistant.const import ( ) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.reload import async_setup_reload_service - -from . import DOMAIN, PLATFORMS _LOGGER = logging.getLogger(__name__) - CONF_BODY_OFF = "body_off" CONF_BODY_ON = "body_on" CONF_IS_ON_TEMPLATE = "is_on_template" @@ -65,9 +61,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the RESTful switch.""" - - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - body_off = config.get(CONF_BODY_OFF) body_on = config.get(CONF_BODY_ON) is_on_template = config.get(CONF_IS_ON_TEMPLATE) diff --git a/tests/components/rest/test_init.py b/tests/components/rest/test_init.py new file mode 100644 index 00000000000..19a5651e989 --- /dev/null +++ b/tests/components/rest/test_init.py @@ -0,0 +1,340 @@ +"""Tests for rest component.""" + +import asyncio +from datetime import timedelta +from os import path +from unittest.mock import patch + +import respx + +from homeassistant import config as hass_config +from homeassistant.components.rest.const import DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + DATA_MEGABYTES, + SERVICE_RELOAD, + STATE_UNAVAILABLE, +) +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow + +from tests.common import async_fire_time_changed + + +@respx.mock +async def test_setup_with_endpoint_timeout_with_recovery(hass): + """Test setup with an endpoint that times out that recovers.""" + await async_setup_component(hass, "homeassistant", {}) + + respx.get("http://localhost").mock(side_effect=asyncio.TimeoutError()) + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + "resource": "http://localhost", + "method": "GET", + "verify_ssl": "false", + "timeout": 30, + "sensor": [ + { + "unit_of_measurement": DATA_MEGABYTES, + "name": "sensor1", + "value_template": "{{ value_json.sensor1 }}", + }, + { + "unit_of_measurement": DATA_MEGABYTES, + "name": "sensor2", + "value_template": "{{ value_json.sensor2 }}", + }, + ], + "binary_sensor": [ + { + "name": "binary_sensor1", + "value_template": "{{ value_json.binary_sensor1 }}", + }, + { + "name": "binary_sensor2", + "value_template": "{{ value_json.binary_sensor2 }}", + }, + ], + } + ] + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 + + respx.get("http://localhost").respond( + status_code=200, + json={ + "sensor1": "1", + "sensor2": "2", + "binary_sensor1": "on", + "binary_sensor2": "off", + }, + ) + + # Refresh the coordinator + async_fire_time_changed(hass, utcnow() + timedelta(seconds=31)) + await hass.async_block_till_done() + + # Wait for platform setup retry + async_fire_time_changed(hass, utcnow() + timedelta(seconds=61)) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 4 + + assert hass.states.get("sensor.sensor1").state == "1" + assert hass.states.get("sensor.sensor2").state == "2" + assert hass.states.get("binary_sensor.binary_sensor1").state == "on" + assert hass.states.get("binary_sensor.binary_sensor2").state == "off" + + # Now the end point flakes out again + respx.get("http://localhost").mock(side_effect=asyncio.TimeoutError()) + + # Refresh the coordinator + async_fire_time_changed(hass, utcnow() + timedelta(seconds=31)) + await hass.async_block_till_done() + + assert hass.states.get("sensor.sensor1").state == STATE_UNAVAILABLE + assert hass.states.get("sensor.sensor2").state == STATE_UNAVAILABLE + assert hass.states.get("binary_sensor.binary_sensor1").state == STATE_UNAVAILABLE + assert hass.states.get("binary_sensor.binary_sensor2").state == STATE_UNAVAILABLE + + # We request a manual refresh when the + # endpoint is working again + + respx.get("http://localhost").respond( + status_code=200, + json={ + "sensor1": "1", + "sensor2": "2", + "binary_sensor1": "on", + "binary_sensor2": "off", + }, + ) + + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["sensor.sensor1"]}, + blocking=True, + ) + assert hass.states.get("sensor.sensor1").state == "1" + assert hass.states.get("sensor.sensor2").state == "2" + assert hass.states.get("binary_sensor.binary_sensor1").state == "on" + assert hass.states.get("binary_sensor.binary_sensor2").state == "off" + + +@respx.mock +async def test_setup_minimum_resource_template(hass): + """Test setup with minimum configuration (resource_template).""" + + respx.get("http://localhost").respond( + status_code=200, + json={ + "sensor1": "1", + "sensor2": "2", + "binary_sensor1": "on", + "binary_sensor2": "off", + }, + ) + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + "resource_template": "{% set url = 'http://localhost' %}{{ url }}", + "method": "GET", + "verify_ssl": "false", + "timeout": 30, + "sensor": [ + { + "unit_of_measurement": DATA_MEGABYTES, + "name": "sensor1", + "value_template": "{{ value_json.sensor1 }}", + }, + { + "unit_of_measurement": DATA_MEGABYTES, + "name": "sensor2", + "value_template": "{{ value_json.sensor2 }}", + }, + ], + "binary_sensor": [ + { + "name": "binary_sensor1", + "value_template": "{{ value_json.binary_sensor1 }}", + }, + { + "name": "binary_sensor2", + "value_template": "{{ value_json.binary_sensor2 }}", + }, + ], + } + ] + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 4 + + assert hass.states.get("sensor.sensor1").state == "1" + assert hass.states.get("sensor.sensor2").state == "2" + assert hass.states.get("binary_sensor.binary_sensor1").state == "on" + assert hass.states.get("binary_sensor.binary_sensor2").state == "off" + + +@respx.mock +async def test_reload(hass): + """Verify we can reload.""" + + respx.get("http://localhost") % 200 + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + "resource": "http://localhost", + "method": "GET", + "verify_ssl": "false", + "timeout": 30, + "sensor": [ + { + "name": "mockrest", + }, + ], + } + ] + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + assert hass.states.get("sensor.mockrest") + + yaml_path = path.join( + _get_fixtures_base_path(), + "fixtures", + "rest/configuration_top_level.yaml", + ) + with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): + await hass.services.async_call( + "rest", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.get("sensor.mockreset") is None + assert hass.states.get("sensor.rollout") + assert hass.states.get("sensor.fallover") + + +@respx.mock +async def test_reload_and_remove_all(hass): + """Verify we can reload and remove all.""" + + respx.get("http://localhost") % 200 + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + "resource": "http://localhost", + "method": "GET", + "verify_ssl": "false", + "timeout": 30, + "sensor": [ + { + "name": "mockrest", + }, + ], + } + ] + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + assert hass.states.get("sensor.mockrest") + + yaml_path = path.join( + _get_fixtures_base_path(), + "fixtures", + "rest/configuration_empty.yaml", + ) + with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): + await hass.services.async_call( + "rest", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.get("sensor.mockreset") is None + + +@respx.mock +async def test_reload_fails_to_read_configuration(hass): + """Verify reload when configuration is missing or broken.""" + + respx.get("http://localhost") % 200 + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + "resource": "http://localhost", + "method": "GET", + "verify_ssl": "false", + "timeout": 30, + "sensor": [ + { + "name": "mockrest", + }, + ], + } + ] + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + yaml_path = path.join( + _get_fixtures_base_path(), + "fixtures", + "rest/configuration_invalid.notyaml", + ) + with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): + await hass.services.async_call( + "rest", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 1 + + +def _get_fixtures_base_path(): + return path.dirname(path.dirname(path.dirname(__file__))) diff --git a/tests/components/rest/test_notify.py b/tests/components/rest/test_notify.py index aa3e40c2dd4..fb7b8a31238 100644 --- a/tests/components/rest/test_notify.py +++ b/tests/components/rest/test_notify.py @@ -2,6 +2,8 @@ from os import path from unittest.mock import patch +import respx + from homeassistant import config as hass_config import homeassistant.components.notify as notify from homeassistant.components.rest import DOMAIN @@ -9,8 +11,10 @@ from homeassistant.const import SERVICE_RELOAD from homeassistant.setup import async_setup_component +@respx.mock async def test_reload_notify(hass): """Verify we can reload the notify service.""" + respx.get("http://localhost") % 200 assert await async_setup_component( hass, diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index 58309cd7532..2e308f69384 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -91,6 +91,38 @@ async def test_setup_minimum(hass): assert len(hass.states.async_all()) == 1 +@respx.mock +async def test_manual_update(hass): + """Test setup with minimum configuration.""" + await async_setup_component(hass, "homeassistant", {}) + respx.get("http://localhost").respond(status_code=200, json={"data": "first"}) + assert await async_setup_component( + hass, + sensor.DOMAIN, + { + "sensor": { + "name": "mysensor", + "value_template": "{{ value_json.data }}", + "platform": "rest", + "resource_template": "{% set url = 'http://localhost' %}{{ url }}", + "method": "GET", + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + assert hass.states.get("sensor.mysensor").state == "first" + + respx.get("http://localhost").respond(status_code=200, json={"data": "second"}) + await hass.services.async_call( + "homeassistant", + "update_entity", + {ATTR_ENTITY_ID: ["sensor.mysensor"]}, + blocking=True, + ) + assert hass.states.get("sensor.mysensor").state == "second" + + @respx.mock async def test_setup_minimum_resource_template(hass): """Test setup with minimum configuration (resource_template).""" diff --git a/tests/components/rest/test_switch.py b/tests/components/rest/test_switch.py index 5e0c9fbeab3..7141a34203a 100644 --- a/tests/components/rest/test_switch.py +++ b/tests/components/rest/test_switch.py @@ -3,6 +3,7 @@ import asyncio import aiohttp +from homeassistant.components.rest import DOMAIN import homeassistant.components.rest.switch as rest from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( @@ -34,14 +35,14 @@ PARAMS = None async def test_setup_missing_config(hass): """Test setup with configuration missing required entries.""" - assert not await rest.async_setup_platform(hass, {CONF_PLATFORM: rest.DOMAIN}, None) + assert not await rest.async_setup_platform(hass, {CONF_PLATFORM: DOMAIN}, None) async def test_setup_missing_schema(hass): """Test setup with resource missing schema.""" assert not await rest.async_setup_platform( hass, - {CONF_PLATFORM: rest.DOMAIN, CONF_RESOURCE: "localhost"}, + {CONF_PLATFORM: DOMAIN, CONF_RESOURCE: "localhost"}, None, ) @@ -51,7 +52,7 @@ async def test_setup_failed_connect(hass, aioclient_mock): aioclient_mock.get("http://localhost", exc=aiohttp.ClientError) assert not await rest.async_setup_platform( hass, - {CONF_PLATFORM: rest.DOMAIN, CONF_RESOURCE: "http://localhost"}, + {CONF_PLATFORM: DOMAIN, CONF_RESOURCE: "http://localhost"}, None, ) @@ -61,7 +62,7 @@ async def test_setup_timeout(hass, aioclient_mock): aioclient_mock.get("http://localhost", exc=asyncio.TimeoutError()) assert not await rest.async_setup_platform( hass, - {CONF_PLATFORM: rest.DOMAIN, CONF_RESOURCE: "http://localhost"}, + {CONF_PLATFORM: DOMAIN, CONF_RESOURCE: "http://localhost"}, None, ) @@ -75,11 +76,12 @@ async def test_setup_minimum(hass, aioclient_mock): SWITCH_DOMAIN, { SWITCH_DOMAIN: { - CONF_PLATFORM: rest.DOMAIN, + CONF_PLATFORM: DOMAIN, CONF_RESOURCE: "http://localhost", } }, ) + await hass.async_block_till_done() assert aioclient_mock.call_count == 1 @@ -92,12 +94,14 @@ async def test_setup_query_params(hass, aioclient_mock): SWITCH_DOMAIN, { SWITCH_DOMAIN: { - CONF_PLATFORM: rest.DOMAIN, + CONF_PLATFORM: DOMAIN, CONF_RESOURCE: "http://localhost", CONF_PARAMS: {"search": "something"}, } }, ) + await hass.async_block_till_done() + print(aioclient_mock) assert aioclient_mock.call_count == 1 @@ -110,7 +114,7 @@ async def test_setup(hass, aioclient_mock): SWITCH_DOMAIN, { SWITCH_DOMAIN: { - CONF_PLATFORM: rest.DOMAIN, + CONF_PLATFORM: DOMAIN, CONF_NAME: "foo", CONF_RESOURCE: "http://localhost", CONF_HEADERS: {"Content-type": CONTENT_TYPE_JSON}, @@ -119,6 +123,7 @@ async def test_setup(hass, aioclient_mock): } }, ) + await hass.async_block_till_done() assert aioclient_mock.call_count == 1 assert_setup_component(1, SWITCH_DOMAIN) @@ -132,7 +137,7 @@ async def test_setup_with_state_resource(hass, aioclient_mock): SWITCH_DOMAIN, { SWITCH_DOMAIN: { - CONF_PLATFORM: rest.DOMAIN, + CONF_PLATFORM: DOMAIN, CONF_NAME: "foo", CONF_RESOURCE: "http://localhost", rest.CONF_STATE_RESOURCE: "http://localhost/state", @@ -142,6 +147,7 @@ async def test_setup_with_state_resource(hass, aioclient_mock): } }, ) + await hass.async_block_till_done() assert aioclient_mock.call_count == 1 assert_setup_component(1, SWITCH_DOMAIN) diff --git a/tests/fixtures/rest/configuration_empty.yaml b/tests/fixtures/rest/configuration_empty.yaml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/fixtures/rest/configuration_invalid.notyaml b/tests/fixtures/rest/configuration_invalid.notyaml new file mode 100644 index 00000000000..548d8bcf5a0 --- /dev/null +++ b/tests/fixtures/rest/configuration_invalid.notyaml @@ -0,0 +1,2 @@ +*!* NOT YAML + diff --git a/tests/fixtures/rest/configuration_top_level.yaml b/tests/fixtures/rest/configuration_top_level.yaml new file mode 100644 index 00000000000..df27e160117 --- /dev/null +++ b/tests/fixtures/rest/configuration_top_level.yaml @@ -0,0 +1,12 @@ +rest: + - method: GET + resource: "http://localhost" + sensor: + name: fallover + +sensor: + - platform: rest + resource: "http://localhost" + method: GET + name: rollout +