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.
@@ -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"]
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ # Contributors
2
+
3
+ * Thijs Damsma [thijs@damsma.net](mailto:thijs@damsma.net)
@@ -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.