pycomap 1.1.0__tar.gz → 2.0.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.0.0}/PKG-INFO +4 -2
- {pycomap-1.1.0 → pycomap-2.0.0}/README.md +3 -1
- {pycomap-1.1.0 → pycomap-2.0.0}/pyproject.toml +2 -1
- {pycomap-1.1.0 → pycomap-2.0.0}/src/pycomap/__init__.py +3 -1
- {pycomap-1.1.0 → pycomap-2.0.0}/src/pycomap/controller.py +5 -3
- {pycomap-1.1.0 → pycomap-2.0.0}/src/pycomap/discovery.py +87 -35
- {pycomap-1.1.0 → pycomap-2.0.0}/src/pycomap/protocol/client.py +11 -2
- {pycomap-1.1.0 → pycomap-2.0.0}/src/pycomap/protocol/transport.py +11 -4
- {pycomap-1.1.0 → pycomap-2.0.0}/src/pycomap/alarms.py +0 -0
- {pycomap-1.1.0 → pycomap-2.0.0}/src/pycomap/configuration.py +0 -0
- {pycomap-1.1.0 → pycomap-2.0.0}/src/pycomap/datatypes.py +0 -0
- {pycomap-1.1.0 → pycomap-2.0.0}/src/pycomap/exceptions.py +0 -0
- {pycomap-1.1.0 → pycomap-2.0.0}/src/pycomap/history.py +0 -0
- {pycomap-1.1.0 → pycomap-2.0.0}/src/pycomap/protocol/__init__.py +0 -0
- {pycomap-1.1.0 → pycomap-2.0.0}/src/pycomap/protocol/commands.py +0 -0
- {pycomap-1.1.0 → pycomap-2.0.0}/src/pycomap/protocol/crc.py +0 -0
- {pycomap-1.1.0 → pycomap-2.0.0}/src/pycomap/protocol/crypto.py +0 -0
- {pycomap-1.1.0 → pycomap-2.0.0}/src/pycomap/protocol/framing.py +0 -0
- {pycomap-1.1.0 → pycomap-2.0.0}/src/pycomap/protocol/objects.py +0 -0
- {pycomap-1.1.0 → pycomap-2.0.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.0.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.0.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"
|
|
@@ -38,6 +38,7 @@ 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",
|
|
@@ -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:
|
|
@@ -154,14 +156,14 @@ class Controller:
|
|
|
154
156
|
Read-only access::
|
|
155
157
|
|
|
156
158
|
async with Controller(
|
|
157
|
-
ComApClient(EthernetTransport("192.168.1.9")), access_code="0"
|
|
159
|
+
ComApClient(EthernetTransport(IPv4Address("192.168.1.9"))), access_code="0"
|
|
158
160
|
) as ctrl:
|
|
159
161
|
values = await ctrl.read_values()
|
|
160
162
|
|
|
161
163
|
With write access::
|
|
162
164
|
|
|
163
165
|
async with Controller(
|
|
164
|
-
ComApClient(EthernetTransport("192.168.1.9")),
|
|
166
|
+
ComApClient(EthernetTransport(IPv4Address("192.168.1.9"))),
|
|
165
167
|
access_code="0",
|
|
166
168
|
password=1234,
|
|
167
169
|
) as ctrl:
|
|
@@ -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
|
|
@@ -146,11 +146,62 @@ def _parse_device(data: bytes) -> DiscoveryDevice:
|
|
|
146
146
|
)
|
|
147
147
|
|
|
148
148
|
|
|
149
|
+
class _DiscoveryProtocol(asyncio.DatagramProtocol):
|
|
150
|
+
"""Collects decoded ``Discovery`` replies, keyed by replying IP address."""
|
|
151
|
+
|
|
152
|
+
def __init__(self, found: dict[IPv4Address, DiscoveryDevice]) -> None:
|
|
153
|
+
self._found = found
|
|
154
|
+
|
|
155
|
+
def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
|
|
156
|
+
try:
|
|
157
|
+
message = parse_inner(data)
|
|
158
|
+
except ComApProtocolError:
|
|
159
|
+
return
|
|
160
|
+
if message.comm_obj != CommunicationObject.DISCOVERY:
|
|
161
|
+
return
|
|
162
|
+
if message.is_error or not message.data:
|
|
163
|
+
return
|
|
164
|
+
device = _parse_device(message.data)
|
|
165
|
+
self._found[device.ip] = device
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def _send_probe(
|
|
169
|
+
loop: asyncio.AbstractEventLoop,
|
|
170
|
+
local_ip: IPv4Address,
|
|
171
|
+
destination: IPv4Address,
|
|
172
|
+
found: dict[IPv4Address, DiscoveryDevice],
|
|
173
|
+
*,
|
|
174
|
+
allow_broadcast: bool = False,
|
|
175
|
+
) -> asyncio.DatagramTransport:
|
|
176
|
+
"""Bind a socket to ``local_ip`` and send a discovery probe to ``destination``.
|
|
177
|
+
|
|
178
|
+
Replies land in ``found`` (keyed by replying IP) as they arrive; the caller is responsible
|
|
179
|
+
for waiting out the collection window and closing the returned transport.
|
|
180
|
+
"""
|
|
181
|
+
transport, _protocol = await loop.create_datagram_endpoint(
|
|
182
|
+
lambda: _DiscoveryProtocol(found),
|
|
183
|
+
local_addr=(str(local_ip), 0),
|
|
184
|
+
allow_broadcast=allow_broadcast,
|
|
185
|
+
)
|
|
186
|
+
_log.debug("sending discovery probe from %s to %s:%d", local_ip, destination, DISCOVERY_PORT)
|
|
187
|
+
transport.sendto(_build_probe(), (str(destination), DISCOVERY_PORT))
|
|
188
|
+
return transport
|
|
189
|
+
|
|
190
|
+
|
|
149
191
|
async def discover(
|
|
192
|
+
*interfaces: IPv4Interface,
|
|
150
193
|
timeout: float = 2.0,
|
|
151
|
-
broadcast_address: str | IPv4Address = "255.255.255.255",
|
|
152
194
|
) -> list[DiscoveryDevice]:
|
|
153
|
-
"""Broadcast a discovery probe and collect replies for
|
|
195
|
+
"""Broadcast a discovery probe from each of ``interfaces`` and collect replies for
|
|
196
|
+
``timeout`` seconds.
|
|
197
|
+
|
|
198
|
+
On a host with multiple NICs on different subnets, a probe sent to the global broadcast
|
|
199
|
+
address `255.255.255.255` only egresses via whichever interface the OS routes it through,
|
|
200
|
+
so it may never reach the controller's actual subnet — pass one `IPv4Interface` per local
|
|
201
|
+
interface you want to probe from (e.g. `IPv4Interface("192.168.1.5/24")`, your own address
|
|
202
|
+
on that subnet) to bind and broadcast on each of them; this module doesn't enumerate
|
|
203
|
+
network interfaces itself. Pass `IPv4Interface("0.0.0.0/0")` to fall back to the wildcard
|
|
204
|
+
bind + global broadcast address on a single-NIC host.
|
|
154
205
|
|
|
155
206
|
Returns one [DiscoveryDevice][pycomap.discovery.DiscoveryDevice] per distinct
|
|
156
207
|
replying IP address. Malformed or unrelated UDP traffic on the same port
|
|
@@ -158,39 +209,40 @@ async def discover(
|
|
|
158
209
|
silently skipped.
|
|
159
210
|
"""
|
|
160
211
|
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
212
|
found: dict[IPv4Address, DiscoveryDevice] = {}
|
|
213
|
+
transports = await asyncio.gather(
|
|
214
|
+
*(
|
|
215
|
+
_send_probe(
|
|
216
|
+
loop, interface.ip, interface.network.broadcast_address, found, allow_broadcast=True
|
|
217
|
+
)
|
|
218
|
+
for interface in interfaces
|
|
219
|
+
)
|
|
220
|
+
)
|
|
166
221
|
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
|
|
222
|
+
await asyncio.sleep(timeout)
|
|
192
223
|
finally:
|
|
193
|
-
|
|
224
|
+
for transport in transports:
|
|
225
|
+
transport.close()
|
|
194
226
|
|
|
195
227
|
_log.info("discovery found %d device(s)", len(found))
|
|
196
228
|
return list(found.values())
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
async def discover_host(ip: IPv4Address, timeout: float = 2.0) -> DiscoveryDevice | None:
|
|
232
|
+
"""Send a discovery probe directly (unicast) to a known ``ip`` and wait up to ``timeout``
|
|
233
|
+
seconds for its reply.
|
|
234
|
+
|
|
235
|
+
Useful when the controller's address is already known, so there's no need to broadcast
|
|
236
|
+
and no ambiguity about which local interface/subnet to use.
|
|
237
|
+
|
|
238
|
+
Returns `None` if ``ip`` doesn't reply within ``timeout``.
|
|
239
|
+
"""
|
|
240
|
+
loop = asyncio.get_running_loop()
|
|
241
|
+
found: dict[IPv4Address, DiscoveryDevice] = {}
|
|
242
|
+
transport = await _send_probe(loop, IPv4Address("0.0.0.0"), ip, found)
|
|
243
|
+
try:
|
|
244
|
+
await asyncio.sleep(timeout)
|
|
245
|
+
finally:
|
|
246
|
+
transport.close()
|
|
247
|
+
|
|
248
|
+
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
|
"""
|
|
@@ -66,9 +68,11 @@ class ComApClient:
|
|
|
66
68
|
|
|
67
69
|
Pass any ``Transport`` implementation — typically ``EthernetTransport``::
|
|
68
70
|
|
|
71
|
+
from ipaddress import IPv4Address
|
|
72
|
+
|
|
69
73
|
from pycomap.protocol.transport import EthernetTransport
|
|
70
74
|
|
|
71
|
-
async with ComApClient(EthernetTransport("192.168.1.9")) as client:
|
|
75
|
+
async with ComApClient(EthernetTransport(IPv4Address("192.168.1.9"))) as client:
|
|
72
76
|
await client.authenticate("0")
|
|
73
77
|
"""
|
|
74
78
|
|
|
@@ -82,6 +86,11 @@ class ComApClient:
|
|
|
82
86
|
self._mode = _Mode.NONE
|
|
83
87
|
self._cipher: ChainedAesCbc | None = None
|
|
84
88
|
|
|
89
|
+
@property
|
|
90
|
+
def transport(self) -> Transport:
|
|
91
|
+
"""The underlying byte-stream transport."""
|
|
92
|
+
return self._transport
|
|
93
|
+
|
|
85
94
|
# -- connection lifecycle -------------------------------------------------
|
|
86
95
|
|
|
87
96
|
async def connect(self) -> None:
|
|
@@ -49,21 +49,28 @@ class EthernetTransport:
|
|
|
49
49
|
ECDH/AES framing on top.
|
|
50
50
|
"""
|
|
51
51
|
|
|
52
|
-
def __init__(self, host: IPv4Address
|
|
52
|
+
def __init__(self, host: IPv4Address, port: int = DEFAULT_PORT) -> None:
|
|
53
53
|
"""
|
|
54
54
|
Args:
|
|
55
|
-
host: Controller IP address
|
|
55
|
+
host: Controller IP address. Exposed back via
|
|
56
|
+
[host][pycomap.protocol.EthernetTransport.host] for e.g. probing reachability
|
|
57
|
+
via [discover_host][pycomap.discovery.discover_host].
|
|
56
58
|
port: TCP port; defaults to ``23`` (ComAp native protocol port).
|
|
57
59
|
"""
|
|
58
|
-
self._host =
|
|
60
|
+
self._host = host
|
|
59
61
|
self._port = port
|
|
60
62
|
self._reader: asyncio.StreamReader | None = None
|
|
61
63
|
self._writer: asyncio.StreamWriter | None = None
|
|
62
64
|
|
|
65
|
+
@property
|
|
66
|
+
def host(self) -> IPv4Address:
|
|
67
|
+
"""The controller's configured IP address."""
|
|
68
|
+
return self._host
|
|
69
|
+
|
|
63
70
|
async def connect(self) -> None:
|
|
64
71
|
_log.debug("connecting to %s:%d", self._host, self._port)
|
|
65
72
|
try:
|
|
66
|
-
self._reader, self._writer = await asyncio.open_connection(self._host, self._port)
|
|
73
|
+
self._reader, self._writer = await asyncio.open_connection(str(self._host), self._port)
|
|
67
74
|
except OSError as exc:
|
|
68
75
|
raise ComApConnectionError(f"failed to connect to {self._host}:{self._port}") from exc
|
|
69
76
|
_log.info("connected to %s:%d", self._host, self._port)
|
|
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
|