pycomap 1.0.2__tar.gz → 2.0.0__tar.gz

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