pycomap 1.0.0__py3-none-any.whl
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/__init__.py +56 -0
- pycomap/alarms.py +100 -0
- pycomap/configuration.py +546 -0
- pycomap/controller.py +790 -0
- pycomap/datatypes.py +202 -0
- pycomap/discovery.py +195 -0
- pycomap/exceptions.py +35 -0
- pycomap/history.py +166 -0
- pycomap/protocol/__init__.py +24 -0
- pycomap/protocol/client.py +357 -0
- pycomap/protocol/commands.py +62 -0
- pycomap/protocol/crc.py +20 -0
- pycomap/protocol/crypto.py +90 -0
- pycomap/protocol/framing.py +145 -0
- pycomap/protocol/objects.py +98 -0
- pycomap/protocol/transport.py +89 -0
- pycomap/py.typed +0 -0
- pycomap-1.0.0.dist-info/METADATA +57 -0
- pycomap-1.0.0.dist-info/RECORD +20 -0
- pycomap-1.0.0.dist-info/WHEEL +4 -0
pycomap/controller.py
ADDED
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
"""High-level ``Controller`` — a caching, name-aware client for ComAp controllers.
|
|
2
|
+
|
|
3
|
+
``Controller`` wraps a ``ComApClient`` and fetches the ``ConfigurationTable`` once on
|
|
4
|
+
connect, enabling value/setpoint lookup by human-readable name, transparent password
|
|
5
|
+
elevation for protected setpoints, and timezone-aware time synchronisation.
|
|
6
|
+
|
|
7
|
+
Typical usage::
|
|
8
|
+
|
|
9
|
+
import pytz
|
|
10
|
+
from pycomap import Controller
|
|
11
|
+
from pycomap.protocol import ComApClient
|
|
12
|
+
from pycomap.protocol.transport import EthernetTransport
|
|
13
|
+
|
|
14
|
+
tz = pytz.timezone("Europe/Kiev")
|
|
15
|
+
async with Controller(
|
|
16
|
+
ComApClient(EthernetTransport("192.168.1.9")),
|
|
17
|
+
access_code="0",
|
|
18
|
+
password=1234,
|
|
19
|
+
) as ctrl:
|
|
20
|
+
values = await ctrl.read_values()
|
|
21
|
+
await ctrl.set_setpoint("Nominal RPM", 1500)
|
|
22
|
+
await ctrl.sync_time(tz=tz)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import datetime
|
|
28
|
+
import logging
|
|
29
|
+
import re
|
|
30
|
+
from types import TracebackType
|
|
31
|
+
|
|
32
|
+
from pycomap.alarms import AlarmRecord, parse_alarm_list
|
|
33
|
+
from pycomap.configuration import (
|
|
34
|
+
ConfigurationTable,
|
|
35
|
+
NamesCategory,
|
|
36
|
+
SetpointDescription,
|
|
37
|
+
ValueDescription,
|
|
38
|
+
ValueState,
|
|
39
|
+
decode_history_snapshot,
|
|
40
|
+
decode_setpoints_all,
|
|
41
|
+
decode_states_all,
|
|
42
|
+
decode_values_all,
|
|
43
|
+
parse_configuration_table,
|
|
44
|
+
parse_names_heap,
|
|
45
|
+
)
|
|
46
|
+
from pycomap.datatypes import (
|
|
47
|
+
_BINARY_TYPES,
|
|
48
|
+
_DATA_TYPE_LENGTH,
|
|
49
|
+
DataType,
|
|
50
|
+
decode_raw_value,
|
|
51
|
+
encode_raw_value,
|
|
52
|
+
)
|
|
53
|
+
from pycomap.exceptions import ComApAuthError, ComApProtocolError
|
|
54
|
+
from pycomap.history import HistoryRecord, parse_history_record
|
|
55
|
+
from pycomap.protocol.client import ComApClient
|
|
56
|
+
from pycomap.protocol.commands import ControllerCommand
|
|
57
|
+
from pycomap.protocol.objects import CommunicationObject
|
|
58
|
+
|
|
59
|
+
_log = logging.getLogger(__name__)
|
|
60
|
+
|
|
61
|
+
_STRING_TYPES = frozenset(
|
|
62
|
+
{
|
|
63
|
+
DataType.SHORT_STRING,
|
|
64
|
+
DataType.LONG_STRING,
|
|
65
|
+
DataType.HUGE_STRING,
|
|
66
|
+
DataType.IP_ADDRESS,
|
|
67
|
+
DataType.TELEPHONE_NUMBER,
|
|
68
|
+
DataType.EMAIL,
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
_INDEX_TYPES = frozenset({DataType.STRING_LIST, DataType.CHAR})
|
|
73
|
+
|
|
74
|
+
# Types for which low_limit/high_limit represent a valid numeric range to enforce.
|
|
75
|
+
_RANGE_VALIDATABLE = frozenset(
|
|
76
|
+
{
|
|
77
|
+
DataType.INTEGER8,
|
|
78
|
+
DataType.INTEGER16,
|
|
79
|
+
DataType.INTEGER32,
|
|
80
|
+
DataType.UNSIGNED8,
|
|
81
|
+
DataType.UNSIGNED16,
|
|
82
|
+
DataType.UNSIGNED32,
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Human-readable setpoint names used to look up timezone configuration.
|
|
87
|
+
# Looked up by name at connect time so no hardcoded comm object numbers are needed.
|
|
88
|
+
_SETPOINT_TIME_ZONE = "Time Zone"
|
|
89
|
+
_SETPOINT_SUMMER_TIME_MODE = "Summer Time Mode"
|
|
90
|
+
|
|
91
|
+
# Summer Time Mode option labels that indicate DST is active (+1 h on top of base offset).
|
|
92
|
+
_SUMMER_MODE_DST_LABELS = frozenset({"Summer", "Summer-S"})
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _parse_gmt_label(label: str) -> datetime.timedelta | None:
|
|
96
|
+
"""Parse a ComAp timezone label like ``'GMT+2:00'`` or ``'GMT-3:30'`` into a
|
|
97
|
+
``timedelta``. Returns ``None`` if the label cannot be parsed.
|
|
98
|
+
"""
|
|
99
|
+
m = re.fullmatch(r"GMT([+-])(\d{1,2}):(\d{2})", label.strip())
|
|
100
|
+
if not m:
|
|
101
|
+
return None
|
|
102
|
+
sign = 1 if m.group(1) == "+" else -1
|
|
103
|
+
hours, minutes = int(m.group(2)), int(m.group(3))
|
|
104
|
+
if hours > 23 or minutes > 59:
|
|
105
|
+
return None
|
|
106
|
+
return sign * datetime.timedelta(hours=hours, minutes=minutes)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _encode_setpoint_value(
|
|
110
|
+
data_type: DataType,
|
|
111
|
+
decimal_places: int,
|
|
112
|
+
value: int | float | str | bytes,
|
|
113
|
+
) -> bytes:
|
|
114
|
+
if isinstance(value, bytes):
|
|
115
|
+
return value
|
|
116
|
+
if isinstance(value, str):
|
|
117
|
+
if data_type not in _STRING_TYPES:
|
|
118
|
+
raise ComApProtocolError(
|
|
119
|
+
f"str value given for DataType.{data_type.name}; "
|
|
120
|
+
"expected int/float or pass bytes directly"
|
|
121
|
+
)
|
|
122
|
+
encoded = value.encode("ascii")
|
|
123
|
+
length = _DATA_TYPE_LENGTH[data_type]
|
|
124
|
+
return encoded[:length].ljust(length, b"\x00")
|
|
125
|
+
if data_type in _INDEX_TYPES:
|
|
126
|
+
return bytes([int(value)])
|
|
127
|
+
return encode_raw_value(data_type, value, decimal_places)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class Controller:
|
|
131
|
+
"""High-level async client for a ComAp controller.
|
|
132
|
+
|
|
133
|
+
Fetches and caches the ``ConfigurationTable`` on [connect][pycomap.Controller.connect],
|
|
134
|
+
which enables
|
|
135
|
+
name-based lookup for all subsequent calls. Password elevation for write-protected
|
|
136
|
+
setpoints is handled automatically (lazy, on first protected write) when ``password``
|
|
137
|
+
is provided.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
client: A ``ComApClient`` wrapping any transport. The ``Controller`` takes
|
|
141
|
+
ownership of the connection lifecycle — do not call ``client.connect()`` yourself.
|
|
142
|
+
access_code: The controller's AccessCode (base/anonymous read-only code, often
|
|
143
|
+
``"0"``). Drives ECDH/AES key derivation — **not** the write password.
|
|
144
|
+
password: The write-protection password (integer 0-9999). Required for setpoints
|
|
145
|
+
with ``access_level > 0``; omit to operate in read-only mode.
|
|
146
|
+
|
|
147
|
+
Examples:
|
|
148
|
+
Read-only access::
|
|
149
|
+
|
|
150
|
+
async with Controller(
|
|
151
|
+
ComApClient(EthernetTransport("192.168.1.9")), access_code="0"
|
|
152
|
+
) as ctrl:
|
|
153
|
+
values = await ctrl.read_values()
|
|
154
|
+
|
|
155
|
+
With write access::
|
|
156
|
+
|
|
157
|
+
async with Controller(
|
|
158
|
+
ComApClient(EthernetTransport("192.168.1.9")),
|
|
159
|
+
access_code="0",
|
|
160
|
+
password=1234,
|
|
161
|
+
) as ctrl:
|
|
162
|
+
await ctrl.set_setpoint("Nominal RPM", 1500)
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
def __init__(
|
|
166
|
+
self,
|
|
167
|
+
client: ComApClient,
|
|
168
|
+
access_code: str,
|
|
169
|
+
password: int | None = None,
|
|
170
|
+
) -> None:
|
|
171
|
+
self._client = client
|
|
172
|
+
self._access_code = access_code
|
|
173
|
+
self._password = password
|
|
174
|
+
self._elevated = False
|
|
175
|
+
self._config_data: bytes | None = None
|
|
176
|
+
self._table: ConfigurationTable | None = None
|
|
177
|
+
self._values_by_name: dict[str, ValueDescription] = {}
|
|
178
|
+
self._values_by_number: dict[int, ValueDescription] = {}
|
|
179
|
+
self._setpoints_by_name: dict[str, SetpointDescription] = {}
|
|
180
|
+
self._setpoints_by_number: dict[int, SetpointDescription] = {}
|
|
181
|
+
self._common_names: list[str] = []
|
|
182
|
+
self._timezone: datetime.timezone = datetime.UTC
|
|
183
|
+
self._summer_time_mode_raw: int = 0
|
|
184
|
+
|
|
185
|
+
# -- connection lifecycle -------------------------------------------------
|
|
186
|
+
|
|
187
|
+
async def connect(self) -> None:
|
|
188
|
+
"""Open the transport, authenticate, and fetch the ``ConfigurationTable``."""
|
|
189
|
+
await self._client.connect()
|
|
190
|
+
await self._client.authenticate(self._access_code)
|
|
191
|
+
await self._load_config()
|
|
192
|
+
|
|
193
|
+
async def close(self) -> None:
|
|
194
|
+
"""Close the underlying transport."""
|
|
195
|
+
await self._client.close()
|
|
196
|
+
|
|
197
|
+
async def __aenter__(self) -> Controller:
|
|
198
|
+
await self.connect()
|
|
199
|
+
return self
|
|
200
|
+
|
|
201
|
+
async def __aexit__(
|
|
202
|
+
self,
|
|
203
|
+
exc_type: type[BaseException] | None,
|
|
204
|
+
exc: BaseException | None,
|
|
205
|
+
tb: TracebackType | None,
|
|
206
|
+
) -> None:
|
|
207
|
+
await self.close()
|
|
208
|
+
|
|
209
|
+
# -- properties ----------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def client(self) -> ComApClient:
|
|
213
|
+
"""The underlying low-level client (escape hatch for direct comm object access)."""
|
|
214
|
+
return self._client
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def table(self) -> ConfigurationTable:
|
|
218
|
+
"""The cached ``ConfigurationTable``.
|
|
219
|
+
|
|
220
|
+
Available after [connect][pycomap.Controller.connect].
|
|
221
|
+
"""
|
|
222
|
+
if self._table is None:
|
|
223
|
+
raise ComApProtocolError("not connected — call connect() first")
|
|
224
|
+
return self._table
|
|
225
|
+
|
|
226
|
+
# -- listing -------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def values(self) -> list[ValueDescription]:
|
|
230
|
+
"""All value descriptions from the cached ``ConfigurationTable``."""
|
|
231
|
+
return self.table.values
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def setpoints(self) -> list[SetpointDescription]:
|
|
235
|
+
"""All setpoint descriptions from the cached ``ConfigurationTable``."""
|
|
236
|
+
return self.table.setpoints
|
|
237
|
+
|
|
238
|
+
# -- name / number resolution --------------------------------------------
|
|
239
|
+
|
|
240
|
+
def value_info(self, name_or_number: str | int) -> ValueDescription:
|
|
241
|
+
"""Look up a value description by name or comm object number.
|
|
242
|
+
|
|
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
|
+
Args:
|
|
247
|
+
name_or_number: Exact value name (e.g. ``"RPM"``) or comm object number.
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
The matching ``ValueDescription``.
|
|
251
|
+
|
|
252
|
+
Raises:
|
|
253
|
+
KeyError: If no value with that name or number exists.
|
|
254
|
+
"""
|
|
255
|
+
if isinstance(name_or_number, int):
|
|
256
|
+
try:
|
|
257
|
+
return self._values_by_number[name_or_number]
|
|
258
|
+
except KeyError:
|
|
259
|
+
raise KeyError(f"no value with number {name_or_number}") from None
|
|
260
|
+
try:
|
|
261
|
+
return self._values_by_name[name_or_number]
|
|
262
|
+
except KeyError:
|
|
263
|
+
raise KeyError(f"no value named {name_or_number!r}") from None
|
|
264
|
+
|
|
265
|
+
def setpoint_info(self, name_or_number: str | int) -> SetpointDescription:
|
|
266
|
+
"""Look up a setpoint description by name or comm object number.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
name_or_number: Exact setpoint name (e.g. ``"Nominal RPM"``) or comm object number.
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
The matching ``SetpointDescription``.
|
|
273
|
+
|
|
274
|
+
Raises:
|
|
275
|
+
KeyError: If no setpoint with that name or number exists.
|
|
276
|
+
"""
|
|
277
|
+
if isinstance(name_or_number, int):
|
|
278
|
+
try:
|
|
279
|
+
return self._setpoints_by_number[name_or_number]
|
|
280
|
+
except KeyError:
|
|
281
|
+
raise KeyError(f"no setpoint with number {name_or_number}") from None
|
|
282
|
+
try:
|
|
283
|
+
return self._setpoints_by_name[name_or_number]
|
|
284
|
+
except KeyError:
|
|
285
|
+
raise KeyError(f"no setpoint named {name_or_number!r}") from None
|
|
286
|
+
|
|
287
|
+
def setpoint_options(self, name_or_number: str | int) -> list[tuple[int, str]]:
|
|
288
|
+
"""Return the available options for a ``STRING_LIST`` setpoint.
|
|
289
|
+
|
|
290
|
+
Options are stored in ``CommonNames`` at indices ``[low_limit .. high_limit]``.
|
|
291
|
+
The wire value (0-based) is what you pass to
|
|
292
|
+
[set_setpoint][pycomap.Controller.set_setpoint]; the label is
|
|
293
|
+
the string shown on the front panel and in InteliConfig.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
name_or_number: Setpoint name or comm object number.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
``[(wire_value, label), ...]`` ordered by wire value.
|
|
300
|
+
|
|
301
|
+
Raises:
|
|
302
|
+
ComApProtocolError: If the setpoint is not ``STRING_LIST`` type.
|
|
303
|
+
|
|
304
|
+
Examples:
|
|
305
|
+
>>> ctrl.setpoint_options("Summer Time Mode")
|
|
306
|
+
[(0, 'Disabled'), (1, 'Winter'), (2, 'Summer'), (3, 'Winter-S'), (4, 'Summer-S')]
|
|
307
|
+
"""
|
|
308
|
+
desc = self.setpoint_info(name_or_number)
|
|
309
|
+
if desc.data_type is not DataType.STRING_LIST:
|
|
310
|
+
raise ComApProtocolError(
|
|
311
|
+
f"setpoint {desc.name!r} is DataType.{desc.data_type.name}, not STRING_LIST"
|
|
312
|
+
)
|
|
313
|
+
return [
|
|
314
|
+
(wire_value, self._common_names[desc.low_limit + wire_value])
|
|
315
|
+
for wire_value in range(desc.high_limit - desc.low_limit + 1)
|
|
316
|
+
if desc.low_limit + wire_value < len(self._common_names)
|
|
317
|
+
]
|
|
318
|
+
|
|
319
|
+
def value_label(self, name_or_number: str | int, wire_value: int) -> str:
|
|
320
|
+
"""Return the display label for a ``STRING_LIST`` value's wire integer.
|
|
321
|
+
|
|
322
|
+
Label = ``CommonNames[low_limit + wire_value]``. Note that
|
|
323
|
+
[read_values][pycomap.Controller.read_values] resolves ``STRING_LIST`` values
|
|
324
|
+
automatically — this
|
|
325
|
+
method is for cases where you have a raw wire integer and need the label.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
name_or_number: Value name or comm object number.
|
|
329
|
+
wire_value: Raw 0-based wire integer (as returned by the controller).
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
Human-readable label string, or ``str(wire_value)`` if out of range.
|
|
333
|
+
|
|
334
|
+
Raises:
|
|
335
|
+
ComApProtocolError: If the value is not ``STRING_LIST`` type.
|
|
336
|
+
"""
|
|
337
|
+
desc = self.value_info(name_or_number)
|
|
338
|
+
if desc.data_type is not DataType.STRING_LIST:
|
|
339
|
+
raise ComApProtocolError(
|
|
340
|
+
f"value {desc.name!r} is DataType.{desc.data_type.name}, not STRING_LIST"
|
|
341
|
+
)
|
|
342
|
+
idx = desc.low_limit + wire_value
|
|
343
|
+
if idx >= len(self._common_names):
|
|
344
|
+
return str(wire_value)
|
|
345
|
+
return self._common_names[idx]
|
|
346
|
+
|
|
347
|
+
def value_bit_names(self, name_or_number: str | int) -> list[tuple[int, str]]:
|
|
348
|
+
"""Return the bit labels for a ``BINARY*`` value.
|
|
349
|
+
|
|
350
|
+
Labels come from ``CommonNames`` starting at ``bit_name_index``; bits without a
|
|
351
|
+
name are omitted from the result.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
name_or_number: Value name or comm object number.
|
|
355
|
+
|
|
356
|
+
Returns:
|
|
357
|
+
``[(bit_index, label), ...]``, bit 0 = LSB, ascending order.
|
|
358
|
+
|
|
359
|
+
Raises:
|
|
360
|
+
ComApProtocolError: If the value is not a ``BINARY*`` type or has no bit labels.
|
|
361
|
+
"""
|
|
362
|
+
desc = self.value_info(name_or_number)
|
|
363
|
+
if desc.data_type not in _BINARY_TYPES:
|
|
364
|
+
raise ComApProtocolError(
|
|
365
|
+
f"value {desc.name!r} is DataType.{desc.data_type.name}, not a BINARY type"
|
|
366
|
+
)
|
|
367
|
+
if desc.bit_name_index is None:
|
|
368
|
+
raise ComApProtocolError(f"value {desc.name!r} has no bit name labels")
|
|
369
|
+
num_bits = _DATA_TYPE_LENGTH[desc.data_type] * 8
|
|
370
|
+
return [
|
|
371
|
+
(bit, self._common_names[desc.bit_name_index + bit])
|
|
372
|
+
for bit in range(num_bits)
|
|
373
|
+
if desc.bit_name_index + bit < len(self._common_names)
|
|
374
|
+
]
|
|
375
|
+
|
|
376
|
+
# -- bulk reads ----------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
def _resolve_raws(
|
|
379
|
+
self,
|
|
380
|
+
raw: dict[int, int | float | bytes],
|
|
381
|
+
by_number: dict[int, ValueDescription] | dict[int, SetpointDescription],
|
|
382
|
+
) -> dict[int, int | float | bytes | str]:
|
|
383
|
+
"""Resolve ``STRING_LIST`` wire bytes to labels and text-typed bytes to strings.
|
|
384
|
+
|
|
385
|
+
Works for both value and setpoint dicts. ``STRING_LIST`` entries arrive as 1-byte
|
|
386
|
+
blobs; the byte is a 0-based offset from ``low_limit`` into ``CommonNames``.
|
|
387
|
+
Text-typed entries (``SHORT_STRING``, ``IP_ADDRESS``, etc.) arrive as null-padded
|
|
388
|
+
byte strings and are decoded to ASCII.
|
|
389
|
+
"""
|
|
390
|
+
result: dict[int, int | float | bytes | str] = {}
|
|
391
|
+
for number, val in raw.items():
|
|
392
|
+
if not isinstance(val, bytes):
|
|
393
|
+
result[number] = val
|
|
394
|
+
continue
|
|
395
|
+
desc = by_number.get(number)
|
|
396
|
+
if desc is None:
|
|
397
|
+
result[number] = val
|
|
398
|
+
elif desc.data_type is DataType.STRING_LIST:
|
|
399
|
+
wire = val[0]
|
|
400
|
+
idx = desc.low_limit + wire
|
|
401
|
+
result[number] = (
|
|
402
|
+
self._common_names[idx] if idx < len(self._common_names) else str(wire)
|
|
403
|
+
)
|
|
404
|
+
elif desc.data_type in _STRING_TYPES:
|
|
405
|
+
result[number] = val.rstrip(b"\x00").decode("ascii", "replace")
|
|
406
|
+
else:
|
|
407
|
+
result[number] = val
|
|
408
|
+
return result
|
|
409
|
+
|
|
410
|
+
async def read_values(self) -> dict[int, int | float | bytes | str]:
|
|
411
|
+
"""Read all values from ``ValuesAll`` (C.O. 24560).
|
|
412
|
+
|
|
413
|
+
``STRING_LIST`` values are resolved to their display label. Text-typed values
|
|
414
|
+
(``SHORT_STRING``, ``IP_ADDRESS``, etc.) are decoded to ASCII ``str``. All other
|
|
415
|
+
values are ``int``, ``float``, or raw ``bytes`` (binary, domain, timer types).
|
|
416
|
+
|
|
417
|
+
Returns:
|
|
418
|
+
``{comm_object_number: value}`` for every value in the controller's table
|
|
419
|
+
except ``ONE_TIME`` category values, which are excluded from ``ValuesAll``.
|
|
420
|
+
|
|
421
|
+
Examples:
|
|
422
|
+
>>> values = await ctrl.read_values()
|
|
423
|
+
>>> rpm_num = ctrl.value_info("RPM").number
|
|
424
|
+
>>> values[rpm_num]
|
|
425
|
+
1450
|
|
426
|
+
"""
|
|
427
|
+
data = await self._client.read_object(CommunicationObject.VALUES_ALL)
|
|
428
|
+
return self._resolve_raws(decode_values_all(self.table, data), self._values_by_number)
|
|
429
|
+
|
|
430
|
+
async def read_setpoints(self) -> dict[int, int | float | bytes | str]:
|
|
431
|
+
"""Read all setpoints from ``SetpointsAll`` (C.O. 24559).
|
|
432
|
+
|
|
433
|
+
``STRING_LIST`` setpoints are resolved to their display label, matching what
|
|
434
|
+
[set_setpoint][pycomap.Controller.set_setpoint] accepts for a clean
|
|
435
|
+
read-modify-write round-trip.
|
|
436
|
+
Text-typed setpoints are decoded to ASCII ``str``.
|
|
437
|
+
|
|
438
|
+
Returns:
|
|
439
|
+
``{comm_object_number: value}`` for every setpoint in the controller's table.
|
|
440
|
+
"""
|
|
441
|
+
data = await self._client.read_object(CommunicationObject.SETPOINTS_ALL)
|
|
442
|
+
return self._resolve_raws(decode_setpoints_all(self.table, data), self._setpoints_by_number)
|
|
443
|
+
|
|
444
|
+
async def read_states(self) -> dict[int, ValueState]:
|
|
445
|
+
"""Read all value protection states (``ValueStatesAll``, C.O. 24555)."""
|
|
446
|
+
data = await self._client.read_object(CommunicationObject.VALUE_STATES_ALL)
|
|
447
|
+
return decode_states_all(self.table, data)
|
|
448
|
+
|
|
449
|
+
async def read_alarms(self) -> list[AlarmRecord]:
|
|
450
|
+
"""Read the current alarm list (``AlarmList``, C.O. 24545)."""
|
|
451
|
+
if self._config_data is None:
|
|
452
|
+
raise ComApProtocolError("not connected — call connect() first")
|
|
453
|
+
data = await self._client.read_object(CommunicationObject.ALARM_LIST)
|
|
454
|
+
return parse_alarm_list(self._config_data, data)
|
|
455
|
+
|
|
456
|
+
async def read_history(self, count: int = 10) -> list[HistoryRecord]:
|
|
457
|
+
"""Read up to ``count`` of the most recent history records, newest first."""
|
|
458
|
+
if self._config_data is None:
|
|
459
|
+
raise ComApProtocolError("not connected — call connect() first")
|
|
460
|
+
records: list[HistoryRecord] = []
|
|
461
|
+
raw = await self._client.read_object(CommunicationObject.YOUNGEST_HISTORY_RECORD)
|
|
462
|
+
rec = parse_history_record(self._config_data, raw)
|
|
463
|
+
if rec:
|
|
464
|
+
records.append(rec)
|
|
465
|
+
for _ in range(count - 1):
|
|
466
|
+
if len(records) >= count:
|
|
467
|
+
break
|
|
468
|
+
raw = await self._client.read_object(CommunicationObject.OLDER_HISTORY_RECORD)
|
|
469
|
+
rec = parse_history_record(self._config_data, raw)
|
|
470
|
+
if rec:
|
|
471
|
+
records.append(rec)
|
|
472
|
+
return records
|
|
473
|
+
|
|
474
|
+
def decode_history_snapshot(
|
|
475
|
+
self, record: HistoryRecord
|
|
476
|
+
) -> dict[int, int | float | bytes | str]:
|
|
477
|
+
"""Decode the value snapshot embedded in an alarm ``HistoryRecord``.
|
|
478
|
+
|
|
479
|
+
Alarm/event records carry a snapshot of the ``ValuesAll`` blob captured at the
|
|
480
|
+
moment of the event. Returns ``{number: decoded_value}`` for every value whose
|
|
481
|
+
data fits within the snapshot; the set of values is typically the first ~31 entries
|
|
482
|
+
from the controller's value table (those with small ``data_index`` values).
|
|
483
|
+
|
|
484
|
+
Returns an empty dict for text records (``record.is_text=True``) or records with
|
|
485
|
+
no embedded data.
|
|
486
|
+
|
|
487
|
+
Use [value_info][pycomap.Controller.value_info] to look up a number's name and metadata::
|
|
488
|
+
|
|
489
|
+
snapshot = ctrl.decode_history_snapshot(rec)
|
|
490
|
+
for number, val in snapshot.items():
|
|
491
|
+
info = ctrl.value_info(number)
|
|
492
|
+
print(f"{info.name}: {val}")
|
|
493
|
+
"""
|
|
494
|
+
return self._resolve_raws(
|
|
495
|
+
decode_history_snapshot(self.table, record.data), self._values_by_number
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
# -- individual read/write -----------------------------------------------
|
|
499
|
+
|
|
500
|
+
async def read_value(self, name_or_number: str | int) -> int | float | bytes | str:
|
|
501
|
+
"""Read a single value by name or number.
|
|
502
|
+
|
|
503
|
+
Reads ``ValuesAll`` internally — use
|
|
504
|
+
[read_values][pycomap.Controller.read_values] when you need multiple
|
|
505
|
+
values to avoid redundant round-trips.
|
|
506
|
+
|
|
507
|
+
Args:
|
|
508
|
+
name_or_number: Value name or comm object number.
|
|
509
|
+
|
|
510
|
+
Returns:
|
|
511
|
+
Decoded value; same type rules as [read_values][pycomap.Controller.read_values].
|
|
512
|
+
|
|
513
|
+
Raises:
|
|
514
|
+
KeyError: If no value with that name or number exists.
|
|
515
|
+
ComApProtocolError: If the value is ``ONE_TIME`` category (not in ``ValuesAll``).
|
|
516
|
+
"""
|
|
517
|
+
desc = self.value_info(name_or_number)
|
|
518
|
+
values = await self.read_values()
|
|
519
|
+
if desc.number not in values:
|
|
520
|
+
raise ComApProtocolError(
|
|
521
|
+
f"value {desc.name!r} (number {desc.number}) is ONE_TIME and not "
|
|
522
|
+
"included in ValuesAll"
|
|
523
|
+
)
|
|
524
|
+
return values[desc.number]
|
|
525
|
+
|
|
526
|
+
async def read_setpoint(self, name_or_number: str | int) -> int | float | bytes | str:
|
|
527
|
+
"""Read a single setpoint by name or number (one round-trip).
|
|
528
|
+
|
|
529
|
+
Args:
|
|
530
|
+
name_or_number: Setpoint name or comm object number.
|
|
531
|
+
|
|
532
|
+
Returns:
|
|
533
|
+
Decoded value; same type rules as [read_setpoints][pycomap.Controller.read_setpoints].
|
|
534
|
+
|
|
535
|
+
Raises:
|
|
536
|
+
KeyError: If no setpoint with that name or number exists.
|
|
537
|
+
"""
|
|
538
|
+
desc = self.setpoint_info(name_or_number)
|
|
539
|
+
raw = await self._client.read_object(desc.number)
|
|
540
|
+
val = decode_raw_value(desc.data_type, raw, desc.decimal_places)
|
|
541
|
+
return self._resolve_raws({desc.number: val}, self._setpoints_by_number)[desc.number]
|
|
542
|
+
|
|
543
|
+
def _coerce_setpoint_value(
|
|
544
|
+
self,
|
|
545
|
+
desc: SetpointDescription,
|
|
546
|
+
value: int | float | str | bytes,
|
|
547
|
+
) -> int | float | str | bytes:
|
|
548
|
+
"""Resolve and validate ``value`` for ``desc`` before encoding.
|
|
549
|
+
|
|
550
|
+
- ``STRING_LIST`` + ``str``: looks up the label in
|
|
551
|
+
[setpoint_options][pycomap.Controller.setpoint_options] and
|
|
552
|
+
returns the matching wire index. Raises ``ValueError`` for unknown labels.
|
|
553
|
+
- ``STRING_LIST`` + ``int``: validates the index is in range (0..high-low).
|
|
554
|
+
- Integer / unsigned types: validates the scaled wire value against
|
|
555
|
+
``low_limit`` / ``high_limit``, skipping whichever bound has ``var_*=True``.
|
|
556
|
+
- All other types: returned unchanged.
|
|
557
|
+
|
|
558
|
+
``bytes`` values are always passed through without validation.
|
|
559
|
+
"""
|
|
560
|
+
if isinstance(value, bytes):
|
|
561
|
+
return value
|
|
562
|
+
|
|
563
|
+
if desc.data_type is DataType.STRING_LIST:
|
|
564
|
+
if isinstance(value, str):
|
|
565
|
+
by_label = {label: wire for wire, label in self.setpoint_options(desc.number)}
|
|
566
|
+
if value not in by_label:
|
|
567
|
+
raise ValueError(
|
|
568
|
+
f"{value!r} is not a valid option for {desc.name!r}; "
|
|
569
|
+
f"valid: {list(by_label)}"
|
|
570
|
+
)
|
|
571
|
+
return by_label[value]
|
|
572
|
+
# int index
|
|
573
|
+
if not desc.var_high_limit:
|
|
574
|
+
max_index = desc.high_limit - desc.low_limit
|
|
575
|
+
if int(value) not in range(max_index + 1):
|
|
576
|
+
raise ValueError(
|
|
577
|
+
f"wire index {int(value)!r} is out of range for {desc.name!r}: "
|
|
578
|
+
f"valid indices are 0..{max_index}"
|
|
579
|
+
)
|
|
580
|
+
return value
|
|
581
|
+
|
|
582
|
+
if isinstance(value, (int, float)) and desc.data_type in _RANGE_VALIDATABLE:
|
|
583
|
+
dp = desc.decimal_places
|
|
584
|
+
raw = round(value * (10**dp)) if dp else int(value)
|
|
585
|
+
low_ok = desc.var_low_limit or desc.low_limit <= raw
|
|
586
|
+
high_ok = desc.var_high_limit or raw <= desc.high_limit
|
|
587
|
+
if not (low_ok and high_ok):
|
|
588
|
+
scale = 10**dp if dp else 1
|
|
589
|
+
lo: int | float | str = "?" if desc.var_low_limit else desc.low_limit / scale
|
|
590
|
+
hi: int | float | str = "?" if desc.var_high_limit else desc.high_limit / scale
|
|
591
|
+
raise ValueError(
|
|
592
|
+
f"{value!r} is out of range for {desc.name!r}: valid range is [{lo}..{hi}]"
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
return value
|
|
596
|
+
|
|
597
|
+
async def set_setpoint(
|
|
598
|
+
self, name_or_number: str | int, value: int | float | str | bytes
|
|
599
|
+
) -> None:
|
|
600
|
+
"""Write a setpoint by name or number.
|
|
601
|
+
|
|
602
|
+
Args:
|
|
603
|
+
name_or_number: Setpoint name or comm object number.
|
|
604
|
+
value: New value. Type depends on the setpoint's ``DataType``:
|
|
605
|
+
|
|
606
|
+
- **Numeric** (``UNSIGNED*``, ``INTEGER*``, ``FLOAT``, ``BINARY*``):
|
|
607
|
+
pass ``int`` or ``float``; ``decimal_places`` scaling is applied
|
|
608
|
+
automatically. Range-checked against ``low_limit``/``high_limit``.
|
|
609
|
+
- **``STRING_LIST``**: pass a ``str`` label (e.g. ``"Winter"``) or
|
|
610
|
+
an ``int`` wire index. Labels are resolved via
|
|
611
|
+
[setpoint_options][pycomap.Controller.setpoint_options].
|
|
612
|
+
- **``CHAR``**: pass an ``int``.
|
|
613
|
+
- **Text** (``SHORT_STRING``, ``IP_ADDRESS``, etc.): pass a ``str``;
|
|
614
|
+
ASCII-encoded, zero-padded or truncated to the wire field length.
|
|
615
|
+
- **Any type**: pass ``bytes`` to write the raw wire value directly
|
|
616
|
+
(skips validation).
|
|
617
|
+
|
|
618
|
+
Raises:
|
|
619
|
+
ComApAuthError: If the setpoint requires a password and none was supplied
|
|
620
|
+
at construction, or if the password is rejected / locked out.
|
|
621
|
+
ValueError: If ``value`` is out of the setpoint's valid range.
|
|
622
|
+
|
|
623
|
+
Examples:
|
|
624
|
+
>>> await ctrl.set_setpoint("Nominal RPM", 1500)
|
|
625
|
+
>>> await ctrl.set_setpoint("Summer Time Mode", "Winter")
|
|
626
|
+
>>> await ctrl.set_setpoint("IP Address", "192.168.1.10")
|
|
627
|
+
"""
|
|
628
|
+
desc = self.setpoint_info(name_or_number)
|
|
629
|
+
if desc.needs_password:
|
|
630
|
+
await self._ensure_elevated()
|
|
631
|
+
value = self._coerce_setpoint_value(desc, value)
|
|
632
|
+
raw = _encode_setpoint_value(desc.data_type, desc.decimal_places, value)
|
|
633
|
+
await self._client.write_object(desc.number, raw)
|
|
634
|
+
_log.debug("set_setpoint %r = %r", desc.name, value)
|
|
635
|
+
|
|
636
|
+
async def execute_command(self, command: ControllerCommand) -> int:
|
|
637
|
+
"""Execute a controller command.
|
|
638
|
+
|
|
639
|
+
Args:
|
|
640
|
+
command: A ``ControllerCommand`` instance; use the ``Command`` enum for
|
|
641
|
+
named commands (e.g. ``Command.FAULT_RESET``).
|
|
642
|
+
|
|
643
|
+
Returns:
|
|
644
|
+
Raw integer result code returned by the controller.
|
|
645
|
+
"""
|
|
646
|
+
return await self._client.execute_command(command)
|
|
647
|
+
|
|
648
|
+
# -- datetime / timezone -------------------------------------------------
|
|
649
|
+
|
|
650
|
+
@property
|
|
651
|
+
def timezone(self) -> datetime.timezone:
|
|
652
|
+
"""Controller's configured UTC offset, derived from the ``Time Zone`` setpoint
|
|
653
|
+
(C.O. 24366) read at connect time.
|
|
654
|
+
|
|
655
|
+
Derived by parsing the ``Time Zone`` setpoint's GMT label (e.g. ``'GMT+2:00'``
|
|
656
|
+
for EET) and adding one hour when ``Summer Time Mode`` is ``'Summer'`` or
|
|
657
|
+
``'Summer-S'``. Supports half-hour offsets.
|
|
658
|
+
Call [refresh_timezone][pycomap.Controller.refresh_timezone] to
|
|
659
|
+
re-read after a setpoint change.
|
|
660
|
+
"""
|
|
661
|
+
return self._timezone
|
|
662
|
+
|
|
663
|
+
@property
|
|
664
|
+
def summer_time_mode(self) -> int:
|
|
665
|
+
"""Raw value of the ``Summer Time Mode`` setpoint (8727) read at connect time.
|
|
666
|
+
|
|
667
|
+
Known values: ``0`` = Winter, ``2`` = Summer, ``4`` = Summer-S.
|
|
668
|
+
Call [refresh_timezone][pycomap.Controller.refresh_timezone] to re-read after a change.
|
|
669
|
+
"""
|
|
670
|
+
return self._summer_time_mode_raw
|
|
671
|
+
|
|
672
|
+
async def refresh_timezone(self) -> None:
|
|
673
|
+
"""Re-read the ``Time Zone`` and ``Summer Time Mode`` setpoints and update the
|
|
674
|
+
cached [timezone][pycomap.Controller.timezone] and
|
|
675
|
+
[summer_time_mode][pycomap.Controller.summer_time_mode] values.
|
|
676
|
+
|
|
677
|
+
Both setpoints are looked up by name from the cached ``ConfigurationTable``, so
|
|
678
|
+
no comm object numbers are hardcoded. Call this if the setpoints were changed
|
|
679
|
+
while the ``Controller`` is connected.
|
|
680
|
+
"""
|
|
681
|
+
base_offset: datetime.timedelta | None = None
|
|
682
|
+
|
|
683
|
+
tz_desc = self._setpoints_by_name.get(_SETPOINT_TIME_ZONE)
|
|
684
|
+
if tz_desc is not None:
|
|
685
|
+
tz_raw = await self._client.read_object(tz_desc.number)
|
|
686
|
+
wire_value = tz_raw[0]
|
|
687
|
+
# Resolve wire value → GMT label via the options list, then parse the label.
|
|
688
|
+
# This handles half-hour offsets (e.g. 'GMT+5:30') correctly without any
|
|
689
|
+
# hardcoded index arithmetic.
|
|
690
|
+
options = dict(self.setpoint_options(tz_desc.number))
|
|
691
|
+
label = options.get(wire_value, "")
|
|
692
|
+
base_offset = _parse_gmt_label(label)
|
|
693
|
+
|
|
694
|
+
dst_desc = self._setpoints_by_name.get(_SETPOINT_SUMMER_TIME_MODE)
|
|
695
|
+
if dst_desc is not None:
|
|
696
|
+
dst_raw = await self._client.read_object(dst_desc.number)
|
|
697
|
+
self._summer_time_mode_raw = dst_raw[0]
|
|
698
|
+
dst_options = dict(self.setpoint_options(dst_desc.number))
|
|
699
|
+
dst_label = dst_options.get(self._summer_time_mode_raw, "")
|
|
700
|
+
if base_offset is not None and dst_label in _SUMMER_MODE_DST_LABELS:
|
|
701
|
+
base_offset += datetime.timedelta(hours=1)
|
|
702
|
+
|
|
703
|
+
if base_offset is not None:
|
|
704
|
+
self._timezone = datetime.timezone(base_offset)
|
|
705
|
+
|
|
706
|
+
async def read_datetime(self) -> datetime.datetime | None:
|
|
707
|
+
"""Read the controller's current clock as a **naive** datetime (local wall-clock
|
|
708
|
+
time). Use [read_aware_datetime][pycomap.Controller.read_aware_datetime]
|
|
709
|
+
to get a timezone-aware result.
|
|
710
|
+
"""
|
|
711
|
+
return await self._client.read_datetime()
|
|
712
|
+
|
|
713
|
+
async def read_aware_datetime(self) -> datetime.datetime | None:
|
|
714
|
+
"""Read the controller's current clock as a **timezone-aware** datetime.
|
|
715
|
+
|
|
716
|
+
Combines the naive clock reading with the
|
|
717
|
+
[timezone][pycomap.Controller.timezone] cached at connect time
|
|
718
|
+
(derived from the ``Time Zone`` setpoint 24366). Returns ``None`` if the
|
|
719
|
+
controller's clock is invalid/unset.
|
|
720
|
+
"""
|
|
721
|
+
dt = await self._client.read_datetime()
|
|
722
|
+
if dt is None:
|
|
723
|
+
return None
|
|
724
|
+
return dt.replace(tzinfo=self._timezone)
|
|
725
|
+
|
|
726
|
+
async def sync_time(self, tz: datetime.tzinfo | None = None) -> None:
|
|
727
|
+
"""Sync the controller clock to the current UTC time.
|
|
728
|
+
|
|
729
|
+
Requires write access — elevates automatically if a ``password`` was supplied.
|
|
730
|
+
|
|
731
|
+
Args:
|
|
732
|
+
tz: Timezone for the local time written to the controller.
|
|
733
|
+
|
|
734
|
+
- ``None`` (default): uses [timezone][pycomap.Controller.timezone] —
|
|
735
|
+
the UTC offset read from
|
|
736
|
+
the controller's own ``Time Zone`` setpoint at connect time.
|
|
737
|
+
- Any ``datetime.tzinfo`` (``pytz``, ``zoneinfo``, or
|
|
738
|
+
``datetime.timezone``): overrides the controller's setting. Use when
|
|
739
|
+
the setpoint is not yet configured::
|
|
740
|
+
|
|
741
|
+
import pytz
|
|
742
|
+
await ctrl.sync_time(tz=pytz.timezone("Europe/Kiev"))
|
|
743
|
+
|
|
744
|
+
Raises:
|
|
745
|
+
ComApAuthError: If write access is needed but no password was provided.
|
|
746
|
+
"""
|
|
747
|
+
await self._ensure_elevated()
|
|
748
|
+
effective_tz: datetime.tzinfo = tz if tz is not None else self._timezone
|
|
749
|
+
now = datetime.datetime.now(datetime.UTC).astimezone(effective_tz).replace(tzinfo=None)
|
|
750
|
+
await self._client.write_datetime(now)
|
|
751
|
+
|
|
752
|
+
# -- private helpers -----------------------------------------------------
|
|
753
|
+
|
|
754
|
+
async def _load_config(self) -> None:
|
|
755
|
+
self._config_data = await self._client.read_object(CommunicationObject.CONFIGURATION_TABLE)
|
|
756
|
+
self._table = parse_configuration_table(self._config_data)
|
|
757
|
+
# Build name → description lookup maps. If names are duplicated, first wins.
|
|
758
|
+
self._values_by_number = {v.number: v for v in self._table.values}
|
|
759
|
+
self._values_by_name = {}
|
|
760
|
+
for v in self._table.values:
|
|
761
|
+
self._values_by_name.setdefault(v.name, v)
|
|
762
|
+
self._setpoints_by_number = {s.number: s for s in self._table.setpoints}
|
|
763
|
+
self._setpoints_by_name = {}
|
|
764
|
+
for s in self._table.setpoints:
|
|
765
|
+
self._setpoints_by_name.setdefault(s.name, s)
|
|
766
|
+
self._common_names = parse_names_heap(self._config_data, NamesCategory.COMMON_NAMES)
|
|
767
|
+
await self.refresh_timezone()
|
|
768
|
+
_log.info(
|
|
769
|
+
"configuration loaded: %d values, %d setpoints",
|
|
770
|
+
len(self._table.values),
|
|
771
|
+
len(self._table.setpoints),
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
async def _ensure_elevated(self) -> None:
|
|
775
|
+
if self._elevated:
|
|
776
|
+
return
|
|
777
|
+
if self._password is None:
|
|
778
|
+
raise ComApAuthError(
|
|
779
|
+
"this operation requires a password but none was supplied to Controller"
|
|
780
|
+
)
|
|
781
|
+
_log.debug("elevating write access")
|
|
782
|
+
await self._client.elevate_access(self._password)
|
|
783
|
+
self._elevated = True
|
|
784
|
+
|
|
785
|
+
async def _refresh_config(self) -> None:
|
|
786
|
+
"""Re-fetch the ``ConfigurationTable`` and timezone (call after firmware update
|
|
787
|
+
or reconnect).
|
|
788
|
+
"""
|
|
789
|
+
await self._load_config()
|
|
790
|
+
self._elevated = False
|