def test_can_send_action_request_in_ready(action_request: xdlms.ActionRequestNormal): c = DlmsConnection( state=state.DlmsConnectionState(current_state=state.READY), client_system_title=b"12345678", ) c.send(action_request) assert c.state.current_state == state.AWAITING_ACTION_RESPONSE
def test_can_release_in_ready_state(rlrq: acse.ReleaseRequest): c = DlmsConnection( state=state.DlmsConnectionState(current_state=state.READY), client_system_title=b"12345678", ) c.send(rlrq) assert c.state.current_state == state.AWAITING_RELEASE_RESPONSE
def test_cannot_re_associate(aarq: acse.ApplicationAssociationRequest): c = DlmsConnection( state=state.DlmsConnectionState(current_state=state.READY), client_system_title=b"12345678", ) with pytest.raises(LocalDlmsProtocolError): c.send(aarq)
def test_can_send_get_when_ready(get_request: xdlms.GetRequestNormal): c = DlmsConnection( state=state.DlmsConnectionState(current_state=state.READY), client_system_title=b"12345678", ) c.send(get_request) assert c.state.current_state == state.AWAITING_GET_RESPONSE
def test_rejection_resets_connection_state( connection_with_hls: DlmsConnection, ciphered_hls_aare: acse.ApplicationAssociationResponse, ): connection_with_hls.state.current_state = state.AWAITING_ASSOCIATION_RESPONSE ciphered_hls_aare.result = enumerations.AssociationResult.REJECTED_PERMANENT connection_with_hls.receive_data(ciphered_hls_aare.to_bytes()) connection_with_hls.next_event() assert connection_with_hls.state.current_state == state.NO_ASSOCIATION
def test_set_request_sets_state_in_waiting_for_set_response( set_request: xdlms.SetRequestNormal, ): c = DlmsConnection( state=state.DlmsConnectionState(current_state=state.READY), client_system_title=b"12345678", ) c.send(set_request) assert c.state.current_state == state.AWAITING_SET_RESPONSE
def test_hls_is_started_automatically( connection_with_hls: DlmsConnection, ciphered_hls_aare: acse.ApplicationAssociationResponseApdu, ): # Force state into awaiting response connection_with_hls.state.current_state = state.AWAITING_ASSOCIATION_RESPONSE connection_with_hls.receive_data(ciphered_hls_aare.to_bytes()) connection_with_hls.next_event() assert (connection_with_hls.state.current_state == state.SHOULD_SEND_HLS_SEVER_CHALLENGE_RESULT)
def test_not_able_to_send_aarq( self, aarq: acse.ApplicationAssociationRequestApdu): c = DlmsConnection.with_pre_established_association( conformance=Conformance()) with pytest.raises(LocalDlmsProtocolError): c.send(aarq)
def connection_with_hls(system_title, global_encryption_key, global_authentication_key) -> DlmsConnection: return DlmsConnection( client_system_title=system_title, authentication_method=enumerations.AuthenticationMechanism.HLS_GMAC, global_encryption_key=global_encryption_key, global_authentication_key=global_authentication_key, security_suite=0, )
def test_hls_fails(connection_with_hls: DlmsConnection): # Force state into awaiting response connection_with_hls.state.current_state = state.AWAITING_HLS_CLIENT_CHALLENGE_RESULT connection_with_hls.meter_system_title = b"12345678" connection_with_hls.meter_invocation_counter = 1 failing_action_response = xdlms.ActionResponseNormal( status=enumerations.ActionResultStatus.OTHER_REASON ) ciphered = security.encrypt( security_control=connection_with_hls.security_control, system_title=connection_with_hls.meter_system_title, auth_key=connection_with_hls.global_authentication_key, key=connection_with_hls.global_encryption_key, invocation_counter=2, plain_text=failing_action_response.to_bytes(), ) ciphered_action_response = xdlms.GeneralGlobalCipher( security_control=connection_with_hls.security_control, system_title=connection_with_hls.meter_system_title, invocation_counter=2, ciphered_text=ciphered, ) connection_with_hls.receive_data(ciphered_action_response.to_bytes()) connection_with_hls.next_event() assert connection_with_hls.state.current_state == state.NO_ASSOCIATION
def test_receive_get_response_sets_state_to_ready(): c = DlmsConnection( state=state.DlmsConnectionState(current_state=state.AWAITING_GET_RESPONSE), client_system_title=b"12345678", ) c.receive_data(b"\xc4\x01\xc1\x00\x06\x00\x00\x13\x91") c.next_event() assert c.state.current_state == state.READY
def test_receive_rlre_terminates_association(rlre: acse.ReleaseResponse): c = DlmsConnection( state=state.DlmsConnectionState(current_state=state.AWAITING_RELEASE_RESPONSE), client_system_title=b"12345678", ) c.receive_data(rlre.to_bytes()) c.next_event() assert c.state.current_state == state.NO_ASSOCIATION
def test_set_response_sets_state_in_ready(set_response: xdlms.SetResponseNormal): c = DlmsConnection( state=state.DlmsConnectionState(current_state=state.AWAITING_SET_RESPONSE), client_system_title=b"12345678", ) c.receive_data(set_response.to_bytes()) c.next_event() assert c.state.current_state == state.READY
def test_negotiated_conformance_is_updated(): c = DlmsConnection(client_system_title=b"12345678") c.send(c.get_aarq()) c.receive_data( acse.ApplicationAssociationResponseApdu( result=enumerations.AssociationResult.ACCEPTED, result_source_diagnostics=enumerations.AcseServiceUserDiagnostics. NULL, user_information=acse.UserInformation( content=xdlms.InitiateResponseApdu( negotiated_conformance=Conformance( general_protection=True, general_block_transfer=True), server_max_receive_pdu_size=500, )), ).to_bytes()) c.next_event() assert c.conformance.general_protection assert c.conformance.general_block_transfer assert c.max_pdu_size == 500 assert c.state.current_state == state.READY
def test_receive_exception_response_sets_state_to_ready( exception_response: xdlms.ExceptionResponse, ): c = DlmsConnection( state=state.DlmsConnectionState(current_state=state.AWAITING_GET_RESPONSE), client_system_title=b"12345678", ) c.receive_data(exception_response.to_bytes()) c.next_event() assert c.state.current_state == state.READY
def test_state_is_ready_in_init(self): c = DlmsConnection.with_pre_established_association( conformance=Conformance( priority_management_supported=True, attribute_0_supported_with_get=True, block_transfer_with_action=True, block_transfer_with_get_or_read=True, block_transfer_with_set_or_write=True, multiple_references=True, get=True, set=True, selective_access=True, event_notification=True, action=True, )) assert c.state.current_state == state.READY
def test_action_response_normal_sets_ready_when_awaiting_action_resoponse(): c = DlmsConnection( state=state.DlmsConnectionState( current_state=state.AWAITING_ACTION_RESPONSE), client_system_title=b"12345678", ) c.receive_data( xdlms.ActionResponseNormal( status=enumerations.ActionResultStatus.SUCCESS, invoke_id_and_priority=xdlms.InvokeIdAndPriority( invoke_id=1, confirmed=True, high_priority=True), ).to_bytes()) c.next_event() assert c.state.current_state == state.READY
def test_not_able_to_send_rlrq(self, rlrq: acse.ReleaseRequest): c = DlmsConnection.with_pre_established_association(conformance=Conformance()) with pytest.raises(exceptions.PreEstablishedAssociationError): c.send(rlrq)
def test_conformance_exists_on_simple_init(): c = DlmsConnection(client_system_title=b"12345678") assert c.conformance is not None assert not c.conformance.general_protection assert c.state.current_state == state.NO_ASSOCIATION
class DlmsClient: client_logical_address: int server_logical_address: int io_interface: DlmsIOInterface authentication_method: Optional[enumerations.AuthenticationMechanism] = attr.ib( default=None ) password: Optional[bytes] = attr.ib(default=None) encryption_key: Optional[bytes] = attr.ib(default=None) authentication_key: Optional[bytes] = attr.ib(default=None) security_suite: Optional[int] = attr.ib(default=0) dedicated_ciphering: bool = attr.ib(default=False) block_transfer: bool = attr.ib(default=False) max_pdu_size: int = attr.ib(default=65535) client_system_title: Optional[bytes] = attr.ib(default=None) client_initial_invocation_counter: int = attr.ib(default=0) meter_initial_invocation_counter: int = attr.ib(default=0) dlms_connection: DlmsConnection = attr.ib( default=attr.Factory( lambda self: DlmsConnection( client_system_title=self.client_system_title, authentication_method=self.authentication_method, password=self.password, global_encryption_key=self.encryption_key, global_authentication_key=self.authentication_key, use_dedicated_ciphering=self.dedicated_ciphering, use_block_transfer=self.block_transfer, security_suite=self.security_suite, max_pdu_size=self.max_pdu_size, client_invocation_counter=self.client_initial_invocation_counter, meter_invocation_counter=self.meter_initial_invocation_counter, ), takes_self=True, ) ) @classmethod def with_serial_hdlc_transport( cls, serial_port: str, client_logical_address: int, server_logical_address: int, server_physical_address: Optional[int], client_physical_address: Optional[int] = None, baud_rate: int = 9600, authentication_method: Optional[enumerations.AuthenticationMechanism] = None, password: Optional[bytes] = None, encryption_key: Optional[bytes] = None, authentication_key: Optional[bytes] = None, security_suite: Optional[int] = 0, dedicated_ciphering: bool = False, block_transfer: bool = False, max_pdu_size: int = 65535, client_system_title: Optional[bytes] = None, client_initial_invocation_counter: int = 0, meter_initial_invocation_counter: int = 0, ): serial_client = SerialHdlcTransport( client_logical_address=client_logical_address, client_physical_address=client_physical_address, server_logical_address=server_logical_address, server_physical_address=server_physical_address, serial_port=serial_port, serial_baud_rate=baud_rate, ) return cls( client_logical_address=client_logical_address, server_logical_address=server_logical_address, authentication_method=authentication_method, password=password, encryption_key=encryption_key, authentication_key=authentication_key, security_suite=security_suite, dedicated_ciphering=dedicated_ciphering, block_transfer=block_transfer, max_pdu_size=max_pdu_size, client_system_title=client_system_title, client_initial_invocation_counter=client_initial_invocation_counter, meter_initial_invocation_counter=meter_initial_invocation_counter, io_interface=serial_client, ) @classmethod def with_tcp_transport( cls, host: str, port: int, client_logical_address: int, server_logical_address: int, authentication_method: Optional[enumerations.AuthenticationMechanism] = None, password: Optional[bytes] = None, encryption_key: Optional[bytes] = None, authentication_key: Optional[bytes] = None, security_suite: Optional[int] = 0, dedicated_ciphering: bool = False, block_transfer: bool = False, max_pdu_size: int = 65535, client_system_title: Optional[bytes] = None, client_initial_invocation_counter: int = 0, meter_initial_invocation_counter: int = 0, ): tcp_transport = BlockingTcpTransport( host=host, port=port, client_logical_address=client_logical_address, server_logical_address=server_logical_address, ) return cls( client_logical_address=client_logical_address, server_logical_address=server_logical_address, authentication_method=authentication_method, password=password, encryption_key=encryption_key, authentication_key=authentication_key, security_suite=security_suite, dedicated_ciphering=dedicated_ciphering, block_transfer=block_transfer, max_pdu_size=max_pdu_size, client_system_title=client_system_title, client_initial_invocation_counter=client_initial_invocation_counter, meter_initial_invocation_counter=meter_initial_invocation_counter, io_interface=tcp_transport, ) @contextlib.contextmanager def session(self) -> "DlmsClient": self.connect() self.associate() yield self self.release_association() self.disconnect() def get( self, cosem_attribute: cosem.CosemAttribute, access_descriptor: Optional[RangeDescriptor] = None, ) -> bytes: self.send( xdlms.GetRequestNormal( cosem_attribute=cosem_attribute, access_selection=access_descriptor ) ) all_data_received = False data = bytearray() while not all_data_received: get_response = self.next_event() if isinstance(get_response, xdlms.GetResponseNormal): data.extend(get_response.data) all_data_received = True continue if isinstance(get_response, xdlms.GetResponseWithBlock): data.extend(get_response.data) self.send( xdlms.GetRequestNext( invoke_id_and_priority=get_response.invoke_id_and_priority, block_number=get_response.block_number, ) ) continue if isinstance(get_response, xdlms.GetResponseLastBlock): data.extend(get_response.data) all_data_received = True continue if isinstance(get_response, xdlms.GetResponseLastBlockWithError): raise DataResultError( f"Error in blocktransfer of GET response: {get_response.error!r}" ) if isinstance(get_response, xdlms.GetResponseNormalWithError): raise DataResultError( f"Could not perform GET request: {get_response.error!r}" ) return bytes(data) def set(self, cosem_attribute: cosem.CosemAttribute, data: bytes): self.send(xdlms.SetRequestNormal(cosem_attribute=cosem_attribute, data=data)) return self.next_event() def action(self, method: cosem.CosemMethod, data: bytes): self.send(xdlms.ActionRequestNormal(cosem_method=method, data=data)) response = self.next_event() if isinstance(response, xdlms.ActionResponseNormalWithError): raise ActionError(response.error.name) elif isinstance(response, xdlms.ActionResponseNormalWithData): if response.status != enumerations.ActionResultStatus.SUCCESS: raise ActionError(f"Unsuccessful ActionRequest: {response.status.name}") return response.data else: if response.status != enumerations.ActionResultStatus.SUCCESS: raise ActionError(f"Unsuccessful ActionRequest: {response.status.name}") return def associate( self, association_request: Optional[acse.ApplicationAssociationRequest] = None, ) -> acse.ApplicationAssociationResponse: # the aarq can be overridden or the standard one from the connection is used. aarq = association_request or self.dlms_connection.get_aarq() self.send(aarq) response = self.next_event() # we could have received an exception from the meter. if isinstance(response, xdlms.ExceptionResponse): raise exceptions.DlmsClientException( f"DLMS Exception: {response.state_error!r}:{response.service_error!r}" ) # the association might not be accepted by the meter if isinstance(response, acse.ApplicationAssociationResponse): if response.result is not enumerations.AssociationResult.ACCEPTED: # there could be an error suppled with the reject. extra_error = None if response.user_information: if isinstance( response.user_information.content, ConfirmedServiceError ): extra_error = response.user_information.content.error raise exceptions.DlmsClientException( f"Unable to perform Association: {response.result!r} and " f"{response.result_source_diagnostics!r}, extra info: {extra_error}" ) else: raise exceptions.LocalDlmsProtocolError( "Did not receive an AARE after sending AARQ" ) if self.should_send_hls_reply(): try: hls_response = self.send_hls_reply() except ActionError as e: raise HLSError from e hls_data = utils.parse_as_dlms_data(hls_response) if not hls_response: raise HLSError("Did not receive any HLS response data") if not self.dlms_connection.hls_response_valid(hls_data): raise HLSError( f"Meter did not respond with correct challenge calculation" ) return response def should_send_hls_reply(self) -> bool: return ( self.dlms_connection.state.current_state == state.SHOULD_SEND_HLS_SEVER_CHALLENGE_RESULT ) def send_hls_reply(self) -> Optional[bytes]: return self.action( method=cosem.CosemMethod( enumerations.CosemInterface.ASSOCIATION_LN, cosem.Obis(0, 0, 40, 0, 0), 1, ), data=dlms_data.OctetStringData( self.dlms_connection.get_hls_reply() ).to_bytes(), ) def release_association(self) -> acse.ReleaseResponse: rlrq = self.dlms_connection.get_rlrq() self.send(rlrq) rlre = self.next_event() return rlre def connect(self): self.io_interface.connect() def disconnect(self): self.io_interface.disconnect() def send(self, *events): for event in events: data = self.dlms_connection.send(event) response_bytes = self.io_interface.send(data) self.dlms_connection.receive_data(response_bytes) def next_event(self): event = self.dlms_connection.next_event() LOG.info(f"Received {event}") return event
def test_conformance_protection_is_set_when_passing_encryption_key(): c = DlmsConnection(global_encryption_key=b"1234", client_system_title=b"12345678") assert c.conformance.general_protection assert c.state.current_state == state.NO_ASSOCIATION