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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycomap
3
- Version: 1.1.0
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 = "1.1.0"
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.10.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) -> Controller:
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``; 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
@@ -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
- assert offset == header_size
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 ``timeout`` seconds.
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
- 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
225
+ await asyncio.sleep(timeout)
192
226
  finally:
193
- sock.close()
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: Transport) -> None:
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) -> ComApClient:
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
- assert self._cipher is not None
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
- assert self._cipher is not None
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 a structural ``typing.Protocol`` — any object with the four async
4
- methods qualifies, no subclassing required. ``EthernetTransport`` is the only
5
- implementation for now; AirGate (cloud relay) and serial transports can be added later
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
- @runtime_checkable
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 | str, port: int = DEFAULT_PORT) -> None:
50
+ def __init__(self, host: IPv4Address, port: int = DEFAULT_PORT) -> None:
53
51
  """
54
52
  Args:
55
- host: Controller IP address (``IPv4Address`` or dotted string) or hostname.
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 = str(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
- assert self._reader is not None
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
- assert self._writer is not None
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