diff --git a/services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3.py b/deprecated/OldDnp3/OldDnp3Driver/PlatformDriverAgent/platform_driver/interfaces/dnp3.py similarity index 99% rename from services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3.py rename to deprecated/OldDnp3/OldDnp3Driver/PlatformDriverAgent/platform_driver/interfaces/dnp3.py index 93e1690465..90334d57ed 100644 --- a/services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3.py +++ b/deprecated/OldDnp3/OldDnp3Driver/PlatformDriverAgent/platform_driver/interfaces/dnp3.py @@ -35,7 +35,7 @@ from datetime import datetime, timedelta import logging -from . import BaseInterface, BaseRegister, BasicRevert +from services.core.PlatformDriverAgent.platform_driver.interfaces import BaseInterface, BaseRegister, BasicRevert _log = logging.getLogger(__name__) type_mapping = {"string": str, diff --git a/services/core/PlatformDriverAgent/tests/test_dnp3_driver.py b/deprecated/OldDnp3/OldDnp3Driver/PlatformDriverAgent/tests/test_dnp3_driver.py similarity index 100% rename from services/core/PlatformDriverAgent/tests/test_dnp3_driver.py rename to deprecated/OldDnp3/OldDnp3Driver/PlatformDriverAgent/tests/test_dnp3_driver.py diff --git a/deprecated/OldDnp3/OldDnp3Driver/dnp3-driver.rst b/deprecated/OldDnp3/OldDnp3Driver/dnp3-driver.rst new file mode 100644 index 0000000000..d35c51e056 --- /dev/null +++ b/deprecated/OldDnp3/OldDnp3Driver/dnp3-driver.rst @@ -0,0 +1,89 @@ +.. _DNP3-Driver: + +=========== +DNP3 Driver +=========== + +VOLTTRON's DNP3 driver enables the use of `DNP3 `_ (Distributed Network Protocol) +communications, reading and writing points via a DNP3 Outstation. + +In order to use a DNP3 driver to read and write point data, VOLTTRON's DNP3 Agent must also +be configured and running. All communication between the VOLTTRON Outstation and a +DNP3 Master happens through the DNP3 Agent. + +For information about the DNP3 Agent, please see the :ref:`DNP3 Platform Specification `. + + +Requirements +============ + +The DNP3 driver requires the PyDNP3 package. This package can be installed in an activated environment with: + +.. code-block:: bash + + pip install pydnp3 + + +Driver Configuration +==================== + +There is one argument for the "driver_config" section of the DNP3 driver configuration file: + + - **dnp3_agent_id** - ID of VOLTTRON's DNP3Agent. + +Here is a sample DNP3 driver configuration file: + +.. code-block:: json + + { + "driver_config": { + "dnp3_agent_id": "dnp3agent" + }, + "campus": "campus", + "building": "building", + "unit": "dnp3", + "driver_type": "dnp3", + "registry_config": "config://dnp3.csv", + "interval": 15, + "timezone": "US/Pacific", + "heart_beat_point": "Heartbeat" + } + +A sample DNP3 driver configuration file can be found in the VOLTTRON repository +in ``services/core/PlatformDriverAgent/example_configurations/test_dnp3.config``. + + +DNP3 Registry Configuration File +================================ + +The driver's registry configuration file, a `CSV `_ file, +specifies which DNP3 points the driver will read and/or write. Each row configures a single DNP3 point. + +The following columns are required for each row: + + - **Volttron Point Name** - The name used by the VOLTTRON platform and agents to refer to the point. + - **Group** - The point's DNP3 group number. + - **Index** - The point's index number within its DNP3 data type (which is derived from its DNP3 group number). + - **Scaling** - A factor by which to multiply point values. + - **Units** - Point value units. + - **Writable** - TRUE or FALSE, indicating whether the point can be written by the driver (FALSE = read-only). + +Consult the **DNP3 data dictionary** for a point's Group and Index values. Point +definitions in the data dictionary are by agreement between the DNP3 Outstation and Master. +The VOLTTRON DNP3Agent loads the data dictionary of point definitions from the JSON file +at "point_definitions_path" in the DNP3Agent's config file. + +A sample data dictionary is available in ``services/core/DNP3Agent/dnp3/mesa_points.config``. + +Point definitions in the DNP3 driver's registry should look something like this: + +.. csv-table:: DNP3 + :header: Volttron Point Name,Group,Index,Scaling,Units,Writable + + DCHD.WTgt,41,65,1.0,NA,FALSE + DCHD.WTgt-In,30,90,1.0,NA,TRUE + DCHD.WinTms,41,66,1.0,NA,FALSE + DCHD.RmpTms,41,67,1.0,NA,FALSE + +A sample DNP3 driver registry configuration file is available +in ``services/core/PlatformDriverAgent/example_configurations/dnp3.csv``. diff --git a/deprecated/OldDnp3/OldDnp3Driverexamples/configurations/drivers/dnp3.csv b/deprecated/OldDnp3/OldDnp3Driverexamples/configurations/drivers/dnp3.csv new file mode 100644 index 0000000000..571d2e9a8e --- /dev/null +++ b/deprecated/OldDnp3/OldDnp3Driverexamples/configurations/drivers/dnp3.csv @@ -0,0 +1,13 @@ +Volttron Point Name,Group,Index,Scaling,Units,Writable +DCHD.WTgt,41,65,1.0,NA,FALSE +DCHD.WTgt-In,30,90,1.0,NA,TRUE +DCHD.WinTms,41,66,1.0,NA,FALSE +DCHD.RmpTms,41,67,1.0,NA,FALSE +DCHD.RevtTms,41,68,1.0,NA,FALSE +DCHD.RmpUpRte,41,69,1.0,NA,FALSE +DCHD.RmpDnRte,41,70,1.0,NA,FALSE +DCHD.ChaRmpUpRte,41,71,1.0,NA,FALSE +DCHD.ChaRmpDnRte,41,72,1.0,NA,FALSE +DCHD.ModPrty,41,9,1.0,NA,FALSE +DCHD.VArAct,41,10,1.0,NA,FALSE +DCHD.ModEna,12,5,1.0,NA,FALSE diff --git a/deprecated/OldDnp3/OldDnp3Driverexamples/configurations/drivers/test_dnp3.config b/deprecated/OldDnp3/OldDnp3Driverexamples/configurations/drivers/test_dnp3.config new file mode 100644 index 0000000000..7296eae4bc --- /dev/null +++ b/deprecated/OldDnp3/OldDnp3Driverexamples/configurations/drivers/test_dnp3.config @@ -0,0 +1,13 @@ +{ + "driver_config": { + "dnp3_agent_id": "dnp3agent" + }, + "campus": "campus", + "building": "building", + "unit": "dnp3", + "driver_type": "dnp3", + "registry_config": "config://dnp3.csv", + "interval": 15, + "timezone": "US/Pacific", + "heart_beat_point": "Heartbeat" +} \ No newline at end of file diff --git a/docs/source/agent-framework/driver-framework/dnp3-driver/dnp3-driver.rst b/docs/source/agent-framework/driver-framework/dnp3-driver/dnp3-driver.rst index d35c51e056..8aa7b03839 100644 --- a/docs/source/agent-framework/driver-framework/dnp3-driver/dnp3-driver.rst +++ b/docs/source/agent-framework/driver-framework/dnp3-driver/dnp3-driver.rst @@ -7,21 +7,18 @@ DNP3 Driver VOLTTRON's DNP3 driver enables the use of `DNP3 `_ (Distributed Network Protocol) communications, reading and writing points via a DNP3 Outstation. -In order to use a DNP3 driver to read and write point data, VOLTTRON's DNP3 Agent must also -be configured and running. All communication between the VOLTTRON Outstation and a -DNP3 Master happens through the DNP3 Agent. - -For information about the DNP3 Agent, please see the :ref:`DNP3 Platform Specification `. - +In order to use a DNP3 driver to read and write point data, a server component (i.e., Outstation) must also +be configured and running. Requirements ============ -The DNP3 driver requires the PyDNP3 package. This package can be installed in an activated environment with: +The DNP3 driver requires the `dnp3-python `_ package, a wrapper on Pydnp3 package. +This package can be installed in an activated environment with: .. code-block:: bash - pip install pydnp3 + pip install dnp3-python Driver Configuration @@ -29,24 +26,23 @@ Driver Configuration There is one argument for the "driver_config" section of the DNP3 driver configuration file: - - **dnp3_agent_id** - ID of VOLTTRON's DNP3Agent. - Here is a sample DNP3 driver configuration file: .. code-block:: json { - "driver_config": { - "dnp3_agent_id": "dnp3agent" - }, - "campus": "campus", - "building": "building", - "unit": "dnp3", - "driver_type": "dnp3", - "registry_config": "config://dnp3.csv", - "interval": 15, - "timezone": "US/Pacific", - "heart_beat_point": "Heartbeat" + "driver_config": {"master_ip": "0.0.0.0", "outstation_ip": "127.0.0.1", + "master_id": 2, "outstation_id": 1, + "port": 20000}, + "registry_config":"config://udd-Dnp3.csv", + "driver_type": "udd_dnp3", + "interval": 5, + "timezone": "UTC", + "campus": "campus-vm", + "building": "building-vm", + "unit": "Dnp3", + "publish_depth_first_all": true, + "heart_beat_point": "random_bool" } A sample DNP3 driver configuration file can be found in the VOLTTRON repository @@ -63,6 +59,7 @@ The following columns are required for each row: - **Volttron Point Name** - The name used by the VOLTTRON platform and agents to refer to the point. - **Group** - The point's DNP3 group number. + - **Variation** - THe permit negotiated exchange of data formatted, i.e., data type. - **Index** - The point's index number within its DNP3 data type (which is derived from its DNP3 group number). - **Scaling** - A factor by which to multiply point values. - **Units** - Point value units. @@ -78,12 +75,16 @@ A sample data dictionary is available in ``services/core/DNP3Agent/dnp3/mesa_poi Point definitions in the DNP3 driver's registry should look something like this: .. csv-table:: DNP3 - :header: Volttron Point Name,Group,Index,Scaling,Units,Writable + :header: Point Name,Volttron Point Name,Group,Variation,Index,Scaling,Units,Writable,Notes + + AnalogInput_index0,AnalogInput_index0,30,6,0,1,NA,FALSE,Double Analogue input without status + BinaryInput_index0,BinaryInput_index0,1,2,0,1,NA,FALSE,Single bit binary input with status + AnalogOutput_index0,AnalogOutput_index0,40,4,0,1,NA,TRUE,Double-precision floating point with flags + BinaryOutput_index0,BinaryOutput_index0,10,2,0,1,NA,TRUE,Binary Output with flags - DCHD.WTgt,41,65,1.0,NA,FALSE - DCHD.WTgt-In,30,90,1.0,NA,TRUE - DCHD.WinTms,41,66,1.0,NA,FALSE - DCHD.RmpTms,41,67,1.0,NA,FALSE A sample DNP3 driver registry configuration file is available in ``services/core/PlatformDriverAgent/example_configurations/dnp3.csv``. + +For more information about Group Variation definition, please refer to `dnp3.Variation +`_. diff --git a/examples/configurations/drivers/dnp3.csv b/examples/configurations/drivers/dnp3.csv index 571d2e9a8e..e71b832b3d 100644 --- a/examples/configurations/drivers/dnp3.csv +++ b/examples/configurations/drivers/dnp3.csv @@ -1,13 +1,17 @@ -Volttron Point Name,Group,Index,Scaling,Units,Writable -DCHD.WTgt,41,65,1.0,NA,FALSE -DCHD.WTgt-In,30,90,1.0,NA,TRUE -DCHD.WinTms,41,66,1.0,NA,FALSE -DCHD.RmpTms,41,67,1.0,NA,FALSE -DCHD.RevtTms,41,68,1.0,NA,FALSE -DCHD.RmpUpRte,41,69,1.0,NA,FALSE -DCHD.RmpDnRte,41,70,1.0,NA,FALSE -DCHD.ChaRmpUpRte,41,71,1.0,NA,FALSE -DCHD.ChaRmpDnRte,41,72,1.0,NA,FALSE -DCHD.ModPrty,41,9,1.0,NA,FALSE -DCHD.VArAct,41,10,1.0,NA,FALSE -DCHD.ModEna,12,5,1.0,NA,FALSE +Point Name,Volttron Point Name,Group,Variation,Index,Scaling,Units,Writable,Notes +AnalogInput_index0,AnalogInput_index0,30,6,0,1,NA,FALSE,Double Analogue input without status +AnalogInput_index1,AnalogInput_index1,30,6,1,1,NA,FALSE,Double Analogue input without status +AnalogInput_index2,AnalogInput_index2,30,6,2,1,NA,FALSE,Double Analogue input without status +AnalogInput_index3,AnalogInput_index3,30,6,3,1,NA,FALSE,Double Analogue input without status +BinaryInput_index0,BinaryInput_index0,1,2,0,1,NA,FALSE,Single bit binary input with status +BinaryInput_index1,BinaryInput_index1,1,2,1,1,NA,FALSE,Single bit binary input with status +BinaryInput_index2,BinaryInput_index2,1,2,2,1,NA,FALSE,Single bit binary input with status +BinaryInput_index3,BinaryInput_index3,1,2,3,1,NA,FALSE,Single bit binary input with status +AnalogOutput_index0,AnalogOutput_index0,40,4,0,1,NA,TRUE,Double-precision floating point with flags +AnalogOutput_index1,AnalogOutput_index1,40,4,1,1,NA,TRUE,Double-precision floating point with flags +AnalogOutput_index2,AnalogOutput_index2,40,4,2,1,NA,TRUE,Double-precision floating point with flags +AnalogOutput_index3,AnalogOutput_index3,40,4,3,1,NA,TRUE,Double-precision floating point with flags +BinaryOutput_index0,BinaryOutput_index0,10,2,0,1,NA,TRUE,Binary Output with flags +BinaryOutput_index1,BinaryOutput_index1,10,2,1,1,NA,TRUE,Binary Output with flags +BinaryOutput_index2,BinaryOutput_index2,10,2,2,1,NA,TRUE,Binary Output with flags +BinaryOutput_index3,BinaryOutput_index3,10,2,3,1,NA,TRUE,Binary Output with flags diff --git a/examples/configurations/drivers/test_dnp3.config b/examples/configurations/drivers/test_dnp3.config index 7296eae4bc..3f66b07e9f 100644 --- a/examples/configurations/drivers/test_dnp3.config +++ b/examples/configurations/drivers/test_dnp3.config @@ -1,13 +1,14 @@ { - "driver_config": { - "dnp3_agent_id": "dnp3agent" - }, - "campus": "campus", - "building": "building", - "unit": "dnp3", - "driver_type": "dnp3", - "registry_config": "config://dnp3.csv", - "interval": 15, - "timezone": "US/Pacific", - "heart_beat_point": "Heartbeat" -} \ No newline at end of file + "driver_config": {"master_ip": "0.0.0.0", "outstation_ip": "127.0.0.1", + "master_id": 2, "outstation_id": 1, + "port": 20000}, + "registry_config":"config://udd-Dnp3.csv", + "driver_type": "udd_dnp3", + "interval": 5, + "timezone": "UTC", + "campus": "campus-vm", + "building": "building-vm", + "unit": "Dnp3", + "publish_depth_first_all": true, + "heart_beat_point": "random_bool" +} diff --git a/scripts/dnp3/get_point_demo.py b/scripts/dnp3/get_point_demo.py new file mode 100644 index 0000000000..bb1e090383 --- /dev/null +++ b/scripts/dnp3/get_point_demo.py @@ -0,0 +1,41 @@ +""" +A demo to test dnp3-driver get_point method using rpc call. + +Pre-requisite: +- install platform-driver +- configure dnp3-driver +- a dnp3 outstation/server is up and running +- platform-driver is up and running +""" + +import random +from volttron.platform.vip.agent.utils import build_agent +from time import sleep +import datetime + + +def main(): + a = build_agent() + while True: + sleep(5) + print("============") + try: + rpc_method = "get_point" + device_name = "campus-vm/building-vm/Dnp3" + + reg_pt_name = "AnalogInput_index0" + rs = a.vip.rpc.call("platform.driver", rpc_method, + device_name, + reg_pt_name).get(timeout=10) + print(datetime.datetime.now(), "point_name: ", reg_pt_name, "value: ", rs) + reg_pt_name = "AnalogInput_index1" + rs = a.vip.rpc.call("platform.driver", rpc_method, + device_name, + reg_pt_name).get(timeout=10) + print(datetime.datetime.now(), "point_name: ", reg_pt_name, "value: ", rs) + except Exception as e: + print(e) + + +if __name__ == "__main__": + main() diff --git a/scripts/dnp3/set_point_demo.py b/scripts/dnp3/set_point_demo.py new file mode 100644 index 0000000000..4f8de9115f --- /dev/null +++ b/scripts/dnp3/set_point_demo.py @@ -0,0 +1,52 @@ +""" +A demo to test dnp3-driver set_point method using rpc call. + +Pre-requisite: +- install platform-driver +- configure dnp3-driver +- a dnp3 outstation/server is up and running +- platform-driver is up and running +""" + +import random +from volttron.platform.vip.agent.utils import build_agent +from time import sleep +import datetime + + +def main(): + a = build_agent() + while True: + sleep(5) + print("============") + try: + rpc_method = "set_point" + device_name = "campus-vm/building-vm/Dnp3" + + for i in range(3): + reg_pt_name = "AnalogOutput_index" + str(i) + val_to_set = random.random() + rs = a.vip.rpc.call("platform.driver", rpc_method, + device_name, + reg_pt_name, + val_to_set).get(timeout=10) + print(datetime.datetime.now(), "point_name: ", reg_pt_name, "response: ", rs) + + # verify + sleep(1) + + for i in range(3): + rpc_method = "get_point" + reg_pt_name = "AnalogOutput_index" + str(i) + # val_to_set = random.random() + rs = a.vip.rpc.call("platform.driver", rpc_method, + device_name, + reg_pt_name + ).get(timeout=10) + print(datetime.datetime.now(), "point_name: ", reg_pt_name, "response: ", rs) + except Exception as e: + print(e) + + +if __name__ == "__main__": + main() diff --git a/services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/__init__.py b/services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/__init__.py new file mode 100644 index 0000000000..91b2cd5813 --- /dev/null +++ b/services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/__init__.py @@ -0,0 +1 @@ +from .dnp3 import * diff --git a/services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/dnp3-driver.md b/services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/dnp3-driver.md new file mode 100644 index 0000000000..3771c0c5b6 --- /dev/null +++ b/services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/dnp3-driver.md @@ -0,0 +1,469 @@ +# DNP3 Driver + +Distributed Network Protocol (DNP +or [DNP3](https://en.wikipedia.org/wiki/DNP3)) +has achieved a large-scale acceptance since its introduction in 1993. This +protocol is an immediately deployable solution for monitoring remote sites because it was developed for communication of +critical infrastructure status, allowing for reliable remote control. + +DNP3 is typically used between centrally located masters and distributed remotes. The master provides the interface +between the human network manager and the monitoring system. The remote (RTUs and intelligent electronic devices) +provides the interface between the master and the physical device(s) being monitored and/or controlled. +The DNP3-Driver is a wrapper on the DNP3 master following +the [VOLTTRON driver framework](https://volttron.readthedocs.io/en/develop/agent-framework/driver-framework/drivers-overview.html#driver-framework). + +Note that the DNP3-Driver requires a DNP3 outstation instance to properly function. e.g., polling data, setting point +values, etc. The [dnp3-python](https://github.com/VOLTTRON/dnp3-python) can provide the essential outstation +functionality, and as part of the DNP3-Driver dependency, it is immediately available after the DNP3-Driver is +installed. + +### Requirements + +The DNP3 driver requires the [dnp3-python](https://github.com/VOLTTRON/dnp3-python) package, a wrapper on Pydnp3 +package. +This package can be installed in an activated environment with: + + pip install dnp3-python==0.2.3b3 + +### Quick Start + +The following recipe walks through the steps to install and configure a DNP3 Driver. Note that it uses default setup to +work out-of-the-box. Please feel free to refer to related documentations for details. + +1. Install volttron and start the platform. + + Refer + to [VOLTTRON Quick Start](https://volttron.readthedocs.io/en/main/tutorials/quick-start.html#volttron-quick-start) to + install the platform. + Then start the platform with the following command. (Please see `volttron --help` for more details.) + + ```shell + # Start platform with output going to volttron.log + volttron -vv -l volttron.log & + ``` + +1. Install the volttron platform driver: + + Install the required dependency for driver using `python bootstrap.py --drivers`. (Please + see `python bootstrap.py --help` for more details.) + + Note: for reproducibility, this demo will install platform driver with `vip-identity==platform_driver_for_dnp3`. + Free feel to specify any agent vip-identity as desired. + + ```shell + vctl install services/core/PlatformDriverAgent/ \ + --vip-identity platform_driver_for_dnp3 \ + --agent-config services/core/PlatformDriverAgent/platform-driver.agent \ + --tag platform_driver_for_dnp3 \ + -f \ + --start + ``` + +
+ Verify with `vctl status`. + + ```shell + (env) kefei@ubuntu-22:~/sandbox/dnp3-driver-sandbox$ vctl status + + UUID AGENT IDENTITY TAG PRIORITY STATUS HEALTH + + 5 platform_driveragent-4.0 platform_driver_for_dnp3 running [23217] GOOD + ``` + +### Configure the DNP3 driver + +1. Install a DNP3 Driver onto the Platform Driver. + + Installing a DNP3 driver in the Platform Driver Agent requires adding copies of the device configuration and registry + configuration files to the Platform Driver’s configuration store. For demo purpose, we will use default configure + files. + + Prepare the default config files: + + ```shell + # Create config file place holders + mkdir config + touch config/dnp3-config.json + touch config/dnp3.csv + ``` + + An example config file is available at " + services/core/PlatformDriverAgent/platform_driver/interfaces/udd_dnp3/examples/dnp3.config" + + ```json + { + "driver_config": { + "master_ip": "0.0.0.0", + "outstation_ip": "127.0.0.1", + "master_id": 2, + "outstation_id": 1, + "port": 20000 + }, + "registry_config": "config://dnp3.csv", + "driver_type": "dnp3", + "interval": 5, + "timezone": "UTC", + "publish_depth_first_all": true, + "heart_beat_point": "random_bool" + } + ``` + + Another example config file is available at " + services/core/PlatformDriverAgent/platform_driver/interfaces/udd_dnp3/examples/dnp3.csv" + + ```csv + Point Name,Volttron Point Name,Group,Variation,Index,Scaling,Units,Writable,Notes + AnalogInput_index0,AnalogInput_index0,30,6,0,1,NA,FALSE,Double Analogue input without status + AnalogInput_index1,AnalogInput_index1,30,6,1,1,NA,FALSE,Double Analogue input without status + AnalogInput_index2,AnalogInput_index2,30,6,2,1,NA,FALSE,Double Analogue input without status + AnalogInput_index3,AnalogInput_index3,30,6,3,1,NA,FALSE,Double Analogue input without status + BinaryInput_index0,BinaryInput_index0,1,2,0,1,NA,FALSE,Single bit binary input with status + BinaryInput_index1,BinaryInput_index1,1,2,1,1,NA,FALSE,Single bit binary input with status + BinaryInput_index2,BinaryInput_index2,1,2,2,1,NA,FALSE,Single bit binary input with status + BinaryInput_index3,BinaryInput_index3,1,2,3,1,NA,FALSE,Single bit binary input with status + AnalogOutput_index0,AnalogOutput_index0,40,4,0,1,NA,TRUE,Double-precision floating point with flags + AnalogOutput_index1,AnalogOutput_index1,40,4,1,1,NA,TRUE,Double-precision floating point with flags + AnalogOutput_index2,AnalogOutput_index2,40,4,2,1,NA,TRUE,Double-precision floating point with flags + AnalogOutput_index3,AnalogOutput_index3,40,4,3,1,NA,TRUE,Double-precision floating point with flags + BinaryOutput_index0,BinaryOutput_index0,10,2,0,1,NA,TRUE,Binary Output with flags + BinaryOutput_index1,BinaryOutput_index1,10,2,1,1,NA,TRUE,Binary Output with flags + BinaryOutput_index2,BinaryOutput_index2,10,2,2,1,NA,TRUE,Binary Output with flags + BinaryOutput_index3,BinaryOutput_index3,10,2,3,1,NA,TRUE,Binary Output with flags + + ``` + + Add config to the configuration store: + + ``` + vctl config store platform_driver_for_dnp3 devices/campus/building/dnp3 services/core/PlatformDriverAgent/platform_driver/interfaces/udd_dnp3/examples/dnp3.config + vctl config store platform_driver_for_dnp3 dnp3.csv services/core/PlatformDriverAgent/platform_driver/interfaces/udd_dnp3/examples/dnp3.csv --csv + ``` + +
+ Verify with `vctl config list` and `vctl config get` command. + (Please refer to the `vctl config` documentation for more details.) + + ```shell + (env) kefei@ubuntu-22:~/sandbox/dnp3-driver-sandbox$ vctl config get platform_driver_for_dnp3 devices/campus/building/dnp3 + { + "driver_config": { + "master_ip": "0.0.0.0", + "outstation_ip": "127.0.0.1", + "master_id": 2, + "outstation_id": 1, + "port": 20000 + }, + "registry_config": "config://dnp3.csv", + "driver_type": "dnp3", + "interval": 5, + "timezone": "UTC", + "publish_depth_first_all": true, + "heart_beat_point": "random_bool" + } + + (env) kefei@ubuntu-22:~/sandbox/dnp3-driver-sandbox$ vctl config get platform_driver_for_dnp3 dnp3.csv + [ + { + "Point Name": "AnalogInput_index0", + "Volttron Point Name": "AnalogInput_index0", + "Group": "30", + "Variation": "6", + "Index": "0", + ... + ] + ``` + +
+ +1. Verify with logging data + + When the DNP3-Driver is properly installed and configured, we can verify with logging data in "volttron.log". + + ``` + tail -f /volttron.log + ``` + +
+ Expected logging example + + ```shell + ... + 2023-03-13 23:26:56,611 (volttron-platform-driver-0.2.0rc1 23666) volttron.driver.base.driver(334) DEBUG: finish publishing: devices/campus/building/dnp3/all + 2023-03-13 23:26:57,897 () volttron.services.auth.auth_service(235) DEBUG: after getting peerlist to send auth updates + 2023-03-13 23:26:57,897 () volttron.services.auth.auth_service(239) DEBUG: Sending auth update to peers platform.control + 2023-03-13 23:26:57,897 () volttron.services.auth.auth_service(239) DEBUG: Sending auth update to peers platform_driver_for_dnp3 + 2023-03-13 23:26:57,898 () volttron.services.auth.auth_service(239) DEBUG: Sending auth update to peers platform.health + 2023-03-13 23:26:57,898 () volttron.services.auth.auth_service(239) DEBUG: Sending auth update to peers platform.config_store + 2023-03-13 23:26:57,898 () volttron.services.auth.auth_service(193) INFO: auth file /home/kefei/.volttron/auth.json loaded + 2023-03-13 23:26:57,898 () volttron.services.auth.auth_service(172) INFO: loading auth file /home/kefei/.volttron/auth.json + 2023-03-13 23:26:57,898 () volttron.services.auth.auth_service(185) DEBUG: Sending auth updates to peers + 2023-03-13 23:26:58,241 (volttron-platform-driver-0.2.0rc1 23666) (0) INFO: ['ms(1678768018241) INFO tcpclient - Connecting to: 127.0.0.1'] + 2023-03-13 23:26:58,241 (volttron-platform-driver-0.2.0rc1 23666) (0) INFO: ['ms(1678768018241) WARN tcpclient - Error Connecting: Connection refused'] + 2023-03-13 23:26:59,905 () volttron.services.auth.auth_service(235) DEBUG: after getting peerlist to send auth updates + 2023-03-13 23:26:59,905 () volttron.services.auth.auth_service(239) DEBUG: Sending auth update to peers platform.control + 2023-03-13 23:26:59,905 () volttron.services.auth.auth_service(239) DEBUG: Sending auth update to peers platform_driver_for_dnp3... + ] + ``` +
+ +1. (Optional) Verify with published data polled from outstation + + To see data being polled from an outstation and published to the bus, we need to + + * Set up an outstation, and + * install a [Listener Agent](https://pypi.org/project/volttron-listener/): + + **Set up an outstation**: The [dnp3-python](https://github.com/VOLTTRON/dnp3-python) is part of the dnp3-driver + dependency, and it is immediately available after the DNP3-Driver is installed. + + **Open another terminal**, and run `dnp3demo outstation`. For demo purpose, we assign arbitrary values to the + point. ( + More details about the "dnp3demo" module, please + see [dnp3demo-Module.md](https://github.com/VOLTTRON/dnp3-python/blob/main/docs/dnp3demo-Module.md)) + + ```shell + ==== Outstation Operation MENU ================================== + - update analog-input point value (for local reading) + - update analog-output point value (for local control) + - update binary-input point value (for local reading) + - update binary-output point value (for local control) +
- display database + - display configuration + ================================================================= + + ======== Your Input Here: ==(outstation)====== + ai + You chose - update analog-input point value (for local reading) + Type in and . Separate with space, then hit ENTER. + Type 'q', 'quit', 'exit' to main menu. + + + ======== Your Input Here: ==(outstation)====== + 0.1212 0 + {'Analog': {0: 0.1212, 1: None, 2: None, 3: None, 4: None, 5: None, 6: None, 7: None, 8: None, 9: None}} + You chose - update analog-input point value (for local reading) + Type in and . Separate with space, then hit ENTER. + Type 'q', 'quit', 'exit' to main menu. + + + ======== Your Input Here: ==(outstation)====== + 1.2323 1 + {'Analog': {0: 0.1212, 1: 1.2323, 2: None, 3: None, 4: None, 5: None, 6: None, 7: None, 8: None, 9: None}} + You chose - update analog-input point value (for local reading) + Type in and . Separate with space, then hit ENTER. + Type 'q', 'quit', 'exit' to main menu. + ``` +
+ Example of interaction with the `dnp3demo outstation` sub-command + + ```shell + (env) kefei@ubuntu-22:~/sandbox/dnp3-driver-sandbox$ dnp3demo outstation + dnp3demo.run_outstation {'command': 'outstation', 'outstation_ip=': '0.0.0.0', 'port=': 20000, 'master_id=': 2, 'outstation_id=': 1} + ms(1678770551216) INFO manager - Starting thread (0) + 2023-03-14 00:09:11,216 control_workflow_demo INFO Connection Config + 2023-03-14 00:09:11,216 control_workflow_demo INFO Connection Config + 2023-03-14 00:09:11,216 control_workflow_demo INFO Connection Config + ms(1678770551216) INFO server - Listening on: 0.0.0.0:20000 + 2023-03-14 00:09:11,216 control_workflow_demo DEBUG Initialization complete. Outstation in command loop. + 2023-03-14 00:09:11,216 control_workflow_demo DEBUG Initialization complete. Outstation in command loop. + 2023-03-14 00:09:11,216 control_workflow_demo DEBUG Initialization complete. Outstation in command loop. + Connection error. + Connection Config {'outstation_ip_str': '0.0.0.0', 'port': 20000, 'masterstation_id_int': 2, 'outstation_id_int': 1} + Start retry... + Connection error. + Connection Config {'outstation_ip_str': '0.0.0.0', 'port': 20000, 'masterstation_id_int': 2, 'outstation_id_int': 1} + ms(1678770565247) INFO server - Accepted connection from: 127.0.0.1 + ==== Outstation Operation MENU ================================== + - update analog-input point value (for local reading) + - update analog-output point value (for local control) + - update binary-input point value (for local reading) + - update binary-output point value (for local control) +
- display database + - display configuration + ================================================================= + + + ======== Your Input Here: ==(outstation)====== + ai + You chose - update analog-input point value (for local reading) + Type in and . Separate with space, then hit ENTER. + Type 'q', 'quit', 'exit' to main menu. + + + ======== Your Input Here: ==(outstation)====== + 0.1212 0 + {'Analog': {0: 0.1212, 1: None, 2: None, 3: None, 4: None, 5: None, 6: None, 7: None, 8: None, 9: None}} + You chose - update analog-input point value (for local reading) + Type in and . Separate with space, then hit ENTER. + Type 'q', 'quit', 'exit' to main menu. + + + ======== Your Input Here: ==(outstation)====== + 1.2323 1 + {'Analog': {0: 0.1212, 1: 1.2323, 2: None, 3: None, 4: None, 5: None, 6: None, 7: None, 8: None, 9: None}} + You chose - update analog-input point value (for local reading) + Type in and . Separate with space, then hit ENTER. + Type 'q', 'quit', 'exit' to main menu. + + + ======== Your Input Here: ==(outstation)====== + ``` +
+ + **Install the [Listener Agent](https://pypi.org/project/volttron-listener/)**: + Run `vctl install volttron-listener --start`. Once installed, you should see the data being published by viewing the + Volttron logs file. (i.e., `tail -f /volttron.log`) + > **Note**: + > it is recommended to restart the Platform Driver after a specific driver is installed and configured. i.e., + > using the `vctl restart ` command.) The expected logging will be similar as follows: + + ```shell + 2023-03-14 00:11:55,000 (volttron-platform-driver-0.2.0rc0 24737) volttron.driver.base.driver(277) DEBUG: scraping device: campus/building/dnp3 + 2023-03-14 00:11:55,805 (volttron-platform-driver-0.2.0rc0 24737) volttron.driver.base.driver(330) DEBUG: publishing: devices/campus/building/dnp3/all + 2023-03-14 00:11:55,810 (volttron-listener-0.2.0rc0 24424) listener.agent(104) INFO: Peer: pubsub, Sender: platform_driver_for_dnp3:, Bus: , Topic: devices/campus/building/dnp3/all, Headers: {'Date': '2023-03-14T05:11:55.805245+00:00', 'TimeStamp': '2023-03-14T05:11:55.805245+00:00', 'SynchronizedTimeStamp': '2023-03-14T05:11:55.000000+00:00', 'min_compatible_version': '3.0', 'max_compatible_version': ''}, Message: + [{'AnalogInput_index0': 0.1212, + 'AnalogInput_index1': 1.2323, + 'AnalogInput_index2': 0.0, + 'AnalogInput_index3': 0.0, + 'AnalogOutput_index0': 0.0, + 'AnalogOutput_index1': 0.0, + 'AnalogOutput_index2': 0.0, + 'AnalogOutput_index3': 0.0, + 'BinaryInput_index0': False, + 'BinaryInput_index1': False, + 'BinaryInput_index2': False, + 'BinaryInput_index3': False, + 'BinaryOutput_index0': False, + 'BinaryOutput_index1': False, + 'BinaryOutput_index2': False, + 'BinaryOutput_index3': False}, + {'AnalogInput_index0': {'type': 'integer', 'tz': 'UTC', 'units': 'NA'}, + 'AnalogInput_index1': {'type': 'integer', 'tz': 'UTC', 'units': 'NA'}, + 'AnalogInput_index2': {'type': 'integer', 'tz': 'UTC', 'units': 'NA'}, + 'AnalogInput_index3': {'type': 'integer', 'tz': 'UTC', 'units': 'NA'}, + 'AnalogOutput_index0': {'type': 'integer', 'tz': 'UTC', 'units': 'NA'}, + 'AnalogOutput_index1': {'type': 'integer', 'tz': 'UTC', 'units': 'NA'}, + 'AnalogOutput_index2': {'type': 'integer', 'tz': 'UTC', 'units': 'NA'}, + 'AnalogOutput_index3': {'type': 'integer', 'tz': 'UTC', 'units': 'NA'}, + 'BinaryInput_index0': {'type': 'integer', 'tz': 'UTC', 'units': 'NA'}, + 'BinaryInput_index1': {'type': 'integer', 'tz': 'UTC', 'units': 'NA'}, + 'BinaryInput_index2': {'type': 'integer', 'tz': 'UTC', 'units': 'NA'}, + 'BinaryInput_index3': {'type': 'integer', 'tz': 'UTC', 'units': 'NA'}, + 'BinaryOutput_index0': {'type': 'integer', 'tz': 'UTC', 'units': 'NA'}, + 'BinaryOutput_index1': {'type': 'integer', 'tz': 'UTC', 'units': 'NA'}, + 'BinaryOutput_index2': {'type': 'integer', 'tz': 'UTC', 'units': 'NA'}, + 'BinaryOutput_index3': {'type': 'integer', 'tz': 'UTC', 'units': 'NA'}}] + 2023-03-14 00:11:55,810 (volttron-platform-driver-0.2.0rc0 24737) volttron.driver.base.driver(334) DEBUG: finish publishing: devices/campus/building/dnp3/all + 2023-03-14 00:11:56,825 (volttron-listener-0.2.0rc0 24424) listener.agent(104) INFO: Peer: pubsub, Sender: volttron-listener-0.2.0rc0_2:, Bus: , Topic: heartbeat/volttron-listener-0.2.0rc0_2, Headers: {'TimeStamp': '2023-03-14T05:11:56.820827+00:00', 'min_compatible_version': '3.0', 'max_compatible_version': ''}, Message: + + ``` + +1. Shutdown the platform + + ```shell + ./stop-volttron + ``` + +### DNP3 Registry Configuration File + +The driver's registry configuration file, a [CSV](https://en.wikipedia.org/wiki/Comma-separated_values) file, +specifies which DNP3 points the driver will read and/or write. Each row configures a single DNP3 point. + +The following columns are required for each row: + +- **Volttron Point Name** - The name used by the VOLTTRON platform and agents to refer to the point. +- **Group** - The point's DNP3 group number. +- **Variation** - THe permit negotiated exchange of data formatted, i.e., data type. +- **Index** - The point's index number within its DNP3 data type (which is derived from its DNP3 group number). +- **Scaling** - A factor by which to multiply point values. +- **Units** - Point value units. +- **Writable** - TRUE or FALSE, indicating whether the point can be written by the driver (FALSE = read-only). + +Consult the **DNP3 data dictionary** for a point's Group and Index values. Point +definitions in the data dictionary are by agreement between the DNP3 Outstation and Master. +The VOLTTRON DNP3Agent loads the data dictionary of point definitions from the JSON file +at "point_definitions_path" in the DNP3Agent's config file. + +### Testing + +1. (If not satisfied,) install the dependencies for testing. + + ```shell + python bootstrap.py --testing + ``` + +1. Run pytest + + ```shell + pytest ./services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/. + ``` + + Note: the tests run on port=20000 by default. Make sure there is no other dnp3 instances running on port 20000 when + running the tests. + +
+ Example output + + ```shell + ===================================================================================================== test session starts ===================================================================================================== + platform linux -- Python 3.10.6, pytest-7.1.2, pluggy-1.0.0 -- /home/kefei/project/volttron/env/bin/python + cachedir: .pytest_cache + rootdir: /home/kefei/project/volttron, configfile: pytest.ini + plugins: rerunfailures-10.2, asyncio-0.19.0, timeout-2.1.0 + asyncio: mode=auto + timeout: 300.0s + timeout method: signal + timeout func_only: False + collected 27 items + + services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver.py::TestDummy::test_dummy PASSED [ 3%] + services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver.py::TestStation::test_station_init PASSED [ 7%] + services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver.py::TestStation::test_station_get_val_analog_input_float PASSED [ 11%] + services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver.py::TestStation::test_station_set_val_analog_input_float PASSED [ 14%] + services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver.py::TestDNPRegister::test_init PASSED [ 18%] + services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver.py::TestDNPRegister::test_get_register_value_analog_float PASSED [ 22%] + services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver.py::TestDNPRegister::test_get_register_value_analog_int PASSED [ 25%] + services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver.py::TestDNPRegister::test_get_register_value_binary PASSED [ 29%] + services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver.py::TestDNP3RegisterControlWorkflow::test_set_register_value_analog_float PASSED [ 33%] + services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver.py::TestDNP3RegisterControlWorkflow::test_set_register_value_analog_int PASSED [ 37%] + services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver.py::TestDNP3RegisterControlWorkflow::test_set_register_value_binary PASSED [ 40%] + services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver.py::TestDNP3InterfaceNaive::test_init PASSED [ 44%] + services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver.py::TestDNP3InterfaceNaive::test_get_reg_point PASSED [ 48%] + services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver.py::TestDNP3InterfaceNaive::test_set_reg_point PASSED [ 51%] + services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver_integration_volttron.py::TestDummy::test_dummy PASSED [ 55%] + services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver_integration_volttron.py::TestDummyAgentFixture::test_agent_dummy[volttron_instance0] SKIPPED (only for debugging purpose) [ 59%] + services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver_integration_volttron.py::TestDnp3DriverRPC::test_interface_get_point[volttron_instance0] PASSED [ 62%] + services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver_integration_volttron.py::TestDnp3DriverRPC::test_interface_set_point[volttron_instance0] PASSED [ 66%]ms(1684117269227) INFO manager - Exiting thread (0) + + services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver_integration_volttron.py::TestDummyAgentFixture::test_agent_dummy[volttron_instance1] SKIPPED (RabbitMQ is not setup and/or...) [ 70%] + services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver_integration_volttron.py::TestDnp3DriverRPC::test_interface_get_point[volttron_instance1] SKIPPED (RabbitMQ is not setup an...) [ 74%] + services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver_integration_volttron.py::TestDnp3DriverRPC::test_interface_set_point[volttron_instance1] SKIPPED (RabbitMQ is not setup an...) [ 77%] + services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver_integration_volttron.py::TestDummyAgentFixture::test_agent_dummy[volttron_instance2] SKIPPED (only for debugging purpose) [ 81%] + services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver_integration_volttron.py::TestDnp3DriverRPC::test_interface_get_point[volttron_instance2] PASSED [ 85%] + services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver_integration_volttron.py::TestDnp3DriverRPC::test_interface_set_point[volttron_instance2] PASSED [ 88%] + services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver_integration_volttron.py::TestDnp3DriverRPC::test_scrape_all SKIPPED (TODO) [ 92%] + services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver_integration_volttron.py::TestDnp3DriverRPC::test_revert_all SKIPPED (TODO) [ 96%] + services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver_integration_volttron.py::TestDnp3DriverRPC::test_revert_point SKIPPED (TODO) [100%] + + ====================================================================================================== warnings summary ======================================================================================================= + env/lib/python3.10/site-packages/wheel/paths.py:7 + /home/kefei/project/volttron/env/lib/python3.10/site-packages/wheel/paths.py:7: DeprecationWarning: The distutils package is deprecated and slated for removal in Python 3.12. Use setuptools or check PEP 632 for potential alternatives + import distutils.command.install as install + + ../../../../usr/lib/python3.10/distutils/command/install.py:13 + /usr/lib/python3.10/distutils/command/install.py:13: DeprecationWarning: The distutils.sysconfig module is deprecated, use sysconfig instead + from distutils.sysconfig import get_config_vars, is_virtual_environment + + -- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html + =================================================================================================== short test summary info =================================================================================================== + SKIPPED [2] services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver_integration_volttron.py: only for debugging purpose + SKIPPED [1] services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver_integration_volttron.py: RabbitMQ is not setup and/or SSL does not work in CI + SKIPPED [1] services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver_integration_volttron.py:65: RabbitMQ is not setup and/or SSL does not work in CI + SKIPPED [1] services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver_integration_volttron.py:85: RabbitMQ is not setup and/or SSL does not work in CI + SKIPPED [1] services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver_integration_volttron.py:104: TODO + SKIPPED [1] services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver_integration_volttron.py:117: TODO + SKIPPED [1] services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver_integration_volttron.py:129: TODO + ==================================================================================== 19 passed, 8 skipped, 2 warnings in 227.38s (0:03:47) ==================================================================================== + ``` + +
diff --git a/services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/dnp3.py b/services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/dnp3.py new file mode 100644 index 0000000000..8f71f67956 --- /dev/null +++ b/services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/dnp3.py @@ -0,0 +1,214 @@ +from .driver_wrapper import WrapperInterface, WrapperRegister +from .driver_wrapper import ImplementedRegister, RegisterValue +from typing import List, Optional, Dict + +from dnp3_python.dnp3station.master_new import MyMasterNew + +# TODO-developer: Your code here +# Add dependency as needed, and update in requirements +import json + +import logging +import random +import sys + +from datetime import datetime + +stdout_stream = logging.StreamHandler(sys.stdout) +stdout_stream.setFormatter(logging.Formatter('%(asctime)s\t%(name)s\t%(levelname)s\t%(message)s')) + +_log = logging.getLogger(__name__) +_log.addHandler(stdout_stream) +_log.setLevel(logging.DEBUG) +_log.setLevel(logging.WARNING) +_log.setLevel(logging.ERROR) + + +# TODO-developer: Your code here +# Change the classname "UserDevelopRegister" as needed +class UserDevelopRegisterDnp3(WrapperRegister): + # TODO-developer: Your code here + def __init__(self, master_application, reg_def, *args, **kwargs): + super().__init__(*args, **kwargs) + # self.master_application = kwargs['master_application'] + # self.reg_def = kwargs['reg_def'] + self.master_application = master_application + self.reg_def = reg_def + + def get_register_value(self) -> RegisterValue: + # TODO-developer: Your code here + # Implement get-register-value logic here + # Note: Keep the method name as it is including the signatures. + # Use a helper method if needed. + + # EXAMPLE: + # def get_register_value(self) -> RegisterValue: + # return _get_register_value_helper(url=self.driver_config.get("url")) + # def _get_register_value_helper(self, url: str): + # ... + + # print("silly implementation") + # the url will be in the config file + + try: + reg_def = self.reg_def + group = int(reg_def.get("Group")) + variation = int(reg_def.get("Variation")) + index = int(reg_def.get("Index")) + val = self._get_outstation_pt(self.master_application, group, variation, index) + # val = str(val) + + if val is not None: + return val + else: + _log.warning("dnp3 driver (master) couldn't collect data from the outstation.") + raise ValueError(f"Returned invalid dnp3 data point {val}") # do not publish invalid values + except Exception as e: + # print(f"!!!!!!!!!!!!!!!!!!!!{e}") + _log.error(e) + + raise Exception(e) + + @staticmethod + def _get_outstation_pt(master_application, group, variation, index) -> RegisterValue: + """ + Core logic to retrieve register value by polling a dnp3 outstation + Note: using def get_db_by_group_variation_index + Returns + ------- + + """ + return_point_value = master_application.get_val_by_group_variation_index(group=group, + variation=variation, + index=index) + return return_point_value + + def set_register_value(self, value, **kwargs) -> Optional[RegisterValue]: + """ + TODO: docstring + """ + try: + reg_def = self.reg_def + group = int(reg_def.get("Group")) + variation = int(reg_def.get("Variation")) + index = int(reg_def.get("Index")) + + val: Optional[RegisterValue] + self._set_outstation_pt(self.master_application, group, variation, index, set_value=value) + val = None + + return val + except Exception as e: + _log.error(e) + _log.warning("dnp3 driver (master) couldn't set value for the outstation.") + + @staticmethod + def _set_outstation_pt(master_application, group, variation, index, set_value) -> None: + """ + Core logic to send point operate command to outstation + Note: using def send_direct_point_command + Returns None + ------- + + """ + master_application.send_direct_point_command(group=group, variation=variation, index=index, + val_to_set=set_value) + + +class Interface(WrapperInterface): + # TODO-developer: Your code here + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.master_application = None + + # TODO-developer: Your code here + # Register type configuration + @staticmethod + def pass_register_types(csv_config: dict, driver_config_in_json_config: List[dict], + register_type_list: List[ImplementedRegister] = None): + """ + Note: based on the config.csv file. + By default, assuming register points are dnp3-register type + (optional) heartbeat register + """ + return [UserDevelopRegisterDnp3] * len(csv_config) + + @staticmethod + def _create_master_station(driver_config: dict): + """ + init a master station and later pass to registers + + Note: rely on XX.config json file convention, e.g., + "driver_config": + {"master_ip": "0.0.0.0", + "outstation_ip": "127.0.0.1", + "master_id": 2, + "outstation_id": 1, + "port": 20000}, + + Returns + ------- + + """ + + master_application = MyMasterNew( + masterstation_ip_str=driver_config.get("master_ip"), + outstation_ip_str=driver_config.get("outstation_ip"), + port=driver_config.get("port"), + masterstation_id_int=driver_config.get("master_id"), + outstation_id_int=driver_config.get("outstation_id"), + ) + # master_application.start() + return master_application + + def create_register(self, driver_config, + point_name, + data_type, + units, + read_only, + default_value, + description, + csv_config, + reg_def, + register_type, *args, **kwargs): + def get_master_station(): + # Note: this a closure, since parameter driver_config is required. + # (at current state) only create_register workflow should use it. + if self.master_application: + return self.master_application + else: + self.master_application = self._create_master_station(driver_config) + return self.master_application + + master = get_master_station() + master.start() + + register = UserDevelopRegisterDnp3( + driver_config=driver_config, + point_name=point_name, + data_type=data_type, # TODO: make it more clear in documentation + units=units, + read_only=read_only, + default_value=default_value, + description=description, + csv_config=csv_config, + reg_def=reg_def, + master_application=master + ) + return register + + @staticmethod + def get_reg_point(register: ImplementedRegister): + """ + Core logic for get_point + Note: Can be used for vip-agent-mock testing + """ + return register.get_register_value() + + @staticmethod + def set_reg_point(register: ImplementedRegister, value_to_set: RegisterValue): + """ + Core logic for set_point, i.e., _set_point without verification + Note: Can be used for vip-agent-mock testing + """ + set_pt_response = register.set_register_value(value=value_to_set) + return set_pt_response diff --git a/services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/driver_wrapper.py b/services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/driver_wrapper.py new file mode 100644 index 0000000000..0284043fa8 --- /dev/null +++ b/services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/driver_wrapper.py @@ -0,0 +1,840 @@ +# -*- coding: utf-8 -*- {{{ +# vim: set fenc=utf-8 ft=python sw=4 ts=4 sts=4 et: +# +# Copyright 2020, Battelle Memorial Institute. +# +# Licensed 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. +# +# This material was prepared as an account of work sponsored by an agency of +# the United States Government. Neither the United States Government nor the +# United States Department of Energy, nor Battelle, nor any of their +# employees, nor any jurisdiction or organization that has cooperated in the +# development of these materials, makes any warranty, express or +# implied, or assumes any legal liability or responsibility for the accuracy, +# completeness, or usefulness or any information, apparatus, product, +# software, or process disclosed, or represents that its use would not infringe +# privately owned rights. Reference herein to any specific commercial product, +# process, or service by trade name, trademark, manufacturer, or otherwise +# does not necessarily constitute or imply its endorsement, recommendation, or +# favoring by the United States Government or any agency thereof, or +# Battelle Memorial Institute. The views and opinions of authors expressed +# herein do not necessarily state or reflect those of the +# United States Government or any agency thereof. +# +# PACIFIC NORTHWEST NATIONAL LABORATORY operated by +# BATTELLE for the UNITED STATES DEPARTMENT OF ENERGY +# under Contract DE-AC05-76RL01830 +# }}} +import abc +import random +import datetime +import math +from math import pi + +from platform_driver.interfaces import BaseInterface, BaseRegister, BasicRevert +# from ...platform_driver.interfaces import BaseInterface, BaseRegister, BasicRevert +from csv import DictReader +from io import StringIO +import logging +import sys + +import requests + +from typing import List, Type, Dict, Union, Optional, TypeVar +from time import sleep + +stdout_stream = logging.StreamHandler(sys.stdout) +stdout_stream.setFormatter(logging.Formatter('%(asctime)s\t%(name)s\t%(levelname)s\t%(message)s')) + +_log = logging.getLogger(__name__) +# _log = logging.getLogger("data_retrieval_demo") +_log.addHandler(stdout_stream) +_log.setLevel(logging.DEBUG) +_log.setLevel(logging.WARNING) + +# TODO: parse to python_type based on literal. i.e., locate("int")("1") -> int(1) +# Design the data type validation logic (recommend but not enforce?) +type_mapping = {"string": str, + "int": int, + "integer": int, + "float": float, + "bool": bool, + "boolean": bool} + +# Type alias +RegisterValue = Union[int, str, float, bool] +Register = TypeVar("Register", bound=BaseRegister) + + +class WrapperRegister(BaseRegister): + """ + Template Register, host boilerplate code + """ + + # TODO: do we need to separate read-only and writable register? How a writable register looks like? + # TODO: e.g., How the set-value pass to the register class? + # TODO: (Mimic what happen to get_register_value method, we might need a controller method. + def __init__(self, driver_config: dict, point_name: str, data_type: RegisterValue, units: str, read_only: bool, + default_value=None, description='', csv_config={}, *args, **kwargs): + """ + Parameters # TODO: clean this up, + ---------- + config_dict: associated with `driver_config` in driver-config.config (json-like file) + user inputs are put here, e.g., IP address, url, etc. + + read_only: associated with `Writable` in driver-config.csv + point_name: associated with `Volttron Point Name` in driver-config.csv + units: associated with `Units` in driver-config.csv + reg_type: ?? # TODO: clean this up, + default_value: ?? # TODO: clean this up, + description: ?? # TODO: clean this up, + + Associated with Point Name,Volttron Point Name,Units,Units Details,Writable,Starting Value,Type,Notes + read_only = regDef['Writable'].lower() != 'true' + point_name = regDef['Volttron Point Name'] + description = regDef.get('Notes', '') + units = regDef['Units'] + default_value = regDef.get("Starting Value", 'sin').strip() + """ + super().__init__("byte", read_only, point_name, units, description='') + self._value: str = "" + self.driver_config: dict = driver_config + + self.point_name: str = point_name + self.data_type_str: str = data_type # "byte" or "bit" + self.units: Optional[str] = units + self.read_only: bool = read_only + self.default_value: Optional[RegisterValue] = default_value + self.description: str = description + self.csv_config: list = csv_config + + @property + def value(self): + self._value = self.get_register_value() # pre-requite methods + return self._value + + @value.setter + def value(self, x: RegisterValue): + if self.read_only: + raise RuntimeError( # TODO: Is RuntimeError necessary + "Trying to write to a point configured read only: " + self.point_name) # TODO: clean up + self._value = x + + @abc.abstractmethod + def get_register_value(self, **kwargs) -> RegisterValue: + """ + Override this to get register value + Examples 1 retrieve: + def get_register_value(): + some_url: str = self.config_dict.get("url") + return self.get_restAPI_value(url=some_url) + def get_restAPI_value(url=some_url) + ... + Returns + ------- + + """ + + @abc.abstractmethod + def set_register_value(self, value, **kwargs) -> Optional[RegisterValue]: # TODO: need an example/redesign for this + pass + # """ + # Override this to set register value. (Only for writable==True/read_only==False) + # Examples: + # def set_register_value(): + # some_temperature: int = get_comfortable_temperature(...) + # self.value(some_temperature) + # def get_comfortable_temperature(**kwargs) -> int: + # ... + # Returns + # ------- + # + # """ + + +# alias +ImplementedRegister = Union[WrapperRegister, Type[WrapperRegister]] + + +class DriverConfig: + """ + For validate driver configuration, e.g., driver-config.csv + """ + + def __init__(self, csv_config: List[dict]): + self.csv_config: List[dict] = csv_config + """ + + Parameters + ---------- + csv_config + + Returns + ------- + Examples: + [{'Point Name': 'Heartbeat', 'Volttron Point Name': 'Heartbeat', 'Units': 'On/Off', + 'Units Details': 'On/Off', 'Writable': 'TRUE', 'Starting Value': '0', 'Type': 'boolean', + 'Notes': 'Point for heartbeat toggle'}, + {'Point Name': 'Catfact', 'Volttron Point Name': 'Catfact', 'Units': 'No cat fact', + 'Units Details': 'No cat fact', 'Writable': 'TRUE', 'Starting Value': 'No cat fact', 'Type': 'str', + 'Notes': 'Cat fact extract from REST API'}] + """ + + @staticmethod + def _validate_header(point_config: dict): + """ + Require the header include the following keys + "PointName", "DataType", "Units", "ReadOnly", "DefaultValue", "Description" + (or allow parsing with minimal effort) + "PointName" <- "Point Name", "point name", "point-name", but not "point names" or "the point name" + Parameters + ---------- + point_config + + Returns + ------- + + """ + + def _to_alpha_lower(key: str): + return ''.join([x.lower() for x in key if x.isalpha()]) + + new_dict = {_to_alpha_lower(k): v for k, v in point_config.items()} + new_keys = new_dict.keys() + + standardized_valid_names = ["Volttron Point Name", "Data Type", "Units", "Writable", "Default Value", "Notes"] + for valid_name in standardized_valid_names: + if valid_name.lower() not in new_keys: + raise ValueError(f"`{valid_name}` is not in the config") + return new_dict + + def key_validate(self) -> List[dict]: + """ + + Returns + EXAMPLE: + {'pointname': 'Heartbeat', + 'datatype': 'boolean', + 'units': 'On/Off', + 'readonly': 'TRUE', + 'defaultvalue': '0', + 'description': 'Point for heartbeat toggle', + 'volttronpointname': 'Heartbeat', + 'unitsdetails': 'On/Off'} + ------- + + """ + key_validate_csv = [self._validate_header(point_config) for point_config in self.csv_config] + return key_validate_csv + + +class WrapperInterface(BasicRevert, BaseInterface): + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self.point_map: Dict[str, ImplementedRegister] = {} # {register.point_name: register} + self.register_types: List[ + ImplementedRegister] = [] # TODO: add sanity check for restister_types, e.g., count == register counts + + self.csv_config = None # TODO: try to get this value, potentially from def configure. get inspiration from modbus_tk testing + self.driver_config_in_json_config = None # TODO: try to get this value, potentially from def configure + + # TODO: clean up this public interface + # from *.csv configure file "driver_config": {...} + # self.driver_config: dict = {} + + def configure(self, driver_config_in_json_config: dict, csv_config: List[ + dict]): # TODO: ask driver.py, BaseInterface.configure to update signature when evoking + """ + Used by driver.py + def get_interface(self, driver_type, config_dict, config_string): + interface.configure(config_dict, config_string) + + Parameters # TODO: follow BaseInterface.configure signatures. But the names are wrong. + ---------- + driver_config_in_json_config: associated with `driver_config` in driver-config.config (json-like file) + user inputs are put here, e.g., IP address, url, etc. + csv_config: associated with the whole driver-config.csv file + Examples: + [{'Point Name': 'Heartbeat', 'Volttron Point Name': 'Heartbeat', 'Units': 'On/Off', + 'Units Details': 'On/Off', 'Writable': 'TRUE', 'Starting Value': '0', 'Type': 'boolean', + 'Notes': 'Point for heartbeat toggle'}, + {'Point Name': 'Catfact', 'Volttron Point Name': 'Catfact', 'Units': 'No cat fact', + 'Units Details': 'No cat fact', 'Writable': 'TRUE', 'Starting Value': 'No cat fact', 'Type': 'str', + 'Notes': 'Cat fact extract from REST API'}] + + """ + # print("========================================== csv_config, ", csv_config) + # print("========================================== driver_config_in_json_config, ", driver_config_in_json_config) + self.csv_config = csv_config + self.driver_config_in_json_config = driver_config_in_json_config + + # TODO configuration validation, i.e., self.config_check(...) + # self.config_check + self.parse_config(csv_config, driver_config_in_json_config) + + @staticmethod + @abc.abstractmethod + def pass_register_types(csv_config: dict, driver_config_in_json_config: List[dict], + register_type_list: List[ImplementedRegister] = None) -> List[ImplementedRegister]: + """ + For ingesting the register types list + Will be used by concrete Interface class inherit this template + + Parameters + ---------- + driver_config_in_json_config: associated with `driver_config` in driver-config.config (json-like file) + user inputs are put here, e.g., IP address, url, etc. + csv_config: associated with the whole driver-config.csv file + Examples: + [{'Point Name': 'Heartbeat', 'Volttron Point Name': 'Heartbeat', 'Units': 'On/Off', + 'Units Details': 'On/Off', 'Writable': 'TRUE', 'Starting Value': '0', 'Type': 'boolean', + 'Notes': 'Point for heartbeat toggle'}, + {'Point Name': 'Catfact', 'Volttron Point Name': 'Catfact', 'Units': 'No cat fact', + 'Units Details': 'No cat fact', 'Writable': 'TRUE', 'Starting Value': 'No cat fact', 'Type': 'str', + 'Notes': 'Cat fact extract from REST API'}] + register_type_list: + Example: + [RestAPIRegister, RestAPIRegister, RestAPIRegister, RandomBoolRegister] + """ + pass + return register_type_list + + def parse_config(self, csv_config, driver_config_in_json_config): # TODO: this configDict is from *.csv not .config + # print("========================================== csv_config, ", csv_config) + # print("========================================== driver_config_in_json_config, ", driver_config_in_json_config) + + # driver_config: DriverConfig = DriverConfig(csv_config) + # valid_csv_config = DriverConfig(csv_config).key_validate() + # print("========================================== valid_csv_config, ", valid_csv_config) + + if csv_config is None: # TODO: leave it now. Later for central data check + return + + register_types: List[ImplementedRegister] = self.pass_register_types(csv_config, driver_config_in_json_config) + valid_csv_config = csv_config # TODO: Design the config check (No config check for now.) + for reg_def, register_type_iter in zip(valid_csv_config, register_types): + # Skip lines that have no address yet. # TODO: understand why + if not reg_def['Point Name']: + continue + + point_name = reg_def['Volttron Point Name'] + type_name = reg_def.get("Data Type", 'string') + reg_type = type_mapping.get(type_name, str) + units = reg_def['Units'] + read_only = reg_def['Writable'].lower() != 'true' # TODO: watch out for this is opposite logic + + description = reg_def.get('Notes', '') + + # default_value = reg_def.get("defaultvalue", 'sin').strip() + default_value = reg_def.get( + "Default Value") # TODO: redesign default value logic, e.g., beable to map to real python type + if not default_value: + default_value = None + + # register_type = FakeRegister if not point_name.startswith('Cat') else CatfactRegister # TODO: change this + register_type = register_type_iter # TODO: Inconventional, document this. + + # print("========================================== point_name, ", point_name) + # print("========================================== reg_type, ", reg_type) + # print("========================================== units, ", units) + # print("========================================== read_only, ", read_only) + # print("========================================== default_value, ", default_value) + # print("========================================== description, ", description) + # print("========================================== reg_def, ", reg_def) + # Note: the following is to init a register_type object, e.g., WrapperRegister + try: + # register: WrapperRegister = register_type(driver_config=driver_config_in_json_config, + # point_name=point_name, + # data_type=reg_type, # TODO: make it more clear in documentation + # units=units, + # read_only=read_only, + # default_value=default_value, + # description=description, + # csv_config=csv_config, + # reg_def=reg_def) + + register: WrapperRegister = self.create_register(driver_config=driver_config_in_json_config, + point_name=point_name, + data_type=reg_type, + # TODO: make it more clear in documentation + units=units, + read_only=read_only, + default_value=default_value, + description=description, + csv_config=csv_config, + reg_def=reg_def, + register_type=register_type) + if default_value is not None: + self.set_default(point_name, register.value) + + self.insert_register(register) + except Exception as e: + print(e) + + + + def create_register(self, driver_config, + point_name, + data_type, + units, + read_only, + default_value, + description, + csv_config, + reg_def, + register_type, *args, **kwargs) -> ImplementedRegister: + pass + """ + Factory method to init (WrapperRegister) register object + + :param register_type: the class name of the to-be-created register, e.g., WrapperRegister + :param driver_config_in_json_config: json config file, + :param csv_config: csv config file, Dict[str, str] + + """ + register: WrapperRegister = register_type(driver_config=driver_config, + point_name=point_name, + data_type=data_type, # TODO: make it more clear in documentation + units=units, + read_only=read_only, + default_value=default_value, + description=description, + csv_config=csv_config, + reg_def=reg_def) + return register + + def insert_register(self, register: WrapperRegister): + """ + Inserts a register into the :py:class:`Interface`. + + :param register: Register to add to the interface. + :type register: :py:class:`BaseRegister` + """ + register_point: str = register.point_name + self.point_map[register_point] = register + + register_type = register.get_register_type() + self.registers[register_type].append(register) + + def get_point(self, point_name, **kwargs) -> RegisterValue: + """ + Override BasicInvert method + Note: this method should be evoked by vip agent + EXAMPLE: + rs = a.vip.rpc.call("platform.driver", "get_point", + "campus-vm/building-vm/Dnp3", + "AnalogInput_index0").get() + """ + register: WrapperRegister = self.get_register_by_name(point_name) + val = self.get_reg_point(register) + return val + + # def _set_point(self, point_name: str, + # value_to_set: RegisterValue): # TODO: this method has some problem. Understand the logic: overall + example + + def set_point(self, point_name, value): + """ + Override/Restate BasicInvert method for convenience + Note: this method should be evoked by vip agent + EXAMPLE: + rs = a.vip.rpc.call("platform.driver", "set_point", + "campus-vm/building-vm/Dnp3", + "AnalogInput_index0", 0.543).get() + """ + # result = self._set_point(point_name, value) + # self._tracker.mark_dirty_point(point_name) + return super().set_point(point_name, value) + + def _set_point(self, point_name, value, **kwargs): + """ + Parameters + ---------- + point_name + value + + Returns + ------- + + """ + # value_to_set = value + register: ImplementedRegister = self.get_register_by_name(point_name) + + # response = self.set_reg_point_w_verification(value_to_set=value, register=register) + response = self.set_reg_point_async_w_verification(value_to_set=value, register=register) + return response + + @staticmethod + def get_reg_point(register: ImplementedRegister): + """ + Core logic for get_point + """ + return register.value + + @staticmethod + def set_reg_point(register: ImplementedRegister, value_to_set: RegisterValue): + """ + Core logic for set_point, i.e., _set_point without verification + Note: Can be used for vip-agent-mock testing + """ + set_pt_response = register.set_register_value(value=value_to_set) + return set_pt_response + + @classmethod + def set_reg_point_w_verification(cls, value_to_set: RegisterValue, register: ImplementedRegister, + relax_verification=True): + """ + Core logic for set_point, i.e., _set_point with verification + Note: Can be used for vip-agent-mock testing + """ + # Note: leave register method to verify, e.g., check writability. + + # set point workflow + set_pt_response = cls.set_reg_point(register=register, value_to_set=value_to_set) + + # verify with get_point + get_pt_response = cls.get_reg_point(register) + + success_flag_strict = (get_pt_response == value_to_set) + success_flag_relax = (str(get_pt_response) == str(value_to_set)) + if relax_verification: + success_flag = success_flag_relax + else: + success_flag = success_flag_strict + + response = {"success_flag": success_flag, + "value_to_set": value_to_set, + "set_pt_response": set_pt_response, + "get_pt_response": get_pt_response} + if not success_flag: + _log.warning(f"Set value failed, {response}") + return response + + @classmethod + def set_reg_point_async_w_verification(cls, value_to_set: RegisterValue, register: ImplementedRegister, + relax_verification=True): + """ + Counterpart of set_reg_point_w_verification for asynchronous workflow with delay and retry. + """ + + # set point workflow + set_pt_response = cls.set_reg_point(register=register, value_to_set=value_to_set) + + # verify with get_point + get_pt_response = cls.get_reg_point(register) + + def check_success_flag(): + _success_flag_strict = (get_pt_response == value_to_set) + _success_flag_relax = (str(get_pt_response) == str(value_to_set)) + if relax_verification: + _success_flag = _success_flag_relax + else: + _success_flag = _success_flag_strict + return _success_flag + + # note: only delay and retry the read/get logic NOT the send/set logic + # note: hard-coded delay time and number of retry. Use small delay, large retry number strategy. + # For local instances, 2 sec should be sufficient. + retry_delay = 0.2 + retry_max = 20 + retry_count = 0 + success_flag = check_success_flag() + while not success_flag and retry_count < retry_max: + sleep(retry_delay) + retry_count += 1 + + get_pt_response = cls.get_reg_point(register) + + success_flag = check_success_flag() + + response = {"success_flag": success_flag, + "value_to_set": value_to_set, + "set_pt_response": set_pt_response, + "get_pt_response": get_pt_response} + if not success_flag: + _log.warning(f"Set value failed, {response}") + return response + + def _scrape_all(self) -> Dict[str, any]: + result: Dict[str, RegisterValue] = {} # Dict[register.point_name, register.value] + read_registers = self.get_registers_by_type(reg_type="byte", + read_only=True) # TODO: Parameterize the "byte" hard-code here + write_registers = self.get_registers_by_type(reg_type="byte", read_only=False) + all_registers: List[ImplementedRegister] = read_registers + write_registers + for register in all_registers: + result[register.point_name] = register.value + return result + + def get_register_by_name(self, name: str) -> WrapperRegister: + """ + Get a register by it's point name. + + :param name: Point name of register. + :type name: str + :return: An instance of BaseRegister + :rtype: :py:class:`BaseRegister` + """ + try: + return self.point_map[name] + except KeyError: + raise DriverInterfaceError("Point not configured on device: " + name) + + +class WrapperInterfaceNew: + """ + Use composition instead of inheritance + """ + + def __init__(self, *args, **kwargs): + # self.basic_revert = BasicRevert(**kwargs) + # self.basic_interface = BaseInterface(**kwargs) + self.basic_revert = BasicRevert() + self.basic_interface = BaseInterface() + self._tracker = self.basic_revert._tracker + + self.point_map: Dict[str, ImplementedRegister] = {} # {register.point_name: register} + self.register_types: List[ + ImplementedRegister] = [] # TODO: add sanity check for restister_types, e.g., count == register counts + + self.csv_config = None # TODO: try to get this value, potentially from def configure. get inspiration from modbus_tk testing + self.driver_config_in_json_config = None # TODO: try to get this value, potentially from def configure + + def configure(self, driver_config_in_json_config: dict, csv_config: List[ + dict]): # TODO: ask driver.py, BaseInterface.configure to update signature when evoking + """ + Used by driver.py + def get_interface(self, driver_type, config_dict, config_string): + interface.configure(config_dict, config_string) + + Parameters # TODO: follow BaseInterface.configure signatures. But the names are wrong. + ---------- + driver_config_in_json_config: associated with `driver_config` in driver-config.config (json-like file) + user inputs are put here, e.g., IP address, url, etc. + csv_config: associated with the whole driver-config.csv file + Examples: + [{'Point Name': 'Heartbeat', 'Volttron Point Name': 'Heartbeat', 'Units': 'On/Off', + 'Units Details': 'On/Off', 'Writable': 'TRUE', 'Starting Value': '0', 'Type': 'boolean', + 'Notes': 'Point for heartbeat toggle'}, + {'Point Name': 'Catfact', 'Volttron Point Name': 'Catfact', 'Units': 'No cat fact', + 'Units Details': 'No cat fact', 'Writable': 'TRUE', 'Starting Value': 'No cat fact', 'Type': 'str', + 'Notes': 'Cat fact extract from REST API'}] + + """ + # print("========================================== csv_config, ", csv_config) + # print("========================================== driver_config_in_json_config, ", driver_config_in_json_config) + self.csv_config = csv_config + self.driver_config_in_json_config = driver_config_in_json_config + + # TODO configuration validation, i.e., self.config_check(...) + # self.config_check + self.parse_config(csv_config, driver_config_in_json_config) + + def parse_config(self, csv_config, driver_config_in_json_config, + register_type_list): # TODO: this configDict is from *.csv not .config + # print("========================================== csv_config, ", csv_config) + # print("========================================== driver_config_in_json_config, ", driver_config_in_json_config) + + # driver_config: DriverConfig = DriverConfig(csv_config) + # valid_csv_config = DriverConfig(csv_config).key_validate() + # print("========================================== valid_csv_config, ", valid_csv_config) + + if csv_config is None: # TODO: leave it now. Later for central data check + return + + # register_types: List[ImplementedRegister] = register_type_list + register_types: List[ImplementedRegister] = self.pass_register_types(csv_config, driver_config_in_json_config) + valid_csv_config = csv_config # TODO: Design the config check (No config check for now.) + for reg_def, register_type_iter in zip(valid_csv_config, register_types): + # Skip lines that have no address yet. # TODO: understand why + if not reg_def['Point Name']: + continue + + point_name = reg_def['Volttron Point Name'] + type_name = reg_def.get("Data Type", 'string') + reg_type = type_mapping.get(type_name, str) + units = reg_def['Units'] + read_only = reg_def['Writable'].lower() != 'true' # TODO: watch out for this is opposite logic + + description = reg_def.get('Notes', '') + + # default_value = reg_def.get("defaultvalue", 'sin').strip() + default_value = reg_def.get( + "Default Value") # TODO: redesign default value logic, e.g., beable to map to real python type + if not default_value: + default_value = None + + # register_type = FakeRegister if not point_name.startswith('Cat') else CatfactRegister # TODO: change this + register_type = register_type_iter # TODO: Inconventional, document this. + + # print("========================================== point_name, ", point_name) + # print("========================================== reg_type, ", reg_type) + # print("========================================== units, ", units) + # print("========================================== read_only, ", read_only) + # print("========================================== default_value, ", default_value) + # print("========================================== description, ", description) + # print("========================================== reg_def, ", reg_def) + # Note: the following is to init a register_type object, e.g., WrapperRegister + try: + register: WrapperRegister = self.create_register(driver_config=driver_config_in_json_config, + point_name=point_name, + data_type=reg_type, + # TODO: make it more clear in documentation + units=units, + read_only=read_only, + default_value=default_value, + description=description, + csv_config=csv_config, + reg_def=reg_def, + register_type=register_type) + + if default_value: + self.basic_revert.set_default(point_name, register.value) + + self.insert_register(register) + + except Exception as e: + print(e) + + @staticmethod + @abc.abstractmethod + def pass_register_types(csv_config: dict, driver_config_in_json_config: List[dict], + register_type_list: List[ImplementedRegister] = None) -> List[ImplementedRegister]: + """ + For ingesting the register types list + Will be used by concrete Interface class inherit this template + + Parameters + ---------- + driver_config_in_json_config: associated with `driver_config` in driver-config.config (json-like file) + user inputs are put here, e.g., IP address, url, etc. + csv_config: associated with the whole driver-config.csv file + Examples: + [{'Point Name': 'Heartbeat', 'Volttron Point Name': 'Heartbeat', 'Units': 'On/Off', + 'Units Details': 'On/Off', 'Writable': 'TRUE', 'Starting Value': '0', 'Type': 'boolean', + 'Notes': 'Point for heartbeat toggle'}, + {'Point Name': 'Catfact', 'Volttron Point Name': 'Catfact', 'Units': 'No cat fact', + 'Units Details': 'No cat fact', 'Writable': 'TRUE', 'Starting Value': 'No cat fact', 'Type': 'str', + 'Notes': 'Cat fact extract from REST API'}] + register_type_list: + Example: + [RestAPIRegister, RestAPIRegister, RestAPIRegister, RandomBoolRegister] + """ + pass + return register_type_list + + def create_register(self, driver_config, + point_name, + data_type, + units, + read_only, + default_value, + description, + csv_config, + reg_def, + register_type, *args, **kwargs) -> ImplementedRegister: + pass + """ + Factory method to init (WrapperRegister) register object + + :param register_type: the class name of the to-be-created register, e.g., WrapperRegister + :param driver_config_in_json_config: json config file, + :param csv_config: csv config file, Dict[str, str] + + """ + register: WrapperRegister = register_type(driver_config=driver_config, + point_name=point_name, + data_type=data_type, # TODO: make it more clear in documentation + units=units, + read_only=read_only, + default_value=default_value, + description=description, + csv_config=csv_config, + reg_def=reg_def) + return register + + def insert_register(self, register: WrapperRegister): + """ + Inserts a register into the :py:class:`Interface`. + + :param register: Register to add to the interface. + :type register: :py:class:`BaseRegister` + """ + register_point: str = register.point_name + self.point_map[register_point] = register + + register_type = register.get_register_type() + self.basic_interface.registers[register_type].append(register) + + def get_point(self, point_name, **kwargs) -> RegisterValue: + register: WrapperRegister = self.get_register_by_name(point_name) + # val: RegisterValue = register.get_register_value() + + # return "testing_value" + return register.value + + def get_register_by_name(self, name: str) -> Register: + return self.basic_interface.get_register_by_name(name) + + def set_point(self, point_name, value): + """ + Implementation of :py:meth:`BaseInterface.set_point` + + Passes arguments through to :py:meth:`BasicRevert._set_point` + """ + # return self.basic_revert.set_point(point_name, value) + result = self._set_point(point_name, value) + self._tracker.mark_dirty_point(point_name) + return result + + def _set_point(self, point_name, value, **kwargs): + """ + Parameters + ---------- + point_name + value + + Returns + ------- + + """ + value_to_set = value + register: ImplementedRegister = self.get_register_by_name(point_name) + # Note: leave register method to verify, e.g., check writability. + # register.value(value_to_set) + # value_response: RegisterValue = register.value + + set_pt_response = register.set_register_value(value=value_to_set) + # verify with get_point + get_pt_response = self.get_point(point_name=point_name) + + success_flag_strict = (get_pt_response == value_to_set) + success_flag_relax = (str(get_pt_response) == str(value_to_set)) + success_flag = success_flag_relax + + response = {"success_flag": success_flag, + "value_to_set": value_to_set, + "set_pt_response": set_pt_response, + "get_pt_response": get_pt_response} + if not success_flag: + _log.warning(f"Set value failed, {response}") + return response + + def scrape_all(self): + """ + Implementation of :py:meth:`BaseInterface.scrape_all` + """ + return self.basic_revert.scrape_all() + + +class DriverInterfaceError(Exception): + pass diff --git a/services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/examples/dnp3.config b/services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/examples/dnp3.config new file mode 100644 index 0000000000..fea35bc607 --- /dev/null +++ b/services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/examples/dnp3.config @@ -0,0 +1,11 @@ +{ + "driver_config": {"master_ip": "0.0.0.0", "outstation_ip": "127.0.0.1", + "master_id": 2, "outstation_id": 1, + "port": 20000}, + "registry_config":"config://dnp3.csv", + "driver_type": "dnp3", + "interval": 5, + "timezone": "UTC", + "publish_depth_first_all": true, + "heart_beat_point": "random_bool" +} diff --git a/services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/examples/dnp3.csv b/services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/examples/dnp3.csv new file mode 100644 index 0000000000..e71b832b3d --- /dev/null +++ b/services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/examples/dnp3.csv @@ -0,0 +1,17 @@ +Point Name,Volttron Point Name,Group,Variation,Index,Scaling,Units,Writable,Notes +AnalogInput_index0,AnalogInput_index0,30,6,0,1,NA,FALSE,Double Analogue input without status +AnalogInput_index1,AnalogInput_index1,30,6,1,1,NA,FALSE,Double Analogue input without status +AnalogInput_index2,AnalogInput_index2,30,6,2,1,NA,FALSE,Double Analogue input without status +AnalogInput_index3,AnalogInput_index3,30,6,3,1,NA,FALSE,Double Analogue input without status +BinaryInput_index0,BinaryInput_index0,1,2,0,1,NA,FALSE,Single bit binary input with status +BinaryInput_index1,BinaryInput_index1,1,2,1,1,NA,FALSE,Single bit binary input with status +BinaryInput_index2,BinaryInput_index2,1,2,2,1,NA,FALSE,Single bit binary input with status +BinaryInput_index3,BinaryInput_index3,1,2,3,1,NA,FALSE,Single bit binary input with status +AnalogOutput_index0,AnalogOutput_index0,40,4,0,1,NA,TRUE,Double-precision floating point with flags +AnalogOutput_index1,AnalogOutput_index1,40,4,1,1,NA,TRUE,Double-precision floating point with flags +AnalogOutput_index2,AnalogOutput_index2,40,4,2,1,NA,TRUE,Double-precision floating point with flags +AnalogOutput_index3,AnalogOutput_index3,40,4,3,1,NA,TRUE,Double-precision floating point with flags +BinaryOutput_index0,BinaryOutput_index0,10,2,0,1,NA,TRUE,Binary Output with flags +BinaryOutput_index1,BinaryOutput_index1,10,2,1,1,NA,TRUE,Binary Output with flags +BinaryOutput_index2,BinaryOutput_index2,10,2,2,1,NA,TRUE,Binary Output with flags +BinaryOutput_index3,BinaryOutput_index3,10,2,3,1,NA,TRUE,Binary Output with flags diff --git a/services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver.py b/services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver.py new file mode 100644 index 0000000000..73eaf8ad9e --- /dev/null +++ b/services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver.py @@ -0,0 +1,504 @@ +import pytest +import gevent +import logging +import time +import csv +import json +from pathlib import Path +import random + +from services.core.PlatformDriverAgent.platform_driver.interfaces. \ + dnp3 import UserDevelopRegisterDnp3 +from pydnp3 import opendnp3 +from services.core.PlatformDriverAgent.platform_driver.interfaces. \ + dnp3.dnp3 import Interface as DNP3Interface + +from dnp3_python.dnp3station.master_new import MyMasterNew +from dnp3_python.dnp3station.outstation_new import MyOutStationNew + +import os + +TEST_DIR = os.path.dirname(os.path.abspath(__file__)) + + +class TestDummy: + """ + Dummy test to check pytest setup + """ + + def test_dummy(self): + print("I am a silly dummy test.") + + +@pytest.fixture( + scope="module" +) +def outstation_app(request): + """ + outstation using default configuration (including default database) + Note: since outstation cannot shut down gracefully, + outstation_app fixture need to in "module" scope to prevent interrupting pytest during outstation shut-down + """ + # Note: allow parsing argument to fixture change port number using `request.param` + try: + port = request.param + except AttributeError: + port = 20000 + outstation_appl = MyOutStationNew(port=port) # Note: using default port 20000 + outstation_appl.start() + # time.sleep(3) + yield outstation_appl + # clean-up + outstation_appl.shutdown() + + +@pytest.fixture( + # scope="module" +) +def master_app(request): + """ + master station using default configuration + Note: outstation needs to exist first to make connection. + """ + + # Note: allow parsing argument to fixture change port number using `request.param` + try: + port = request.param + except AttributeError: + port = 20000 + # Note: using default port 20000, + # Note: using small "stale_if_longer_than" to force update + master_appl = MyMasterNew(port=port, stale_if_longer_than=0.1) + master_appl.start() + # Note: add delay to prevent conflict + # (there is a delay when master shutdown. And all master shares the same config) + time.sleep(1) + yield master_appl + # clean-up + master_appl.shutdown() + time.sleep(1) + + +class TestStation: + """ + Testing the underlying pydnp3 package station-related fuctions. + """ + + def test_station_init(self, master_app, outstation_app): + # master_app = MyMasterNew() + # master_app.start() + driver_wrapper_init_arg = {'driver_config': {}, 'point_name': "", 'data_type': "", 'units': "", 'read_only': ""} + UserDevelopRegisterDnp3(master_application=master_app, reg_def={}, + **driver_wrapper_init_arg) + + def test_station_get_val_analog_input_float(self, master_app, outstation_app): + + # outstation update with values + analog_input_val = [1.2454, 33453.23, 45.21] + for i, val_update in enumerate(analog_input_val): + outstation_app.apply_update(opendnp3.Analog(value=val_update, + flags=opendnp3.Flags(24), + time=opendnp3.DNPTime(3094)), + index=i) + # Note: group=30, variation=6 is AnalogInputFloat + for i, val_update in enumerate(analog_input_val): + val_get = master_app.get_val_by_group_variation_index(group=30, variation=6, index=i) + # print(f"===val_update {val_update}, val_get {val_get}") + assert val_get == val_update + + time.sleep(1) # add delay buffer to pass the "stale_if_longer_than" checking statge + + # outstation update with random values + analog_input_val_random = [random.random() for i in range(3)] + for i, val_update in enumerate(analog_input_val_random): + outstation_app.apply_update(opendnp3.Analog(value=val_update), + index=i) + # Note: group=30, variation=6 is AnalogInputFloat + for i, val_update in enumerate(analog_input_val_random): + val_get = master_app.get_val_by_group_variation_index(group=30, variation=6, index=i) + # print(f"===val_update {val_update}, val_get {val_get}") + assert val_get == val_update + + def test_station_set_val_analog_input_float(self, master_app, outstation_app): + + # outstation update with values + analog_output_val = [1.2454, 33453.23, 45.21] + for i, val_to_set in enumerate(analog_output_val): + master_app.send_direct_point_command(group=40, variation=4, index=i, + val_to_set=val_to_set) + # Note: group=40, variation=4 is AnalogOutFloat + for i, val_to_set in enumerate(analog_output_val): + val_get = master_app.get_val_by_group_variation_index(group=40, variation=4, index=i) + # print(f"===val_update {val_update}, val_get {val_get}") + assert val_get == val_to_set + + time.sleep(1) # add delay buffer to pass the "stale_if_longer_than" checking statge + + # outstation update with random values + analog_output_val_random = [random.random() for i in range(3)] + for i, val_to_set in enumerate(analog_output_val_random): + master_app.send_direct_point_command(group=40, variation=4, index=i, + val_to_set=val_to_set) + # Note: group=40, variation=4 is AnalogOutFloat + for i, val_to_set in enumerate(analog_output_val_random): + val_get = master_app.get_val_by_group_variation_index(group=40, variation=4, index=i) + # print(f"===val_update {val_update}, val_get {val_get}") + assert val_get == val_to_set + + +@pytest.fixture +def dnp3_inherit_init_args(csv_config, driver_config_in_json_config): + """ + args required for parent class init (i.e., class WrapperRegister) + """ + args = {'driver_config': driver_config_in_json_config, + 'point_name': "", + 'data_type': "", + 'units': "", + 'read_only': ""} + return args + + +@pytest.fixture +def driver_config_in_json_config(): + """ + associated with `driver_config` in driver-config.config (json-like file) + user inputs are put here, e.g., IP address, url, etc. + """ + json_path = Path("./testing_data/dnp3.config") + json_path = Path(TEST_DIR, json_path) + with open(json_path) as json_f: + driver_config = json.load(json_f) + k = "driver_config" + return {k: driver_config.get(k)} + + +@pytest.fixture +def csv_config(): + """ + associated with the whole driver-config.csv file + """ + csv_path = Path("./testing_data/dnp3.csv") + csv_path = Path(TEST_DIR, csv_path) + with open(csv_path) as f: + reader = csv.DictReader(f, delimiter=',') + csv_config = [row for row in reader] + + return csv_config + + +@pytest.fixture +def reg_def_dummy(): + """ + register definition, row of csv config file + """ + # reg_def = {'Point Name': 'AnalogInput_index0', 'Volttron Point Name': 'AnalogInput_index0', + # 'Group': '30', 'Variation': '6', 'Index': '0', 'Scaling': '1', 'Units': 'NA', + # 'Writable': 'FALSE', 'Notes': 'Double Analogue input without status'} + reg_def = {'Point Name': 'pn', 'Volttron Point Name': 'pn', + 'Group': 'int', 'Variation': 'int', 'Index': 'int', 'Scaling': '1', 'Units': 'NA', + 'Writable': 'NA', 'Notes': ''} + return reg_def + + +class TestDNPRegister: + """ + Tests for UserDevelopRegisterDnp3 class + + init + + get_register_value + analog input float + analog input int + binary input + """ + + def test_init(self, master_app, csv_config, dnp3_inherit_init_args): + for reg_def in csv_config: + UserDevelopRegisterDnp3(master_application=master_app, + reg_def=reg_def, + **dnp3_inherit_init_args + ) + + def test_get_register_value_analog_float(self, outstation_app, master_app, csv_config, + dnp3_inherit_init_args, reg_def_dummy): + + # dummy test variable + analog_input_val = [445.33, 1123.56, 98.456] + [random.random() for i in range(3)] + + # dummy reg_def (csv config row) + # Note: group = 30, variation = 6 is AnalogInputFloat + reg_def = reg_def_dummy + reg_defs = [] + for i in range(len(analog_input_val)): + reg_def["Group"] = "30" + reg_def["Variation"] = "6" + reg_def["Index"] = str(i) + reg_defs.append(reg_def.copy()) # Note: Python gotcha, mutable don't evaluate til the end of the loop. + + # outstation update values + for i, val_update in enumerate(analog_input_val): + outstation_app.apply_update(opendnp3.Analog(value=val_update), index=i) + + # verify: driver read value + for i, (val_update, csv_row) in enumerate(zip(analog_input_val, reg_defs)): + # print(f"====== reg_defs {reg_defs}, analog_input_val {analog_input_val}") + dnp3_register = UserDevelopRegisterDnp3(master_application=master_app, + reg_def=csv_row, + **dnp3_inherit_init_args + ) + val_get = dnp3_register.get_register_value() + # print("===========val_get, val_update", val_get, val_update) + assert val_get == val_update + + def test_get_register_value_analog_int(self, outstation_app, master_app, csv_config, + dnp3_inherit_init_args, reg_def_dummy): + + # dummy test variable + analog_input_val = [345, 1123, 98] + [random.randint(1, 100) for i in range(3)] + + # dummy reg_def (csv config row) + # Note: group = 30, variation = 1 is AnalogInputInt32 + reg_def = reg_def_dummy + reg_defs = [] + for i in range(len(analog_input_val)): + reg_def["Group"] = "30" + reg_def["Variation"] = "1" + reg_def["Index"] = str(i) + reg_defs.append(reg_def.copy()) # Note: Python gotcha, mutable don't evaluate til the end of the loop. + + # outstation update values + for i, val_update in enumerate(analog_input_val): + outstation_app.apply_update(opendnp3.Analog(value=val_update), index=i) + + # verify: driver read value + for i, (val_update, csv_row) in enumerate(zip(analog_input_val, reg_defs)): + # print(f"====== reg_defs {reg_defs}, analog_input_val {analog_input_val}") + dnp3_register = UserDevelopRegisterDnp3(master_application=master_app, + reg_def=csv_row, + **dnp3_inherit_init_args + ) + val_get = dnp3_register.get_register_value() + # print("===========val_get, val_update", val_get, val_update) + assert val_get == val_update + + def test_get_register_value_binary(self, outstation_app, master_app, csv_config, + dnp3_inherit_init_args, reg_def_dummy): + + # dummy test variable + binary_input_val = [True, False, True] + [random.choice([True, False]) for i in range(3)] + + # dummy reg_def (csv config row) + # Note: group = 1, variation = 2 is BinaryInput + reg_def = reg_def_dummy + reg_defs = [] + for i in range(len(binary_input_val)): + reg_def["Group"] = "1" + reg_def["Variation"] = "2" + reg_def["Index"] = str(i) + reg_defs.append(reg_def.copy()) # Note: Python gotcha, mutable don't evaluate til the end of the loop. + + # outstation update values + for i, val_update in enumerate(binary_input_val): + outstation_app.apply_update(opendnp3.Binary(value=val_update), index=i) + + # verify: driver read value + for i, (val_update, csv_row) in enumerate(zip(binary_input_val, reg_defs)): + # print(f"====== reg_defs {reg_defs}, analog_input_val {analog_input_val}") + dnp3_register = UserDevelopRegisterDnp3(master_application=master_app, + reg_def=csv_row, + **dnp3_inherit_init_args + ) + val_get = dnp3_register.get_register_value() + # print(f"=========== i {i}, val_get {val_get}, val_update {val_update}") + assert val_get == val_update + + +class TestDNP3RegisterControlWorkflow: + + def test_set_register_value_analog_float(self, outstation_app, master_app, csv_config, + dnp3_inherit_init_args, reg_def_dummy): + + # dummy test variable + # Note: group=40, variation=4 is AnalogOutputDoubleFloat + output_val = [343.23, 23.1109, 58.2] + [random.random() for i in range(3)] + + # dummy reg_def (csv config row) + # Note: group = 1, variation = 2 is BinaryInput + reg_def = reg_def_dummy + reg_defs = [] + for i in range(len(output_val)): + reg_def["Group"] = "40" + reg_def["Variation"] = "4" + reg_def["Index"] = str(i) + reg_defs.append(reg_def.copy()) # Note: Python gotcha, mutable don't evaluate til the end of the loop. + + # master set values + for i, (val_set, csv_row) in enumerate(zip(output_val, reg_defs)): + dnp3_register = UserDevelopRegisterDnp3(master_application=master_app, + reg_def=csv_row, + **dnp3_inherit_init_args + ) + dnp3_register.set_register_value(value=val_set) + + # verify: driver read value + for i, (val_set, csv_row) in enumerate(zip(output_val, reg_defs)): + # print(f"====== reg_defs {reg_defs}, analog_input_val {analog_input_val}") + dnp3_register = UserDevelopRegisterDnp3(master_application=master_app, + reg_def=csv_row, + **dnp3_inherit_init_args + ) + val_get = dnp3_register.get_register_value() + # print("===========val_get, val_update", val_get, val_update) + assert val_get == val_set + + def test_set_register_value_analog_int(self, outstation_app, master_app, csv_config, + dnp3_inherit_init_args, reg_def_dummy): + + # dummy test variable + # Note: group=40, variation=4 is AnalogOutputDoubleFloat + output_val = [45343, 344, 221] + [random.randint(1, 1000) for i in range(3)] + + # dummy reg_def (csv config row) + # Note: group = 1, variation = 2 is BinaryInput + reg_def = reg_def_dummy + reg_defs = [] + for i in range(len(output_val)): + reg_def["Group"] = "40" + reg_def["Variation"] = "1" + reg_def["Index"] = str(i) + reg_defs.append(reg_def.copy()) # Note: Python gotcha, mutable don't evaluate til the end of the loop. + + # master set values + for i, (val_set, csv_row) in enumerate(zip(output_val, reg_defs)): + dnp3_register = UserDevelopRegisterDnp3(master_application=master_app, + reg_def=csv_row, + **dnp3_inherit_init_args + ) + dnp3_register.set_register_value(value=val_set) + + # verify: driver read value + for i, (val_set, csv_row) in enumerate(zip(output_val, reg_defs)): + # print(f"====== reg_defs {reg_defs}, analog_input_val {analog_input_val}") + dnp3_register = UserDevelopRegisterDnp3(master_application=master_app, + reg_def=csv_row, + **dnp3_inherit_init_args + ) + val_get = dnp3_register.get_register_value() + # print("===========val_get, val_update", val_get, val_update) + assert val_get == val_set + + def test_set_register_value_binary(self, outstation_app, master_app, csv_config, + dnp3_inherit_init_args, reg_def_dummy): + + # dummy test variable + # Note: group=40, variation=4 is AnalogOutputDoubleFloat + output_val = [True, False, True] + [random.choice([True, False]) for i in range(3)] + + # dummy reg_def (csv config row) + # Note: group = 1, variation = 2 is BinaryInput + reg_def = reg_def_dummy + reg_defs = [] + for i in range(len(output_val)): + reg_def["Group"] = "10" + reg_def["Variation"] = "2" + reg_def["Index"] = str(i) + reg_defs.append(reg_def.copy()) # Note: Python gotcha, mutable don't evaluate til the end of the loop. + + # master set values + for i, (val_set, csv_row) in enumerate(zip(output_val, reg_defs)): + dnp3_register = UserDevelopRegisterDnp3(master_application=master_app, + reg_def=csv_row, + **dnp3_inherit_init_args + ) + dnp3_register.set_register_value(value=val_set) + + # verify: driver read value + for i, (val_set, csv_row) in enumerate(zip(output_val, reg_defs)): + # print(f"====== reg_defs {reg_defs}, analog_input_val {analog_input_val}") + dnp3_register = UserDevelopRegisterDnp3(master_application=master_app, + reg_def=csv_row, + **dnp3_inherit_init_args + ) + val_get = dnp3_register.get_register_value() + # print("===========val_get, val_update", val_get, val_update) + assert val_get == val_set + + +class TestDNP3InterfaceNaive: + + def test_init(self): + pass + dnp3_interface = DNP3Interface() + + def test_get_reg_point(self, outstation_app, master_app, csv_config, + dnp3_inherit_init_args, reg_def_dummy): + # dummy test variable + analog_input_val = [445.33, 1123.56, 98.456] + [random.random() for i in range(3)] + + # dummy reg_def (csv config row) + # Note: group = 30, variation = 6 is AnalogInputFloat + reg_def = reg_def_dummy + reg_defs = [] + for i in range(len(analog_input_val)): + reg_def["Group"] = "30" + reg_def["Variation"] = "6" + reg_def["Index"] = str(i) + reg_defs.append(reg_def.copy()) # Note: Python gotcha, mutable don't evaluate til the end of the loop. + + # outstation update values + for i, val_update in enumerate(analog_input_val): + outstation_app.apply_update(opendnp3.Analog(value=val_update), index=i) + + # verify: driver read value + dnp3_interface = DNP3Interface() + for i, (val_update, csv_row) in enumerate(zip(analog_input_val, reg_defs)): + # print(f"====== reg_defs {reg_defs}, analog_input_val {analog_input_val}") + dnp3_register = UserDevelopRegisterDnp3(master_application=master_app, + reg_def=csv_row, + **dnp3_inherit_init_args + ) + + val_get = dnp3_interface.get_reg_point(register=dnp3_register) + # print("======== dnp3_register.value", dnp3_register.value) + # print("===========val_get, val_update", val_get, val_update) + assert val_get == val_update + + def test_set_reg_point(self, outstation_app, master_app, csv_config, + dnp3_inherit_init_args, reg_def_dummy): + # dummy test variable + analog_output_val = [445.33, 1123.56, 98.456] + [random.random() for i in range(3)] + + # dummy reg_def (csv config row) + # Note: group = 30, variation = 6 is AnalogInputFloat + reg_def = reg_def_dummy + reg_defs = [] + for i in range(len(analog_output_val)): + reg_def["Group"] = "40" + reg_def["Variation"] = "4" + reg_def["Index"] = str(i) + reg_defs.append(reg_def.copy()) # Note: Python gotcha, mutable don't evaluate til the end of the loop. + + dnp3_interface = DNP3Interface() + + # dnp3_interface update values + for i, (val_update, csv_row) in enumerate(zip(analog_output_val, reg_defs)): + dnp3_register = UserDevelopRegisterDnp3(master_application=master_app, + reg_def=csv_row, + **dnp3_inherit_init_args + ) + dnp3_interface.set_reg_point(register=dnp3_register, value_to_set=val_update) + + # verify: driver read value + + for i, (val_update, csv_row) in enumerate(zip(analog_output_val, reg_defs)): + # print(f"====== reg_defs {reg_defs}, analog_input_val {analog_input_val}") + dnp3_register = UserDevelopRegisterDnp3(master_application=master_app, + reg_def=csv_row, + **dnp3_inherit_init_args + ) + + val_get = dnp3_interface.get_reg_point(register=dnp3_register) + # print("======== dnp3_register.value", dnp3_register.value) + # print("===========val_get, val_update", val_get, val_update) + assert val_get == val_update diff --git a/services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver_integration_volttron.py b/services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver_integration_volttron.py new file mode 100644 index 0000000000..67dbd2b4ae --- /dev/null +++ b/services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/test_dnp3_driver_integration_volttron.py @@ -0,0 +1,209 @@ +import pytest +import gevent +import logging +import time +import random + +from volttron.platform import get_services_core, jsonapi + +from volttron.platform.agent.known_identities import PLATFORM_DRIVER + +from pydnp3 import opendnp3 + +from dnp3_python.dnp3station.outstation_new import MyOutStationNew +from pathlib import Path + +import sys +import os + +TEST_DIR = os.path.dirname(os.path.abspath(__file__)) + + +# TODO: add IP, port pool to avoid conflict +# TODO: make sleep more robust and flexible. (Currently relies on manually setup sleep time.) + + +class TestDummy: + """ + Dummy test to check pytest setup + """ + + def test_dummy(self): + print("I am a silly dummy test.") + + +@pytest.fixture( + scope="class" +) +def outstation_app_p20000(): + """ + outstation using default configuration (including default database) + Note: since outstation cannot shut down gracefully, + outstation_app fixture need to in "module" scope to prevent interrupting pytest during outstation shut-down + """ + port = 20000 + outstation_appl = MyOutStationNew(port=port) # Note: using default port 20000 + outstation_appl.start() + gevent.sleep(10) + yield outstation_appl + + outstation_appl.shutdown() + + +@pytest.mark.skip(reason="only for debugging purpose") +class TestDummyAgentFixture: + """ + Dummy test to check VOLTTRON agent (carry on test VOLTTRON instance) setup + """ + + def test_agent_dummy(self, dnp3_tester_agent): + print("I am a fixture agent dummy test.") + + +class TestDnp3DriverRPC: + + def test_interface_get_point( + self, + dnp3_tester_agent, + outstation_app_p20000, + ): + val_update = 7.124 + random.random() + outstation_app_p20000.apply_update(opendnp3.Analog(value=val_update, + flags=opendnp3.Flags(24), + time=opendnp3.DNPTime(3094)), + index=0) + + time.sleep(2) + + res_val = dnp3_tester_agent.vip.rpc.call("platform.driver", "get_point", + "campus-vm/building-vm/Dnp3-port20000", + "AnalogInput_index0").get(timeout=5) + + print(f"======res_val {res_val}") + assert res_val == val_update + + def test_interface_set_point( + self, + dnp3_tester_agent, + outstation_app_p20000, + ): + val_set = 8.342 + random.random() + + res_val = dnp3_tester_agent.vip.rpc.call("platform.driver", "set_point", + "campus-vm/building-vm/Dnp3-port20000", + "AnalogOutput_index0", val_set).get(timeout=5) + + # print(f"======res_val {res_val}") + # Expected output + # {'success_flag': True, 'value_to_set': 8.342, 'set_pt_response': None, 'get_pt_response': 8.342} + try: + assert res_val.get("success_flag") + except AssertionError: + print(f"======res_val {res_val}") + + @pytest.mark.skip(reason="TODO") + def test_scrape_all(self, ): + """ + Issue a get_point RPC call for the device and return the result. + + @param agent: The test Agent. + @param device_name: The driver name, by default: 'devices/device_name'. + @return: The dictionary mapping point names to their actual values from + the RPC call. + """ + # return agent.vip.rpc.call(PLATFORM_DRIVER, 'scrape_all', device_name) \ + # .get(timeout=10) + + @pytest.mark.skip(reason="TODO") + def test_revert_all(self, ): + """ + Issue a get_point RPC call for the device and return the result. + + @param agent: The test Agent. + @param device_name: The driver name, by default: 'devices/device_name'. + @return: Return value from the RPC call. + """ + # return agent.vip.rpc.call(PLATFORM_DRIVER, 'revert_device', + # device_name).get(timeout=10) + + @pytest.mark.skip(reason="TODO") + def test_revert_point(self, ): + """ + Issue a get_point RPC call for the named point and return the result. + + @param agent: The test Agent. + @param device_name: The driver name, by default: 'devices/device_name'. + @param point_name: The name of the point to query. + @return: Return value from the RPC call. + """ + # return agent.vip.rpc.call(PLATFORM_DRIVER, 'revert_point', + # device_name, point_name).get(timeout=10) + + +@pytest.fixture(scope="module") +# @pytest.fixture +def dnp3_tester_agent(request, volttron_instance): + """ + Build PlatformDriverAgent, add modbus driver & csv configurations + """ + + # Build platform driver agent + tester_agent = volttron_instance.build_agent(identity="test_dnp3_agent") + gevent.sleep(1) + capabilities = {'edit_config_store': {'identity': PLATFORM_DRIVER}} + # Note: commented out the add_capabilities due to complained by volttron_instance fixture, i.e., + # pytest.param(dict(messagebus='rmq', ssl_auth=True), + # marks=rmq_skipif), # complain add_capabilities + # dict(messagebus='zmq', auth_enabled=False), # complain add_capabilities + if volttron_instance.auth_enabled: + volttron_instance.add_capabilities(tester_agent.core.publickey, capabilities) + + # Clean out platform driver configurations + # wait for it to return before adding new config + tester_agent.vip.rpc.call(peer='config.store', + method='manage_delete_store', + identity=PLATFORM_DRIVER).get(timeout=5) + + json_config_path = Path("../examples/dnp3.config") + json_config_path = Path(TEST_DIR, json_config_path) + with open(json_config_path, "r") as f: + json_str_p20000 = f.read() + + csv_config_path = Path("../examples/dnp3.csv") + csv_config_path = Path(TEST_DIR, csv_config_path) + with open(csv_config_path, "r") as f: + csv_str = f.read() + + tester_agent.vip.rpc.call(peer='config.store', + method='manage_store', + identity=PLATFORM_DRIVER, + config_name="dnp3.csv", + raw_contents=csv_str, + config_type='csv' + ).get(timeout=5) + + tester_agent.vip.rpc.call('config.store', + method='manage_store', + identity=PLATFORM_DRIVER, + config_name="devices/campus-vm/building-vm/Dnp3-port20000", + raw_contents=json_str_p20000, + config_type='json' + ).get(timeout=5) + + platform_uuid = volttron_instance.install_agent( + agent_dir=get_services_core("PlatformDriverAgent"), + config_file={}, + start=True) + + gevent.sleep(10) # Note: important, wait for the agent to start and start the devices, otherwise rpc call may fail. + # time.sleep(10) # wait for the agent to start and start the devices + + def stop(): + """ + Stop platform driver agent + """ + volttron_instance.stop_agent(platform_uuid) + tester_agent.core.stop() + + yield tester_agent + request.addfinalizer(stop) diff --git a/services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/testing_data/dnp3.config b/services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/testing_data/dnp3.config new file mode 100644 index 0000000000..fea35bc607 --- /dev/null +++ b/services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/testing_data/dnp3.config @@ -0,0 +1,11 @@ +{ + "driver_config": {"master_ip": "0.0.0.0", "outstation_ip": "127.0.0.1", + "master_id": 2, "outstation_id": 1, + "port": 20000}, + "registry_config":"config://dnp3.csv", + "driver_type": "dnp3", + "interval": 5, + "timezone": "UTC", + "publish_depth_first_all": true, + "heart_beat_point": "random_bool" +} diff --git a/services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/testing_data/dnp3.csv b/services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/testing_data/dnp3.csv new file mode 100644 index 0000000000..e71b832b3d --- /dev/null +++ b/services/core/PlatformDriverAgent/platform_driver/interfaces/dnp3/tests/testing_data/dnp3.csv @@ -0,0 +1,17 @@ +Point Name,Volttron Point Name,Group,Variation,Index,Scaling,Units,Writable,Notes +AnalogInput_index0,AnalogInput_index0,30,6,0,1,NA,FALSE,Double Analogue input without status +AnalogInput_index1,AnalogInput_index1,30,6,1,1,NA,FALSE,Double Analogue input without status +AnalogInput_index2,AnalogInput_index2,30,6,2,1,NA,FALSE,Double Analogue input without status +AnalogInput_index3,AnalogInput_index3,30,6,3,1,NA,FALSE,Double Analogue input without status +BinaryInput_index0,BinaryInput_index0,1,2,0,1,NA,FALSE,Single bit binary input with status +BinaryInput_index1,BinaryInput_index1,1,2,1,1,NA,FALSE,Single bit binary input with status +BinaryInput_index2,BinaryInput_index2,1,2,2,1,NA,FALSE,Single bit binary input with status +BinaryInput_index3,BinaryInput_index3,1,2,3,1,NA,FALSE,Single bit binary input with status +AnalogOutput_index0,AnalogOutput_index0,40,4,0,1,NA,TRUE,Double-precision floating point with flags +AnalogOutput_index1,AnalogOutput_index1,40,4,1,1,NA,TRUE,Double-precision floating point with flags +AnalogOutput_index2,AnalogOutput_index2,40,4,2,1,NA,TRUE,Double-precision floating point with flags +AnalogOutput_index3,AnalogOutput_index3,40,4,3,1,NA,TRUE,Double-precision floating point with flags +BinaryOutput_index0,BinaryOutput_index0,10,2,0,1,NA,TRUE,Binary Output with flags +BinaryOutput_index1,BinaryOutput_index1,10,2,1,1,NA,TRUE,Binary Output with flags +BinaryOutput_index2,BinaryOutput_index2,10,2,2,1,NA,TRUE,Binary Output with flags +BinaryOutput_index3,BinaryOutput_index3,10,2,3,1,NA,TRUE,Binary Output with flags