s7datablock 0.0.0a0__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.
- s7datablock/__init__.py +23 -0
- s7datablock/constants.py +93 -0
- s7datablock/fields.py +341 -0
- s7datablock/grammar.py +112 -0
- s7datablock/mapping.py +207 -0
- s7datablock/parser.py +83 -0
- s7datablock/py.typed +0 -0
- s7datablock-0.0.0a0.dist-info/METADATA +136 -0
- s7datablock-0.0.0a0.dist-info/RECORD +12 -0
- s7datablock-0.0.0a0.dist-info/WHEEL +4 -0
- s7datablock-0.0.0a0.dist-info/licenses/AUTHORS.md +3 -0
- s7datablock-0.0.0a0.dist-info/licenses/LICENSE.txt +21 -0
s7datablock/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
if sys.version_info[:2] >= (3, 8):
|
|
4
|
+
# TODO: Import directly (no need for conditional) when `python_requires = >= 3.8`
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version # pragma: no cover
|
|
6
|
+
else:
|
|
7
|
+
from importlib_metadata import PackageNotFoundError, version # pragma: no cover
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
# Change here if project is renamed and does not equal the package name
|
|
11
|
+
dist_name = "s7datablock"
|
|
12
|
+
__version__ = version(dist_name)
|
|
13
|
+
except PackageNotFoundError: # pragma: no cover
|
|
14
|
+
__version__ = "unknown"
|
|
15
|
+
finally:
|
|
16
|
+
del version, PackageNotFoundError
|
|
17
|
+
|
|
18
|
+
# Export public API
|
|
19
|
+
from s7datablock.fields import S7Field
|
|
20
|
+
from s7datablock.mapping import BufferMapping, S7DataBlock
|
|
21
|
+
from s7datablock.parser import parse_db_file
|
|
22
|
+
|
|
23
|
+
__all__ = ["S7Field", "BufferMapping", "S7DataBlock", "parse_db_file"]
|
s7datablock/constants.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Constants and type definitions for S7 PLC data types."""
|
|
2
|
+
from datetime import datetime, time, timedelta
|
|
3
|
+
from typing import Final, Literal, TypeAlias, Union
|
|
4
|
+
|
|
5
|
+
# S7 Type Literals
|
|
6
|
+
S7ElementaryType = Literal[
|
|
7
|
+
"Bool",
|
|
8
|
+
"Byte",
|
|
9
|
+
"Word",
|
|
10
|
+
"DWord",
|
|
11
|
+
"SInt",
|
|
12
|
+
"USInt",
|
|
13
|
+
"Int",
|
|
14
|
+
"UInt",
|
|
15
|
+
"DInt",
|
|
16
|
+
"UDInt",
|
|
17
|
+
"LInt",
|
|
18
|
+
"ULInt",
|
|
19
|
+
"Real",
|
|
20
|
+
"LReal",
|
|
21
|
+
"Char",
|
|
22
|
+
"WChar",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
S7DateTimeType = Literal[
|
|
26
|
+
"S5Time",
|
|
27
|
+
"Time",
|
|
28
|
+
"LTime",
|
|
29
|
+
"Date",
|
|
30
|
+
"Time_of_Day",
|
|
31
|
+
"DATE_AND_TIME",
|
|
32
|
+
"LDT",
|
|
33
|
+
"DTL",
|
|
34
|
+
"LTIME_OF_DAY",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
S7CompositeType = Literal["Struct"]
|
|
38
|
+
S7Type = Union[S7ElementaryType, S7DateTimeType, S7CompositeType]
|
|
39
|
+
|
|
40
|
+
# Type Aliases for values
|
|
41
|
+
S7ElementaryValue: TypeAlias = Union[bool, int, float, str]
|
|
42
|
+
S7DateTimeValue: TypeAlias = Union[datetime, time, timedelta, None] # None for invalid/unset dates
|
|
43
|
+
S7Value: TypeAlias = Union[S7ElementaryValue, S7DateTimeValue]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Sets of types
|
|
47
|
+
S7_ELEMENTARY_TYPES: Final = frozenset(
|
|
48
|
+
[
|
|
49
|
+
"Bool",
|
|
50
|
+
"Byte",
|
|
51
|
+
"Word",
|
|
52
|
+
"DWord",
|
|
53
|
+
"SInt",
|
|
54
|
+
"USInt",
|
|
55
|
+
"Int",
|
|
56
|
+
"UInt",
|
|
57
|
+
"DInt",
|
|
58
|
+
"UDInt",
|
|
59
|
+
"LInt",
|
|
60
|
+
"ULInt",
|
|
61
|
+
"Real",
|
|
62
|
+
"LReal",
|
|
63
|
+
"Char",
|
|
64
|
+
"WChar",
|
|
65
|
+
]
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
S7_DATETIME_TYPES: Final = frozenset(
|
|
69
|
+
["S5Time", "Time", "LTime", "Date", "Time_of_Day", "DATE_AND_TIME", "LDT", "DTL", "LTIME_OF_DAY"]
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
S7_COMPOSITE_TYPES: Final = frozenset(["Struct"])
|
|
73
|
+
S7_ALL_TYPES: Final = S7_ELEMENTARY_TYPES | S7_DATETIME_TYPES | S7_COMPOSITE_TYPES
|
|
74
|
+
|
|
75
|
+
# Struct format mapping
|
|
76
|
+
S7_TYPE_TO_STRUCT_FORMAT: Final = {
|
|
77
|
+
"Bool": "H", # 1 byte, but packed as part of a word
|
|
78
|
+
"Byte": "B", # 8-bit unsigned
|
|
79
|
+
"USInt": "B", # 8-bit unsigned
|
|
80
|
+
"Char": "c", # 1 character, 1 byte
|
|
81
|
+
"SInt": "b", # 8-bit signed
|
|
82
|
+
"UInt": "H", # 16-bit unsigned
|
|
83
|
+
"Word": "H", # 16-bit unsigned
|
|
84
|
+
"Int": "h", # 16-bit signed
|
|
85
|
+
"DInt": "i", # 32-bit signed
|
|
86
|
+
"UDInt": "I", # 32-bit unsigned
|
|
87
|
+
"DWord": "I", # 32-bit unsigned
|
|
88
|
+
"Real": "f", # 32-bit IEEE 754 float
|
|
89
|
+
"LReal": "d", # 64-bit IEEE 754 float
|
|
90
|
+
"Time": "I", # 32-bit unsigned (milliseconds)
|
|
91
|
+
"Date": "H", # 16-bit unsigned (days since 1990-01-01)
|
|
92
|
+
"Time_of_Day": "I", # 32-bit unsigned (ms since midnight)
|
|
93
|
+
}
|
s7datablock/fields.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""S7Field definitions for S7 PLC data types."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import struct
|
|
5
|
+
from abc import ABC
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from typing import Any, Generic, NamedTuple, TypeVar, cast
|
|
8
|
+
|
|
9
|
+
from rich.style import Style
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from s7datablock.constants import S7Type, S7Value
|
|
13
|
+
|
|
14
|
+
T = TypeVar("T", bound=S7Value)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class NameType(NamedTuple):
|
|
18
|
+
"""Type for storing S7Field name and type information."""
|
|
19
|
+
|
|
20
|
+
name: list[str]
|
|
21
|
+
type: S7Type
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SubField(NamedTuple):
|
|
25
|
+
"""Represents a subfield in a structure."""
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
s7_type: S7Type
|
|
29
|
+
relative_offset: int
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FormattedTableRow(NamedTuple):
|
|
33
|
+
"""Represents a formatted row in a table with rich text support."""
|
|
34
|
+
|
|
35
|
+
name: str
|
|
36
|
+
type: Text
|
|
37
|
+
offset: str
|
|
38
|
+
value: Text
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class S7Field(ABC, Generic[T]):
|
|
42
|
+
"""Base class for all S7 PLC fields"""
|
|
43
|
+
|
|
44
|
+
_registry: dict[str, type[S7Field]] = {}
|
|
45
|
+
|
|
46
|
+
buffer_slice: slice
|
|
47
|
+
offset: int # Can be a byte offset or (byte, bit) tuple for bit-addressed fields
|
|
48
|
+
offset_bit: int = 0 # Bit offset for bit-addressed fields
|
|
49
|
+
s7_type: S7Type
|
|
50
|
+
struct_fmt: str
|
|
51
|
+
struct_width: int # Width of the struct in bytes, used for packing/unpacking
|
|
52
|
+
|
|
53
|
+
def __init_subclass__(cls, *, s7_type: S7Type | None = None, subfield_config: list[SubField] | None = None) -> None:
|
|
54
|
+
super().__init_subclass__()
|
|
55
|
+
if s7_type is not None:
|
|
56
|
+
S7Field._registry[s7_type] = cls
|
|
57
|
+
cls.s7_type = s7_type
|
|
58
|
+
cls.subfield_config = subfield_config or []
|
|
59
|
+
|
|
60
|
+
def __init__(self, offset: int, offset_bit: int = 0):
|
|
61
|
+
self.offset = offset
|
|
62
|
+
self.offset_bit = offset_bit
|
|
63
|
+
self.struct_width = struct.calcsize(self.struct_fmt)
|
|
64
|
+
self.buffer_slice = slice(offset, offset + self.struct_width)
|
|
65
|
+
|
|
66
|
+
def __eq__(self, other: object) -> bool:
|
|
67
|
+
if not isinstance(other, S7Field):
|
|
68
|
+
return NotImplemented
|
|
69
|
+
# Compare core attributes that define S7Field equality
|
|
70
|
+
if not (self.s7_type == other.s7_type and self.offset == other.offset):
|
|
71
|
+
return False
|
|
72
|
+
# For boolean fields, also compare bit position
|
|
73
|
+
if isinstance(self.offset, tuple) and isinstance(other.offset, tuple):
|
|
74
|
+
return self.offset[1] == other.offset[1]
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
def __repr__(self) -> str:
|
|
78
|
+
offset_str = f"({self.offset[0]}, {self.offset[1]})" if isinstance(self.offset, tuple) else str(self.offset)
|
|
79
|
+
return f"{self.__class__.__name__}(offset={offset_str}, s7_type='{self.s7_type}')"
|
|
80
|
+
|
|
81
|
+
def unpack(self, buffer: bytearray) -> T:
|
|
82
|
+
"""Unpack value from buffer."""
|
|
83
|
+
return cast(T, struct.unpack(self.struct_fmt, buffer[self.buffer_slice])[0])
|
|
84
|
+
|
|
85
|
+
def pack(self, buffer: bytearray, value: T) -> None:
|
|
86
|
+
"""Pack value into buffer."""
|
|
87
|
+
buffer[self.buffer_slice] = struct.pack(self.struct_fmt, value)
|
|
88
|
+
|
|
89
|
+
def format_value(self, value: T) -> str:
|
|
90
|
+
"""Format a value for display based on its type."""
|
|
91
|
+
if isinstance(value, bool):
|
|
92
|
+
return str(value)
|
|
93
|
+
elif isinstance(value, float):
|
|
94
|
+
return f"{value:.3f}"
|
|
95
|
+
elif isinstance(value, datetime):
|
|
96
|
+
return value.strftime("%Y-%m-%d %H:%M:%S")
|
|
97
|
+
return str(value)
|
|
98
|
+
|
|
99
|
+
def get_table_row(self, name: str, buffer: bytearray, indent_level: int = 0) -> FormattedTableRow:
|
|
100
|
+
"""Get a formatted row for the rich text table."""
|
|
101
|
+
display_name = " " * indent_level + name
|
|
102
|
+
type_style = Style(
|
|
103
|
+
color={
|
|
104
|
+
# Elementary types (blue family)
|
|
105
|
+
"SInt": "#4169E1", # Royal blue
|
|
106
|
+
"Int": "#324AB2", # Medium royal blue
|
|
107
|
+
"DInt": "#1E4BB8", # Deep royal blue
|
|
108
|
+
# Unsigned integers (turquoise family)
|
|
109
|
+
"USInt": "#20B2AA", # Light turquoise
|
|
110
|
+
"UInt": "#17887F", # Medium turquoise
|
|
111
|
+
"UDInt": "#0F5E54", # Deep turquoise
|
|
112
|
+
# Floating point (purple family)
|
|
113
|
+
"Real": "#B87FD9", # Light purple
|
|
114
|
+
"LReal": "#9B4FC3", # Deep purple
|
|
115
|
+
# Word types (cyan family)
|
|
116
|
+
"Byte": "#69C5CE", # Light cyan
|
|
117
|
+
"Word": "#4AACB5", # Medium cyan
|
|
118
|
+
"DWord": "#2B939C", # Deep cyan
|
|
119
|
+
# Character types (warm gray family)
|
|
120
|
+
"Char": "#D4C5BC", # Light warm gray
|
|
121
|
+
"String": "#B5A69C", # Medium warm gray
|
|
122
|
+
"WChar": "#96877C", # Deep warm gray
|
|
123
|
+
# Special types with distinct colors
|
|
124
|
+
"Bool": "#FFD700", # Gold for booleans
|
|
125
|
+
"DTL": "#FF8C42", # Bright orange for date/time
|
|
126
|
+
"Struct": "#F0F0F0", # Soft white for structures
|
|
127
|
+
}.get(self.s7_type, "#FFFFFF")
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
return FormattedTableRow(
|
|
131
|
+
name=display_name,
|
|
132
|
+
type=Text(self.s7_type, style=type_style),
|
|
133
|
+
offset=f"{self.offset}.{self.offset_bit}",
|
|
134
|
+
value=Text(self.format_value(self.unpack(buffer)), style=type_style),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def subfields(self) -> dict[str, S7Field[S7Value]]:
|
|
139
|
+
"""Get all the component fields of the structure."""
|
|
140
|
+
assert isinstance(self.offset, int), "Offset must be an integer for subfields"
|
|
141
|
+
return {
|
|
142
|
+
name: S7Field.create(self.offset + rel_offset, s7_type)
|
|
143
|
+
for name, s7_type, rel_offset in self.subfield_config
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def create(offset: int, s7_type: str, offset_bit: int = 0) -> S7Field[S7Value]:
|
|
148
|
+
"""Create a new S7Field instance of the appropriate type."""
|
|
149
|
+
field_class = S7Field._registry.get(s7_type)
|
|
150
|
+
if field_class is None:
|
|
151
|
+
raise ValueError(f"Unknown S7 type: {s7_type}")
|
|
152
|
+
return field_class(offset, offset_bit)
|
|
153
|
+
|
|
154
|
+
def __str__(self) -> str:
|
|
155
|
+
return repr(self)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class S7Bool(S7Field[bool], s7_type="Bool"):
|
|
159
|
+
"""Boolean S7Field type."""
|
|
160
|
+
|
|
161
|
+
struct_fmt = "?"
|
|
162
|
+
|
|
163
|
+
def pack(self, buffer: bytearray, value: bool) -> None:
|
|
164
|
+
byte = buffer[self.offset]
|
|
165
|
+
if value:
|
|
166
|
+
byte |= 1 << self.offset_bit
|
|
167
|
+
else:
|
|
168
|
+
byte &= ~(1 << self.offset_bit)
|
|
169
|
+
buffer[self.offset] = byte
|
|
170
|
+
|
|
171
|
+
def unpack(self, buffer: bytearray) -> bool:
|
|
172
|
+
return bool(buffer[self.offset] & (1 << self.offset_bit))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class S7Real(S7Field[float], s7_type="Real"):
|
|
176
|
+
"""32-bit floating point S7Field."""
|
|
177
|
+
|
|
178
|
+
struct_fmt = ">f"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class S7Byte(S7Field[int], s7_type="Byte"):
|
|
182
|
+
"""8-bit unsigned integer S7Field."""
|
|
183
|
+
|
|
184
|
+
struct_fmt = "B"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class S7Word(S7Field[int], s7_type="Word"):
|
|
188
|
+
"""16-bit unsigned integer S7Field."""
|
|
189
|
+
|
|
190
|
+
struct_fmt = ">H"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class S7DWord(S7Field[int], s7_type="DWord"):
|
|
194
|
+
"""32-bit unsigned integer S7Field."""
|
|
195
|
+
|
|
196
|
+
struct_fmt = ">I"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class S7DInt(S7Field[int], s7_type="DInt"):
|
|
200
|
+
"""32-bit signed integer S7Field."""
|
|
201
|
+
|
|
202
|
+
struct_fmt = ">i"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class S7UInt(S7Field[int], s7_type="UInt"):
|
|
206
|
+
"""16-bit unsigned integer S7Field."""
|
|
207
|
+
|
|
208
|
+
struct_fmt = ">H"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class S7UDInt(S7Field[int], s7_type="UDInt"):
|
|
212
|
+
"""32-bit unsigned integer S7Field."""
|
|
213
|
+
|
|
214
|
+
struct_fmt = ">I"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class S7USInt(S7Field[int], s7_type="USInt"):
|
|
218
|
+
"""8-bit unsigned integer S7Field."""
|
|
219
|
+
|
|
220
|
+
struct_fmt = "B"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class S7Char(S7Field[str], s7_type="Char"):
|
|
224
|
+
"""Single character S7Field."""
|
|
225
|
+
|
|
226
|
+
struct_fmt = "c"
|
|
227
|
+
|
|
228
|
+
def pack(self, buffer: bytearray, value: str) -> None:
|
|
229
|
+
buffer[self.buffer_slice] = value.encode("ascii")
|
|
230
|
+
|
|
231
|
+
def unpack(self, buffer: bytearray) -> str:
|
|
232
|
+
return buffer[self.buffer_slice].decode("ascii")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# Date and time types
|
|
236
|
+
class S7DTL(
|
|
237
|
+
S7Field[datetime | None],
|
|
238
|
+
s7_type="DTL",
|
|
239
|
+
subfield_config=[
|
|
240
|
+
SubField("YEAR", "UInt", 0),
|
|
241
|
+
SubField("MONTH", "USInt", 2),
|
|
242
|
+
SubField("DAY", "USInt", 3),
|
|
243
|
+
SubField("WEEKDAY", "USInt", 4),
|
|
244
|
+
SubField("HOUR", "USInt", 5),
|
|
245
|
+
SubField("MINUTE", "USInt", 6),
|
|
246
|
+
SubField("SECOND", "USInt", 7),
|
|
247
|
+
SubField("NANOSECOND", "UDInt", 8),
|
|
248
|
+
],
|
|
249
|
+
):
|
|
250
|
+
"""DTL (Date and Time Long) S7Field type."""
|
|
251
|
+
|
|
252
|
+
struct_fmt = ">HBBBBBBI" # Year(2), Month(1), Day(1), Weekday(1), Hour(1), Minute(1), Second(1), Nanosecond(4)
|
|
253
|
+
|
|
254
|
+
def unpack(self, buffer: bytearray) -> datetime | None:
|
|
255
|
+
try:
|
|
256
|
+
year, month, day, _, hour, minute, second, ns = struct.unpack(self.struct_fmt, buffer[self.buffer_slice])
|
|
257
|
+
return datetime(
|
|
258
|
+
year=year, month=month, day=day, hour=hour, minute=minute, second=second, microsecond=ns // 1000
|
|
259
|
+
)
|
|
260
|
+
except (ValueError, struct.error):
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
def pack(self, buffer: bytearray, value: datetime | None) -> None:
|
|
264
|
+
if value is None:
|
|
265
|
+
buffer[self.buffer_slice] = bytes(12)
|
|
266
|
+
return
|
|
267
|
+
buffer[self.buffer_slice] = struct.pack(
|
|
268
|
+
self.struct_fmt,
|
|
269
|
+
value.year,
|
|
270
|
+
value.month,
|
|
271
|
+
value.day,
|
|
272
|
+
value.isoweekday() % 7, # 0=Sunday
|
|
273
|
+
value.hour,
|
|
274
|
+
value.minute,
|
|
275
|
+
value.second,
|
|
276
|
+
value.microsecond * 1000,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
class S7Time(S7Field[timedelta], s7_type="Time"):
|
|
281
|
+
"""Time duration S7Field type."""
|
|
282
|
+
|
|
283
|
+
struct_fmt = "I"
|
|
284
|
+
|
|
285
|
+
def unpack(self, buffer: bytearray) -> timedelta:
|
|
286
|
+
ms = struct.unpack(self.struct_fmt, buffer[self.buffer_slice])[0]
|
|
287
|
+
return timedelta(milliseconds=ms)
|
|
288
|
+
|
|
289
|
+
def pack(self, buffer: bytearray, value: timedelta) -> None:
|
|
290
|
+
ms = int(value.total_seconds() * 1000)
|
|
291
|
+
buffer[self.buffer_slice] = struct.pack(self.struct_fmt, ms)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class S7Date(S7Field[datetime | None], s7_type="Date"):
|
|
295
|
+
"""Date S7Field type."""
|
|
296
|
+
|
|
297
|
+
struct_fmt = ">H"
|
|
298
|
+
|
|
299
|
+
def unpack(self, buffer: bytearray) -> datetime | None:
|
|
300
|
+
try:
|
|
301
|
+
(days,) = struct.unpack(self.struct_fmt, buffer[self.buffer_slice])
|
|
302
|
+
return datetime(1990, 1, 1) + timedelta(days=days)
|
|
303
|
+
except (ValueError, struct.error):
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
def pack(self, buffer: bytearray, value: datetime | None) -> None:
|
|
307
|
+
if value is None:
|
|
308
|
+
buffer[self.buffer_slice] = bytes(2)
|
|
309
|
+
return
|
|
310
|
+
days = (value - datetime(1990, 1, 1)).days
|
|
311
|
+
buffer[self.buffer_slice] = struct.pack(self.struct_fmt, days)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class S7TimeOfDay(S7Field[timedelta], s7_type="Time_of_Day"):
|
|
315
|
+
"""Time of day S7Field type."""
|
|
316
|
+
|
|
317
|
+
struct_fmt = ">I"
|
|
318
|
+
|
|
319
|
+
def unpack(self, buffer: bytearray) -> timedelta:
|
|
320
|
+
"""Unpack Time of Day value from buffer to timedelta"""
|
|
321
|
+
ms_since_midnight = struct.unpack(self.struct_fmt, buffer[self.buffer_slice])[0]
|
|
322
|
+
return timedelta(milliseconds=ms_since_midnight)
|
|
323
|
+
|
|
324
|
+
def pack(self, buffer: bytearray, value: timedelta) -> None:
|
|
325
|
+
"""Pack a timedelta value into the buffer."""
|
|
326
|
+
ms = int(value.total_seconds() * 1000)
|
|
327
|
+
buffer[self.buffer_slice] = struct.pack(self.struct_fmt, ms)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class DBParseResult(NamedTuple):
|
|
331
|
+
"""Result type for DB parsing operations"""
|
|
332
|
+
|
|
333
|
+
mapping: dict[str, S7Field[Any]]
|
|
334
|
+
length: int
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# Signed integer types
|
|
338
|
+
class S7Int(S7Field[int], s7_type="Int"):
|
|
339
|
+
"""16-bit signed integer S7Field."""
|
|
340
|
+
|
|
341
|
+
struct_fmt = "h"
|
s7datablock/grammar.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Grammar definitions for parsing TIA Portal DB exports."""
|
|
2
|
+
from functools import reduce
|
|
3
|
+
from operator import or_
|
|
4
|
+
|
|
5
|
+
from pyparsing import CaselessKeyword, Combine, Dict, Forward, Group, OneOrMore
|
|
6
|
+
from pyparsing import Opt as Optional # workaround for mypy bug
|
|
7
|
+
from pyparsing import Regex, Suppress, Word, ZeroOrMore, alphanums, alphas, nums
|
|
8
|
+
|
|
9
|
+
from s7datablock.constants import S7_ALL_TYPES
|
|
10
|
+
|
|
11
|
+
# Basic tokens
|
|
12
|
+
QUOTE = Suppress(Word("'\""))
|
|
13
|
+
UNQUOTED_IDENT = Word(f"{alphas}_", f"{alphanums}_")
|
|
14
|
+
QUOTED_IDENT = QUOTE + Word(f"{alphanums}_. ") + QUOTE
|
|
15
|
+
IDENT = QUOTED_IDENT | UNQUOTED_IDENT
|
|
16
|
+
REAL = Regex(r"\d+\.\d*")
|
|
17
|
+
INT = Regex(r"\d+")
|
|
18
|
+
LBRACE, RBRACE, SEMI, COLON = map(Suppress, "{};:")
|
|
19
|
+
EQUALS = Suppress(":=")
|
|
20
|
+
BOOLEAN = CaselessKeyword("True") | CaselessKeyword("False")
|
|
21
|
+
COMMENT = Suppress("//" + Regex(r".*"))
|
|
22
|
+
POINT = Suppress(".")
|
|
23
|
+
hex_prefix = Combine(Word(nums) + "#")
|
|
24
|
+
hex_digits = Word(nums + "ABCDEFabcdef")
|
|
25
|
+
HEX = Combine(hex_prefix + hex_digits)
|
|
26
|
+
VALUE = BOOLEAN | HEX | REAL | INT
|
|
27
|
+
|
|
28
|
+
# S7 specific grammar
|
|
29
|
+
S7_DTYPE = reduce(or_, map(CaselessKeyword, S7_ALL_TYPES))
|
|
30
|
+
DURATION = Regex(r"T#\d+[sMHmsdh]")
|
|
31
|
+
|
|
32
|
+
# Attribute assignments and values
|
|
33
|
+
optional_attribute_assignments = Suppress(
|
|
34
|
+
Optional(
|
|
35
|
+
LBRACE + ZeroOrMore(IDENT + EQUALS + QUOTE + (BOOLEAN | S7_DTYPE | REAL) + QUOTE + Optional(SEMI)) + RBRACE
|
|
36
|
+
)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
date = IDENT + optional_attribute_assignments
|
|
40
|
+
value_assignment = EQUALS + (REAL | BOOLEAN | INT | DURATION)
|
|
41
|
+
optional_default_value = Suppress(Optional(expr=value_assignment, default=None))
|
|
42
|
+
|
|
43
|
+
# Structure definitions
|
|
44
|
+
struct_def = Forward()
|
|
45
|
+
|
|
46
|
+
struct_element = Dict(
|
|
47
|
+
Group(
|
|
48
|
+
Optional(date)
|
|
49
|
+
+ Optional(IDENT)
|
|
50
|
+
+ optional_attribute_assignments
|
|
51
|
+
+ COLON
|
|
52
|
+
+ Optional((S7_DTYPE + optional_default_value + SEMI) | (QUOTED_IDENT + SEMI) | struct_def)
|
|
53
|
+
)
|
|
54
|
+
+ Optional(COMMENT)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
struct_def <<= (
|
|
58
|
+
Suppress(CaselessKeyword("STRUCT"))
|
|
59
|
+
+ Optional(COMMENT)
|
|
60
|
+
+ Group(OneOrMore(struct_element))
|
|
61
|
+
+ Suppress(CaselessKeyword("END_STRUCT"))
|
|
62
|
+
+ SEMI
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Type and variable definitions
|
|
66
|
+
type_def = Dict(
|
|
67
|
+
Group(
|
|
68
|
+
Suppress("TYPE")
|
|
69
|
+
+ IDENT
|
|
70
|
+
+ Suppress("VERSION")
|
|
71
|
+
+ COLON
|
|
72
|
+
+ Suppress(REAL("version"))
|
|
73
|
+
+ struct_def
|
|
74
|
+
+ Suppress("END_TYPE")
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
var_def = Dict(
|
|
79
|
+
Group(
|
|
80
|
+
Suppress(CaselessKeyword("VAR"))
|
|
81
|
+
+ ZeroOrMore(struct_element)
|
|
82
|
+
+ Group(OneOrMore(struct_element))
|
|
83
|
+
+ Suppress("END_VAR")
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Default values and data block definitions
|
|
88
|
+
default_value_element = Group(ZeroOrMore(IDENT + POINT) + IDENT + Suppress(EQUALS) + VALUE + Suppress(SEMI)) + Group(
|
|
89
|
+
ZeroOrMore(Suppress(EQUALS) + VALUE + Suppress(SEMI))
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
defaults_values_block = Dict(
|
|
93
|
+
Group(Suppress(CaselessKeyword("BEGIN")) + ZeroOrMore(default_value_element) + Suppress("END_DATA_BLOCK"))
|
|
94
|
+
)("BEGIN")
|
|
95
|
+
|
|
96
|
+
data_block_def = Dict(
|
|
97
|
+
Group(
|
|
98
|
+
Suppress("DATA_BLOCK")
|
|
99
|
+
+ QUOTE
|
|
100
|
+
+ IDENT
|
|
101
|
+
+ QUOTE
|
|
102
|
+
+ optional_attribute_assignments
|
|
103
|
+
+ Suppress("VERSION")
|
|
104
|
+
+ COLON
|
|
105
|
+
+ Suppress(REAL)
|
|
106
|
+
+ Suppress(Optional(CaselessKeyword("NON_RETAIN")))
|
|
107
|
+
+ (var_def | struct_def | QUOTED_IDENT)
|
|
108
|
+
)
|
|
109
|
+
)("DATA_BLOCK")
|
|
110
|
+
|
|
111
|
+
# Complete program grammar
|
|
112
|
+
program = Group(ZeroOrMore(type_def))("TYPES") + data_block_def + defaults_values_block
|
s7datablock/mapping.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
from collections.abc import ItemsView, MutableMapping, ValuesView
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
|
|
4
|
+
import snap7
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.style import Style
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
|
|
10
|
+
from s7datablock.fields import S7Field, S7Value
|
|
11
|
+
from s7datablock.parser import parse_db_file
|
|
12
|
+
|
|
13
|
+
ElementalType = float | int | bool | datetime
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BufferMapping(MutableMapping[str, ElementalType]):
|
|
17
|
+
"""A mapping that allows for easy access to a buffer of bytes.
|
|
18
|
+
|
|
19
|
+
The buffer is Siemens S7 compatible (for unoptimised DB's), including the bit packing
|
|
20
|
+
where multiple bools are packed into a single byte.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
buffer: A bytearray containing the raw data.
|
|
24
|
+
mapping: A dict mapping names to either:
|
|
25
|
+
- Field or S7Field objects (recommended)
|
|
26
|
+
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, buffer: bytearray, mapping: dict[str, S7Field[S7Value]]) -> None:
|
|
30
|
+
self.buffer = buffer
|
|
31
|
+
self.mapping = mapping
|
|
32
|
+
|
|
33
|
+
def __getitem__(self, name: str):
|
|
34
|
+
# Check if this is a DTL subfield access (e.g. "datetime.SECOND")
|
|
35
|
+
if name in self.mapping:
|
|
36
|
+
field: S7Field = self.mapping[name]
|
|
37
|
+
return field.unpack(self.buffer)
|
|
38
|
+
raise KeyError(f"'{name}' not found")
|
|
39
|
+
|
|
40
|
+
def __setitem__(self, name: str, value) -> None:
|
|
41
|
+
if name in self.mapping:
|
|
42
|
+
field: S7Field = self.mapping[name]
|
|
43
|
+
return field.pack(self.buffer, value)
|
|
44
|
+
raise KeyError(f"'{name}' not found")
|
|
45
|
+
|
|
46
|
+
def __delitem__(self, name: str) -> None:
|
|
47
|
+
"""Delete an item from the mapping.
|
|
48
|
+
|
|
49
|
+
Not supported as the buffer has a fixed size and structure.
|
|
50
|
+
"""
|
|
51
|
+
raise NotImplementedError("Cannot delete items from a fixed buffer mapping")
|
|
52
|
+
|
|
53
|
+
def __len__(self) -> int:
|
|
54
|
+
"""Return the number of fields in the mapping."""
|
|
55
|
+
return len(self.mapping)
|
|
56
|
+
|
|
57
|
+
def __iter__(self):
|
|
58
|
+
"""Return an iterator over the field names."""
|
|
59
|
+
return iter(self.mapping)
|
|
60
|
+
|
|
61
|
+
def __repr__(self) -> str:
|
|
62
|
+
# return the unpacked values instead of the mapping S7Field
|
|
63
|
+
return {k: self[k] for k in self.mapping.keys()}.__repr__()
|
|
64
|
+
|
|
65
|
+
def __str__(self) -> str:
|
|
66
|
+
"""Generate a rich table representation of the data block structure.
|
|
67
|
+
|
|
68
|
+
Returns a formatted string showing field names, data types, offsets, and current values
|
|
69
|
+
with proper hierarchical indentation.
|
|
70
|
+
"""
|
|
71
|
+
table = Table(
|
|
72
|
+
show_edge=True,
|
|
73
|
+
show_header=True,
|
|
74
|
+
show_lines=False,
|
|
75
|
+
padding=(0, 1),
|
|
76
|
+
title="\nData Block Structure",
|
|
77
|
+
width=None,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
table.add_column("Name", justify="left", min_width=16, style=Style(color="bright_cyan", bold=True))
|
|
81
|
+
table.add_column("Data type", justify="left", min_width=11)
|
|
82
|
+
table.add_column("Offset", justify="right", min_width=8)
|
|
83
|
+
table.add_column("Value", justify="left", min_width=11)
|
|
84
|
+
|
|
85
|
+
prev_bases = []
|
|
86
|
+
prev_sub = ""
|
|
87
|
+
for name, field in self.mapping.items():
|
|
88
|
+
(*bases, sub) = name.split(".") # Split on the last dot to get the parent name
|
|
89
|
+
indent_level = len(name.split(".")) - 1
|
|
90
|
+
if bases != prev_bases:
|
|
91
|
+
# Add an empty row for the new base
|
|
92
|
+
n = 0
|
|
93
|
+
while n < len(bases) and n < len(prev_bases):
|
|
94
|
+
if bases[n] != prev_bases[n]:
|
|
95
|
+
break
|
|
96
|
+
n += 1
|
|
97
|
+
if bases and bases[-1] == prev_sub:
|
|
98
|
+
# If the last base is the same as the previous sub, we don't need to add a new row
|
|
99
|
+
n += 1
|
|
100
|
+
|
|
101
|
+
for m in range(n, len(bases)):
|
|
102
|
+
# Add a new base row
|
|
103
|
+
table.add_row(
|
|
104
|
+
" " * m + bases[m], # Indent based on the hierarchy level
|
|
105
|
+
Text("Struct", Style(color="bright_white", bold=True)),
|
|
106
|
+
"",
|
|
107
|
+
"",
|
|
108
|
+
)
|
|
109
|
+
prev_bases = bases
|
|
110
|
+
|
|
111
|
+
# Add a row to the table
|
|
112
|
+
table.add_row(*field.get_table_row(name=name, indent_level=indent_level, buffer=self.buffer))
|
|
113
|
+
prev_sub = sub
|
|
114
|
+
|
|
115
|
+
# Calculate total size
|
|
116
|
+
total_size = max(
|
|
117
|
+
field.offset[0] + 2 if isinstance(field.offset, tuple) else field.offset + field.struct_width
|
|
118
|
+
for field in self.mapping.values()
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
console = Console(record=True, width=120)
|
|
122
|
+
with console.capture() as capture:
|
|
123
|
+
console.print() # Empty line before table
|
|
124
|
+
for _ in range(17):
|
|
125
|
+
console.print(" ", end="")
|
|
126
|
+
console.print(table)
|
|
127
|
+
console.print(f" Total size: {total_size} bytes")
|
|
128
|
+
console.print() # Empty line after
|
|
129
|
+
|
|
130
|
+
return capture.get()
|
|
131
|
+
|
|
132
|
+
def keys(self):
|
|
133
|
+
"""Returns a view of the field names in the mapping."""
|
|
134
|
+
return self.mapping.keys()
|
|
135
|
+
|
|
136
|
+
def values(self):
|
|
137
|
+
"""Returns a view of the current values in the mapping."""
|
|
138
|
+
return ValuesView(self)
|
|
139
|
+
|
|
140
|
+
def items(self):
|
|
141
|
+
"""Returns a view of (name, value) pairs for all fields in the mapping."""
|
|
142
|
+
return ItemsView(self)
|
|
143
|
+
|
|
144
|
+
def to_dict(self) -> dict[str, ElementalType]:
|
|
145
|
+
"""Convert the mapping to a regular dictionary.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
A dictionary containing all field names and their current values
|
|
149
|
+
"""
|
|
150
|
+
return dict(self.items())
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class S7DataBlock(BufferMapping):
|
|
154
|
+
buffer: bytearray
|
|
155
|
+
db_number: int | None
|
|
156
|
+
db_size: int
|
|
157
|
+
|
|
158
|
+
def __init__(
|
|
159
|
+
self,
|
|
160
|
+
buffer: bytearray,
|
|
161
|
+
mapping: dict[str, S7Field],
|
|
162
|
+
db_number: int | None,
|
|
163
|
+
db_size: int,
|
|
164
|
+
):
|
|
165
|
+
super().__init__(buffer, mapping)
|
|
166
|
+
self.db_number = db_number
|
|
167
|
+
self.db_size = db_size
|
|
168
|
+
|
|
169
|
+
@classmethod
|
|
170
|
+
def from_definition_file(cls, path, db_number: int | None = None, nesting_depth_to_skip: int = 1) -> "S7DataBlock":
|
|
171
|
+
"""Create an S7DataBlock from a TIA Portal export file.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
path: Path to the TIA Portal export file
|
|
175
|
+
db_number: Optional DB number for PLC communication
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
S7DataBlock: The created datablock
|
|
179
|
+
"""
|
|
180
|
+
mapping, size = parse_db_file(path, nesting_depth_to_skip=nesting_depth_to_skip)
|
|
181
|
+
return cls(buffer=bytearray(size), mapping=mapping, db_number=db_number, db_size=size)
|
|
182
|
+
|
|
183
|
+
def GET(self, client: snap7.client.Client):
|
|
184
|
+
"""Read all values from PLC.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
client: snap7.client.Client instance
|
|
188
|
+
"""
|
|
189
|
+
if self.db_number is None:
|
|
190
|
+
raise ValueError("No DB number specified")
|
|
191
|
+
|
|
192
|
+
self.buffer = client.db_read(self.db_number, 0, self.db_size)
|
|
193
|
+
|
|
194
|
+
def SET(self, client: snap7.client.Client):
|
|
195
|
+
"""Write all values to PLC.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
client: snap7.client.Client instance
|
|
199
|
+
"""
|
|
200
|
+
if self.db_number is None:
|
|
201
|
+
raise ValueError("No DB number specified")
|
|
202
|
+
|
|
203
|
+
client.db_write(self.db_number, 0, self.buffer)
|
|
204
|
+
|
|
205
|
+
# Alias methods for backward compatibility
|
|
206
|
+
pull = GET
|
|
207
|
+
push = SET
|
s7datablock/parser.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Functions to parse DB definitions exported from TIA Portal."""
|
|
2
|
+
import math
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from io import StringIO
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from s7datablock.fields import DBParseResult, NameType, S7Field
|
|
10
|
+
from s7datablock.grammar import program
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def resolve_data_types(types: dict[str, Any], prefix: list[str], d: Any) -> Iterator[tuple[list[str], Any]]:
|
|
14
|
+
if isinstance(d, dict):
|
|
15
|
+
for k, v in d.items():
|
|
16
|
+
yield from resolve_data_types(types, prefix + [k], v)
|
|
17
|
+
elif d in types:
|
|
18
|
+
yield from resolve_data_types(types, prefix, deepcopy(types[d]))
|
|
19
|
+
else:
|
|
20
|
+
yield prefix, d
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_db_file(p: Path | StringIO, nesting_depth_to_skip=1) -> DBParseResult:
|
|
24
|
+
"""Parse a DB file and return a S7Field mapping and total size.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
p (Path): Path to the DB file
|
|
28
|
+
nesting_depth_to_skip (int, optional): How many levels in the nested data to skip when generating the fieldnames for nested fields.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
tuple[dict[str, S7Field], int]: The S7Field mapping and total size in bytes
|
|
32
|
+
"""
|
|
33
|
+
if isinstance(p, Path):
|
|
34
|
+
string = p.read_text(encoding="utf-8-sig")
|
|
35
|
+
elif isinstance(p, StringIO):
|
|
36
|
+
string = p.read()
|
|
37
|
+
else:
|
|
38
|
+
raise TypeError("Input must be a Path or StringIO")
|
|
39
|
+
|
|
40
|
+
if nesting_depth_to_skip < 0:
|
|
41
|
+
raise ValueError("nesting_depth_to_skip must be a non-negative integer")
|
|
42
|
+
result = program.parseString(string, parse_all=True).as_dict()
|
|
43
|
+
types = result["TYPES"]
|
|
44
|
+
data = result["DATA_BLOCK"]
|
|
45
|
+
result["BEGIN"] # default values could be handled here
|
|
46
|
+
|
|
47
|
+
# Get the flattened list of fields and types
|
|
48
|
+
name_types = [NameType(name=k, type=v) for k, v in list(resolve_data_types(types, [], data))]
|
|
49
|
+
|
|
50
|
+
# Process fields into S7Field mapping
|
|
51
|
+
mapping = {}
|
|
52
|
+
offset = 0
|
|
53
|
+
current_bools: list[str] = []
|
|
54
|
+
prev_base_name = ""
|
|
55
|
+
for i in name_types:
|
|
56
|
+
name_parts = i.name[nesting_depth_to_skip:]
|
|
57
|
+
full_name = ".".join(name_parts)
|
|
58
|
+
base_name = ".".join(name_parts[:-1])
|
|
59
|
+
if current_bools and (base_name != prev_base_name or i.type != "Bool"):
|
|
60
|
+
offset += flush_bools(current_bools, offset, mapping)
|
|
61
|
+
current_bools = []
|
|
62
|
+
prev_base_name = base_name
|
|
63
|
+
if i.type == "Bool":
|
|
64
|
+
current_bools.append(full_name)
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
field = S7Field.create(offset, i.type)
|
|
68
|
+
mapping[full_name] = field
|
|
69
|
+
for k, v in field.subfields.items():
|
|
70
|
+
mapping[f"{full_name}.{k}"] = v
|
|
71
|
+
offset += field.struct_width
|
|
72
|
+
|
|
73
|
+
if current_bools:
|
|
74
|
+
offset += flush_bools(current_bools, offset, mapping)
|
|
75
|
+
|
|
76
|
+
return DBParseResult(mapping=mapping, length=offset)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def flush_bools(current_bools: list[str], offset: int, mapping: dict[str, S7Field]) -> int:
|
|
80
|
+
"""Pack boolean fields into byte-aligned memory and update the mapping."""
|
|
81
|
+
for i, name in enumerate(current_bools):
|
|
82
|
+
mapping[name] = S7Field.create(offset + (i // 8), "Bool", offset_bit=i % 8)
|
|
83
|
+
return 2 * math.ceil(len(current_bools) / 16)
|
s7datablock/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: s7datablock
|
|
3
|
+
Version: 0.0.0a0
|
|
4
|
+
Summary: A Python library interacting with Siemens S7 PLC datablocks
|
|
5
|
+
Project-URL: Homepage, https://github.com/pyscaffold/pyscaffold/
|
|
6
|
+
Project-URL: Documentation, https://pyscaffold.org/
|
|
7
|
+
Author-email: Thijs Damsma <thijs@damsma.net>
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: AUTHORS.md
|
|
10
|
+
License-File: LICENSE.txt
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Programming Language :: Python
|
|
13
|
+
Requires-Python: >=3.11
|
|
14
|
+
Requires-Dist: pyparsing
|
|
15
|
+
Requires-Dist: python-snap7
|
|
16
|
+
Requires-Dist: rich
|
|
17
|
+
Provides-Extra: testing
|
|
18
|
+
Requires-Dist: pytest; extra == 'testing'
|
|
19
|
+
Requires-Dist: pytest-cov; extra == 'testing'
|
|
20
|
+
Requires-Dist: ruff; extra == 'testing'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# S7DataBlock
|
|
24
|
+
|
|
25
|
+
- [S7DataBlock](#s7datablock)
|
|
26
|
+
- [Introduction](#introduction)
|
|
27
|
+
- [Installation](#installation)
|
|
28
|
+
- [Parsing Data Blocks](#parsing-data-blocks)
|
|
29
|
+
- [Contributing and Issues](#contributing-and-issues)
|
|
30
|
+
|
|
31
|
+
## Introduction
|
|
32
|
+
|
|
33
|
+
> A Python library for parsing Siemens S7 PLC data blocks, in conjunction with Snap7.
|
|
34
|
+
|
|
35
|
+
`s7datablock` is a Python library designed to parse and interpret Siemens S7 PLC data blocks. It works in conjunction with the [python-snap7](https://github.com/gijzelaerr/python-snap7) library, providing a set of tools and utilities to read and write the bytes in a PLC datablock as structured data, using the DB definitions as exported from a TIA Portal project. This makes it easier for developers and engineers to work with Siemens S7 PLCs in their Python applications.
|
|
36
|
+
|
|
37
|
+
Instead of manually mapping bytes, the dataformat defined in Tia Portal can be used to generate the appropriate Python mapping. The configuration file can be kept separate from the code, making it easier to track versions and support complicated data structures.
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install s7datablock
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Parsing Data Blocks
|
|
46
|
+
|
|
47
|
+
In this example, we will demonstrate how to use the `s7datablock` library to parse and interpret Siemens S7 PLC data blocks.
|
|
48
|
+
|
|
49
|
+
First, we define a s7_1200_out user-defined type (UDT) and a data block in Structured Control Language (SCL). The UDT, named "s7_1200_out_udt", consists of five boolean variables and one integer variable. The data block, named "s7_1200_out", uses this UDT. This file was defined in and exported from Tia Portal, using the `generate source from blocks, including all dependent blocks` option.
|
|
50
|
+
|
|
51
|
+
```scl
|
|
52
|
+
# s7_1200_out.db
|
|
53
|
+
TYPE "s7_1200_out_udt"
|
|
54
|
+
VERSION : 0.1
|
|
55
|
+
STRUCT
|
|
56
|
+
PLC_DQ_0 : Bool; # Boolean variable
|
|
57
|
+
PLC_DQ_1 : Bool; # Boolean variable
|
|
58
|
+
PLC_DQ_2 : Bool; # Boolean variable
|
|
59
|
+
PLC_DQ_3 : Bool; # Boolean variable
|
|
60
|
+
PLC_DQ_4 : Bool; # Boolean variable
|
|
61
|
+
SB_AQ_0 : Int; # Integer variable
|
|
62
|
+
END_STRUCT;
|
|
63
|
+
END_TYPE
|
|
64
|
+
|
|
65
|
+
DATA_BLOCK "s7_1200_out"
|
|
66
|
+
{ S7_Optimized_Access := 'FALSE' }
|
|
67
|
+
VERSION : 0.1
|
|
68
|
+
NON_RETAIN
|
|
69
|
+
"s7_1200_out_udt"
|
|
70
|
+
|
|
71
|
+
BEGIN
|
|
72
|
+
|
|
73
|
+
END_DATA_BLOCK
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Next, we use the `S7DataBlock` class from the `s7datablock` library to parse the data block definition file. We then display the parsed data block and its initial buffer state. Finally, we modify one of the boolean variables in the data block and display the updated buffer state.
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
# Import necessary modules
|
|
80
|
+
from s7datablock.mapping import S7DataBlock
|
|
81
|
+
from pathlib import Path
|
|
82
|
+
|
|
83
|
+
# Parse the data block definition file
|
|
84
|
+
db1200 = S7DataBlock.from_definition_file(
|
|
85
|
+
Path("tests/definitions/s7_1200_out.db"),
|
|
86
|
+
db_number=1200
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# The printing the db1200 datasrutcure produces a nicely formatted output
|
|
90
|
+
print(db1200)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```raw
|
|
94
|
+
Data Block Structure
|
|
95
|
+
┏━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┓
|
|
96
|
+
┃ Name ┃ Data type ┃ Offset ┃ Value ┃
|
|
97
|
+
┡━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━┩
|
|
98
|
+
│ PLC_DQ_0 │ Bool │ 0.0 │ False │
|
|
99
|
+
│ PLC_DQ_1 │ Bool │ 0.1 │ False │
|
|
100
|
+
│ PLC_DQ_2 │ Bool │ 0.2 │ False │
|
|
101
|
+
│ PLC_DQ_3 │ Bool │ 0.3 │ False │
|
|
102
|
+
│ PLC_DQ_4 │ Bool │ 0.4 │ False │
|
|
103
|
+
│ SB_AQ_0 │ Int │ 2.0 │ 0 │
|
|
104
|
+
│ TIMEFIELD │ DTL │ 4.0 │ None │
|
|
105
|
+
│ YEAR │ UInt │ 4.0 │ 0 │
|
|
106
|
+
│ MONTH │ USInt │ 6.0 │ 0 │
|
|
107
|
+
│ DAY │ USInt │ 7.0 │ 0 │
|
|
108
|
+
│ WEEKDAY │ USInt │ 8.0 │ 0 │
|
|
109
|
+
│ HOUR │ USInt │ 9.0 │ 0 │
|
|
110
|
+
│ MINUTE │ USInt │ 10.0 │ 0 │
|
|
111
|
+
│ SECOND │ USInt │ 11.0 │ 0 │
|
|
112
|
+
│ NANOSECOND │ UDInt │ 12.0 │ 0 │
|
|
113
|
+
└──────────────────┴─────────────┴──────────┴─────────────┘
|
|
114
|
+
Total size: 16 bytes
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
# db1200 maps the data like a dictionary but the hood it stores the data in a continuous buffer,
|
|
119
|
+
# using the same format and byte ordering as the PLC
|
|
120
|
+
# Initially the buffer is only zeros
|
|
121
|
+
print(db1200.buffer)
|
|
122
|
+
# Output: bytearray(b'\x00\x00\x00\x00')
|
|
123
|
+
|
|
124
|
+
# Modify one of the boolean variables
|
|
125
|
+
db1200["PLC_DQ_3"] = True
|
|
126
|
+
|
|
127
|
+
# Now one bit has changed
|
|
128
|
+
print(db1200.buffer)
|
|
129
|
+
# Output: bytearray(b'\x08\x00\x00\x00')
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
## Contributing and Issues
|
|
135
|
+
|
|
136
|
+
The library is in its early stages, and contributions are welcome. Feel free to submit a pull request or open an issue on GitHub.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
s7datablock/__init__.py,sha256=W9jDPLmxl-FMN6OXjWjR8D7Yxho4x5QCtZUrKypXgyM,818
|
|
2
|
+
s7datablock/constants.py,sha256=BPvINBVkIZlyg5epw_t6HrVMc7jEVxgHJpjuwWM7dkE,2267
|
|
3
|
+
s7datablock/fields.py,sha256=ZhppVdLlJxNU2t72deXSNwKulLGFP8ilE9eSp4dNRKo,11084
|
|
4
|
+
s7datablock/grammar.py,sha256=fI3k2zyftTdNAsShvzUcRAp_91CZQyhPTRUIlU53_yo,3286
|
|
5
|
+
s7datablock/mapping.py,sha256=VoZdTK4zbU7Xo4nAs-xkhuuuYIsfhyXYi0w3Ib8q5OE,7130
|
|
6
|
+
s7datablock/parser.py,sha256=Lk2vddIg_Et37swLvOEG9vTE1tKpCdkBSc52VW_3vWw,3095
|
|
7
|
+
s7datablock/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
s7datablock-0.0.0a0.dist-info/METADATA,sha256=Zlj-nE5BUIzd2dbM69TZcymBGzL-FLwutdtK6sHnALM,5825
|
|
9
|
+
s7datablock-0.0.0a0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
10
|
+
s7datablock-0.0.0a0.dist-info/licenses/AUTHORS.md,sha256=J6wEVz7P0KscmxwBFWWgzZFNJrEan2u1Rb9jmR6YTF8,75
|
|
11
|
+
s7datablock-0.0.0a0.dist-info/licenses/LICENSE.txt,sha256=CAKsax5K78p1TWRfIDTfNvdhfCU9dkqYzm0hVpxfhf4,1061
|
|
12
|
+
s7datablock-0.0.0a0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 CEAD
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|