Add support for using a single endpoint for rest data (#46711)

This commit is contained in:
J. Nick Koston 2021-02-19 19:44:15 -10:00 committed by GitHub
parent 71586b7661
commit 2f3c2f5f4d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 858 additions and 273 deletions

View file

@ -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
)

View file

@ -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()

View file

@ -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"]

View file

@ -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."""

View file

@ -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)

View file

@ -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,
)

View file

@ -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

View file

@ -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)

View file

@ -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__)))

View file

@ -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,

View file

@ -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)."""

View file

@ -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)

View file

View file

@ -0,0 +1,2 @@
*!* NOT YAML

View file

@ -0,0 +1,12 @@
rest:
- method: GET
resource: "http://localhost"
sensor:
name: fallover
sensor:
- platform: rest
resource: "http://localhost"
method: GET
name: rollout