py61850 0.2.0.dev1__tar.gz → 0.3.0.dev1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/CONTRIBUTING.md +20 -0
  2. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/PKG-INFO +43 -1
  3. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/README.md +42 -0
  4. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/ROADMAP.md +26 -3
  5. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/__init__.py +18 -1
  6. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/core/__init__.py +2 -0
  7. py61850-0.3.0.dev1/src/py61850/core/fc.py +83 -0
  8. py61850-0.3.0.dev1/src/py61850/core/refs.py +94 -0
  9. py61850-0.3.0.dev1/src/py61850/scl/__init__.py +98 -0
  10. py61850-0.3.0.dev1/src/py61850/scl/_xmlsafe.py +113 -0
  11. py61850-0.3.0.dev1/src/py61850/scl/communication.py +188 -0
  12. py61850-0.3.0.dev1/src/py61850/scl/controls.py +222 -0
  13. py61850-0.3.0.dev1/src/py61850/scl/document.py +326 -0
  14. py61850-0.3.0.dev1/src/py61850/scl/model.py +454 -0
  15. py61850-0.3.0.dev1/src/py61850/scl/templates.py +229 -0
  16. py61850-0.3.0.dev1/tests/unit/scl_fixtures.py +219 -0
  17. py61850-0.3.0.dev1/tests/unit/test_fc.py +66 -0
  18. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/tests/unit/test_public_api.py +2 -0
  19. py61850-0.3.0.dev1/tests/unit/test_refs.py +92 -0
  20. py61850-0.3.0.dev1/tests/unit/test_scl_api.py +67 -0
  21. py61850-0.3.0.dev1/tests/unit/test_scl_communication.py +144 -0
  22. py61850-0.3.0.dev1/tests/unit/test_scl_controls.py +153 -0
  23. py61850-0.3.0.dev1/tests/unit/test_scl_document.py +164 -0
  24. py61850-0.3.0.dev1/tests/unit/test_scl_model.py +339 -0
  25. py61850-0.3.0.dev1/tests/unit/test_scl_templates.py +154 -0
  26. py61850-0.3.0.dev1/tests/unit/test_xmlsafe.py +81 -0
  27. py61850-0.2.0.dev1/src/py61850/scl/__init__.py +0 -23
  28. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/.gitignore +0 -0
  29. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/CLA.md +0 -0
  30. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/COMMERCIAL.md +0 -0
  31. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/LICENSE +0 -0
  32. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/examples/01_connect_and_scan.py +0 -0
  33. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/examples/02_read_values.py +0 -0
  34. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/examples/03_file_transfer.py +0 -0
  35. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/examples/04_error_handling.py +0 -0
  36. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/examples/05_fleet_inventory.py +0 -0
  37. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/examples/06_find_files.py +0 -0
  38. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/examples/07_read_object_references.py +0 -0
  39. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/examples/README.md +0 -0
  40. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/pyproject.toml +0 -0
  41. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/__main__.py +0 -0
  42. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/cli/__init__.py +0 -0
  43. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/cli/__main__.py +0 -0
  44. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/cli/files.py +0 -0
  45. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/cli/main.py +0 -0
  46. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/cli/scan.py +0 -0
  47. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/core/ber.py +0 -0
  48. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/core/data.py +0 -0
  49. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/core/quality.py +0 -0
  50. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/core/time.py +0 -0
  51. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/errors.py +0 -0
  52. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/goose/__init__.py +0 -0
  53. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/link/__init__.py +0 -0
  54. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/mms/__init__.py +0 -0
  55. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/mms/client.py +0 -0
  56. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/mms/pdu.py +0 -0
  57. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/mms/service_error.py +0 -0
  58. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/mms/services/__init__.py +0 -0
  59. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/mms/services/directory.py +0 -0
  60. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/mms/services/files.py +0 -0
  61. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/mms/services/read.py +0 -0
  62. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/mms/types.py +0 -0
  63. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/osi/__init__.py +0 -0
  64. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/osi/acse.py +0 -0
  65. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/osi/cotp.py +0 -0
  66. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/osi/oids.py +0 -0
  67. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/osi/presentation.py +0 -0
  68. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/osi/session.py +0 -0
  69. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/osi/stack.py +0 -0
  70. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/osi/tpkt.py +0 -0
  71. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/py.typed +0 -0
  72. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/sv/__init__.py +0 -0
  73. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/tests/BENCH.md +0 -0
  74. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/tests/README.md +0 -0
  75. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/tests/__init__.py +0 -0
  76. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/tests/fixtures/README.md +0 -0
  77. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/tests/unit/__init__.py +0 -0
  78. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/tests/unit/test_ber.py +0 -0
  79. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/tests/unit/test_client.py +0 -0
  80. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/tests/unit/test_data.py +0 -0
  81. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/tests/unit/test_file_filter.py +0 -0
  82. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/tests/unit/test_logical_nodes.py +0 -0
  83. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/tests/unit/test_osi.py +0 -0
  84. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/tests/unit/test_pdu.py +0 -0
  85. {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/tests/unit/test_types.py +0 -0
@@ -23,6 +23,26 @@ Contributions that need **no** CLA: issues, bug reports, feature requests,
23
23
  questions, and recorded PDUs for `tests/fixtures/` (data captured from a device,
24
24
  not authored code).
25
25
 
26
+ ## What a patch is judged against
27
+
28
+ py61850 is a general IEC 61850 toolkit. A change is assessed against what any
29
+ 61850 tool would need, not only against the use that prompted it:
30
+
31
+ - **Model the standard, not one file.** "The files I have do not use it" is a
32
+ reason to document the gap, not to design it out.
33
+ - **No vendor specifics.** Vendors extend SCL through `Private` elements and
34
+ through standard attributes carrying vendor value grammars. Expose both
35
+ faithfully; interpret neither. Vendor handling belongs in a library that
36
+ attaches to this one.
37
+ - **Derive from the file, do not hardcode a list.** A hardcoded set of names
38
+ standing in for a type the document already declares is wrong on some
39
+ document. If `DataTypeTemplates` can answer it, resolve it.
40
+ - **Say what you measured.** The commit body carries the numbers that
41
+ justified the change. "It is faster" is not a claim this project accepts;
42
+ "1406 ms to 670 ms on a 22 MB SCD" is.
43
+ - **Zero runtime dependencies, Python 3.9, Linux + Windows + macOS.** All
44
+ three are checked in CI and none is negotiable.
45
+
26
46
  ## What makes a good patch here
27
47
 
28
48
  The repository conventions worth knowing before you start; the ones that come
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: py61850
3
- Version: 0.2.0.dev1
3
+ Version: 0.3.0.dev1
4
4
  Summary: Pure-Python IEC 61850 toolkit — MMS (ISO 9506) client today; GOOSE and SV planned. Standard library only.
5
5
  Project-URL: Homepage, https://github.com/GuilhermeMarini/py61850
6
6
  Project-URL: Repository, https://github.com/GuilhermeMarini/py61850
@@ -40,6 +40,27 @@ TCP → TPKT (RFC1006) → COTP (ISO8073) → ISO Session → ISO Presentation
40
40
  See [ROADMAP.md](ROADMAP.md) for what's next (an MMS sniffer, MMS simulation
41
41
  from SCL, and a GOOSE sniff/publish interface).
42
42
 
43
+ ## What this library is for
44
+
45
+ py61850 is a general IEC 61850 toolkit, not a support library for one
46
+ application. Every part of it is judged against what an open equivalent of
47
+ IEDScout or an IEC browser would need — and against what the *next* project to
48
+ use it will need, not only the one that prompted the code.
49
+
50
+ Concretely, that means three things for any change here:
51
+
52
+ - **Model the standard, not the file in front of you.** If IEC 61850 defines
53
+ it and a vendor-neutral tool would show it, the API should express it — even
54
+ when today's caller does not ask.
55
+ - **No vendor specifics in this library.** Vendors extend SCL through
56
+ `Private` elements and through standard attributes carrying their own value
57
+ grammars (`sAddr` is the usual one). py61850 exposes both faithfully and
58
+ interprets neither. A vendor library attaches to the model; it does not
59
+ live inside it.
60
+ - **Derive, do not hardcode.** A list of names that stands in for a type the
61
+ file already declares will be wrong on some file. Resolve
62
+ `DataTypeTemplates` and answer from what the document says.
63
+
43
64
  ## Install
44
65
 
45
66
  ```bash
@@ -114,6 +135,27 @@ with FileTransfer("192.0.2.22") as ft:
114
135
  progress=lambda got, total: print(f"{got}/{total}"))
115
136
  ```
116
137
 
138
+ ### Reading an SCL file
139
+
140
+ ```python
141
+ from py61850.scl import SclDocument
142
+
143
+ doc = SclDocument.parse("station.scd")
144
+
145
+ for name, header in doc.ied_headers.items(): # cheap: no instance tree
146
+ print(name, header.manufacturer, header.config_version)
147
+
148
+ print(doc.communication.ip_by_ied()) # {iedName: IP}
149
+
150
+ ied = doc.ied("QPC1_TR1_UPC1") # built on demand, cached
151
+ for ln in ied.logical_nodes():
152
+ for attr in ln.walk():
153
+ print(attr.reference(), attr.mms_item(), attr.fc, attr.btype)
154
+ ```
155
+
156
+ `py61850.scl` is imported explicitly and is never pulled in by `import
157
+ py61850`, so the MMS client keeps installing and running unprivileged.
158
+
117
159
  Errors share one base, so a fleet job catches the whole family at once:
118
160
 
119
161
  ```python
@@ -13,6 +13,27 @@ TCP → TPKT (RFC1006) → COTP (ISO8073) → ISO Session → ISO Presentation
13
13
  See [ROADMAP.md](ROADMAP.md) for what's next (an MMS sniffer, MMS simulation
14
14
  from SCL, and a GOOSE sniff/publish interface).
15
15
 
16
+ ## What this library is for
17
+
18
+ py61850 is a general IEC 61850 toolkit, not a support library for one
19
+ application. Every part of it is judged against what an open equivalent of
20
+ IEDScout or an IEC browser would need — and against what the *next* project to
21
+ use it will need, not only the one that prompted the code.
22
+
23
+ Concretely, that means three things for any change here:
24
+
25
+ - **Model the standard, not the file in front of you.** If IEC 61850 defines
26
+ it and a vendor-neutral tool would show it, the API should express it — even
27
+ when today's caller does not ask.
28
+ - **No vendor specifics in this library.** Vendors extend SCL through
29
+ `Private` elements and through standard attributes carrying their own value
30
+ grammars (`sAddr` is the usual one). py61850 exposes both faithfully and
31
+ interprets neither. A vendor library attaches to the model; it does not
32
+ live inside it.
33
+ - **Derive, do not hardcode.** A list of names that stands in for a type the
34
+ file already declares will be wrong on some file. Resolve
35
+ `DataTypeTemplates` and answer from what the document says.
36
+
16
37
  ## Install
17
38
 
18
39
  ```bash
@@ -87,6 +108,27 @@ with FileTransfer("192.0.2.22") as ft:
87
108
  progress=lambda got, total: print(f"{got}/{total}"))
88
109
  ```
89
110
 
111
+ ### Reading an SCL file
112
+
113
+ ```python
114
+ from py61850.scl import SclDocument
115
+
116
+ doc = SclDocument.parse("station.scd")
117
+
118
+ for name, header in doc.ied_headers.items(): # cheap: no instance tree
119
+ print(name, header.manufacturer, header.config_version)
120
+
121
+ print(doc.communication.ip_by_ied()) # {iedName: IP}
122
+
123
+ ied = doc.ied("QPC1_TR1_UPC1") # built on demand, cached
124
+ for ln in ied.logical_nodes():
125
+ for attr in ln.walk():
126
+ print(attr.reference(), attr.mms_item(), attr.fc, attr.btype)
127
+ ```
128
+
129
+ `py61850.scl` is imported explicitly and is never pulled in by `import
130
+ py61850`, so the MMS client keeps installing and running unprivileged.
131
+
90
132
  Errors share one base, so a fleet job catches the whole family at once:
91
133
 
92
134
  ```python
@@ -105,15 +105,38 @@ Make the client dependable enough for unattended fleet jobs to build on.
105
105
 
106
106
  ---
107
107
 
108
+ ## 0.3 — The SCL model ✅
109
+
110
+ - ✅ `core/fc.py` — the 13 functional constraints of 61850-7-2 and a
111
+ documented read-preference ranking. In `core` rather than `scl` because
112
+ a client matching items against `GetLogicalDeviceDirectory` needs the
113
+ same vocabulary as a reader walking a file.
114
+ - ✅ `core/refs.py` — object reference ↔ MMS domain and item name (61850-8-1).
115
+ - ✅ `scl/` — see 1.0 below.
116
+
117
+ ---
118
+
108
119
  ## 1.0 — MMS simulation 🧭
109
120
 
110
121
  **Goal:** read an SCL file (`.scd` / `.cid` / `.icd`) and stand up a virtual IED
111
122
  on the network that a real client/HMI can associate to and browse — the mirror
112
123
  image of today's client.
113
124
 
114
- - 🧭 **SCL parser** — `py61850.scl` (package reserved): parse IEDs, LDs, LNs, DOs, DAs,
115
- DataSets, and Report/Setting control blocks from an SCL XML file into an
116
- in-memory object model. (Shared by MMS-sim and GOOSE-sim below.)
125
+ - ✅ **SCL parser** — `py61850.scl`: a general IEC 61850-6 object model.
126
+ `SclDocument` owns the file; `DataTypeTemplates` is resolved once per
127
+ document into a shared type pool; the instance tree
128
+ (IED → AccessPoint → Server → LDevice → LN → DO → DA, plus DataSets,
129
+ control blocks and ExtRefs) is built per IED on demand. Every node
130
+ exposes its `Private` elements, which is how a vendor library attaches
131
+ its own half without parsing the file again.
132
+
133
+ It landed ahead of the rest of 1.0 because it is the shared spine: the
134
+ MMS server answers GetNameList and GetVariableAccessAttributes out of
135
+ this model, and the GOOSE publisher builds frames from the GoCB and
136
+ dataset in the same model.
137
+
138
+ Not modelled, because no file in the reference corpus carries them: the
139
+ `Substation` section and `Log`.
117
140
  - 🧭 **Model → MMS server** — a listening `MmsServer` on TCP 102 that answers the
118
141
  confirmed services the client already speaks, driven by the SCL model:
119
142
  - Initiate / association (server side of `associate.py`)
@@ -21,6 +21,13 @@ Public API
21
21
  LogicalNode one LN returned by MmsClient.find_logical_nodes()
22
22
  folder_of the folder part of an MMS file name, for grouping hits
23
23
 
24
+ FUNCTIONAL_CONSTRAINTS / fc_is_control / fc_read_rank
25
+ the 61850-7-2 functional constraints, shared by the SCL
26
+ reader and the live MMS path
27
+
28
+ mms_item / object_reference / split_item / da_parts
29
+ 61850-8-1 naming: object reference <-> MMS domain and item
30
+
24
31
  Iec61850Error base of every error the library raises
25
32
  TransportError TPKT/COTP/socket and OSI framing failures
26
33
  MmsError MMS service errors, rejects, refused associations
@@ -68,8 +75,11 @@ from .mms.services.directory import LogicalNode
68
75
  from .mms.services.files import folder_of
69
76
  from .mms.service_error import decode_service_error
70
77
  from .mms.types import decode_data_definition
78
+ from .core.fc import FUNCTIONAL_CONSTRAINTS, is_control as fc_is_control, \
79
+ read_rank as fc_read_rank
80
+ from .core.refs import da_parts, mms_item, object_reference, split_item
71
81
 
72
- __version__ = "0.2.0.dev1"
82
+ __version__ = "0.3.0.dev1"
73
83
 
74
84
  __all__ = [
75
85
  "MmsClient",
@@ -77,6 +87,13 @@ __all__ = [
77
87
  "DirEntry",
78
88
  "LogicalNode",
79
89
  "folder_of",
90
+ "FUNCTIONAL_CONSTRAINTS",
91
+ "fc_is_control",
92
+ "fc_read_rank",
93
+ "mms_item",
94
+ "object_reference",
95
+ "split_item",
96
+ "da_parts",
80
97
  "Iec61850Error",
81
98
  "TransportError",
82
99
  "MmsError",
@@ -14,6 +14,8 @@ subscriber and a publisher (GOOSE/SV), and be unit-tested offline.
14
14
  ber definite-length BER TLV encode/decode
15
15
  data MMS ``Data`` values -- the CHOICE reused verbatim by GOOSE
16
16
  ``allData`` and inside the SV ``savPdu`` envelope
17
+ fc the IEC 61850-7-2 functional constraints and their read ranking
18
+ refs IEC 61850-8-1 object reference <-> MMS domain/item names
17
19
  quality the IEC 61850 13-bit Quality bitstring
18
20
  time MMS ``UtcTime`` / ``BinaryTime``
19
21
 
@@ -0,0 +1,83 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 Guilherme Marini
3
+ #
4
+ # This file is part of py61850. It is free software under the GNU Affero
5
+ # General Public License v3 or later; see LICENSE. A commercial licence,
6
+ # for use in software you do not wish to release under the AGPL, is
7
+ # available from the copyright holder -- see COMMERCIAL.md.
8
+ """The IEC 61850-7-2 functional constraints.
9
+
10
+ An FC says what a data attribute *is for* -- a status, a measurement, a
11
+ setting, a command -- and it appears in two places that must agree: the
12
+ ``fc`` attribute of a ``DA`` in an SCL file, and the middle segment of an
13
+ MMS item name (``LN$ST$Pos$stVal``). That is why this lives in ``core``
14
+ rather than under ``scl``: a client matching an item against
15
+ ``GetLogicalDeviceDirectory`` needs the same vocabulary as a reader walking
16
+ a file, and must not have to import the SCL package to get it.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ #: Every functional constraint IEC 61850-7-2 defines.
22
+ #:
23
+ #: The first twelve were measured across the three reference SCDs (7.1, 13.5
24
+ #: and 23.5 MB, SEL and Siemens): ``ST SR CF EX SE DC MX OR CO SV SP BL``.
25
+ #: ``SG`` (setting group) completes the standard's set and is kept even
26
+ #: though that corpus does not use it -- a vocabulary with a hole in it fails
27
+ #: silently on the first file that fills it.
28
+ FUNCTIONAL_CONSTRAINTS = (
29
+ "ST", # status
30
+ "MX", # measurand
31
+ "SP", # setpoint
32
+ "SV", # substitution
33
+ "CF", # configuration
34
+ "DC", # description
35
+ "SG", # setting group
36
+ "SE", # setting group editable
37
+ "SR", # service response / service tracking
38
+ "OR", # operate received
39
+ "BL", # blocking
40
+ "EX", # extended definition
41
+ "CO", # control
42
+ )
43
+
44
+ #: The FCs that carry a command rather than a reading.
45
+ CONTROL_FCS = frozenset({"CO"})
46
+
47
+ # Ordered best-to-worst for "if this attribute is reachable under several FCs,
48
+ # which one should be read?". Status first, then measurand, then the settings
49
+ # and descriptive constraints. Controls are absent on purpose -- they are
50
+ # ranked by `read_rank` below, strictly last, and never by position here.
51
+ _READ_PREFERENCE = ("ST", "MX", "SP", "CF", "DC", "SR", "OR", "EX", "SV",
52
+ "SG", "SE", "BL")
53
+
54
+
55
+ def _norm(value) -> str:
56
+ return value.upper() if isinstance(value, str) else ""
57
+
58
+
59
+ def is_valid(fc) -> bool:
60
+ """Is this one of the 13 functional constraints 7-2 defines?"""
61
+ return _norm(fc) in FUNCTIONAL_CONSTRAINTS
62
+
63
+
64
+ def is_control(fc) -> bool:
65
+ """Does this FC carry a command rather than a reading?"""
66
+ return _norm(fc) in CONTROL_FCS
67
+
68
+
69
+ def read_rank(fc) -> tuple:
70
+ """Sort key for one candidate FC; lower is a better thing to poll.
71
+
72
+ ``(0, position)`` for anything that is not a control, ``(1, 0)`` for one
73
+ that is. The two tiers are deliberate: they keep a control strictly worse
74
+ than *every* reading, including an FC this table has never heard of. A
75
+ command SETS a point, so polling it returns what the device was last told
76
+ rather than what it sees, and that is worse than an unfamiliar reading.
77
+ """
78
+ name = _norm(fc)
79
+ if name in CONTROL_FCS:
80
+ return (1, 0)
81
+ if name in _READ_PREFERENCE:
82
+ return (0, _READ_PREFERENCE.index(name))
83
+ return (0, len(_READ_PREFERENCE))
@@ -0,0 +1,94 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 Guilherme Marini
3
+ #
4
+ # This file is part of py61850. It is free software under the GNU Affero
5
+ # General Public License v3 or later; see LICENSE. A commercial licence,
6
+ # for use in software you do not wish to release under the AGPL, is
7
+ # available from the copyright holder -- see COMMERCIAL.md.
8
+ """IEC 61850-8-1 naming: object reference <-> MMS domain and item name.
9
+
10
+ The same data attribute has two spellings, and a tool that reads files and
11
+ talks to devices needs both::
12
+
13
+ object reference QPC1PRO/PTRC1.Op.general (61850-6, 61850-7-2)
14
+ MMS domain + item QPC1PRO PTRC1$ST$Op$general (61850-8-1)
15
+
16
+ The mapping is mechanical but not obvious: the functional constraint sits
17
+ *between* the logical node and the data object in the MMS spelling and does
18
+ not appear in the object reference at all, and the MMS domain is the LDevice's
19
+ ``ldName`` when it has one, or ``iedName`` concatenated with ``inst`` when it
20
+ does not.
21
+
22
+ This is in ``core`` because it belongs to neither side exclusively: an SCL
23
+ reader builds these names from a file, and an MMS client parses them back out
24
+ of ``GetNameList``.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+
30
+ def ld_name(ied_name: str, ld_inst: str, ld_name_attr=None) -> str:
31
+ """The MMS domain of one LDevice.
32
+
33
+ ``ldName`` when the file gives one -- 61850-6 allows an LDevice to name
34
+ itself outright, and then that name IS the domain. Otherwise the 8-1
35
+ default: the IED's name with the LDevice's ``inst`` appended.
36
+ """
37
+ explicit = (ld_name_attr or "").strip()
38
+ if explicit:
39
+ return explicit
40
+ return f"{ied_name or ''}{ld_inst or ''}"
41
+
42
+
43
+ def ln_name(prefix, ln_class, inst) -> str:
44
+ """The MMS spelling of one logical node: prefix + class + instance.
45
+
46
+ ``LN0`` is spelled ``LLN0`` by its ``lnClass``, so no special case is
47
+ needed here -- the caller passes what the element carries.
48
+ """
49
+ return f"{prefix or ''}{ln_class or ''}{inst or ''}"
50
+
51
+
52
+ def mms_item(ln: str, fc: str, path) -> str:
53
+ """``PTRC1``, ``ST``, ``["Op", "general"]`` -> ``PTRC1$ST$Op$general``.
54
+
55
+ ``path`` is the descent from the data object down to the leaf attribute,
56
+ one entry per level: ``["Pos", "Oper", "ctlVal"]`` for a control.
57
+ """
58
+ return "$".join([ln, fc] + [str(p) for p in path])
59
+
60
+
61
+ def object_reference(ld: str, ln: str, path) -> str:
62
+ """``QPC1PRO``, ``PTRC1``, ``["Op", "general"]``
63
+ -> ``QPC1PRO/PTRC1.Op.general``.
64
+
65
+ The functional constraint is absent by design: an object reference names
66
+ the object, and the FC says how it is being accessed.
67
+ """
68
+ return f"{ld}/{ln}." + ".".join(str(p) for p in path)
69
+
70
+
71
+ def split_item(item: str):
72
+ """``PTRC1$ST$Op$general`` -> ``("PTRC1", "ST", ("Op", "general"))``.
73
+
74
+ ``None`` for anything that is not an attribute item -- a bare logical
75
+ node name, or an empty string. Refusing is deliberate: inventing an FC
76
+ for a name that carries none produces an item no device serves, and the
77
+ failure then surfaces far downstream as a silent missing value.
78
+ """
79
+ parts = [p for p in (item or "").split("$") if p]
80
+ if len(parts) < 3:
81
+ return None
82
+ return parts[0], parts[1], tuple(parts[2:])
83
+
84
+
85
+ def da_parts(da) -> tuple:
86
+ """``"Oper.ctlVal"`` / ``"Oper$ctlVal"`` -> ``("Oper", "ctlVal")``.
87
+
88
+ SCL writes the descent through an SDI with ``.``; MMS spells every level
89
+ with ``$``. A tool reading both needs one canonical form, and empty
90
+ segments are dropped rather than preserved.
91
+ """
92
+ if not isinstance(da, str):
93
+ return tuple(da) if da else ()
94
+ return tuple(p for p in da.replace("$", ".").split(".") if p)
@@ -0,0 +1,98 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 Guilherme Marini
3
+ #
4
+ # This file is part of py61850. It is free software under the GNU Affero
5
+ # General Public License v3 or later; see LICENSE. A commercial licence,
6
+ # for use in software you do not wish to release under the AGPL, is
7
+ # available from the copyright holder -- see COMMERCIAL.md.
8
+ """SCL -- the substation configuration files (``.scd`` / ``.cid`` / ``.icd``).
9
+
10
+ from py61850.scl import SclDocument
11
+
12
+ doc = SclDocument.parse("station.scd")
13
+ for name in doc.ied_names:
14
+ ied = doc.ied(name)
15
+ for ln in ied.logical_nodes():
16
+ for attr in ln.walk():
17
+ print(attr.reference(), attr.mms_item(), attr.btype)
18
+
19
+ **What this package is for.** It is a general IEC 61850-6 implementation,
20
+ judged against what any SCL tool would need -- IEDScout, IEC Browser, OpenSCD
21
+ -- not against what one consumer happens to extract today. If a vendor-neutral
22
+ tool would show it or act on it, the model should express it.
23
+
24
+ **Vendor specifics never enter this package; they attach to it.** Two seams
25
+ carry them, and both are standard SCL:
26
+
27
+ - ``Private`` elements, exposed on every model node as ``.privates``, keyed by
28
+ ``type``. One reference station carries 5,886 of a single vendor's type.
29
+ - Standard attributes whose VALUE holds a vendor grammar. ``sAddr`` is the
30
+ common one: this package hands over the string and takes no view on it,
31
+ because the grammar inside it belongs to whoever wrote the file.
32
+
33
+ A vendor library reads its own half off these model nodes. It never parses the
34
+ XML a second time, and no vendor concept is reflected here.
35
+
36
+ **Layers.** The granularity follows SCL's own sections rather than being
37
+ imposed on them:
38
+
39
+ SclDocument the file: load/parse, edition, header, privates
40
+ .templates DataTypeTemplates -- station-wide, built once, cached
41
+ .communication SubNetwork/ConnectedAP/Address/GSE/SMV -- shallow, cheap
42
+ .ied_headers identifying fields, no instance tree
43
+ .ied(name) the full instance tree for ONE IED, on demand and cached
44
+
45
+ Templates are document-scoped because they are shared by every IED; instance
46
+ trees are per IED because that is the unit a consumer works in. One reference
47
+ SCD carries 178,406 ``DAI`` elements across 30 IEDs.
48
+
49
+ This package is never imported by ``py61850`` itself, so the MMS client keeps
50
+ installing and running unprivileged on any OS. It is pure ``xml.etree`` over
51
+ the standard library: no network, no privileges, no dependencies.
52
+
53
+ **Not implemented yet**, because no file in the reference corpus carries them:
54
+ the ``Substation`` section (VoltageLevel/Bay/ConductingEquipment) and ``Log``.
55
+ Both are in the schema; neither has test material, and building a tree with
56
+ nothing to check it against is how a reader acquires confident wrong answers.
57
+ """
58
+
59
+ from ._xmlsafe import DtdNotAllowed, reject_dtd_in_bytes, reject_dtd_in_file
60
+ from .communication import (
61
+ Address,
62
+ Communication,
63
+ ConnectedAP,
64
+ ControlBlockAddress,
65
+ SubNetwork,
66
+ )
67
+ from .controls import ControlBlock, DataSet, ExtRef, FCDA, SettingControl
68
+ from .document import (
69
+ Header,
70
+ SclDocument,
71
+ children_local,
72
+ iter_local,
73
+ privates_of,
74
+ strip_ns,
75
+ )
76
+ from .model import (
77
+ AccessPoint,
78
+ DataAttribute,
79
+ DataObject,
80
+ Ied,
81
+ IedHeader,
82
+ LDevice,
83
+ LogicalNode,
84
+ Server,
85
+ )
86
+ from .templates import AttributeSpec, DoTypeSpec, LNodeTypeSpec, TemplatePool
87
+
88
+ __all__ = [
89
+ "SclDocument", "Header",
90
+ "DtdNotAllowed", "reject_dtd_in_file", "reject_dtd_in_bytes",
91
+ "TemplatePool", "LNodeTypeSpec", "DoTypeSpec", "AttributeSpec",
92
+ "Communication", "SubNetwork", "ConnectedAP", "Address",
93
+ "ControlBlockAddress",
94
+ "IedHeader", "Ied", "AccessPoint", "Server", "LDevice", "LogicalNode",
95
+ "DataObject", "DataAttribute",
96
+ "DataSet", "FCDA", "ControlBlock", "SettingControl", "ExtRef",
97
+ "strip_ns", "iter_local", "children_local", "privates_of",
98
+ ]
@@ -0,0 +1,113 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 Guilherme Marini
3
+ #
4
+ # This file is part of py61850. It is free software under the GNU Affero
5
+ # General Public License v3 or later; see LICENSE. A commercial licence,
6
+ # for use in software you do not wish to release under the AGPL, is
7
+ # available from the copyright holder -- see COMMERCIAL.md.
8
+ """Refuse an XML document that declares a DTD, before anything expands it.
9
+
10
+ Every SCL file this library reads arrives from outside -- what a client's
11
+ integrator sent, what a vendor's tool exported. "The file is trusted" is not
12
+ true even on a substation LAN.
13
+
14
+ A DTD may define entities, and entities may reference each other, so each
15
+ level multiplies::
16
+
17
+ <!ENTITY a "AAAAAAAAAA"> 10 chars
18
+ <!ENTITY b "&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;"> 100
19
+ <!ENTITY c "&b;&b;..."> 1 000
20
+
21
+ The file stays tiny because it stores only the definitions; the expansion
22
+ happens in memory while parsing, and the parser cannot stop half way -- it
23
+ finishes or the process dies. Measured against this stack: 317 bytes on the
24
+ wire became 1,000,000 characters in 7 ms, a factor of 3155, and two more
25
+ levels of the same file is 100 million. Python's own documentation lists
26
+ ``xml.etree.ElementTree`` as vulnerable to it; the attack is old enough to
27
+ have a name, "billion laughs".
28
+
29
+ Refusing the whole construct is what makes the class gone rather than
30
+ mitigated. It costs nothing, because SCL does not use DTDs -- IEC 61850
31
+ validates against an XSD schema referenced by namespace. Measured over a
32
+ 696-file corpus of factory ICDs and substation exports (345 factory ICDs
33
+ and 351 substation SCD/ICD/CID files): **0 with a DOCTYPE**.
34
+
35
+ Why a separate pass rather than a parser flag: ``xml.etree``'s C parser does
36
+ not expose the expat instance underneath it, so there is no handler to install
37
+ on the parse that actually builds the tree. This runs expat over the PROLOG
38
+ only and stops at the root element, so it reads a few hundred bytes of a 22 MB
39
+ file -- 0.7 ms even on a document padded with a 500 kB comment, which is the
40
+ shape that would defeat a "look at the first N bytes" check.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ from pathlib import Path
46
+ from typing import BinaryIO
47
+ from xml.parsers import expat
48
+
49
+ from ..errors import SclError
50
+
51
+
52
+ class DtdNotAllowed(SclError, ValueError):
53
+ """The document declares a ``<!DOCTYPE>``. See this module's docstring.
54
+
55
+ Both bases are load-bearing: ``SclError`` so that a caller catching
56
+ ``Iec61850Error`` catches this too, and ``ValueError`` because that is
57
+ what it has always been to the callers that predate this package.
58
+ """
59
+
60
+
61
+ class _ReachedRoot(Exception):
62
+ """Internal: the prolog ended without a DTD, so there is nothing to check."""
63
+
64
+
65
+ def _scan(chunks) -> None:
66
+ """Feed ``chunks`` to expat until the root element or a DTD declaration.
67
+
68
+ A malformed prolog raises ``ExpatError``, which is swallowed: the real
69
+ parse is about to hit the very same bytes with the very same grammar and
70
+ will report it properly. That is not a way past this check -- a document
71
+ whose prolog expat cannot read is a document ``ET.parse`` cannot read
72
+ either.
73
+ """
74
+ parser = expat.ParserCreate()
75
+
76
+ def _on_doctype(name, sysid, pubid, has_internal_subset):
77
+ raise DtdNotAllowed(
78
+ f"the document declares a DTD (<!DOCTYPE {name}>), which this "
79
+ f"format does not use and which permits entity expansion"
80
+ )
81
+
82
+ def _on_root(name, attrs):
83
+ raise _ReachedRoot
84
+
85
+ parser.StartDoctypeDeclHandler = _on_doctype
86
+ parser.StartElementHandler = _on_root
87
+
88
+ try:
89
+ for chunk in chunks:
90
+ parser.Parse(chunk, not chunk)
91
+ except _ReachedRoot:
92
+ return
93
+ except expat.ExpatError:
94
+ return
95
+
96
+
97
+ def _file_chunks(stream: BinaryIO, size: int = 8192):
98
+ while True:
99
+ chunk = stream.read(size)
100
+ yield chunk
101
+ if not chunk:
102
+ return
103
+
104
+
105
+ def reject_dtd_in_file(path: Path) -> None:
106
+ """Raise :class:`DtdNotAllowed` if the file at ``path`` declares a DTD."""
107
+ with open(path, "rb") as fh:
108
+ _scan(_file_chunks(fh))
109
+
110
+
111
+ def reject_dtd_in_bytes(data: bytes) -> None:
112
+ """Raise :class:`DtdNotAllowed` if ``data`` declares a DTD."""
113
+ _scan(iter((data, b"")))