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:
parent
4b95ea864f
commit
2cc38b426a
5 changed files with 107 additions and 18 deletions
|
@ -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()
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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."""
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue