s7commplus 0.1.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.
Files changed (105) hide show
  1. s7commplus/__init__.py +64 -0
  2. s7commplus/alarm.py +543 -0
  3. s7commplus/async_client.py +1425 -0
  4. s7commplus/blob_decompressor.py +89 -0
  5. s7commplus/catalog.py +137 -0
  6. s7commplus/client.py +1719 -0
  7. s7commplus/codec.py +810 -0
  8. s7commplus/connection.py +2233 -0
  9. s7commplus/error.py +456 -0
  10. s7commplus/legitimation.py +271 -0
  11. s7commplus/protocol.py +387 -0
  12. s7commplus/py.typed +0 -0
  13. s7commplus/server.py +1374 -0
  14. s7commplus/session_auth/ARCHITECTURE.md +192 -0
  15. s7commplus/session_auth/LICENSE-HarpoS7 +21 -0
  16. s7commplus/session_auth/__init__.py +80 -0
  17. s7commplus/session_auth/artifacts.json +254 -0
  18. s7commplus/session_auth/blob_metadata.py +142 -0
  19. s7commplus/session_auth/family0/__init__.py +7 -0
  20. s7commplus/session_auth/family0/_generated/__init__.py +20 -0
  21. s7commplus/session_auth/family0/_generated/data/__init__.py +96 -0
  22. s7commplus/session_auth/family0/_generated/data/_constants.py +1637 -0
  23. s7commplus/session_auth/family0/_generated/data/fp_data1.bin +0 -0
  24. s7commplus/session_auth/family0/_generated/data/fp_data2.bin +0 -0
  25. s7commplus/session_auth/family0/_generated/data/transform12_big_int_data.bin +0 -0
  26. s7commplus/session_auth/family0/_generated/data/transform12_metadata.bin +0 -0
  27. s7commplus/session_auth/family0/_generated/monolith1.py +3619 -0
  28. s7commplus/session_auth/family0/_generated/monolith10.py +18 -0
  29. s7commplus/session_auth/family0/_generated/monolith11.py +1091 -0
  30. s7commplus/session_auth/family0/_generated/monolith2.py +3430 -0
  31. s7commplus/session_auth/family0/_generated/monolith3.py +2900 -0
  32. s7commplus/session_auth/family0/_generated/monolith4.py +5578 -0
  33. s7commplus/session_auth/family0/_generated/monolith5.py +3625 -0
  34. s7commplus/session_auth/family0/_generated/monolith6.py +4052 -0
  35. s7commplus/session_auth/family0/_generated/monolith7.py +2263 -0
  36. s7commplus/session_auth/family0/_generated/monolith8.py +3636 -0
  37. s7commplus/session_auth/family0/_generated/monolith9.py +38 -0
  38. s7commplus/session_auth/family0/_generated/nine/__init__.py +0 -0
  39. s7commplus/session_auth/family0/_generated/nine/part1.py +3227 -0
  40. s7commplus/session_auth/family0/_generated/nine/part10.py +4550 -0
  41. s7commplus/session_auth/family0/_generated/nine/part11.py +5033 -0
  42. s7commplus/session_auth/family0/_generated/nine/part2.py +4524 -0
  43. s7commplus/session_auth/family0/_generated/nine/part3.py +5044 -0
  44. s7commplus/session_auth/family0/_generated/nine/part4.py +4449 -0
  45. s7commplus/session_auth/family0/_generated/nine/part5.py +4606 -0
  46. s7commplus/session_auth/family0/_generated/nine/part6.py +4518 -0
  47. s7commplus/session_auth/family0/_generated/nine/part7.py +4619 -0
  48. s7commplus/session_auth/family0/_generated/nine/part8.py +4495 -0
  49. s7commplus/session_auth/family0/_generated/nine/part9.py +4523 -0
  50. s7commplus/session_auth/family0/_generated/ten/__init__.py +0 -0
  51. s7commplus/session_auth/family0/_generated/ten/part1.py +4080 -0
  52. s7commplus/session_auth/family0/_generated/ten/part2.py +4968 -0
  53. s7commplus/session_auth/family0/_generated/ten/part3.py +5170 -0
  54. s7commplus/session_auth/family0/authenticator.py +168 -0
  55. s7commplus/session_auth/family0/big_int_operations.py +199 -0
  56. s7commplus/session_auth/family0/big_int_transforms.py +198 -0
  57. s7commplus/session_auth/family0/checksum_transform.py +80 -0
  58. s7commplus/session_auth/family0/fingerprint.py +134 -0
  59. s7commplus/session_auth/family0/key_derivation_transform.py +62 -0
  60. s7commplus/session_auth/family0/lut_generator.py +75 -0
  61. s7commplus/session_auth/family0/monolith_wrappers.py +101 -0
  62. s7commplus/session_auth/family0/pre_seed_transform.py +46 -0
  63. s7commplus/session_auth/family0/seed_transform.py +83 -0
  64. s7commplus/session_auth/family0/transform12.py +76 -0
  65. s7commplus/session_auth/family0/transform13.py +51 -0
  66. s7commplus/session_auth/family0/transform7.py +149 -0
  67. s7commplus/session_auth/harpo_aes.py +59 -0
  68. s7commplus/session_auth/harpo_aes_ctr.py +255 -0
  69. s7commplus/session_auth/harpo_hash.py +220 -0
  70. s7commplus/session_auth/key_derivation.py +129 -0
  71. s7commplus/session_auth/keys.py +205 -0
  72. s7commplus/session_auth/legacy_auth.py +48 -0
  73. s7commplus/session_auth/legitimate.py +97 -0
  74. s7commplus/session_auth/utils.py +42 -0
  75. s7commplus/subscription.py +285 -0
  76. s7commplus/tag_browser.py +358 -0
  77. s7commplus/transport.py +542 -0
  78. s7commplus/typeinfo.py +809 -0
  79. s7commplus/vlq.py +338 -0
  80. s7commplus/zlib_dicts/1398a37f_CompilerSettings_90000001.xml +1 -0
  81. s7commplus/zlib_dicts/1bac39f0_DebugInfo_90000001.xml +1 -0
  82. s7commplus/zlib_dicts/3c55436a_LineComm_98000001.xml +1 -0
  83. s7commplus/zlib_dicts/4b8416f0_IntfDesc_90000001.xml +1 -0
  84. s7commplus/zlib_dicts/5814b03b_IdentES_98000001.xml +1 -0
  85. s7commplus/zlib_dicts/66052b13_DebugInfo_IntfDesc_98000001.xml +1 -0
  86. s7commplus/zlib_dicts/79b2bda3_LineComm_90000001.xml +1 -0
  87. s7commplus/zlib_dicts/81d8db20_IdentES_90000002.xml +1 -0
  88. s7commplus/zlib_dicts/845fc605_NWT_98000001.xml +1 -0
  89. s7commplus/zlib_dicts/9b6a3a92_ExtRefData_90000001.xml +1 -0
  90. s7commplus/zlib_dicts/__init__.py +28 -0
  91. s7commplus/zlib_dicts/ab6fa31e_NWC_90000001.xml +1 -0
  92. s7commplus/zlib_dicts/b0155ff8_IntRefData_98000001.xml +1 -0
  93. s7commplus/zlib_dicts/c5d26ac3_NWC_98000001.xml +1 -0
  94. s7commplus/zlib_dicts/ce9b821b_IntfDescTag_90000001.xml +1 -0
  95. s7commplus/zlib_dicts/da4a88f4_IntRefData_90000001.xml +1 -0
  96. s7commplus/zlib_dicts/df91b6bb_IdentES_90000001.xml +1 -0
  97. s7commplus/zlib_dicts/e2729ea1_TagLineComm_90000001.xml +1 -0
  98. s7commplus/zlib_dicts/efaeae49_BodyDesc_90000001.xml +1 -0
  99. s7commplus/zlib_dicts/fd69ac74_NWT_90000001.xml +1 -0
  100. s7commplus-0.1.0.dist-info/METADATA +90 -0
  101. s7commplus-0.1.0.dist-info/RECORD +105 -0
  102. s7commplus-0.1.0.dist-info/WHEEL +5 -0
  103. s7commplus-0.1.0.dist-info/licenses/LICENSE +20 -0
  104. s7commplus-0.1.0.dist-info/licenses/s7commplus/session_auth/LICENSE-HarpoS7 +21 -0
  105. s7commplus-0.1.0.dist-info/top_level.txt +1 -0
s7commplus/__init__.py ADDED
@@ -0,0 +1,64 @@
1
+ """S7CommPlus protocol client for S7-1200/1500 PLCs.
2
+
3
+ Pure Python implementation of the S7CommPlus protocol for direct
4
+ communication with Siemens S7-1200 and S7-1500 PLCs. For legacy
5
+ S7-300/400 PLCs, use ``snap7.Client`` instead.
6
+
7
+ Usage::
8
+
9
+ from s7commplus import Client
10
+
11
+ client = Client()
12
+ client.connect("192.168.1.10")
13
+ data = client.db_read(1, 0, 4)
14
+ """
15
+
16
+ from .async_client import S7CommPlusAsyncClient as AsyncClient
17
+ from .alarm import Alarm, AlarmNotification, AlarmText, LanguageId
18
+ from .blob_decompressor import decompress_blob, find_and_decompress
19
+ from .catalog import ArrayDimension, SymbolCatalog, SymbolicTag, TagResult
20
+ from .client import DBWriteItem, SymbolicReadItem
21
+ from .client import S7CommPlusClient as Client
22
+ from .connection import S7CommPlusConnection
23
+ from .server import CPUState, DataBlock
24
+ from .server import S7CommPlusServer as Server
25
+ from .subscription import SubscriptionItem, SubscriptionNotification
26
+ from .tag_browser import (
27
+ DataBlock as ExploreDataBlock,
28
+ )
29
+ from .tag_browser import (
30
+ Member,
31
+ Tag,
32
+ block_interface_from_explore,
33
+ datablocks_from_explore,
34
+ tags_from_explore,
35
+ )
36
+
37
+ __all__ = [
38
+ "Alarm",
39
+ "AlarmNotification",
40
+ "AlarmText",
41
+ "ArrayDimension",
42
+ "AsyncClient",
43
+ "CPUState",
44
+ "Client",
45
+ "DBWriteItem",
46
+ "DataBlock",
47
+ "ExploreDataBlock",
48
+ "LanguageId",
49
+ "Member",
50
+ "S7CommPlusConnection",
51
+ "Server",
52
+ "SubscriptionItem",
53
+ "SubscriptionNotification",
54
+ "SymbolCatalog",
55
+ "SymbolicReadItem",
56
+ "SymbolicTag",
57
+ "Tag",
58
+ "TagResult",
59
+ "block_interface_from_explore",
60
+ "datablocks_from_explore",
61
+ "decompress_blob",
62
+ "find_and_decompress",
63
+ "tags_from_explore",
64
+ ]
s7commplus/alarm.py ADDED
@@ -0,0 +1,543 @@
1
+ """Alarm models and wire decoders for S7CommPlus notifications."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import struct
7
+ from collections.abc import Sequence
8
+ from dataclasses import dataclass, field
9
+ from enum import IntEnum
10
+ from typing import Any
11
+
12
+ from .codec import encode_object_qualifier
13
+ from .protocol import DataType, ElementID, Ids, Opcode
14
+ from .vlq import decode_int32_vlq, decode_int64_vlq, decode_uint32_vlq, decode_uint64_vlq, encode_uint32_vlq
15
+
16
+ _ALARM_SUBSCRIPTION_RELATION_ID = 0x7FFFC001
17
+ _ALARM_REFERENCE_RELATION_ID = 0x51010001
18
+ _ASSOCIATED_VALUE_TOKEN = re.compile(r"@(\d+)(%[^@]+)@")
19
+
20
+ _SOFTDATATYPE_BOOL = 0x02000001
21
+ _SOFTDATATYPE_BYTE = 0x02000002
22
+ _SOFTDATATYPE_CHAR = 0x02000003
23
+ _SOFTDATATYPE_WORD = 0x02000004
24
+ _SOFTDATATYPE_INT = 0x02000005
25
+ _SOFTDATATYPE_DWORD = 0x02000006
26
+ _SOFTDATATYPE_DINT = 0x02000007
27
+ _SOFTDATATYPE_REAL = 0x02000008
28
+ _SOFTDATATYPE_STRING = 0x02000013
29
+ _SOFTDATATYPE_LREAL = 0x02000030
30
+ _SOFTDATATYPE_USINT = 0x02000034
31
+ _SOFTDATATYPE_UINT = 0x02000035
32
+ _SOFTDATATYPE_UDINT = 0x02000036
33
+ _SOFTDATATYPE_SINT = 0x02000037
34
+ _SOFTDATATYPE_WCHAR = 0x0200003D
35
+ _SOFTDATATYPE_WSTRING = 0x0200003E
36
+ _STRING_TYPE_MIN = 0x020A0000
37
+ _STRING_TYPE_MAX = 0x020AFFFF
38
+ _WSTRING_TYPE_MIN = 0x020B0000
39
+ _WSTRING_TYPE_MAX = 0x020BFFFF
40
+
41
+
42
+ class LanguageId(IntEnum):
43
+ """Windows locale identifiers (LCIDs) commonly supported by Siemens HMIs."""
44
+
45
+ CHINESE_TRADITIONAL = 1028
46
+ CZECH = 1029
47
+ DANISH = 1030
48
+ GERMAN_GERMANY = 1031
49
+ GREEK = 1032
50
+ ENGLISH_UNITED_STATES = 1033
51
+ SPANISH_TRADITIONAL = 1034
52
+ FINNISH = 1035
53
+ FRENCH_FRANCE = 1036
54
+ HUNGARIAN = 1038
55
+ ITALIAN_ITALY = 1040
56
+ JAPANESE = 1041
57
+ KOREAN = 1042
58
+ DUTCH_NETHERLANDS = 1043
59
+ POLISH = 1045
60
+ PORTUGUESE_BRAZIL = 1046
61
+ RUSSIAN = 1049
62
+ SWEDISH = 1053
63
+ TURKISH = 1055
64
+ CHINESE_SIMPLIFIED = 2052
65
+ DUTCH_BELGIUM = 2067
66
+ PORTUGUESE_PORTUGAL = 2070
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class AlarmText:
71
+ """The texts for one alarm in one PLC language."""
72
+
73
+ language_id: LanguageId | int
74
+ info_text: str = ""
75
+ alarm_text: str = ""
76
+ additional_texts: tuple[str, ...] = ()
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class Alarm:
81
+ """Current state of a PLC alarm."""
82
+
83
+ cpu_alarm_id: int
84
+ all_states_info: int
85
+ domain: int
86
+ message_type: int
87
+ sequence_counter: int
88
+ name: str = ""
89
+ state: str = "unknown"
90
+ timestamp: int | None = None
91
+ acknowledge_timestamp: int | None = None
92
+ hmi_info: bytes = b""
93
+ associated_values: tuple[bytes, ...] = ()
94
+ texts: dict[int, AlarmText] = field(default_factory=dict)
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class AlarmNotification:
99
+ """An unsolicited S7CommPlus alarm notification."""
100
+
101
+ subscription_id: int
102
+ credit_tick: int
103
+ sequence_number: int
104
+ subscription_change_counter: int
105
+ timestamp: int | None
106
+ alarms: tuple[Alarm, ...]
107
+
108
+
109
+ @dataclass
110
+ class _Object:
111
+ relation_id: int
112
+ class_id: int
113
+ attributes: dict[int, Any] = field(default_factory=dict)
114
+ children: list[_Object] = field(default_factory=list)
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class _Blob:
119
+ root_id: int
120
+ value: bytes
121
+
122
+
123
+ def _attribute(attribute_id: int, datatype: int, value: bytes, flags: int = 0) -> bytes:
124
+ return bytes([ElementID.ATTRIBUTE]) + encode_uint32_vlq(attribute_id) + bytes([flags, datatype]) + value
125
+
126
+
127
+ def _wstring(value: str) -> bytes:
128
+ encoded = value.encode("utf-8")
129
+ return encode_uint32_vlq(len(encoded)) + encoded
130
+
131
+
132
+ def _udint_array(values: Sequence[int], flags: int = 0x20) -> bytes:
133
+ return (
134
+ bytes([flags, DataType.UDINT]) + encode_uint32_vlq(len(values)) + b"".join(encode_uint32_vlq(value) for value in values)
135
+ )
136
+
137
+
138
+ def _uint_array(values: list[int], flags: int = 0x10) -> bytes:
139
+ return bytes([flags, DataType.UINT]) + encode_uint32_vlq(len(values)) + b"".join(struct.pack(">H", value) for value in values)
140
+
141
+
142
+ def build_alarm_subscription_request(
143
+ subscription_container_id: int,
144
+ language_ids: Sequence[LanguageId | int] | None = None,
145
+ domains: list[int] | None = None,
146
+ credit_limit: int = 10,
147
+ ) -> bytes:
148
+ """Build an alarm-subscription CREATE_OBJECT payload."""
149
+ if not -1 <= credit_limit <= 255:
150
+ raise ValueError("credit_limit must be -1 (unlimited) or between 0 and 255")
151
+ languages = [] if language_ids is None else language_ids
152
+ domain_filter = [0xFFFF] if domains is None else domains
153
+ if any(not 0 <= value <= 0xFFFF for value in domain_filter):
154
+ raise ValueError("alarm domains must be UInt16 values")
155
+ if any(not 0 <= value <= 0xFFFFFFFF for value in languages):
156
+ raise ValueError("language IDs must be UInt32 values")
157
+
158
+ payload = bytearray()
159
+ payload += struct.pack(">I", subscription_container_id)
160
+ payload += bytes([0, DataType.UDINT]) + encode_uint32_vlq(0)
161
+ payload += struct.pack(">I", 0)
162
+ payload += bytes([ElementID.START_OF_OBJECT])
163
+ payload += struct.pack(">I", _ALARM_SUBSCRIPTION_RELATION_ID)
164
+ payload += encode_uint32_vlq(Ids.CLASS_SUBSCRIPTION)
165
+ payload += encode_uint32_vlq(0) + encode_uint32_vlq(0)
166
+ payload += _attribute(
167
+ Ids.OBJECT_VARIABLE_TYPE_NAME,
168
+ DataType.WSTRING,
169
+ _wstring(f"Subscription_{_ALARM_SUBSCRIPTION_RELATION_ID}"),
170
+ )
171
+ payload += _attribute(Ids.SUBSCRIPTION_FUNCTION_CLASS_ID, DataType.USINT, b"\x02")
172
+ payload += _attribute(Ids.SUBSCRIPTION_MISSED_SENDINGS, DataType.UINT, struct.pack(">H", 0))
173
+ payload += _attribute(Ids.SUBSCRIPTION_SUBSYSTEM_ERROR, DataType.LINT, encode_uint32_vlq(0))
174
+ payload += _attribute(Ids.SUBSCRIPTION_ROUTE_MODE, DataType.USINT, b"\x02")
175
+ payload += _attribute(Ids.SUBSCRIPTION_ACTIVE, DataType.BOOL, b"\x01")
176
+ payload += bytes([ElementID.ATTRIBUTE]) + encode_uint32_vlq(Ids.SUBSCRIPTION_REFERENCE_LIST)
177
+ payload += _udint_array([0x80010000, 0, 0])
178
+ payload += _attribute(Ids.SUBSCRIPTION_CYCLE_TIME, DataType.UDINT, encode_uint32_vlq(0))
179
+ payload += _attribute(Ids.SUBSCRIPTION_DELAY_TIME, DataType.UDINT, encode_uint32_vlq(0))
180
+ payload += _attribute(Ids.SUBSCRIPTION_DISABLED, DataType.USINT, b"\x00")
181
+ payload += _attribute(Ids.SUBSCRIPTION_COUNT, DataType.USINT, b"\x00")
182
+ payload += _attribute(Ids.SUBSCRIPTION_CREDIT_LIMIT, DataType.INT, struct.pack(">h", credit_limit))
183
+ payload += _attribute(Ids.SUBSCRIPTION_TICKS, DataType.UINT, struct.pack(">H", 0xFFFF))
184
+
185
+ payload += bytes([ElementID.START_OF_OBJECT])
186
+ payload += struct.pack(">I", _ALARM_REFERENCE_RELATION_ID)
187
+ payload += encode_uint32_vlq(Ids.ALARM_SUBSCRIPTION_REF_CLASS_RID)
188
+ payload += encode_uint32_vlq(0) + encode_uint32_vlq(0)
189
+ payload += _attribute(Ids.OBJECT_VARIABLE_TYPE_NAME, DataType.WSTRING, _wstring("S7pDriver_Alarming"))
190
+ payload += _attribute(Ids.SUBSCRIPTION_REFERENCE_TRIGGER_MODE, DataType.USINT, b"\x03")
191
+ payload += bytes([ElementID.ATTRIBUTE]) + encode_uint32_vlq(Ids.ALARM_SUBSCRIPTION_REF_ALARM_DOMAIN)
192
+ payload += _uint_array([0] * 10)
193
+ payload += bytes([ElementID.ATTRIBUTE]) + encode_uint32_vlq(Ids.ALARM_SUBSCRIPTION_REF_ALARM_DOMAIN_FILTER)
194
+ payload += _uint_array(domain_filter, flags=0x20)
195
+ payload += bytes([ElementID.ATTRIBUTE]) + encode_uint32_vlq(Ids.ALARM_SUBSCRIPTION_REF_TEXT_LANGUAGES)
196
+ payload += _udint_array(languages)
197
+ payload += _attribute(Ids.ALARM_SUBSCRIPTION_REF_SEND_TEXTS, DataType.BOOL, b"\x01")
198
+ payload += bytes([ElementID.RELATION])
199
+ payload += encode_uint32_vlq(Ids.ALARM_SUBSCRIPTION_REF_ITS_ALARM_SUBSYSTEM)
200
+ payload += struct.pack(">I", Ids.NATIVE_THE_ALARM_SUBSYSTEM_RID)
201
+ payload += bytes([ElementID.TERMINATING_OBJECT, ElementID.TERMINATING_OBJECT])
202
+ payload += struct.pack(">I", 0)
203
+ return bytes(payload)
204
+
205
+
206
+ def build_delete_alarm_subscription_request(subscription_container_id: int, protocol_version: int) -> bytes:
207
+ """Build the DeleteObject payload used for an alarm subscription container."""
208
+ return (
209
+ struct.pack(">I", subscription_container_id)
210
+ + b"\x00"
211
+ + encode_object_qualifier(protocol_version=protocol_version)
212
+ + struct.pack(">I", 0)
213
+ )
214
+
215
+
216
+ def build_alarm_explore_request() -> bytes:
217
+ """Build an EXPLORE request for the current alarm state."""
218
+ attributes = [
219
+ Ids.ALARM_DAI_CPU_ALARM_ID,
220
+ Ids.ALARM_DAI_ALL_STATES_INFO,
221
+ Ids.ALARM_DAI_DOMAIN,
222
+ Ids.ALARM_DAI_COMING,
223
+ Ids.ALARM_DAI_GOING,
224
+ Ids.ALARM_DAI_MESSAGE_TYPE,
225
+ Ids.ALARM_DAI_HMI_INFO,
226
+ Ids.OBJECT_VARIABLE_TYPE_NAME,
227
+ Ids.ALARM_DAI_SEQUENCE_COUNTER,
228
+ Ids.ALARM_DAI_TEXTS,
229
+ ]
230
+ payload = bytearray(struct.pack(">I", Ids.NATIVE_THE_ALARM_SUBSYSTEM_RID))
231
+ payload += encode_uint32_vlq(Ids.ALARM_SUBSYSTEM_UPDATE_RELEVANT_DAI)
232
+ payload += b"\x01\x01\x00\x00"
233
+ payload += encode_uint32_vlq(len(attributes))
234
+ for attribute_id in attributes:
235
+ payload += encode_uint32_vlq(attribute_id)
236
+ payload += struct.pack(">I", 0) + b"\x00"
237
+ return bytes(payload)
238
+
239
+
240
+ def _read_vlq32(data: bytes, offset: int) -> tuple[int, int]:
241
+ value, consumed = decode_uint32_vlq(data, offset)
242
+ return value, offset + consumed
243
+
244
+
245
+ def _read_vlq64(data: bytes, offset: int) -> tuple[int, int]:
246
+ value, consumed = decode_uint64_vlq(data, offset)
247
+ return value, offset + consumed
248
+
249
+
250
+ def _decode_blob(data: bytes, offset: int) -> tuple[_Blob, int]:
251
+ root_id, offset = _read_vlq32(data, offset)
252
+ if root_id > 1:
253
+ if offset + 9 > len(data):
254
+ raise ValueError("Truncated typed alarm blob")
255
+ offset += 8
256
+ blob_type = data[offset]
257
+ offset += 1
258
+ if blob_type not in (2, 3):
259
+ raise ValueError(f"Unsupported alarm blob type: {blob_type}")
260
+ size, offset = _read_vlq32(data, offset)
261
+ end = offset + size
262
+ if end > len(data):
263
+ raise ValueError("Truncated alarm blob")
264
+ return _Blob(root_id, bytes(data[offset:end])), end
265
+
266
+
267
+ def _decode_scalar(data: bytes, offset: int, datatype: int) -> tuple[Any, int]:
268
+ if datatype == DataType.NULL:
269
+ return None, offset
270
+ if datatype == DataType.BOOL:
271
+ return bool(data[offset]), offset + 1
272
+ if datatype in (DataType.USINT, DataType.BYTE):
273
+ return data[offset], offset + 1
274
+ if datatype == DataType.SINT:
275
+ return struct.unpack_from(">b", data, offset)[0], offset + 1
276
+ if datatype in (DataType.UINT, DataType.WORD):
277
+ return struct.unpack_from(">H", data, offset)[0], offset + 2
278
+ if datatype == DataType.INT:
279
+ return struct.unpack_from(">h", data, offset)[0], offset + 2
280
+ if datatype in (DataType.UDINT, DataType.AID):
281
+ return _read_vlq32(data, offset)
282
+ if datatype == DataType.DINT:
283
+ value, consumed = decode_int32_vlq(data, offset)
284
+ return value, offset + consumed
285
+ if datatype == DataType.ULINT:
286
+ return _read_vlq64(data, offset)
287
+ if datatype in (DataType.LINT, DataType.TIMESPAN):
288
+ value, consumed = decode_int64_vlq(data, offset)
289
+ return value, offset + consumed
290
+ if datatype in (DataType.DWORD, DataType.RID):
291
+ return struct.unpack_from(">I", data, offset)[0], offset + 4
292
+ if datatype == DataType.LWORD:
293
+ return struct.unpack_from(">Q", data, offset)[0], offset + 8
294
+ if datatype == DataType.REAL:
295
+ return struct.unpack_from(">f", data, offset)[0], offset + 4
296
+ if datatype == DataType.LREAL:
297
+ return struct.unpack_from(">d", data, offset)[0], offset + 8
298
+ if datatype == DataType.TIMESTAMP:
299
+ return struct.unpack_from(">Q", data, offset)[0], offset + 8
300
+ if datatype == DataType.WSTRING:
301
+ size, offset = _read_vlq32(data, offset)
302
+ end = offset + size
303
+ return data[offset:end].decode("utf-8", errors="replace"), end
304
+ if datatype == DataType.BLOB:
305
+ return _decode_blob(data, offset)
306
+ if datatype == DataType.STRUCT:
307
+ struct_id = struct.unpack_from(">I", data, offset)[0]
308
+ offset += 4
309
+ members: dict[int, Any] = {0: struct_id}
310
+ while offset < len(data) and data[offset] != 0:
311
+ member_id, offset = _read_vlq32(data, offset)
312
+ value, offset = _decode_value(data, offset)
313
+ members[member_id] = value
314
+ return members, offset + 1
315
+ raise ValueError(f"Unsupported alarm value datatype: {datatype:#x}")
316
+
317
+
318
+ def _decode_value(data: bytes, offset: int) -> tuple[Any, int]:
319
+ if offset + 2 > len(data):
320
+ raise ValueError("Truncated alarm value")
321
+ flags, datatype = data[offset], data[offset + 1]
322
+ offset += 2
323
+ if flags == 0x40:
324
+ values: dict[int, Any] = {}
325
+ key, offset = _read_vlq32(data, offset)
326
+ while key:
327
+ if datatype == DataType.BLOB:
328
+ value, offset = _decode_blob(data, offset)
329
+ else:
330
+ value, offset = _decode_scalar(data, offset, datatype)
331
+ values[key] = value
332
+ key, offset = _read_vlq32(data, offset)
333
+ return values, offset
334
+ if flags in (0x10, 0x20):
335
+ count, offset = _read_vlq32(data, offset)
336
+ array_values: list[Any] = []
337
+ for _ in range(count):
338
+ value, offset = _decode_scalar(data, offset, datatype)
339
+ array_values.append(value)
340
+ return array_values, offset
341
+ return _decode_scalar(data, offset, datatype)
342
+
343
+
344
+ def _decode_object(data: bytes, offset: int) -> tuple[_Object, int]:
345
+ if data[offset] != ElementID.START_OF_OBJECT:
346
+ raise ValueError("Expected S7CommPlus object")
347
+ offset += 1
348
+ relation_id = struct.unpack_from(">I", data, offset)[0]
349
+ offset += 4
350
+ class_id, offset = _read_vlq32(data, offset)
351
+ _, offset = _read_vlq32(data, offset) # class flags
352
+ _, offset = _read_vlq32(data, offset) # attribute id
353
+ result = _Object(relation_id, class_id)
354
+ while offset < len(data):
355
+ tag = data[offset]
356
+ if tag == ElementID.TERMINATING_OBJECT:
357
+ return result, offset + 1
358
+ if tag == ElementID.START_OF_OBJECT:
359
+ child, offset = _decode_object(data, offset)
360
+ result.children.append(child)
361
+ continue
362
+ if tag == ElementID.ATTRIBUTE:
363
+ attribute_id, value_offset = _read_vlq32(data, offset + 1)
364
+ value, offset = _decode_value(data, value_offset)
365
+ result.attributes[attribute_id] = value
366
+ continue
367
+ if tag == ElementID.RELATION:
368
+ _, offset = _read_vlq32(data, offset + 1)
369
+ offset += 4
370
+ continue
371
+ raise ValueError(f"Unsupported object element: {tag:#x}")
372
+ raise ValueError("Unterminated S7CommPlus object")
373
+
374
+
375
+ def _decode_objects(data: bytes, offset: int) -> tuple[list[_Object], int]:
376
+ objects = []
377
+ while offset < len(data) and data[offset] == ElementID.START_OF_OBJECT:
378
+ obj, offset = _decode_object(data, offset)
379
+ objects.append(obj)
380
+ return objects, offset
381
+
382
+
383
+ def _decode_associated_value(blob: _Blob) -> object | None:
384
+ """Decode an alarm SD value using its Softdatatype root ID."""
385
+ root_id, value = blob.root_id, blob.value
386
+ try:
387
+ if root_id == _SOFTDATATYPE_STRING or _STRING_TYPE_MIN <= root_id <= _STRING_TYPE_MAX:
388
+ size = value[1]
389
+ return value[2 : 2 + size].decode("latin-1", errors="replace")
390
+ if root_id == _SOFTDATATYPE_WSTRING or _WSTRING_TYPE_MIN <= root_id <= _WSTRING_TYPE_MAX:
391
+ size = struct.unpack_from(">H", value, 2)[0]
392
+ return value[4 : 4 + 2 * size].decode("utf-16-be", errors="replace")
393
+ if root_id == _SOFTDATATYPE_BOOL:
394
+ return bool(value[0])
395
+ if root_id in (_SOFTDATATYPE_BYTE, _SOFTDATATYPE_USINT):
396
+ return value[0]
397
+ if root_id == _SOFTDATATYPE_CHAR:
398
+ return value[:1].decode("latin-1", errors="replace")
399
+ if root_id == _SOFTDATATYPE_SINT:
400
+ return int(struct.unpack(">b", value)[0])
401
+ if root_id in (_SOFTDATATYPE_WORD, _SOFTDATATYPE_UINT):
402
+ return int(struct.unpack(">H", value)[0])
403
+ if root_id == _SOFTDATATYPE_INT:
404
+ return int(struct.unpack(">h", value)[0])
405
+ if root_id in (_SOFTDATATYPE_DWORD, _SOFTDATATYPE_UDINT):
406
+ return int(struct.unpack(">I", value)[0])
407
+ if root_id == _SOFTDATATYPE_DINT:
408
+ return int(struct.unpack(">i", value)[0])
409
+ if root_id == _SOFTDATATYPE_REAL:
410
+ return float(struct.unpack(">f", value)[0])
411
+ if root_id == _SOFTDATATYPE_LREAL:
412
+ return float(struct.unpack(">d", value)[0])
413
+ if root_id == _SOFTDATATYPE_WCHAR:
414
+ return value[:2].decode("utf-16-be", errors="replace")
415
+ except (IndexError, struct.error):
416
+ return None
417
+ return None
418
+
419
+
420
+ def _interpolate_associated_values(text: str, values: Sequence[_Blob]) -> str:
421
+ def replace(match: re.Match[str]) -> str:
422
+ index = int(match.group(1))
423
+ if index >= len(values):
424
+ return match.group(0)
425
+ value = _decode_associated_value(values[index])
426
+ if value is None:
427
+ return match.group(0)
428
+ try:
429
+ return match.group(2) % value
430
+ except (TypeError, ValueError):
431
+ return match.group(0)
432
+
433
+ return _ASSOCIATED_VALUE_TOKEN.sub(replace, text)
434
+
435
+
436
+ def _alarm_texts(
437
+ value: Any, language_ids: set[LanguageId | int] | None, associated_values: Sequence[_Blob]
438
+ ) -> dict[int, AlarmText]:
439
+ grouped: dict[int, dict[int, str]] = {}
440
+ if not isinstance(value, dict):
441
+ return {}
442
+ for key, blob in value.items():
443
+ if not isinstance(key, int) or not isinstance(blob, _Blob):
444
+ continue
445
+ language_id, text_id = key >> 16, key & 0xFFFF
446
+ if language_ids is not None and language_id not in language_ids:
447
+ continue
448
+ text = blob.value.decode("utf-8", errors="replace")
449
+ grouped.setdefault(language_id, {})[text_id] = _interpolate_associated_values(text, associated_values)
450
+ result = {}
451
+ for language_id, texts in grouped.items():
452
+ additional = tuple(texts.get(i, "") for i in range(3, 12))
453
+ result[language_id] = AlarmText(language_id, texts.get(1, ""), texts.get(2, ""), additional)
454
+ return result
455
+
456
+
457
+ def _alarm_from_object(obj: _Object, language_ids: set[LanguageId | int] | None) -> Alarm:
458
+ attrs = obj.attributes
459
+ state_id = Ids.ALARM_DAI_COMING if Ids.ALARM_DAI_COMING in attrs else Ids.ALARM_DAI_GOING
460
+ state_value = attrs.get(state_id)
461
+ state = "coming" if state_id == Ids.ALARM_DAI_COMING else "going"
462
+ timestamp: int | None = None
463
+ acknowledge_timestamp: int | None = None
464
+ associated_blobs: tuple[_Blob, ...] = ()
465
+ if isinstance(state_value, dict):
466
+ timestamp_value = state_value.get(3475)
467
+ acknowledge_value = state_value.get(3646)
468
+ timestamp = timestamp_value if isinstance(timestamp_value, int) else None
469
+ acknowledge_timestamp = acknowledge_value if isinstance(acknowledge_value, int) else None
470
+ raw_values = state_value.get(3476)
471
+ if isinstance(raw_values, list):
472
+ associated_blobs = tuple(value for value in raw_values if isinstance(value, _Blob))
473
+ hmi = attrs.get(Ids.ALARM_DAI_HMI_INFO)
474
+ return Alarm(
475
+ cpu_alarm_id=int(attrs.get(Ids.ALARM_DAI_CPU_ALARM_ID, 0)),
476
+ all_states_info=int(attrs.get(Ids.ALARM_DAI_ALL_STATES_INFO, 0)),
477
+ domain=int(attrs.get(Ids.ALARM_DAI_DOMAIN, 0)),
478
+ message_type=int(attrs.get(Ids.ALARM_DAI_MESSAGE_TYPE, 0)),
479
+ sequence_counter=int(attrs.get(Ids.ALARM_DAI_SEQUENCE_COUNTER, 0)),
480
+ name=str(attrs.get(Ids.OBJECT_VARIABLE_TYPE_NAME, "")),
481
+ state=state if state_value is not None else "unknown",
482
+ timestamp=timestamp,
483
+ acknowledge_timestamp=acknowledge_timestamp,
484
+ hmi_info=hmi.value if isinstance(hmi, _Blob) else b"",
485
+ associated_values=tuple(blob.value for blob in associated_blobs),
486
+ texts=_alarm_texts(attrs.get(Ids.ALARM_DAI_TEXTS), language_ids, associated_blobs),
487
+ )
488
+
489
+
490
+ def parse_alarm_explore_response(response: bytes, language_ids: Sequence[LanguageId | int] | None = None) -> list[Alarm]:
491
+ """Parse the payload returned by an alarm-subsystem EXPLORE request."""
492
+ return_value, offset = _read_vlq64(response, 0)
493
+ if return_value != 0:
494
+ raise RuntimeError(f"Alarm browse failed: PLC returned {return_value:#x}")
495
+ if offset + 4 > len(response):
496
+ raise ValueError("Alarm browse response is truncated")
497
+ offset += 4 # ExploreId
498
+ # IntegrityId is between ExploreId and the object list on V2+ responses.
499
+ while offset < len(response) and response[offset] != ElementID.START_OF_OBJECT:
500
+ _, offset = _read_vlq32(response, offset)
501
+ objects, _ = _decode_objects(response, offset)
502
+ wanted = set(language_ids) if language_ids is not None else None
503
+ return [_alarm_from_object(obj, wanted) for obj in objects if obj.class_id == Ids.ALARM_DAI_CLASS_RID]
504
+
505
+
506
+ def parse_alarm_notification(frame: bytes, language_ids: Sequence[LanguageId | int] | None = None) -> AlarmNotification:
507
+ """Parse one complete S7CommPlus notification frame."""
508
+ if len(frame) < 5 or frame[0] != 0x72:
509
+ raise ValueError("Invalid S7CommPlus notification frame")
510
+ data_length = struct.unpack_from(">H", frame, 2)[0]
511
+ data = frame[4 : 4 + data_length]
512
+ if not data or data[0] != Opcode.NOTIFICATION:
513
+ raise ValueError("Expected S7CommPlus notification opcode")
514
+ offset = 1
515
+ subscription_id = struct.unpack_from(">I", data, offset)[0]
516
+ offset += 10 # subscription id plus three unknown UInt16 fields
517
+ credit_tick = data[offset]
518
+ offset += 1
519
+ sequence_number, offset = _read_vlq32(data, offset)
520
+ change_counter = data[offset]
521
+ timestamp: int | None = None
522
+ if change_counter:
523
+ offset += 1
524
+ else:
525
+ timestamp = struct.unpack_from(">Q", data, offset)[0]
526
+ offset += 9 # timestamp plus additional change counter
527
+ # Skip the data-change value list. Alarm-only subscriptions terminate it with zero.
528
+ while offset < len(data) and data[offset] != 0:
529
+ raise ValueError("Mixed data/alarm notifications are not supported")
530
+ offset += 1
531
+ alarms: list[Alarm] = []
532
+ if offset < len(data) and data[offset] != 0:
533
+ alarm_subscription_id = struct.unpack_from(">I", data, offset)[0]
534
+ offset += 6
535
+ if data[offset] != 0x81:
536
+ raise ValueError(f"Unsupported alarm notification return value: {data[offset]:#x}")
537
+ offset += 1
538
+ objects, _ = _decode_objects(data, offset)
539
+ wanted = set(language_ids) if language_ids is not None else None
540
+ alarms = [_alarm_from_object(obj, wanted) for obj in objects if obj.class_id == Ids.ALARM_DAI_CLASS_RID]
541
+ if subscription_id == 0:
542
+ subscription_id = alarm_subscription_id
543
+ return AlarmNotification(subscription_id, credit_tick, sequence_number, change_counter, timestamp, tuple(alarms))