pycomap 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,357 @@
1
+ """Async client for ComAp's native ``EthernetMessage`` protocol (TCP port 23).
2
+
3
+ See ``docs/protocol.md`` section 2 for the full reverse-engineering notes this implements,
4
+ and the docstrings on [pycomap.protocol.crypto][] / [pycomap.protocol.framing][] for
5
+ the trickiest details (read/write key-format asymmetry, the single shared IV chain).
6
+
7
+ Typical usage::
8
+
9
+ from pycomap.protocol.transport import EthernetTransport
10
+
11
+ async with ComApClient(EthernetTransport("192.168.1.9")) as client:
12
+ await client.authenticate("0")
13
+ values = await client.read_object(CommunicationObject.VALUES_ALL)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import datetime
19
+ import enum
20
+ import hashlib
21
+ import logging
22
+ import struct
23
+ from types import TracebackType
24
+
25
+ from pycomap.datatypes import decode_fdate, decode_ftime, encode_fdate, encode_ftime
26
+ from pycomap.exceptions import (
27
+ ComApAuthError,
28
+ ComApControllerError,
29
+ ComApProtocolError,
30
+ )
31
+ from pycomap.protocol import crypto
32
+ from pycomap.protocol.commands import ControllerCommand
33
+ from pycomap.protocol.crypto import ChainedAesCbc
34
+ from pycomap.protocol.framing import (
35
+ BLOCK_SIZE,
36
+ Message,
37
+ Operation,
38
+ build_inner,
39
+ pad_to_block,
40
+ parse_inner,
41
+ wrap_outer,
42
+ )
43
+ from pycomap.protocol.objects import CommunicationObject, ControllerError
44
+ from pycomap.protocol.transport import Transport
45
+
46
+
47
+ class _Mode(enum.Enum):
48
+ """Wire mode the connection has progressed through: NONE -> ALIGNED -> AES."""
49
+
50
+ NONE = enum.auto()
51
+ ALIGNED = enum.auto()
52
+ AES = enum.auto()
53
+
54
+
55
+ _AUTH_FALLBACK_CODES = {
56
+ ControllerError.TERMINAL_ACCESS_DISABLED,
57
+ ControllerError.NON_EXISTING_COMMUNICATION_OBJECT,
58
+ }
59
+
60
+ _log = logging.getLogger(__name__)
61
+
62
+
63
+ class ComApClient:
64
+ """Speaks the ECDH/AES-encrypted ``EthernetMessage`` protocol over any ``Transport``.
65
+
66
+ Pass any ``Transport`` implementation — typically ``EthernetTransport``::
67
+
68
+ from pycomap.protocol.transport import EthernetTransport
69
+
70
+ async with ComApClient(EthernetTransport("192.168.1.9")) as client:
71
+ await client.authenticate("0")
72
+ """
73
+
74
+ def __init__(self, transport: Transport) -> None:
75
+ """
76
+ Args:
77
+ transport: Byte-stream transport to use (typically ``EthernetTransport``).
78
+ """
79
+ self._transport = transport
80
+ self._identifier = 0
81
+ self._mode = _Mode.NONE
82
+ self._cipher: ChainedAesCbc | None = None
83
+
84
+ # -- connection lifecycle -------------------------------------------------
85
+
86
+ async def connect(self) -> None:
87
+ """Open the transport and consume the controller's unsolicited ``VersionIB``."""
88
+ await self._transport.connect()
89
+
90
+ message = await self._read_message()
91
+ if message.comm_obj != CommunicationObject.VERSION_IB:
92
+ raise ComApProtocolError(
93
+ f"expected VersionIB as the first message, got comm_obj={message.comm_obj}"
94
+ )
95
+ version_word = struct.unpack_from("<H", message.data, 0)[0]
96
+ if not version_word & 0x8000:
97
+ raise ComApProtocolError(
98
+ "controller requires the legacy Blowfish cipher, which is not supported"
99
+ )
100
+ _log.debug("VersionIB received (version_word=0x%04X)", version_word)
101
+ self._mode = _Mode.ALIGNED
102
+
103
+ async def authenticate(self, access_code: str) -> None:
104
+ """Perform the ECDH handshake and verify ``access_code`` with the controller.
105
+
106
+ The AES session key is derived once from ``access_code`` and cannot be changed
107
+ without reconnecting. Pass the base/anonymous code (often ``"0"``), not a
108
+ privileged write code. To unlock write-protected setpoints afterward, call
109
+ [elevate_access][pycomap.protocol.ComApClient.elevate_access] —
110
+ re-calling this method with a different code would
111
+ corrupt the cipher state.
112
+
113
+ Args:
114
+ access_code: The controller's base AccessCode (drives ECDH/AES key derivation).
115
+
116
+ Raises:
117
+ ComApAuthError: If the controller rejects the access code.
118
+ ComApProtocolError: If the ECDH exchange produces an unexpected response.
119
+ """
120
+ server_pub_data = await self.read_object(CommunicationObject.ECDH_PUBLIC_KEY)
121
+ server_pub_point = server_pub_data[4:]
122
+
123
+ private_key = crypto.generate_keypair()
124
+ our_pub_point = crypto.public_point_bytes(private_key)
125
+ await self.write_object(
126
+ CommunicationObject.ECDH_PUBLIC_KEY,
127
+ bytes([len(our_pub_point)]) + our_pub_point,
128
+ )
129
+
130
+ secret = crypto.shared_secret(private_key, server_pub_point)
131
+ aes_key, iv = crypto.derive_key_and_iv(secret, access_code)
132
+ self._cipher = ChainedAesCbc(aes_key, iv)
133
+ self._mode = _Mode.AES
134
+
135
+ try:
136
+ nonce = await self.read_object(CommunicationObject.VERIFY_ACCESS_HASH)
137
+ except ComApControllerError as exc:
138
+ if exc.code not in _AUTH_FALLBACK_CODES:
139
+ raise ComApAuthError("access verification failed") from exc
140
+ _log.warning("hash auth not supported by controller, falling back to plain access code")
141
+ source = access_code.encode("ascii").ljust(16, b"\x00")
142
+ try:
143
+ await self.write_object(CommunicationObject.VERIFY_ACCESS, source)
144
+ except ComApControllerError as exc2:
145
+ raise ComApAuthError("access code rejected by controller") from exc2
146
+ _log.info("authenticated via plain access code")
147
+ else:
148
+ digest = hashlib.md5(nonce + access_code.encode("ascii")).digest()
149
+ credentials = "".join(f"{b:02X}" for b in digest).encode("ascii")
150
+ try:
151
+ await self.write_object(CommunicationObject.VERIFY_ACCESS_HASH, credentials)
152
+ except ComApControllerError as exc3:
153
+ raise ComApAuthError("access code rejected by controller") from exc3
154
+ _log.info("authenticated via hash")
155
+
156
+ async def elevate_access(self, password: int) -> None:
157
+ """Submit the controller's write-protection password to unlock password-protected
158
+ setpoints for this session.
159
+
160
+ This is a completely separate credential from the ``access_code`` passed to
161
+ [authenticate][pycomap.protocol.ComApClient.authenticate] —
162
+ the *AccessCode* gates the TCP connection itself, while the
163
+ *Password* (0-9999) gates individual setpoint writes based on each setpoint's
164
+ configured ``accessLevel``. Verified live against a real controller: write
165
+ ``CommunicationObject.PASSWORD_FOR_WRITE`` (24524) with the 2-byte little-endian
166
+ password value, then setpoint writes that would otherwise return
167
+ ``ControllerError.INVALID_PASSWORD`` succeed.
168
+
169
+ The controller enforces brute-force protection (5 wrong attempts → 1 min block,
170
+ doubling each time, 100 wrong → permanent lockout). Do not call this in a retry
171
+ loop. See ``docs/protocol.md`` section 2.4.1 for the full picture.
172
+ """
173
+ try:
174
+ await self.write_object(
175
+ CommunicationObject.PASSWORD_FOR_WRITE, struct.pack("<H", password)
176
+ )
177
+ except ComApControllerError as exc:
178
+ raise ComApAuthError(
179
+ "password rejected by controller (wrong password or brute-force lockout active)"
180
+ ) from exc
181
+ _log.info("write-protection password accepted")
182
+
183
+ async def close(self) -> None:
184
+ await self._transport.close()
185
+ self._mode = _Mode.NONE
186
+ self._cipher = None
187
+
188
+ async def __aenter__(self) -> ComApClient:
189
+ await self.connect()
190
+ return self
191
+
192
+ async def __aexit__(
193
+ self,
194
+ exc_type: type[BaseException] | None,
195
+ exc: BaseException | None,
196
+ tb: TracebackType | None,
197
+ ) -> None:
198
+ await self.close()
199
+
200
+ # -- communication objects -------------------------------------------------
201
+
202
+ async def read_object(self, comm_obj: int, addr: int = 1) -> bytes:
203
+ """Read a communication object, handling ``SendToBlock`` continuation transparently.
204
+
205
+ Args:
206
+ comm_obj: Communication object number (C.O.).
207
+ addr: Controller unit address; ``1`` for the primary unit.
208
+
209
+ Returns:
210
+ Raw payload bytes.
211
+
212
+ Raises:
213
+ ComApControllerError: If the controller responds with an error code.
214
+ ComApProtocolError: If an unexpected message operation is received.
215
+ """
216
+ ident = self._next_identifier()
217
+ await self._write_message(Operation.SEND_ME, addr, comm_obj, b"", ident)
218
+
219
+ data = bytearray()
220
+ while True:
221
+ message = await self._read_message()
222
+ if message.is_error:
223
+ raise ComApControllerError(message.error_code)
224
+ if message.op is Operation.SEND_TO:
225
+ data.extend(message.data)
226
+ return bytes(data)
227
+ if message.op is Operation.SEND_TO_BLOCK:
228
+ data.extend(message.data)
229
+ if message.is_last_block:
230
+ return bytes(data)
231
+ next_ident = self._next_identifier()
232
+ await self._write_message(Operation.NEXT, addr, comm_obj, b"", next_ident)
233
+ continue
234
+ raise ComApProtocolError(
235
+ f"unexpected operation {message.op!r} while reading {comm_obj}"
236
+ )
237
+
238
+ async def write_object(self, comm_obj: int, data: bytes, addr: int = 1) -> bytes:
239
+ """Write a communication object.
240
+
241
+ Args:
242
+ comm_obj: Communication object number (C.O.).
243
+ data: Raw payload bytes to write.
244
+ addr: Controller unit address; ``1`` for the primary unit.
245
+
246
+ Returns:
247
+ Any data carried back on the ``NEXT`` acknowledgment (usually empty).
248
+
249
+ Raises:
250
+ ComApControllerError: If the controller responds with an error code.
251
+ """
252
+ ident = self._next_identifier()
253
+ await self._write_message(Operation.SEND_TO, addr, comm_obj, data, ident)
254
+ message = await self._read_message()
255
+ if message.is_error:
256
+ raise ComApControllerError(message.error_code)
257
+ if message.op is not Operation.NEXT:
258
+ raise ComApProtocolError(
259
+ f"unexpected operation {message.op!r} while writing {comm_obj}"
260
+ )
261
+ return message.data
262
+
263
+ async def execute_command(self, command: ControllerCommand) -> int:
264
+ """Execute a controller command and return the ``uint32`` result code.
265
+
266
+ Writes the argument to ``COMMAND_ARGUMENT`` (24550), triggers via ``COMMAND``
267
+ (24551), then reads ``COMMAND_ARGUMENT`` back for the return value. Compare the
268
+ result against ``command.expected_return`` (or use ``command.succeeded(result)``)
269
+ to determine success. A result of ``ControllerCommand.RESULT_REFUSED`` (``0x02``)
270
+ means the controller state doesn't allow the action right now (e.g. engine start
271
+ attempted outside MAN mode); ``RESULT_INVALID_ARGUMENT`` (``0x01``) means the
272
+ command/argument combination is unrecognised.
273
+
274
+ Note: some commands require the session to be password-elevated first via
275
+ [elevate_access][pycomap.protocol.ComApClient.elevate_access] — the controller will raise
276
+ ``ControllerError.INVALID_PASSWORD`` on the write if so.
277
+ """
278
+ await self.write_object(
279
+ CommunicationObject.COMMAND_ARGUMENT, struct.pack("<I", command.argument)
280
+ )
281
+ await self.write_object(CommunicationObject.COMMAND, struct.pack("<H", command.code))
282
+ result_raw = await self.read_object(CommunicationObject.COMMAND_ARGUMENT)
283
+ return struct.unpack("<I", result_raw)[0]
284
+
285
+ async def read_datetime(self) -> datetime.datetime | None:
286
+ """Read the controller's current date and time as a **naive** datetime.
287
+
288
+ Reads ``DATE`` (C.O. 24553) and ``TIME`` (C.O. 24554) in two round-trips.
289
+ Returns ``None`` if the controller reports an invalid/unset clock (e.g. after a
290
+ factory reset before time sync).
291
+
292
+ The value is the controller's local wall-clock time (no timezone info attached).
293
+ DST is governed by the ``Summer Time Mode`` setpoint (8727) and the UTC offset
294
+ by the ``Time Zone`` setpoint (24366).
295
+ """
296
+ date_raw = await self.read_object(CommunicationObject.DATE)
297
+ time_raw = await self.read_object(CommunicationObject.TIME)
298
+ date = decode_fdate(date_raw)
299
+ time = decode_ftime(time_raw)
300
+ if date is None or time is None:
301
+ return None
302
+ return datetime.datetime.combine(date, time)
303
+
304
+ async def write_datetime(self, dt: datetime.datetime) -> None:
305
+ """Set the controller's clock to the wall-clock components of ``dt``.
306
+
307
+ Writes ``DATE`` (C.O. 24553) then ``TIME`` (C.O. 24554). Seconds are included;
308
+ sub-second precision is dropped. Year must be ≥ 2000.
309
+
310
+ Both setpoints have ``access_level=1`` — call
311
+ [elevate_access][pycomap.protocol.ComApClient.elevate_access] first.
312
+ Pass the correct **local time** directly.
313
+ """
314
+ await self.write_object(CommunicationObject.DATE, encode_fdate(dt.date()))
315
+ await self.write_object(CommunicationObject.TIME, encode_ftime(dt.time()))
316
+
317
+ # -- low-level framing ---------------------------------------------------
318
+
319
+ def _next_identifier(self) -> int:
320
+ ident = self._identifier
321
+ self._identifier = (self._identifier + 1) & 0xFF
322
+ return ident
323
+
324
+ async def _read_message(self) -> Message:
325
+ inner = await self._read_inner()
326
+ return parse_inner(inner)
327
+
328
+ async def _write_message(
329
+ self, op: Operation, addr: int, comm_obj: int, data: bytes, ident: int
330
+ ) -> None:
331
+ inner = build_inner(op, addr, comm_obj, data, ident)
332
+ await self._write_inner(inner)
333
+
334
+ async def _read_inner(self) -> bytes:
335
+ if self._mode is _Mode.NONE:
336
+ header = await self._transport.read_exactly(8)
337
+ data_len = struct.unpack_from("<H", header, 0)[0]
338
+ rest = await self._transport.read_exactly(data_len)
339
+ return header + rest
340
+
341
+ count_byte = await self._transport.read_exactly(1)
342
+ block_payload = await self._transport.read_exactly(count_byte[0] * BLOCK_SIZE)
343
+
344
+ if self._mode is _Mode.ALIGNED:
345
+ return block_payload
346
+ assert self._cipher is not None
347
+ return self._cipher.decrypt(block_payload)
348
+
349
+ async def _write_inner(self, inner: bytes) -> None:
350
+ padded = pad_to_block(inner)
351
+ if self._mode is _Mode.NONE:
352
+ await self._transport.write(inner)
353
+ elif self._mode is _Mode.ALIGNED:
354
+ await self._transport.write(wrap_outer(padded))
355
+ else:
356
+ assert self._cipher is not None
357
+ await self._transport.write(wrap_outer(self._cipher.encrypt(padded)))
@@ -0,0 +1,62 @@
1
+ """Controller command definitions.
2
+
3
+ ``ControllerCommand`` bundles a command's wire-level code, argument, and expected
4
+ success return value. ``Command`` is a namespace of pre-built named instances for all
5
+ known IL3 commands. Pass them to ``ComApClient.execute_command``.
6
+
7
+ Source: InteliLite Global Guide §6.1 "List of commands and arguments" and decompiled
8
+ ``ComAp.Controller.dll`` ``CommandNumber`` class.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class ControllerCommand:
18
+ """A controller command bundling its code, argument, and the return value that
19
+ indicates success.
20
+
21
+ To execute: ``await client.execute_command(command)``.
22
+ To check the result: compare the returned ``uint32`` against ``expected_return``
23
+ or use ``command.succeeded(result)``.
24
+ """
25
+
26
+ code: int
27
+ argument: int
28
+ expected_return: int
29
+
30
+ # Application-level failure codes returned inside COMMAND_ARGUMENT on error.
31
+ RESULT_INVALID_ARGUMENT: int = 0x00000001
32
+ RESULT_REFUSED: int = 0x00000002
33
+
34
+ def succeeded(self, result: int) -> bool:
35
+ """True if ``result`` (the uint32 read back from ``COMMAND_ARGUMENT``) matches
36
+ the expected success return value for this command.
37
+
38
+ Note: a successful return value means the *protocol* acknowledged the command —
39
+ not that the mechanical action was carried out. The controller's own mode logic
40
+ gates execution (e.g. ENGINE_START returns the success code even outside MAN mode,
41
+ but the engine does not start). Always verify the physical outcome separately.
42
+ """
43
+ return result == self.expected_return
44
+
45
+
46
+ class Command:
47
+ """Named controller commands. Pass to ``ComApClient.execute_command``.
48
+
49
+ Some commands require the controller to be in a specific mode (e.g. MAN for engine
50
+ start/stop); the controller returns ``RESULT_REFUSED`` (0x02) otherwise.
51
+ """
52
+
53
+ ENGINE_START = ControllerCommand(0x01, 0x01FE0000, 0x000001FF)
54
+ ENGINE_STOP = ControllerCommand(0x01, 0x02FD0000, 0x000002FE)
55
+ FAULT_RESET = ControllerCommand(0x01, 0x08F70000, 0x000008F8)
56
+ HORN_RESET = ControllerCommand(0x01, 0x04FB0000, 0x000004FC)
57
+ GCB_TOGGLE = ControllerCommand(0x02, 0x11EE0000, 0x000011EF)
58
+ GCB_ON = ControllerCommand(0x02, 0x11EF0000, 0x000011F0)
59
+ GCB_OFF = ControllerCommand(0x02, 0x11F00000, 0x000011F1)
60
+ MCB_TOGGLE = ControllerCommand(0x02, 0x12ED0000, 0x000012EE)
61
+ MCB_ON = ControllerCommand(0x02, 0x12EE0000, 0x000012EF)
62
+ MCB_OFF = ControllerCommand(0x02, 0x12EF0000, 0x000012F0)
@@ -0,0 +1,20 @@
1
+ """CRC16 used to checksum every ComAp ``EthernetMessage``.
2
+
3
+ Standard MODBUS/ANSI CRC16: init ``0xFFFF``, polynomial ``0xA001`` (reflected ``0x8005``),
4
+ no final XOR. Verified byte-for-byte against live ComAp traffic — see
5
+ ``docs/protocol.md`` section 2.2.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ _POLY = 0xA001
11
+
12
+
13
+ def crc16(data: bytes) -> int:
14
+ """Compute the CRC16 used by the ComAp wire protocol over ``data``."""
15
+ crc = 0xFFFF
16
+ for byte in data:
17
+ crc ^= byte
18
+ for _ in range(8):
19
+ crc = (crc >> 1) ^ _POLY if crc & 1 else crc >> 1
20
+ return crc
@@ -0,0 +1,90 @@
1
+ """ECDH handshake and the chained-IV AES-256-CBC cipher used after it.
2
+
3
+ See ``docs/protocol.md`` section 2.4 (steps 4-9) for the full derivation. The key points
4
+ that are easy to get wrong (and were, during reverse-engineering):
5
+
6
+ - The EC key exchange uses curve secp256r1 (P-256), raw uncompressed point encoding
7
+ (``0x04 || X[32] || Y[32]``).
8
+ - The *read* and *write* wire formats for the public key are different: reading the
9
+ controller's key requires skipping a 4-byte prefix; writing ours requires prefixing a
10
+ single length byte. See [pycomap.protocol.client][].
11
+ - The AES key/IV are derived via double HMAC-SHA256 keyed by the access code (not the shared
12
+ secret) — see [derive_key_and_iv][].
13
+ - There is exactly **one** IV for the whole connection, shared between encryption and
14
+ decryption, advanced after *every* AES operation (either direction) to that operation's
15
+ ciphertext's last 16 bytes. [ChainedAesCbc][] models this with a single internal IV.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import hmac as _hmac
22
+
23
+ from cryptography.hazmat.backends import default_backend
24
+ from cryptography.hazmat.primitives.asymmetric import ec
25
+ from cryptography.hazmat.primitives.ciphers import Cipher as _CryptoCipher
26
+ from cryptography.hazmat.primitives.ciphers import algorithms, modes
27
+ from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
28
+
29
+ CURVE = ec.SECP256R1()
30
+ EC_POINT_LENGTH = 65 # 1 (0x04 prefix) + 32 (X) + 32 (Y)
31
+ AES_KEY_LENGTH = 32
32
+ AES_IV_LENGTH = 16
33
+
34
+
35
+ def generate_keypair() -> ec.EllipticCurvePrivateKey:
36
+ """Generate a fresh ephemeral keypair on secp256r1."""
37
+ return ec.generate_private_key(CURVE, default_backend())
38
+
39
+
40
+ def public_point_bytes(private_key: ec.EllipticCurvePrivateKey) -> bytes:
41
+ """Raw uncompressed point encoding of ``private_key``'s public key (65 bytes)."""
42
+ return private_key.public_key().public_bytes(
43
+ encoding=Encoding.X962,
44
+ format=PublicFormat.UncompressedPoint,
45
+ )
46
+
47
+
48
+ def shared_secret(private_key: ec.EllipticCurvePrivateKey, peer_point: bytes) -> bytes:
49
+ """Compute the ECDH shared secret (32 bytes) from our private key and the peer's
50
+ raw uncompressed public point.
51
+ """
52
+ peer_key = ec.EllipticCurvePublicKey.from_encoded_point(CURVE, peer_point)
53
+ secret = private_key.exchange(ec.ECDH(), peer_key)
54
+ return secret.rjust(AES_KEY_LENGTH, b"\x00")
55
+
56
+
57
+ def derive_key_and_iv(shared_secret_bytes: bytes, access_code: str) -> tuple[bytes, bytes]:
58
+ """Derive the AES-256 key and initial IV from the ECDH shared secret and access code."""
59
+ access_bytes = access_code.encode("utf-8")
60
+ aes_key = _hmac.new(access_bytes, shared_secret_bytes, hashlib.sha256).digest()
61
+ iv = _hmac.new(access_bytes, aes_key, hashlib.sha256).digest()[:AES_IV_LENGTH]
62
+ return aes_key, iv
63
+
64
+
65
+ class ChainedAesCbc:
66
+ """AES-256-CBC with a single IV shared between encrypt and decrypt, chained on
67
+ ciphertext across the whole connection (see module docstring).
68
+ """
69
+
70
+ def __init__(self, key: bytes, iv: bytes) -> None:
71
+ self._key = key
72
+ self._iv = iv
73
+
74
+ def encrypt(self, block_payload: bytes) -> bytes:
75
+ """Encrypt an already 16-byte-aligned plaintext payload, advancing the shared IV."""
76
+ encryptor = _CryptoCipher(
77
+ algorithms.AES(self._key), modes.CBC(self._iv), backend=default_backend()
78
+ ).encryptor()
79
+ ciphertext = encryptor.update(block_payload) + encryptor.finalize()
80
+ self._iv = ciphertext[-16:]
81
+ return ciphertext
82
+
83
+ def decrypt(self, block_payload: bytes) -> bytes:
84
+ """Decrypt an already 16-byte-aligned ciphertext payload, advancing the shared IV."""
85
+ decryptor = _CryptoCipher(
86
+ algorithms.AES(self._key), modes.CBC(self._iv), backend=default_backend()
87
+ ).decryptor()
88
+ plaintext = decryptor.update(block_payload) + decryptor.finalize()
89
+ self._iv = block_payload[-16:]
90
+ return plaintext
@@ -0,0 +1,145 @@
1
+ """ComAp ``EthernetMessage`` wire framing.
2
+
3
+ See ``docs/protocol.md`` section 2.1 for the full format. Two nested layers:
4
+
5
+ Outer "block alignment" wrapper (present on every message except the very first one on a
6
+ connection, see [pycomap.protocol.client][])::
7
+
8
+ [1 byte: block_count][block_count * 16 bytes: payload, zero-padded to the boundary]
9
+
10
+ Inner ``EthernetMessage``::
11
+
12
+ offset size field
13
+ 0 2 data_length (uint16 LE)
14
+ 2 1 bits[0:3]=Operation, bits[3:8]=ControllerAddress-1
15
+ 3 1 Identifier
16
+ 4 2 CommunicationObject id (uint16 LE)
17
+ 6 dlen data (or, for SendToBlock: 1 block-info byte + (dlen-1) bytes of data)
18
+ 6+dlen 2 CRC16 LE over bytes [0 .. 6+dlen)
19
+
20
+ This module only deals with bytes — it knows nothing about sockets or encryption. The outer
21
+ wrapper's payload may be plaintext (before AES is established) or AES-CBC ciphertext (after);
22
+ either way this module just packs/unpacks the block-count-prefixed, 16-byte-aligned blob.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import enum
28
+ import struct
29
+ from dataclasses import dataclass
30
+
31
+ from pycomap.exceptions import ComApProtocolError
32
+ from pycomap.protocol.crc import crc16
33
+
34
+ BLOCK_SIZE = 16
35
+
36
+
37
+ class Operation(enum.IntEnum):
38
+ """ComAp ``Message.Operation`` enum (3 bits on the wire)."""
39
+
40
+ SEND_ME = 0
41
+ SEND_TO = 1
42
+ SEND_TO_BLOCK = 2
43
+ NEXT = 3 # aka "Acknowledgment"
44
+ ERROR = 4
45
+ REPEAT = 5
46
+
47
+
48
+ @dataclass(slots=True)
49
+ class Message:
50
+ """A parsed (or about-to-be-built) inner ``EthernetMessage``."""
51
+
52
+ op: Operation
53
+ addr: int
54
+ ident: int
55
+ comm_obj: int
56
+ data: bytes
57
+ block_index: int | None = None
58
+ is_last_block: bool | None = None
59
+
60
+ @property
61
+ def is_error(self) -> bool:
62
+ return self.op is Operation.ERROR
63
+
64
+ @property
65
+ def error_code(self) -> int:
66
+ """Decode the ``uint32`` LE error code carried in an ``ERROR`` message's data."""
67
+ if not self.is_error:
68
+ raise ValueError("not an ERROR message")
69
+ return struct.unpack_from("<I", self.data, 0)[0]
70
+
71
+
72
+ def parse_inner(buf: bytes) -> Message:
73
+ """Parse one inner ``EthernetMessage`` from ``buf`` (CRC-validated)."""
74
+ if len(buf) < 8:
75
+ raise ComApProtocolError(f"message too short: {len(buf)} bytes")
76
+ data_len = struct.unpack_from("<H", buf, 0)[0]
77
+ total = data_len + 8
78
+ if total > len(buf):
79
+ raise ComApProtocolError(f"truncated message: need {total} bytes, have {len(buf)}")
80
+ buf = buf[:total]
81
+
82
+ b2 = buf[2]
83
+ op = Operation(b2 & 0x07)
84
+ addr = (b2 >> 3) + 1
85
+ ident = buf[3]
86
+ comm_obj = struct.unpack_from("<H", buf, 4)[0]
87
+
88
+ block_index: int | None = None
89
+ is_last_block: bool | None = None
90
+ if op is Operation.SEND_TO_BLOCK:
91
+ block_byte = buf[6]
92
+ block_index = block_byte & 0x7F
93
+ is_last_block = bool(block_byte & 0x80)
94
+ data = buf[7 : 6 + data_len]
95
+ else:
96
+ data = buf[6 : 6 + data_len]
97
+
98
+ crc_expected = struct.unpack_from("<H", buf, len(buf) - 2)[0]
99
+ crc_actual = crc16(buf[:-2])
100
+ if crc_expected != crc_actual:
101
+ raise ComApProtocolError(
102
+ f"CRC mismatch: expected {crc_expected:#06x}, got {crc_actual:#06x} ({buf.hex()})"
103
+ )
104
+
105
+ return Message(
106
+ op=op,
107
+ addr=addr,
108
+ ident=ident,
109
+ comm_obj=comm_obj,
110
+ data=bytes(data),
111
+ block_index=block_index,
112
+ is_last_block=is_last_block,
113
+ )
114
+
115
+
116
+ def build_inner(
117
+ op: Operation,
118
+ addr: int,
119
+ comm_obj: int,
120
+ data: bytes,
121
+ ident: int,
122
+ ) -> bytes:
123
+ """Build one inner ``EthernetMessage`` (header + data + CRC), CRC-validated by construction."""
124
+ b2 = (op & 0x07) | ((addr - 1) << 3)
125
+ header = struct.pack("<H", len(data)) + bytes([b2, ident]) + struct.pack("<H", comm_obj)
126
+ msg = header + data
127
+ return msg + struct.pack("<H", crc16(msg))
128
+
129
+
130
+ def pad_to_block(payload: bytes) -> bytes:
131
+ """Zero-pad ``payload`` up to the next 16-byte boundary."""
132
+ remainder = len(payload) % BLOCK_SIZE
133
+ if remainder == 0:
134
+ return payload
135
+ return payload + b"\x00" * (BLOCK_SIZE - remainder)
136
+
137
+
138
+ def wrap_outer(block_payload: bytes) -> bytes:
139
+ """Prefix an already block-aligned payload with its 1-byte block count."""
140
+ if len(block_payload) % BLOCK_SIZE != 0:
141
+ raise ComApProtocolError("outer payload is not block-aligned")
142
+ count = len(block_payload) // BLOCK_SIZE
143
+ if count > 255:
144
+ raise ComApProtocolError("message too long for a single block-count byte")
145
+ return bytes([count]) + block_payload