Skip to content

Commit 64349f1

Browse files
authored
fix: tolerate empty optional endpoints on factory-reset ISY-994 (#506)
1 parent d60907d commit 64349f1

3 files changed

Lines changed: 76 additions & 5 deletions

File tree

pyisy/connection.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,24 @@ async def request(
218218
):
219219
_LOGGER.debug("ISY not ready or closed connection.")
220220
except aiohttp.ClientResponseError as err:
221-
# Malformed framing/protocol error — retrying won't recover; bail.
221+
# Malformed framing/protocol error — retrying won't recover.
222+
# When the caller already opted into ``ok404=True`` we treat it
223+
# as another flavor of "feature not present": ISY-994 firmware
224+
# on a factory-reset / un-configured controller responds to
225+
# missing optional resources (``/CONF/STATE.VAR``,
226+
# ``/CONF/NET/RES.CFG``) with a real 404 whose framing trips
227+
# aiohttp's parser when the connection is reused — the next
228+
# request on the kept-alive socket reads the prior 404's HTML
229+
# body where an HTTP status line should be. Demote that to a
230+
# debug log and return ``""`` so the optional manager's
231+
# "no resource configured" path handles it cleanly.
232+
if ok404:
233+
_LOGGER.debug(
234+
"ISY response framing error on optional endpoint %s: %s",
235+
url,
236+
err.message,
237+
)
238+
return ""
222239
_LOGGER.error(
223240
"Client Response Error from ISY: %s %s.",
224241
err.status,
@@ -289,13 +306,25 @@ async def get_status(self) -> str | None:
289306
return await self.request(req_url)
290307

291308
async def get_variable_defs(self) -> list[str | BaseException] | None:
292-
"""Fetch the list of variables from the ISY."""
309+
"""Fetch the list of variables from the ISY.
310+
311+
``ok404=True`` because both endpoints legitimately 404 on a
312+
factory-reset / un-configured ISY-994 (``/CONF/INTEGER.VAR not
313+
found`` / ``/CONF/STATE.VAR not found``); the ``Variables``
314+
parser already handles those bodies and ``None`` as
315+
"no variables defined" (see ``EMPTY_VARIABLE_RESPONSES``).
316+
Without ``ok404`` the request path emits ERROR-level log spam
317+
for what is really an empty-config success.
318+
"""
293319
req_list = [
294320
[URL_VARIABLES, URL_DEFINITIONS, VAR_INTEGER],
295321
[URL_VARIABLES, URL_DEFINITIONS, VAR_STATE],
296322
]
297323
req_urls = [self.compile_url(req) for req in req_list]
298-
return await asyncio.gather(*[self.request(req_url) for req_url in req_urls], return_exceptions=True)
324+
return await asyncio.gather(
325+
*[self.request(req_url, ok404=True) for req_url in req_urls],
326+
return_exceptions=True,
327+
)
299328

300329
async def get_variables(self) -> str | None:
301330
"""Fetch the variable details from the ISY to update local copy."""

pyisy/variables/__init__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,11 @@ def parse_definitions(self, xmls: list[str]) -> bool:
104104
valid_definitions = False
105105
for ind in range(2):
106106
# parse definitions
107-
if xmls[ind] is None or xmls[ind] in EMPTY_VARIABLE_RESPONSES:
108-
# No variables of this type defined.
107+
if not xmls[ind] or xmls[ind] in EMPTY_VARIABLE_RESPONSES:
108+
# No variables of this type defined. ``not xmls[ind]``
109+
# catches both ``None`` (request returned nothing) and
110+
# ``""`` (the ``ok404`` / framing-desync path in
111+
# ``Connection.request``).
109112
_LOGGER.info("No Type %s variables defined", ind + 1)
110113
continue
111114
try:

tests/test_connection_extras.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,19 @@ async def test_get_variable_defs_returns_two_responses(conn: Connection) -> None
184184
assert conn.request.await_count == 2
185185

186186

187+
async def test_get_variable_defs_passes_ok404(conn: Connection) -> None:
188+
"""Both per-type requests must be issued with ``ok404=True`` so a
189+
factory-reset / un-configured ISY-994 doesn't surface its
190+
``/CONF/INTEGER.VAR not found`` 404 as ERROR-level log spam — the
191+
Variables parser already treats that body as "no variables
192+
defined"."""
193+
conn.request = AsyncMock(return_value="")
194+
await conn.get_variable_defs()
195+
assert conn.request.await_count == 2
196+
for call in conn.request.await_args_list:
197+
assert call.kwargs.get("ok404") is True
198+
199+
187200
async def test_get_variables_concatenates_and_strips_inner_boundary(
188201
conn: Connection,
189202
) -> None:
@@ -259,6 +272,32 @@ async def test_request_client_response_error_returns_none(conn: Connection) -> N
259272
assert result is None
260273

261274

275+
async def test_request_client_response_error_with_ok404_returns_empty(
276+
conn: Connection,
277+
) -> None:
278+
"""Regression: ISY-994 firmware on a factory-reset controller answers
279+
missing optional resources (``/CONF/STATE.VAR``,
280+
``/CONF/NET/RES.CFG``) with a real 404 whose framing desyncs the
281+
keep-alive connection — aiohttp's parser then raises
282+
``ClientResponseError("Expected HTTP/, RTSP/ or ICE/:")`` on the
283+
*next* request that reuses the socket. Callers that already opted
284+
into ``ok404=True`` (variable defs, network resources) should see
285+
that absorbed as ``""`` rather than an ERROR-level log + None."""
286+
url = conn.compile_url(["vars", "definitions", "1"])
287+
with aioresponses() as mocked:
288+
mocked.get(
289+
url,
290+
exception=aiohttp.ClientResponseError(
291+
request_info=None,
292+
history=(),
293+
status=400,
294+
message="Expected HTTP/, RTSP/ or ICE/:",
295+
),
296+
)
297+
result = await conn.request(url, ok404=True)
298+
assert result == ""
299+
300+
262301
async def test_request_non_rest_url_does_not_crash(conn: Connection) -> None:
263302
"""Regression for #488: ``request()`` derives its debug-log endpoint
264303
from the URL by splitting on ``"rest"``. ``get_description()`` builds

0 commit comments

Comments
 (0)