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.
- pycomap/__init__.py +56 -0
- pycomap/alarms.py +100 -0
- pycomap/configuration.py +546 -0
- pycomap/controller.py +790 -0
- pycomap/datatypes.py +202 -0
- pycomap/discovery.py +195 -0
- pycomap/exceptions.py +35 -0
- pycomap/history.py +166 -0
- pycomap/protocol/__init__.py +24 -0
- pycomap/protocol/client.py +357 -0
- pycomap/protocol/commands.py +62 -0
- pycomap/protocol/crc.py +20 -0
- pycomap/protocol/crypto.py +90 -0
- pycomap/protocol/framing.py +145 -0
- pycomap/protocol/objects.py +98 -0
- pycomap/protocol/transport.py +89 -0
- pycomap/py.typed +0 -0
- pycomap-1.0.0.dist-info/METADATA +57 -0
- pycomap-1.0.0.dist-info/RECORD +20 -0
- pycomap-1.0.0.dist-info/WHEEL +4 -0
|
@@ -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)
|
pycomap/protocol/crc.py
ADDED
|
@@ -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
|