Skip to content

Commit 3cef197

Browse files
committed
Add ControlObject implementation for IEC61850
--- + Add select/operate/cancel method and ControlObject to iec61850 implementation + DataObjectReference is now able to change the functional constraint
1 parent ebb3bd8 commit 3cef197

5 files changed

Lines changed: 675 additions & 4 deletions

File tree

src/icspacket/proto/iec61850/classes.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,31 @@ class FC(enum.Enum): # Functional Constraint
189189
SV = "Substitution"
190190
US = "Unicast sampled value control"
191191
XX = "All"
192+
193+
194+
class ControlModel(enum.IntEnum):
195+
"""
196+
IEC 61850 control models for logical nodes and control blocks.
197+
198+
These values define the operational semantics of control actions
199+
such as status-only operation, direct control, or "select-before-operate"
200+
(SBO) modes. They are used to configure how client applications interact
201+
with controllable data objects.
202+
203+
.. versionadded:: 0.2.4
204+
"""
205+
206+
STATUS_ONLY = 0
207+
"""Status-only mode."""
208+
209+
DIRECT_NORMAL = 1
210+
"""Direct-control with normal security."""
211+
212+
SBO_NORMAL = 2
213+
"""Select-Before-Operate (SBO) with normal security."""
214+
215+
DIRECT_ENHANCED = 3
216+
"""Direct-control with enhanced security."""
217+
218+
SBO_ENHANCED = 4
219+
"""Select-Before-Operate with enhanced security."""

src/icspacket/proto/iec61850/client.py

Lines changed: 274 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,34 @@
1414
# You should have received a copy of the GNU General Public License
1515
# along with this program. If not, see <https://www.gnu.org/licenses/>.
1616
import enum
17+
from typing_extensions import Any
1718

1819
from icspacket.core.connection import ConnectionNotEstablished
19-
from icspacket.proto.iec61850.path import ObjectReference
20+
from icspacket.proto.iec61850.classes import FC, ControlModel
21+
from icspacket.proto.iec61850.control import (
22+
Cause,
23+
ControlError,
24+
ControlObject,
25+
LastApplError,
26+
)
27+
from icspacket.proto.iec61850.path import DataObjectReference, ObjectReference
2028
from icspacket.proto.mms._mms import (
2129
AccessResult,
2230
Data,
2331
DataAccessError,
2432
DirectoryEntry,
2533
GetVariableAccessAttributes_Response,
34+
InformationReport,
35+
MMSpdu,
36+
UnconfirmedService,
37+
)
38+
from icspacket.proto.mms.connection import (
39+
MMS_Connection,
40+
UnconfirmedServiceCallback,
41+
UnconfirmedServiceHandler,
2642
)
27-
from icspacket.proto.mms.connection import MMS_Connection
43+
from icspacket.proto.mms.data import Timestamp
44+
from icspacket.proto.mms.exceptions import MMSServiceError
2845
from icspacket.proto.mms.util import (
2946
BasicObjectClassType,
3047
NamedVariableSpecificationItem,
@@ -88,10 +105,19 @@ class IED_Client:
88105
... for dev in devices:
89106
... print(dev)
90107
108+
Additional support for control objects and operations is provided:
109+
110+
>>> with IED_Client(("127.0.0.1", 102)) as client:
111+
... co = client.get_control_object(node_ref)
112+
... client.operate(co, True) # direct control
113+
91114
:param address: Optional IP/port tuple for automatic association.
92115
:type address: tuple[str, int] | None
93116
:param conn: Pre-established MMS connection, if available.
94117
:type conn: MMS_Connection | None
118+
119+
.. versionchanged:: 0.2.4
120+
Added support for control objects/operations.
95121
"""
96122

97123
def __init__(
@@ -100,6 +126,13 @@ def __init__(
100126
conn: MMS_Connection | None = None,
101127
) -> None:
102128
self.__conn = conn
129+
if self.__conn:
130+
self.__conn.unconfirmed_cb.append(
131+
UnconfirmedServiceHandler(
132+
UnconfirmedService.PRESENT.PR_informationReport,
133+
self._handle_unconfirmed_pdu,
134+
)
135+
)
103136
if address:
104137
self.associate(address)
105138

@@ -118,6 +151,19 @@ def mms_conn(self) -> MMS_Connection:
118151

119152
return self.__conn
120153

154+
def register_unconfirmed_cb(self, cb: UnconfirmedServiceCallback) -> None:
155+
"""
156+
Register a callback for unconfirmed MMS services.
157+
158+
:param cb: The callback to register.
159+
:type cb: UnconfirmedServiceCallback
160+
"""
161+
self.mms_conn.unconfirmed_cb.append(
162+
UnconfirmedServiceHandler(
163+
UnconfirmedService.PRESENT.PR_informationReport, cb
164+
)
165+
)
166+
121167
def associate(self, address: tuple[str, int] | None = None) -> None:
122168
"""
123169
Establish an association with a remote MMS server.
@@ -127,6 +173,12 @@ def associate(self, address: tuple[str, int] | None = None) -> None:
127173
"""
128174
if not self.__conn:
129175
self.__conn = MMS_Connection()
176+
self.__conn.unconfirmed_cb.append(
177+
UnconfirmedServiceHandler(
178+
UnconfirmedService.PRESENT.PR_informationReport,
179+
self._handle_unconfirmed_pdu,
180+
)
181+
)
130182

131183
if self.mms_conn.is_valid():
132184
return
@@ -406,3 +458,223 @@ def get_dataset_directory(
406458
return list(
407459
self.mms_conn.variable_list_attributes(datref.mms_name).listOfVariable
408460
)
461+
462+
# ---------------------------------------------------------------------- #
463+
# 20 Control class model
464+
# ---------------------------------------------------------------------- #
465+
def control(self, target: DataObjectReference, /) -> ControlObject:
466+
"""
467+
Retrieve a `ControlObject` for the given data object reference.
468+
469+
This method reads the ``ctlModel`` attribute of the target and
470+
uses the associated type description to construct a `ControlObject`.
471+
472+
.. versionadded:: 0.2.4
473+
474+
:param target: Data object reference to the control object.
475+
:type target: DataObjectReference
476+
:return: Initialized control object instance.
477+
:rtype: ControlObject
478+
:raises ConnectionError: If retrieving the control model fails.
479+
"""
480+
cf_target = target.change_fc(FC.CF)
481+
model_result = self.get_data_values(cf_target / "ctlModel")
482+
if model_result.failure:
483+
raise ConnectionError("Failed to get control model")
484+
485+
model = ControlModel(model_result.success.integer or 0)
486+
spec_result = self.get_data_definition(target)
487+
return ControlObject(target, spec_result.typeDescription, model)
488+
489+
# Here, object references are made to the named variable on which to operate
490+
# on. The CO_CtrlObjectRef is defined as:
491+
# - <LDname>/<LNname>$CO$<DOname>
492+
def select(self, co: ControlObject, /) -> DataAccessError | None:
493+
"""
494+
Perform the Select (SBO) operation on a control object.
495+
496+
This method only works with `ControlObject` instances using
497+
the ``SBO_NORMAL`` model. It reads the ``SBO`` attribute to perform
498+
the selection.
499+
500+
.. versionadded:: 0.2.4
501+
502+
:param co: Control object to select.
503+
:type co: ControlObject
504+
:return: Access error if selection fails, or None on success.
505+
:rtype: DataAccessError | None
506+
:raises ValueError: If the control object does not use ``SBO_NORMAL``.
507+
"""
508+
if co.model != ControlModel.SBO_NORMAL:
509+
raise ValueError("ControlObject without SBO model cannot be selected!")
510+
511+
sel_object_ref = co.ctrl_object_ref / "SBO"
512+
result = self.get_data_values(sel_object_ref)
513+
error = result.failure
514+
access_data = result.success
515+
if access_data is not None:
516+
if access_data.present == Data.PRESENT.PR_visible_string:
517+
if not bool(access_data.visible_string):
518+
error = DataAccessError(
519+
DataAccessError.VALUES.V_object_non_existent
520+
)
521+
return error
522+
523+
def select_with_value(
524+
self,
525+
co: ControlObject,
526+
/,
527+
ctl_val: Any,
528+
oper_time: Timestamp | None = None,
529+
) -> DataAccessError | None:
530+
"""
531+
Perform the SelectWithValue operation (SBOw) for an enhanced control object.
532+
533+
Only supported for `ControlObject` instances with the
534+
``SBO_ENHANCED`` model. Writes the provided `ctl_val` and optional
535+
operation timestamp to the ``SBOw`` attribute.
536+
537+
.. versionadded:: 0.2.4
538+
539+
:param co: Control object to select with value.
540+
:type co: ControlObject
541+
:param ctl_val: Control value to write.
542+
:type ctl_val: Any
543+
:param oper_time: Optional timestamp for the operation.
544+
:type oper_time: Timestamp | None
545+
:return: Access error if selection fails, or None on success.
546+
:rtype: DataAccessError | None
547+
:raises ValueError: If the control object does not use ``SBO_ENHANCED``.
548+
:raises MMSServiceError: If the MMS write operation fails.
549+
"""
550+
if co.model != ControlModel.SBO_ENHANCED:
551+
raise ValueError("ControlObject without SBO model cannot be selected!")
552+
553+
sel_object_ref = co.ctrl_object_ref / "SBOw"
554+
data = co.get_operate_data(ctl_val, oper_time)
555+
try:
556+
return self.set_data_values(sel_object_ref, data)
557+
except MMSServiceError as error:
558+
mmspdu = error.response
559+
if mmspdu:
560+
self._handle_control_error(mmspdu)
561+
raise error
562+
563+
def operate(
564+
self, co: ControlObject, /, ctl_val: Any, oper_time: Timestamp | None = None
565+
):
566+
"""
567+
Execute a control operation on a `ControlObject`.
568+
569+
Writes the provided control value to the ``Oper`` attribute. Supports
570+
all models of control objects.
571+
572+
.. versionadded:: 0.2.4
573+
574+
:param co: Control object to operate.
575+
:type co: ControlObject
576+
:param ctl_val: Control value to write.
577+
:type ctl_val: Any
578+
:param oper_time: Optional timestamp for the operation.
579+
:type oper_time: Timestamp | None
580+
:raises MMSServiceError: If the MMS write operation fails.
581+
"""
582+
ref = co.ctrl_object_ref / "Oper"
583+
data = co.get_operate_data(ctl_val, oper_time)
584+
try:
585+
return self.set_data_values(ref, data)
586+
except MMSServiceError as error:
587+
mmspdu = error.response
588+
if mmspdu:
589+
self._handle_control_error(mmspdu)
590+
raise error
591+
592+
def cancel(
593+
self, co: ControlObject, /, ctl_val: Any, oper_time: Timestamp | None = None
594+
):
595+
"""
596+
Cancel a previously issued control operation.
597+
598+
Writes the control value to the ``Cancel`` attribute without
599+
performing interlock or synchrocheck.
600+
601+
.. versionadded:: 0.2.4
602+
603+
:param co: Control object to cancel.
604+
:type co: ControlObject
605+
:param ctl_val: Control value for cancellation.
606+
:type ctl_val: Any
607+
:param oper_time: Optional timestamp for the cancellation.
608+
:type oper_time: Timestamp | None
609+
:raises MMSServiceError: If the MMS write operation fails.
610+
"""
611+
ref = co.ctrl_object_ref / "Cancel"
612+
data = co.get_operate_data(ctl_val, oper_time, check=False)
613+
try:
614+
return self.set_data_values(ref, data)
615+
except MMSServiceError as error:
616+
mmspdu = error.response
617+
if mmspdu:
618+
self._handle_control_error(mmspdu)
619+
raise error
620+
621+
def await_command_termination(self, /) -> InformationReport:
622+
"""
623+
Block until a control command terminates and an unconfirmed report is received.
624+
625+
Continuously reads unconfirmed PDUs until an ``InformationReport`` is received.
626+
627+
.. versionadded:: 0.2.4
628+
629+
:return: The unconfirmed MMS InformationReport containing the control result.
630+
:rtype: InformationReport
631+
"""
632+
report = None
633+
while report is None:
634+
pdu: MMSpdu = self.mms_conn.presentation.recv_encoded_data()
635+
self._handle_control_error(pdu)
636+
if pdu.present != MMSpdu.PRESENT.PR_unconfirmed_PDU:
637+
continue
638+
639+
service = pdu.unconfirmed_PDU.service
640+
report = service.informationReport
641+
return report
642+
643+
# 20.11 AdditionalCauseDiagnosis in negative control service responses
644+
def _handle_control_error(self, mmspdu: MMSpdu, /):
645+
try:
646+
if mmspdu.present != MMSpdu.PRESENT.PR_unconfirmed_PDU:
647+
return
648+
649+
pdu = mmspdu.unconfirmed_PDU
650+
self._handle_unconfirmed_pdu(self.mms_conn, pdu.service)
651+
except AttributeError:
652+
pass
653+
654+
def _handle_unconfirmed_pdu(
655+
self, conn: MMS_Connection, service: UnconfirmedService
656+
):
657+
try:
658+
if service.present != UnconfirmedService.PRESENT.PR_informationReport:
659+
return
660+
661+
report = service.informationReport
662+
spec = report.variableAccessSpecification.listOfVariable[0]
663+
if spec.variableSpecification.name.vmd_specific.value != "LastApplError":
664+
return
665+
666+
appl_error = report.listOfAccessResult[0].success.structure
667+
ctrl_obj = appl_error[0].visible_string
668+
error = ControlError(appl_error[1].integer)
669+
# origin ignored
670+
ctl_num = appl_error[3].unsigned
671+
cause = Cause(appl_error[4].integer)
672+
raise LastApplError(
673+
ctrl_obj,
674+
error,
675+
ctl_num,
676+
cause,
677+
f"Failed to control {ctrl_obj} with error: {error.name}, cause: {cause.name}",
678+
)
679+
except AttributeError:
680+
pass

0 commit comments

Comments
 (0)