pycomap 1.0.2__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.0.2 → pycomap-2.0.0}/PKG-INFO +4 -2
- {pycomap-1.0.2 → pycomap-2.0.0}/README.md +3 -1
- {pycomap-1.0.2 → pycomap-2.0.0}/pyproject.toml +2 -1
- {pycomap-1.0.2 → pycomap-2.0.0}/src/pycomap/__init__.py +9 -1
- {pycomap-1.0.2 → pycomap-2.0.0}/src/pycomap/configuration.py +25 -12
- {pycomap-1.0.2 → pycomap-2.0.0}/src/pycomap/controller.py +151 -41
- {pycomap-1.0.2 → pycomap-2.0.0}/src/pycomap/datatypes.py +4 -3
- {pycomap-1.0.2 → pycomap-2.0.0}/src/pycomap/discovery.py +90 -37
- {pycomap-1.0.2 → pycomap-2.0.0}/src/pycomap/exceptions.py +8 -0
- {pycomap-1.0.2 → pycomap-2.0.0}/src/pycomap/protocol/client.py +18 -8
- {pycomap-1.0.2 → pycomap-2.0.0}/src/pycomap/protocol/transport.py +11 -3
- {pycomap-1.0.2 → pycomap-2.0.0}/src/pycomap/alarms.py +0 -0
- {pycomap-1.0.2 → pycomap-2.0.0}/src/pycomap/history.py +0 -0
- {pycomap-1.0.2 → pycomap-2.0.0}/src/pycomap/protocol/__init__.py +0 -0
- {pycomap-1.0.2 → pycomap-2.0.0}/src/pycomap/protocol/commands.py +0 -0
- {pycomap-1.0.2 → pycomap-2.0.0}/src/pycomap/protocol/crc.py +0 -0
- {pycomap-1.0.2 → pycomap-2.0.0}/src/pycomap/protocol/crypto.py +0 -0
- {pycomap-1.0.2 → pycomap-2.0.0}/src/pycomap/protocol/framing.py +0 -0
- {pycomap-1.0.2 → pycomap-2.0.0}/src/pycomap/protocol/objects.py +0 -0
- {pycomap-1.0.2 → 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)
|
|
@@ -29,12 +31,15 @@ from __future__ import annotations
|
|
|
29
31
|
import logging
|
|
30
32
|
|
|
31
33
|
from pycomap.controller import Controller
|
|
34
|
+
from pycomap.datatypes import Value
|
|
32
35
|
from pycomap.discovery import discover
|
|
33
36
|
from pycomap.exceptions import (
|
|
34
37
|
ComApAuthError,
|
|
35
38
|
ComApConnectionError,
|
|
36
39
|
ComApControllerError,
|
|
37
40
|
ComApError,
|
|
41
|
+
ComApInvalidAccessCodeError,
|
|
42
|
+
ComApInvalidPasswordError,
|
|
38
43
|
ComApProtocolError,
|
|
39
44
|
)
|
|
40
45
|
from pycomap.protocol import ComApClient, Command, ControllerCommand, EthernetTransport
|
|
@@ -47,10 +52,13 @@ __all__ = [
|
|
|
47
52
|
"ComApConnectionError",
|
|
48
53
|
"ComApControllerError",
|
|
49
54
|
"ComApError",
|
|
55
|
+
"ComApInvalidAccessCodeError",
|
|
56
|
+
"ComApInvalidPasswordError",
|
|
50
57
|
"ComApProtocolError",
|
|
51
58
|
"Command",
|
|
52
59
|
"Controller",
|
|
53
60
|
"ControllerCommand",
|
|
54
61
|
"EthernetTransport",
|
|
62
|
+
"Value",
|
|
55
63
|
"discover",
|
|
56
64
|
]
|
|
@@ -33,6 +33,7 @@ from __future__ import annotations
|
|
|
33
33
|
import dataclasses
|
|
34
34
|
import enum
|
|
35
35
|
import functools
|
|
36
|
+
import logging
|
|
36
37
|
import struct
|
|
37
38
|
from collections.abc import Sequence
|
|
38
39
|
from dataclasses import dataclass
|
|
@@ -41,12 +42,15 @@ from pycomap.datatypes import (
|
|
|
41
42
|
_DATA_TYPE_LENGTH,
|
|
42
43
|
DataType,
|
|
43
44
|
ProtectionState,
|
|
45
|
+
RawValue,
|
|
44
46
|
decode_raw_value,
|
|
45
47
|
encode_raw_value,
|
|
46
48
|
get_bits,
|
|
47
49
|
)
|
|
48
50
|
from pycomap.exceptions import ComApProtocolError
|
|
49
51
|
|
|
52
|
+
_log = logging.getLogger(__name__)
|
|
53
|
+
|
|
50
54
|
__all__ = [
|
|
51
55
|
"ConfigurationTable",
|
|
52
56
|
"NamesCategory",
|
|
@@ -465,25 +469,34 @@ def _parse_setpoints(
|
|
|
465
469
|
return setpoints
|
|
466
470
|
|
|
467
471
|
|
|
468
|
-
def decode_values_all(table: ConfigurationTable, data: bytes) -> dict[int,
|
|
472
|
+
def decode_values_all(table: ConfigurationTable, data: bytes) -> dict[int, RawValue]:
|
|
469
473
|
"""Decode a ``ValuesAll`` (or the data portion of ``ValueStatesAndDataAll``) blob.
|
|
470
474
|
|
|
471
|
-
Returns a mapping of value ``number`` -> decoded value
|
|
472
|
-
``ValueCategory.FIRST``/``SECOND``/``THIRD``
|
|
473
|
-
|
|
475
|
+
Returns a mapping of value ``number`` -> decoded value for every
|
|
476
|
+
``ValueCategory.FIRST``/``SECOND``/``THIRD`` value. ``ONE_TIME`` values are excluded
|
|
477
|
+
— they are not present in ``ValuesAll`` and must be read individually.
|
|
474
478
|
"""
|
|
475
|
-
result: dict[int,
|
|
479
|
+
result: dict[int, RawValue] = {}
|
|
476
480
|
for value in table.values:
|
|
477
481
|
if value.category is ValueCategory.ONE_TIME:
|
|
478
482
|
continue
|
|
479
|
-
|
|
483
|
+
end = value.data_index + value.data_length
|
|
484
|
+
if end > len(data):
|
|
485
|
+
_log.warning(
|
|
486
|
+
"value %r (number %d) data_index %d+%d exceeds blob size %d — skipping",
|
|
487
|
+
value.name,
|
|
488
|
+
value.number,
|
|
489
|
+
value.data_index,
|
|
490
|
+
value.data_length,
|
|
491
|
+
len(data),
|
|
492
|
+
)
|
|
493
|
+
continue
|
|
494
|
+
raw = data[value.data_index : end]
|
|
480
495
|
result[value.number] = decode_raw_value(value.data_type, raw, value.decimal_places)
|
|
481
496
|
return result
|
|
482
497
|
|
|
483
498
|
|
|
484
|
-
def decode_history_snapshot(
|
|
485
|
-
table: ConfigurationTable, snapshot: bytes
|
|
486
|
-
) -> dict[int, int | float | bytes]:
|
|
499
|
+
def decode_history_snapshot(table: ConfigurationTable, snapshot: bytes) -> dict[int, RawValue]:
|
|
487
500
|
"""Decode the value snapshot from a ``HistoryRecord.data`` field.
|
|
488
501
|
|
|
489
502
|
Alarm/event history records carry a snapshot of the first N bytes of the
|
|
@@ -497,7 +510,7 @@ def decode_history_snapshot(
|
|
|
497
510
|
[decode_values_all][pycomap.configuration.decode_values_all]. ``ONE_TIME`` values
|
|
498
511
|
are never included. Returns an empty dict if ``snapshot`` is empty (text records).
|
|
499
512
|
"""
|
|
500
|
-
result: dict[int,
|
|
513
|
+
result: dict[int, RawValue] = {}
|
|
501
514
|
for value in table.values:
|
|
502
515
|
if value.category is ValueCategory.ONE_TIME:
|
|
503
516
|
continue
|
|
@@ -509,12 +522,12 @@ def decode_history_snapshot(
|
|
|
509
522
|
return result
|
|
510
523
|
|
|
511
524
|
|
|
512
|
-
def decode_setpoints_all(table: ConfigurationTable, data: bytes) -> dict[int,
|
|
525
|
+
def decode_setpoints_all(table: ConfigurationTable, data: bytes) -> dict[int, RawValue]:
|
|
513
526
|
"""Decode a ``SetpointsAll`` blob into a mapping of setpoint ``number`` -> decoded value.
|
|
514
527
|
|
|
515
528
|
Unlike values, every setpoint (both ``P`` and ``R`` categories) is included.
|
|
516
529
|
"""
|
|
517
|
-
result: dict[int,
|
|
530
|
+
result: dict[int, RawValue] = {}
|
|
518
531
|
for setpoint in table.setpoints:
|
|
519
532
|
raw = data[setpoint.data_index : setpoint.data_index + setpoint.data_length]
|
|
520
533
|
result[setpoint.number] = decode_raw_value(setpoint.data_type, raw, setpoint.decimal_places)
|
|
@@ -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:
|
|
@@ -27,13 +29,15 @@ from __future__ import annotations
|
|
|
27
29
|
import datetime
|
|
28
30
|
import logging
|
|
29
31
|
import re
|
|
30
|
-
from
|
|
32
|
+
from collections.abc import Mapping
|
|
33
|
+
from types import MappingProxyType, TracebackType
|
|
31
34
|
|
|
32
35
|
from pycomap.alarms import AlarmRecord, parse_alarm_list
|
|
33
36
|
from pycomap.configuration import (
|
|
34
37
|
ConfigurationTable,
|
|
35
38
|
NamesCategory,
|
|
36
39
|
SetpointDescription,
|
|
40
|
+
ValueCategory,
|
|
37
41
|
ValueDescription,
|
|
38
42
|
ValueState,
|
|
39
43
|
decode_history_snapshot,
|
|
@@ -47,6 +51,8 @@ from pycomap.datatypes import (
|
|
|
47
51
|
_BINARY_TYPES,
|
|
48
52
|
_DATA_TYPE_LENGTH,
|
|
49
53
|
DataType,
|
|
54
|
+
RawValue,
|
|
55
|
+
Value,
|
|
50
56
|
decode_raw_value,
|
|
51
57
|
encode_raw_value,
|
|
52
58
|
)
|
|
@@ -109,7 +115,7 @@ def _parse_gmt_label(label: str) -> datetime.timedelta | None:
|
|
|
109
115
|
def _encode_setpoint_value(
|
|
110
116
|
data_type: DataType,
|
|
111
117
|
decimal_places: int,
|
|
112
|
-
value:
|
|
118
|
+
value: Value,
|
|
113
119
|
) -> bytes:
|
|
114
120
|
if isinstance(value, bytes):
|
|
115
121
|
return value
|
|
@@ -131,10 +137,9 @@ class Controller:
|
|
|
131
137
|
"""High-level async client for a ComAp controller.
|
|
132
138
|
|
|
133
139
|
Fetches and caches the ``ConfigurationTable`` on [connect][pycomap.Controller.connect],
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
is provided.
|
|
140
|
+
enabling name-based lookup for all subsequent calls. Password elevation for
|
|
141
|
+
write-protected setpoints is handled automatically (lazy, on first protected write)
|
|
142
|
+
when ``password`` is provided.
|
|
138
143
|
|
|
139
144
|
Args:
|
|
140
145
|
client: A ``ComApClient`` wrapping any transport. The ``Controller`` takes
|
|
@@ -143,19 +148,22 @@ class Controller:
|
|
|
143
148
|
``"0"``). Drives ECDH/AES key derivation — **not** the write password.
|
|
144
149
|
password: The write-protection password (integer 0-9999). Required for setpoints
|
|
145
150
|
with ``access_level > 0``; omit to operate in read-only mode.
|
|
151
|
+
include_invisible: When ``False`` (default), ONE_TIME values in the ``'Invisible'``
|
|
152
|
+
group are skipped during connect, saving one ``read_object`` round-trip per
|
|
153
|
+
invisible item. Set to ``True`` to cache them anyway.
|
|
146
154
|
|
|
147
155
|
Examples:
|
|
148
156
|
Read-only access::
|
|
149
157
|
|
|
150
158
|
async with Controller(
|
|
151
|
-
ComApClient(EthernetTransport("192.168.1.9")), access_code="0"
|
|
159
|
+
ComApClient(EthernetTransport(IPv4Address("192.168.1.9"))), access_code="0"
|
|
152
160
|
) as ctrl:
|
|
153
161
|
values = await ctrl.read_values()
|
|
154
162
|
|
|
155
163
|
With write access::
|
|
156
164
|
|
|
157
165
|
async with Controller(
|
|
158
|
-
ComApClient(EthernetTransport("192.168.1.9")),
|
|
166
|
+
ComApClient(EthernetTransport(IPv4Address("192.168.1.9"))),
|
|
159
167
|
access_code="0",
|
|
160
168
|
password=1234,
|
|
161
169
|
) as ctrl:
|
|
@@ -167,20 +175,25 @@ class Controller:
|
|
|
167
175
|
client: ComApClient,
|
|
168
176
|
access_code: str,
|
|
169
177
|
password: int | None = None,
|
|
178
|
+
include_invisible: bool = False,
|
|
170
179
|
) -> None:
|
|
171
180
|
self._client = client
|
|
172
181
|
self._access_code = access_code
|
|
173
182
|
self._password = password
|
|
183
|
+
self._include_invisible = include_invisible
|
|
174
184
|
self._elevated = False
|
|
175
185
|
self._config_data: bytes | None = None
|
|
176
186
|
self._table: ConfigurationTable | None = None
|
|
177
187
|
self._values_by_name: dict[str, ValueDescription] = {}
|
|
178
188
|
self._values_by_number: dict[int, ValueDescription] = {}
|
|
189
|
+
self._ambiguous_value_names: set[str] = set()
|
|
179
190
|
self._setpoints_by_name: dict[str, SetpointDescription] = {}
|
|
180
191
|
self._setpoints_by_number: dict[int, SetpointDescription] = {}
|
|
192
|
+
self._ambiguous_setpoint_names: set[str] = set()
|
|
181
193
|
self._common_names: list[str] = []
|
|
182
194
|
self._timezone: datetime.timezone = datetime.UTC
|
|
183
195
|
self._summer_time_mode_raw: int = 0
|
|
196
|
+
self._one_time_values: dict[int, Value] = {}
|
|
184
197
|
|
|
185
198
|
# -- connection lifecycle -------------------------------------------------
|
|
186
199
|
|
|
@@ -235,14 +248,22 @@ class Controller:
|
|
|
235
248
|
"""All setpoint descriptions from the cached ``ConfigurationTable``."""
|
|
236
249
|
return self.table.setpoints
|
|
237
250
|
|
|
251
|
+
@property
|
|
252
|
+
def one_time_values(self) -> Mapping[int, Value]:
|
|
253
|
+
"""Static ``ONE_TIME`` values read once at connect time.
|
|
254
|
+
|
|
255
|
+
Returns a read-only ``{comm_object_number: value}`` mapping for every ``ONE_TIME``
|
|
256
|
+
value successfully read during [connect][pycomap.Controller.connect]. Entries use
|
|
257
|
+
the same type and resolution rules as [read_values][pycomap.Controller.read_values].
|
|
258
|
+
Empty before connect; stable for the lifetime of the connection.
|
|
259
|
+
"""
|
|
260
|
+
return MappingProxyType(self._one_time_values)
|
|
261
|
+
|
|
238
262
|
# -- name / number resolution --------------------------------------------
|
|
239
263
|
|
|
240
264
|
def value_info(self, name_or_number: str | int) -> ValueDescription:
|
|
241
265
|
"""Look up a value description by name or comm object number.
|
|
242
266
|
|
|
243
|
-
Names must match exactly as returned by the controller's names heap.
|
|
244
|
-
If multiple values share a name, the first one in the table is returned.
|
|
245
|
-
|
|
246
267
|
Args:
|
|
247
268
|
name_or_number: Exact value name (e.g. ``"RPM"``) or comm object number.
|
|
248
269
|
|
|
@@ -251,12 +272,18 @@ class Controller:
|
|
|
251
272
|
|
|
252
273
|
Raises:
|
|
253
274
|
KeyError: If no value with that name or number exists.
|
|
275
|
+
ComApProtocolError: If the name is shared by multiple values — use a number instead.
|
|
254
276
|
"""
|
|
255
277
|
if isinstance(name_or_number, int):
|
|
256
278
|
try:
|
|
257
279
|
return self._values_by_number[name_or_number]
|
|
258
280
|
except KeyError:
|
|
259
281
|
raise KeyError(f"no value with number {name_or_number}") from None
|
|
282
|
+
if name_or_number in self._ambiguous_value_names:
|
|
283
|
+
raise ComApProtocolError(
|
|
284
|
+
f"value name {name_or_number!r} is ambiguous — "
|
|
285
|
+
"multiple values share this name; use a comm object number instead"
|
|
286
|
+
)
|
|
260
287
|
try:
|
|
261
288
|
return self._values_by_name[name_or_number]
|
|
262
289
|
except KeyError:
|
|
@@ -273,12 +300,18 @@ class Controller:
|
|
|
273
300
|
|
|
274
301
|
Raises:
|
|
275
302
|
KeyError: If no setpoint with that name or number exists.
|
|
303
|
+
ComApProtocolError: If the name is shared by multiple setpoints — use a number instead.
|
|
276
304
|
"""
|
|
277
305
|
if isinstance(name_or_number, int):
|
|
278
306
|
try:
|
|
279
307
|
return self._setpoints_by_number[name_or_number]
|
|
280
308
|
except KeyError:
|
|
281
309
|
raise KeyError(f"no setpoint with number {name_or_number}") from None
|
|
310
|
+
if name_or_number in self._ambiguous_setpoint_names:
|
|
311
|
+
raise ComApProtocolError(
|
|
312
|
+
f"setpoint name {name_or_number!r} is ambiguous — "
|
|
313
|
+
"multiple setpoints share this name; use a comm object number instead"
|
|
314
|
+
)
|
|
282
315
|
try:
|
|
283
316
|
return self._setpoints_by_name[name_or_number]
|
|
284
317
|
except KeyError:
|
|
@@ -377,9 +410,9 @@ class Controller:
|
|
|
377
410
|
|
|
378
411
|
def _resolve_raws(
|
|
379
412
|
self,
|
|
380
|
-
raw: dict[int,
|
|
413
|
+
raw: dict[int, RawValue],
|
|
381
414
|
by_number: dict[int, ValueDescription] | dict[int, SetpointDescription],
|
|
382
|
-
) -> dict[int,
|
|
415
|
+
) -> dict[int, Value]:
|
|
383
416
|
"""Resolve ``STRING_LIST`` wire bytes to labels and text-typed bytes to strings.
|
|
384
417
|
|
|
385
418
|
Works for both value and setpoint dicts. ``STRING_LIST`` entries arrive as 1-byte
|
|
@@ -387,7 +420,7 @@ class Controller:
|
|
|
387
420
|
Text-typed entries (``SHORT_STRING``, ``IP_ADDRESS``, etc.) arrive as null-padded
|
|
388
421
|
byte strings and are decoded to ASCII.
|
|
389
422
|
"""
|
|
390
|
-
result: dict[int,
|
|
423
|
+
result: dict[int, Value] = {}
|
|
391
424
|
for number, val in raw.items():
|
|
392
425
|
if not isinstance(val, bytes):
|
|
393
426
|
result[number] = val
|
|
@@ -402,12 +435,12 @@ class Controller:
|
|
|
402
435
|
self._common_names[idx] if idx < len(self._common_names) else str(wire)
|
|
403
436
|
)
|
|
404
437
|
elif desc.data_type in _STRING_TYPES:
|
|
405
|
-
result[number] = val.
|
|
438
|
+
result[number] = val.split(b"\x00")[0].decode("ascii", "replace")
|
|
406
439
|
else:
|
|
407
440
|
result[number] = val
|
|
408
441
|
return result
|
|
409
442
|
|
|
410
|
-
async def read_values(self) -> dict[int,
|
|
443
|
+
async def read_values(self) -> dict[int, Value]:
|
|
411
444
|
"""Read all values from ``ValuesAll`` (C.O. 24560).
|
|
412
445
|
|
|
413
446
|
``STRING_LIST`` values are resolved to their display label. Text-typed values
|
|
@@ -415,8 +448,9 @@ class Controller:
|
|
|
415
448
|
values are ``int``, ``float``, or raw ``bytes`` (binary, domain, timer types).
|
|
416
449
|
|
|
417
450
|
Returns:
|
|
418
|
-
``{comm_object_number: value}`` for every value
|
|
419
|
-
|
|
451
|
+
``{comm_object_number: value}`` for every value whose data fits within the
|
|
452
|
+
``ValuesAll`` blob, including static ``ONE_TIME`` values (firmware version,
|
|
453
|
+
ID string, etc.).
|
|
420
454
|
|
|
421
455
|
Examples:
|
|
422
456
|
>>> values = await ctrl.read_values()
|
|
@@ -427,7 +461,7 @@ class Controller:
|
|
|
427
461
|
data = await self._client.read_object(CommunicationObject.VALUES_ALL)
|
|
428
462
|
return self._resolve_raws(decode_values_all(self.table, data), self._values_by_number)
|
|
429
463
|
|
|
430
|
-
async def read_setpoints(self) -> dict[int,
|
|
464
|
+
async def read_setpoints(self) -> dict[int, Value]:
|
|
431
465
|
"""Read all setpoints from ``SetpointsAll`` (C.O. 24559).
|
|
432
466
|
|
|
433
467
|
``STRING_LIST`` setpoints are resolved to their display label, matching what
|
|
@@ -471,9 +505,7 @@ class Controller:
|
|
|
471
505
|
records.append(rec)
|
|
472
506
|
return records
|
|
473
507
|
|
|
474
|
-
def decode_history_snapshot(
|
|
475
|
-
self, record: HistoryRecord
|
|
476
|
-
) -> dict[int, int | float | bytes | str]:
|
|
508
|
+
def decode_history_snapshot(self, record: HistoryRecord) -> dict[int, Value]:
|
|
477
509
|
"""Decode the value snapshot embedded in an alarm ``HistoryRecord``.
|
|
478
510
|
|
|
479
511
|
Alarm/event records carry a snapshot of the ``ValuesAll`` blob captured at the
|
|
@@ -497,12 +529,14 @@ class Controller:
|
|
|
497
529
|
|
|
498
530
|
# -- individual read/write -----------------------------------------------
|
|
499
531
|
|
|
500
|
-
async def read_value(self, name_or_number: str | int) ->
|
|
532
|
+
async def read_value(self, name_or_number: str | int) -> Value:
|
|
501
533
|
"""Read a single value by name or number.
|
|
502
534
|
|
|
503
535
|
Reads ``ValuesAll`` internally — use
|
|
504
536
|
[read_values][pycomap.Controller.read_values] when you need multiple
|
|
505
|
-
values to avoid redundant round-trips.
|
|
537
|
+
values to avoid redundant round-trips. For ``ONE_TIME`` static values use
|
|
538
|
+
[one_time_value][pycomap.Controller.one_time_value] or
|
|
539
|
+
[one_time_values][pycomap.Controller.one_time_values].
|
|
506
540
|
|
|
507
541
|
Args:
|
|
508
542
|
name_or_number: Value name or comm object number.
|
|
@@ -512,18 +546,54 @@ class Controller:
|
|
|
512
546
|
|
|
513
547
|
Raises:
|
|
514
548
|
KeyError: If no value with that name or number exists.
|
|
515
|
-
ComApProtocolError: If the
|
|
549
|
+
ComApProtocolError: If the name is shared by multiple values, or if the value
|
|
550
|
+
is ``ONE_TIME`` category — use ``one_time_value()`` instead.
|
|
516
551
|
"""
|
|
517
552
|
desc = self.value_info(name_or_number)
|
|
553
|
+
if desc.category is ValueCategory.ONE_TIME:
|
|
554
|
+
raise ComApProtocolError(
|
|
555
|
+
f"value {desc.name!r} (number {desc.number}) is ONE_TIME — "
|
|
556
|
+
"use Controller.one_time_values instead"
|
|
557
|
+
)
|
|
518
558
|
values = await self.read_values()
|
|
519
559
|
if desc.number not in values:
|
|
520
560
|
raise ComApProtocolError(
|
|
521
|
-
f"value {desc.name!r} (number {desc.number}) is
|
|
522
|
-
"included in ValuesAll"
|
|
561
|
+
f"value {desc.name!r} (number {desc.number}) is not present in ValuesAll"
|
|
523
562
|
)
|
|
524
563
|
return values[desc.number]
|
|
525
564
|
|
|
526
|
-
|
|
565
|
+
def one_time_value(self, name_or_number: str | int) -> Value:
|
|
566
|
+
"""Return a cached ONE_TIME value by name or number (no network call).
|
|
567
|
+
|
|
568
|
+
ONE_TIME values are read once at connect time and stored in
|
|
569
|
+
[one_time_values][pycomap.Controller.one_time_values].
|
|
570
|
+
|
|
571
|
+
Args:
|
|
572
|
+
name_or_number: Value name or comm object number.
|
|
573
|
+
|
|
574
|
+
Returns:
|
|
575
|
+
Resolved value; strings are already null-stripped.
|
|
576
|
+
|
|
577
|
+
Raises:
|
|
578
|
+
KeyError: If no value with that name or number exists in the table.
|
|
579
|
+
ComApProtocolError: If the name is shared by multiple values, if the value
|
|
580
|
+
is not ``ONE_TIME`` category, or if it was not cached (e.g. skipped as
|
|
581
|
+
invisible or failed to read at connect).
|
|
582
|
+
"""
|
|
583
|
+
desc = self.value_info(name_or_number)
|
|
584
|
+
if desc.category is not ValueCategory.ONE_TIME:
|
|
585
|
+
raise ComApProtocolError(
|
|
586
|
+
f"value {desc.name!r} (number {desc.number}) is not ONE_TIME — "
|
|
587
|
+
"use read_value() instead"
|
|
588
|
+
)
|
|
589
|
+
if desc.number not in self._one_time_values:
|
|
590
|
+
raise ComApProtocolError(
|
|
591
|
+
f"ONE_TIME value {desc.name!r} (number {desc.number}) is not in cache "
|
|
592
|
+
"(skipped as invisible or failed to read at connect)"
|
|
593
|
+
)
|
|
594
|
+
return self._one_time_values[desc.number]
|
|
595
|
+
|
|
596
|
+
async def read_setpoint(self, name_or_number: str | int) -> Value:
|
|
527
597
|
"""Read a single setpoint by name or number (one round-trip).
|
|
528
598
|
|
|
529
599
|
Args:
|
|
@@ -543,8 +613,8 @@ class Controller:
|
|
|
543
613
|
def _coerce_setpoint_value(
|
|
544
614
|
self,
|
|
545
615
|
desc: SetpointDescription,
|
|
546
|
-
value:
|
|
547
|
-
) ->
|
|
616
|
+
value: Value,
|
|
617
|
+
) -> Value:
|
|
548
618
|
"""Resolve and validate ``value`` for ``desc`` before encoding.
|
|
549
619
|
|
|
550
620
|
- ``STRING_LIST`` + ``str``: looks up the label in
|
|
@@ -594,9 +664,7 @@ class Controller:
|
|
|
594
664
|
|
|
595
665
|
return value
|
|
596
666
|
|
|
597
|
-
async def set_setpoint(
|
|
598
|
-
self, name_or_number: str | int, value: int | float | str | bytes
|
|
599
|
-
) -> None:
|
|
667
|
+
async def set_setpoint(self, name_or_number: str | int, value: Value) -> None:
|
|
600
668
|
"""Write a setpoint by name or number.
|
|
601
669
|
|
|
602
670
|
Args:
|
|
@@ -627,7 +695,7 @@ class Controller:
|
|
|
627
695
|
"""
|
|
628
696
|
desc = self.setpoint_info(name_or_number)
|
|
629
697
|
if desc.needs_password:
|
|
630
|
-
await self.
|
|
698
|
+
await self.elevate_access()
|
|
631
699
|
value = self._coerce_setpoint_value(desc, value)
|
|
632
700
|
raw = _encode_setpoint_value(desc.data_type, desc.decimal_places, value)
|
|
633
701
|
await self._client.write_object(desc.number, raw)
|
|
@@ -744,7 +812,7 @@ class Controller:
|
|
|
744
812
|
Raises:
|
|
745
813
|
ComApAuthError: If write access is needed but no password was provided.
|
|
746
814
|
"""
|
|
747
|
-
await self.
|
|
815
|
+
await self.elevate_access()
|
|
748
816
|
effective_tz: datetime.tzinfo = tz if tz is not None else self._timezone
|
|
749
817
|
now = datetime.datetime.now(datetime.UTC).astimezone(effective_tz).replace(tzinfo=None)
|
|
750
818
|
await self._client.write_datetime(now)
|
|
@@ -754,24 +822,66 @@ class Controller:
|
|
|
754
822
|
async def _load_config(self) -> None:
|
|
755
823
|
self._config_data = await self._client.read_object(CommunicationObject.CONFIGURATION_TABLE)
|
|
756
824
|
self._table = parse_configuration_table(self._config_data)
|
|
757
|
-
# Build name → description lookup maps. If names are duplicated, first wins.
|
|
758
825
|
self._values_by_number = {v.number: v for v in self._table.values}
|
|
759
826
|
self._values_by_name = {}
|
|
827
|
+
self._ambiguous_value_names = set()
|
|
760
828
|
for v in self._table.values:
|
|
761
|
-
|
|
829
|
+
if v.name in self._values_by_name:
|
|
830
|
+
self._ambiguous_value_names.add(v.name)
|
|
831
|
+
else:
|
|
832
|
+
self._values_by_name[v.name] = v
|
|
762
833
|
self._setpoints_by_number = {s.number: s for s in self._table.setpoints}
|
|
763
834
|
self._setpoints_by_name = {}
|
|
835
|
+
self._ambiguous_setpoint_names = set()
|
|
764
836
|
for s in self._table.setpoints:
|
|
765
|
-
|
|
837
|
+
if s.name in self._setpoints_by_name:
|
|
838
|
+
self._ambiguous_setpoint_names.add(s.name)
|
|
839
|
+
else:
|
|
840
|
+
self._setpoints_by_name[s.name] = s
|
|
766
841
|
self._common_names = parse_names_heap(self._config_data, NamesCategory.COMMON_NAMES)
|
|
767
842
|
await self.refresh_timezone()
|
|
843
|
+
await self._cache_one_time_values()
|
|
768
844
|
_log.info(
|
|
769
|
-
"configuration loaded: %d values, %d setpoints",
|
|
845
|
+
"configuration loaded: %d values, %d setpoints, %d one-time",
|
|
770
846
|
len(self._table.values),
|
|
771
847
|
len(self._table.setpoints),
|
|
848
|
+
len(self._one_time_values),
|
|
772
849
|
)
|
|
773
850
|
|
|
774
|
-
async def
|
|
851
|
+
async def _cache_one_time_values(self) -> None:
|
|
852
|
+
descs = [
|
|
853
|
+
v
|
|
854
|
+
for v in self.table.values
|
|
855
|
+
if v.category is ValueCategory.ONE_TIME
|
|
856
|
+
and (self._include_invisible or v.group != "Invisible")
|
|
857
|
+
]
|
|
858
|
+
if not descs:
|
|
859
|
+
return
|
|
860
|
+
raw_map: dict[int, RawValue] = {}
|
|
861
|
+
for desc in descs:
|
|
862
|
+
try:
|
|
863
|
+
raw = await self._client.read_object(desc.number)
|
|
864
|
+
except Exception as exc:
|
|
865
|
+
_log.warning(
|
|
866
|
+
"failed to read ONE_TIME value %r (number %d): %s",
|
|
867
|
+
desc.name,
|
|
868
|
+
desc.number,
|
|
869
|
+
exc,
|
|
870
|
+
)
|
|
871
|
+
continue
|
|
872
|
+
raw_map[desc.number] = decode_raw_value(desc.data_type, raw, desc.decimal_places)
|
|
873
|
+
self._one_time_values = self._resolve_raws(raw_map, self._values_by_number)
|
|
874
|
+
|
|
875
|
+
async def elevate_access(self) -> None:
|
|
876
|
+
"""Send the write-protection password to the controller, enabling protected writes.
|
|
877
|
+
|
|
878
|
+
Idempotent — safe to call multiple times; the password is only sent once.
|
|
879
|
+
Call this early to validate the password without waiting for the first write.
|
|
880
|
+
|
|
881
|
+
Raises:
|
|
882
|
+
ComApAuthError: If no password was supplied to the ``Controller``.
|
|
883
|
+
ComApInvalidPasswordError: If the controller rejects the password.
|
|
884
|
+
"""
|
|
775
885
|
if self._elevated:
|
|
776
886
|
return
|
|
777
887
|
if self._password is None:
|
|
@@ -91,6 +91,9 @@ _NUMERIC_STRUCT_FORMAT: dict[DataType, str] = {
|
|
|
91
91
|
|
|
92
92
|
_BINARY_TYPES = (DataType.BINARY8, DataType.BINARY16, DataType.BINARY32)
|
|
93
93
|
|
|
94
|
+
RawValue = int | float | bytes
|
|
95
|
+
Value = int | float | bytes | str
|
|
96
|
+
|
|
94
97
|
|
|
95
98
|
class ProtectionState(enum.IntFlag):
|
|
96
99
|
"""ComAp ``ProtectionState`` enum (``ValueState.Level1``/``Level2``/``SensorFail``).
|
|
@@ -106,9 +109,7 @@ class ProtectionState(enum.IntFlag):
|
|
|
106
109
|
NOT_CONFIRMED = 4
|
|
107
110
|
|
|
108
111
|
|
|
109
|
-
def decode_raw_value(
|
|
110
|
-
data_type: DataType, raw: bytes, decimal_places: int = 0
|
|
111
|
-
) -> int | float | bytes:
|
|
112
|
+
def decode_raw_value(data_type: DataType, raw: bytes, decimal_places: int = 0) -> RawValue:
|
|
112
113
|
"""Decode ``raw`` bytes (``len(raw) == _DATA_TYPE_LENGTH[data_type]``) per ``data_type``.
|
|
113
114
|
|
|
114
115
|
Only numeric and binary(bitfield) types are decoded to Python numbers; string/domain/
|
|
@@ -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,9 +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, IPv4Interface
|
|
24
25
|
|
|
25
26
|
from pycomap.exceptions import ComApProtocolError
|
|
26
27
|
from pycomap.protocol.framing import Operation, build_inner, parse_inner
|
|
@@ -70,7 +71,7 @@ class DiscoveryDevice:
|
|
|
70
71
|
serial_number: int
|
|
71
72
|
firmware_major_minor: int
|
|
72
73
|
firmware_patch_build: int
|
|
73
|
-
ip:
|
|
74
|
+
ip: IPv4Address
|
|
74
75
|
mac: str
|
|
75
76
|
comm_port: int
|
|
76
77
|
access_type: AccessType
|
|
@@ -103,7 +104,7 @@ def _parse_device(data: bytes) -> DiscoveryDevice:
|
|
|
103
104
|
else:
|
|
104
105
|
firmware_patch_build = 0
|
|
105
106
|
|
|
106
|
-
ip =
|
|
107
|
+
ip = IPv4Address(bytes(data[offset : offset + 4]))
|
|
107
108
|
offset += 4
|
|
108
109
|
mac = ":".join(f"{b:02x}" for b in data[offset : offset + 6])
|
|
109
110
|
offset += 6
|
|
@@ -145,11 +146,62 @@ def _parse_device(data: bytes) -> DiscoveryDevice:
|
|
|
145
146
|
)
|
|
146
147
|
|
|
147
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
|
+
|
|
148
191
|
async def discover(
|
|
192
|
+
*interfaces: IPv4Interface,
|
|
149
193
|
timeout: float = 2.0,
|
|
150
|
-
broadcast_address: str = "255.255.255.255",
|
|
151
194
|
) -> list[DiscoveryDevice]:
|
|
152
|
-
"""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.
|
|
153
205
|
|
|
154
206
|
Returns one [DiscoveryDevice][pycomap.discovery.DiscoveryDevice] per distinct
|
|
155
207
|
replying IP address. Malformed or unrelated UDP traffic on the same port
|
|
@@ -157,39 +209,40 @@ async def discover(
|
|
|
157
209
|
silently skipped.
|
|
158
210
|
"""
|
|
159
211
|
loop = asyncio.get_running_loop()
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
+
)
|
|
165
221
|
try:
|
|
166
|
-
|
|
167
|
-
_log.debug("sending discovery probe to %s:%d", broadcast_address, DISCOVERY_PORT)
|
|
168
|
-
sock.sendto(_build_probe(), (broadcast_address, DISCOVERY_PORT))
|
|
169
|
-
|
|
170
|
-
end_time = loop.time() + timeout
|
|
171
|
-
while True:
|
|
172
|
-
remaining = end_time - loop.time()
|
|
173
|
-
if remaining <= 0:
|
|
174
|
-
break
|
|
175
|
-
try:
|
|
176
|
-
payload, _peer_addr = await asyncio.wait_for(
|
|
177
|
-
loop.sock_recvfrom(sock, 4096), timeout=remaining
|
|
178
|
-
)
|
|
179
|
-
except TimeoutError:
|
|
180
|
-
break
|
|
181
|
-
try:
|
|
182
|
-
message = parse_inner(payload)
|
|
183
|
-
except ComApProtocolError:
|
|
184
|
-
continue
|
|
185
|
-
if message.comm_obj != CommunicationObject.DISCOVERY:
|
|
186
|
-
continue
|
|
187
|
-
if message.is_error or not message.data:
|
|
188
|
-
continue
|
|
189
|
-
device = _parse_device(message.data)
|
|
190
|
-
found[device.ip] = device
|
|
222
|
+
await asyncio.sleep(timeout)
|
|
191
223
|
finally:
|
|
192
|
-
|
|
224
|
+
for transport in transports:
|
|
225
|
+
transport.close()
|
|
193
226
|
|
|
194
227
|
_log.info("discovery found %d device(s)", len(found))
|
|
195
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)
|
|
@@ -22,6 +22,14 @@ class ComApAuthError(ComApError):
|
|
|
22
22
|
"""Raised when access-code verification fails or is rejected by the controller."""
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
class ComApInvalidAccessCodeError(ComApAuthError):
|
|
26
|
+
"""Raised when the controller rejects the access code during authentication."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ComApInvalidPasswordError(ComApAuthError):
|
|
30
|
+
"""Raised when the controller rejects the write-protection password."""
|
|
31
|
+
|
|
32
|
+
|
|
25
33
|
class ComApControllerError(ComApError):
|
|
26
34
|
"""Raised when the controller answers with an explicit ``Error`` operation.
|
|
27
35
|
|
|
@@ -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
|
"""
|
|
@@ -24,8 +26,9 @@ from types import TracebackType
|
|
|
24
26
|
|
|
25
27
|
from pycomap.datatypes import decode_fdate, decode_ftime, encode_fdate, encode_ftime
|
|
26
28
|
from pycomap.exceptions import (
|
|
27
|
-
ComApAuthError,
|
|
28
29
|
ComApControllerError,
|
|
30
|
+
ComApInvalidAccessCodeError,
|
|
31
|
+
ComApInvalidPasswordError,
|
|
29
32
|
ComApProtocolError,
|
|
30
33
|
)
|
|
31
34
|
from pycomap.protocol import crypto
|
|
@@ -65,9 +68,11 @@ class ComApClient:
|
|
|
65
68
|
|
|
66
69
|
Pass any ``Transport`` implementation — typically ``EthernetTransport``::
|
|
67
70
|
|
|
71
|
+
from ipaddress import IPv4Address
|
|
72
|
+
|
|
68
73
|
from pycomap.protocol.transport import EthernetTransport
|
|
69
74
|
|
|
70
|
-
async with ComApClient(EthernetTransport("192.168.1.9")) as client:
|
|
75
|
+
async with ComApClient(EthernetTransport(IPv4Address("192.168.1.9"))) as client:
|
|
71
76
|
await client.authenticate("0")
|
|
72
77
|
"""
|
|
73
78
|
|
|
@@ -81,6 +86,11 @@ class ComApClient:
|
|
|
81
86
|
self._mode = _Mode.NONE
|
|
82
87
|
self._cipher: ChainedAesCbc | None = None
|
|
83
88
|
|
|
89
|
+
@property
|
|
90
|
+
def transport(self) -> Transport:
|
|
91
|
+
"""The underlying byte-stream transport."""
|
|
92
|
+
return self._transport
|
|
93
|
+
|
|
84
94
|
# -- connection lifecycle -------------------------------------------------
|
|
85
95
|
|
|
86
96
|
async def connect(self) -> None:
|
|
@@ -114,7 +124,7 @@ class ComApClient:
|
|
|
114
124
|
access_code: The controller's base AccessCode (drives ECDH/AES key derivation).
|
|
115
125
|
|
|
116
126
|
Raises:
|
|
117
|
-
|
|
127
|
+
ComApInvalidAccessCodeError: If the controller rejects the access code.
|
|
118
128
|
ComApProtocolError: If the ECDH exchange produces an unexpected response.
|
|
119
129
|
"""
|
|
120
130
|
server_pub_data = await self.read_object(CommunicationObject.ECDH_PUBLIC_KEY)
|
|
@@ -136,13 +146,13 @@ class ComApClient:
|
|
|
136
146
|
nonce = await self.read_object(CommunicationObject.VERIFY_ACCESS_HASH)
|
|
137
147
|
except ComApControllerError as exc:
|
|
138
148
|
if exc.code not in _AUTH_FALLBACK_CODES:
|
|
139
|
-
raise
|
|
149
|
+
raise ComApInvalidAccessCodeError("access verification failed") from exc
|
|
140
150
|
_log.warning("hash auth not supported by controller, falling back to plain access code")
|
|
141
151
|
source = access_code.encode("ascii").ljust(16, b"\x00")
|
|
142
152
|
try:
|
|
143
153
|
await self.write_object(CommunicationObject.VERIFY_ACCESS, source)
|
|
144
154
|
except ComApControllerError as exc2:
|
|
145
|
-
raise
|
|
155
|
+
raise ComApInvalidAccessCodeError("access code rejected by controller") from exc2
|
|
146
156
|
_log.info("authenticated via plain access code")
|
|
147
157
|
else:
|
|
148
158
|
digest = hashlib.md5(nonce + access_code.encode("ascii")).digest()
|
|
@@ -150,7 +160,7 @@ class ComApClient:
|
|
|
150
160
|
try:
|
|
151
161
|
await self.write_object(CommunicationObject.VERIFY_ACCESS_HASH, credentials)
|
|
152
162
|
except ComApControllerError as exc3:
|
|
153
|
-
raise
|
|
163
|
+
raise ComApInvalidAccessCodeError("access code rejected by controller") from exc3
|
|
154
164
|
_log.info("authenticated via hash")
|
|
155
165
|
|
|
156
166
|
async def elevate_access(self, password: int) -> None:
|
|
@@ -175,7 +185,7 @@ class ComApClient:
|
|
|
175
185
|
CommunicationObject.PASSWORD_FOR_WRITE, struct.pack("<H", password)
|
|
176
186
|
)
|
|
177
187
|
except ComApControllerError as exc:
|
|
178
|
-
raise
|
|
188
|
+
raise ComApInvalidPasswordError(
|
|
179
189
|
"password rejected by controller (wrong password or brute-force lockout active)"
|
|
180
190
|
) from exc
|
|
181
191
|
_log.info("write-protection password accepted")
|
|
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|
|
11
11
|
import asyncio
|
|
12
12
|
import contextlib
|
|
13
13
|
import logging
|
|
14
|
+
from ipaddress import IPv4Address
|
|
14
15
|
from typing import Protocol, runtime_checkable
|
|
15
16
|
|
|
16
17
|
from pycomap.exceptions import ComApConnectionError
|
|
@@ -48,10 +49,12 @@ class EthernetTransport:
|
|
|
48
49
|
ECDH/AES framing on top.
|
|
49
50
|
"""
|
|
50
51
|
|
|
51
|
-
def __init__(self, host:
|
|
52
|
+
def __init__(self, host: IPv4Address, port: int = DEFAULT_PORT) -> None:
|
|
52
53
|
"""
|
|
53
54
|
Args:
|
|
54
|
-
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].
|
|
55
58
|
port: TCP port; defaults to ``23`` (ComAp native protocol port).
|
|
56
59
|
"""
|
|
57
60
|
self._host = host
|
|
@@ -59,10 +62,15 @@ class EthernetTransport:
|
|
|
59
62
|
self._reader: asyncio.StreamReader | None = None
|
|
60
63
|
self._writer: asyncio.StreamWriter | None = None
|
|
61
64
|
|
|
65
|
+
@property
|
|
66
|
+
def host(self) -> IPv4Address:
|
|
67
|
+
"""The controller's configured IP address."""
|
|
68
|
+
return self._host
|
|
69
|
+
|
|
62
70
|
async def connect(self) -> None:
|
|
63
71
|
_log.debug("connecting to %s:%d", self._host, self._port)
|
|
64
72
|
try:
|
|
65
|
-
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)
|
|
66
74
|
except OSError as exc:
|
|
67
75
|
raise ComApConnectionError(f"failed to connect to {self._host}:{self._port}") from exc
|
|
68
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
|