pycomap 2.0.0__tar.gz → 2.1.1__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: 2.0.0
3
+ Version: 2.1.1
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>
@@ -50,7 +50,7 @@ async with Controller(
50
50
  await ctrl.set_setpoint("Summer Time Mode", "Winter") # STRING_LIST by label
51
51
  ```
52
52
 
53
- See the [API docs](https://igor-panteleev.github.io/pycomap/) for full reference. `just docs-serve` to browse locally.
53
+ See the [API docs](https://igor-panteleev.github.io/pycomap/) for full reference, built with [Zensical](https://zensical.org/). `just docs-serve` to browse locally.
54
54
 
55
55
  ## Development
56
56
 
@@ -23,7 +23,7 @@ async with Controller(
23
23
  await ctrl.set_setpoint("Summer Time Mode", "Winter") # STRING_LIST by label
24
24
  ```
25
25
 
26
- See the [API docs](https://igor-panteleev.github.io/pycomap/) for full reference. `just docs-serve` to browse locally.
26
+ See the [API docs](https://igor-panteleev.github.io/pycomap/) for full reference, built with [Zensical](https://zensical.org/). `just docs-serve` to browse locally.
27
27
 
28
28
  ## Development
29
29
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pycomap"
3
- version = "2.0.0"
3
+ version = "2.1.1"
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,15 +33,16 @@ 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
+ "configargparse>=1.7.5",
41
42
  "ifaddr>=0.2.0",
42
- "mkdocs>=1.6,<2",
43
- "mkdocs-material>=9.5",
44
- "mkdocstrings[python]>=0.25",
43
+ "python-dotenv>=1.2.2",
44
+ "zensical>=0.0.50",
45
+ "mkdocstrings-python>=1.0",
45
46
  "pre-commit>=4.6.0",
46
47
  "pytest>=8",
47
48
  "pytest-asyncio>=0.24",
@@ -90,4 +91,5 @@ known-first-party = ["pycomap"]
90
91
  python-version = "3.14"
91
92
 
92
93
  [tool.uv]
94
+ required-version = ">=0.11.24"
93
95
  publish-url = "https://upload.pypi.org/legacy/"
@@ -31,6 +31,7 @@ import logging
31
31
  import re
32
32
  from collections.abc import Mapping
33
33
  from types import MappingProxyType, TracebackType
34
+ from typing import Self
34
35
 
35
36
  from pycomap.alarms import AlarmRecord, parse_alarm_list
36
37
  from pycomap.configuration import (
@@ -61,6 +62,7 @@ from pycomap.history import HistoryRecord, parse_history_record
61
62
  from pycomap.protocol.client import ComApClient
62
63
  from pycomap.protocol.commands import ControllerCommand
63
64
  from pycomap.protocol.objects import CommunicationObject
65
+ from pycomap.protocol.transport import Transport
64
66
 
65
67
  _log = logging.getLogger(__name__)
66
68
 
@@ -133,7 +135,7 @@ def _encode_setpoint_value(
133
135
  return encode_raw_value(data_type, value, decimal_places)
134
136
 
135
137
 
136
- class Controller:
138
+ class Controller[TransportT: Transport]:
137
139
  """High-level async client for a ComAp controller.
138
140
 
139
141
  Fetches and caches the ``ConfigurationTable`` on [connect][pycomap.Controller.connect],
@@ -172,7 +174,7 @@ class Controller:
172
174
 
173
175
  def __init__(
174
176
  self,
175
- client: ComApClient,
177
+ client: ComApClient[TransportT],
176
178
  access_code: str,
177
179
  password: int | None = None,
178
180
  include_invisible: bool = False,
@@ -207,7 +209,7 @@ class Controller:
207
209
  """Close the underlying transport."""
208
210
  await self._client.close()
209
211
 
210
- async def __aenter__(self) -> Controller:
212
+ async def __aenter__(self) -> Self:
211
213
  await self.connect()
212
214
  return self
213
215
 
@@ -222,7 +224,7 @@ class Controller:
222
224
  # -- properties ----------------------------------------------------------
223
225
 
224
226
  @property
225
- def client(self) -> ComApClient:
227
+ def client(self) -> ComApClient[TransportT]:
226
228
  """The underlying low-level client (escape hatch for direct comm object access)."""
227
229
  return self._client
228
230
 
@@ -317,6 +319,51 @@ class Controller:
317
319
  except KeyError:
318
320
  raise KeyError(f"no setpoint named {name_or_number!r}") from None
319
321
 
322
+ def _string_list_options(
323
+ self, desc: ValueDescription | SetpointDescription
324
+ ) -> list[tuple[int, str]]:
325
+ """Shared ``STRING_LIST`` option lookup for
326
+ [value_options][pycomap.Controller.value_options] and
327
+ [setpoint_options][pycomap.Controller.setpoint_options].
328
+
329
+ Options are stored in ``CommonNames`` at indices ``[low_limit .. high_limit]``.
330
+ """
331
+ if desc.data_type is not DataType.STRING_LIST:
332
+ raise ComApProtocolError(
333
+ f"{desc.name!r} is DataType.{desc.data_type.name}, not STRING_LIST"
334
+ )
335
+ return [
336
+ (wire_value, self._common_names[desc.low_limit + wire_value])
337
+ for wire_value in range(desc.high_limit - desc.low_limit + 1)
338
+ if desc.low_limit + wire_value < len(self._common_names)
339
+ ]
340
+
341
+ def value_options(self, name_or_number: str | int) -> list[tuple[int, str]]:
342
+ """Return the available options for a ``STRING_LIST`` value.
343
+
344
+ Options are stored in ``CommonNames`` at indices ``[low_limit .. high_limit]``.
345
+ The wire value (0-based) is what the controller sends on the wire and what
346
+ [value_label][pycomap.Controller.value_label] expects; the label is the string
347
+ shown on the front panel and in InteliConfig. Note that
348
+ [read_values][pycomap.Controller.read_values] resolves ``STRING_LIST`` values to
349
+ their label automatically — this method is for enumerating all possible options
350
+ up front (e.g. to build a legend or validate against known states).
351
+
352
+ Args:
353
+ name_or_number: Value name or comm object number.
354
+
355
+ Returns:
356
+ ``[(wire_value, label), ...]`` ordered by wire value.
357
+
358
+ Raises:
359
+ ComApProtocolError: If the value is not ``STRING_LIST`` type.
360
+
361
+ Examples:
362
+ >>> ctrl.value_options("Engine State")
363
+ [(0, 'Ready'), (1, 'Prestart'), (2, 'Cranking'), ...]
364
+ """
365
+ return self._string_list_options(self.value_info(name_or_number))
366
+
320
367
  def setpoint_options(self, name_or_number: str | int) -> list[tuple[int, str]]:
321
368
  """Return the available options for a ``STRING_LIST`` setpoint.
322
369
 
@@ -338,16 +385,7 @@ class Controller:
338
385
  >>> ctrl.setpoint_options("Summer Time Mode")
339
386
  [(0, 'Disabled'), (1, 'Winter'), (2, 'Summer'), (3, 'Winter-S'), (4, 'Summer-S')]
340
387
  """
341
- desc = self.setpoint_info(name_or_number)
342
- if desc.data_type is not DataType.STRING_LIST:
343
- raise ComApProtocolError(
344
- f"setpoint {desc.name!r} is DataType.{desc.data_type.name}, not STRING_LIST"
345
- )
346
- return [
347
- (wire_value, self._common_names[desc.low_limit + wire_value])
348
- for wire_value in range(desc.high_limit - desc.low_limit + 1)
349
- if desc.low_limit + wire_value < len(self._common_names)
350
- ]
388
+ return self._string_list_options(self.setpoint_info(name_or_number))
351
389
 
352
390
  def value_label(self, name_or_number: str | int, wire_value: int) -> str:
353
391
  """Return the display label for a ``STRING_LIST`` value's wire integer.
@@ -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],
@@ -23,6 +23,7 @@ import hashlib
23
23
  import logging
24
24
  import struct
25
25
  from types import TracebackType
26
+ from typing import Self
26
27
 
27
28
  from pycomap.datatypes import decode_fdate, decode_ftime, encode_fdate, encode_ftime
28
29
  from pycomap.exceptions import (
@@ -63,9 +64,14 @@ _AUTH_FALLBACK_CODES = {
63
64
  _log = logging.getLogger(__name__)
64
65
 
65
66
 
66
- class ComApClient:
67
+ class ComApClient[TransportT: Transport]:
67
68
  """Speaks the ECDH/AES-encrypted ``EthernetMessage`` protocol over any ``Transport``.
68
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
+
69
75
  Pass any ``Transport`` implementation — typically ``EthernetTransport``::
70
76
 
71
77
  from ipaddress import IPv4Address
@@ -76,7 +82,7 @@ class ComApClient:
76
82
  await client.authenticate("0")
77
83
  """
78
84
 
79
- def __init__(self, transport: Transport) -> None:
85
+ def __init__(self, transport: TransportT) -> None:
80
86
  """
81
87
  Args:
82
88
  transport: Byte-stream transport to use (typically ``EthernetTransport``).
@@ -87,7 +93,7 @@ class ComApClient:
87
93
  self._cipher: ChainedAesCbc | None = None
88
94
 
89
95
  @property
90
- def transport(self) -> Transport:
96
+ def transport(self) -> TransportT:
91
97
  """The underlying byte-stream transport."""
92
98
  return self._transport
93
99
 
@@ -195,7 +201,7 @@ class ComApClient:
195
201
  self._mode = _Mode.NONE
196
202
  self._cipher = None
197
203
 
198
- async def __aenter__(self) -> ComApClient:
204
+ async def __aenter__(self) -> Self:
199
205
  await self.connect()
200
206
  return self
201
207
 
@@ -353,7 +359,8 @@ class ComApClient:
353
359
 
354
360
  if self._mode is _Mode.ALIGNED:
355
361
  return block_payload
356
- assert self._cipher is not None
362
+ if self._cipher is None:
363
+ raise ComApProtocolError("AES mode active but cipher not initialized")
357
364
  return self._cipher.decrypt(block_payload)
358
365
 
359
366
  async def _write_inner(self, inner: bytes) -> None:
@@ -363,5 +370,6 @@ class ComApClient:
363
370
  elif self._mode is _Mode.ALIGNED:
364
371
  await self._transport.write(wrap_outer(padded))
365
372
  else:
366
- assert self._cipher is not None
373
+ if self._cipher is None:
374
+ raise ComApProtocolError("AES mode active but cipher not initialized")
367
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,28 +20,27 @@ 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
@@ -67,6 +65,11 @@ class EthernetTransport:
67
65
  """The controller's configured IP address."""
68
66
  return self._host
69
67
 
68
+ @property
69
+ def port(self) -> int:
70
+ """The controller's configured TCP port."""
71
+ return self._port
72
+
70
73
  async def connect(self) -> None:
71
74
  _log.debug("connecting to %s:%d", self._host, self._port)
72
75
  try:
@@ -85,13 +88,15 @@ class EthernetTransport:
85
88
  self._writer = None
86
89
 
87
90
  async def read_exactly(self, n: int) -> bytes:
88
- assert self._reader is not None
91
+ if self._reader is None:
92
+ raise ComApConnectionError("not connected — call connect() first")
89
93
  try:
90
94
  return await self._reader.readexactly(n)
91
95
  except asyncio.IncompleteReadError as exc:
92
96
  raise ComApConnectionError("connection closed while reading a message") from exc
93
97
 
94
98
  async def write(self, data: bytes) -> None:
95
- assert self._writer is not None
99
+ if self._writer is None:
100
+ raise ComApConnectionError("not connected — call connect() first")
96
101
  self._writer.write(data)
97
102
  await self._writer.drain()
File without changes
File without changes
File without changes
File without changes