From 9a968b3b44ea1a07fff2da6f500838e8d31c5a32 Mon Sep 17 00:00:00 2001 From: Henrik Wahlgren Date: Mon, 26 Feb 2024 22:43:09 +0100 Subject: [PATCH 1/3] feat(connection): Added use_rlrq_rlre on DlmsConnectionSetting Some devices does not use the ReleaseRequest and ReleaseResponse to release an association and the lower layer can be disconnecter right away. Implementing the new setting, how it handled in the connection and in the client. Fixes #78 --- HISTORY.md | 2 ++ dlms_cosem/client.py | 33 +++++++++++++++++--------------- dlms_cosem/connection.py | 19 ++++++++++++++++-- dlms_cosem/exceptions.py | 7 +++++++ dlms_cosem/state.py | 9 +++++++++ tests/test_dlms_connection.py | 36 ++++++++++++++++++++--------------- 6 files changed, 74 insertions(+), 32 deletions(-) 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) From 8ed016dea1ac7ae115f61997042ac6083fc9e1ff Mon Sep 17 00:00:00 2001 From: Henrik Wahlgren Date: Mon, 26 Feb 2024 23:14:16 +0100 Subject: [PATCH 2/3] Added so code coverage runs on python 3.10 --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2f3f781..4f922de 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,7 +26,7 @@ jobs: - name: Submit coverage report to Codecov # only submit to Codecov once - if: ${{ matrix.python-version == 3.8 }} + if: ${{ matrix.python-version == 3.10 }} uses: codecov/codecov-action@v1.0.12 with: fail_ci_if_error: true From d149129db724b6f85cd2a42301ef4fdd5d71fa9b Mon Sep 17 00:00:00 2001 From: Henrik Wahlgren Date: Mon, 26 Feb 2024 23:29:08 +0100 Subject: [PATCH 3/3] Removed codecov in workflow --- .github/workflows/test.yml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4f922de..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.10 }} - 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