From 2cc38b426aa1af41b8c15a2ab156a07ef5b5aeac Mon Sep 17 00:00:00 2001 From: Oleg Kurapov Date: Thu, 30 May 2024 16:29:50 +0200 Subject: [PATCH] Add XML support to RESTful binary sensor (#110062) * Add XML support to RESTful binary sensor * Add test for binary sensor with XML input data * Address mypy validation results by handling None returns * Use proper incorrect XML instead of blank * Change failure condition to match the behavior of the library method * Change error handling for bad XML to expect ExpatError * Parametrize bad XML test to catch both empty and invalid XML * Move exception handling out of the shared method --------- Co-authored-by: Erik Montnemery --- .../components/rest/binary_sensor.py | 18 +++-- homeassistant/components/rest/data.py | 11 +-- homeassistant/components/rest/sensor.py | 9 ++- tests/components/rest/test_binary_sensor.py | 71 +++++++++++++++++++ tests/components/rest/test_sensor.py | 16 ++++- 5 files changed, 107 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/rest/binary_sensor.py b/homeassistant/components/rest/binary_sensor.py index 0568203a91c..5aafd727178 100644 --- a/homeassistant/components/rest/binary_sensor.py +++ b/homeassistant/components/rest/binary_sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging import ssl +from xml.parsers.expat import ExpatError import voluptuous as vol @@ -149,24 +150,31 @@ class RestBinarySensor(ManualTriggerEntity, RestEntity, BinarySensorEntity): self._attr_is_on = False return - response = self.rest.data + try: + response = self.rest.data_without_xml() + except ExpatError as err: + self._attr_is_on = False + _LOGGER.warning( + "REST xml result could not be parsed and converted to JSON: %s", err + ) + return raw_value = response - if self._value_template is not None: + if response is not None and self._value_template is not None: response = self._value_template.async_render_with_possible_json_value( - self.rest.data, False + response, False ) try: - self._attr_is_on = bool(int(response)) + self._attr_is_on = bool(int(str(response))) except ValueError: self._attr_is_on = { "true": True, "on": True, "open": True, "yes": True, - }.get(response.lower(), False) + }.get(str(response).lower(), False) self._process_manual_data(raw_value) self.async_write_ha_state() diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index 4c9667e7651..e198202ae57 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -4,7 +4,6 @@ from __future__ import annotations import logging import ssl -from xml.parsers.expat import ExpatError import httpx import xmltodict @@ -79,14 +78,8 @@ class RestData: and (content_type := headers.get("content-type")) and content_type.startswith(XML_MIME_TYPES) ): - try: - value = json_dumps(xmltodict.parse(value)) - except ExpatError: - _LOGGER.warning( - "REST xml result could not be parsed and converted to JSON" - ) - else: - _LOGGER.debug("JSON converted from XML: %s", value) + value = json_dumps(xmltodict.parse(value)) + _LOGGER.debug("JSON converted from XML: %s", value) return value async def async_update(self, log_errors: bool = True) -> None: diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index 199ab3721c3..810d286d147 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -5,6 +5,7 @@ from __future__ import annotations import logging import ssl from typing import Any +from xml.parsers.expat import ExpatError import voluptuous as vol @@ -159,7 +160,13 @@ class RestSensor(ManualTriggerSensorEntity, RestEntity): def _update_from_rest_data(self) -> None: """Update state from the rest data.""" - value = self.rest.data_without_xml() + try: + value = self.rest.data_without_xml() + except ExpatError as err: + _LOGGER.warning( + "REST xml result could not be parsed and converted to JSON: %s", err + ) + value = self.rest.data if self._json_attrs: self._attr_extra_state_attributes = parse_json_attributes( diff --git a/tests/components/rest/test_binary_sensor.py b/tests/components/rest/test_binary_sensor.py index 39e6a7aea0d..65ec6bf5c05 100644 --- a/tests/components/rest/test_binary_sensor.py +++ b/tests/components/rest/test_binary_sensor.py @@ -362,6 +362,77 @@ async def test_setup_get_on(hass: HomeAssistant) -> None: assert state.state == STATE_ON +@respx.mock +async def test_setup_get_xml(hass: HomeAssistant) -> None: + """Test setup with valid xml configuration.""" + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, + headers={"content-type": "text/xml"}, + content="1", + ) + assert await async_setup_component( + hass, + BINARY_SENSOR_DOMAIN, + { + BINARY_SENSOR_DOMAIN: { + "platform": DOMAIN, + "resource": "http://localhost", + "method": "GET", + "value_template": "{{ value_json.dog }}", + "name": "foo", + "verify_ssl": "true", + "timeout": 30, + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all(BINARY_SENSOR_DOMAIN)) == 1 + + state = hass.states.get("binary_sensor.foo") + assert state.state == STATE_ON + + +@respx.mock +@pytest.mark.parametrize( + ("content"), + [ + (""), + (""), + ], +) +async def test_setup_get_bad_xml( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, content: str +) -> None: + """Test attributes get extracted from a XML result with bad xml.""" + + respx.get("http://localhost").respond( + status_code=HTTPStatus.OK, + headers={"content-type": "text/xml"}, + content=content, + ) + assert await async_setup_component( + hass, + BINARY_SENSOR_DOMAIN, + { + BINARY_SENSOR_DOMAIN: { + "platform": DOMAIN, + "resource": "http://localhost", + "method": "GET", + "value_template": "{{ value_json.toplevel.master_value }}", + "name": "foo", + "verify_ssl": "true", + "timeout": 30, + } + }, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all(BINARY_SENSOR_DOMAIN)) == 1 + state = hass.states.get("binary_sensor.foo") + + assert state.state == STATE_OFF + assert "REST xml result could not be parsed" in caplog.text + + @respx.mock async def test_setup_with_exception(hass: HomeAssistant) -> None: """Test setup with exception.""" diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index 9af1ac9273e..2e02063b215 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -868,15 +868,25 @@ async def test_update_with_application_xml_convert_json_attrs_with_jsonattr_temp @respx.mock +@pytest.mark.parametrize( + ("content", "error_message"), + [ + ("", "Empty reply"), + ("", "Erroneous JSON"), + ], +) async def test_update_with_xml_convert_bad_xml( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + content: str, + error_message: str, ) -> None: """Test attributes get extracted from a XML result with bad xml.""" respx.get("http://localhost").respond( status_code=HTTPStatus.OK, headers={"content-type": "text/xml"}, - content="", + content=content, ) assert await async_setup_component( hass, @@ -901,7 +911,7 @@ async def test_update_with_xml_convert_bad_xml( assert state.state == STATE_UNKNOWN assert "REST xml result could not be parsed" in caplog.text - assert "Empty reply" in caplog.text + assert error_message in caplog.text @respx.mock