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

View file

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

View file

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

View file

@ -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="<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
async def test_setup_with_exception(hass: HomeAssistant) -> None:
"""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
@pytest.mark.parametrize(
("content", "error_message"),
[
("", "Empty reply"),
("<open></close>", "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