pycomap 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pycomap/__init__.py +56 -0
- pycomap/alarms.py +100 -0
- pycomap/configuration.py +546 -0
- pycomap/controller.py +790 -0
- pycomap/datatypes.py +202 -0
- pycomap/discovery.py +195 -0
- pycomap/exceptions.py +35 -0
- pycomap/history.py +166 -0
- pycomap/protocol/__init__.py +24 -0
- pycomap/protocol/client.py +357 -0
- pycomap/protocol/commands.py +62 -0
- pycomap/protocol/crc.py +20 -0
- pycomap/protocol/crypto.py +90 -0
- pycomap/protocol/framing.py +145 -0
- pycomap/protocol/objects.py +98 -0
- pycomap/protocol/transport.py +89 -0
- pycomap/py.typed +0 -0
- pycomap-1.0.0.dist-info/METADATA +57 -0
- pycomap-1.0.0.dist-info/RECORD +20 -0
- pycomap-1.0.0.dist-info/WHEEL +4 -0
pycomap/configuration.py
ADDED
|
@@ -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
|