pycomap 1.0.2__tar.gz → 1.1.0__tar.gz

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