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
128 changes: 76 additions & 52 deletions pyhap/hds_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,73 +76,97 @@ class Int64(int):
_DICT_TERMINATED = 0xEF


def _encode_int(value: int) -> bytes:
def _encode_int(value: int, out: bytearray) -> None:
if isinstance(value, Int64):
return bytes([_INT64]) + struct.pack("<q", value)
if value == -1:
return bytes([_INT_MINUS_ONE])
if 0 <= value <= 38:
return bytes([_INT_0 + value])
if -128 <= value <= 127:
return bytes([_INT8]) + struct.pack("<b", value)
if -32768 <= value <= 32767:
return bytes([_INT16]) + struct.pack("<h", value)
if -(2**31) <= value <= 2**31 - 1:
return bytes([_INT32]) + struct.pack("<i", value)
return bytes([_INT64]) + struct.pack("<q", value)
out += struct.pack("<Bq", _INT64, value)
elif value == -1:
out.append(_INT_MINUS_ONE)
elif 0 <= value <= 38:
out.append(_INT_0 + value)
elif -128 <= value <= 127:
out += struct.pack("<Bb", _INT8, value)
elif -32768 <= value <= 32767:
out += struct.pack("<Bh", _INT16, value)
elif -(2**31) <= value <= 2**31 - 1:
out += struct.pack("<Bi", _INT32, value)
else:
out += struct.pack("<Bq", _INT64, value)


def _encode_length_prefixed(
value: bytes, base: int, len8: int, len16: int, len32: int, len64: int
) -> bytes:
value: bytes,
out: bytearray,
base: int,
len8: int,
len16: int,
len32: int,
len64: int,
) -> None:
length = len(value)
if length <= 32:
return bytes([base + length]) + value
if length <= 0xFF:
return bytes([len8]) + struct.pack("<B", length) + value
if length <= 0xFFFF:
return bytes([len16]) + struct.pack("<H", length) + value
if length <= 0xFFFFFFFF:
return bytes([len32]) + struct.pack("<I", length) + value
return bytes([len64]) + struct.pack("<Q", length) + value


def encode(value: Any) -> bytes:
"""Serialize a python value into the HDS payload format."""
out.append(base + length)
elif length <= 0xFF:
out.append(len8)
out.append(length)
elif length <= 0xFFFF:
out += struct.pack("<BH", len16, length)
elif length <= 0xFFFFFFFF:
out += struct.pack("<BI", len32, length)
else:
out += struct.pack("<BQ", len64, length)
out += value


def _encode_into(value: Any, out: bytearray) -> None:
"""Append the HDS serialization of a python value to ``out``."""
if value is True:
return bytes([_TRUE])
if value is False:
return bytes([_FALSE])
if value is None:
return bytes([_NULL])
if isinstance(value, int):
return _encode_int(value)
if isinstance(value, float):
return bytes([_FLOAT64]) + struct.pack("<d", value)
if isinstance(value, str):
return _encode_length_prefixed(
out.append(_TRUE)
elif value is False:
out.append(_FALSE)
elif value is None:
out.append(_NULL)
elif isinstance(value, int):
_encode_int(value, out)
elif isinstance(value, float):
out += struct.pack("<Bd", _FLOAT64, value)
elif isinstance(value, str):
_encode_length_prefixed(
value.encode("utf-8"),
out,
_UTF8_0,
_UTF8_LEN8,
_UTF8_LEN16,
_UTF8_LEN32,
_UTF8_LEN64,
)
if isinstance(value, (bytes, bytearray)):
return _encode_length_prefixed(
bytes(value), _DATA_0, _DATA_LEN8, _DATA_LEN16, _DATA_LEN32, _DATA_LEN64
elif isinstance(value, (bytes, bytearray)):
_encode_length_prefixed(
value, out, _DATA_0, _DATA_LEN8, _DATA_LEN16, _DATA_LEN32, _DATA_LEN64
)
if isinstance(value, (list, tuple)):
items = b"".join(encode(item) for item in value)
if len(value) <= 14:
return bytes([_ARRAY_0 + len(value)]) + items
return bytes([_ARRAY_TERMINATED]) + items + bytes([_TERMINATOR])
if isinstance(value, dict):
pairs = b"".join(encode(k) + encode(v) for k, v in value.items())
if len(value) <= 14:
return bytes([_DICT_0 + len(value)]) + pairs
return bytes([_DICT_TERMINATED]) + pairs + bytes([_TERMINATOR])
raise TypeError(f"Cannot encode {type(value).__name__} into an HDS payload")
elif isinstance(value, (list, tuple)):
terminated = len(value) > 14
out.append(_ARRAY_TERMINATED if terminated else _ARRAY_0 + len(value))
for item in value:
_encode_into(item, out)
if terminated:
out.append(_TERMINATOR)
elif isinstance(value, dict):
terminated = len(value) > 14
out.append(_DICT_TERMINATED if terminated else _DICT_0 + len(value))
for key, item in value.items():
_encode_into(key, out)
_encode_into(item, out)
if terminated:
out.append(_TERMINATOR)
else:
raise TypeError(f"Cannot encode {type(value).__name__} into an HDS payload")


def encode(value: Any) -> bytes:
"""Serialize a python value into the HDS payload format."""
out = bytearray()
_encode_into(value, out)
return bytes(out)


# Real HDS payloads nest only a handful of levels; bound recursion well above
Expand Down
64 changes: 64 additions & 0 deletions tests/test_hds_protocol.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Tests for the HDS payload encoder and decoder."""

from typing import Any

import pytest

from pyhap.hds_protocol import Int64, decode, encode

VALUES = [
True,
False,
None,
-1,
0,
38,
39,
-128,
127,
-32768,
32767,
-(2**31),
2**31 - 1,
2**40,
Int64(5),
1.5,
"",
"a" * 32,
"a" * 33,
"a" * 0x100,
b"x" * 32,
b"x" * 0xFF,
b"x" * 0x10000,
bytearray(b"ab"),
[1, "two", b"3"],
list(range(15)),
{"a": 1, "b": [True, None]},
{str(i): i for i in range(15)},
]


@pytest.mark.parametrize("value", VALUES)
def test_round_trip(value: Any) -> None:
"""Encoded values decode back to the same value."""
result = decode(encode(value))
if isinstance(value, bytearray):
value = bytes(value)
assert result == value


def test_encode_int_size() -> None:
"""Each integer uses the smallest encoding, Int64 always uses 8 bytes."""
assert len(encode(-1)) == 1
assert len(encode(38)) == 1
assert len(encode(-128)) == 2
assert len(encode(32767)) == 3
assert len(encode(2**31 - 1)) == 5
assert len(encode(2**40)) == 9
assert len(encode(Int64(5))) == 9


def test_encode_unsupported_type() -> None:
"""An unsupported type raises TypeError."""
with pytest.raises(TypeError):
encode(object())
Loading