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.
- s7commplus/__init__.py +64 -0
- s7commplus/alarm.py +543 -0
- s7commplus/async_client.py +1425 -0
- s7commplus/blob_decompressor.py +89 -0
- s7commplus/catalog.py +137 -0
- s7commplus/client.py +1719 -0
- s7commplus/codec.py +810 -0
- s7commplus/connection.py +2233 -0
- s7commplus/error.py +456 -0
- s7commplus/legitimation.py +271 -0
- s7commplus/protocol.py +387 -0
- s7commplus/py.typed +0 -0
- s7commplus/server.py +1374 -0
- s7commplus/session_auth/ARCHITECTURE.md +192 -0
- s7commplus/session_auth/LICENSE-HarpoS7 +21 -0
- s7commplus/session_auth/__init__.py +80 -0
- s7commplus/session_auth/artifacts.json +254 -0
- s7commplus/session_auth/blob_metadata.py +142 -0
- s7commplus/session_auth/family0/__init__.py +7 -0
- s7commplus/session_auth/family0/_generated/__init__.py +20 -0
- s7commplus/session_auth/family0/_generated/data/__init__.py +96 -0
- s7commplus/session_auth/family0/_generated/data/_constants.py +1637 -0
- s7commplus/session_auth/family0/_generated/data/fp_data1.bin +0 -0
- s7commplus/session_auth/family0/_generated/data/fp_data2.bin +0 -0
- s7commplus/session_auth/family0/_generated/data/transform12_big_int_data.bin +0 -0
- s7commplus/session_auth/family0/_generated/data/transform12_metadata.bin +0 -0
- s7commplus/session_auth/family0/_generated/monolith1.py +3619 -0
- s7commplus/session_auth/family0/_generated/monolith10.py +18 -0
- s7commplus/session_auth/family0/_generated/monolith11.py +1091 -0
- s7commplus/session_auth/family0/_generated/monolith2.py +3430 -0
- s7commplus/session_auth/family0/_generated/monolith3.py +2900 -0
- s7commplus/session_auth/family0/_generated/monolith4.py +5578 -0
- s7commplus/session_auth/family0/_generated/monolith5.py +3625 -0
- s7commplus/session_auth/family0/_generated/monolith6.py +4052 -0
- s7commplus/session_auth/family0/_generated/monolith7.py +2263 -0
- s7commplus/session_auth/family0/_generated/monolith8.py +3636 -0
- s7commplus/session_auth/family0/_generated/monolith9.py +38 -0
- s7commplus/session_auth/family0/_generated/nine/__init__.py +0 -0
- s7commplus/session_auth/family0/_generated/nine/part1.py +3227 -0
- s7commplus/session_auth/family0/_generated/nine/part10.py +4550 -0
- s7commplus/session_auth/family0/_generated/nine/part11.py +5033 -0
- s7commplus/session_auth/family0/_generated/nine/part2.py +4524 -0
- s7commplus/session_auth/family0/_generated/nine/part3.py +5044 -0
- s7commplus/session_auth/family0/_generated/nine/part4.py +4449 -0
- s7commplus/session_auth/family0/_generated/nine/part5.py +4606 -0
- s7commplus/session_auth/family0/_generated/nine/part6.py +4518 -0
- s7commplus/session_auth/family0/_generated/nine/part7.py +4619 -0
- s7commplus/session_auth/family0/_generated/nine/part8.py +4495 -0
- s7commplus/session_auth/family0/_generated/nine/part9.py +4523 -0
- s7commplus/session_auth/family0/_generated/ten/__init__.py +0 -0
- s7commplus/session_auth/family0/_generated/ten/part1.py +4080 -0
- s7commplus/session_auth/family0/_generated/ten/part2.py +4968 -0
- s7commplus/session_auth/family0/_generated/ten/part3.py +5170 -0
- s7commplus/session_auth/family0/authenticator.py +168 -0
- s7commplus/session_auth/family0/big_int_operations.py +199 -0
- s7commplus/session_auth/family0/big_int_transforms.py +198 -0
- s7commplus/session_auth/family0/checksum_transform.py +80 -0
- s7commplus/session_auth/family0/fingerprint.py +134 -0
- s7commplus/session_auth/family0/key_derivation_transform.py +62 -0
- s7commplus/session_auth/family0/lut_generator.py +75 -0
- s7commplus/session_auth/family0/monolith_wrappers.py +101 -0
- s7commplus/session_auth/family0/pre_seed_transform.py +46 -0
- s7commplus/session_auth/family0/seed_transform.py +83 -0
- s7commplus/session_auth/family0/transform12.py +76 -0
- s7commplus/session_auth/family0/transform13.py +51 -0
- s7commplus/session_auth/family0/transform7.py +149 -0
- s7commplus/session_auth/harpo_aes.py +59 -0
- s7commplus/session_auth/harpo_aes_ctr.py +255 -0
- s7commplus/session_auth/harpo_hash.py +220 -0
- s7commplus/session_auth/key_derivation.py +129 -0
- s7commplus/session_auth/keys.py +205 -0
- s7commplus/session_auth/legacy_auth.py +48 -0
- s7commplus/session_auth/legitimate.py +97 -0
- s7commplus/session_auth/utils.py +42 -0
- s7commplus/subscription.py +285 -0
- s7commplus/tag_browser.py +358 -0
- s7commplus/transport.py +542 -0
- s7commplus/typeinfo.py +809 -0
- s7commplus/vlq.py +338 -0
- s7commplus/zlib_dicts/1398a37f_CompilerSettings_90000001.xml +1 -0
- s7commplus/zlib_dicts/1bac39f0_DebugInfo_90000001.xml +1 -0
- s7commplus/zlib_dicts/3c55436a_LineComm_98000001.xml +1 -0
- s7commplus/zlib_dicts/4b8416f0_IntfDesc_90000001.xml +1 -0
- s7commplus/zlib_dicts/5814b03b_IdentES_98000001.xml +1 -0
- s7commplus/zlib_dicts/66052b13_DebugInfo_IntfDesc_98000001.xml +1 -0
- s7commplus/zlib_dicts/79b2bda3_LineComm_90000001.xml +1 -0
- s7commplus/zlib_dicts/81d8db20_IdentES_90000002.xml +1 -0
- s7commplus/zlib_dicts/845fc605_NWT_98000001.xml +1 -0
- s7commplus/zlib_dicts/9b6a3a92_ExtRefData_90000001.xml +1 -0
- s7commplus/zlib_dicts/__init__.py +28 -0
- s7commplus/zlib_dicts/ab6fa31e_NWC_90000001.xml +1 -0
- s7commplus/zlib_dicts/b0155ff8_IntRefData_98000001.xml +1 -0
- s7commplus/zlib_dicts/c5d26ac3_NWC_98000001.xml +1 -0
- s7commplus/zlib_dicts/ce9b821b_IntfDescTag_90000001.xml +1 -0
- s7commplus/zlib_dicts/da4a88f4_IntRefData_90000001.xml +1 -0
- s7commplus/zlib_dicts/df91b6bb_IdentES_90000001.xml +1 -0
- s7commplus/zlib_dicts/e2729ea1_TagLineComm_90000001.xml +1 -0
- s7commplus/zlib_dicts/efaeae49_BodyDesc_90000001.xml +1 -0
- s7commplus/zlib_dicts/fd69ac74_NWT_90000001.xml +1 -0
- s7commplus-0.1.0.dist-info/METADATA +90 -0
- s7commplus-0.1.0.dist-info/RECORD +105 -0
- s7commplus-0.1.0.dist-info/WHEEL +5 -0
- s7commplus-0.1.0.dist-info/licenses/LICENSE +20 -0
- s7commplus-0.1.0.dist-info/licenses/s7commplus/session_auth/LICENSE-HarpoS7 +21 -0
- 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))
|