diff --git a/homeassistant/components/rest/manifest.json b/homeassistant/components/rest/manifest.json index 8c8b7f39609..d03dce3017b 100644 --- a/homeassistant/components/rest/manifest.json +++ b/homeassistant/components/rest/manifest.json @@ -2,7 +2,7 @@ "domain": "rest", "name": "RESTful", "documentation": "https://www.home-assistant.io/integrations/rest", - "requirements": [], + "requirements": ["xmltodict==0.12.0"], "dependencies": [], "codeowners": [] } diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index 51120cb350c..bdff22d5a3b 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -1,10 +1,13 @@ """Support for RESTful API sensors.""" +import ast import json import logging +from xml.parsers.expat import ExpatError import requests from requests.auth import HTTPBasicAuth, HTTPDigestAuth import voluptuous as vol +import xmltodict from homeassistant.components.sensor import DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA from homeassistant.const import ( @@ -38,7 +41,11 @@ DEFAULT_VERIFY_SSL = True DEFAULT_FORCE_UPDATE = False DEFAULT_TIMEOUT = 10 +CONF_CONVERT_XML = "convert_xml" +DEFAULT_CONVERT_XML = False + CONF_JSON_ATTRS = "json_attributes" +CONF_JSON_ATTRS_TEMPLATE = "json_attributes_template" METHODS = ["POST", "GET"] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -50,6 +57,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ), vol.Optional(CONF_HEADERS): vol.Schema({cv.string: cv.string}), vol.Optional(CONF_JSON_ATTRS, default=[]): cv.ensure_list_csv, + vol.Optional(CONF_CONVERT_XML, default=DEFAULT_CONVERT_XML): cv.boolean, 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, @@ -57,6 +65,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 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_TEMPLATE): cv.template, 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, @@ -84,9 +93,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): device_class = config.get(CONF_DEVICE_CLASS) value_template = config.get(CONF_VALUE_TEMPLATE) json_attrs = config.get(CONF_JSON_ATTRS) + convert_xml = config.get(CONF_CONVERT_XML) + json_attrs_template = config.get(CONF_JSON_ATTRS_TEMPLATE) force_update = config.get(CONF_FORCE_UPDATE) timeout = config.get(CONF_TIMEOUT) + if json_attrs_template is not None: + json_attrs_template.hass = hass + if value_template is not None: value_template.hass = hass @@ -120,6 +134,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): json_attrs, force_update, resource_template, + convert_xml, + json_attrs_template, ) ], True, @@ -140,6 +156,8 @@ class RestSensor(Entity): json_attrs, force_update, resource_template, + convert_xml, + json_attrs_template, ): """Initialize the REST sensor.""" self._hass = hass @@ -153,6 +171,8 @@ class RestSensor(Entity): self._attributes = None self._force_update = force_update self._resource_template = resource_template + self._convert_xml = convert_xml + self._json_attrs_template = json_attrs_template @property def name(self): @@ -191,12 +211,30 @@ class RestSensor(Entity): self.rest.update() value = self.rest.data + json_dict = None + + if self._convert_xml: + try: + value = json.dumps(xmltodict.parse(value)) + except ExpatError: + _LOGGER.warning( + "REST xml result could not be parsed and converted to JSON." + ) + _LOGGER.debug("Erroneous XML: %s", value) if self._json_attrs: self._attributes = {} if value: try: json_dict = json.loads(value) + if self._json_attrs_template is not None: + # render_with_possible_json_value returns single quoted + # strings so we cannot use json.loads to read it back here + json_dict = ast.literal_eval( + self._json_attrs_template.render_with_possible_json_value( + value, None + ) + ) if isinstance(json_dict, list): json_dict = json_dict[0] if isinstance(json_dict, dict): diff --git a/requirements_all.txt b/requirements_all.txt index ac811f0889f..f30de81f153 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2094,6 +2094,7 @@ xfinity-gateway==0.0.4 xknx==0.11.2 # homeassistant.components.bluesound +# homeassistant.components.rest # homeassistant.components.startca # homeassistant.components.ted5000 # homeassistant.components.yr diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 130f9c4530d..5c6282cf4dc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -711,6 +711,7 @@ withings-api==2.1.3 wled==0.2.1 # homeassistant.components.bluesound +# homeassistant.components.rest # homeassistant.components.startca # homeassistant.components.ted5000 # homeassistant.components.yr diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index 7e03eb0fd41..d2ce631b4db 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -166,6 +166,34 @@ class TestRestSensorSetup(unittest.TestCase): ) assert 2 == mock_req.call_count + @requests_mock.Mocker() + def test_setup_get_xml(self, mock_req): + """Test setup with valid configuration.""" + mock_req.get("http://localhost", status_code=200) + with assert_setup_component(1, "sensor"): + assert setup_component( + self.hass, + "sensor", + { + "sensor": { + "platform": "rest", + "resource": "http://localhost", + "method": "GET", + "value_template": "{{ value_json.key }}", + "name": "foo", + "unit_of_measurement": DATA_MEGABYTES, + "verify_ssl": "true", + "timeout": 30, + "convert_xml": True, + "authentication": "basic", + "username": "my username", + "password": "my password", + "headers": {"Accept": "text/xml"}, + } + }, + ) + assert 2 == mock_req.call_count + class TestRestSensor(unittest.TestCase): """Tests for REST sensor platform.""" @@ -185,9 +213,12 @@ class TestRestSensor(unittest.TestCase): self.unit_of_measurement = DATA_MEGABYTES self.device_class = None self.value_template = template("{{ value_json.key }}") + self.json_attrs_template = template("{{ value_json }}") self.value_template.hass = self.hass + self.json_attrs_template.hass = self.hass self.force_update = False self.resource_template = None + self.convert_xml = False self.sensor = rest.RestSensor( self.hass, @@ -199,6 +230,8 @@ class TestRestSensor(unittest.TestCase): [], self.force_update, self.resource_template, + self.convert_xml, + self.json_attrs_template, ) def tearDown(self): @@ -260,6 +293,8 @@ class TestRestSensor(unittest.TestCase): [], self.force_update, self.resource_template, + self.convert_xml, + self.json_attrs_template, ) self.sensor.update() assert "plain_state" == self.sensor.state @@ -281,6 +316,8 @@ class TestRestSensor(unittest.TestCase): ["key"], self.force_update, self.resource_template, + self.convert_xml, + self.json_attrs_template, ) self.sensor.update() assert "some_json_value" == self.sensor.device_state_attributes["key"] @@ -301,6 +338,8 @@ class TestRestSensor(unittest.TestCase): ["key"], self.force_update, self.resource_template, + self.convert_xml, + self.json_attrs_template, ) self.sensor.update() assert "another_value" == self.sensor.device_state_attributes["key"] @@ -321,6 +360,8 @@ class TestRestSensor(unittest.TestCase): ["key"], self.force_update, self.resource_template, + self.convert_xml, + self.json_attrs_template, ) self.sensor.update() assert {} == self.sensor.device_state_attributes @@ -343,6 +384,8 @@ class TestRestSensor(unittest.TestCase): ["key"], self.force_update, self.resource_template, + self.convert_xml, + self.json_attrs_template, ) self.sensor.update() assert {} == self.sensor.device_state_attributes @@ -365,6 +408,8 @@ class TestRestSensor(unittest.TestCase): ["key"], self.force_update, self.resource_template, + self.convert_xml, + self.json_attrs_template, ) self.sensor.update() assert {} == self.sensor.device_state_attributes @@ -389,6 +434,8 @@ class TestRestSensor(unittest.TestCase): ["key"], self.force_update, self.resource_template, + self.convert_xml, + self.json_attrs_template, ) self.sensor.update() @@ -397,6 +444,141 @@ class TestRestSensor(unittest.TestCase): "json_state_updated_value" == self.sensor.device_state_attributes["key"] ), self.force_update + def test_update_with_json_attrs_with_json_attrs_template(self): + """Test attributes get extracted from a JSON result with a template for the attributes.""" + json_attrs_template = template("{{ value_json.toplevel.second_level }}") + json_attrs_template.hass = self.hass + value_template = template("{{ value_json.toplevel.master_value }}") + value_template.hass = self.hass + + self.rest.update = Mock( + "rest.RestData.update", + side_effect=self.update_side_effect( + '{ "toplevel": {"master_value": "master", "second_level": {"some_json_key": "some_json_value", "some_json_key2": "some_json_value2" } } }' + ), + ) + self.sensor = rest.RestSensor( + self.hass, + self.rest, + self.name, + self.unit_of_measurement, + self.device_class, + value_template, + ["some_json_key", "some_json_key2"], + self.force_update, + self.resource_template, + self.convert_xml, + json_attrs_template, + ) + + self.sensor.update() + assert "some_json_value" == self.sensor.device_state_attributes["some_json_key"] + assert ( + "some_json_value2" == self.sensor.device_state_attributes["some_json_key2"] + ) + assert "master" == self.sensor.state + + def test_update_with_xml_convert_json_attrs_with_json_attrs_template(self): + """Test attributes get extracted from a JSON result that was converted from XML with a template for the attributes.""" + json_attrs_template = template("{{ value_json.toplevel.second_level }}") + json_attrs_template.hass = self.hass + value_template = template("{{ value_json.toplevel.master_value }}") + value_template.hass = self.hass + convert_xml = True + + self.rest.update = Mock( + "rest.RestData.update", + side_effect=self.update_side_effect( + "mastersome_json_valuesome_json_value2" + ), + ) + self.sensor = rest.RestSensor( + self.hass, + self.rest, + self.name, + self.unit_of_measurement, + self.device_class, + value_template, + ["some_json_key", "some_json_key2"], + self.force_update, + self.resource_template, + convert_xml, + json_attrs_template, + ) + + self.sensor.update() + assert "some_json_value" == self.sensor.device_state_attributes["some_json_key"] + assert ( + "some_json_value2" == self.sensor.device_state_attributes["some_json_key2"] + ) + assert "master" == self.sensor.state + + def test_update_with_xml_convert_json_attrs_with_jsonattr_template(self): + """Test attributes get extracted from a JSON result that was converted from XML.""" + json_attrs_template = template("{{ value_json.response }}") + json_attrs_template.hass = self.hass + value_template = template("{{ value_json.response.bss.wlan }}") + value_template.hass = self.hass + convert_xml = True + + self.rest.update = Mock( + "rest.RestData.update", + side_effect=self.update_side_effect( + '01255648alexander000bogus000000000upupupup000x0XF0x0XF 0' + ), + ) + self.sensor = rest.RestSensor( + self.hass, + self.rest, + self.name, + self.unit_of_measurement, + self.device_class, + value_template, + ["led0", "led1", "temp0", "time0", "ver"], + self.force_update, + self.resource_template, + convert_xml, + json_attrs_template, + ) + + self.sensor.update() + assert "0" == self.sensor.device_state_attributes["led0"] + assert "0" == self.sensor.device_state_attributes["led1"] + assert "0x0XF0x0XF" == self.sensor.device_state_attributes["temp0"] + assert "0" == self.sensor.device_state_attributes["time0"] + assert "12556" == self.sensor.device_state_attributes["ver"] + assert "bogus" == self.sensor.state + + @patch("homeassistant.components.rest.sensor._LOGGER") + def test_update_with_xml_convert_bad_xml(self, mock_logger): + """Test attributes get extracted from a XML result with bad xml.""" + value_template = template("{{ value_json.toplevel.master_value }}") + value_template.hass = self.hass + convert_xml = True + + self.rest.update = Mock( + "rest.RestData.update", + side_effect=self.update_side_effect("this is not xml"), + ) + self.sensor = rest.RestSensor( + self.hass, + self.rest, + self.name, + self.unit_of_measurement, + self.device_class, + value_template, + ["key"], + self.force_update, + self.resource_template, + convert_xml, + self.json_attrs_template, + ) + + self.sensor.update() + assert {} == self.sensor.device_state_attributes + assert mock_logger.warning.called + assert mock_logger.debug.called + class TestRestData(unittest.TestCase): """Tests for RestData."""