py61850 0.3.0.dev1__tar.gz → 0.4.0__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 (93) hide show
  1. py61850-0.4.0/.gitignore +45 -0
  2. {py61850-0.3.0.dev1 → py61850-0.4.0}/PKG-INFO +23 -2
  3. {py61850-0.3.0.dev1 → py61850-0.4.0}/README.md +22 -1
  4. {py61850-0.3.0.dev1 → py61850-0.4.0}/ROADMAP.md +36 -0
  5. {py61850-0.3.0.dev1 → py61850-0.4.0}/pyproject.toml +6 -0
  6. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/__init__.py +11 -2
  7. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/core/__init__.py +9 -3
  8. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/core/fc.py +38 -0
  9. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/scl/__init__.py +12 -0
  10. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/scl/controls.py +12 -1
  11. py61850-0.4.0/src/py61850/scl/document.py +870 -0
  12. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/scl/model.py +70 -16
  13. {py61850-0.3.0.dev1 → py61850-0.4.0}/tests/BENCH.md +4 -4
  14. py61850-0.4.0/tests/fixtures/scl/README.md +82 -0
  15. py61850-0.4.0/tests/unit/roundtrip.py +382 -0
  16. {py61850-0.3.0.dev1 → py61850-0.4.0}/tests/unit/scl_fixtures.py +15 -2
  17. {py61850-0.3.0.dev1 → py61850-0.4.0}/tests/unit/test_fc.py +43 -0
  18. py61850-0.4.0/tests/unit/test_fixture_hygiene.py +108 -0
  19. {py61850-0.3.0.dev1 → py61850-0.4.0}/tests/unit/test_public_api.py +1 -0
  20. py61850-0.4.0/tests/unit/test_scl_comments.py +332 -0
  21. {py61850-0.3.0.dev1 → py61850-0.4.0}/tests/unit/test_scl_model.py +77 -0
  22. py61850-0.4.0/tests/unit/test_scl_namespaces.py +346 -0
  23. py61850-0.4.0/tests/unit/test_scl_roundtrip.py +200 -0
  24. py61850-0.4.0/tests/unit/test_scl_write.py +303 -0
  25. {py61850-0.3.0.dev1 → py61850-0.4.0}/tests/unit/test_xmlsafe.py +18 -0
  26. py61850-0.3.0.dev1/.gitignore +0 -24
  27. py61850-0.3.0.dev1/src/py61850/scl/document.py +0 -326
  28. {py61850-0.3.0.dev1 → py61850-0.4.0}/CLA.md +0 -0
  29. {py61850-0.3.0.dev1 → py61850-0.4.0}/COMMERCIAL.md +0 -0
  30. {py61850-0.3.0.dev1 → py61850-0.4.0}/CONTRIBUTING.md +0 -0
  31. {py61850-0.3.0.dev1 → py61850-0.4.0}/LICENSE +0 -0
  32. {py61850-0.3.0.dev1 → py61850-0.4.0}/examples/01_connect_and_scan.py +0 -0
  33. {py61850-0.3.0.dev1 → py61850-0.4.0}/examples/02_read_values.py +0 -0
  34. {py61850-0.3.0.dev1 → py61850-0.4.0}/examples/03_file_transfer.py +0 -0
  35. {py61850-0.3.0.dev1 → py61850-0.4.0}/examples/04_error_handling.py +0 -0
  36. {py61850-0.3.0.dev1 → py61850-0.4.0}/examples/05_fleet_inventory.py +0 -0
  37. {py61850-0.3.0.dev1 → py61850-0.4.0}/examples/06_find_files.py +0 -0
  38. {py61850-0.3.0.dev1 → py61850-0.4.0}/examples/07_read_object_references.py +0 -0
  39. {py61850-0.3.0.dev1 → py61850-0.4.0}/examples/README.md +0 -0
  40. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/__main__.py +0 -0
  41. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/cli/__init__.py +0 -0
  42. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/cli/__main__.py +0 -0
  43. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/cli/files.py +0 -0
  44. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/cli/main.py +0 -0
  45. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/cli/scan.py +0 -0
  46. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/core/ber.py +0 -0
  47. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/core/data.py +0 -0
  48. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/core/quality.py +0 -0
  49. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/core/refs.py +0 -0
  50. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/core/time.py +0 -0
  51. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/errors.py +0 -0
  52. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/goose/__init__.py +0 -0
  53. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/link/__init__.py +0 -0
  54. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/mms/__init__.py +0 -0
  55. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/mms/client.py +0 -0
  56. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/mms/pdu.py +0 -0
  57. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/mms/service_error.py +0 -0
  58. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/mms/services/__init__.py +0 -0
  59. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/mms/services/directory.py +0 -0
  60. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/mms/services/files.py +0 -0
  61. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/mms/services/read.py +0 -0
  62. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/mms/types.py +0 -0
  63. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/osi/__init__.py +0 -0
  64. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/osi/acse.py +0 -0
  65. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/osi/cotp.py +0 -0
  66. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/osi/oids.py +0 -0
  67. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/osi/presentation.py +0 -0
  68. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/osi/session.py +0 -0
  69. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/osi/stack.py +0 -0
  70. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/osi/tpkt.py +0 -0
  71. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/py.typed +0 -0
  72. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/scl/_xmlsafe.py +0 -0
  73. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/scl/communication.py +0 -0
  74. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/scl/templates.py +0 -0
  75. {py61850-0.3.0.dev1 → py61850-0.4.0}/src/py61850/sv/__init__.py +0 -0
  76. {py61850-0.3.0.dev1 → py61850-0.4.0}/tests/README.md +0 -0
  77. {py61850-0.3.0.dev1 → py61850-0.4.0}/tests/__init__.py +0 -0
  78. {py61850-0.3.0.dev1 → py61850-0.4.0}/tests/fixtures/README.md +0 -0
  79. {py61850-0.3.0.dev1 → py61850-0.4.0}/tests/unit/__init__.py +0 -0
  80. {py61850-0.3.0.dev1 → py61850-0.4.0}/tests/unit/test_ber.py +0 -0
  81. {py61850-0.3.0.dev1 → py61850-0.4.0}/tests/unit/test_client.py +0 -0
  82. {py61850-0.3.0.dev1 → py61850-0.4.0}/tests/unit/test_data.py +0 -0
  83. {py61850-0.3.0.dev1 → py61850-0.4.0}/tests/unit/test_file_filter.py +0 -0
  84. {py61850-0.3.0.dev1 → py61850-0.4.0}/tests/unit/test_logical_nodes.py +0 -0
  85. {py61850-0.3.0.dev1 → py61850-0.4.0}/tests/unit/test_osi.py +0 -0
  86. {py61850-0.3.0.dev1 → py61850-0.4.0}/tests/unit/test_pdu.py +0 -0
  87. {py61850-0.3.0.dev1 → py61850-0.4.0}/tests/unit/test_refs.py +0 -0
  88. {py61850-0.3.0.dev1 → py61850-0.4.0}/tests/unit/test_scl_api.py +0 -0
  89. {py61850-0.3.0.dev1 → py61850-0.4.0}/tests/unit/test_scl_communication.py +0 -0
  90. {py61850-0.3.0.dev1 → py61850-0.4.0}/tests/unit/test_scl_controls.py +0 -0
  91. {py61850-0.3.0.dev1 → py61850-0.4.0}/tests/unit/test_scl_document.py +0 -0
  92. {py61850-0.3.0.dev1 → py61850-0.4.0}/tests/unit/test_scl_templates.py +0 -0
  93. {py61850-0.3.0.dev1 → py61850-0.4.0}/tests/unit/test_types.py +0 -0
@@ -0,0 +1,45 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .venv/
7
+ venv/
8
+ .pytest_cache/
9
+ downloads/
10
+ relay_files/
11
+ inventory.csv
12
+
13
+ # SCL / IED capability files (vendor-supplied, large, not redistributable)
14
+ *.icd
15
+ *.ICD
16
+ *.cid
17
+ *.CID
18
+ *.scd
19
+ *.SCD
20
+ *.iid
21
+ *.IID
22
+ *.sed
23
+ *.SED
24
+ fixtures/ICD/
25
+
26
+ # The round-trip corpus is tracked, by name and never by wildcard.
27
+ #
28
+ # The `*.scd` rule above is right for every SCD except these four: a vendor
29
+ # file dropped in this tree is somebody's substation and must not be
30
+ # committed by accident. The three real ones below were anonymised by
31
+ # `tools/anonymise_scd.py` before they were added -- substation, utility,
32
+ # Windows accounts, machine names and addressing all substituted, IED names
33
+ # deliberately kept. The fourth is hand-written and carries no identity.
34
+ #
35
+ # Named one by one on purpose. A negation like `!tests/fixtures/scl/*.scd`
36
+ # would re-include ANY .scd dropped in that directory, which is exactly how
37
+ # an un-anonymised file reached a sibling repository once.
38
+ !tests/fixtures/scl/sel.scd
39
+ !tests/fixtures/scl/mixed.scd
40
+ !tests/fixtures/scl/siemens.scd
41
+ !tests/fixtures/scl/namespaces.scd
42
+
43
+ # The substitution mapping names what was substituted -- the substation, the
44
+ # utility, the engineers' accounts. Tracking it would undo the anonymisation.
45
+ tools/*.local.json
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: py61850
3
- Version: 0.3.0.dev1
3
+ Version: 0.4.0
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
@@ -135,7 +135,7 @@ with FileTransfer("192.0.2.22") as ft:
135
135
  progress=lambda got, total: print(f"{got}/{total}"))
136
136
  ```
137
137
 
138
- ### Reading an SCL file
138
+ ### Reading an SCL file — and writing it back unchanged
139
139
 
140
140
  ```python
141
141
  from py61850.scl import SclDocument
@@ -153,6 +153,27 @@ for ln in ied.logical_nodes():
153
153
  print(attr.reference(), attr.mms_item(), attr.fc, attr.btype)
154
154
  ```
155
155
 
156
+ A parsed document goes back out as the file it came from:
157
+
158
+ ```python
159
+ doc.write("station.scd") # atomic: temp file beside it, then os.replace
160
+ raw = doc.to_bytes() # or the bytes, to hand somewhere else
161
+ ```
162
+
163
+ **Byte for byte**, with no edit applied — comments, indentation, attribute
164
+ order, namespace prefixes, `xmlns` declarations nothing uses, the line ending
165
+ the file was written with and the XML declaration as it was spelled all
166
+ survive. Five differences are permitted, none of them observable through an
167
+ XML parser and every one a limit of the standard library's serialiser:
168
+ attribute quote style, empty-element spacing and form, CDATA boundaries, and
169
+ the spelling of a numeric character reference. `SclDocument.to_bytes` states
170
+ all five; the round-trip test holds them against 44 MB of real station
171
+ exports from three vendors.
172
+
173
+ That guarantee is the point of the write side. The file goes back into DIGSI
174
+ and SEL Architect, and a library that reformats the 99 % of an SCD it did not
175
+ touch turns every save into a whole-file diff.
176
+
156
177
  `py61850.scl` is imported explicitly and is never pulled in by `import
157
178
  py61850`, so the MMS client keeps installing and running unprivileged.
158
179
 
@@ -108,7 +108,7 @@ with FileTransfer("192.0.2.22") as ft:
108
108
  progress=lambda got, total: print(f"{got}/{total}"))
109
109
  ```
110
110
 
111
- ### Reading an SCL file
111
+ ### Reading an SCL file — and writing it back unchanged
112
112
 
113
113
  ```python
114
114
  from py61850.scl import SclDocument
@@ -126,6 +126,27 @@ for ln in ied.logical_nodes():
126
126
  print(attr.reference(), attr.mms_item(), attr.fc, attr.btype)
127
127
  ```
128
128
 
129
+ A parsed document goes back out as the file it came from:
130
+
131
+ ```python
132
+ doc.write("station.scd") # atomic: temp file beside it, then os.replace
133
+ raw = doc.to_bytes() # or the bytes, to hand somewhere else
134
+ ```
135
+
136
+ **Byte for byte**, with no edit applied — comments, indentation, attribute
137
+ order, namespace prefixes, `xmlns` declarations nothing uses, the line ending
138
+ the file was written with and the XML declaration as it was spelled all
139
+ survive. Five differences are permitted, none of them observable through an
140
+ XML parser and every one a limit of the standard library's serialiser:
141
+ attribute quote style, empty-element spacing and form, CDATA boundaries, and
142
+ the spelling of a numeric character reference. `SclDocument.to_bytes` states
143
+ all five; the round-trip test holds them against 44 MB of real station
144
+ exports from three vendors.
145
+
146
+ That guarantee is the point of the write side. The file goes back into DIGSI
147
+ and SEL Architect, and a library that reformats the 99 % of an SCD it did not
148
+ touch turns every save into a whole-file diff.
149
+
129
150
  `py61850.scl` is imported explicitly and is never pulled in by `import
130
151
  py61850`, so the MMS client keeps installing and running unprivileged.
131
152
 
@@ -111,11 +111,36 @@ Make the client dependable enough for unattended fleet jobs to build on.
111
111
  documented read-preference ranking. In `core` rather than `scl` because
112
112
  a client matching items against `GetLogicalDeviceDirectory` needs the
113
113
  same vocabulary as a reader walking a file.
114
+ - ✅ `core/fc.py` also carries the control model's own data attributes
115
+ (`Oper`, `SBOw`, `SBO`, `Cancel`) and `is_control_attribute`, which asks
116
+ "command or reading?" of an attribute NAME. Both directions are needed:
117
+ an attribute path is often in hand when its FC is not.
114
118
  - ✅ `core/refs.py` — object reference ↔ MMS domain and item name (61850-8-1).
115
119
  - ✅ `scl/` — see 1.0 below.
116
120
 
117
121
  ---
118
122
 
123
+ ## 0.4 — The SCL round trip ✅
124
+
125
+ Turns a read-only reader into a read-write one. The model is unchanged; what
126
+ is new is that a document can go back out as the file it came from.
127
+
128
+ - ✅ **Comment-preserving parse** — `insert_comments` on the tree builder, and
129
+ every walk in the package tolerates a node whose `tag` is a factory
130
+ rather than a name. An engineer's note beside a setting is content.
131
+ - ✅ **Namespace fidelity** — prefixes are the document's own, and a
132
+ declaration the file carries but nothing uses is re-emitted. `sxy:`
133
+ coordinates are read by DIGSI and SEL Architect to draw a single-line
134
+ diagram; losing that declaration would damage one silently.
135
+ - ✅ **`SclDocument.to_bytes` / `.write`** — the public write API, atomic on a
136
+ real path, with the fidelity guarantee and its five cosmetic exceptions
137
+ stated where a consumer reads them.
138
+ - ✅ **The round-trip test** — four station exports, 44 MB, parsed and written
139
+ with no edit and compared at the level of bytes. The comparison names
140
+ what differed rather than that something did.
141
+
142
+ ---
143
+
119
144
  ## 1.0 — MMS simulation 🧭
120
145
 
121
146
  **Goal:** read an SCL file (`.scd` / `.cid` / `.icd`) and stand up a virtual IED
@@ -137,6 +162,17 @@ image of today's client.
137
162
 
138
163
  Not modelled, because no file in the reference corpus carries them: the
139
164
  `Substation` section and `Log`.
165
+
166
+ **The vendor seam is proven by two unrelated vendors**, which is what
167
+ 0.3.0 waited for rather than shipping on its author's word. Two
168
+ libraries outside this project were rebuilt on the model, sharing no
169
+ code with each other and neither written against it: one reads SEL's
170
+ `sAddr` value grammar (178,406 configured attributes in one station),
171
+ the other Siemens' `Private` elements (5,886 of a single type). Both
172
+ reproduce every field their own XML readers produced, on the same
173
+ files — one of which is a Siemens export containing SEL relays, read by
174
+ both without either opening it twice. Nothing was added to this package
175
+ for the second one.
140
176
  - 🧭 **Model → MMS server** — a listening `MmsServer` on TCP 102 that answers the
141
177
  confirmed services the client already speaks, driven by the SCL model:
142
178
  - Initiate / association (server side of `associate.py`)
@@ -52,3 +52,9 @@ packages = ["src/py61850"]
52
52
  [tool.hatch.build.targets.sdist]
53
53
  include = ["src", "tests", "examples", "README.md", "ROADMAP.md", "LICENSE",
54
54
  "COMMERCIAL.md", "CONTRIBUTING.md", "CLA.md"]
55
+ # The SCL round-trip corpus is 44 MB of real vendor SCDs -- a development
56
+ # artefact, not something an installer needs. Shipping it would grow the sdist
57
+ # of a zero-dependency standard-library package by ~2.3 MB compressed and buy
58
+ # whoever downloads it nothing. `tests/fixtures/scl/README.md` says how to
59
+ # obtain the corpus; the tests that need it skip when it is absent.
60
+ exclude = ["tests/fixtures/scl/*.scd"]
@@ -25,6 +25,11 @@ Public API
25
25
  the 61850-7-2 functional constraints, shared by the SCL
26
26
  reader and the live MMS path
27
27
 
28
+ CONTROL_DATA_ATTRIBUTES / fc_is_control_attribute
29
+ the control model's own data attributes -- the same
30
+ "command, not a reading" question asked of an attribute
31
+ name rather than of an FC
32
+
28
33
  mms_item / object_reference / split_item / da_parts
29
34
  61850-8-1 naming: object reference <-> MMS domain and item
30
35
 
@@ -75,11 +80,13 @@ from .mms.services.directory import LogicalNode
75
80
  from .mms.services.files import folder_of
76
81
  from .mms.service_error import decode_service_error
77
82
  from .mms.types import decode_data_definition
78
- from .core.fc import FUNCTIONAL_CONSTRAINTS, is_control as fc_is_control, \
83
+ from .core.fc import CONTROL_DATA_ATTRIBUTES, FUNCTIONAL_CONSTRAINTS, \
84
+ is_control as fc_is_control, \
85
+ is_control_attribute as fc_is_control_attribute, \
79
86
  read_rank as fc_read_rank
80
87
  from .core.refs import da_parts, mms_item, object_reference, split_item
81
88
 
82
- __version__ = "0.3.0.dev1"
89
+ __version__ = "0.4.0"
83
90
 
84
91
  __all__ = [
85
92
  "MmsClient",
@@ -90,6 +97,8 @@ __all__ = [
90
97
  "FUNCTIONAL_CONSTRAINTS",
91
98
  "fc_is_control",
92
99
  "fc_read_rank",
100
+ "CONTROL_DATA_ATTRIBUTES",
101
+ "fc_is_control_attribute",
93
102
  "mms_item",
94
103
  "object_reference",
95
104
  "split_item",
@@ -14,11 +14,17 @@ 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
17
+ fc the IEC 61850-7-2 functional constraints, the control
18
+ model's own data attributes, and the read ranking over both
18
19
  refs IEC 61850-8-1 object reference <-> MMS domain/item names
19
20
  quality the IEC 61850 13-bit Quality bitstring
20
21
  time MMS ``UtcTime`` / ``BinaryTime``
21
22
 
22
- Import these from ``py61850.core.<module>``; they are internal to the package
23
- and may change between releases.
23
+ Most of this is internal and may change between releases. The exception is
24
+ what the top level re-exports -- ``FUNCTIONAL_CONSTRAINTS``,
25
+ ``CONTROL_DATA_ATTRIBUTES``, ``fc_is_control``, ``fc_is_control_attribute``,
26
+ ``fc_read_rank``, ``mms_item``, ``object_reference``, ``split_item`` and
27
+ ``da_parts``. Those are public API and are reached as ``py61850.<name>``; the
28
+ rest is reached as ``py61850.core.<module>.<name>`` and carries no such
29
+ promise.
24
30
  """
@@ -14,10 +14,18 @@ MMS item name (``LN$ST$Pos$stVal``). That is why this lives in ``core``
14
14
  rather than under ``scl``: a client matching an item against
15
15
  ``GetLogicalDeviceDirectory`` needs the same vocabulary as a reader walking
16
16
  a file, and must not have to import the SCL package to get it.
17
+
18
+ The same module answers the same question from the other direction. An FC
19
+ says a whole container is a command; the control model of 61850-7-2 says
20
+ which *attributes* of a controllable data object carry one. Both are needed,
21
+ because the two are not always available together -- see
22
+ :func:`is_control_attribute`.
17
23
  """
18
24
 
19
25
  from __future__ import annotations
20
26
 
27
+ from .refs import da_parts
28
+
21
29
  #: Every functional constraint IEC 61850-7-2 defines.
22
30
  #:
23
31
  #: The first twelve were measured across the three reference SCDs (7.1, 13.5
@@ -44,6 +52,15 @@ FUNCTIONAL_CONSTRAINTS = (
44
52
  #: The FCs that carry a command rather than a reading.
45
53
  CONTROL_FCS = frozenset({"CO"})
46
54
 
55
+ #: The data attributes of the 61850-7-2 control model: the ones through which
56
+ #: a controllable data object (SPC, DPC, INC, ENC, BSC, ISC, APC, BAC) is
57
+ #: COMMANDED rather than read. They are the attribute-name counterpart of
58
+ #: ``CONTROL_FCS``, and they are not redundant with it: an attribute path is
59
+ #: often in hand when its FC is not. A live client resolving a name against
60
+ #: ``GetLogicalDeviceDirectory``, or any consumer holding an item name whose
61
+ #: middle segment it has not yet parsed, has only the spelling to go on.
62
+ CONTROL_DATA_ATTRIBUTES = frozenset({"Oper", "SBOw", "SBO", "Cancel"})
63
+
47
64
  # Ordered best-to-worst for "if this attribute is reachable under several FCs,
48
65
  # which one should be read?". Status first, then measurand, then the settings
49
66
  # and descriptive constraints. Controls are absent on purpose -- they are
@@ -81,3 +98,24 @@ def read_rank(fc) -> tuple:
81
98
  if name in _READ_PREFERENCE:
82
99
  return (0, _READ_PREFERENCE.index(name))
83
100
  return (0, len(_READ_PREFERENCE))
101
+
102
+
103
+ def is_control_attribute(path) -> bool:
104
+ """Does this attribute path address a command rather than a reading?
105
+
106
+ Takes either spelling of the descent -- ``"Oper.ctlVal"`` as SCL writes
107
+ it, ``"Oper$ctlVal"`` as MMS does, or the parts already split.
108
+
109
+ Two rules, and the second is not covered by the first. The path is a
110
+ command when it descends through one of :data:`CONTROL_DATA_ATTRIBUTES`,
111
+ which is the control model's own vocabulary; and also when its leaf is a
112
+ ``ctlVal``, because ``ctlVal`` appears only inside a control and a
113
+ consumer may hold the leaf without the root that carried it. The leaf
114
+ test is a prefix match: 7-3 spells the analogue control value
115
+ ``ctlVal`` on its own but attaches the setpoint variants beside it, and
116
+ a name that begins ``ctlVal`` is a control value in every one of them.
117
+ """
118
+ parts = da_parts(path)
119
+ if not parts:
120
+ return False
121
+ return parts[0] in CONTROL_DATA_ATTRIBUTES or parts[-1].startswith("ctlVal")
@@ -16,6 +16,18 @@
16
16
  for attr in ln.walk():
17
17
  print(attr.reference(), attr.mms_item(), attr.btype)
18
18
 
19
+ doc.write("station.scd") # atomically, and byte for byte
20
+
21
+ **Reading is not the whole of it: a document written back out is the file it
22
+ came from.** ``SclDocument.to_bytes`` and ``SclDocument.write`` hold a
23
+ fidelity guarantee -- comments, indentation, attribute order, namespace
24
+ prefixes, declarations nothing uses, the line ending and the XML declaration
25
+ all survive a parse and a serialise, with five exceptions no XML parser can
26
+ observe. :meth:`SclDocument.to_bytes` names all five. It matters because the
27
+ file goes back to DIGSI and to SEL Architect: a library that reformats the
28
+ 99 % of a station export it did not touch turns every save into a whole-file
29
+ diff.
30
+
19
31
  **What this package is for.** It is a general IEC 61850-6 implementation,
20
32
  judged against what any SCL tool would need -- IEDScout, IEC Browser, OpenSCD
21
33
  -- not against what one consumer happens to extract today. If a vendor-neutral
@@ -111,8 +111,19 @@ class ControlBlock:
111
111
 
112
112
  @property
113
113
  def key(self) -> tuple:
114
+ """``(iedName, ldInst, name)``.
115
+
116
+ ``ldInst`` is ``""`` for a control block on an access-point-level LN,
117
+ which is what the SCL would spell there too: such a node is in no
118
+ logical device, so there is no ``inst`` to quote. No file in the
119
+ reference corpus carries one -- 61850-6 puts control blocks on an
120
+ ``LN0``, and an access-point LN is never one -- but the key must
121
+ still be a triple rather than raise, because it is what every
122
+ publisher/subscriber join in this package looks up by.
123
+ """
114
124
  ld = self.logical_node.ldevice
115
- return (ld.ied.name, ld.inst, self.name)
125
+ return (self.logical_node.ied.name, "" if ld is None else ld.inst,
126
+ self.name)
116
127
 
117
128
  def __repr__(self):
118
129
  return f"<{self.kind} {self.key!r} datSet={self.dat_set!r}>"