Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
3 changes: 3 additions & 0 deletions python_thingset/_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ 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 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

offset = 1 # status byte
Expand Down
33 changes: 33 additions & 0 deletions tests/async_tcp/test_async_tcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: 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:
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
39 changes: 38 additions & 1 deletion tests/protocol/test_try_consume.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading