diff --git a/docs/protocol.md b/docs/protocol.md index b28ab1b..0c60767 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 6d924cf..313fd8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/pycomap/protocol/client.py b/src/pycomap/protocol/client.py index c566e0e..5c60ffc 100644 --- a/src/pycomap/protocol/client.py +++ b/src/pycomap/protocol/client.py @@ -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 @@ -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. @@ -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) @@ -251,13 +261,14 @@ 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). @@ -265,6 +276,7 @@ async def write_object(self, comm_obj: int, data: bytes, addr: int = 1) -> bytes 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() diff --git a/tests/unit/protocol/test_client.py b/tests/unit/protocol/test_client.py index c555add..87a80c9 100644 --- a/tests/unit/protocol/test_client.py +++ b/tests/unit/protocol/test_client.py @@ -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 @@ -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 diff --git a/uv.lock b/uv.lock index d3fb788..4b51edc 100644 --- a/uv.lock +++ b/uv.lock @@ -444,7 +444,7 @@ wheels = [ [[package]] name = "pycomap" -version = "2.2.0" +version = "2.3.0" source = { editable = "." } dependencies = [ { name = "cryptography" },