Skip to content
Open
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
9 changes: 7 additions & 2 deletions pyomnilogic_local/api/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,9 @@ async def _reassemble_multipart(self, lead_msg: OmniLogicMessage) -> tuple[bytes
received_block_count += 1
_LOGGER.debug("received block %d/%d", received_block_count, lead.msg_block_count)

return payload_data, compressed
# MsgSize identifies the response boundary. Do not strip null bytes from
# the payload: they may be valid bytes in a compressed stream's trailer.
return payload_data[: lead.msg_size], compressed

def _parse_lead_message(self, msg: OmniLogicMessage) -> LeadMessage:
"""Parse the XML payload of an MSP_LEADMESSAGE into a LeadMessage model.
Expand All @@ -284,7 +286,10 @@ def _decode_payload(self, data: bytes, compressed: bool) -> str:
Decoded UTF-8 string with leading/trailing null bytes stripped.
"""
if compressed:
data = zlib.decompress(data.rstrip(b"\x00"))
# zlib ignores bytes after the end of a complete stream, so keeping
# the wire payload intact preserves a valid trailer that may end in
# a null byte while still tolerating protocol padding.
data = zlib.decompress(data)
return data.decode("utf-8").strip("\x00")

# -------------------------------------------------------------------------
Expand Down
40 changes: 40 additions & 0 deletions tests/test_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,46 @@ async def test_receive_file_decompresses_data() -> None:
assert result == original.decode("utf-8")


@pytest.mark.asyncio
async def test_receive_file_decompresses_data_with_protocol_padding() -> None:
"""Test that valid compressed trailers are not removed with protocol padding."""
protocol = OmniLogicProtocol()

original = b""
compressed_data = b""
for value in range(256):
candidate = b"<STATUS/>" + b"a" * value
compressed_candidate = zlib.compress(candidate)
if compressed_candidate.endswith(b"\x00"):
original = candidate
compressed_data = compressed_candidate
break
else:
pytest.fail("Could not create a zlib stream ending in a null byte")

leadmsg_payload = (
'<?xml version="1.0" encoding="UTF-8" ?><Response xmlns="http://nextgen.hayward.com/api"><Name>LeadMessage</Name><Parameters>'
'<Parameter name="SourceOpId" dataType="int">1003</Parameter>'
f'<Parameter name="MsgSize" dataType="int">{len(compressed_data)}</Parameter>'
'<Parameter name="MsgBlockCount" dataType="int">1</Parameter><Parameter name="Type" dataType="int">0</Parameter></Parameters>'
"</Response>"
)
leadmsg = OmniLogicMessage(100, MessageType.MSP_LEADMESSAGE, payload=leadmsg_payload)
leadmsg.compressed = True

block = OmniLogicMessage(101, MessageType.MSP_BLOCKMESSAGE)
block.payload = b"\x00" * 8 + compressed_data + b"\x00" * 3

await protocol._receive_queue.put(leadmsg)
await protocol._receive_queue.put(block)

raw_data, compressed = await protocol._receive_response()

assert raw_data == compressed_data
assert compressed is True
assert protocol._decode_payload(raw_data, compressed) == original.decode()


@pytest.mark.asyncio
async def test_receive_file_decompression_error() -> None:
"""Test that _decode_payload raises OmniMessageFormatError for invalid compressed data."""
Expand Down