pycomap 1.0.2__tar.gz → 1.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-1.0.2 → pycomap-1.1.0}/PKG-INFO +1 -1
- {pycomap-1.0.2 → pycomap-1.1.0}/pyproject.toml +1 -1
- {pycomap-1.0.2 → pycomap-1.1.0}/src/pycomap/__init__.py +6 -0
- {pycomap-1.0.2 → pycomap-1.1.0}/src/pycomap/configuration.py +25 -12
- {pycomap-1.0.2 → pycomap-1.1.0}/src/pycomap/controller.py +146 -38
- {pycomap-1.0.2 → pycomap-1.1.0}/src/pycomap/datatypes.py +4 -3
- {pycomap-1.0.2 → pycomap-1.1.0}/src/pycomap/discovery.py +6 -5
- {pycomap-1.0.2 → pycomap-1.1.0}/src/pycomap/exceptions.py +8 -0
- {pycomap-1.0.2 → pycomap-1.1.0}/src/pycomap/protocol/client.py +7 -6
- {pycomap-1.0.2 → pycomap-1.1.0}/src/pycomap/protocol/transport.py +4 -3
- {pycomap-1.0.2 → pycomap-1.1.0}/README.md +0 -0
- {pycomap-1.0.2 → pycomap-1.1.0}/src/pycomap/alarms.py +0 -0
- {pycomap-1.0.2 → pycomap-1.1.0}/src/pycomap/history.py +0 -0
- {pycomap-1.0.2 → pycomap-1.1.0}/src/pycomap/protocol/__init__.py +0 -0
- {pycomap-1.0.2 → pycomap-1.1.0}/src/pycomap/protocol/commands.py +0 -0
- {pycomap-1.0.2 → pycomap-1.1.0}/src/pycomap/protocol/crc.py +0 -0
- {pycomap-1.0.2 → pycomap-1.1.0}/src/pycomap/protocol/crypto.py +0 -0
- {pycomap-1.0.2 → pycomap-1.1.0}/src/pycomap/protocol/framing.py +0 -0
- {pycomap-1.0.2 → pycomap-1.1.0}/src/pycomap/protocol/objects.py +0 -0
- {pycomap-1.0.2 → pycomap-1.1.0}/src/pycomap/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pycomap
|
|
3
|
-
Version: 1.0
|
|
3
|
+
Version: 1.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>
|
|
@@ -29,12 +29,15 @@ from __future__ import annotations
|
|
|
29
29
|
import logging
|
|
30
30
|
|
|
31
31
|
from pycomap.controller import Controller
|
|
32
|
+
from pycomap.datatypes import Value
|
|
32
33
|
from pycomap.discovery import discover
|
|
33
34
|
from pycomap.exceptions import (
|
|
34
35
|
ComApAuthError,
|
|
35
36
|
ComApConnectionError,
|
|
36
37
|
ComApControllerError,
|
|
37
38
|
ComApError,
|
|
39
|
+
ComApInvalidAccessCodeError,
|
|
40
|
+
ComApInvalidPasswordError,
|
|
38
41
|
ComApProtocolError,
|
|
39
42
|
)
|
|
40
43
|
from pycomap.protocol import ComApClient, Command, ControllerCommand, EthernetTransport
|
|
@@ -47,10 +50,13 @@ __all__ = [
|
|
|
47
50
|
"ComApConnectionError",
|
|
48
51
|
"ComApControllerError",
|
|
49
52
|
"ComApError",
|
|
53
|
+
"ComApInvalidAccessCodeError",
|
|
54
|
+
"ComApInvalidPasswordError",
|
|
50
55
|
"ComApProtocolError",
|
|
51
56
|
"Command",
|
|
52
57
|
"Controller",
|
|
53
58
|
"ControllerCommand",
|
|
54
59
|
"EthernetTransport",
|
|
60
|
+
"Value",
|
|
55
61
|
"discover",
|
|
56
62
|
]
|
|
@@ -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)
|
|
@@ -27,13 +27,15 @@ from __future__ import annotations
|
|
|
27
27
|
import datetime
|
|
28
28
|
import logging
|
|
29
29
|
import re
|
|
30
|
-
from
|
|
30
|
+
from collections.abc import Mapping
|
|
31
|
+
from types import MappingProxyType, TracebackType
|
|
31
32
|
|
|
32
33
|
from pycomap.alarms import AlarmRecord, parse_alarm_list
|
|
33
34
|
from pycomap.configuration import (
|
|
34
35
|
ConfigurationTable,
|
|
35
36
|
NamesCategory,
|
|
36
37
|
SetpointDescription,
|
|
38
|
+
ValueCategory,
|
|
37
39
|
ValueDescription,
|
|
38
40
|
ValueState,
|
|
39
41
|
decode_history_snapshot,
|
|
@@ -47,6 +49,8 @@ from pycomap.datatypes import (
|
|
|
47
49
|
_BINARY_TYPES,
|
|
48
50
|
_DATA_TYPE_LENGTH,
|
|
49
51
|
DataType,
|
|
52
|
+
RawValue,
|
|
53
|
+
Value,
|
|
50
54
|
decode_raw_value,
|
|
51
55
|
encode_raw_value,
|
|
52
56
|
)
|
|
@@ -109,7 +113,7 @@ def _parse_gmt_label(label: str) -> datetime.timedelta | None:
|
|
|
109
113
|
def _encode_setpoint_value(
|
|
110
114
|
data_type: DataType,
|
|
111
115
|
decimal_places: int,
|
|
112
|
-
value:
|
|
116
|
+
value: Value,
|
|
113
117
|
) -> bytes:
|
|
114
118
|
if isinstance(value, bytes):
|
|
115
119
|
return value
|
|
@@ -131,10 +135,9 @@ class Controller:
|
|
|
131
135
|
"""High-level async client for a ComAp controller.
|
|
132
136
|
|
|
133
137
|
Fetches and caches the ``ConfigurationTable`` on [connect][pycomap.Controller.connect],
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
is provided.
|
|
138
|
+
enabling name-based lookup for all subsequent calls. Password elevation for
|
|
139
|
+
write-protected setpoints is handled automatically (lazy, on first protected write)
|
|
140
|
+
when ``password`` is provided.
|
|
138
141
|
|
|
139
142
|
Args:
|
|
140
143
|
client: A ``ComApClient`` wrapping any transport. The ``Controller`` takes
|
|
@@ -143,6 +146,9 @@ class Controller:
|
|
|
143
146
|
``"0"``). Drives ECDH/AES key derivation — **not** the write password.
|
|
144
147
|
password: The write-protection password (integer 0-9999). Required for setpoints
|
|
145
148
|
with ``access_level > 0``; omit to operate in read-only mode.
|
|
149
|
+
include_invisible: When ``False`` (default), ONE_TIME values in the ``'Invisible'``
|
|
150
|
+
group are skipped during connect, saving one ``read_object`` round-trip per
|
|
151
|
+
invisible item. Set to ``True`` to cache them anyway.
|
|
146
152
|
|
|
147
153
|
Examples:
|
|
148
154
|
Read-only access::
|
|
@@ -167,20 +173,25 @@ class Controller:
|
|
|
167
173
|
client: ComApClient,
|
|
168
174
|
access_code: str,
|
|
169
175
|
password: int | None = None,
|
|
176
|
+
include_invisible: bool = False,
|
|
170
177
|
) -> None:
|
|
171
178
|
self._client = client
|
|
172
179
|
self._access_code = access_code
|
|
173
180
|
self._password = password
|
|
181
|
+
self._include_invisible = include_invisible
|
|
174
182
|
self._elevated = False
|
|
175
183
|
self._config_data: bytes | None = None
|
|
176
184
|
self._table: ConfigurationTable | None = None
|
|
177
185
|
self._values_by_name: dict[str, ValueDescription] = {}
|
|
178
186
|
self._values_by_number: dict[int, ValueDescription] = {}
|
|
187
|
+
self._ambiguous_value_names: set[str] = set()
|
|
179
188
|
self._setpoints_by_name: dict[str, SetpointDescription] = {}
|
|
180
189
|
self._setpoints_by_number: dict[int, SetpointDescription] = {}
|
|
190
|
+
self._ambiguous_setpoint_names: set[str] = set()
|
|
181
191
|
self._common_names: list[str] = []
|
|
182
192
|
self._timezone: datetime.timezone = datetime.UTC
|
|
183
193
|
self._summer_time_mode_raw: int = 0
|
|
194
|
+
self._one_time_values: dict[int, Value] = {}
|
|
184
195
|
|
|
185
196
|
# -- connection lifecycle -------------------------------------------------
|
|
186
197
|
|
|
@@ -235,14 +246,22 @@ class Controller:
|
|
|
235
246
|
"""All setpoint descriptions from the cached ``ConfigurationTable``."""
|
|
236
247
|
return self.table.setpoints
|
|
237
248
|
|
|
249
|
+
@property
|
|
250
|
+
def one_time_values(self) -> Mapping[int, Value]:
|
|
251
|
+
"""Static ``ONE_TIME`` values read once at connect time.
|
|
252
|
+
|
|
253
|
+
Returns a read-only ``{comm_object_number: value}`` mapping for every ``ONE_TIME``
|
|
254
|
+
value successfully read during [connect][pycomap.Controller.connect]. Entries use
|
|
255
|
+
the same type and resolution rules as [read_values][pycomap.Controller.read_values].
|
|
256
|
+
Empty before connect; stable for the lifetime of the connection.
|
|
257
|
+
"""
|
|
258
|
+
return MappingProxyType(self._one_time_values)
|
|
259
|
+
|
|
238
260
|
# -- name / number resolution --------------------------------------------
|
|
239
261
|
|
|
240
262
|
def value_info(self, name_or_number: str | int) -> ValueDescription:
|
|
241
263
|
"""Look up a value description by name or comm object number.
|
|
242
264
|
|
|
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
265
|
Args:
|
|
247
266
|
name_or_number: Exact value name (e.g. ``"RPM"``) or comm object number.
|
|
248
267
|
|
|
@@ -251,12 +270,18 @@ class Controller:
|
|
|
251
270
|
|
|
252
271
|
Raises:
|
|
253
272
|
KeyError: If no value with that name or number exists.
|
|
273
|
+
ComApProtocolError: If the name is shared by multiple values — use a number instead.
|
|
254
274
|
"""
|
|
255
275
|
if isinstance(name_or_number, int):
|
|
256
276
|
try:
|
|
257
277
|
return self._values_by_number[name_or_number]
|
|
258
278
|
except KeyError:
|
|
259
279
|
raise KeyError(f"no value with number {name_or_number}") from None
|
|
280
|
+
if name_or_number in self._ambiguous_value_names:
|
|
281
|
+
raise ComApProtocolError(
|
|
282
|
+
f"value name {name_or_number!r} is ambiguous — "
|
|
283
|
+
"multiple values share this name; use a comm object number instead"
|
|
284
|
+
)
|
|
260
285
|
try:
|
|
261
286
|
return self._values_by_name[name_or_number]
|
|
262
287
|
except KeyError:
|
|
@@ -273,12 +298,18 @@ class Controller:
|
|
|
273
298
|
|
|
274
299
|
Raises:
|
|
275
300
|
KeyError: If no setpoint with that name or number exists.
|
|
301
|
+
ComApProtocolError: If the name is shared by multiple setpoints — use a number instead.
|
|
276
302
|
"""
|
|
277
303
|
if isinstance(name_or_number, int):
|
|
278
304
|
try:
|
|
279
305
|
return self._setpoints_by_number[name_or_number]
|
|
280
306
|
except KeyError:
|
|
281
307
|
raise KeyError(f"no setpoint with number {name_or_number}") from None
|
|
308
|
+
if name_or_number in self._ambiguous_setpoint_names:
|
|
309
|
+
raise ComApProtocolError(
|
|
310
|
+
f"setpoint name {name_or_number!r} is ambiguous — "
|
|
311
|
+
"multiple setpoints share this name; use a comm object number instead"
|
|
312
|
+
)
|
|
282
313
|
try:
|
|
283
314
|
return self._setpoints_by_name[name_or_number]
|
|
284
315
|
except KeyError:
|
|
@@ -377,9 +408,9 @@ class Controller:
|
|
|
377
408
|
|
|
378
409
|
def _resolve_raws(
|
|
379
410
|
self,
|
|
380
|
-
raw: dict[int,
|
|
411
|
+
raw: dict[int, RawValue],
|
|
381
412
|
by_number: dict[int, ValueDescription] | dict[int, SetpointDescription],
|
|
382
|
-
) -> dict[int,
|
|
413
|
+
) -> dict[int, Value]:
|
|
383
414
|
"""Resolve ``STRING_LIST`` wire bytes to labels and text-typed bytes to strings.
|
|
384
415
|
|
|
385
416
|
Works for both value and setpoint dicts. ``STRING_LIST`` entries arrive as 1-byte
|
|
@@ -387,7 +418,7 @@ class Controller:
|
|
|
387
418
|
Text-typed entries (``SHORT_STRING``, ``IP_ADDRESS``, etc.) arrive as null-padded
|
|
388
419
|
byte strings and are decoded to ASCII.
|
|
389
420
|
"""
|
|
390
|
-
result: dict[int,
|
|
421
|
+
result: dict[int, Value] = {}
|
|
391
422
|
for number, val in raw.items():
|
|
392
423
|
if not isinstance(val, bytes):
|
|
393
424
|
result[number] = val
|
|
@@ -402,12 +433,12 @@ class Controller:
|
|
|
402
433
|
self._common_names[idx] if idx < len(self._common_names) else str(wire)
|
|
403
434
|
)
|
|
404
435
|
elif desc.data_type in _STRING_TYPES:
|
|
405
|
-
result[number] = val.
|
|
436
|
+
result[number] = val.split(b"\x00")[0].decode("ascii", "replace")
|
|
406
437
|
else:
|
|
407
438
|
result[number] = val
|
|
408
439
|
return result
|
|
409
440
|
|
|
410
|
-
async def read_values(self) -> dict[int,
|
|
441
|
+
async def read_values(self) -> dict[int, Value]:
|
|
411
442
|
"""Read all values from ``ValuesAll`` (C.O. 24560).
|
|
412
443
|
|
|
413
444
|
``STRING_LIST`` values are resolved to their display label. Text-typed values
|
|
@@ -415,8 +446,9 @@ class Controller:
|
|
|
415
446
|
values are ``int``, ``float``, or raw ``bytes`` (binary, domain, timer types).
|
|
416
447
|
|
|
417
448
|
Returns:
|
|
418
|
-
``{comm_object_number: value}`` for every value
|
|
419
|
-
|
|
449
|
+
``{comm_object_number: value}`` for every value whose data fits within the
|
|
450
|
+
``ValuesAll`` blob, including static ``ONE_TIME`` values (firmware version,
|
|
451
|
+
ID string, etc.).
|
|
420
452
|
|
|
421
453
|
Examples:
|
|
422
454
|
>>> values = await ctrl.read_values()
|
|
@@ -427,7 +459,7 @@ class Controller:
|
|
|
427
459
|
data = await self._client.read_object(CommunicationObject.VALUES_ALL)
|
|
428
460
|
return self._resolve_raws(decode_values_all(self.table, data), self._values_by_number)
|
|
429
461
|
|
|
430
|
-
async def read_setpoints(self) -> dict[int,
|
|
462
|
+
async def read_setpoints(self) -> dict[int, Value]:
|
|
431
463
|
"""Read all setpoints from ``SetpointsAll`` (C.O. 24559).
|
|
432
464
|
|
|
433
465
|
``STRING_LIST`` setpoints are resolved to their display label, matching what
|
|
@@ -471,9 +503,7 @@ class Controller:
|
|
|
471
503
|
records.append(rec)
|
|
472
504
|
return records
|
|
473
505
|
|
|
474
|
-
def decode_history_snapshot(
|
|
475
|
-
self, record: HistoryRecord
|
|
476
|
-
) -> dict[int, int | float | bytes | str]:
|
|
506
|
+
def decode_history_snapshot(self, record: HistoryRecord) -> dict[int, Value]:
|
|
477
507
|
"""Decode the value snapshot embedded in an alarm ``HistoryRecord``.
|
|
478
508
|
|
|
479
509
|
Alarm/event records carry a snapshot of the ``ValuesAll`` blob captured at the
|
|
@@ -497,12 +527,14 @@ class Controller:
|
|
|
497
527
|
|
|
498
528
|
# -- individual read/write -----------------------------------------------
|
|
499
529
|
|
|
500
|
-
async def read_value(self, name_or_number: str | int) ->
|
|
530
|
+
async def read_value(self, name_or_number: str | int) -> Value:
|
|
501
531
|
"""Read a single value by name or number.
|
|
502
532
|
|
|
503
533
|
Reads ``ValuesAll`` internally — use
|
|
504
534
|
[read_values][pycomap.Controller.read_values] when you need multiple
|
|
505
|
-
values to avoid redundant round-trips.
|
|
535
|
+
values to avoid redundant round-trips. For ``ONE_TIME`` static values use
|
|
536
|
+
[one_time_value][pycomap.Controller.one_time_value] or
|
|
537
|
+
[one_time_values][pycomap.Controller.one_time_values].
|
|
506
538
|
|
|
507
539
|
Args:
|
|
508
540
|
name_or_number: Value name or comm object number.
|
|
@@ -512,18 +544,54 @@ class Controller:
|
|
|
512
544
|
|
|
513
545
|
Raises:
|
|
514
546
|
KeyError: If no value with that name or number exists.
|
|
515
|
-
ComApProtocolError: If the
|
|
547
|
+
ComApProtocolError: If the name is shared by multiple values, or if the value
|
|
548
|
+
is ``ONE_TIME`` category — use ``one_time_value()`` instead.
|
|
516
549
|
"""
|
|
517
550
|
desc = self.value_info(name_or_number)
|
|
551
|
+
if desc.category is ValueCategory.ONE_TIME:
|
|
552
|
+
raise ComApProtocolError(
|
|
553
|
+
f"value {desc.name!r} (number {desc.number}) is ONE_TIME — "
|
|
554
|
+
"use Controller.one_time_values instead"
|
|
555
|
+
)
|
|
518
556
|
values = await self.read_values()
|
|
519
557
|
if desc.number not in values:
|
|
520
558
|
raise ComApProtocolError(
|
|
521
|
-
f"value {desc.name!r} (number {desc.number}) is
|
|
522
|
-
"included in ValuesAll"
|
|
559
|
+
f"value {desc.name!r} (number {desc.number}) is not present in ValuesAll"
|
|
523
560
|
)
|
|
524
561
|
return values[desc.number]
|
|
525
562
|
|
|
526
|
-
|
|
563
|
+
def one_time_value(self, name_or_number: str | int) -> Value:
|
|
564
|
+
"""Return a cached ONE_TIME value by name or number (no network call).
|
|
565
|
+
|
|
566
|
+
ONE_TIME values are read once at connect time and stored in
|
|
567
|
+
[one_time_values][pycomap.Controller.one_time_values].
|
|
568
|
+
|
|
569
|
+
Args:
|
|
570
|
+
name_or_number: Value name or comm object number.
|
|
571
|
+
|
|
572
|
+
Returns:
|
|
573
|
+
Resolved value; strings are already null-stripped.
|
|
574
|
+
|
|
575
|
+
Raises:
|
|
576
|
+
KeyError: If no value with that name or number exists in the table.
|
|
577
|
+
ComApProtocolError: If the name is shared by multiple values, if the value
|
|
578
|
+
is not ``ONE_TIME`` category, or if it was not cached (e.g. skipped as
|
|
579
|
+
invisible or failed to read at connect).
|
|
580
|
+
"""
|
|
581
|
+
desc = self.value_info(name_or_number)
|
|
582
|
+
if desc.category is not ValueCategory.ONE_TIME:
|
|
583
|
+
raise ComApProtocolError(
|
|
584
|
+
f"value {desc.name!r} (number {desc.number}) is not ONE_TIME — "
|
|
585
|
+
"use read_value() instead"
|
|
586
|
+
)
|
|
587
|
+
if desc.number not in self._one_time_values:
|
|
588
|
+
raise ComApProtocolError(
|
|
589
|
+
f"ONE_TIME value {desc.name!r} (number {desc.number}) is not in cache "
|
|
590
|
+
"(skipped as invisible or failed to read at connect)"
|
|
591
|
+
)
|
|
592
|
+
return self._one_time_values[desc.number]
|
|
593
|
+
|
|
594
|
+
async def read_setpoint(self, name_or_number: str | int) -> Value:
|
|
527
595
|
"""Read a single setpoint by name or number (one round-trip).
|
|
528
596
|
|
|
529
597
|
Args:
|
|
@@ -543,8 +611,8 @@ class Controller:
|
|
|
543
611
|
def _coerce_setpoint_value(
|
|
544
612
|
self,
|
|
545
613
|
desc: SetpointDescription,
|
|
546
|
-
value:
|
|
547
|
-
) ->
|
|
614
|
+
value: Value,
|
|
615
|
+
) -> Value:
|
|
548
616
|
"""Resolve and validate ``value`` for ``desc`` before encoding.
|
|
549
617
|
|
|
550
618
|
- ``STRING_LIST`` + ``str``: looks up the label in
|
|
@@ -594,9 +662,7 @@ class Controller:
|
|
|
594
662
|
|
|
595
663
|
return value
|
|
596
664
|
|
|
597
|
-
async def set_setpoint(
|
|
598
|
-
self, name_or_number: str | int, value: int | float | str | bytes
|
|
599
|
-
) -> None:
|
|
665
|
+
async def set_setpoint(self, name_or_number: str | int, value: Value) -> None:
|
|
600
666
|
"""Write a setpoint by name or number.
|
|
601
667
|
|
|
602
668
|
Args:
|
|
@@ -627,7 +693,7 @@ class Controller:
|
|
|
627
693
|
"""
|
|
628
694
|
desc = self.setpoint_info(name_or_number)
|
|
629
695
|
if desc.needs_password:
|
|
630
|
-
await self.
|
|
696
|
+
await self.elevate_access()
|
|
631
697
|
value = self._coerce_setpoint_value(desc, value)
|
|
632
698
|
raw = _encode_setpoint_value(desc.data_type, desc.decimal_places, value)
|
|
633
699
|
await self._client.write_object(desc.number, raw)
|
|
@@ -744,7 +810,7 @@ class Controller:
|
|
|
744
810
|
Raises:
|
|
745
811
|
ComApAuthError: If write access is needed but no password was provided.
|
|
746
812
|
"""
|
|
747
|
-
await self.
|
|
813
|
+
await self.elevate_access()
|
|
748
814
|
effective_tz: datetime.tzinfo = tz if tz is not None else self._timezone
|
|
749
815
|
now = datetime.datetime.now(datetime.UTC).astimezone(effective_tz).replace(tzinfo=None)
|
|
750
816
|
await self._client.write_datetime(now)
|
|
@@ -754,24 +820,66 @@ class Controller:
|
|
|
754
820
|
async def _load_config(self) -> None:
|
|
755
821
|
self._config_data = await self._client.read_object(CommunicationObject.CONFIGURATION_TABLE)
|
|
756
822
|
self._table = parse_configuration_table(self._config_data)
|
|
757
|
-
# Build name → description lookup maps. If names are duplicated, first wins.
|
|
758
823
|
self._values_by_number = {v.number: v for v in self._table.values}
|
|
759
824
|
self._values_by_name = {}
|
|
825
|
+
self._ambiguous_value_names = set()
|
|
760
826
|
for v in self._table.values:
|
|
761
|
-
|
|
827
|
+
if v.name in self._values_by_name:
|
|
828
|
+
self._ambiguous_value_names.add(v.name)
|
|
829
|
+
else:
|
|
830
|
+
self._values_by_name[v.name] = v
|
|
762
831
|
self._setpoints_by_number = {s.number: s for s in self._table.setpoints}
|
|
763
832
|
self._setpoints_by_name = {}
|
|
833
|
+
self._ambiguous_setpoint_names = set()
|
|
764
834
|
for s in self._table.setpoints:
|
|
765
|
-
|
|
835
|
+
if s.name in self._setpoints_by_name:
|
|
836
|
+
self._ambiguous_setpoint_names.add(s.name)
|
|
837
|
+
else:
|
|
838
|
+
self._setpoints_by_name[s.name] = s
|
|
766
839
|
self._common_names = parse_names_heap(self._config_data, NamesCategory.COMMON_NAMES)
|
|
767
840
|
await self.refresh_timezone()
|
|
841
|
+
await self._cache_one_time_values()
|
|
768
842
|
_log.info(
|
|
769
|
-
"configuration loaded: %d values, %d setpoints",
|
|
843
|
+
"configuration loaded: %d values, %d setpoints, %d one-time",
|
|
770
844
|
len(self._table.values),
|
|
771
845
|
len(self._table.setpoints),
|
|
846
|
+
len(self._one_time_values),
|
|
772
847
|
)
|
|
773
848
|
|
|
774
|
-
async def
|
|
849
|
+
async def _cache_one_time_values(self) -> None:
|
|
850
|
+
descs = [
|
|
851
|
+
v
|
|
852
|
+
for v in self.table.values
|
|
853
|
+
if v.category is ValueCategory.ONE_TIME
|
|
854
|
+
and (self._include_invisible or v.group != "Invisible")
|
|
855
|
+
]
|
|
856
|
+
if not descs:
|
|
857
|
+
return
|
|
858
|
+
raw_map: dict[int, RawValue] = {}
|
|
859
|
+
for desc in descs:
|
|
860
|
+
try:
|
|
861
|
+
raw = await self._client.read_object(desc.number)
|
|
862
|
+
except Exception as exc:
|
|
863
|
+
_log.warning(
|
|
864
|
+
"failed to read ONE_TIME value %r (number %d): %s",
|
|
865
|
+
desc.name,
|
|
866
|
+
desc.number,
|
|
867
|
+
exc,
|
|
868
|
+
)
|
|
869
|
+
continue
|
|
870
|
+
raw_map[desc.number] = decode_raw_value(desc.data_type, raw, desc.decimal_places)
|
|
871
|
+
self._one_time_values = self._resolve_raws(raw_map, self._values_by_number)
|
|
872
|
+
|
|
873
|
+
async def elevate_access(self) -> None:
|
|
874
|
+
"""Send the write-protection password to the controller, enabling protected writes.
|
|
875
|
+
|
|
876
|
+
Idempotent — safe to call multiple times; the password is only sent once.
|
|
877
|
+
Call this early to validate the password without waiting for the first write.
|
|
878
|
+
|
|
879
|
+
Raises:
|
|
880
|
+
ComApAuthError: If no password was supplied to the ``Controller``.
|
|
881
|
+
ComApInvalidPasswordError: If the controller rejects the password.
|
|
882
|
+
"""
|
|
775
883
|
if self._elevated:
|
|
776
884
|
return
|
|
777
885
|
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/
|
|
@@ -21,6 +21,7 @@ import logging
|
|
|
21
21
|
import socket
|
|
22
22
|
import struct
|
|
23
23
|
from dataclasses import dataclass, field
|
|
24
|
+
from ipaddress import IPv4Address
|
|
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
|
|
@@ -147,7 +148,7 @@ def _parse_device(data: bytes) -> DiscoveryDevice:
|
|
|
147
148
|
|
|
148
149
|
async def discover(
|
|
149
150
|
timeout: float = 2.0,
|
|
150
|
-
broadcast_address: str = "255.255.255.255",
|
|
151
|
+
broadcast_address: str | IPv4Address = "255.255.255.255",
|
|
151
152
|
) -> list[DiscoveryDevice]:
|
|
152
153
|
"""Broadcast a discovery probe and collect replies for ``timeout`` seconds.
|
|
153
154
|
|
|
@@ -161,11 +162,11 @@ async def discover(
|
|
|
161
162
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
|
162
163
|
sock.setblocking(False)
|
|
163
164
|
|
|
164
|
-
found: dict[
|
|
165
|
+
found: dict[IPv4Address, DiscoveryDevice] = {}
|
|
165
166
|
try:
|
|
166
167
|
sock.bind(("", 0))
|
|
167
168
|
_log.debug("sending discovery probe to %s:%d", broadcast_address, DISCOVERY_PORT)
|
|
168
|
-
sock.sendto(_build_probe(), (broadcast_address, DISCOVERY_PORT))
|
|
169
|
+
sock.sendto(_build_probe(), (str(broadcast_address), DISCOVERY_PORT))
|
|
169
170
|
|
|
170
171
|
end_time = loop.time() + timeout
|
|
171
172
|
while True:
|
|
@@ -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
|
|
|
@@ -24,8 +24,9 @@ from types import TracebackType
|
|
|
24
24
|
|
|
25
25
|
from pycomap.datatypes import decode_fdate, decode_ftime, encode_fdate, encode_ftime
|
|
26
26
|
from pycomap.exceptions import (
|
|
27
|
-
ComApAuthError,
|
|
28
27
|
ComApControllerError,
|
|
28
|
+
ComApInvalidAccessCodeError,
|
|
29
|
+
ComApInvalidPasswordError,
|
|
29
30
|
ComApProtocolError,
|
|
30
31
|
)
|
|
31
32
|
from pycomap.protocol import crypto
|
|
@@ -114,7 +115,7 @@ class ComApClient:
|
|
|
114
115
|
access_code: The controller's base AccessCode (drives ECDH/AES key derivation).
|
|
115
116
|
|
|
116
117
|
Raises:
|
|
117
|
-
|
|
118
|
+
ComApInvalidAccessCodeError: If the controller rejects the access code.
|
|
118
119
|
ComApProtocolError: If the ECDH exchange produces an unexpected response.
|
|
119
120
|
"""
|
|
120
121
|
server_pub_data = await self.read_object(CommunicationObject.ECDH_PUBLIC_KEY)
|
|
@@ -136,13 +137,13 @@ class ComApClient:
|
|
|
136
137
|
nonce = await self.read_object(CommunicationObject.VERIFY_ACCESS_HASH)
|
|
137
138
|
except ComApControllerError as exc:
|
|
138
139
|
if exc.code not in _AUTH_FALLBACK_CODES:
|
|
139
|
-
raise
|
|
140
|
+
raise ComApInvalidAccessCodeError("access verification failed") from exc
|
|
140
141
|
_log.warning("hash auth not supported by controller, falling back to plain access code")
|
|
141
142
|
source = access_code.encode("ascii").ljust(16, b"\x00")
|
|
142
143
|
try:
|
|
143
144
|
await self.write_object(CommunicationObject.VERIFY_ACCESS, source)
|
|
144
145
|
except ComApControllerError as exc2:
|
|
145
|
-
raise
|
|
146
|
+
raise ComApInvalidAccessCodeError("access code rejected by controller") from exc2
|
|
146
147
|
_log.info("authenticated via plain access code")
|
|
147
148
|
else:
|
|
148
149
|
digest = hashlib.md5(nonce + access_code.encode("ascii")).digest()
|
|
@@ -150,7 +151,7 @@ class ComApClient:
|
|
|
150
151
|
try:
|
|
151
152
|
await self.write_object(CommunicationObject.VERIFY_ACCESS_HASH, credentials)
|
|
152
153
|
except ComApControllerError as exc3:
|
|
153
|
-
raise
|
|
154
|
+
raise ComApInvalidAccessCodeError("access code rejected by controller") from exc3
|
|
154
155
|
_log.info("authenticated via hash")
|
|
155
156
|
|
|
156
157
|
async def elevate_access(self, password: int) -> None:
|
|
@@ -175,7 +176,7 @@ class ComApClient:
|
|
|
175
176
|
CommunicationObject.PASSWORD_FOR_WRITE, struct.pack("<H", password)
|
|
176
177
|
)
|
|
177
178
|
except ComApControllerError as exc:
|
|
178
|
-
raise
|
|
179
|
+
raise ComApInvalidPasswordError(
|
|
179
180
|
"password rejected by controller (wrong password or brute-force lockout active)"
|
|
180
181
|
) from exc
|
|
181
182
|
_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,13 +49,13 @@ class EthernetTransport:
|
|
|
48
49
|
ECDH/AES framing on top.
|
|
49
50
|
"""
|
|
50
51
|
|
|
51
|
-
def __init__(self, host: str, port: int = DEFAULT_PORT) -> None:
|
|
52
|
+
def __init__(self, host: IPv4Address | str, port: int = DEFAULT_PORT) -> None:
|
|
52
53
|
"""
|
|
53
54
|
Args:
|
|
54
|
-
host: Controller IP address or hostname.
|
|
55
|
+
host: Controller IP address (``IPv4Address`` or dotted string) or hostname.
|
|
55
56
|
port: TCP port; defaults to ``23`` (ComAp native protocol port).
|
|
56
57
|
"""
|
|
57
|
-
self._host = host
|
|
58
|
+
self._host = str(host)
|
|
58
59
|
self._port = port
|
|
59
60
|
self._reader: asyncio.StreamReader | None = None
|
|
60
61
|
self._writer: asyncio.StreamWriter | None = None
|
|
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
|