pycomap 1.1.0__tar.gz → 2.1.0__tar.gz
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-1.1.0 → pycomap-2.1.0}/PKG-INFO +4 -2
- {pycomap-1.1.0 → pycomap-2.1.0}/README.md +3 -1
- {pycomap-1.1.0 → pycomap-2.1.0}/pyproject.toml +4 -2
- {pycomap-1.1.0 → pycomap-2.1.0}/src/pycomap/__init__.py +3 -1
- {pycomap-1.1.0 → pycomap-2.1.0}/src/pycomap/controller.py +11 -7
- {pycomap-1.1.0 → pycomap-2.1.0}/src/pycomap/discovery.py +91 -36
- {pycomap-1.1.0 → pycomap-2.1.0}/src/pycomap/protocol/client.py +24 -7
- {pycomap-1.1.0 → pycomap-2.1.0}/src/pycomap/protocol/transport.py +30 -18
- {pycomap-1.1.0 → pycomap-2.1.0}/src/pycomap/alarms.py +0 -0
- {pycomap-1.1.0 → pycomap-2.1.0}/src/pycomap/configuration.py +0 -0
- {pycomap-1.1.0 → pycomap-2.1.0}/src/pycomap/datatypes.py +0 -0
- {pycomap-1.1.0 → pycomap-2.1.0}/src/pycomap/exceptions.py +0 -0
- {pycomap-1.1.0 → pycomap-2.1.0}/src/pycomap/history.py +0 -0
- {pycomap-1.1.0 → pycomap-2.1.0}/src/pycomap/protocol/__init__.py +0 -0
- {pycomap-1.1.0 → pycomap-2.1.0}/src/pycomap/protocol/commands.py +0 -0
- {pycomap-1.1.0 → pycomap-2.1.0}/src/pycomap/protocol/crc.py +0 -0
- {pycomap-1.1.0 → pycomap-2.1.0}/src/pycomap/protocol/crypto.py +0 -0
- {pycomap-1.1.0 → pycomap-2.1.0}/src/pycomap/protocol/framing.py +0 -0
- {pycomap-1.1.0 → pycomap-2.1.0}/src/pycomap/protocol/objects.py +0 -0
- {pycomap-1.1.0 → pycomap-2.1.0}/src/pycomap/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pycomap
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: Async Python client for ComAp controllers: LAN discovery and the native ECDH/AES-encrypted control protocol
|
|
5
5
|
Author: Igor Panteleyev
|
|
6
6
|
Author-email: Igor Panteleyev <panteleev.igor69@gmail.com>
|
|
@@ -33,11 +33,13 @@ siblings): LAN discovery and the native ECDH/AES-encrypted control protocol on p
|
|
|
33
33
|
## Quick start
|
|
34
34
|
|
|
35
35
|
```python
|
|
36
|
+
from ipaddress import IPv4Address
|
|
37
|
+
|
|
36
38
|
from pycomap import Controller, EthernetTransport
|
|
37
39
|
from pycomap.protocol import ComApClient
|
|
38
40
|
|
|
39
41
|
async with Controller(
|
|
40
|
-
ComApClient(EthernetTransport("192.168.1.9")),
|
|
42
|
+
ComApClient(EthernetTransport(IPv4Address("192.168.1.9"))),
|
|
41
43
|
access_code="0", # factory default (drives ECDH key derivation)
|
|
42
44
|
password=1234, # write-protection password (0-9999); omit for read-only
|
|
43
45
|
) as ctrl:
|
|
@@ -6,11 +6,13 @@ siblings): LAN discovery and the native ECDH/AES-encrypted control protocol on p
|
|
|
6
6
|
## Quick start
|
|
7
7
|
|
|
8
8
|
```python
|
|
9
|
+
from ipaddress import IPv4Address
|
|
10
|
+
|
|
9
11
|
from pycomap import Controller, EthernetTransport
|
|
10
12
|
from pycomap.protocol import ComApClient
|
|
11
13
|
|
|
12
14
|
async with Controller(
|
|
13
|
-
ComApClient(EthernetTransport("192.168.1.9")),
|
|
15
|
+
ComApClient(EthernetTransport(IPv4Address("192.168.1.9"))),
|
|
14
16
|
access_code="0", # factory default (drives ECDH key derivation)
|
|
15
17
|
password=1234, # write-protection password (0-9999); omit for read-only
|
|
16
18
|
) as ctrl:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pycomap"
|
|
3
|
-
version = "
|
|
3
|
+
version = "2.1.0"
|
|
4
4
|
description = "Async Python client for ComAp controllers: LAN discovery and the native ECDH/AES-encrypted control protocol"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -33,11 +33,12 @@ Issues = "https://github.com/igor-panteleev/pycomap/issues"
|
|
|
33
33
|
Changelog = "https://github.com/igor-panteleev/pycomap/releases"
|
|
34
34
|
|
|
35
35
|
[build-system]
|
|
36
|
-
requires = ["uv_build>=0.9.26,<0.
|
|
36
|
+
requires = ["uv_build>=0.9.26,<0.12.0"]
|
|
37
37
|
build-backend = "uv_build"
|
|
38
38
|
|
|
39
39
|
[dependency-groups]
|
|
40
40
|
dev = [
|
|
41
|
+
"ifaddr>=0.2.0",
|
|
41
42
|
"mkdocs>=1.6,<2",
|
|
42
43
|
"mkdocs-material>=9.5",
|
|
43
44
|
"mkdocstrings[python]>=0.25",
|
|
@@ -89,4 +90,5 @@ known-first-party = ["pycomap"]
|
|
|
89
90
|
python-version = "3.14"
|
|
90
91
|
|
|
91
92
|
[tool.uv]
|
|
93
|
+
required-version = ">=0.11.24"
|
|
92
94
|
publish-url = "https://upload.pypi.org/legacy/"
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
Quick start::
|
|
4
4
|
|
|
5
|
+
from ipaddress import IPv4Address
|
|
6
|
+
|
|
5
7
|
from pycomap import Command, Controller, EthernetTransport, discover
|
|
6
8
|
|
|
7
|
-
async with ComApClient(EthernetTransport("192.168.1.9")) as client:
|
|
9
|
+
async with ComApClient(EthernetTransport(IPv4Address("192.168.1.9"))) as client:
|
|
8
10
|
await client.authenticate("0")
|
|
9
11
|
await client.elevate_access(password)
|
|
10
12
|
result = await client.execute_command(Command.FAULT_RESET)
|
|
@@ -6,6 +6,8 @@ elevation for protected setpoints, and timezone-aware time synchronisation.
|
|
|
6
6
|
|
|
7
7
|
Typical usage::
|
|
8
8
|
|
|
9
|
+
from ipaddress import IPv4Address
|
|
10
|
+
|
|
9
11
|
import pytz
|
|
10
12
|
from pycomap import Controller
|
|
11
13
|
from pycomap.protocol import ComApClient
|
|
@@ -13,7 +15,7 @@ Typical usage::
|
|
|
13
15
|
|
|
14
16
|
tz = pytz.timezone("Europe/Kiev")
|
|
15
17
|
async with Controller(
|
|
16
|
-
ComApClient(EthernetTransport("192.168.1.9")),
|
|
18
|
+
ComApClient(EthernetTransport(IPv4Address("192.168.1.9"))),
|
|
17
19
|
access_code="0",
|
|
18
20
|
password=1234,
|
|
19
21
|
) as ctrl:
|
|
@@ -29,6 +31,7 @@ import logging
|
|
|
29
31
|
import re
|
|
30
32
|
from collections.abc import Mapping
|
|
31
33
|
from types import MappingProxyType, TracebackType
|
|
34
|
+
from typing import Self
|
|
32
35
|
|
|
33
36
|
from pycomap.alarms import AlarmRecord, parse_alarm_list
|
|
34
37
|
from pycomap.configuration import (
|
|
@@ -59,6 +62,7 @@ from pycomap.history import HistoryRecord, parse_history_record
|
|
|
59
62
|
from pycomap.protocol.client import ComApClient
|
|
60
63
|
from pycomap.protocol.commands import ControllerCommand
|
|
61
64
|
from pycomap.protocol.objects import CommunicationObject
|
|
65
|
+
from pycomap.protocol.transport import Transport
|
|
62
66
|
|
|
63
67
|
_log = logging.getLogger(__name__)
|
|
64
68
|
|
|
@@ -131,7 +135,7 @@ def _encode_setpoint_value(
|
|
|
131
135
|
return encode_raw_value(data_type, value, decimal_places)
|
|
132
136
|
|
|
133
137
|
|
|
134
|
-
class Controller:
|
|
138
|
+
class Controller[TransportT: Transport]:
|
|
135
139
|
"""High-level async client for a ComAp controller.
|
|
136
140
|
|
|
137
141
|
Fetches and caches the ``ConfigurationTable`` on [connect][pycomap.Controller.connect],
|
|
@@ -154,14 +158,14 @@ class Controller:
|
|
|
154
158
|
Read-only access::
|
|
155
159
|
|
|
156
160
|
async with Controller(
|
|
157
|
-
ComApClient(EthernetTransport("192.168.1.9")), access_code="0"
|
|
161
|
+
ComApClient(EthernetTransport(IPv4Address("192.168.1.9"))), access_code="0"
|
|
158
162
|
) as ctrl:
|
|
159
163
|
values = await ctrl.read_values()
|
|
160
164
|
|
|
161
165
|
With write access::
|
|
162
166
|
|
|
163
167
|
async with Controller(
|
|
164
|
-
ComApClient(EthernetTransport("192.168.1.9")),
|
|
168
|
+
ComApClient(EthernetTransport(IPv4Address("192.168.1.9"))),
|
|
165
169
|
access_code="0",
|
|
166
170
|
password=1234,
|
|
167
171
|
) as ctrl:
|
|
@@ -170,7 +174,7 @@ class Controller:
|
|
|
170
174
|
|
|
171
175
|
def __init__(
|
|
172
176
|
self,
|
|
173
|
-
client: ComApClient,
|
|
177
|
+
client: ComApClient[TransportT],
|
|
174
178
|
access_code: str,
|
|
175
179
|
password: int | None = None,
|
|
176
180
|
include_invisible: bool = False,
|
|
@@ -205,7 +209,7 @@ class Controller:
|
|
|
205
209
|
"""Close the underlying transport."""
|
|
206
210
|
await self._client.close()
|
|
207
211
|
|
|
208
|
-
async def __aenter__(self) ->
|
|
212
|
+
async def __aenter__(self) -> Self:
|
|
209
213
|
await self.connect()
|
|
210
214
|
return self
|
|
211
215
|
|
|
@@ -220,7 +224,7 @@ class Controller:
|
|
|
220
224
|
# -- properties ----------------------------------------------------------
|
|
221
225
|
|
|
222
226
|
@property
|
|
223
|
-
def client(self) -> ComApClient:
|
|
227
|
+
def client(self) -> ComApClient[TransportT]:
|
|
224
228
|
"""The underlying low-level client (escape hatch for direct comm object access)."""
|
|
225
229
|
return self._client
|
|
226
230
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""UDP discovery of ComAp controllers on the local network.
|
|
2
2
|
|
|
3
3
|
See ``docs/protocol.md`` section 1. InteliConfig broadcasts a probe to
|
|
4
|
-
``<broadcast>:2413
|
|
4
|
+
``<subnet-broadcast>:2413`` (e.g. ``192.168.1.255``, never the global ``255.255.255.255``);
|
|
5
|
+
controllers reply unicast from port 2413. Despite living on its own
|
|
5
6
|
UDP port, the probe and reply are not a bespoke discovery-only format — they're a regular
|
|
6
7
|
[pycomap.protocol.framing.Message][] (the exact same CRC16-validated `EthernetMessage`
|
|
7
8
|
framing used by the TCP protocol on port 23): a ``SendMe`` for the ``Discovery``
|
|
@@ -18,10 +19,9 @@ from __future__ import annotations
|
|
|
18
19
|
import asyncio
|
|
19
20
|
import enum
|
|
20
21
|
import logging
|
|
21
|
-
import socket
|
|
22
22
|
import struct
|
|
23
23
|
from dataclasses import dataclass, field
|
|
24
|
-
from ipaddress import IPv4Address
|
|
24
|
+
from ipaddress import IPv4Address, IPv4Interface
|
|
25
25
|
|
|
26
26
|
from pycomap.exceptions import ComApProtocolError
|
|
27
27
|
from pycomap.protocol.framing import Operation, build_inner, parse_inner
|
|
@@ -119,7 +119,10 @@ def _parse_device(data: bytes) -> DiscoveryDevice:
|
|
|
119
119
|
is_units_list_complete = bool(data[offset])
|
|
120
120
|
offset += 1
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
if offset != header_size:
|
|
123
|
+
raise ComApProtocolError(
|
|
124
|
+
f"discovery device header parse offset mismatch: got {offset}, expected {header_size}"
|
|
125
|
+
)
|
|
123
126
|
units = [
|
|
124
127
|
DiscoveryUnit(
|
|
125
128
|
type=data[i],
|
|
@@ -146,11 +149,62 @@ def _parse_device(data: bytes) -> DiscoveryDevice:
|
|
|
146
149
|
)
|
|
147
150
|
|
|
148
151
|
|
|
152
|
+
class _DiscoveryProtocol(asyncio.DatagramProtocol):
|
|
153
|
+
"""Collects decoded ``Discovery`` replies, keyed by replying IP address."""
|
|
154
|
+
|
|
155
|
+
def __init__(self, found: dict[IPv4Address, DiscoveryDevice]) -> None:
|
|
156
|
+
self._found = found
|
|
157
|
+
|
|
158
|
+
def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
|
|
159
|
+
try:
|
|
160
|
+
message = parse_inner(data)
|
|
161
|
+
except ComApProtocolError:
|
|
162
|
+
return
|
|
163
|
+
if message.comm_obj != CommunicationObject.DISCOVERY:
|
|
164
|
+
return
|
|
165
|
+
if message.is_error or not message.data:
|
|
166
|
+
return
|
|
167
|
+
device = _parse_device(message.data)
|
|
168
|
+
self._found[device.ip] = device
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def _send_probe(
|
|
172
|
+
loop: asyncio.AbstractEventLoop,
|
|
173
|
+
local_ip: IPv4Address,
|
|
174
|
+
destination: IPv4Address,
|
|
175
|
+
found: dict[IPv4Address, DiscoveryDevice],
|
|
176
|
+
*,
|
|
177
|
+
allow_broadcast: bool = False,
|
|
178
|
+
) -> asyncio.DatagramTransport:
|
|
179
|
+
"""Bind a socket to ``local_ip`` and send a discovery probe to ``destination``.
|
|
180
|
+
|
|
181
|
+
Replies land in ``found`` (keyed by replying IP) as they arrive; the caller is responsible
|
|
182
|
+
for waiting out the collection window and closing the returned transport.
|
|
183
|
+
"""
|
|
184
|
+
transport, _protocol = await loop.create_datagram_endpoint(
|
|
185
|
+
lambda: _DiscoveryProtocol(found),
|
|
186
|
+
local_addr=(str(local_ip), 0),
|
|
187
|
+
allow_broadcast=allow_broadcast,
|
|
188
|
+
)
|
|
189
|
+
_log.debug("sending discovery probe from %s to %s:%d", local_ip, destination, DISCOVERY_PORT)
|
|
190
|
+
transport.sendto(_build_probe(), (str(destination), DISCOVERY_PORT))
|
|
191
|
+
return transport
|
|
192
|
+
|
|
193
|
+
|
|
149
194
|
async def discover(
|
|
195
|
+
*interfaces: IPv4Interface,
|
|
150
196
|
timeout: float = 2.0,
|
|
151
|
-
broadcast_address: str | IPv4Address = "255.255.255.255",
|
|
152
197
|
) -> list[DiscoveryDevice]:
|
|
153
|
-
"""Broadcast a discovery probe and collect replies for
|
|
198
|
+
"""Broadcast a discovery probe from each of ``interfaces`` and collect replies for
|
|
199
|
+
``timeout`` seconds.
|
|
200
|
+
|
|
201
|
+
On a host with multiple NICs on different subnets, a probe sent to the global broadcast
|
|
202
|
+
address `255.255.255.255` only egresses via whichever interface the OS routes it through,
|
|
203
|
+
so it may never reach the controller's actual subnet — pass one `IPv4Interface` per local
|
|
204
|
+
interface you want to probe from (e.g. `IPv4Interface("192.168.1.5/24")`, your own address
|
|
205
|
+
on that subnet) to bind and broadcast on each of them; this module doesn't enumerate
|
|
206
|
+
network interfaces itself. Pass `IPv4Interface("0.0.0.0/0")` to fall back to the wildcard
|
|
207
|
+
bind + global broadcast address on a single-NIC host.
|
|
154
208
|
|
|
155
209
|
Returns one [DiscoveryDevice][pycomap.discovery.DiscoveryDevice] per distinct
|
|
156
210
|
replying IP address. Malformed or unrelated UDP traffic on the same port
|
|
@@ -158,39 +212,40 @@ async def discover(
|
|
|
158
212
|
silently skipped.
|
|
159
213
|
"""
|
|
160
214
|
loop = asyncio.get_running_loop()
|
|
161
|
-
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
162
|
-
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
|
163
|
-
sock.setblocking(False)
|
|
164
|
-
|
|
165
215
|
found: dict[IPv4Address, DiscoveryDevice] = {}
|
|
216
|
+
transports = await asyncio.gather(
|
|
217
|
+
*(
|
|
218
|
+
_send_probe(
|
|
219
|
+
loop, interface.ip, interface.network.broadcast_address, found, allow_broadcast=True
|
|
220
|
+
)
|
|
221
|
+
for interface in interfaces
|
|
222
|
+
)
|
|
223
|
+
)
|
|
166
224
|
try:
|
|
167
|
-
|
|
168
|
-
_log.debug("sending discovery probe to %s:%d", broadcast_address, DISCOVERY_PORT)
|
|
169
|
-
sock.sendto(_build_probe(), (str(broadcast_address), DISCOVERY_PORT))
|
|
170
|
-
|
|
171
|
-
end_time = loop.time() + timeout
|
|
172
|
-
while True:
|
|
173
|
-
remaining = end_time - loop.time()
|
|
174
|
-
if remaining <= 0:
|
|
175
|
-
break
|
|
176
|
-
try:
|
|
177
|
-
payload, _peer_addr = await asyncio.wait_for(
|
|
178
|
-
loop.sock_recvfrom(sock, 4096), timeout=remaining
|
|
179
|
-
)
|
|
180
|
-
except TimeoutError:
|
|
181
|
-
break
|
|
182
|
-
try:
|
|
183
|
-
message = parse_inner(payload)
|
|
184
|
-
except ComApProtocolError:
|
|
185
|
-
continue
|
|
186
|
-
if message.comm_obj != CommunicationObject.DISCOVERY:
|
|
187
|
-
continue
|
|
188
|
-
if message.is_error or not message.data:
|
|
189
|
-
continue
|
|
190
|
-
device = _parse_device(message.data)
|
|
191
|
-
found[device.ip] = device
|
|
225
|
+
await asyncio.sleep(timeout)
|
|
192
226
|
finally:
|
|
193
|
-
|
|
227
|
+
for transport in transports:
|
|
228
|
+
transport.close()
|
|
194
229
|
|
|
195
230
|
_log.info("discovery found %d device(s)", len(found))
|
|
196
231
|
return list(found.values())
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
async def discover_host(ip: IPv4Address, timeout: float = 2.0) -> DiscoveryDevice | None:
|
|
235
|
+
"""Send a discovery probe directly (unicast) to a known ``ip`` and wait up to ``timeout``
|
|
236
|
+
seconds for its reply.
|
|
237
|
+
|
|
238
|
+
Useful when the controller's address is already known, so there's no need to broadcast
|
|
239
|
+
and no ambiguity about which local interface/subnet to use.
|
|
240
|
+
|
|
241
|
+
Returns `None` if ``ip`` doesn't reply within ``timeout``.
|
|
242
|
+
"""
|
|
243
|
+
loop = asyncio.get_running_loop()
|
|
244
|
+
found: dict[IPv4Address, DiscoveryDevice] = {}
|
|
245
|
+
transport = await _send_probe(loop, IPv4Address("0.0.0.0"), ip, found)
|
|
246
|
+
try:
|
|
247
|
+
await asyncio.sleep(timeout)
|
|
248
|
+
finally:
|
|
249
|
+
transport.close()
|
|
250
|
+
|
|
251
|
+
return found.get(ip)
|
|
@@ -6,9 +6,11 @@ the trickiest details (read/write key-format asymmetry, the single shared IV cha
|
|
|
6
6
|
|
|
7
7
|
Typical usage::
|
|
8
8
|
|
|
9
|
+
from ipaddress import IPv4Address
|
|
10
|
+
|
|
9
11
|
from pycomap.protocol.transport import EthernetTransport
|
|
10
12
|
|
|
11
|
-
async with ComApClient(EthernetTransport("192.168.1.9")) as client:
|
|
13
|
+
async with ComApClient(EthernetTransport(IPv4Address("192.168.1.9"))) as client:
|
|
12
14
|
await client.authenticate("0")
|
|
13
15
|
values = await client.read_object(CommunicationObject.VALUES_ALL)
|
|
14
16
|
"""
|
|
@@ -21,6 +23,7 @@ import hashlib
|
|
|
21
23
|
import logging
|
|
22
24
|
import struct
|
|
23
25
|
from types import TracebackType
|
|
26
|
+
from typing import Self
|
|
24
27
|
|
|
25
28
|
from pycomap.datatypes import decode_fdate, decode_ftime, encode_fdate, encode_ftime
|
|
26
29
|
from pycomap.exceptions import (
|
|
@@ -61,18 +64,25 @@ _AUTH_FALLBACK_CODES = {
|
|
|
61
64
|
_log = logging.getLogger(__name__)
|
|
62
65
|
|
|
63
66
|
|
|
64
|
-
class ComApClient:
|
|
67
|
+
class ComApClient[TransportT: Transport]:
|
|
65
68
|
"""Speaks the ECDH/AES-encrypted ``EthernetMessage`` protocol over any ``Transport``.
|
|
66
69
|
|
|
70
|
+
Generic over the concrete ``Transport`` type, so
|
|
71
|
+
[transport][pycomap.protocol.ComApClient.transport] gives back the type you passed in
|
|
72
|
+
(e.g. ``ComApClient[EthernetTransport]`` exposes ``.transport.host``) instead of the
|
|
73
|
+
narrower structural ``Transport`` protocol.
|
|
74
|
+
|
|
67
75
|
Pass any ``Transport`` implementation — typically ``EthernetTransport``::
|
|
68
76
|
|
|
77
|
+
from ipaddress import IPv4Address
|
|
78
|
+
|
|
69
79
|
from pycomap.protocol.transport import EthernetTransport
|
|
70
80
|
|
|
71
|
-
async with ComApClient(EthernetTransport("192.168.1.9")) as client:
|
|
81
|
+
async with ComApClient(EthernetTransport(IPv4Address("192.168.1.9"))) as client:
|
|
72
82
|
await client.authenticate("0")
|
|
73
83
|
"""
|
|
74
84
|
|
|
75
|
-
def __init__(self, transport:
|
|
85
|
+
def __init__(self, transport: TransportT) -> None:
|
|
76
86
|
"""
|
|
77
87
|
Args:
|
|
78
88
|
transport: Byte-stream transport to use (typically ``EthernetTransport``).
|
|
@@ -82,6 +92,11 @@ class ComApClient:
|
|
|
82
92
|
self._mode = _Mode.NONE
|
|
83
93
|
self._cipher: ChainedAesCbc | None = None
|
|
84
94
|
|
|
95
|
+
@property
|
|
96
|
+
def transport(self) -> TransportT:
|
|
97
|
+
"""The underlying byte-stream transport."""
|
|
98
|
+
return self._transport
|
|
99
|
+
|
|
85
100
|
# -- connection lifecycle -------------------------------------------------
|
|
86
101
|
|
|
87
102
|
async def connect(self) -> None:
|
|
@@ -186,7 +201,7 @@ class ComApClient:
|
|
|
186
201
|
self._mode = _Mode.NONE
|
|
187
202
|
self._cipher = None
|
|
188
203
|
|
|
189
|
-
async def __aenter__(self) ->
|
|
204
|
+
async def __aenter__(self) -> Self:
|
|
190
205
|
await self.connect()
|
|
191
206
|
return self
|
|
192
207
|
|
|
@@ -344,7 +359,8 @@ class ComApClient:
|
|
|
344
359
|
|
|
345
360
|
if self._mode is _Mode.ALIGNED:
|
|
346
361
|
return block_payload
|
|
347
|
-
|
|
362
|
+
if self._cipher is None:
|
|
363
|
+
raise ComApProtocolError("AES mode active but cipher not initialized")
|
|
348
364
|
return self._cipher.decrypt(block_payload)
|
|
349
365
|
|
|
350
366
|
async def _write_inner(self, inner: bytes) -> None:
|
|
@@ -354,5 +370,6 @@ class ComApClient:
|
|
|
354
370
|
elif self._mode is _Mode.ALIGNED:
|
|
355
371
|
await self._transport.write(wrap_outer(padded))
|
|
356
372
|
else:
|
|
357
|
-
|
|
373
|
+
if self._cipher is None:
|
|
374
|
+
raise ComApProtocolError("AES mode active but cipher not initialized")
|
|
358
375
|
await self._transport.write(wrap_outer(self._cipher.encrypt(padded)))
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
"""Transport layer abstraction for the ComAp native protocol.
|
|
2
2
|
|
|
3
|
-
``Transport`` is
|
|
4
|
-
methods
|
|
5
|
-
|
|
6
|
-
without changing ``ComApClient``.
|
|
3
|
+
``Transport`` is an ``abc.ABC`` — implementations must subclass it and provide the four
|
|
4
|
+
async methods. ``EthernetTransport`` is the only implementation for now; AirGate (cloud
|
|
5
|
+
relay) and serial transports can be added later without changing ``ComApClient``.
|
|
7
6
|
"""
|
|
8
7
|
|
|
9
8
|
from __future__ import annotations
|
|
@@ -11,8 +10,8 @@ from __future__ import annotations
|
|
|
11
10
|
import asyncio
|
|
12
11
|
import contextlib
|
|
13
12
|
import logging
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
14
|
from ipaddress import IPv4Address
|
|
15
|
-
from typing import Protocol, runtime_checkable
|
|
16
15
|
|
|
17
16
|
from pycomap.exceptions import ComApConnectionError
|
|
18
17
|
|
|
@@ -21,49 +20,60 @@ DEFAULT_PORT = 23
|
|
|
21
20
|
_log = logging.getLogger(__name__)
|
|
22
21
|
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
class Transport(Protocol):
|
|
23
|
+
class Transport(ABC):
|
|
26
24
|
"""Byte-stream transport used by ``ComApClient``."""
|
|
27
25
|
|
|
26
|
+
@abstractmethod
|
|
28
27
|
async def connect(self) -> None:
|
|
29
28
|
"""Open the underlying connection."""
|
|
30
|
-
...
|
|
31
29
|
|
|
30
|
+
@abstractmethod
|
|
32
31
|
async def close(self) -> None:
|
|
33
32
|
"""Close the underlying connection, suppressing already-closed errors."""
|
|
34
|
-
...
|
|
35
33
|
|
|
34
|
+
@abstractmethod
|
|
36
35
|
async def read_exactly(self, n: int) -> bytes:
|
|
37
36
|
"""Read exactly ``n`` bytes, raising ``ComApConnectionError`` if the stream ends."""
|
|
38
|
-
...
|
|
39
37
|
|
|
38
|
+
@abstractmethod
|
|
40
39
|
async def write(self, data: bytes) -> None:
|
|
41
40
|
"""Write ``data`` and flush."""
|
|
42
|
-
...
|
|
43
41
|
|
|
44
42
|
|
|
45
|
-
class EthernetTransport:
|
|
43
|
+
class EthernetTransport(Transport):
|
|
46
44
|
"""TCP transport for the ComAp native protocol (port 23).
|
|
47
45
|
|
|
48
46
|
Connects to ``host:port`` via plain TCP; the ``ComApClient`` layer adds the
|
|
49
47
|
ECDH/AES framing on top.
|
|
50
48
|
"""
|
|
51
49
|
|
|
52
|
-
def __init__(self, host: IPv4Address
|
|
50
|
+
def __init__(self, host: IPv4Address, port: int = DEFAULT_PORT) -> None:
|
|
53
51
|
"""
|
|
54
52
|
Args:
|
|
55
|
-
host: Controller IP address
|
|
53
|
+
host: Controller IP address. Exposed back via
|
|
54
|
+
[host][pycomap.protocol.EthernetTransport.host] for e.g. probing reachability
|
|
55
|
+
via [discover_host][pycomap.discovery.discover_host].
|
|
56
56
|
port: TCP port; defaults to ``23`` (ComAp native protocol port).
|
|
57
57
|
"""
|
|
58
|
-
self._host =
|
|
58
|
+
self._host = host
|
|
59
59
|
self._port = port
|
|
60
60
|
self._reader: asyncio.StreamReader | None = None
|
|
61
61
|
self._writer: asyncio.StreamWriter | None = None
|
|
62
62
|
|
|
63
|
+
@property
|
|
64
|
+
def host(self) -> IPv4Address:
|
|
65
|
+
"""The controller's configured IP address."""
|
|
66
|
+
return self._host
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def port(self) -> int:
|
|
70
|
+
"""The controller's configured TCP port."""
|
|
71
|
+
return self._port
|
|
72
|
+
|
|
63
73
|
async def connect(self) -> None:
|
|
64
74
|
_log.debug("connecting to %s:%d", self._host, self._port)
|
|
65
75
|
try:
|
|
66
|
-
self._reader, self._writer = await asyncio.open_connection(self._host, self._port)
|
|
76
|
+
self._reader, self._writer = await asyncio.open_connection(str(self._host), self._port)
|
|
67
77
|
except OSError as exc:
|
|
68
78
|
raise ComApConnectionError(f"failed to connect to {self._host}:{self._port}") from exc
|
|
69
79
|
_log.info("connected to %s:%d", self._host, self._port)
|
|
@@ -78,13 +88,15 @@ class EthernetTransport:
|
|
|
78
88
|
self._writer = None
|
|
79
89
|
|
|
80
90
|
async def read_exactly(self, n: int) -> bytes:
|
|
81
|
-
|
|
91
|
+
if self._reader is None:
|
|
92
|
+
raise ComApConnectionError("not connected — call connect() first")
|
|
82
93
|
try:
|
|
83
94
|
return await self._reader.readexactly(n)
|
|
84
95
|
except asyncio.IncompleteReadError as exc:
|
|
85
96
|
raise ComApConnectionError("connection closed while reading a message") from exc
|
|
86
97
|
|
|
87
98
|
async def write(self, data: bytes) -> None:
|
|
88
|
-
|
|
99
|
+
if self._writer is None:
|
|
100
|
+
raise ComApConnectionError("not connected — call connect() first")
|
|
89
101
|
self._writer.write(data)
|
|
90
102
|
await self._writer.drain()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|