pycomap 2.0.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-2.0.0 → pycomap-2.1.0}/PKG-INFO +1 -1
- {pycomap-2.0.0 → pycomap-2.1.0}/pyproject.toml +3 -2
- {pycomap-2.0.0 → pycomap-2.1.0}/src/pycomap/controller.py +6 -4
- {pycomap-2.0.0 → pycomap-2.1.0}/src/pycomap/discovery.py +4 -1
- {pycomap-2.0.0 → pycomap-2.1.0}/src/pycomap/protocol/client.py +14 -6
- {pycomap-2.0.0 → pycomap-2.1.0}/src/pycomap/protocol/transport.py +19 -14
- {pycomap-2.0.0 → pycomap-2.1.0}/README.md +0 -0
- {pycomap-2.0.0 → pycomap-2.1.0}/src/pycomap/__init__.py +0 -0
- {pycomap-2.0.0 → pycomap-2.1.0}/src/pycomap/alarms.py +0 -0
- {pycomap-2.0.0 → pycomap-2.1.0}/src/pycomap/configuration.py +0 -0
- {pycomap-2.0.0 → pycomap-2.1.0}/src/pycomap/datatypes.py +0 -0
- {pycomap-2.0.0 → pycomap-2.1.0}/src/pycomap/exceptions.py +0 -0
- {pycomap-2.0.0 → pycomap-2.1.0}/src/pycomap/history.py +0 -0
- {pycomap-2.0.0 → pycomap-2.1.0}/src/pycomap/protocol/__init__.py +0 -0
- {pycomap-2.0.0 → pycomap-2.1.0}/src/pycomap/protocol/commands.py +0 -0
- {pycomap-2.0.0 → pycomap-2.1.0}/src/pycomap/protocol/crc.py +0 -0
- {pycomap-2.0.0 → pycomap-2.1.0}/src/pycomap/protocol/crypto.py +0 -0
- {pycomap-2.0.0 → pycomap-2.1.0}/src/pycomap/protocol/framing.py +0 -0
- {pycomap-2.0.0 → pycomap-2.1.0}/src/pycomap/protocol/objects.py +0 -0
- {pycomap-2.0.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: 2.
|
|
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>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pycomap"
|
|
3
|
-
version = "2.
|
|
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,7 +33,7 @@ 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]
|
|
@@ -90,4 +90,5 @@ known-first-party = ["pycomap"]
|
|
|
90
90
|
python-version = "3.14"
|
|
91
91
|
|
|
92
92
|
[tool.uv]
|
|
93
|
+
required-version = ">=0.11.24"
|
|
93
94
|
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) ->
|
|
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
|
|
|
@@ -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],
|
|
@@ -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:
|
|
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) ->
|
|
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) ->
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,28 +20,27 @@ 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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|