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/>.
1616import enum
17+ from typing_extensions import Any
1718
1819from 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
2028from 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
2845from 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