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/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