From e51ab1afeae93d17055749a58ccbdac5638c6f49 Mon Sep 17 00:00:00 2001 From: Adam Mitchell Date: Fri, 16 Oct 2026 19:58:15 +0000 Subject: [PATCH 1/3] Treat lone single-byte responses as valid in some cases --- python_thingset/_protocol.py | 10 ++++++++ tests/async_tcp/test_async_tcp.py | 33 +++++++++++++++++++++++++ tests/protocol/test_try_consume.py | 39 +++++++++++++++++++++++++++++- 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/python_thingset/_protocol.py b/python_thingset/_protocol.py index fc71bc8..7fe949f 100644 --- a/python_thingset/_protocol.py +++ b/python_thingset/_protocol.py @@ -163,6 +163,16 @@ def try_consume(self, buffer: bytes) -> Tuple[Union[ParsedResponse, None], int]: if self.wire_format is not WireFormat.BINARY: raise ValueError("try_consume is binary only") if len(buffer) < 2: + # A lone ERROR status byte is a complete response: ThingSet++ + # firmware (HMCU/ACMU/DMU) answers NOT_FOUND, GATEWAY_TIMEOUT, + # NOT_GATEWAY, ... with just the status byte -- no 0xf6 marker + # and never a payload -- so waiting for a second byte turns every + # device-side error into a client-side timeout. A lone SUCCESS + # byte is always the head of a longer reply (success responses + # carry the 0xf6 marker and/or a CBOR document), so keep waiting + # for those. + if len(buffer) == 1 and buffer[0] >= ThingSetStatus.BAD_REQUEST: + return self._parse_binary(buffer[:1]), 1 return None, 0 offset = 1 # status byte diff --git a/tests/async_tcp/test_async_tcp.py b/tests/async_tcp/test_async_tcp.py index b4f78c0..991c619 100644 --- a/tests/async_tcp/test_async_tcp.py +++ b/tests/async_tcp/test_async_tcp.py @@ -377,3 +377,36 @@ async def test_connect_timeout_not_hit_on_fast_connect(): ) as client: r = await client.get(0x00) assert r.data == 42 + + +async def test_bare_error_status_reply_is_parsed_without_timeout(): + """ThingSet++ answers an error with ONE byte (no 0xf6, no payload). That + must come back as that status immediately, not as a timeout: an HMCU that + cannot reach a gateway-forwarded child replies 0xC4 this way, and callers + use the status to fail fast.""" + request = _protocol.encode_get(0xF03) + async with _CannedServer({request: bytes([ThingSetStatus.GATEWAY_TIMEOUT])}) as server: + async with AsyncThingSetTCP("127.0.0.1", port=server.port, timeout=2.0) as client: + started = asyncio.get_running_loop().time() + r = await client.get(0xF03) + elapsed = asyncio.get_running_loop().time() - started + assert r.status_code == ThingSetStatus.GATEWAY_TIMEOUT + assert r.data is None + assert r.values == [] + assert elapsed < 1.0 # well inside the 2 s timeout: parsed, not timed out + + +async def test_bare_error_status_reply_via_gateway_forward(): + """Same, through the forward envelope: the gateway strips the envelope + and relays the child's reply (or its own 0xC4) unwrapped.""" + eui = 0xAD76807C58EC545D + request = _protocol.encode_get(0xF03) + async with _CannedServer( + {request: bytes([ThingSetStatus.NOT_FOUND])}, expect_forward_eui=eui + ) as server: + async with AsyncThingSetTCP( + "127.0.0.1", port=server.port, timeout=2.0, target_eui=eui + ) as client: + r = await client.get(0xF03) + assert r.status_code == ThingSetStatus.NOT_FOUND + assert r.data is None diff --git a/tests/protocol/test_try_consume.py b/tests/protocol/test_try_consume.py index 01324ae..fdf8120 100644 --- a/tests/protocol/test_try_consume.py +++ b/tests/protocol/test_try_consume.py @@ -13,12 +13,49 @@ def test_empty_buffer_returns_none(): assert consumed == 0 -def test_one_byte_needs_more_data(): +def test_one_success_byte_needs_more_data(): + """A success status always carries 0xf6 and/or a CBOR document.""" resp, consumed = _mk().try_consume(b"\x85") assert resp is None assert consumed == 0 +def test_lone_error_status_is_complete(): + """ThingSet++ answers errors with the bare status byte: nothing follows, + so a lone error byte must parse now rather than wait (and time out).""" + for status in ( + ThingSetStatus.BAD_REQUEST, + ThingSetStatus.NOT_FOUND, + ThingSetStatus.GATEWAY_TIMEOUT, + ThingSetStatus.NOT_GATEWAY, + ): + resp, consumed = _mk().try_consume(bytes([status])) + assert resp is not None + assert consumed == 1 + assert resp.status_code == status + assert resp.data is None + + +def test_error_status_with_marker_still_consumes_marker(): + """thingset-node-c sends errors as status + 0xf6 (+ optional message): + when both bytes are already in the buffer the marker is consumed too.""" + resp, consumed = _mk().try_consume(b"\xa4\xf6") + assert resp is not None + assert consumed == 2 + assert resp.status_code == ThingSetStatus.NOT_FOUND + assert resp.data is None + + +def test_error_status_with_message_payload(): + payload = cbor2.dumps("no such node", canonical=True) + buf = b"\xa4\xf6" + payload + resp, consumed = _mk().try_consume(buf) + assert resp is not None + assert consumed == len(buf) + assert resp.status_code == ThingSetStatus.NOT_FOUND + assert resp.data == "no such node" + + def test_status_plus_null_marker_only(): """status + 0xf6 with no further bytes is a complete status-only response.""" buf = b"\x85\xf6" From f77dcb860f43e52558a92765f4a627388b3e015a Mon Sep 17 00:00:00 2001 From: Adam Mitchell Date: Fri, 16 Oct 2026 20:12:49 +0000 Subject: [PATCH 2/3] Bump to 0.5.3 --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 25d63bb..4c4b0d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "python_thingset" -version = "0.5.2" +version = "0.5.3" description = "A Python library for ThingSet functionality" authors = [ { name = "Adam Mitchell", email = "adam.mitchell@brillpower.com" } diff --git a/uv.lock b/uv.lock index af8c26d..da83ed7 100644 --- a/uv.lock +++ b/uv.lock @@ -311,7 +311,7 @@ wheels = [ [[package]] name = "python-thingset" -version = "0.5.2" +version = "0.5.3" source = { editable = "." } dependencies = [ { name = "can-isotp" }, From a0808311d23ff3578a01a1127c8c09cad01b7f51 Mon Sep 17 00:00:00 2001 From: Adam Mitchell Date: Fri, 16 Oct 2026 20:28:45 +0000 Subject: [PATCH 3/3] Genericise --- python_thingset/_protocol.py | 9 +-------- tests/async_tcp/test_async_tcp.py | 6 +++--- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/python_thingset/_protocol.py b/python_thingset/_protocol.py index 7fe949f..17432c1 100644 --- a/python_thingset/_protocol.py +++ b/python_thingset/_protocol.py @@ -163,14 +163,7 @@ def try_consume(self, buffer: bytes) -> Tuple[Union[ParsedResponse, None], int]: if self.wire_format is not WireFormat.BINARY: raise ValueError("try_consume is binary only") if len(buffer) < 2: - # A lone ERROR status byte is a complete response: ThingSet++ - # firmware (HMCU/ACMU/DMU) answers NOT_FOUND, GATEWAY_TIMEOUT, - # NOT_GATEWAY, ... with just the status byte -- no 0xf6 marker - # and never a payload -- so waiting for a second byte turns every - # device-side error into a client-side timeout. A lone SUCCESS - # byte is always the head of a longer reply (success responses - # carry the 0xf6 marker and/or a CBOR document), so keep waiting - # for those. + # A lone error status byte can be a complete repsonse if len(buffer) == 1 and buffer[0] >= ThingSetStatus.BAD_REQUEST: return self._parse_binary(buffer[:1]), 1 return None, 0 diff --git a/tests/async_tcp/test_async_tcp.py b/tests/async_tcp/test_async_tcp.py index 991c619..4b01a6f 100644 --- a/tests/async_tcp/test_async_tcp.py +++ b/tests/async_tcp/test_async_tcp.py @@ -381,9 +381,9 @@ async def test_connect_timeout_not_hit_on_fast_connect(): async def test_bare_error_status_reply_is_parsed_without_timeout(): """ThingSet++ answers an error with ONE byte (no 0xf6, no payload). That - must come back as that status immediately, not as a timeout: an HMCU that - cannot reach a gateway-forwarded child replies 0xC4 this way, and callers - use the status to fail fast.""" + must come back as that status immediately, not as a timeout: a gateway that + cannot reach a forwarded child replies 0xC4 this way, and callers use the + status to fail fast.""" request = _protocol.encode_get(0xF03) async with _CannedServer({request: bytes([ThingSetStatus.GATEWAY_TIMEOUT])}) as server: async with AsyncThingSetTCP("127.0.0.1", port=server.port, timeout=2.0) as client: