py61850 0.3.0.dev1__tar.gz → 0.3.0.dev2__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.3.0.dev1 → py61850-0.3.0.dev2}/PKG-INFO +1 -1
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/__init__.py +11 -2
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/core/__init__.py +2 -1
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/core/fc.py +38 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/scl/controls.py +12 -1
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/scl/model.py +70 -16
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/scl_fixtures.py +4 -2
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_fc.py +43 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_public_api.py +1 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_scl_model.py +77 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_xmlsafe.py +18 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/.gitignore +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/CLA.md +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/COMMERCIAL.md +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/CONTRIBUTING.md +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/LICENSE +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/README.md +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/ROADMAP.md +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/examples/01_connect_and_scan.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/examples/02_read_values.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/examples/03_file_transfer.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/examples/04_error_handling.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/examples/05_fleet_inventory.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/examples/06_find_files.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/examples/07_read_object_references.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/examples/README.md +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/pyproject.toml +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/__main__.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/cli/__init__.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/cli/__main__.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/cli/files.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/cli/main.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/cli/scan.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/core/ber.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/core/data.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/core/quality.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/core/refs.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/core/time.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/errors.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/goose/__init__.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/link/__init__.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/mms/__init__.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/mms/client.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/mms/pdu.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/mms/service_error.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/mms/services/__init__.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/mms/services/directory.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/mms/services/files.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/mms/services/read.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/mms/types.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/osi/__init__.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/osi/acse.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/osi/cotp.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/osi/oids.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/osi/presentation.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/osi/session.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/osi/stack.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/osi/tpkt.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/py.typed +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/scl/__init__.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/scl/_xmlsafe.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/scl/communication.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/scl/document.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/scl/templates.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/sv/__init__.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/BENCH.md +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/README.md +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/__init__.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/fixtures/README.md +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/__init__.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_ber.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_client.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_data.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_file_filter.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_logical_nodes.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_osi.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_pdu.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_refs.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_scl_api.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_scl_communication.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_scl_controls.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_scl_document.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_scl_templates.py +0 -0
- {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: py61850
|
|
3
|
-
Version: 0.3.0.
|
|
3
|
+
Version: 0.3.0.dev2
|
|
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
|
|
@@ -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
|
|
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.
|
|
89
|
+
__version__ = "0.3.0.dev2"
|
|
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,7 +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
|
|
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``
|
|
@@ -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")
|
|
@@ -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 (
|
|
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}>"
|
|
@@ -63,12 +63,15 @@ class LogicalNode:
|
|
|
63
63
|
type, its datasets, its privates).
|
|
64
64
|
"""
|
|
65
65
|
|
|
66
|
-
__slots__ = ("element", "ldevice", "ln_class", "prefix", "inst",
|
|
66
|
+
__slots__ = ("element", "ldevice", "ied", "ln_class", "prefix", "inst",
|
|
67
67
|
"ln_type", "desc", "is_ln0", "privates", "_cache")
|
|
68
68
|
|
|
69
|
-
def __init__(self, el, ldevice, is_ln0=False):
|
|
69
|
+
def __init__(self, el, ldevice, is_ln0=False, ied=None):
|
|
70
70
|
self.element = el
|
|
71
|
+
#: The LDevice this LN is served from, or ``None`` for one declared
|
|
72
|
+
#: straight under an ``AccessPoint``. See :attr:`reference`.
|
|
71
73
|
self.ldevice = ldevice
|
|
74
|
+
self.ied = ied if ldevice is None else ldevice.ied
|
|
72
75
|
self.ln_class = el.get("lnClass") or ("LLN0" if is_ln0 else "")
|
|
73
76
|
self.prefix = el.get("prefix") or ""
|
|
74
77
|
self.inst = el.get("inst") or ""
|
|
@@ -84,8 +87,17 @@ class LogicalNode:
|
|
|
84
87
|
return _ln_name(self.prefix, self.ln_class, self.inst)
|
|
85
88
|
|
|
86
89
|
@property
|
|
87
|
-
def reference(self)
|
|
88
|
-
"""``LDName/LNName
|
|
90
|
+
def reference(self):
|
|
91
|
+
"""``LDName/LNName``, or ``None`` for an access-point-level LN.
|
|
92
|
+
|
|
93
|
+
``None`` is not a failure: an LN declared directly under an
|
|
94
|
+
``AccessPoint`` sits in no logical device, so it has no MMS domain
|
|
95
|
+
and 61850-6 gives it no ``LDName/LNName`` reference to return. It is
|
|
96
|
+
still a real logical node with a type, inputs and privates -- see
|
|
97
|
+
:attr:`AccessPoint.logical_nodes`.
|
|
98
|
+
"""
|
|
99
|
+
if self.ldevice is None:
|
|
100
|
+
return None
|
|
89
101
|
return f"{self.ldevice.ld_name}/{self.name}"
|
|
90
102
|
|
|
91
103
|
@property
|
|
@@ -99,7 +111,7 @@ class LogicalNode:
|
|
|
99
111
|
"""
|
|
100
112
|
dos = self._cache.get("data_objects")
|
|
101
113
|
if dos is None:
|
|
102
|
-
pool = self.
|
|
114
|
+
pool = self.ied.document.templates
|
|
103
115
|
spec = pool.lnode_type(self.ln_type)
|
|
104
116
|
dos = self._cache["data_objects"] = {}
|
|
105
117
|
if spec is not None:
|
|
@@ -204,7 +216,7 @@ class Server:
|
|
|
204
216
|
|
|
205
217
|
|
|
206
218
|
class AccessPoint:
|
|
207
|
-
"""One ``AccessPoint
|
|
219
|
+
"""One ``AccessPoint``: a ``Server``, and any LNs declared beside it.
|
|
208
220
|
|
|
209
221
|
An IED may have several -- 41 across 30 IEDs in one reference SCD -- and an
|
|
210
222
|
access point without a Server is normal: it may delegate to a sibling
|
|
@@ -217,19 +229,37 @@ class AccessPoint:
|
|
|
217
229
|
rather than "no Server, full stop" -- a consumer that needs the
|
|
218
230
|
delegation resolved still has to read ``<ServerAt>`` off ``element``
|
|
219
231
|
itself.
|
|
232
|
+
|
|
233
|
+
``logical_nodes`` holds the LNs 61850-6 allows DIRECTLY under an access
|
|
234
|
+
point, outside any ``Server``. They are how a gateway or proxy declares
|
|
235
|
+
the interface it presents -- an ``ITCI`` for a telecontrol interface, an
|
|
236
|
+
``IHMI`` for an operator one -- and they are real logical nodes: they
|
|
237
|
+
carry a type, privates, and their own ``Inputs``. What they do not carry
|
|
238
|
+
is a logical device, so they have no MMS domain and no
|
|
239
|
+
``LDName/LNName`` reference; :attr:`LogicalNode.reference` is ``None``
|
|
240
|
+
for them.
|
|
241
|
+
|
|
242
|
+
Measured on the reference corpus: exactly one, the ``ITCI`` on the RTAC
|
|
243
|
+
gateway's ``C1`` access point in the SEL station, and it holds 58 bound
|
|
244
|
+
``ExtRef`` entries -- a quarter of that station's subscriptions. Before
|
|
245
|
+
they were modelled, ``Ied.ext_refs()`` claimed to return every ExtRef in
|
|
246
|
+
the IED and returned 1,290 of that gateway's 1,348.
|
|
220
247
|
"""
|
|
221
248
|
|
|
222
|
-
__slots__ = ("element", "name", "server", "privates")
|
|
249
|
+
__slots__ = ("element", "name", "server", "logical_nodes", "privates")
|
|
223
250
|
|
|
224
251
|
def __init__(self, el, ied):
|
|
225
252
|
self.element = el
|
|
226
253
|
self.name = el.get("name") or ""
|
|
227
254
|
server_el = next(children_local(el, "Server"), None)
|
|
228
255
|
self.server = Server(server_el, ied) if server_el is not None else None
|
|
256
|
+
self.logical_nodes = [LogicalNode(n, None, ied=ied)
|
|
257
|
+
for n in children_local(el, "LN")]
|
|
229
258
|
self.privates = privates_of(el)
|
|
230
259
|
|
|
231
260
|
def __repr__(self):
|
|
232
|
-
return f"<AccessPoint {self.name!r} server={self.server is not None}
|
|
261
|
+
return (f"<AccessPoint {self.name!r} server={self.server is not None} "
|
|
262
|
+
f"lns={len(self.logical_nodes)}>")
|
|
233
263
|
|
|
234
264
|
|
|
235
265
|
class Ied:
|
|
@@ -282,8 +312,18 @@ class Ied:
|
|
|
282
312
|
for ld in ap.server.ldevices]
|
|
283
313
|
|
|
284
314
|
def logical_nodes(self) -> list:
|
|
285
|
-
"""Every logical node in the IED, in document order.
|
|
286
|
-
|
|
315
|
+
"""Every logical node in the IED, in document order.
|
|
316
|
+
|
|
317
|
+
The LDevices' nodes first, then the ones declared directly under an
|
|
318
|
+
access point. The access-point ones come last rather than in strict
|
|
319
|
+
document order because they are the exception: a consumer walking
|
|
320
|
+
this list is almost always after the servable model, and the
|
|
321
|
+
handful that answer :attr:`LogicalNode.reference` with ``None``
|
|
322
|
+
are better met at the end than interleaved.
|
|
323
|
+
"""
|
|
324
|
+
nodes = [n for ld in self.ldevices() for n in ld.logical_nodes]
|
|
325
|
+
nodes.extend(n for ap in self.access_points for n in ap.logical_nodes)
|
|
326
|
+
return nodes
|
|
287
327
|
|
|
288
328
|
def ldevice(self, inst):
|
|
289
329
|
"""The LDevice with this ``inst``, or ``None``."""
|
|
@@ -358,9 +398,18 @@ class DataAttribute:
|
|
|
358
398
|
"""``CSWI1$CO$Pos$Oper$ctlVal`` -- the 61850-8-1 name."""
|
|
359
399
|
return _mms_item(self.logical_node.name, self.fc or "", self.path)
|
|
360
400
|
|
|
361
|
-
def reference(self)
|
|
362
|
-
"""``QPC1PRO/CSWI1.Pos.Oper.ctlVal`` -- the 61850-6 object reference.
|
|
363
|
-
|
|
401
|
+
def reference(self):
|
|
402
|
+
"""``QPC1PRO/CSWI1.Pos.Oper.ctlVal`` -- the 61850-6 object reference.
|
|
403
|
+
|
|
404
|
+
``None`` when the logical node is not in a logical device, which is
|
|
405
|
+
the access-point-level case: there is no MMS domain to name it in.
|
|
406
|
+
:meth:`mms_item` is unaffected -- it names the attribute within its
|
|
407
|
+
LN and never needed the domain.
|
|
408
|
+
"""
|
|
409
|
+
ldevice = self.logical_node.ldevice
|
|
410
|
+
if ldevice is None:
|
|
411
|
+
return None
|
|
412
|
+
return _object_reference(ldevice.ld_name,
|
|
364
413
|
self.logical_node.name, self.path)
|
|
365
414
|
|
|
366
415
|
def walk(self):
|
|
@@ -395,7 +444,7 @@ class DataObject:
|
|
|
395
444
|
self.attributes = {}
|
|
396
445
|
self.sub_objects = {}
|
|
397
446
|
self.privates = privates_of(doi_el) if doi_el is not None else {}
|
|
398
|
-
pool = logical_node.
|
|
447
|
+
pool = logical_node.ied.document.templates
|
|
399
448
|
spec = pool.do_type(do_type_id)
|
|
400
449
|
if spec is None:
|
|
401
450
|
self.cdc = None
|
|
@@ -424,8 +473,13 @@ class DataObject:
|
|
|
424
473
|
yield item
|
|
425
474
|
|
|
426
475
|
@property
|
|
427
|
-
def reference(self)
|
|
428
|
-
|
|
476
|
+
def reference(self):
|
|
477
|
+
"""``None`` for an access-point-level LN; see
|
|
478
|
+
:meth:`DataAttribute.reference`."""
|
|
479
|
+
ldevice = self.logical_node.ldevice
|
|
480
|
+
if ldevice is None:
|
|
481
|
+
return None
|
|
482
|
+
return _object_reference(ldevice.ld_name,
|
|
429
483
|
self.logical_node.name, self.path)
|
|
430
484
|
|
|
431
485
|
def __repr__(self):
|
|
@@ -160,9 +160,11 @@ def ldevice(inst, body="", **attrs):
|
|
|
160
160
|
return f'<LDevice inst="{inst}"{extra}>{body}</LDevice>'
|
|
161
161
|
|
|
162
162
|
|
|
163
|
-
def access_point(name="S1", body="", server=True):
|
|
163
|
+
def access_point(name="S1", body="", server=True, lns=""):
|
|
164
|
+
"""An `<AccessPoint>`. `lns` goes BESIDE the Server, not inside it --
|
|
165
|
+
that is where 61850-6 puts a gateway's proxy LNs."""
|
|
164
166
|
inner = f"<Server>{body}</Server>" if server else body
|
|
165
|
-
return f'<AccessPoint name="{name}">{inner}</AccessPoint>'
|
|
167
|
+
return f'<AccessPoint name="{name}">{inner}{lns}</AccessPoint>'
|
|
166
168
|
|
|
167
169
|
|
|
168
170
|
def dai(name, val=None, s_addr=None, body=""):
|
|
@@ -39,6 +39,49 @@ class TestControl(unittest.TestCase):
|
|
|
39
39
|
self.assertFalse(fc.is_control(other), other)
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
class TestControlDataAttributes(unittest.TestCase):
|
|
43
|
+
"""The control model's own attributes -- the "is this a command?" question
|
|
44
|
+
asked of a NAME rather than of an FC."""
|
|
45
|
+
|
|
46
|
+
def test_the_7_2_control_attributes_are_present(self):
|
|
47
|
+
self.assertEqual(sorted(fc.CONTROL_DATA_ATTRIBUTES),
|
|
48
|
+
["Cancel", "Oper", "SBO", "SBOw"])
|
|
49
|
+
|
|
50
|
+
def test_a_control_root_makes_the_whole_descent_a_command(self):
|
|
51
|
+
# The FC belongs to the root DA, and so does this: everything under
|
|
52
|
+
# `Oper` is part of the command, not a reading of the point.
|
|
53
|
+
self.assertTrue(fc.is_control_attribute("Oper"))
|
|
54
|
+
self.assertTrue(fc.is_control_attribute("Oper.ctlVal"))
|
|
55
|
+
self.assertTrue(fc.is_control_attribute("SBOw.ctlNum"))
|
|
56
|
+
self.assertTrue(fc.is_control_attribute("Cancel.origin.orIdent"))
|
|
57
|
+
|
|
58
|
+
def test_both_spellings_of_the_descent(self):
|
|
59
|
+
# SCL writes an SDI descent with '.', MMS spells every level with '$'.
|
|
60
|
+
self.assertTrue(fc.is_control_attribute("Oper$ctlVal"))
|
|
61
|
+
self.assertEqual(fc.is_control_attribute("Oper$ctlVal"),
|
|
62
|
+
fc.is_control_attribute("Oper.ctlVal"))
|
|
63
|
+
|
|
64
|
+
def test_parts_already_split_are_accepted(self):
|
|
65
|
+
self.assertTrue(fc.is_control_attribute(("Oper", "ctlVal")))
|
|
66
|
+
|
|
67
|
+
def test_a_bare_ctlval_leaf_is_a_command_without_its_root(self):
|
|
68
|
+
# This is the rule the root test does not cover: a consumer may hold
|
|
69
|
+
# the leaf alone, and `ctlVal` occurs nowhere but inside a control.
|
|
70
|
+
self.assertTrue(fc.is_control_attribute("ctlVal"))
|
|
71
|
+
|
|
72
|
+
def test_a_status_attribute_is_not_a_command(self):
|
|
73
|
+
for reading in ("stVal", "general", "phsA", "mag.f", "q", "t",
|
|
74
|
+
"setVal", "dirGeneral"):
|
|
75
|
+
self.assertFalse(fc.is_control_attribute(reading), reading)
|
|
76
|
+
|
|
77
|
+
def test_an_empty_path_is_not_a_command(self):
|
|
78
|
+
# Refusing is the safe answer: an unknown shape must not be promoted
|
|
79
|
+
# into "this is a command" any more than into "this is a reading".
|
|
80
|
+
self.assertFalse(fc.is_control_attribute(""))
|
|
81
|
+
self.assertFalse(fc.is_control_attribute(None))
|
|
82
|
+
self.assertFalse(fc.is_control_attribute(()))
|
|
83
|
+
|
|
84
|
+
|
|
42
85
|
class TestReadRank(unittest.TestCase):
|
|
43
86
|
def test_status_beats_measurement_beats_config(self):
|
|
44
87
|
self.assertLess(fc.read_rank("ST"), fc.read_rank("MX"))
|
|
@@ -36,6 +36,7 @@ class TestPublicApi(unittest.TestCase):
|
|
|
36
36
|
self.assertEqual(sorted(py61850.__all__), sorted([
|
|
37
37
|
"MmsClient", "FileTransfer", "DirEntry", "LogicalNode", "folder_of",
|
|
38
38
|
"FUNCTIONAL_CONSTRAINTS", "fc_is_control", "fc_read_rank",
|
|
39
|
+
"CONTROL_DATA_ATTRIBUTES", "fc_is_control_attribute",
|
|
39
40
|
"mms_item", "object_reference", "split_item", "da_parts",
|
|
40
41
|
"Iec61850Error", "TransportError", "MmsError",
|
|
41
42
|
"LinkError", "GooseError", "SvError", "SclError",
|
|
@@ -126,6 +126,83 @@ class TestIed(_Base):
|
|
|
126
126
|
self.assertEqual([ld.inst for ld in ied.ldevices()], ["PRO"])
|
|
127
127
|
|
|
128
128
|
|
|
129
|
+
class TestAccessPointLogicalNodes(_Base):
|
|
130
|
+
"""LNs declared directly under an `<AccessPoint>`, outside any Server.
|
|
131
|
+
|
|
132
|
+
61850-6 allows them, and a gateway uses them: the reference SEL station's
|
|
133
|
+
RTAC carries an `ITCI` on its `C1` access point holding 58 bound
|
|
134
|
+
`ExtRef`s. While they were unmodelled, `Ied.ext_refs()` -- documented as
|
|
135
|
+
"every ExtRef in the IED" -- returned 1,290 of that device's 1,348.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def ap_ied(self):
|
|
139
|
+
return self.doc(
|
|
140
|
+
fx.templates(fx.lnode_type("T_ITCI", ln_class="ITCI")),
|
|
141
|
+
fx.ied("GW", body=fx.access_point(
|
|
142
|
+
"C1",
|
|
143
|
+
body=fx.ldevice("PRO", body=fx.ln0()),
|
|
144
|
+
lns=fx.ln("ITCI", inst="1", prefix="I", ln_type="T_ITCI",
|
|
145
|
+
body=fx.inputs(fx.ext_ref(
|
|
146
|
+
iedName="PUB", srcLDInst="CFG",
|
|
147
|
+
srcCBName="GoSB00", intAddr="VB001"))))))
|
|
148
|
+
|
|
149
|
+
def test_they_are_reachable_from_the_access_point(self):
|
|
150
|
+
ap = self.ap_ied().ied("GW").access_points[0]
|
|
151
|
+
self.assertEqual([n.name for n in ap.logical_nodes], ["IITCI1"])
|
|
152
|
+
self.assertEqual(ap.logical_nodes[0].ln_class, "ITCI")
|
|
153
|
+
|
|
154
|
+
def test_they_are_not_confused_with_the_servers_own_nodes(self):
|
|
155
|
+
# The Server's LNs stay where they are: an AccessPoint-level LN is a
|
|
156
|
+
# sibling of <Server>, never a member of it.
|
|
157
|
+
ap = self.ap_ied().ied("GW").access_points[0]
|
|
158
|
+
self.assertEqual([n.name for n in ap.server.ldevices[0].logical_nodes],
|
|
159
|
+
["LLN0"])
|
|
160
|
+
|
|
161
|
+
def test_the_ieds_logical_nodes_include_them(self):
|
|
162
|
+
names = [n.name for n in self.ap_ied().ied("GW").logical_nodes()]
|
|
163
|
+
self.assertEqual(names, ["LLN0", "IITCI1"])
|
|
164
|
+
|
|
165
|
+
def test_their_ext_refs_reach_ied_ext_refs(self):
|
|
166
|
+
# The bug this class exists for: 58 real subscriptions of a gateway
|
|
167
|
+
# were invisible to a caller asking the IED for its inputs.
|
|
168
|
+
refs = self.ap_ied().ied("GW").ext_refs()
|
|
169
|
+
self.assertEqual([r.int_addr for r in refs], ["VB001"])
|
|
170
|
+
self.assertEqual(refs[0].source_key, ("PUB", "CFG", "GoSB00"))
|
|
171
|
+
|
|
172
|
+
def test_they_have_no_logical_device_and_so_no_reference(self):
|
|
173
|
+
# Not a failure to report a name: an LN outside a Server is in no MMS
|
|
174
|
+
# domain, so 61850-6 gives it no LDName/LNName to return.
|
|
175
|
+
node = self.ap_ied().ied("GW").access_points[0].logical_nodes[0]
|
|
176
|
+
self.assertIsNone(node.ldevice)
|
|
177
|
+
self.assertIsNone(node.reference)
|
|
178
|
+
|
|
179
|
+
def test_they_still_resolve_their_type_through_the_document_pool(self):
|
|
180
|
+
# The type pool hangs off the DOCUMENT, not off the LDevice, so an
|
|
181
|
+
# LN with no LDevice resolves like any other.
|
|
182
|
+
d = self.doc(
|
|
183
|
+
fx.templates(
|
|
184
|
+
fx.lnode_type("T_ITCI", ln_class="ITCI",
|
|
185
|
+
dos=[("Health", "T_INS")]),
|
|
186
|
+
fx.do_type("T_INS", cdc="INS",
|
|
187
|
+
das=[{"name": "stVal", "fc": "ST",
|
|
188
|
+
"bType": "INT32"}])),
|
|
189
|
+
fx.ied("GW", body=fx.access_point(
|
|
190
|
+
"C1", server=False,
|
|
191
|
+
lns=fx.ln("ITCI", inst="1", ln_type="T_ITCI"))))
|
|
192
|
+
node = d.ied("GW").access_points[0].logical_nodes[0]
|
|
193
|
+
attr = node.data_objects["Health"].attributes["stVal"]
|
|
194
|
+
self.assertEqual(attr.fc, "ST")
|
|
195
|
+
self.assertEqual(attr.btype, "INT32")
|
|
196
|
+
# The item name never needed the domain; the object reference does.
|
|
197
|
+
self.assertEqual(attr.mms_item(), "ITCI1$ST$Health$stVal")
|
|
198
|
+
self.assertIsNone(attr.reference())
|
|
199
|
+
|
|
200
|
+
def test_an_access_point_with_no_lns_reports_none(self):
|
|
201
|
+
d = self.doc(fx.ied("A", body=fx.access_point(
|
|
202
|
+
body=fx.ldevice("PRO", body=fx.ln0()))))
|
|
203
|
+
self.assertEqual(d.ied("A").access_points[0].logical_nodes, [])
|
|
204
|
+
|
|
205
|
+
|
|
129
206
|
class TestLDevice(_Base):
|
|
130
207
|
def test_ld_name_defaults_to_ied_name_plus_inst(self):
|
|
131
208
|
d = self.doc(fx.ied("QPC1", body=fx.access_point(
|
|
@@ -63,6 +63,24 @@ class TestRejectFile(unittest.TestCase):
|
|
|
63
63
|
def test_a_clean_file_passes(self):
|
|
64
64
|
_xmlsafe.reject_dtd_in_file(self._write(_CLEAN))
|
|
65
65
|
|
|
66
|
+
def test_a_doctype_behind_a_large_comment_is_still_found(self):
|
|
67
|
+
# The file reader needs its own version of the bytes test above, and
|
|
68
|
+
# a mutation check is what proved it: turning the file scan into a
|
|
69
|
+
# fixed 4 kB window left the in-memory test passing, because that one
|
|
70
|
+
# hands the whole document over at once. Only the streaming reader
|
|
71
|
+
# can be fooled by a prolog longer than its window.
|
|
72
|
+
padded = (b'<?xml version="1.0"?>\n<!--' + b'x' * 500_000 + b'-->\n'
|
|
73
|
+
+ _BILLION_LAUGHS.split(b'\n', 1)[1])
|
|
74
|
+
with self.assertRaises(_xmlsafe.DtdNotAllowed):
|
|
75
|
+
_xmlsafe.reject_dtd_in_file(self._write(padded))
|
|
76
|
+
|
|
77
|
+
def test_the_scan_stops_at_the_root_and_does_not_read_the_body(self):
|
|
78
|
+
# It reads the PROLOG, not the document: a 22 MB SCD must not be read
|
|
79
|
+
# end to end just to check its first line.
|
|
80
|
+
big = _CLEAN.replace(b"</SCL>", b"<Junk/>" * 200_000 + b"</SCL>")
|
|
81
|
+
self.assertGreater(len(big), 1_000_000)
|
|
82
|
+
_xmlsafe.reject_dtd_in_file(self._write(big)) # must not raise
|
|
83
|
+
|
|
66
84
|
|
|
67
85
|
class TestErrorTree(unittest.TestCase):
|
|
68
86
|
def test_it_is_both_an_scl_error_and_a_value_error(self):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|