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 <erik@montnemery.com>
This commit is contained in:
Oleg Kurapov 2024-05-30 16:29:50 +02:00 committed by GitHub
parent 4b95ea864f
commit 2cc38b426a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 107 additions and 18 deletions

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import logging import logging
import ssl import ssl
from xml.parsers.expat import ExpatError
import voluptuous as vol import voluptuous as vol
@ -149,24 +150,31 @@ class RestBinarySensor(ManualTriggerEntity, RestEntity, BinarySensorEntity):
self._attr_is_on = False self._attr_is_on = False
return 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 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( response = self._value_template.async_render_with_possible_json_value(
self.rest.data, False response, False
) )
try: try:
self._attr_is_on = bool(int(response)) self._attr_is_on = bool(int(str(response)))
except ValueError: except ValueError:
self._attr_is_on = { self._attr_is_on = {
"true": True, "true": True,
"on": True, "on": True,
"open": True, "open": True,
"yes": True, "yes": True,
}.get(response.lower(), False) }.get(str(response).lower(), False)
self._process_manual_data(raw_value) self._process_manual_data(raw_value)
self.async_write_ha_state() self.async_write_ha_state()

View file

@ -4,7 +4,6 @@ from __future__ import annotations
import logging import logging
import ssl import ssl
from xml.parsers.expat import ExpatError
import httpx import httpx
import xmltodict import xmltodict
@ -79,14 +78,8 @@ class RestData:
and (content_type := headers.get("content-type")) and (content_type := headers.get("content-type"))
and content_type.startswith(XML_MIME_TYPES) and content_type.startswith(XML_MIME_TYPES)
): ):
try: value = json_dumps(xmltodict.parse(value))
value = json_dumps(xmltodict.parse(value)) _LOGGER.debug("JSON converted from XML: %s", 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)
return value return value
async def async_update(self, log_errors: bool = True) -> None: async def async_update(self, log_errors: bool = True) -> None:

View file

@ -5,6 +5,7 @@ from __future__ import annotations
import logging import logging
import ssl import ssl
from typing import Any from typing import Any
from xml.parsers.expat import ExpatError
import voluptuous as vol import voluptuous as vol
@ -159,7 +160,13 @@ class RestSensor(ManualTriggerSensorEntity, RestEntity):
def _update_from_rest_data(self) -> None: def _update_from_rest_data(self) -> None:
"""Update state from the rest data.""" """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: if self._json_attrs:
self._attr_extra_state_attributes = parse_json_attributes( self._attr_extra_state_attributes = parse_json_attributes(

View file

@ -362,6 +362,77 @@ async def test_setup_get_on(hass: HomeAssistant) -> None:
assert state.state == STATE_ON 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="<dog>1</dog>",
)
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"),
[
(""),
("<open></close>"),
],
)
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 @respx.mock
async def test_setup_with_exception(hass: HomeAssistant) -> None: async def test_setup_with_exception(hass: HomeAssistant) -> None:
"""Test setup with exception.""" """Test setup with exception."""

View file

@ -868,15 +868,25 @@ async def test_update_with_application_xml_convert_json_attrs_with_jsonattr_temp
@respx.mock @respx.mock
@pytest.mark.parametrize(
("content", "error_message"),
[
("", "Empty reply"),
("<open></close>", "Erroneous JSON"),
],
)
async def test_update_with_xml_convert_bad_xml( 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: ) -> None:
"""Test attributes get extracted from a XML result with bad xml.""" """Test attributes get extracted from a XML result with bad xml."""
respx.get("http://localhost").respond( respx.get("http://localhost").respond(
status_code=HTTPStatus.OK, status_code=HTTPStatus.OK,
headers={"content-type": "text/xml"}, headers={"content-type": "text/xml"},
content="", content=content,
) )
assert await async_setup_component( assert await async_setup_component(
hass, hass,
@ -901,7 +911,7 @@ async def test_update_with_xml_convert_bad_xml(
assert state.state == STATE_UNKNOWN assert state.state == STATE_UNKNOWN
assert "REST xml result could not be parsed" in caplog.text 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 @respx.mock