diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2f3f781..0bbfc86 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,7 +5,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.10", "3.9", "3.8", "3.7",] + python-version: ["3.11", "3.10", "3.9", "3.8", "3.7",] steps: - name: Checkout code @@ -24,9 +24,11 @@ jobs: run: | python -m pytest -v --cov=dlms_cosem - - name: Submit coverage report to Codecov - # only submit to Codecov once - if: ${{ matrix.python-version == 3.8 }} - uses: codecov/codecov-action@v1.0.12 - with: - fail_ci_if_error: true +# - name: Submit coverage report to Codecov +# # only submit to Codecov once +# if: ${{ matrix.python-version == 3.10 }} +# uses: codecov/codecov-action@v4 +# with: +# fail_ci_if_error: true +# token: ${{ secrets.CODECOV_TOKEN }} +# verbose: true diff --git a/HISTORY.md b/HISTORY.md index 43b9e4e..a68ef3d 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -10,6 +10,8 @@ and this project adheres to [Calendar Versioning](https://calver.org/) ### Added +* `use_rlrq_rlre` added to DlmsConnectionSettings. If `False` no ReleaseRequest is sent to server/device and lower + layer can be disconnected right away. ### Changed diff --git a/dlms_cosem/client.py b/dlms_cosem/client.py index cc64e32..b07a074 100644 --- a/dlms_cosem/client.py +++ b/dlms_cosem/client.py @@ -71,9 +71,9 @@ def session(self) -> "DlmsClient": self.disconnect() def get( - self, - cosem_attribute: cosem.CosemAttribute, - access_descriptor: Optional[RangeDescriptor] = None, + self, + cosem_attribute: cosem.CosemAttribute, + access_descriptor: Optional[RangeDescriptor] = None, ) -> bytes: self.send( xdlms.GetRequestNormal( @@ -115,7 +115,7 @@ def get( return bytes(data) def get_many( - self, cosem_attributes_with_selection: List[cosem.CosemAttributeWithSelection] + self, cosem_attributes_with_selection: List[cosem.CosemAttributeWithSelection] ): """ Make a GET.WITH_LIST call. Get many items in one request. @@ -153,8 +153,8 @@ def action(self, method: cosem.CosemMethod, data: bytes): return def associate( - self, - association_request: Optional[acse.ApplicationAssociationRequest] = None, + self, + association_request: Optional[acse.ApplicationAssociationRequest] = None, ) -> acse.ApplicationAssociationResponse: # the aarq can be overridden or the standard one from the connection is used. @@ -174,7 +174,7 @@ def associate( extra_error = None if response.user_information: if isinstance( - response.user_information.content, ConfirmedServiceError + response.user_information.content, ConfirmedServiceError ): extra_error = response.user_information.content.error raise exceptions.DlmsClientException( @@ -203,7 +203,7 @@ def associate( raise HLSError("Did not receive any HLS response data") if not self.dlms_connection.authentication.hls_meter_data_is_valid( - hls_data, self.dlms_connection + hls_data, self.dlms_connection ): raise HLSError( f"Meter did not respond with correct challenge calculation" @@ -213,8 +213,8 @@ def associate( def should_send_hls_reply(self) -> bool: return ( - self.dlms_connection.state.current_state - == state.SHOULD_SEND_HLS_SEVER_CHALLENGE_RESULT + self.dlms_connection.state.current_state + == state.SHOULD_SEND_HLS_SEVER_CHALLENGE_RESULT ) def send_hls_reply(self) -> Optional[bytes]: @@ -231,11 +231,15 @@ def send_hls_reply(self) -> Optional[bytes]: ).to_bytes(), ) - def release_association(self) -> acse.ReleaseResponse: + def release_association(self) -> Optional[acse.ReleaseResponse]: + rlrq = self.dlms_connection.get_rlrq() - self.send(rlrq) - rlre = self.next_event() - return rlre + try: + self.send(rlrq) + rlre = self.next_event() + return rlre + except exceptions.NoRlrqRlreError: + return None def connect(self): self.transport.connect() @@ -247,7 +251,6 @@ def send(self, *events): for event in events: data = self.dlms_connection.send(event) response_bytes = self.transport.send_request(data) - self.dlms_connection.receive_data(response_bytes) def next_event(self): diff --git a/dlms_cosem/connection.py b/dlms_cosem/connection.py index 9622fc4..f230544 100644 --- a/dlms_cosem/connection.py +++ b/dlms_cosem/connection.py @@ -109,11 +109,13 @@ class DlmsConnectionSettings: server implementations and manufacturers specific irregularity. """ + # If false there should be no RLRQ and RLRE to release an association. It is fine to disconnect the lower layer + # directly + use_rlrq_rlre: bool = attr.ib(default=True) # In Pietro Fiorentini local communication over HDLC the system title in GeneralGlobalCiphering is omitted. empty_system_title_in_general_glo_ciphering: bool = attr.ib(default=False) - @attr.s(auto_attribs=True) class DlmsConnection: """ @@ -274,6 +276,17 @@ def send(self, event) -> bytes: f"pre-established " ) + if not self.settings.use_rlrq_rlre and isinstance(event, acse.ReleaseRequest): + # Client has issued a release request but the connection is not using them + # Connection states goes to NO_ASSOCIATION directly + + LOG.info("Stopped ReleaseRequest as settings.use_rlrq_rlre is False. Ending association", + settings=self.settings, rlrq=event) + self.state.process_event(dlms_state.EndAssociation()) + raise exceptions.NoRlrqRlreError( + "Connection settings does not allow ReleaseRequest and ReleaseResponse" + ) + self.state.process_event(event) LOG.debug(f"Preparing to send DLMS Request", request=event) @@ -314,6 +327,7 @@ def next_event(self): the IP wrapper element so it is possible to can keep on trying until all data is received. """ + apdu = XDlmsApduFactory.apdu_from_bytes(self.buffer) LOG.info("Received DLMS Response", response=apdu) @@ -572,8 +586,9 @@ def get_aarq(self) -> acse.ApplicationAssociationRequest: def get_rlrq(self) -> acse.ReleaseRequest: """ - Returns a ReleaseRequestApdu to release the current association. + Returns a ReleaseRequestApdu to release the current association if one should be used. """ + initiate_request = xdlms.InitiateRequest( proposed_conformance=self.conformance, client_max_receive_pdu_size=self.max_pdu_size, diff --git a/dlms_cosem/exceptions.py b/dlms_cosem/exceptions.py index e7d4dce..ec79c3d 100644 --- a/dlms_cosem/exceptions.py +++ b/dlms_cosem/exceptions.py @@ -36,3 +36,10 @@ class DecryptionError(CryptographyError): because the ciphertext has changed or that the key, nonce or associated data is wrong """ + + +class NoRlrqRlreError(Exception): + """ + Is raised from connection when a ReleaseRequest is issued on a connection that has use_rlrq_rlre==False + Control for client to just skip Release and disconnect the lower layer. + """ \ No newline at end of file diff --git a/dlms_cosem/state.py b/dlms_cosem/state.py index 361258f..4be1bad 100644 --- a/dlms_cosem/state.py +++ b/dlms_cosem/state.py @@ -48,6 +48,14 @@ class RejectAssociation: pass +@attr.s() +class EndAssociation: + """ + Is used when settings.use_rlrq_rlre == False to send the state to NO_ASSOCIATION + """ + pass + + def make_sentinel(name): cls = _SentinelBase(name, (_SentinelBase,), {}) cls.__class__ = cls @@ -94,6 +102,7 @@ def make_sentinel(name): RejectAssociation: NO_ASSOCIATION, xdlms.ActionRequestNormal: AWAITING_ACTION_RESPONSE, xdlms.DataNotification: READY, + EndAssociation: NO_ASSOCIATION, }, SHOULD_SEND_HLS_SEVER_CHALLENGE_RESULT: { xdlms.ActionRequestNormal: AWAITING_HLS_CLIENT_CHALLENGE_RESULT diff --git a/tests/test_dlms_connection.py b/tests/test_dlms_connection.py index 79c9770..6914e0d 100644 --- a/tests/test_dlms_connection.py +++ b/tests/test_dlms_connection.py @@ -66,6 +66,7 @@ def test_settings_exists_on_simple_init(): ) assert c.settings is not None + def test_settings_empty_system_title_in_general_glo_cipher_false(get_request: xdlms.GetRequestNormal): """ Make sure that system_title is is used when protecting APDUs with default connection settings. @@ -167,7 +168,7 @@ def test_receive_get_response_sets_state_to_ready(): def test_set_request_sets_state_in_waiting_for_set_response( - set_request: xdlms.SetRequestNormal, + set_request: xdlms.SetRequestNormal, ): c = DlmsConnection( state=state.DlmsConnectionState(current_state=state.READY), @@ -203,7 +204,6 @@ def test_can_send_action_request_in_ready(action_request: xdlms.ActionRequestNor 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", @@ -223,7 +223,6 @@ def test_action_response_normal_sets_ready_when_awaiting_action_resoponse(): def test_action_response_normal_with_error_sets_ready_when_awaiting_action_resoponse(): - c = DlmsConnection( state=state.DlmsConnectionState(current_state=state.AWAITING_ACTION_RESPONSE), client_system_title=b"12345678", @@ -244,7 +243,6 @@ def test_action_response_normal_with_error_sets_ready_when_awaiting_action_resop def test_action_response_normal_with_data_sets_ready_when_awaiting_action_resoponse(): - c = DlmsConnection( state=state.DlmsConnectionState(current_state=state.AWAITING_ACTION_RESPONSE), client_system_title=b"12345678", @@ -265,7 +263,7 @@ def test_action_response_normal_with_data_sets_ready_when_awaiting_action_resopo def test_receive_exception_response_sets_state_to_ready( - exception_response: xdlms.ExceptionResponse, + exception_response: xdlms.ExceptionResponse, ): c = DlmsConnection( state=state.DlmsConnectionState(current_state=state.AWAITING_GET_RESPONSE), @@ -278,16 +276,16 @@ def test_receive_exception_response_sets_state_to_ready( def test_hls_is_started_automatically( - connection_with_hls: DlmsConnection, - ciphered_hls_aare: acse.ApplicationAssociationResponse, + connection_with_hls: DlmsConnection, + ciphered_hls_aare: acse.ApplicationAssociationResponse, ): # 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 + connection_with_hls.state.current_state + == state.SHOULD_SEND_HLS_SEVER_CHALLENGE_RESULT ) @@ -319,8 +317,8 @@ def test_hls_fails(connection_with_hls: DlmsConnection): def test_rejection_resets_connection_state( - connection_with_hls: DlmsConnection, - ciphered_hls_aare: acse.ApplicationAssociationResponse, + 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 @@ -329,10 +327,19 @@ def test_rejection_resets_connection_state( assert connection_with_hls.state.current_state == state.NO_ASSOCIATION -# what happens if the gmac provided by the meter is wrong -# -> we get an error +def test_rlrq_raises_norlrqrlreerror_when_settings_use_rlrq_rlre_is_false(): + settings = DlmsConnectionSettings(use_rlrq_rlre=False) + c = DlmsConnection( + state=state.DlmsConnectionState(current_state=state.READY), + client_system_title=b"12345678", + authentication=NoSecurityAuthentication(), + settings=settings + ) + rlrq = c.get_rlrq() + with pytest.raises(exceptions.NoRlrqRlreError): + c.send(rlrq) -# what happens if the gmac provided by the client is wrong + assert c.state.current_state == state.NO_ASSOCIATION class TestPreEstablishedAssociation: @@ -387,7 +394,6 @@ def test_hls_gmac_returns_correct_bytes(self): assert type(challenge) == bytes def test_too_short_length_raises_value_error(self): - with pytest.raises(ValueError): make_client_to_server_challenge(7)