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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycomap
3
- Version: 1.1.0
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 = "1.1.0"
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``; controllers reply unicast from port 2413. Despite living on its own
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 ``timeout`` seconds.
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
- sock.bind(("", 0))
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
- sock.close()
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 | str, port: int = DEFAULT_PORT) -> None:
52
+ def __init__(self, host: IPv4Address, port: int = DEFAULT_PORT) -> None:
53
53
  """
54
54
  Args:
55
- host: Controller IP address (``IPv4Address`` or dotted string) or hostname.
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 = str(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