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.
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/CONTRIBUTING.md +20 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/PKG-INFO +43 -1
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/README.md +42 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/ROADMAP.md +26 -3
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/__init__.py +18 -1
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/core/__init__.py +2 -0
- py61850-0.3.0.dev1/src/py61850/core/fc.py +83 -0
- py61850-0.3.0.dev1/src/py61850/core/refs.py +94 -0
- py61850-0.3.0.dev1/src/py61850/scl/__init__.py +98 -0
- py61850-0.3.0.dev1/src/py61850/scl/_xmlsafe.py +113 -0
- py61850-0.3.0.dev1/src/py61850/scl/communication.py +188 -0
- py61850-0.3.0.dev1/src/py61850/scl/controls.py +222 -0
- py61850-0.3.0.dev1/src/py61850/scl/document.py +326 -0
- py61850-0.3.0.dev1/src/py61850/scl/model.py +454 -0
- py61850-0.3.0.dev1/src/py61850/scl/templates.py +229 -0
- py61850-0.3.0.dev1/tests/unit/scl_fixtures.py +219 -0
- py61850-0.3.0.dev1/tests/unit/test_fc.py +66 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/tests/unit/test_public_api.py +2 -0
- py61850-0.3.0.dev1/tests/unit/test_refs.py +92 -0
- py61850-0.3.0.dev1/tests/unit/test_scl_api.py +67 -0
- py61850-0.3.0.dev1/tests/unit/test_scl_communication.py +144 -0
- py61850-0.3.0.dev1/tests/unit/test_scl_controls.py +153 -0
- py61850-0.3.0.dev1/tests/unit/test_scl_document.py +164 -0
- py61850-0.3.0.dev1/tests/unit/test_scl_model.py +339 -0
- py61850-0.3.0.dev1/tests/unit/test_scl_templates.py +154 -0
- py61850-0.3.0.dev1/tests/unit/test_xmlsafe.py +81 -0
- py61850-0.2.0.dev1/src/py61850/scl/__init__.py +0 -23
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/.gitignore +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/CLA.md +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/COMMERCIAL.md +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/LICENSE +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/examples/01_connect_and_scan.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/examples/02_read_values.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/examples/03_file_transfer.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/examples/04_error_handling.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/examples/05_fleet_inventory.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/examples/06_find_files.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/examples/07_read_object_references.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/examples/README.md +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/pyproject.toml +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/__main__.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/cli/__init__.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/cli/__main__.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/cli/files.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/cli/main.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/cli/scan.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/core/ber.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/core/data.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/core/quality.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/core/time.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/errors.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/goose/__init__.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/link/__init__.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/mms/__init__.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/mms/client.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/mms/pdu.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/mms/service_error.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/mms/services/__init__.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/mms/services/directory.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/mms/services/files.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/mms/services/read.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/mms/types.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/osi/__init__.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/osi/acse.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/osi/cotp.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/osi/oids.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/osi/presentation.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/osi/session.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/osi/stack.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/osi/tpkt.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/py.typed +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/src/py61850/sv/__init__.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/tests/BENCH.md +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/tests/README.md +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/tests/__init__.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/tests/fixtures/README.md +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/tests/unit/__init__.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/tests/unit/test_ber.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/tests/unit/test_client.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/tests/unit/test_data.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/tests/unit/test_file_filter.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/tests/unit/test_logical_nodes.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/tests/unit/test_osi.py +0 -0
- {py61850-0.2.0.dev1 → py61850-0.3.0.dev1}/tests/unit/test_pdu.py +0 -0
- {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.
|
|
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
|
-
-
|
|
115
|
-
|
|
116
|
-
|
|
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.
|
|
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"")))
|