python-snap7 3.1.2__py3-none-any.whl → 3.2.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.
snap7/util/symbols.py DELETED
@@ -1,547 +0,0 @@
1
- """
2
- Symbolic addressing for S7 PLC data blocks.
3
-
4
- Provides a SymbolTable class that maps human-readable tag names to PLC
5
- addresses (db_number, byte_offset, data_type), enabling read/write operations
6
- by tag name instead of raw addresses.
7
-
8
- Example::
9
-
10
- from snap7.util.symbols import SymbolTable
11
-
12
- symbols = SymbolTable.from_csv("tags.csv")
13
- value = symbols.read(client, "Motor1.Speed")
14
- symbols.write(client, "Motor1.Speed", 1500.0)
15
- """
16
-
17
- import csv
18
- import io
19
- import json
20
- import re
21
- from dataclasses import dataclass
22
- from logging import getLogger
23
- from pathlib import Path
24
- from typing import Any, Dict, Union
25
-
26
- from snap7.type import ValueType
27
- from snap7.util import (
28
- get_bool,
29
- get_byte,
30
- get_char,
31
- get_dint,
32
- get_dword,
33
- get_dt,
34
- get_dtl,
35
- get_int,
36
- get_lreal,
37
- get_lint,
38
- get_ulint,
39
- get_ltime,
40
- get_ltod,
41
- get_ldt,
42
- get_real,
43
- get_sint,
44
- get_string,
45
- get_tod,
46
- get_udint,
47
- get_uint,
48
- get_usint,
49
- get_wchar,
50
- get_word,
51
- get_wstring,
52
- get_date,
53
- get_time,
54
- get_lword,
55
- set_bool,
56
- set_byte,
57
- set_char,
58
- set_date,
59
- set_dint,
60
- set_dword,
61
- set_dt,
62
- set_dtl,
63
- set_int,
64
- set_lint,
65
- set_ulint,
66
- set_ltime,
67
- set_ltod,
68
- set_ldt,
69
- set_lreal,
70
- set_real,
71
- set_sint,
72
- set_string,
73
- set_tod,
74
- set_udint,
75
- set_uint,
76
- set_usint,
77
- set_wchar,
78
- set_word,
79
- set_wstring,
80
- set_time,
81
- set_lword,
82
- )
83
-
84
- logger = getLogger(__name__)
85
-
86
- # ---------------------------------------------------------------------------
87
- # Module-level getter/setter dispatch maps (built once, not per call)
88
- # ---------------------------------------------------------------------------
89
-
90
- _GETTER_MAP: Dict[str, Any] = {
91
- "BYTE": get_byte,
92
- "SINT": get_sint,
93
- "USINT": get_usint,
94
- "CHAR": get_char,
95
- "INT": get_int,
96
- "UINT": get_uint,
97
- # NOTE: get_word is annotated as returning bytearray but actually returns
98
- # int at runtime (struct.unpack(">H", ...) -> int). It behaves correctly.
99
- "WORD": get_word,
100
- "DATE": get_date,
101
- "DINT": get_dint,
102
- "UDINT": get_udint,
103
- "DWORD": get_dword,
104
- "REAL": get_real,
105
- "TIME": get_time,
106
- "TOD": get_tod,
107
- "TIME_OF_DAY": get_tod,
108
- "DATE_AND_TIME": get_dt,
109
- "DT": get_dt,
110
- "LREAL": get_lreal,
111
- "LWORD": get_lword,
112
- "WCHAR": get_wchar,
113
- "DTL": get_dtl,
114
- "LINT": get_lint,
115
- "ULINT": get_ulint,
116
- "LTIME": get_ltime,
117
- "LTOD": get_ltod,
118
- "LDT": get_ldt,
119
- }
120
-
121
- # Setters that cast value to int before calling
122
- _INT_SETTER_MAP: Dict[str, Any] = {
123
- "BYTE": set_byte,
124
- "SINT": set_sint,
125
- "USINT": set_usint,
126
- "INT": set_int,
127
- "UINT": set_uint,
128
- "WORD": set_word,
129
- "DINT": set_dint,
130
- "UDINT": set_udint,
131
- "DWORD": set_dword,
132
- "LINT": set_lint,
133
- "ULINT": set_ulint,
134
- }
135
-
136
- # Setters that pass value through without casting
137
- _SIMPLE_SETTER_MAP: Dict[str, Any] = {
138
- "REAL": set_real,
139
- "LREAL": set_lreal,
140
- "CHAR": set_char,
141
- "WCHAR": set_wchar,
142
- "TIME": set_time,
143
- "DATE": set_date,
144
- "TOD": set_tod,
145
- "TIME_OF_DAY": set_tod,
146
- "DATE_AND_TIME": set_dt,
147
- "DT": set_dt,
148
- "DTL": set_dtl,
149
- "LWORD": set_lword,
150
- "LTIME": set_ltime,
151
- "LTOD": set_ltod,
152
- "LDT": set_ldt,
153
- }
154
-
155
- # Mapping from S7 type name to the number of bytes needed to read
156
- _TYPE_SIZE: Dict[str, int] = {
157
- "BOOL": 1,
158
- "BYTE": 1,
159
- "SINT": 1,
160
- "USINT": 1,
161
- "CHAR": 1,
162
- "INT": 2,
163
- "UINT": 2,
164
- "WORD": 2,
165
- "DATE": 2,
166
- "DINT": 4,
167
- "UDINT": 4,
168
- "DWORD": 4,
169
- "REAL": 4,
170
- "TIME": 4,
171
- "TOD": 4,
172
- "TIME_OF_DAY": 4,
173
- "DATE_AND_TIME": 8,
174
- "DT": 8,
175
- "LREAL": 8,
176
- "LWORD": 8,
177
- "WCHAR": 2,
178
- "DTL": 12,
179
- "LINT": 8,
180
- "ULINT": 8,
181
- "LTIME": 8,
182
- "LTOD": 8,
183
- "LDT": 8,
184
- }
185
-
186
- # Regex to extract STRING[n] or WSTRING[n] with size parameter
187
- _STRING_RE = re.compile(r"^(STRING|WSTRING|FSTRING)\[(\d+)]$", re.IGNORECASE)
188
-
189
-
190
- def _read_source(source: Union[str, Path]) -> str:
191
- """Resolve *source* to text content.
192
-
193
- If *source* is a :class:`~pathlib.Path` it is always read as a file.
194
- If it is a string that contains a newline character it is treated as
195
- inline content (CSV / JSON). Otherwise the string is checked as a
196
- file path and read if it exists; if not it is returned verbatim.
197
- """
198
- if isinstance(source, Path):
199
- return source.read_text()
200
- s = str(source)
201
- if "\n" in s:
202
- return s
203
- path = Path(s)
204
- if path.exists():
205
- return path.read_text()
206
- return s
207
-
208
-
209
- @dataclass(frozen=True)
210
- class TagAddress:
211
- """Resolved address for a single PLC tag."""
212
-
213
- db: int
214
- offset: int
215
- bit: int
216
- type: str
217
-
218
- @property
219
- def byte_offset(self) -> int:
220
- """Return the byte offset (without bit component)."""
221
- return self.offset
222
-
223
- def read_size(self) -> int:
224
- """Return the number of bytes that need to be read from the PLC for this tag."""
225
- upper = self.type.upper()
226
- match = _STRING_RE.match(upper)
227
- if match:
228
- kind = match.group(1)
229
- length = int(match.group(2))
230
- if kind == "FSTRING":
231
- return length
232
- elif kind == "STRING":
233
- # S7 STRING: 2-byte header + max_length characters
234
- return 2 + length
235
- elif kind == "WSTRING":
236
- # S7 WSTRING: 4-byte header + max_length * 2 bytes
237
- return 4 + length * 2
238
- if upper in _TYPE_SIZE:
239
- return _TYPE_SIZE[upper]
240
- raise ValueError(f"Unknown S7 type: {self.type}")
241
-
242
-
243
- def _parse_offset(offset_str: str) -> tuple[int, int]:
244
- """Parse an offset string like '4' or '4.0' into (byte_offset, bit_index).
245
-
246
- Args:
247
- offset_str: offset value, e.g. '4', '4.0', '12.3'
248
-
249
- Returns:
250
- Tuple of (byte_offset, bit_index).
251
- """
252
- if "." in str(offset_str):
253
- parts = str(offset_str).split(".")
254
- return int(parts[0]), int(parts[1])
255
- return int(float(offset_str)), 0
256
-
257
-
258
- class SymbolTable:
259
- """Map symbolic tag names to PLC addresses and perform typed reads/writes.
260
-
261
- Supports construction from:
262
- - A Python dict mapping tag names to address dicts
263
- - A CSV file or string (via :meth:`from_csv`)
264
- - A JSON file or string (via :meth:`from_json`)
265
-
266
- Tag names support dot-separated nested paths (e.g. ``"Motor1.Speed"``)
267
- and array indexing (e.g. ``"Motors[3].Speed"``).
268
-
269
- Example::
270
-
271
- symbols = SymbolTable({
272
- "Motor1.Speed": {"db": 1, "offset": 0, "type": "REAL"},
273
- "Motor1.Running": {"db": 1, "offset": 4, "bit": 0, "type": "BOOL"},
274
- })
275
- value = symbols.read(client, "Motor1.Speed")
276
- symbols.write(client, "Motor1.Speed", 1500.0)
277
- """
278
-
279
- def __init__(self, tags: Dict[str, Dict[str, Any]]) -> None:
280
- self._tags: Dict[str, TagAddress] = {}
281
- for name, info in tags.items():
282
- self._add_tag(name, info)
283
-
284
- # ------------------------------------------------------------------
285
- # Construction helpers
286
- # ------------------------------------------------------------------
287
-
288
- def _add_tag(self, name: str, info: Dict[str, Any]) -> None:
289
- db = int(info["db"])
290
- offset_raw = info.get("offset", 0)
291
- byte_offset, default_bit = _parse_offset(str(offset_raw))
292
- bit = int(info.get("bit", default_bit))
293
- type_ = str(info["type"])
294
- self._tags[name] = TagAddress(db=db, offset=byte_offset, bit=bit, type=type_)
295
-
296
- @classmethod
297
- def from_csv(cls, source: Union[str, Path]) -> "SymbolTable":
298
- """Create a SymbolTable from a CSV file or CSV string.
299
-
300
- The CSV must have columns: ``tag``, ``db``, ``offset``, ``type``.
301
- An optional ``bit`` column overrides the bit index parsed from the offset.
302
-
303
- Args:
304
- source: path to a CSV file, or a CSV-formatted string. Strings
305
- that contain newlines are always treated as inline CSV content.
306
-
307
- Returns:
308
- A new :class:`SymbolTable`.
309
- """
310
- text = _read_source(source)
311
-
312
- reader = csv.DictReader(io.StringIO(text))
313
- tags: Dict[str, Dict[str, Any]] = {}
314
- for row in reader:
315
- name = row["tag"].strip()
316
- entry: Dict[str, Any] = {
317
- "db": row["db"].strip(),
318
- "offset": row["offset"].strip(),
319
- "type": row["type"].strip(),
320
- }
321
- if "bit" in row and row["bit"] is not None and row["bit"].strip():
322
- entry["bit"] = row["bit"].strip()
323
- tags[name] = entry
324
- return cls(tags)
325
-
326
- @classmethod
327
- def from_json(cls, source: Union[str, Path]) -> "SymbolTable":
328
- """Create a SymbolTable from a JSON file or JSON string.
329
-
330
- The JSON should be an object mapping tag names to address objects,
331
- each with keys ``db``, ``offset``, ``type``, and optionally ``bit``.
332
-
333
- Args:
334
- source: path to a JSON file, or a JSON-formatted string. Strings
335
- that contain newlines are always treated as inline JSON content.
336
-
337
- Returns:
338
- A new :class:`SymbolTable`.
339
- """
340
- text = _read_source(source)
341
-
342
- data: Dict[str, Dict[str, Any]] = json.loads(text)
343
- return cls(data)
344
-
345
- # ------------------------------------------------------------------
346
- # Lookup
347
- # ------------------------------------------------------------------
348
-
349
- def resolve(self, tag: str) -> TagAddress:
350
- """Resolve a tag name to its :class:`TagAddress`.
351
-
352
- Args:
353
- tag: the symbolic name (e.g. ``"Motor1.Speed"``).
354
-
355
- Returns:
356
- The resolved address.
357
-
358
- Raises:
359
- KeyError: if the tag is not defined in this table.
360
- """
361
- if tag in self._tags:
362
- return self._tags[tag]
363
- raise KeyError(f"Unknown tag: {tag!r}")
364
-
365
- @property
366
- def tags(self) -> Dict[str, TagAddress]:
367
- """Return a copy of the internal tag mapping."""
368
- return dict(self._tags)
369
-
370
- def __len__(self) -> int:
371
- return len(self._tags)
372
-
373
- def __contains__(self, tag: str) -> bool:
374
- return tag in self._tags
375
-
376
- # ------------------------------------------------------------------
377
- # Read / write
378
- # ------------------------------------------------------------------
379
-
380
- def read(self, client: Any, tag: str) -> ValueType:
381
- """Read a single tag value from the PLC.
382
-
383
- Args:
384
- client: a connected :class:`~snap7.client.Client`.
385
- tag: symbolic tag name.
386
-
387
- Returns:
388
- The value, typed according to the tag's S7 data type.
389
- """
390
- addr = self.resolve(tag)
391
- size = addr.read_size()
392
- data = client.db_read(addr.db, addr.byte_offset, size)
393
- return self._get_value(data, 0, addr)
394
-
395
- def write(self, client: Any, tag: str, value: Any) -> None:
396
- """Write a single tag value to the PLC.
397
-
398
- Args:
399
- client: a connected :class:`~snap7.client.Client`.
400
- tag: symbolic tag name.
401
- value: the value to write.
402
- """
403
- addr = self.resolve(tag)
404
- size = addr.read_size()
405
-
406
- upper = addr.type.upper()
407
- if upper == "BOOL":
408
- # For BOOL we need to read-modify-write the byte
409
- data = client.db_read(addr.db, addr.byte_offset, 1)
410
- set_bool(data, 0, addr.bit, bool(value))
411
- client.db_write(addr.db, addr.byte_offset, data)
412
- return
413
-
414
- # For non-BOOL types we can write directly
415
- data = bytearray(size)
416
- self._set_value(data, 0, addr, value)
417
- client.db_write(addr.db, addr.byte_offset, data)
418
-
419
- def read_many(self, client: Any, tags: list[str]) -> Dict[str, ValueType]:
420
- """Read multiple tags in batched requests.
421
-
422
- Groups tags by DB number and uses :meth:`~snap7.client.Client.read_multi_vars`
423
- to read them in minimal PDU exchanges (via the optimizer when enabled).
424
-
425
- Args:
426
- client: a connected :class:`~snap7.client.Client`.
427
- tags: list of tag names to read.
428
-
429
- Returns:
430
- Dictionary mapping tag names to their values.
431
- """
432
- if not tags:
433
- return {}
434
-
435
- # Resolve all tags
436
- resolved: list[tuple[str, TagAddress]] = [(tag, self.resolve(tag)) for tag in tags]
437
-
438
- # Build multi-read items grouped by what read_multi_vars expects
439
- items: list[dict[str, Any]] = []
440
- for _tag, addr in resolved:
441
- items.append({"area": 0x84, "db_number": addr.db, "start": addr.offset, "size": addr.read_size()})
442
-
443
- # Batch read via read_multi_vars (uses optimizer if enabled)
444
- if len(items) == 1:
445
- # Single tag, just read directly
446
- return {tags[0]: self.read(client, tags[0])}
447
-
448
- _code, data_list = client.read_multi_vars(items)
449
-
450
- # Parse results
451
- result: Dict[str, ValueType] = {}
452
- for i, (tag, addr) in enumerate(resolved):
453
- raw = data_list[i]
454
- if isinstance(raw, (bytes, bytearray)):
455
- result[tag] = self._get_value(bytearray(raw), 0, addr)
456
- else:
457
- result[tag] = self.read(client, tag)
458
-
459
- return result
460
-
461
- # ------------------------------------------------------------------
462
- # Internal getter / setter dispatch
463
- # ------------------------------------------------------------------
464
-
465
- @staticmethod
466
- def _get_value(data: bytearray, base_offset: int, addr: TagAddress) -> ValueType:
467
- """Extract a typed value from a bytearray at the given offset."""
468
- upper = addr.type.upper()
469
- offset = base_offset
470
-
471
- if upper == "BOOL":
472
- return get_bool(data, offset, addr.bit)
473
-
474
- match = _STRING_RE.match(upper)
475
- if match:
476
- kind = match.group(1)
477
- length = int(match.group(2))
478
- if kind == "FSTRING":
479
- from snap7.util import get_fstring
480
-
481
- return get_fstring(data, offset, length)
482
- elif kind == "STRING":
483
- return get_string(data, offset)
484
- elif kind == "WSTRING":
485
- return get_wstring(data, offset)
486
-
487
- if upper in _GETTER_MAP:
488
- return _GETTER_MAP[upper](data, offset) # type: ignore[no-any-return]
489
-
490
- raise ValueError(f"Unsupported S7 type for reading: {addr.type}")
491
-
492
- @staticmethod
493
- def _set_value(data: bytearray, base_offset: int, addr: TagAddress, value: Any) -> None:
494
- """Write a typed value into a bytearray at the given offset."""
495
- upper = addr.type.upper()
496
- offset = base_offset
497
-
498
- if upper == "BOOL":
499
- set_bool(data, offset, addr.bit, bool(value))
500
- return
501
-
502
- match = _STRING_RE.match(upper)
503
- if match:
504
- kind = match.group(1)
505
- length = int(match.group(2))
506
- if kind == "FSTRING":
507
- from snap7.util import set_fstring
508
-
509
- set_fstring(data, offset, str(value), length)
510
- return
511
- elif kind == "STRING":
512
- set_string(data, offset, str(value), length)
513
- return
514
- elif kind == "WSTRING":
515
- set_wstring(data, offset, str(value), length)
516
- return
517
-
518
- if upper in _INT_SETTER_MAP:
519
- _INT_SETTER_MAP[upper](data, offset, int(value))
520
- return
521
-
522
- if upper in _SIMPLE_SETTER_MAP:
523
- _SIMPLE_SETTER_MAP[upper](data, offset, value)
524
- return
525
-
526
- raise ValueError(f"Unsupported S7 type for writing: {addr.type}")
527
-
528
- # ------------------------------------------------------------------
529
- # Merge
530
- # ------------------------------------------------------------------
531
-
532
- def merge(self, other: "SymbolTable") -> "SymbolTable":
533
- """Return a new SymbolTable containing tags from both tables.
534
-
535
- Args:
536
- other: another :class:`SymbolTable` to merge with.
537
-
538
- Returns:
539
- A new merged :class:`SymbolTable`. Tags from *other* override
540
- duplicates from *self*.
541
- """
542
- combined: Dict[str, Dict[str, Any]] = {}
543
- for name, addr in self._tags.items():
544
- combined[name] = {"db": addr.db, "offset": addr.offset, "bit": addr.bit, "type": addr.type}
545
- for name, addr in other._tags.items():
546
- combined[name] = {"db": addr.db, "offset": addr.offset, "bit": addr.bit, "type": addr.type}
547
- return SymbolTable(combined)