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
4 changes: 4 additions & 0 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,10 @@ first one (see §2.4 step 2):
offset size field
0 2 data_length : uint16 LE — length of `data` below (header/CRC not included)
2 1 bit-packed: bits[0:3] = Operation (§2.2), bits[3:8] = ControllerAddress - 1
(matches setpoint `ControllerAddress`, C.O. 24537 — factory default `1`, range
1-32; only relevant with multiple units addressed through one gateway/party-line.
`ComApClient(transport, addr=N)` sets the default for every `read_object`/
`write_object` call; each call can still override it per-call.)
3 1 Identifier — sequence/correlation byte
4 2 CommunicationObject ID : uint16 LE
6 dlen data
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "pycomap"
version = "2.2.0"
version = "2.3.0"
description = "Async Python client for ComAp controllers: LAN discovery and the native ECDH/AES-encrypted control protocol"
readme = "README.md"
license = "MIT"
Expand Down
22 changes: 17 additions & 5 deletions src/pycomap/protocol/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,20 @@ class ComApClient[TransportT: Transport]:
await client.authenticate("0")
"""

def __init__(self, transport: TransportT) -> None:
def __init__(self, transport: TransportT, addr: int = 1) -> None:
"""
Args:
transport: Byte-stream transport to use (typically ``EthernetTransport``).
addr: Default controller unit address (``ControllerAddress`` setpoint, C.O.
24537; range 1-32, factory default ``1``) used for every ``read_object``/
``write_object`` call that doesn't override it explicitly. Only matters if
the target controller's ``ControllerAddress`` has been changed from the
default -- e.g. multiple units addressed through one gateway/party-line,
the same address you'd set in InteliMonitor/InteliConfig's connection
dialog.
"""
self._transport = transport
self._addr = addr
self._identifier = 0
self._mode = _Mode.NONE
self._cipher: ChainedAesCbc | None = None
Expand Down Expand Up @@ -215,12 +223,13 @@ async def __aexit__(

# -- communication objects -------------------------------------------------

async def read_object(self, comm_obj: int, addr: int = 1) -> bytes:
async def read_object(self, comm_obj: int, addr: int | None = None) -> bytes:
"""Read a communication object, handling ``SendToBlock`` continuation transparently.

Args:
comm_obj: Communication object number (C.O.).
addr: Controller unit address; ``1`` for the primary unit.
addr: Controller unit address; defaults to the address passed to
``ComApClient.__init__`` (``1`` unless overridden there).

Returns:
Raw payload bytes.
Expand All @@ -229,6 +238,7 @@ async def read_object(self, comm_obj: int, addr: int = 1) -> bytes:
ComApControllerError: If the controller responds with an error code.
ComApProtocolError: If an unexpected message operation is received.
"""
addr = self._addr if addr is None else addr
ident = self._next_identifier()
await self._write_message(Operation.SEND_ME, addr, comm_obj, b"", ident)

Expand All @@ -251,20 +261,22 @@ async def read_object(self, comm_obj: int, addr: int = 1) -> bytes:
f"unexpected operation {message.op!r} while reading {comm_obj}"
)

async def write_object(self, comm_obj: int, data: bytes, addr: int = 1) -> bytes:
async def write_object(self, comm_obj: int, data: bytes, addr: int | None = None) -> bytes:
"""Write a communication object.

Args:
comm_obj: Communication object number (C.O.).
data: Raw payload bytes to write.
addr: Controller unit address; ``1`` for the primary unit.
addr: Controller unit address; defaults to the address passed to
``ComApClient.__init__`` (``1`` unless overridden there).

Returns:
Any data carried back on the ``NEXT`` acknowledgment (usually empty).

Raises:
ComApControllerError: If the controller responds with an error code.
"""
addr = self._addr if addr is None else addr
ident = self._next_identifier()
await self._write_message(Operation.SEND_TO, addr, comm_obj, data, ident)
message = await self._read_message()
Expand Down
63 changes: 63 additions & 0 deletions tests/unit/protocol/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from pycomap.exceptions import ComApProtocolError
from pycomap.protocol.client import ComApClient, _Mode
from pycomap.protocol.framing import Message, Operation
from pycomap.protocol.transport import Transport


Expand Down Expand Up @@ -46,3 +47,65 @@ async def test_write_inner_without_cipher_in_aes_mode_raises_protocol_error(
) -> None:
with pytest.raises(ComApProtocolError, match="cipher not initialized"):
await client_in_aes_mode_without_cipher._write_inner(b"\x00" * 16)


# ---------------------------------------------------------------------------
# addr default (ControllerAddress, C.O. 24537 -- see docs/protocol.md 2.1)
# ---------------------------------------------------------------------------


async def test_read_object_uses_constructor_addr_by_default(mocker) -> None:
client = ComApClient(_StubTransport(), addr=5)
write_message = mocker.patch.object(client, "_write_message")
mocker.patch.object(
client,
"_read_message",
return_value=Message(op=Operation.SEND_TO, addr=5, ident=0, comm_obj=100, data=b"\x01\x02"),
)

result = await client.read_object(100)

assert result == b"\x01\x02"
assert write_message.call_args.args[1] == 5 # addr


async def test_read_object_per_call_addr_overrides_constructor_default(mocker) -> None:
client = ComApClient(_StubTransport(), addr=5)
write_message = mocker.patch.object(client, "_write_message")
mocker.patch.object(
client,
"_read_message",
return_value=Message(op=Operation.SEND_TO, addr=9, ident=0, comm_obj=100, data=b""),
)

await client.read_object(100, addr=9)

assert write_message.call_args.args[1] == 9 # addr


async def test_write_object_uses_constructor_addr_by_default(mocker) -> None:
client = ComApClient(_StubTransport(), addr=7)
write_message = mocker.patch.object(client, "_write_message")
mocker.patch.object(
client,
"_read_message",
return_value=Message(op=Operation.NEXT, addr=7, ident=0, comm_obj=100, data=b""),
)

await client.write_object(100, b"\x00")

assert write_message.call_args.args[1] == 7 # addr


async def test_client_defaults_to_addr_1(mocker) -> None:
client = ComApClient(_StubTransport())
write_message = mocker.patch.object(client, "_write_message")
mocker.patch.object(
client,
"_read_message",
return_value=Message(op=Operation.SEND_TO, addr=1, ident=0, comm_obj=100, data=b""),
)

await client.read_object(100)

assert write_message.call_args.args[1] == 1 # addr
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.