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.
Files changed (84) hide show
  1. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/PKG-INFO +1 -1
  2. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/__init__.py +11 -2
  3. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/core/__init__.py +2 -1
  4. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/core/fc.py +38 -0
  5. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/scl/controls.py +12 -1
  6. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/scl/model.py +70 -16
  7. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/scl_fixtures.py +4 -2
  8. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_fc.py +43 -0
  9. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_public_api.py +1 -0
  10. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_scl_model.py +77 -0
  11. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_xmlsafe.py +18 -0
  12. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/.gitignore +0 -0
  13. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/CLA.md +0 -0
  14. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/COMMERCIAL.md +0 -0
  15. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/CONTRIBUTING.md +0 -0
  16. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/LICENSE +0 -0
  17. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/README.md +0 -0
  18. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/ROADMAP.md +0 -0
  19. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/examples/01_connect_and_scan.py +0 -0
  20. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/examples/02_read_values.py +0 -0
  21. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/examples/03_file_transfer.py +0 -0
  22. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/examples/04_error_handling.py +0 -0
  23. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/examples/05_fleet_inventory.py +0 -0
  24. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/examples/06_find_files.py +0 -0
  25. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/examples/07_read_object_references.py +0 -0
  26. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/examples/README.md +0 -0
  27. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/pyproject.toml +0 -0
  28. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/__main__.py +0 -0
  29. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/cli/__init__.py +0 -0
  30. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/cli/__main__.py +0 -0
  31. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/cli/files.py +0 -0
  32. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/cli/main.py +0 -0
  33. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/cli/scan.py +0 -0
  34. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/core/ber.py +0 -0
  35. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/core/data.py +0 -0
  36. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/core/quality.py +0 -0
  37. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/core/refs.py +0 -0
  38. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/core/time.py +0 -0
  39. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/errors.py +0 -0
  40. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/goose/__init__.py +0 -0
  41. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/link/__init__.py +0 -0
  42. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/mms/__init__.py +0 -0
  43. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/mms/client.py +0 -0
  44. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/mms/pdu.py +0 -0
  45. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/mms/service_error.py +0 -0
  46. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/mms/services/__init__.py +0 -0
  47. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/mms/services/directory.py +0 -0
  48. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/mms/services/files.py +0 -0
  49. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/mms/services/read.py +0 -0
  50. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/mms/types.py +0 -0
  51. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/osi/__init__.py +0 -0
  52. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/osi/acse.py +0 -0
  53. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/osi/cotp.py +0 -0
  54. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/osi/oids.py +0 -0
  55. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/osi/presentation.py +0 -0
  56. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/osi/session.py +0 -0
  57. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/osi/stack.py +0 -0
  58. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/osi/tpkt.py +0 -0
  59. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/py.typed +0 -0
  60. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/scl/__init__.py +0 -0
  61. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/scl/_xmlsafe.py +0 -0
  62. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/scl/communication.py +0 -0
  63. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/scl/document.py +0 -0
  64. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/scl/templates.py +0 -0
  65. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/src/py61850/sv/__init__.py +0 -0
  66. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/BENCH.md +0 -0
  67. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/README.md +0 -0
  68. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/__init__.py +0 -0
  69. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/fixtures/README.md +0 -0
  70. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/__init__.py +0 -0
  71. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_ber.py +0 -0
  72. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_client.py +0 -0
  73. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_data.py +0 -0
  74. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_file_filter.py +0 -0
  75. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_logical_nodes.py +0 -0
  76. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_osi.py +0 -0
  77. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_pdu.py +0 -0
  78. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_refs.py +0 -0
  79. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_scl_api.py +0 -0
  80. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_scl_communication.py +0 -0
  81. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_scl_controls.py +0 -0
  82. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_scl_document.py +0 -0
  83. {py61850-0.3.0.dev1 → py61850-0.3.0.dev2}/tests/unit/test_scl_templates.py +0 -0
  84. {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.dev1
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 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.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 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``
@@ -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 (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}>"
@@ -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) -> str:
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.ldevice.ied.document.templates
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``. ``server`` is ``None`` when it hosts none.
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
- return [n for ld in self.ldevices() for n in ld.logical_nodes]
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) -> str:
362
- """``QPC1PRO/CSWI1.Pos.Oper.ctlVal`` -- the 61850-6 object reference."""
363
- return _object_reference(self.logical_node.ldevice.ld_name,
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.ldevice.ied.document.templates
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) -> str:
428
- return _object_reference(self.logical_node.ldevice.ld_name,
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