This is an automated email from the ASF dual-hosted git repository. hutcheb pushed a commit to branch plc4py/plc_field in repository https://gitbox.apache.org/repos/asf/plc4x.git
commit e502c9d2dae57cae56f28c0c9adab5afcf9c46e7 Author: Ben Hutcheson <[email protected]> AuthorDate: Sat Apr 23 07:55:50 2022 +1000 Updated the Mock Connection and changed it to a driver module --- sandbox/plc4py/plc4py/api/PlcConnection.py | 23 ++- sandbox/plc4py/plc4py/api/exceptions/exceptions.py | 4 + .../plc4py/plc4py/drivers/mock/MockConnection.py | 156 +++++++++++++++++++++ .../drivers/mock}/MockReadRequestBuilder.py | 0 .../exceptions.py => drivers/mock/__init__.py} | 9 -- .../exceptions.py => spi/messages/PlcReader.py} | 20 ++- sandbox/plc4py/setup.py | 3 +- sandbox/plc4py/tests/test_plc4py.py | 7 + .../tests/unit/plc4py/api/test/MockPlcConection.py | 83 ----------- .../tests/unit/plc4py/api/test_PlcRequest.py | 64 ++++++++- .../tests/unit/plc4py/test_PlcDriverManager.py | 4 +- 11 files changed, 265 insertions(+), 108 deletions(-) diff --git a/sandbox/plc4py/plc4py/api/PlcConnection.py b/sandbox/plc4py/plc4py/api/PlcConnection.py index 52c439ccc0..7f85ea0f88 100644 --- a/sandbox/plc4py/plc4py/api/PlcConnection.py +++ b/sandbox/plc4py/plc4py/api/PlcConnection.py @@ -16,12 +16,14 @@ # specific language governing permissions and limitations # under the License. # +import asyncio from abc import abstractmethod from typing import Awaitable -from plc4py.api.messages.PlcResponse import PlcResponse -from plc4py.api.messages.PlcRequest import ReadRequestBuilder +from plc4py.api.messages.PlcResponse import PlcResponse, PlcReadResponse +from plc4py.api.messages.PlcRequest import ReadRequestBuilder, PlcRequest from plc4py.api.exceptions.exceptions import PlcConnectionException +from plc4py.api.value.PlcValue import PlcResponseCode from plc4py.utils.GenericTypes import GenericGenerator @@ -60,10 +62,23 @@ class PlcConnection(GenericGenerator): pass @abstractmethod - def execute(self, PlcRequest) -> Awaitable[PlcResponse]: + def execute(self, request: PlcRequest) -> Awaitable[PlcResponse]: """ Executes a PlcRequest as long as it's already connected - :param PlcRequest: Plc Request to execute + :param request: Plc Request to execute :return: The response from the Plc/Device """ pass + + def _default_failed_request( + self, code: PlcResponseCode + ) -> Awaitable[PlcReadResponse]: + """ + Returns a default PlcResponse, mainly used in case of a failed request + :param code: The response code to return + :return: The PlcResponse + """ + loop = asyncio.get_running_loop() + future = loop.create_future() + future.set_result(PlcResponse(code)) + return future diff --git a/sandbox/plc4py/plc4py/api/exceptions/exceptions.py b/sandbox/plc4py/plc4py/api/exceptions/exceptions.py index 7a7eb4647b..45d3c86f2c 100644 --- a/sandbox/plc4py/plc4py/api/exceptions/exceptions.py +++ b/sandbox/plc4py/plc4py/api/exceptions/exceptions.py @@ -25,3 +25,7 @@ class PlcException(Exception): class PlcConnectionException(Exception): logging.error("Unable to establish a connection to the plc") + + +class PlcFieldParseException(Exception): + pass diff --git a/sandbox/plc4py/plc4py/drivers/mock/MockConnection.py b/sandbox/plc4py/plc4py/drivers/mock/MockConnection.py new file mode 100644 index 0000000000..82f91862df --- /dev/null +++ b/sandbox/plc4py/plc4py/drivers/mock/MockConnection.py @@ -0,0 +1,156 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# +import asyncio +import logging +from dataclasses import dataclass, field +from typing import Awaitable, Type + +import plc4py + +from plc4py.api.PlcConnection import PlcConnection +from plc4py.api.exceptions.exceptions import PlcFieldParseException +from plc4py.api.messages.PlcField import PlcField +from plc4py.api.messages.PlcRequest import ( + ReadRequestBuilder, + PlcReadRequest, + PlcRequest, +) +from plc4py.api.messages.PlcResponse import PlcReadResponse, PlcResponse +from plc4py.api.value.PlcValue import PlcResponseCode, PlcValue +from plc4py.drivers.PlcConnectionLoader import PlcConnectionLoader +from plc4py.spi.messages.PlcReader import PlcReader +from plc4py.spi.messages.utils.ResponseItem import ResponseItem +from plc4py.spi.values.PlcBOOL import PlcBOOL +from plc4py.spi.values.PlcINT import PlcINT +from plc4py.drivers.mock.MockReadRequestBuilder import MockReadRequestBuilder + + +@dataclass +class MockPlcField(PlcField): + datatype: str = "INT" + + +class MockPlcFieldHandler: + @staticmethod + def of(field: str) -> MockPlcField: + try: + datatype = field.split(":")[1] + return MockPlcField(field, datatype) + except IndexError: + raise PlcFieldParseException + + +class MockDevice: + def read(self, field) -> list[ResponseItem[PlcValue]]: + logging.debug(f"Reading field {field} from Mock Device") + plc_field = MockPlcFieldHandler.of(field) + if plc_field.datatype == "BOOL": + return [ResponseItem(PlcResponseCode.OK, PlcBOOL(True))] + elif plc_field.datatype == "INT": + return [ResponseItem(PlcResponseCode.OK, PlcINT(1))] + else: + raise PlcFieldParseException + + +@dataclass +class MockConnection(PlcConnection, PlcReader): + _is_connected: bool = False + device: MockDevice = field(default_factory=lambda: MockDevice()) + + def connect(self): + """ + Connect the Mock PLC connection + :return: + """ + self._is_connected = True + self.device = MockDevice() + + def is_connected(self) -> bool: + """ + Return the current status of the Mock PLC Connection + :return bool: True is connected + """ + return self._is_connected + + def close(self) -> None: + """ + Closes the connection to the remote PLC. + :return: + """ + self._is_connected = False + + def read_request_builder(self) -> ReadRequestBuilder: + """ + :return: read request builder. + """ + return MockReadRequestBuilder() + + def execute(self, request: PlcRequest) -> Awaitable[PlcResponse]: + """ + Executes a PlcRequest as long as it's already connected + :param PlcRequest: Plc Request to execute + :return: The response from the Plc/Device + """ + if not self.is_connected(): + return self._default_failed_request(PlcResponseCode.NOT_CONNECTED) + + if isinstance(request, PlcReadRequest): + return self._read(request) + + return self._default_failed_request(PlcResponseCode.NOT_CONNECTED) + + def _read(self, request: PlcReadRequest) -> Awaitable[PlcReadResponse]: + """ + Executes a PlcReadRequest + """ + if self.device is None: + logging.error("No device is set in the mock connection!") + return self._default_failed_request(PlcResponseCode.NOT_CONNECTED) + + loop = asyncio.get_running_loop() + logging.debug("Sending read request to MockDevice") + future = loop.create_future() + + async def _request(fut, req, device): + try: + response = PlcReadResponse( + PlcResponseCode.OK, + req.fields, + {field: device.read(field) for field in req.field_names}, + ) + fut.set_result(response) + except PlcFieldParseException: + fut.set_result( + PlcReadResponse(PlcResponseCode.INTERNAL_ERROR, req.fields, {}) + ) + + loop.create_task(_request(future, request, self.device)) + return future + + +class MockConnectionLoader(PlcConnectionLoader): + @staticmethod + @plc4py.hookimpl + def get_connection() -> Type[MockConnection]: + return MockConnection + + @staticmethod + @plc4py.hookimpl + def key() -> str: + return "mock" diff --git a/sandbox/plc4py/tests/unit/plc4py/api/test/MockReadRequestBuilder.py b/sandbox/plc4py/plc4py/drivers/mock/MockReadRequestBuilder.py similarity index 100% rename from sandbox/plc4py/tests/unit/plc4py/api/test/MockReadRequestBuilder.py rename to sandbox/plc4py/plc4py/drivers/mock/MockReadRequestBuilder.py diff --git a/sandbox/plc4py/plc4py/api/exceptions/exceptions.py b/sandbox/plc4py/plc4py/drivers/mock/__init__.py similarity index 82% copy from sandbox/plc4py/plc4py/api/exceptions/exceptions.py copy to sandbox/plc4py/plc4py/drivers/mock/__init__.py index 7a7eb4647b..585be9602f 100644 --- a/sandbox/plc4py/plc4py/api/exceptions/exceptions.py +++ b/sandbox/plc4py/plc4py/drivers/mock/__init__.py @@ -16,12 +16,3 @@ # specific language governing permissions and limitations # under the License. # -import logging - - -class PlcException(Exception): - pass - - -class PlcConnectionException(Exception): - logging.error("Unable to establish a connection to the plc") diff --git a/sandbox/plc4py/plc4py/api/exceptions/exceptions.py b/sandbox/plc4py/plc4py/spi/messages/PlcReader.py similarity index 58% copy from sandbox/plc4py/plc4py/api/exceptions/exceptions.py copy to sandbox/plc4py/plc4py/spi/messages/PlcReader.py index 7a7eb4647b..5f1b993e05 100644 --- a/sandbox/plc4py/plc4py/api/exceptions/exceptions.py +++ b/sandbox/plc4py/plc4py/spi/messages/PlcReader.py @@ -16,12 +16,22 @@ # specific language governing permissions and limitations # under the License. # -import logging +from typing import Awaitable -class PlcException(Exception): - pass +from plc4py.api.messages.PlcRequest import PlcReadRequest +from plc4py.api.messages.PlcResponse import PlcReadResponse -class PlcConnectionException(Exception): - logging.error("Unable to establish a connection to the plc") +class PlcReader: + """ + Interface implemented by all PlcConnections that are able to read from remote resources. + """ + + def _read(self, request: PlcReadRequest) -> Awaitable[PlcReadResponse]: + """ + Reads a requested value from a PLC + + :param request: object describing the type and location of the value + :return: Future, giving async access to the returned value + """ diff --git a/sandbox/plc4py/setup.py b/sandbox/plc4py/setup.py index 9bb84aa512..6acab18132 100644 --- a/sandbox/plc4py/setup.py +++ b/sandbox/plc4py/setup.py @@ -51,7 +51,8 @@ setup( }, entry_points={ "plc4py.drivers": [ - "modbus = plc4py.drivers.modbus.ModbusConnection:ModbusConnectionLoader" + "mock = plc4py.drivers.mock.MockConnection:MockConnectionLoader", + "modbus = plc4py.drivers.modbus.ModbusConnection:ModbusConnectionLoader", ] }, ) diff --git a/sandbox/plc4py/tests/test_plc4py.py b/sandbox/plc4py/tests/test_plc4py.py index 5427c832b5..dbf0d42fc1 100644 --- a/sandbox/plc4py/tests/test_plc4py.py +++ b/sandbox/plc4py/tests/test_plc4py.py @@ -20,6 +20,7 @@ from plc4py import __version__ from plc4py.PlcDriverManager import PlcDriverManager from plc4py.api.PlcConnection import PlcConnection +from plc4py.drivers.mock.MockConnection import MockConnection from plc4py.drivers.modbus.ModbusConnection import ModbusConnection @@ -37,3 +38,9 @@ def test_plc_driver_manager_init_modbus(): driver_manager = PlcDriverManager() with driver_manager.connection("modbus:tcp://127.0.0.1:502") as connection: assert isinstance(connection, ModbusConnection) + + +def test_plc_driver_manager_init_mock(): + driver_manager = PlcDriverManager() + with driver_manager.connection("mock:tcp://127.0.0.1:502") as connection: + assert isinstance(connection, MockConnection) diff --git a/sandbox/plc4py/tests/unit/plc4py/api/test/MockPlcConection.py b/sandbox/plc4py/tests/unit/plc4py/api/test/MockPlcConection.py deleted file mode 100644 index 0e52c49d2f..0000000000 --- a/sandbox/plc4py/tests/unit/plc4py/api/test/MockPlcConection.py +++ /dev/null @@ -1,83 +0,0 @@ -# -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# -import asyncio -from dataclasses import dataclass -from typing import Awaitable - -from plc4py.api.PlcConnection import PlcConnection -from plc4py.api.messages.PlcRequest import ReadRequestBuilder, PlcReadRequest -from plc4py.api.messages.PlcResponse import PlcReadResponse, PlcResponse -from plc4py.api.value.PlcValue import PlcResponseCode -from tests.unit.plc4py.api.test.MockReadRequestBuilder import MockReadRequestBuilder - - -@dataclass -class MockPlcConnection(PlcConnection): - _is_connected: bool = False - - def connect(self): - """ - Connect the Mock PLC connection - :return: - """ - self._is_connected = True - - def is_connected(self) -> bool: - """ - Return the current status of the Mock PLC Connection - :return bool: True is connected - """ - return self._is_connected - - def close(self) -> None: - """ - Closes the connection to the remote PLC. - :return: - """ - self._is_connected = False - - def read_request_builder(self) -> ReadRequestBuilder: - """ - :return: read request builder. - """ - return MockReadRequestBuilder() - - def _default_failed_request( - self, code: PlcResponseCode - ) -> Awaitable[PlcReadResponse]: - """ - Returns a default PlcResponse, mainly used in case of a failed request - :param code: The response code to return - :return: The PlcResponse - """ - loop = asyncio.get_running_loop() - fut = loop.create_future() - fut.set_result(PlcResponse(code)) - return fut - - def execute(self, request: PlcReadRequest) -> Awaitable[PlcReadResponse]: - """ - Executes a PlcRequest as long as it's already connected - :param PlcRequest: Plc Request to execute - :return: The response from the Plc/Device - """ - if not self.is_connected(): - return self._default_failed_request(PlcResponseCode.NOT_CONNECTED) - - return self._default_failed_request(PlcResponseCode.NOT_CONNECTED) diff --git a/sandbox/plc4py/tests/unit/plc4py/api/test_PlcRequest.py b/sandbox/plc4py/tests/unit/plc4py/api/test_PlcRequest.py index 2e15de85c0..97c499f350 100644 --- a/sandbox/plc4py/tests/unit/plc4py/api/test_PlcRequest.py +++ b/sandbox/plc4py/tests/unit/plc4py/api/test_PlcRequest.py @@ -16,6 +16,8 @@ # specific language governing permissions and limitations # under the License. # +from typing import cast + import pytest from plc4py.api.PlcConnection import PlcConnection @@ -28,7 +30,7 @@ from plc4py.api.value.PlcValue import PlcResponseCode from plc4py.spi.messages.utils.ResponseItem import ResponseItem from plc4py.spi.values.PlcBOOL import PlcBOOL from plc4py.spi.values.PlcINT import PlcINT -from tests.unit.plc4py.api.test.MockPlcConection import MockPlcConnection +from plc4py.drivers.mock.MockConnection import MockConnection def test_read_request_builder_empty_request(mocker) -> None: @@ -37,7 +39,7 @@ def test_read_request_builder_empty_request(mocker) -> None: :param mocker: :return: """ - connection: PlcConnection = MockPlcConnection() + connection: PlcConnection = MockConnection() # the connection function is supposed to support context manager # so using it in a with statement should result in close being called on the connection @@ -52,7 +54,7 @@ def test_read_request_builder_non_empty_request(mocker) -> None: :param mocker: :return: """ - connection: PlcConnection = MockPlcConnection() + connection: PlcConnection = MockConnection() # the connection function is supposed to support context manager # so using it in a with statement should result in close being called on the connection @@ -72,7 +74,7 @@ async def test_read_request_builder_non_empty_request_not_connected(mocker) -> N :param mocker: :return: """ - connection: PlcConnection = MockPlcConnection() + connection: PlcConnection = MockConnection() # the connection function is supposed to support context manager # so using it in a with statement should result in close being called on the connection @@ -85,6 +87,60 @@ async def test_read_request_builder_non_empty_request_not_connected(mocker) -> N assert response.code == PlcResponseCode.NOT_CONNECTED [email protected] +async def test_read_request_builder_non_empty_request_connected_bool(mocker) -> None: + """ + Create a request with a field and then confirm an non empty response gets returned with a OK code + :param mocker: + :return: + """ + connection: PlcConnection = MockConnection() + connection.connect() + field = "1:BOOL" + + # the connection function is supposed to support context manager + # so using it in a with statement should result in close being called on the connection + with connection.read_request_builder() as builder: + builder.add_item(field) + request: PlcFieldRequest = builder.build() + response: PlcReadResponse = cast( + PlcReadResponse, await connection.execute(request) + ) + + # verify that request has one field + assert response.code == PlcResponseCode.OK + + value: PlcBOOL = cast(PlcBOOL, response.values[field][0].value) + assert value.get_bool() + + [email protected] +async def test_read_request_builder_non_empty_request_connected_int(mocker) -> None: + """ + Create a request with a field and then confirm an non empty response gets returned with a OK code + :param mocker: + :return: + """ + connection: PlcConnection = MockConnection() + connection.connect() + field = "1:INT" + + # the connection function is supposed to support context manager + # so using it in a with statement should result in close being called on the connection + with connection.read_request_builder() as builder: + builder.add_item(field) + request: PlcFieldRequest = builder.build() + response: PlcReadResponse = cast( + PlcReadResponse, await connection.execute(request) + ) + + # verify that request has one field + assert response.code == PlcResponseCode.OK + + value: PlcINT = cast(PlcINT, response.values[field][0].value) + assert value.get_int() == 1 + + def test_read_response_boolean_response(mocker) -> None: """ Create a Plc Response with a boolean field, confirm that a boolean gets returned diff --git a/sandbox/plc4py/tests/unit/plc4py/test_PlcDriverManager.py b/sandbox/plc4py/tests/unit/plc4py/test_PlcDriverManager.py index a18b652f64..cf89c38792 100644 --- a/sandbox/plc4py/tests/unit/plc4py/test_PlcDriverManager.py +++ b/sandbox/plc4py/tests/unit/plc4py/test_PlcDriverManager.py @@ -19,7 +19,7 @@ from unittest.mock import MagicMock from plc4py.PlcDriverManager import PlcDriverManager -from tests.unit.plc4py.api.test.MockPlcConection import MockPlcConnection +from plc4py.drivers.mock.MockConnection import MockConnection def test_connection_context_manager_impl_close_called(mocker) -> None: @@ -27,7 +27,7 @@ def test_connection_context_manager_impl_close_called(mocker) -> None: # getup a plain return value for get_connection connection_mock: MagicMock = mocker.patch.object(manager, "get_connection") - connection_mock.return_value = MockPlcConnection() + connection_mock.return_value = MockConnection() # the connection function is supposed to support context manager # so using it in a with statement should result in close being called on the connection
