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.
@@ -0,0 +1,546 @@
1
+ """Parsing of the controller's ``ConfigurationTable`` and decoding of the blobs it describes.
2
+
3
+ See ``docs/protocol.md`` section 4 for the full reverse-engineering notes (decompiled from
4
+ ``ComAp.Controller.dll``, cross-validated against a live controller's raw bytes and against
5
+ ``cfgmodbus.txt``'s independently-exported Modbus table).
6
+
7
+ The short version: ``ValuesAll``/``ValueStatesAndDataAll`` are not a fixed schema. Each value
8
+ is placed at a byte offset (``data_index``) and length (``data_length``) computed from the
9
+ controller's own ``ConfigurationTable`` (communication object 24575) -- decoding one requires
10
+ decoding the other first.
11
+
12
+ Only the InteliLite3 (``IL3``) binary format is implemented (``ControllerType`` byte 14 at
13
+ offset 6 of the table, format versions 0-4), since that's the only hardware this has been
14
+ validated against. ``SetpointsAll`` decoding doesn't resolve substituted-setpoint info,
15
+ default values, or "periphery setpoint" info (all read via their own absolute offsets, not
16
+ the main per-record stream, and not needed to decode a setpoint's current value).
17
+ String/domain/timer/date-typed values are returned as raw bytes rather than decoded.
18
+
19
+ Each value's human-readable name and unit are resolved from the "unified names heap": the
20
+ ``name_index``/``dim_index`` fields in a value's record are indices into a per-category,
21
+ per-language array of length-prefixed strings, reached through one level of indirection (an
22
+ "access vector" mapping index -> byte offset into a separate heap of string contents). Only
23
+ the first/default language is decoded -- this hasn't been tested against a multi-language
24
+ configuration.
25
+
26
+ Wire type codec (`DataType`, `decode_raw_value`, `encode_raw_value`) lives in
27
+ [pycomap.datatypes][]. Alarm and history record parsing live in [pycomap.alarms][]
28
+ and [pycomap.history][] respectively.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import dataclasses
34
+ import enum
35
+ import functools
36
+ import struct
37
+ from collections.abc import Sequence
38
+ from dataclasses import dataclass
39
+
40
+ from pycomap.datatypes import (
41
+ _DATA_TYPE_LENGTH,
42
+ DataType,
43
+ ProtectionState,
44
+ decode_raw_value,
45
+ encode_raw_value,
46
+ get_bits,
47
+ )
48
+ from pycomap.exceptions import ComApProtocolError
49
+
50
+ __all__ = [
51
+ "ConfigurationTable",
52
+ "NamesCategory",
53
+ "ProtectionState",
54
+ "SetpointCategory",
55
+ "SetpointDescription",
56
+ "ValueCategory",
57
+ "ValueDescription",
58
+ "ValueState",
59
+ "decode_history_snapshot",
60
+ "decode_raw_value",
61
+ "decode_setpoints_all",
62
+ "decode_states_all",
63
+ "decode_values_all",
64
+ "encode_raw_value",
65
+ "parse_configuration_table",
66
+ "parse_names_heap",
67
+ ]
68
+
69
+ _IL3_CONTROLLER_TYPE = 14
70
+ _CONTROLLER_TYPE_OFFSET = 6
71
+ _CONFIG_FORMAT_TERMINAL_OFFSET = 5
72
+ _NUM_VALUES_CAT_I_OFFSET = 50
73
+ _VALUE_RECORD_SIZE = 13
74
+ _STATE_INDEX_FOR_NO_STATE = 1023
75
+ _NUM_SETPOINTS_CAT_P_OFFSET = 98
76
+ _SETPOINT_RECORD_SIZE = 14
77
+
78
+ # Unified names heap (IL3-specific fixed offsets, see module docstring and
79
+ # docs/protocol.md section 4).
80
+ _DESCR_LANG_OFFSET = 169
81
+ _NAMES_HEAP_ACCESS_VECTOR_OFFSET = 6
82
+ _NAMES_HEAP_CONTENTS_OFFSET = 10
83
+
84
+
85
+ class NamesCategory(enum.Enum):
86
+ """Subset of ComAp's ``NamesCategory`` enum -- only the categories needed to resolve a
87
+ value's name/dimension/group, alarm reason/prefix, and history reason/prefix are implemented.
88
+ Values are arbitrary (used only as a dict key into ``_NAMES_CATEGORY_LAYOUT``), not
89
+ wire values.
90
+ """
91
+
92
+ COMMON_NAMES = enum.auto()
93
+ DIMENSIONS = enum.auto()
94
+ ALARM_REASON_NAMES = enum.auto()
95
+ HISTORY_PREFIX_NAMES = enum.auto()
96
+ HISTORY_REASON_NAMES = enum.auto()
97
+ GROUP_NAMES = enum.auto()
98
+
99
+
100
+ @dataclass(frozen=True, slots=True)
101
+ class _NamesCategoryLayout:
102
+ num_names_offset: int
103
+ tab_names_offset: int
104
+ num_names_is_byte: bool
105
+
106
+
107
+ # Absolute offsets within the ConfigurationTable, hardcoded for IL3 in
108
+ # ConfigurationTableLoaderIL3.CreateNamesOffsetDescription/GetNamesCategoryOffset.
109
+ # namesOffset base = 503 (Dimensions tab); each category's tab = namesOffset+N,
110
+ # count = namesOffset+N+2 (per GetNamesCategoryOffset in ComAp.Controller.dll).
111
+ # GroupNames is at namesOffset+10 (4th in the ordered CreateNamesOffsetDescription list:
112
+ # Dimensions(byte)+3, FixedNames(u16)+4, SetpointLimitNames(byte)+3, GroupNames(byte)).
113
+ _NAMES_CATEGORY_LAYOUT = {
114
+ NamesCategory.COMMON_NAMES: _NamesCategoryLayout(594, 592, num_names_is_byte=False),
115
+ NamesCategory.DIMENSIONS: _NamesCategoryLayout(505, 503, num_names_is_byte=True),
116
+ NamesCategory.HISTORY_PREFIX_NAMES: _NamesCategoryLayout(525, 523, num_names_is_byte=True),
117
+ NamesCategory.HISTORY_REASON_NAMES: _NamesCategoryLayout(528, 526, num_names_is_byte=False),
118
+ NamesCategory.ALARM_REASON_NAMES: _NamesCategoryLayout(532, 530, num_names_is_byte=False),
119
+ NamesCategory.GROUP_NAMES: _NamesCategoryLayout(515, 513, num_names_is_byte=True),
120
+ }
121
+
122
+ # Group table header offsets (IL3, ConfigurationTableLoaderIL3.CreateConfigurationTableLoadInfo).
123
+ # NumGroupsOffset=134 (uint16), DescrGroupsItemOffset=136 (uint32 absolute address).
124
+ # GroupTableBaseOffset=0 means items/subgroups offsets in each group record are absolute.
125
+ _NUM_GROUPS_OFFSET = 134
126
+ _GROUPS_ADDR_OFFSET = 136
127
+
128
+
129
+ @functools.cache
130
+ def parse_names_heap(data: bytes, category: NamesCategory) -> list[str]:
131
+ """Decode one category of the controller's "unified names heap" (first/default
132
+ language only). Returned list is indexed directly by a value record's ``name_index``/
133
+ ``dim_index`` field.
134
+ """
135
+ layout = _NAMES_CATEGORY_LAYOUT[category]
136
+ item_size = 2 if data[_CONFIG_FORMAT_TERMINAL_OFFSET] <= 3 else 4
137
+
138
+ language_base = struct.unpack_from("<I", data, _DESCR_LANG_OFFSET)[0]
139
+ access_vector_addr = struct.unpack_from(
140
+ "<I", data, language_base + _NAMES_HEAP_ACCESS_VECTOR_OFFSET
141
+ )[0]
142
+ heap_contents_addr = struct.unpack_from(
143
+ "<I", data, language_base + _NAMES_HEAP_CONTENTS_OFFSET
144
+ )[0]
145
+
146
+ count = (
147
+ data[layout.num_names_offset]
148
+ if layout.num_names_is_byte
149
+ else struct.unpack_from("<H", data, layout.num_names_offset)[0]
150
+ )
151
+ base_index = struct.unpack_from("<H", data, layout.tab_names_offset)[0]
152
+
153
+ access_vector_fmt = "<H" if item_size == 2 else "<I"
154
+ names = []
155
+ for j in range(count):
156
+ position = access_vector_addr + item_size * (base_index + j)
157
+ heap_offset = struct.unpack_from(access_vector_fmt, data, position)[0]
158
+ string_position = heap_contents_addr + heap_offset
159
+ length = data[string_position]
160
+ names.append(data[string_position + 1 : string_position + 1 + length].decode("utf-8"))
161
+ return names
162
+
163
+
164
+ class ValueCategory(enum.IntEnum):
165
+ """ComAp ``ValueCategory`` enum.
166
+
167
+ ``FIRST``/``SECOND``/``THIRD`` are refresh-rate tiers (each with its own poll period, read
168
+ from the table); ``ONE_TIME`` values aren't included in ``ValuesAll``/
169
+ ``ValueStatesAndDataAll`` at all.
170
+ """
171
+
172
+ FIRST = 0
173
+ SECOND = 1
174
+ THIRD = 2
175
+ ONE_TIME = 3
176
+
177
+
178
+ @dataclass(slots=True, frozen=True)
179
+ class ValueDescription:
180
+ """One entry from the controller's ``ConfigurationTable`` describing a single value."""
181
+
182
+ number: int
183
+ category: ValueCategory
184
+ data_type: DataType
185
+ data_length: int
186
+ decimal_places: int
187
+ data_index: int
188
+ state_index: int | None
189
+ name: str
190
+ dimension: str
191
+ group: str | None
192
+ low_limit: int
193
+ high_limit: int
194
+ var_low_limit: bool
195
+ var_high_limit: bool
196
+ bit_name_index: int | None
197
+
198
+
199
+ class SetpointCategory(enum.IntEnum):
200
+ """ComAp ``SetpointCategory`` enum. Unlike values, every setpoint is included in
201
+ ``SetpointsAll`` regardless of category, and setpoints have no associated state.
202
+ """
203
+
204
+ P = 0
205
+ R = 1
206
+
207
+
208
+ @dataclass(slots=True, frozen=True)
209
+ class SetpointDescription:
210
+ """One entry from the controller's ``ConfigurationTable`` describing a single setpoint."""
211
+
212
+ number: int
213
+ category: SetpointCategory
214
+ data_type: DataType
215
+ data_length: int
216
+ decimal_places: int
217
+ data_index: int
218
+ name: str
219
+ dimension: str
220
+ group: str | None
221
+ access_level: int
222
+ low_limit: int
223
+ high_limit: int
224
+ var_low_limit: bool
225
+ var_high_limit: bool
226
+
227
+ @property
228
+ def needs_password(self) -> bool:
229
+ """True if writing this setpoint requires a password (``access_level > 0``)."""
230
+ return self.access_level > 0
231
+
232
+
233
+ @dataclass(slots=True, frozen=True)
234
+ class ValueState:
235
+ """Protection state for one value, decoded from a ``ValueStatesAll`` byte.
236
+
237
+ Each field is a ``ProtectionState`` flag combination — check for activity with
238
+ ``state.level1 & ProtectionState.ACTIVE``. ``NOT_CONFIRMED`` is a combinable flag:
239
+ ``ACTIVE | NOT_CONFIRMED`` (value 6) means the alarm is active but not yet
240
+ acknowledged by the operator pressing Fault Reset.
241
+
242
+ Source: ``ComAp.Controller.DataTypes.ValueState`` in ``ComAp.Controller.dll``::
243
+
244
+ Level1 = bits 0-2, direct ProtectionState cast
245
+ Level2 = bits 3-5, direct ProtectionState cast
246
+ SensorFail = bits 6-7, left-shifted by 1 (raw 1 → ACTIVE, raw 2 → NOT_CONFIRMED)
247
+ """
248
+
249
+ level1: ProtectionState
250
+ level2: ProtectionState
251
+ sensor_fail: ProtectionState
252
+
253
+ @property
254
+ def any_alarm(self) -> bool:
255
+ """True if any protection level or sensor failure is active."""
256
+ active = ProtectionState.ACTIVE
257
+ return bool((self.level1 & active) or (self.level2 & active) or (self.sensor_fail & active))
258
+
259
+
260
+ @dataclass(slots=True, frozen=True)
261
+ class ConfigurationTable:
262
+ """Parsed ``ConfigurationTable`` (value and setpoint descriptions -- see module
263
+ docstring).
264
+ """
265
+
266
+ values: list[ValueDescription]
267
+ setpoints: list[SetpointDescription]
268
+
269
+
270
+ def _as_int16(v: int) -> int:
271
+ """Reinterpret a uint16 as a signed int16 (same bit pattern, different sign)."""
272
+ return struct.unpack("<h", struct.pack("<H", v))[0]
273
+
274
+
275
+ def _expand_categories[T](counts: list[int], categories: list[T]) -> list[T]:
276
+ """Expand per-category counts into a per-item category list.
277
+
278
+ E.g. ``counts=[2, 1]``, ``categories=[A, B]`` → ``[A, A, B]``.
279
+ """
280
+ result: list[T] = []
281
+ for cat, count in zip(categories, counts, strict=True):
282
+ result.extend([cat] * count)
283
+ return result
284
+
285
+
286
+ def _parse_group_map(
287
+ data: bytes,
288
+ value_numbers: Sequence[int],
289
+ setpoint_numbers: Sequence[int],
290
+ ) -> dict[int, str]:
291
+ """Parse the GroupDescription section and return {CO_number: group_name}.
292
+
293
+ Source: ConfigurationTableLoaderIL3.LoadGroupDescriptionFromStream and
294
+ ConfigurationTableLoader.CommonLoadGroupsFromStream in ComAp.Controller.dll.
295
+ Each group record is 11 bytes (3x uint16 fields + uint16 items_offset +
296
+ uint16 subgroups_offset + 1 byte flags). Each item is 4 bytes (2x uint16):
297
+ bits 0-1 of iw1 = CommunicationObjectType (0=value, 1=setpoint), bits 2-12 = object index.
298
+ """
299
+ group_names = parse_names_heap(data, NamesCategory.GROUP_NAMES)
300
+ num_groups = struct.unpack_from("<H", data, _NUM_GROUPS_OFFSET)[0]
301
+ groups_addr = struct.unpack_from("<I", data, _GROUPS_ADDR_OFFSET)[0]
302
+
303
+ co_to_group: dict[int, str] = {}
304
+ pos = groups_addr
305
+ for _ in range(num_groups):
306
+ w2 = struct.unpack_from("<H", data, pos + 2)[0]
307
+ w3 = struct.unpack_from("<H", data, pos + 4)[0]
308
+ items_offset = struct.unpack_from("<H", data, pos + 6)[0]
309
+ pos += 11
310
+
311
+ name_idx = get_bits(w3, 9, 6)
312
+ num_items = get_bits(w2, 9, 7)
313
+ group_name = group_names[name_idx] if name_idx < len(group_names) else ""
314
+
315
+ item_pos = items_offset
316
+ for _ in range(num_items):
317
+ iw1 = struct.unpack_from("<H", data, item_pos)[0]
318
+ item_pos += 4
319
+ obj_type = get_bits(iw1, 0, 2)
320
+ obj_idx = get_bits(iw1, 2, 11)
321
+ if obj_type == 0 and obj_idx < len(value_numbers):
322
+ co_to_group[value_numbers[obj_idx]] = group_name
323
+ elif obj_type == 1 and obj_idx < len(setpoint_numbers):
324
+ co_to_group[setpoint_numbers[obj_idx]] = group_name
325
+
326
+ return co_to_group
327
+
328
+
329
+ def parse_configuration_table(data: bytes) -> ConfigurationTable:
330
+ """Parse the value-description section of a raw ``ConfigurationTable`` blob."""
331
+ controller_type = data[_CONTROLLER_TYPE_OFFSET]
332
+ if controller_type != _IL3_CONTROLLER_TYPE:
333
+ raise ComApProtocolError(
334
+ f"unsupported ControllerType {controller_type}; "
335
+ f"only InteliLite3 ({_IL3_CONTROLLER_TYPE}) is implemented"
336
+ )
337
+
338
+ offset = _NUM_VALUES_CAT_I_OFFSET
339
+ category_counts = list(struct.unpack_from("<4H", data, offset))
340
+ offset += 4 * 2 + 2 # category counts + redundant total count
341
+ offset += 4 * 2 + 2 # per-category data lengths + redundant total data length
342
+ offset += 3 * 2 + 2 # per-category state lengths + redundant total state length
343
+ offset += 3 * 2 # refresh periods (First/Second/Third), unused here
344
+ value_ids_addr, value_descriptions_addr = struct.unpack_from("<II", data, offset)
345
+
346
+ total = sum(category_counts)
347
+ numbers = struct.unpack_from(f"<{total}H", data, value_ids_addr)
348
+ category_of = _expand_categories(
349
+ category_counts,
350
+ [ValueCategory.FIRST, ValueCategory.SECOND, ValueCategory.THIRD, ValueCategory.ONE_TIME],
351
+ )
352
+
353
+ common_names = parse_names_heap(data, NamesCategory.COMMON_NAMES)
354
+ dimensions = parse_names_heap(data, NamesCategory.DIMENSIONS)
355
+
356
+ offset = value_descriptions_addr
357
+ values = []
358
+ for i in range(total):
359
+ dword1, dword2 = struct.unpack_from("<II", data, offset)
360
+ name_index = get_bits(dword1, 0, 12)
361
+ dim_index = get_bits(dword1, 12, 6)
362
+ decimal_places = get_bits(dword1, 18, 3)
363
+ var_low_limit = bool(get_bits(dword1, 23, 1))
364
+ var_high_limit = bool(get_bits(dword1, 24, 1))
365
+ raw_bit_name_index = get_bits(dword2, 1, 10)
366
+ raw_state_index = get_bits(dword2, 11, 10)
367
+ data_index = get_bits(dword2, 21, 10)
368
+ data_type = DataType(data[offset + 8])
369
+ raw_low, raw_high = struct.unpack_from("<HH", data, offset + 9)
370
+ if data_type in (DataType.INTEGER8, DataType.INTEGER16, DataType.INTEGER32):
371
+ low_limit, high_limit = _as_int16(raw_low), _as_int16(raw_high)
372
+ else:
373
+ low_limit, high_limit = raw_low, raw_high
374
+ offset += _VALUE_RECORD_SIZE
375
+
376
+ values.append(
377
+ ValueDescription(
378
+ number=numbers[i],
379
+ category=category_of[i],
380
+ data_type=data_type,
381
+ data_length=_DATA_TYPE_LENGTH[data_type],
382
+ decimal_places=decimal_places,
383
+ data_index=data_index,
384
+ state_index=(
385
+ None if raw_state_index == _STATE_INDEX_FOR_NO_STATE else raw_state_index
386
+ ),
387
+ name=common_names[name_index],
388
+ dimension=dimensions[dim_index],
389
+ group=None,
390
+ low_limit=low_limit,
391
+ high_limit=high_limit,
392
+ var_low_limit=var_low_limit,
393
+ var_high_limit=var_high_limit,
394
+ bit_name_index=(
395
+ None if raw_bit_name_index == _STATE_INDEX_FOR_NO_STATE else raw_bit_name_index
396
+ ),
397
+ )
398
+ )
399
+
400
+ setpoints = _parse_setpoints(data, common_names, dimensions)
401
+
402
+ group_map = _parse_group_map(data, list(numbers), [s.number for s in setpoints])
403
+ values = [dataclasses.replace(v, group=group_map.get(v.number)) for v in values]
404
+ setpoints = [dataclasses.replace(s, group=group_map.get(s.number)) for s in setpoints]
405
+
406
+ return ConfigurationTable(values=values, setpoints=setpoints)
407
+
408
+
409
+ def _parse_setpoints(
410
+ data: bytes, common_names: list[str], dimensions: list[str]
411
+ ) -> list[SetpointDescription]:
412
+ offset = _NUM_SETPOINTS_CAT_P_OFFSET
413
+ category_counts = list(struct.unpack_from("<2H", data, offset))
414
+ offset += 2 * 2 + 2 # category counts (P, R) + redundant total count
415
+ offset += 2 * 2 + 2 # per-category data lengths + redundant total data length
416
+ setpoint_ids_addr, setpoint_descriptions_addr = struct.unpack_from("<II", data, offset)
417
+
418
+ total = sum(category_counts)
419
+ numbers = struct.unpack_from(f"<{total}H", data, setpoint_ids_addr)
420
+ category_of = _expand_categories(category_counts, [SetpointCategory.P, SetpointCategory.R])
421
+
422
+ offset = setpoint_descriptions_addr
423
+ setpoints = []
424
+ for i in range(total):
425
+ dword1, byte1 = struct.unpack_from("<IB", data, offset)
426
+ name_index = get_bits(dword1, 0, 12)
427
+ dim_index = get_bits(dword1, 12, 6)
428
+ access_level = get_bits(dword1, 24, 8)
429
+ decimal_places = get_bits(byte1, 4, 3)
430
+ data_type = DataType(data[offset + 5])
431
+ dword2 = struct.unpack_from("<I", data, offset + 6)[0]
432
+ # bit 1 = varLowLimit, bit 2 = varHighLimit (IL3, ComObjectsHas32BitLimits=False,
433
+ # so num=1; GetBit(dword2, num) and GetBit(dword2, 1+num) per ReadLimit source).
434
+ var_low_limit = bool(get_bits(dword2, 1, 1))
435
+ var_high_limit = bool(get_bits(dword2, 2, 1))
436
+ data_index = get_bits(dword2, 13, 11)
437
+ raw_low, raw_high = struct.unpack_from("<HH", data, offset + 10)
438
+ # For signed integer types, reinterpret uint16 as int16 (IL3 always has 16-bit
439
+ # limits even for INTEGER32). Source: IsSignedDataType cast in ConfigTableLoader.
440
+ if data_type in (DataType.INTEGER8, DataType.INTEGER16, DataType.INTEGER32):
441
+ low_limit, high_limit = _as_int16(raw_low), _as_int16(raw_high)
442
+ else:
443
+ low_limit, high_limit = raw_low, raw_high
444
+ offset += _SETPOINT_RECORD_SIZE
445
+
446
+ setpoints.append(
447
+ SetpointDescription(
448
+ number=numbers[i],
449
+ category=category_of[i],
450
+ data_type=data_type,
451
+ data_length=_DATA_TYPE_LENGTH[data_type],
452
+ decimal_places=decimal_places,
453
+ data_index=data_index,
454
+ name=common_names[name_index],
455
+ dimension=dimensions[dim_index],
456
+ group=None,
457
+ access_level=access_level,
458
+ low_limit=low_limit,
459
+ high_limit=high_limit,
460
+ var_low_limit=var_low_limit,
461
+ var_high_limit=var_high_limit,
462
+ )
463
+ )
464
+
465
+ return setpoints
466
+
467
+
468
+ def decode_values_all(table: ConfigurationTable, data: bytes) -> dict[int, int | float | bytes]:
469
+ """Decode a ``ValuesAll`` (or the data portion of ``ValueStatesAndDataAll``) blob.
470
+
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).
474
+ """
475
+ result: dict[int, int | float | bytes] = {}
476
+ for value in table.values:
477
+ if value.category is ValueCategory.ONE_TIME:
478
+ continue
479
+ raw = data[value.data_index : value.data_index + value.data_length]
480
+ result[value.number] = decode_raw_value(value.data_type, raw, value.decimal_places)
481
+ return result
482
+
483
+
484
+ def decode_history_snapshot(
485
+ table: ConfigurationTable, snapshot: bytes
486
+ ) -> dict[int, int | float | bytes]:
487
+ """Decode the value snapshot from a ``HistoryRecord.data`` field.
488
+
489
+ Alarm/event history records carry a snapshot of the first N bytes of the
490
+ ``ValuesAll`` blob captured at the moment the event occurred. The layout is
491
+ identical to ``ValuesAll`` (each value at its ``data_index`` byte offset) but
492
+ truncated to ``len(snapshot)`` — values whose data would extend beyond the
493
+ snapshot are silently omitted.
494
+
495
+ Returns ``{number: decoded_value}`` for every value that fits, using the same
496
+ type/decimal-places decoding as
497
+ [decode_values_all][pycomap.configuration.decode_values_all]. ``ONE_TIME`` values
498
+ are never included. Returns an empty dict if ``snapshot`` is empty (text records).
499
+ """
500
+ result: dict[int, int | float | bytes] = {}
501
+ for value in table.values:
502
+ if value.category is ValueCategory.ONE_TIME:
503
+ continue
504
+ end = value.data_index + value.data_length
505
+ if end > len(snapshot):
506
+ continue
507
+ raw = snapshot[value.data_index : end]
508
+ result[value.number] = decode_raw_value(value.data_type, raw, value.decimal_places)
509
+ return result
510
+
511
+
512
+ def decode_setpoints_all(table: ConfigurationTable, data: bytes) -> dict[int, int | float | bytes]:
513
+ """Decode a ``SetpointsAll`` blob into a mapping of setpoint ``number`` -> decoded value.
514
+
515
+ Unlike values, every setpoint (both ``P`` and ``R`` categories) is included.
516
+ """
517
+ result: dict[int, int | float | bytes] = {}
518
+ for setpoint in table.setpoints:
519
+ raw = data[setpoint.data_index : setpoint.data_index + setpoint.data_length]
520
+ result[setpoint.number] = decode_raw_value(setpoint.data_type, raw, setpoint.decimal_places)
521
+ return result
522
+
523
+
524
+ def decode_states_all(table: ConfigurationTable, data: bytes) -> dict[int, ValueState]:
525
+ """Decode a ``ValueStatesAll`` blob (or the state portion of ``ValueStatesAndDataAll``).
526
+
527
+ Returns a mapping of value ``number`` -> ``ValueState`` for every value that has a
528
+ ``state_index`` (i.e. ``ValueDescription.state_index is not None``). Values with no
529
+ state (``state_index is None``) are omitted.
530
+
531
+ For ``ValueStatesAndDataAll`` (C.O. 24529): the blob is the data region (size =
532
+ max ``data_index + data_length`` across non-OneTime values) followed immediately by
533
+ the state region — pass only the state suffix to this function, or slice it yourself:
534
+ ``data[data_region_size:]``.
535
+ """
536
+ result: dict[int, ValueState] = {}
537
+ for value in table.values:
538
+ if value.category is ValueCategory.ONE_TIME or value.state_index is None:
539
+ continue
540
+ raw = data[value.state_index]
541
+ result[value.number] = ValueState(
542
+ level1=ProtectionState(get_bits(raw, 0, 3)),
543
+ level2=ProtectionState(get_bits(raw, 3, 3)),
544
+ sensor_fail=ProtectionState(get_bits(raw, 6, 2) << 1),
545
+ )
546
+ return result