├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
└── workflows
│ ├── build_exe.yml
│ ├── dependency-review.yml
│ └── release_package.yml
├── .gitignore
├── CLI.md
├── CODE_OF_CONDUCT.md
├── LICENSE.txt
├── README.md
├── doc
├── OpenOPC.pdf
├── Roadmap.md
├── assets
│ ├── LinuxSetup.png
│ ├── WindowsSetup.png
│ ├── cli_properties.png
│ ├── cli_read.png
│ ├── cli_server-info.png
│ ├── cli_write.png
│ └── open-opc.png
└── tipps.md
├── examples
└── connect_discover_read_tags.py
├── lib
├── api-ms-win-core-path-l1-1-0.dll
└── gbda_aut.dll
├── openopc2
├── __init__.py
├── __main__.py
├── cli.py
├── config.py
├── da_client.py
├── da_com.py
├── exceptions.py
├── gateway_proxy.py
├── gateway_server.py
├── gateway_service.py
├── logger.py
├── opc_types.py
├── pythoncom_datatypes.py
├── system_health.py
└── utils.py
├── pyproject.toml
├── scripts
├── OpenOpcService.spec
├── build_executables.ps1
├── create_dll_interface.py
├── generate_cli_md.sh
├── graybox_opc_automation_wrapper.py
├── opc_com_basic_test.py
└── redeploy_open_opc_service.ps1
└── tests
├── __init__.py
├── test_cli.py
├── test_config.py
├── test_exceptions.py
├── test_list.py
├── test_opc_com.py
├── test_opc_gateway_server.py
├── test_open_opc_service.py
├── test_properties.py
├── test_properties_class.py
├── test_read.py
├── test_server.py
└── test_write.py
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Make a minmal code example that reproduces the bug
16 | 2. Write about the environment (OS, OPC Server)
17 | 3. Can you reproduce the Bug with the Matrikon Simulation server in nomal opc Mode without the Gateway
18 | 4. Paste your environment variables of the Openopc2
19 |
20 |
21 | **Expected behavior**
22 | A clear and concise description of what you expected to happen.
23 |
24 | **Screenshots**
25 | If applicable, add screenshots to help explain your problem.
26 | Copy shell output text here (prefered to screenshots)
27 |
28 | **Desktop (please complete the following information):**
29 | - OS: [e.g. iOS]
30 | - Version [e.g. 22]
31 | - OPC Server
32 | - Python Version
33 | - Environment Variables
34 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/.github/workflows/build_exe.yml:
--------------------------------------------------------------------------------
1 | name: Build EXE
2 | on:
3 | create:
4 | tags:
5 | - "*.*.*"
6 | jobs:
7 | build:
8 | runs-on: windows-latest
9 | steps:
10 | - uses: actions/checkout@v4
11 | - name: Set up Python (32-bit)
12 | uses: actions/setup-python@v5
13 | with:
14 | python-version: 3.8
15 | architecture: x86
16 | - name: Install poetry
17 | uses: abatilo/actions-poetry@v3
18 | - name: Register gbda_aut.dll
19 | run: |
20 | Copy-Item .\lib\gbda_aut.dll C:\Windows\System32\
21 | regsvr32 /s C:\Windows\System32\gbda_aut.dll
22 | shell: pwsh
23 | - name: Install dependencies
24 | run: poetry install
25 | - name: Build executables
26 | run: scripts/build_executables.ps1
27 | - name: Create GitHub Release
28 | uses: ncipollo/release-action@v1
29 | with:
30 | artifacts: |
31 | dist/OpenOpcCli.exe
32 | dist/OpenOpcServer.exe
33 | dist/OpenOpcService.exe
--------------------------------------------------------------------------------
/.github/workflows/dependency-review.yml:
--------------------------------------------------------------------------------
1 | # Dependency Review Action
2 | #
3 | # This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging.
4 | #
5 | # Source repository: https://github.com/actions/dependency-review-action
6 | # Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement
7 | name: 'Dependency Review'
8 | on: [pull_request]
9 |
10 | permissions:
11 | contents: read
12 |
13 | jobs:
14 | dependency-review:
15 | runs-on: ubuntu-latest
16 | steps:
17 | - name: 'Checkout Repository'
18 | uses: actions/checkout@v4
19 | - name: 'Dependency Review'
20 | uses: actions/dependency-review-action@v4
21 |
--------------------------------------------------------------------------------
/.github/workflows/release_package.yml:
--------------------------------------------------------------------------------
1 | name: Release package
2 | on:
3 | create:
4 | tags:
5 | - "*.*.*"
6 | jobs:
7 | build:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - uses: actions/checkout@v4
11 | - name: Build and publish to pypi
12 | uses: JRubics/poetry-publish@v2.0
13 | with:
14 | pypi_token: ${{ secrets.PYPI_TOKEN }}
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # IDEs
2 | .idea
3 | .vscode
4 |
5 | # Python
6 | build
7 | **/__pycache__
8 | *.spec
9 | dist
10 |
11 | # Poetry
12 | poetry.lock
13 |
14 | # Other
15 | .DS_Store
16 | .direnv
17 | .tool-versions
18 | .envrc
--------------------------------------------------------------------------------
/CLI.md:
--------------------------------------------------------------------------------
1 | # `openopc2 CLI`
2 |
3 | **Usage**:
4 |
5 | ```console
6 | $ openopc2 CLI [OPTIONS] COMMAND [ARGS]...
7 | ```
8 |
9 | **Options**:
10 |
11 | - `--install-completion`: Install completion for the current shell.
12 | - `--show-completion`: Show completion for the current shell, to copy it or customize the installation.
13 | - `--help`: Show this message and exit.
14 |
15 | **Commands**:
16 |
17 | - `list-clients`: \[EXPERIMENTAL\] List clients of OpenOPC...
18 | - `list-tags`: List tags (items) of OPC server
19 | - `properties`: Show properties of given tags
20 | - `read`: Read tags
21 | - `server-info`: Display OPC server information
22 | - `write`: Write values
23 |
24 | ## `openopc2 CLI list-clients`
25 |
26 | \[EXPERIMENTAL\] List clients of OpenOPC Gateway Server
27 |
28 | **Usage**:
29 |
30 | ```console
31 | $ openopc2 CLI list-clients [OPTIONS]
32 | ```
33 |
34 | **Options**:
35 |
36 | - `--log-level [CRITICAL|ERROR|WARNING|INFO|DEBUG]`: Log level \[default: WARNING\]
37 | - `--help`: Show this message and exit.
38 |
39 | ## `openopc2 CLI list-tags`
40 |
41 | List tags (items) of OPC server
42 |
43 | **Usage**:
44 |
45 | ```console
46 | $ openopc2 CLI list-tags [OPTIONS]
47 | ```
48 |
49 | **Options**:
50 |
51 | - `--protocol-mode [com|gateway]`: Protocol mode \[default: gateway\]
52 | - `--opc-server TEXT`: OPC Server to connect to \[default: Matrikon.OPC.Simulation\]
53 | - `--opc-host TEXT`: OPC Host to connect to \[default: localhost\]
54 | - `--gateway-host TEXT`: OPC Gateway Host to connect to \[default: 192.168.0.115\]
55 | - `--gateway-port INTEGER`: OPC Gateway Port to connect to \[default: 7766\]
56 | - `--recursive / --no-recursive`: Recursively read sub-tags \[default: False\]
57 | - `--output-csv / --no-output-csv`: Output in CSV format \[default: False\]
58 | - `--log-level [CRITICAL|ERROR|WARNING|INFO|DEBUG]`: Log level \[default: WARNING\]
59 | - `--help`: Show this message and exit.
60 |
61 | ## `openopc2 CLI properties`
62 |
63 | Show properties of given tags
64 |
65 | **Usage**:
66 |
67 | ```console
68 | $ openopc2 CLI properties [OPTIONS] TAGS...
69 | ```
70 |
71 | **Arguments**:
72 |
73 | - `TAGS...`: Tags to read \[required\]
74 |
75 | **Options**:
76 |
77 | - `--protocol-mode [com|gateway]`: Protocol mode \[default: gateway\]
78 | - `--opc-server TEXT`: OPC Server to connect to \[default: Matrikon.OPC.Simulation\]
79 | - `--opc-host TEXT`: OPC Host to connect to \[default: localhost\]
80 | - `--gateway-host TEXT`: OPC Gateway Host to connect to \[default: 192.168.0.115\]
81 | - `--gateway-port INTEGER`: OPC Gateway Port to connect to \[default: 7766\]
82 | - `--log-level [CRITICAL|ERROR|WARNING|INFO|DEBUG]`: Log level \[default: WARNING\]
83 | - `--help`: Show this message and exit.
84 |
85 | ## `openopc2 CLI read`
86 |
87 | Read tags
88 |
89 | **Usage**:
90 |
91 | ```console
92 | $ openopc2 CLI read [OPTIONS] TAGS...
93 | ```
94 |
95 | **Arguments**:
96 |
97 | - `TAGS...`: Tags to read \[required\]
98 |
99 | **Options**:
100 |
101 | - `--protocol-mode [com|gateway]`: Protocol mode \[default: gateway\]
102 | - `--opc-server TEXT`: OPC Server to connect to \[default: Matrikon.OPC.Simulation\]
103 | - `--opc-host TEXT`: OPC Host to connect to \[default: localhost\]
104 | - `--gateway-host TEXT`: OPC Gateway Host to connect to \[default: 192.168.0.115\]
105 | - `--gateway-port INTEGER`: OPC Gateway Port to connect to \[default: 7766\]
106 | - `--group-size INTEGER`: Group tags into group_size tags per transaction
107 | - `--pause INTEGER`: Sleep time between transactionsin milliseconds \[default: 0\]
108 | - `--source [cache|device|hybrid]`: Data SOURCE for reads (cache, device, hybrid) \[default: hybrid\]
109 | - `--update-rate INTEGER`: Update rate for group in milliseconds \[default: 0\]
110 | - `--timeout INTEGER`: Read timeout in milliseconds \[default: 10000\]
111 | - `--include-error-messages / --no-include-error-messages`: Include descriptive error message strings \[default: False\]
112 | - `--output-csv / --no-output-csv`: Output in CSV format \[default: False\]
113 | - `--log-level [CRITICAL|ERROR|WARNING|INFO|DEBUG]`: Log level \[default: WARNING\]
114 | - `--help`: Show this message and exit.
115 |
116 | ## `openopc2 CLI server-info`
117 |
118 | Display OPC server information
119 |
120 | **Usage**:
121 |
122 | ```console
123 | $ openopc2 CLI server-info [OPTIONS]
124 | ```
125 |
126 | **Options**:
127 |
128 | - `--protocol-mode [com|gateway]`: Protocol mode \[default: gateway\]
129 | - `--opc-server TEXT`: OPC Server to connect to \[default: Matrikon.OPC.Simulation\]
130 | - `--opc-host TEXT`: OPC Host to connect to \[default: localhost\]
131 | - `--gateway-host TEXT`: OPC Gateway Host to connect to \[default: 192.168.0.115\]
132 | - `--gateway-port INTEGER`: OPC Gateway Port to connect to \[default: 7766\]
133 | - `--log-level [CRITICAL|ERROR|WARNING|INFO|DEBUG]`: Log level \[default: WARNING\]
134 | - `--help`: Show this message and exit.
135 |
136 | ## `openopc2 CLI write`
137 |
138 | Write values
139 |
140 | **Usage**:
141 |
142 | ```console
143 | $ openopc2 CLI write [OPTIONS] TAG_VALUE_PAIRS...
144 | ```
145 |
146 | **Arguments**:
147 |
148 | - `TAG_VALUE_PAIRS...`: Tag value pairs to write (use ITEM,VALUE) \[required\]
149 |
150 | **Options**:
151 |
152 | - `--protocol-mode [com|gateway]`: Protocol mode \[default: gateway\]
153 | - `--opc-server TEXT`: OPC Server to connect to \[default: Matrikon.OPC.Simulation\]
154 | - `--opc-host TEXT`: OPC Host to connect to \[default: localhost\]
155 | - `--gateway-host TEXT`: OPC Gateway Host to connect to \[default: 192.168.0.115\]
156 | - `--gateway-port INTEGER`: OPC Gateway Port to connect to \[default: 7766\]
157 | - `--group-size INTEGER`: Group tags into group_size tags per transaction
158 | - `--pause INTEGER`: Sleep time between transactionsin milliseconds \[default: 0\]
159 | - `--include-error-messages / --no-include-error-messages`: Include descriptive error message strings \[default: False\]
160 | - `--log-level [CRITICAL|ERROR|WARNING|INFO|DEBUG]`: Log level \[default: WARNING\]
161 | - `--help`: Show this message and exit.
162 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our
6 | community a harassment-free experience for everyone, regardless of age, body
7 | size, visible or invisible disability, ethnicity, sex characteristics, gender
8 | identity and expression, level of experience, education, socio-economic status,
9 | nationality, personal appearance, race, religion, or sexual identity
10 | and orientation.
11 |
12 | We pledge to act and interact in ways that contribute to an open, welcoming,
13 | diverse, inclusive, and healthy community.
14 |
15 | ## Our Standards
16 |
17 | Examples of behavior that contributes to a positive environment for our
18 | community include:
19 |
20 | - Demonstrating empathy and kindness toward other people
21 | - Being respectful of differing opinions, viewpoints, and experiences
22 | - Giving and gracefully accepting constructive feedback
23 | - Accepting responsibility and apologizing to those affected by our mistakes,
24 | and learning from the experience
25 | - Focusing on what is best not just for us as individuals, but for the
26 | overall community
27 |
28 | Examples of unacceptable behavior include:
29 |
30 | - The use of sexualized language or imagery, and sexual attention or
31 | advances of any kind
32 | - Trolling, insulting or derogatory comments, and personal or political attacks
33 | - Public or private harassment
34 | - Publishing others' private information, such as a physical or email
35 | address, without their explicit permission
36 | - Other conduct which could reasonably be considered inappropriate in a
37 | professional setting
38 |
39 | ## Enforcement Responsibilities
40 |
41 | Community leaders are responsible for clarifying and enforcing our standards of
42 | acceptable behavior and will take appropriate and fair corrective action in
43 | response to any behavior that they deem inappropriate, threatening, offensive,
44 | or harmful.
45 |
46 | Community leaders have the right and responsibility to remove, edit, or reject
47 | comments, commits, code, wiki edits, issues, and other contributions that are
48 | not aligned to this Code of Conduct, and will communicate reasons for moderation
49 | decisions when appropriate.
50 |
51 | ## Scope
52 |
53 | This Code of Conduct applies within all community spaces, and also applies when
54 | an individual is officially representing the community in public spaces.
55 | Examples of representing our community include using an official e-mail address,
56 | posting via an official social media account, or acting as an appointed
57 | representative at an online or offline event.
58 |
59 | ## Enforcement
60 |
61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
62 | reported to the community leaders responsible for enforcement at
63 | .
64 | All complaints will be reviewed and investigated promptly and fairly.
65 |
66 | All community leaders are obligated to respect the privacy and security of the
67 | reporter of any incident.
68 |
69 | ## Enforcement Guidelines
70 |
71 | Community leaders will follow these Community Impact Guidelines in determining
72 | the consequences for any action they deem in violation of this Code of Conduct:
73 |
74 | ### 1. Correction
75 |
76 | **Community Impact**: Use of inappropriate language or other behavior deemed
77 | unprofessional or unwelcome in the community.
78 |
79 | **Consequence**: A private, written warning from community leaders, providing
80 | clarity around the nature of the violation and an explanation of why the
81 | behavior was inappropriate. A public apology may be requested.
82 |
83 | ### 2. Warning
84 |
85 | **Community Impact**: A violation through a single incident or series
86 | of actions.
87 |
88 | **Consequence**: A warning with consequences for continued behavior. No
89 | interaction with the people involved, including unsolicited interaction with
90 | those enforcing the Code of Conduct, for a specified period of time. This
91 | includes avoiding interactions in community spaces as well as external channels
92 | like social media. Violating these terms may lead to a temporary or
93 | permanent ban.
94 |
95 | ### 3. Temporary Ban
96 |
97 | **Community Impact**: A serious violation of community standards, including
98 | sustained inappropriate behavior.
99 |
100 | **Consequence**: A temporary ban from any sort of interaction or public
101 | communication with the community for a specified period of time. No public or
102 | private interaction with the people involved, including unsolicited interaction
103 | with those enforcing the Code of Conduct, is allowed during this period.
104 | Violating these terms may lead to a permanent ban.
105 |
106 | ### 4. Permanent Ban
107 |
108 | **Community Impact**: Demonstrating a pattern of violation of community
109 | standards, including sustained inappropriate behavior, harassment of an
110 | individual, or aggression toward or disparagement of classes of individuals.
111 |
112 | **Consequence**: A permanent ban from any sort of public interaction within
113 | the community.
114 |
115 | ## Attribution
116 |
117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118 | version 2.0, available at
119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
120 |
121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct
122 | enforcement ladder](https://github.com/mozilla/diversity).
123 |
124 | For answers to common questions about this code of conduct, see the FAQ at
125 | https://www.contributor-covenant.org/faq. Translations are available at
126 | https://www.contributor-covenant.org/translations.
127 |
128 | [homepage]: https://www.contributor-covenant.org
129 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | OpenOPC for Python
2 | Copyright (c) 2008-2012 by Barry Barnreiter (barry_b@users.sourceforge.net)
3 | Copyright (c) 2014 by Anton D. Kachalov (mouse@yandex.ru)
4 | Copyright (c) 2017 by José A. Maita (jose.a.maita@gmail.com)
5 |
6 | This software is licensed under the terms of the GNU GPL v2 license plus
7 | a special linking exception for portions of the package.
8 |
9 | GNU GENERAL PUBLIC LICENSE
10 | Version 2, June 1991
11 |
12 | Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
13 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
14 | Everyone is permitted to copy and distribute verbatim copies
15 | of this license document, but changing it is not allowed.
16 |
17 | Preamble
18 |
19 | The licenses for most software are designed to take away your
20 | freedom to share and change it. By contrast, the GNU General Public
21 | License is intended to guarantee your freedom to share and change free
22 | software--to make sure the software is free for all its users. This
23 | General Public License applies to most of the Free Software
24 | Foundation's software and to any other program whose authors commit to
25 | using it. (Some other Free Software Foundation software is covered by
26 | the GNU Lesser General Public License instead.) You can apply it to
27 | your programs, too.
28 |
29 | When we speak of free software, we are referring to freedom, not
30 | price. Our General Public Licenses are designed to make sure that you
31 | have the freedom to distribute copies of free software (and charge for
32 | this service if you wish), that you receive source code or can get it
33 | if you want it, that you can change the software or use pieces of it
34 | in new free programs; and that you know you can do these things.
35 |
36 | To protect your rights, we need to make restrictions that forbid
37 | anyone to deny you these rights or to ask you to surrender the rights.
38 | These restrictions translate to certain responsibilities for you if you
39 | distribute copies of the software, or if you modify it.
40 |
41 | For example, if you distribute copies of such a program, whether
42 | gratis or for a fee, you must give the recipients all the rights that
43 | you have. You must make sure that they, too, receive or can get the
44 | source code. And you must show them these terms so they know their
45 | rights.
46 |
47 | We protect your rights with two steps: (1) copyright the software, and
48 | (2) offer you this license which gives you legal permission to copy,
49 | distribute and/or modify the software.
50 |
51 | Also, for each author's protection and ours, we want to make certain
52 | that everyone understands that there is no warranty for this free
53 | software. If the software is modified by someone else and passed on, we
54 | want its recipients to know that what they have is not the original, so
55 | that any problems introduced by others will not reflect on the original
56 | authors' reputations.
57 |
58 | Finally, any free program is threatened constantly by software
59 | patents. We wish to avoid the danger that redistributors of a free
60 | program will individually obtain patent licenses, in effect making the
61 | program proprietary. To prevent this, we have made it clear that any
62 | patent must be licensed for everyone's free use or not licensed at all.
63 |
64 | The precise terms and conditions for copying, distribution and
65 | modification follow.
66 |
67 | GNU GENERAL PUBLIC LICENSE
68 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
69 |
70 | 0. This License applies to any program or other work which contains
71 | a notice placed by the copyright holder saying it may be distributed
72 | under the terms of this General Public License. The "Program", below,
73 | refers to any such program or work, and a "work based on the Program"
74 | means either the Program or any derivative work under copyright law:
75 | that is to say, a work containing the Program or a portion of it,
76 | either verbatim or with modifications and/or translated into another
77 | language. (Hereinafter, translation is included without limitation in
78 | the term "modification".) Each licensee is addressed as "you".
79 |
80 | Activities other than copying, distribution and modification are not
81 | covered by this License; they are outside its scope. The act of
82 | running the Program is not restricted, and the output from the Program
83 | is covered only if its contents constitute a work based on the
84 | Program (independent of having been made by running the Program).
85 | Whether that is true depends on what the Program does.
86 |
87 | 1. You may copy and distribute verbatim copies of the Program's
88 | source code as you receive it, in any medium, provided that you
89 | conspicuously and appropriately publish on each copy an appropriate
90 | copyright notice and disclaimer of warranty; keep intact all the
91 | notices that refer to this License and to the absence of any warranty;
92 | and give any other recipients of the Program a copy of this License
93 | along with the Program.
94 |
95 | You may charge a fee for the physical act of transferring a copy, and
96 | you may at your option offer warranty protection in exchange for a fee.
97 |
98 | 2. You may modify your copy or copies of the Program or any portion
99 | of it, thus forming a work based on the Program, and copy and
100 | distribute such modifications or work under the terms of Section 1
101 | above, provided that you also meet all of these conditions:
102 |
103 | a) You must cause the modified files to carry prominent notices
104 | stating that you changed the files and the date of any change.
105 |
106 | b) You must cause any work that you distribute or publish, that in
107 | whole or in part contains or is derived from the Program or any
108 | part thereof, to be licensed as a whole at no charge to all third
109 | parties under the terms of this License.
110 |
111 | c) If the modified program normally reads commands interactively
112 | when run, you must cause it, when started running for such
113 | interactive use in the most ordinary way, to print or display an
114 | announcement including an appropriate copyright notice and a
115 | notice that there is no warranty (or else, saying that you provide
116 | a warranty) and that users may redistribute the program under
117 | these conditions, and telling the user how to view a copy of this
118 | License. (Exception: if the Program itself is interactive but
119 | does not normally print such an announcement, your work based on
120 | the Program is not required to print an announcement.)
121 |
122 | These requirements apply to the modified work as a whole. If
123 | identifiable sections of that work are not derived from the Program,
124 | and can be reasonably considered independent and separate works in
125 | themselves, then this License, and its terms, do not apply to those
126 | sections when you distribute them as separate works. But when you
127 | distribute the same sections as part of a whole which is a work based
128 | on the Program, the distribution of the whole must be on the terms of
129 | this License, whose permissions for other licensees extend to the
130 | entire whole, and thus to each and every part regardless of who wrote it.
131 |
132 | Thus, it is not the intent of this section to claim rights or contest
133 | your rights to work written entirely by you; rather, the intent is to
134 | exercise the right to control the distribution of derivative or
135 | collective works based on the Program.
136 |
137 | In addition, mere aggregation of another work not based on the Program
138 | with the Program (or with a work based on the Program) on a volume of
139 | a storage or distribution medium does not bring the other work under
140 | the scope of this License.
141 |
142 | 3. You may copy and distribute the Program (or a work based on it,
143 | under Section 2) in object code or executable form under the terms of
144 | Sections 1 and 2 above provided that you also do one of the following:
145 |
146 | a) Accompany it with the complete corresponding machine-readable
147 | source code, which must be distributed under the terms of Sections
148 | 1 and 2 above on a medium customarily used for software interchange; or,
149 |
150 | b) Accompany it with a written offer, valid for at least three
151 | years, to give any third party, for a charge no more than your
152 | cost of physically performing source distribution, a complete
153 | machine-readable copy of the corresponding source code, to be
154 | distributed under the terms of Sections 1 and 2 above on a medium
155 | customarily used for software interchange; or,
156 |
157 | c) Accompany it with the information you received as to the offer
158 | to distribute corresponding source code. (This alternative is
159 | allowed only for noncommercial distribution and only if you
160 | received the program in object code or executable form with such
161 | an offer, in accord with Subsection b above.)
162 |
163 | The source code for a work means the preferred form of the work for
164 | making modifications to it. For an executable work, complete source
165 | code means all the source code for all modules it contains, plus any
166 | associated interface definition files, plus the scripts used to
167 | control compilation and installation of the executable. However, as a
168 | special exception, the source code distributed need not include
169 | anything that is normally distributed (in either source or binary
170 | form) with the major components (compiler, kernel, and so on) of the
171 | operating system on which the executable runs, unless that component
172 | itself accompanies the executable.
173 |
174 | If distribution of executable or object code is made by offering
175 | access to copy from a designated place, then offering equivalent
176 | access to copy the source code from the same place counts as
177 | distribution of the source code, even though third parties are not
178 | compelled to copy the source along with the object code.
179 |
180 | 4. You may not copy, modify, sublicense, or distribute the Program
181 | except as expressly provided under this License. Any attempt
182 | otherwise to copy, modify, sublicense or distribute the Program is
183 | void, and will automatically terminate your rights under this License.
184 | However, parties who have received copies, or rights, from you under
185 | this License will not have their licenses terminated so long as such
186 | parties remain in full compliance.
187 |
188 | 5. You are not required to accept this License, since you have not
189 | signed it. However, nothing else grants you permission to modify or
190 | distribute the Program or its derivative works. These actions are
191 | prohibited by law if you do not accept this License. Therefore, by
192 | modifying or distributing the Program (or any work based on the
193 | Program), you indicate your acceptance of this License to do so, and
194 | all its terms and conditions for copying, distributing or modifying
195 | the Program or works based on it.
196 |
197 | 6. Each time you redistribute the Program (or any work based on the
198 | Program), the recipient automatically receives a license from the
199 | original licensor to copy, distribute or modify the Program subject to
200 | these terms and conditions. You may not impose any further
201 | restrictions on the recipients' exercise of the rights granted herein.
202 | You are not responsible for enforcing compliance by third parties to
203 | this License.
204 |
205 | 7. If, as a consequence of a court judgment or allegation of patent
206 | infringement or for any other reason (not limited to patent issues),
207 | conditions are imposed on you (whether by court order, agreement or
208 | otherwise) that contradict the conditions of this License, they do not
209 | excuse you from the conditions of this License. If you cannot
210 | distribute so as to satisfy simultaneously your obligations under this
211 | License and any other pertinent obligations, then as a consequence you
212 | may not distribute the Program at all. For example, if a patent
213 | license would not permit royalty-free redistribution of the Program by
214 | all those who receive copies directly or indirectly through you, then
215 | the only way you could satisfy both it and this License would be to
216 | refrain entirely from distribution of the Program.
217 |
218 | If any portion of this section is held invalid or unenforceable under
219 | any particular circumstance, the balance of the section is intended to
220 | apply and the section as a whole is intended to apply in other
221 | circumstances.
222 |
223 | It is not the purpose of this section to induce you to infringe any
224 | patents or other property right claims or to contest validity of any
225 | such claims; this section has the sole purpose of protecting the
226 | integrity of the free software distribution system, which is
227 | implemented by public license practices. Many people have made
228 | generous contributions to the wide range of software distributed
229 | through that system in reliance on consistent application of that
230 | system; it is up to the author/donor to decide if he or she is willing
231 | to distribute software through any other system and a licensee cannot
232 | impose that choice.
233 |
234 | This section is intended to make thoroughly clear what is believed to
235 | be a consequence of the rest of this License.
236 |
237 | 8. If the distribution and/or use of the Program is restricted in
238 | certain countries either by patents or by copyrighted interfaces, the
239 | original copyright holder who places the Program under this License
240 | may add an explicit geographical distribution limitation excluding
241 | those countries, so that distribution is permitted only in or among
242 | countries not thus excluded. In such case, this License incorporates
243 | the limitation as if written in the body of this License.
244 |
245 | 9. The Free Software Foundation may publish revised and/or new versions
246 | of the General Public License from time to time. Such new versions will
247 | be similar in spirit to the present version, but may differ in detail to
248 | address new problems or concerns.
249 |
250 | Each version is given a distinguishing version number. If the Program
251 | specifies a version number of this License which applies to it and "any
252 | later version", you have the option of following the terms and conditions
253 | either of that version or of any later version published by the Free
254 | Software Foundation. If the Program does not specify a version number of
255 | this License, you may choose any version ever published by the Free Software
256 | Foundation.
257 |
258 | 10. If you wish to incorporate parts of the Program into other free
259 | programs whose distribution conditions are different, write to the author
260 | to ask for permission. For software which is copyrighted by the Free
261 | Software Foundation, write to the Free Software Foundation; we sometimes
262 | make exceptions for this. Our decision will be guided by the two goals
263 | of preserving the free status of all derivatives of our free software and
264 | of promoting the sharing and reuse of software generally.
265 |
266 | NO WARRANTY
267 |
268 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
269 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
270 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
271 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
272 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
273 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
274 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
275 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
276 | REPAIR OR CORRECTION.
277 |
278 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
279 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
280 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
281 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
282 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
283 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
284 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
285 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
286 | POSSIBILITY OF SUCH DAMAGES.
287 |
288 | 13. Linking Exception (additional terms provided by OpenOPC project)
289 |
290 | The following linking exception applies only to the OpenOPC for Python
291 | library module (OpenOPC.py). It does not apply to any other files or
292 | or programs included as part of the OpenOPC project or this installation.
293 |
294 | As a special exception, the copyright holders of this library give you
295 | permission to link this library with independent modules to produce an
296 | executable, regardless of the license terms of these independent modules,
297 | and to copy and distribute the resulting executable under terms of your
298 | choice, provided that you also meet, for each linked independent module,
299 | the terms and conditions of the license of that module. An independent
300 | module is a module which is not derived from or based on this library.
301 | If you modify this library, you may extend this exception to your version
302 | of the library, but you are not obliged to do so. If you do not wish to
303 | do so, delete this exception statement from your version.
304 |
305 | END OF TERMS AND CONDITIONS
306 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | [](https://badge.fury.io/py/openopc2)
6 | 
7 |
8 | **OpenOPC 2** is a Python Library for OPC DA. It is Open source and free for everyone. It allows you to use
9 | [OPC Classic](https://opcfoundation.org/about/opc-technologies/opc-classic/) (OPC Data Access) in
10 | modern Python environments. OPC Classic is a pure Windows technology by design, but this library includes a Gateway Server
11 | that lets you use OPC Classic on any architecture (Linux, MacOS, Windows, Docker). So this Library creates a gateway
12 | between 2022 and the late 90ties. Like cruising into the sunset with Marty McFly in a Tesla.
13 |
14 | OpenOPC 2 is based on the OpenOPC Library that was initially created by Barry Barnleitner and hosted on Source Forge, but
15 | It was completely refactorerd and migrated to Python 3.8+
16 |
17 | # 🔥 Features
18 |
19 | - An OpenOPC Gateway Service (a Windows service providing remote access
20 | to the OpenOPC library, which is useful to avoid DCOM issues).
21 | - Command Line Interface (CLI)
22 | - Enables you to use OPC Classic with any Platform
23 | - CLI and Gateway are independent Executables that do not require Python
24 | - A system check module (allows you to check the health of your system)
25 | - A free OPC automation wrapper (required DLL file).
26 | - General documentation with updated procedures (this file).
27 |
28 | # 🐍 OpenOPC vs OpenOPC 2
29 |
30 | Open OPC 2 is based on OpenOPC and should be seen as a successor. If you already have an application that is based on
31 | OpenOPC, you can migrate with a minimal effort. Our main motivation to build this new version was to improve the developer
32 | experience and create a base for other developers that is easier to maintain, test and work with...
33 |
34 | - Simpler installation
35 | - Mostly the same api (but we take the freedom to not be compatible)
36 | - No memory leak in the OpenOpcService 🎉
37 | - Python 3.8+ (tested with 3.10)
38 | - Typings
39 | - Pyro5, increased security
40 | - We added tests 😎
41 | - Refactoring for increased readablity
42 | - Nicer CLI
43 | - Pipy Package
44 |
45 | # 🚀 Getting started
46 |
47 | For an indepth Tutorial in Spanish click here... Ándale
48 | [Spanish Tutorial ](https://joseamaita.com/blog/openopc-con-python-3/)
49 |
50 |
51 |
52 | ## Windows local installation
53 |
54 | The quickest way to start is the cli application. Start your OPC server and use the openopc2.exe cli application for test (no python
55 | installation required).
56 |
57 | Now you know that your OPC server is talking to OpenOPC 2. Then lets get started with python. If you use OpenOPC 2 with
58 | Python in windows directly you are **limited to a 32bit Python** installation. This is because the dlls of OPC are 32bit.
59 | If you prefer working with a 64bit Python version you can simply use the With OpenOPC Gateway.
60 |
61 |
62 |
63 | You must install the gbda_aut.dll (in /lib) which is the GrayboxOpcDa wrapper.
64 |
65 | http://gray-box.net/daawrapper.php?lang=en
66 |
67 | ```console
68 | python -m openopc2 list-servers
69 | ```
70 |
71 | ## Multi platform installation
72 |
73 | One of the main benefits of OpenOPC 2 is the OpenOPC gateway. This enables you to use any modern platform for
74 | developing your application. Start the OpenOPC service in the Windows environment where the OPC server is running.
75 | The Service starts a server (Pyro5) that lets you use the OpenOPC2 OpcDaClient on another machine. Due to the magic of
76 | Pyro (Python Remote Objects) the developer experience and usage of the Library remains the same as if you work in the
77 | local Windows setup.
78 |
79 | ([Download the executables here](https://github.com/iterativ/openopc2/releases/latest))
80 |
81 |
82 |
83 |
84 | On the Windows Machine open the console as administrator.
85 |
86 | ```shell
87 | openopcservice install
88 | openopcservice start
89 | ```
90 |
91 | On your Linux machine
92 |
93 | ```shell
94 | pip install openopc2
95 | ```
96 |
97 | python
98 |
99 | ```python
100 | from openopc2.da_client import OpcDaClient
101 | ```
102 |
103 | # ⚙️ Configuration
104 |
105 | The configuration of the OpenOpc 2 library and the OpenOpcGateway is done via environment variables.
106 |
107 | ```
108 | OPC_CLASS=Graybox.OPC.DAWrapper
109 | OPC_CLIENT=OpenOPC
110 | OPC_GATE_HOST=192.168.1.96 # IMPORTANT: Replace with your IP address
111 | OPC_GATE_PORT=7766
112 | OPC_HOST=localhost
113 | OPC_MODE=dcom
114 | OPC_SERVER=Matrikon.OPC.Simulation
115 | ```
116 |
117 | - If they are not set, open a command prompt window (`cmd`) and type:
118 |
119 | ```
120 | C:\>set ENV_VAR=VALUE
121 | C:\>set OPC_GATE_HOST=172.16.4.22 # this is an example
122 | ```
123 |
124 | - Alternately, Windows OS system or user environment variables work.
125 | Note that user environment variables take precedent over system environment
126 | variables.
127 |
128 | - Make sure the firewall is allowed to keep the port 7766 open. If in
129 | doubt, and you're doing a quick test, just turn off your firewall
130 | completely.
131 |
132 | - For easy testing, make sure an OPC server is installed in your Windows
133 | box (i.e. Matrikon OPC Simulation Server).
134 |
135 | - The work environment for testing these changes was a remote MacOs with Window10 64bit host and the Matrikon simulation
136 | server.
137 |
138 | - Register the OPC automation wrapper ( `gbda_aut.dll` ) by typing this
139 | in the command line:
140 |
141 | ```shell
142 | C:\openopc2\lib>regsvr32 gbda_aut.dll
143 | ```
144 |
145 | - If, for any reason, you want to uninstall this file and remove it from
146 | your system registry later, type this in the command line:
147 |
148 | ```shell
149 | C:\openopc2\lib>regsvr32 gbda_aut.dll -u
150 | ```
151 |
152 | # CLI
153 |
154 | The CLI (Command Line Interface) lets you use OpenOPC2 in the shell and offers you a quick way to explore your opc server
155 | and the OpenOPC DA client without the need of writing Python code.
156 |
157 | The documentation of the CLI can be found [here](CLI.md)
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
166 |
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 | # OpenOPC Gateway
176 |
177 | This task can be completed from one of two ways (make sure to have it
178 | installed first):
179 |
180 | - By clicking the `Start` link on the "OpenOPC Gateway Service" from the
181 | "Services" window (Start -> Control Panel -> System and Security ->
182 | Administrative Tools).
183 | - By running the `net start SERVICE` command like this:
184 |
185 | ```shell
186 | C:\openopc2\bin> zzzOpenOPCService
187 | ```
188 |
189 | - If you have problems starting the service, you can also try to start
190 | this in "debug" mode:
191 |
192 | ```shell
193 | C:\openopc2\src>python OpenOPCService.py debug
194 | ```
195 |
196 | ```shell
197 | C:\openopc2\>net stop zzzOpenOPCService
198 | ```
199 |
200 | ### Configure the way the OpenOPC Gateway Service starts
201 |
202 | If you are going to use this service frequently, it would be better to
203 | configure it to start in "automatic" mode. To do this:
204 |
205 | - Select the "OpenOPC Gateway Service" from the "Services" window
206 | (Start -> Control Panel -> System and Security -> Administrative Tools).
207 | - Right-click and choose "Properties".
208 | - Change the startup mode to "Automatic". Click "Apply" and "OK"
209 | buttons.
210 | - Start the service (if not already started).
211 |
212 | ## 🙏 Credits
213 |
214 | OpenOPC 2 is based on the OpenOPC python library that was originally created by Barry Barnleitner and its many Forks on
215 | Github. Without the great work of all the contributors, this would not be possible. Contribution is open for everyone.
216 |
217 | The authors of the package are (among others):
218 |
219 | | Years | | Name | User |
220 | | --------- | --- | ----------------- | ----------------------------- |
221 | | 2008-2012 | 🇺🇸 | Barry Barnreiter | barry_b@users.sourceforge.net |
222 | | 2014 | 🇷🇺 | Anton D. Kachalov | https://github.com/ya-mouse |
223 | | 2017 | 🇻🇪 | José A. Maita | https://github.com/joseamaita |
224 | | 2022 | 🇨🇭 | Lorenz Padberg | https://github.com/renzop |
225 | | 2022 | 🇨🇭 | Elia Bieri | https://github.com/eliabieri |
226 |
227 | ## 📜 License
228 |
229 | This software is licensed under the terms of the GNU GPL v2 license plus
230 | a special linking exception for portions of the package. This license is
231 | available in the `LICENSE.txt` file.
232 |
--------------------------------------------------------------------------------
/doc/OpenOPC.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/iterativ/openopc2/a91250fe4b560a22debfd5284c99ae51358b916a/doc/OpenOPC.pdf
--------------------------------------------------------------------------------
/doc/Roadmap.md:
--------------------------------------------------------------------------------
1 | Version 1.5
2 |
3 | Goals:
4 |
5 | - Create a maintainable repository
6 | - Integration tests coverage 80 %
7 | - Python 3.8, 3.9
8 | - Create executables for OpenOPCService and OPC cli
9 | - Create pypy package
10 | - remain compatible to 1.3 and 1.2
11 |
12 | Known Issues:
13 |
14 | - Return Values sometimes List of tuples and sometimes tuple... causes trouble complex code
15 | - write(tag, include_error=True) returns list of tuples (which should be tuple)
16 | - too many except: pass this is a very dangerous pattern
17 |
18 | Future Goals:
19 |
20 | - Proper Logging
21 | - code refactoring, reduce complexity increase readablility
22 | - replace response tuples with named tuples / dataclasses whatever is suitable
23 | - improve error handling
24 | - introduce encrypted protocol for gateway
25 | - write unittests
26 | - consolidate repositories and forks on github
27 | - test on multiple python versions and platforms
28 |
29 | Far away Goals:
30 |
31 | - OPC UA Gateway
32 | - OPC AE support
33 | - Rest API
34 |
--------------------------------------------------------------------------------
/doc/assets/LinuxSetup.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/iterativ/openopc2/a91250fe4b560a22debfd5284c99ae51358b916a/doc/assets/LinuxSetup.png
--------------------------------------------------------------------------------
/doc/assets/WindowsSetup.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/iterativ/openopc2/a91250fe4b560a22debfd5284c99ae51358b916a/doc/assets/WindowsSetup.png
--------------------------------------------------------------------------------
/doc/assets/cli_properties.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/iterativ/openopc2/a91250fe4b560a22debfd5284c99ae51358b916a/doc/assets/cli_properties.png
--------------------------------------------------------------------------------
/doc/assets/cli_read.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/iterativ/openopc2/a91250fe4b560a22debfd5284c99ae51358b916a/doc/assets/cli_read.png
--------------------------------------------------------------------------------
/doc/assets/cli_server-info.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/iterativ/openopc2/a91250fe4b560a22debfd5284c99ae51358b916a/doc/assets/cli_server-info.png
--------------------------------------------------------------------------------
/doc/assets/cli_write.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/iterativ/openopc2/a91250fe4b560a22debfd5284c99ae51358b916a/doc/assets/cli_write.png
--------------------------------------------------------------------------------
/doc/assets/open-opc.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/iterativ/openopc2/a91250fe4b560a22debfd5284c99ae51358b916a/doc/assets/open-opc.png
--------------------------------------------------------------------------------
/doc/tipps.md:
--------------------------------------------------------------------------------
1 | Start the OpenOPC Gateway locally
2 |
3 | python .\\OpenOPCService.py --foreground
4 |
--------------------------------------------------------------------------------
/examples/connect_discover_read_tags.py:
--------------------------------------------------------------------------------
1 | from tests.test_config import test_config
2 | from utils import get_opc_da_client
3 | import time
4 |
5 |
6 | def main():
7 | """
8 | This example show a few simple commands how to configure and connect an OPC server.
9 | for the ease of use print() is used extensively
10 | """
11 | open_opc_config = test_config()
12 | paths = "*"
13 | open_opc_config.OPC_SERVER = "Matrikon.OPC.Simulation"
14 | open_opc_config.OPC_GATEWAY_HOST = "192.168.0.115"
15 | open_opc_config.OPC_CLASS = "Graybox.OPC.DAWrapper"
16 | open_opc_config.OPC_MODE = 'com'
17 |
18 | limit = 10
19 | n_reads = 1
20 | sync = False
21 |
22 | opc_client = get_opc_da_client(open_opc_config)
23 | tags = opc_client.list(paths=paths, recursive=False, include_type=False, flat=True)
24 |
25 | tags = [tag for tag in tags if "@" not in tag]
26 | if limit:
27 | tags = tags[:limit]
28 | print("TAGS:")
29 | for n, tag in enumerate(tags):
30 | print(f"{n:3} {tag}")
31 | #
32 | print("READ:")
33 | for n, tag in enumerate(tags):
34 | start = time.time()
35 | read = opc_client.read(tag, sync=sync)
36 | print(f'{n:3} {time.time()-start:.3f}s {tag} {read}')
37 |
38 | print("READ: LIST")
39 | for n in range(n_reads):
40 | start = time.time()
41 | read = opc_client.read(tags, sync=sync)
42 | print(f'{n:3} {time.time()-start:.3f}s {read}')
43 |
44 | print("PROPERTIES:")
45 | for n, tag in enumerate(tags):
46 | properties = opc_client.properties(tag)
47 | print(f'{n:3} {time.time()-start:.3f}s {tag} {properties}')
48 |
49 | print("PROPERTIES LIST:")
50 | for n in range(n_reads):
51 | start = time.time()
52 | properties = opc_client.properties(tags)
53 | print(f'{n} {time.time()-start:.3f}s {properties}')
54 |
55 |
56 | if __name__ == '__main__':
57 | main()
--------------------------------------------------------------------------------
/lib/api-ms-win-core-path-l1-1-0.dll:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/iterativ/openopc2/a91250fe4b560a22debfd5284c99ae51358b916a/lib/api-ms-win-core-path-l1-1-0.dll
--------------------------------------------------------------------------------
/lib/gbda_aut.dll:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/iterativ/openopc2/a91250fe4b560a22debfd5284c99ae51358b916a/lib/gbda_aut.dll
--------------------------------------------------------------------------------
/openopc2/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/iterativ/openopc2/a91250fe4b560a22debfd5284c99ae51358b916a/openopc2/__init__.py
--------------------------------------------------------------------------------
/openopc2/__main__.py:
--------------------------------------------------------------------------------
1 | import openopc2.cli as cli
2 |
3 | if __name__ == '__main__':
4 | cli.cli()
5 |
--------------------------------------------------------------------------------
/openopc2/cli.py:
--------------------------------------------------------------------------------
1 | """
2 | Command line interface for OpenOPC Service
3 | """
4 | from typing import Optional, List, Dict, Any, Tuple, cast
5 |
6 | from Pyro5.errors import CommunicationError
7 |
8 | import typer
9 |
10 | # pylint: disable=redefined-builtin
11 | from rich import print
12 | from rich.console import Console
13 | from rich.table import Table
14 |
15 | from openopc2.config import OpenOpcConfig
16 | from openopc2.gateway_proxy import OpenOpcGatewayProxy
17 | from openopc2.da_client import OpcDaClient
18 | from openopc2.logger import log
19 |
20 | from openopc2.opc_types import ProtocolMode, DataSource, LogLevel
21 |
22 | open_opc_config = OpenOpcConfig()
23 |
24 | TagsArgument = typer.Argument(..., help='Tags to read')
25 | TagValuePairsArgument = typer.Argument(...,
26 | help='Tag value pairs to write (use ITEM=VALUE)'
27 | )
28 | OpcServerOption = typer.Option(open_opc_config.OPC_SERVER, help='OPC Server to connect to')
29 | OpcHostOption = typer.Option(open_opc_config.OPC_HOST, help='OPC Host to connect to')
30 | GatewayHostOption = typer.Option(open_opc_config.OPC_GATEWAY_HOST, help='OPC Gateway Host to connect to')
31 | GatewayPortOption = typer.Option(open_opc_config.OPC_GATEWAY_PORT,help='OPC Gateway Port to connect to')
32 | ProtocolModeOption = typer.Option(ProtocolMode.GATEWAY,help='Protocol mode', case_sensitive=False)
33 | GroupSizeOption = typer.Option(None, help='Group tags into group_size tags per transaction')
34 | DataSourceOption = typer.Option(DataSource.HYBRID,help='Data SOURCE for reads (cache, device, hybrid)', case_sensitive=False)
35 | IncludeErrorMessagesOption = typer.Option(False,help='Include descriptive error message strings')
36 | PauseOption = typer.Option(0, help='Sleep time between transactionsin milliseconds')
37 | UpdateRateOption = typer.Option(0, help='Update rate for group in milliseconds')
38 | TimeoutOption = typer.Option(10000, help='Read timeout in milliseconds')
39 | LogLevelOption = typer.Option(LogLevel.WARNING, help='Log level')
40 | OutputCsvOption = typer.Option(False, help='Output in CSV format')
41 | RecursiveOption = typer.Option(False, help="Recursively read sub-tags")
42 |
43 | app = typer.Typer()
44 |
45 |
46 | def get_connected_da_client(
47 | protocol_mode: ProtocolMode,
48 | opc_server: str,
49 | opc_host: str,
50 | gateway_host: str,
51 | gateway_port: int
52 | ) -> OpcDaClient:
53 | """
54 | Returns a OPC DA Client based on the given protocol mode
55 | """
56 | client: Optional[OpcDaClient] = None
57 | if ProtocolMode.COM == protocol_mode:
58 | client = OpcDaClient(open_opc_config)
59 |
60 | if ProtocolMode.GATEWAY == protocol_mode:
61 | client = cast(OpcDaClient, OpenOpcGatewayProxy(gateway_host, gateway_port).get_opc_da_client_proxy())
62 | if client is not None:
63 | client.connect(opc_server, opc_host)
64 | return client
65 | raise NotImplementedError(f"Protocol mode {protocol_mode} is unrecognized")
66 |
67 |
68 | @app.command()
69 | def read(
70 | tags: List[str] = TagsArgument,
71 | protocol_mode: ProtocolMode = ProtocolModeOption,
72 | opc_server: str = OpcServerOption,
73 | opc_host: str = OpcHostOption,
74 | gateway_host: str = GatewayHostOption,
75 | gateway_port: int = GatewayPortOption,
76 | group_size: Optional[int] = GroupSizeOption,
77 | pause: int = PauseOption,
78 | source: DataSource = DataSourceOption,
79 | update_rate: int = UpdateRateOption,
80 | timeout: int = TimeoutOption,
81 | include_error_messages: bool = IncludeErrorMessagesOption,
82 | output_csv: bool = OutputCsvOption,
83 | log_level: LogLevel = LogLevelOption,
84 | ) -> None:
85 | """
86 | Read tags
87 | """
88 | log.setLevel(log_level.upper())
89 | client = get_connected_da_client(
90 | protocol_mode,
91 | opc_server,
92 | opc_host,
93 | gateway_host,
94 | gateway_port
95 | )
96 | responses: List[str] = client.read(tags,
97 | group='test',
98 | size=group_size,
99 | pause=pause,
100 | source=source,
101 | update=update_rate,
102 | timeout=timeout,
103 | sync=True,
104 | include_error=include_error_messages
105 | )
106 | if output_csv:
107 | for response in responses:
108 | print(','.join(str(val) for val in response))
109 | return
110 |
111 | table = Table(title="Tags")
112 | table.add_column("Tag", style="cyan")
113 | table.add_column("Value", style="bold dark_green")
114 | table.add_column("Quality", style="gold3")
115 | table.add_column("Time", style="cyan")
116 | for response in responses:
117 | table.add_row(*(str(val) for val in response))
118 | Console().print(table)
119 |
120 |
121 | @app.command()
122 | def write(
123 | tag_value_pairs: List[str] = TagValuePairsArgument,
124 | protocol_mode: ProtocolMode = ProtocolModeOption,
125 | opc_server: str = OpcServerOption,
126 | opc_host: str = OpcHostOption,
127 | gateway_host: str = GatewayHostOption,
128 | gateway_port: int = GatewayPortOption,
129 | group_size: Optional[int] = GroupSizeOption,
130 | pause: int = PauseOption,
131 | include_error_messages: bool = IncludeErrorMessagesOption,
132 | log_level: LogLevel = LogLevelOption,
133 | ) -> None:
134 | """
135 | Write values
136 | """
137 | log.setLevel(log_level.upper())
138 |
139 | # Validate and transform tag value pairs
140 | tag_values: List[Tuple[str, str]] = []
141 | try:
142 | for tag in tag_value_pairs:
143 | tag_values.append(tuple(tag.split('=')))
144 | except IndexError:
145 | log.error('Write input must be in TAG=VALUE format')
146 | return
147 |
148 | try:
149 | print(f"Writing {len(tag_values)} value(s)...")
150 | responses: List[Tuple[str, str]] = get_connected_da_client(
151 | protocol_mode,
152 | opc_server,
153 | opc_host,
154 | gateway_host,
155 | gateway_port
156 | ).write(
157 | tag_value_pairs=tag_values,
158 | size=group_size,
159 | pause=pause,
160 | include_error=include_error_messages
161 | )
162 | console = Console()
163 | failed = list(filter(lambda response: response[1] != "Success",
164 | # Ugly hack to handle dynamic return value of write()
165 | [responses] if isinstance(responses, Tuple) else responses
166 | ))
167 | if failed:
168 | failed_tag_names = map(lambda response: response[0], failed)
169 | console.print(f"Failed to write {', '.join(failed_tag_names)}", style="bold red")
170 | else:
171 | print("Success")
172 | except CommunicationError as error:
173 | log.error(f"Could not write to OPC server: {error}")
174 |
175 |
176 | @app.command()
177 | def list_clients(log_level: LogLevel = LogLevelOption) -> None:
178 | """
179 | [EXPERIMENTAL] List clients of OpenOPC Gateway Server
180 | """
181 | log.setLevel(log_level.upper())
182 | console = Console()
183 | table = Table(title="OpenOPC Gateway Server Clients")
184 | with console.status("Getting clients..."):
185 | clients: List[Dict[str, Any]] = OpenOpcGatewayProxy().get_server_proxy().get_clients()
186 | if not clients:
187 | print('No clients found')
188 | return
189 |
190 | table.add_column("Client ID", style="cyan")
191 | table.add_column("TX time", style="magenta")
192 | table.add_column("Init time", style="green")
193 |
194 | for client in clients:
195 | table.add_row(client['client_id'], client['tx_time'], client['init_time'])
196 | console.print(table)
197 |
198 |
199 | @app.command()
200 | def list_tags(
201 | protocol_mode: ProtocolMode = ProtocolModeOption,
202 | opc_server: str = OpcServerOption,
203 | opc_host: str = OpcHostOption,
204 | gateway_host: str = GatewayHostOption,
205 | gateway_port: int = GatewayPortOption,
206 | recursive: bool = RecursiveOption,
207 | output_csv: bool = OutputCsvOption,
208 | log_level: LogLevel = LogLevelOption,
209 | ) -> None:
210 | """
211 | List tags (items) of OPC server
212 | """
213 | log.setLevel(log_level.upper())
214 | console = Console()
215 | tags: List[str]
216 | with console.status("Getting tags..."):
217 | tags = get_connected_da_client(
218 | protocol_mode,
219 | opc_server,
220 | opc_host,
221 | gateway_host,
222 | gateway_port
223 | ).list(recursive=recursive)
224 | if output_csv:
225 | print(','.join(tags))
226 | return
227 | table = Table(title="Tags", style="green")
228 | table.add_column("#")
229 | table.add_column("Tag name")
230 | for i, tag in enumerate(tags):
231 | table.add_row(str(i), tag)
232 | Console().print(table)
233 |
234 |
235 | @app.command()
236 | def properties(
237 | tags: List[str] = TagsArgument,
238 | protocol_mode: ProtocolMode = ProtocolModeOption,
239 | opc_server: str = OpcServerOption,
240 | opc_host: str = OpcHostOption,
241 | gateway_host: str = GatewayHostOption,
242 | gateway_port: int = GatewayPortOption,
243 | log_level: LogLevel = LogLevelOption,
244 | ) -> None:
245 | """
246 | Show properties of given tags
247 | """
248 | log.setLevel(log_level.upper())
249 | properties: List[Tuple[int, str, Any]] = get_connected_da_client(
250 | protocol_mode,
251 | opc_server,
252 | opc_host,
253 | gateway_host,
254 | gateway_port
255 | ).properties(tags)
256 | if not properties:
257 | print('No properties found')
258 | return
259 |
260 | table = Table(title="Properties")
261 | table.add_column("index", style="green")
262 | table.add_column("Tag Name", style="cyan")
263 | table.add_column("Data Type", style="dark_orange3")
264 | table.add_column("Access Rights", style="dark_orange3")
265 | table.add_column("Value", style="dark_orange3")
266 | for index, property in enumerate(properties):
267 | table.add_row(str(index), str(property.tag_name), str(property.data_type), str(property.access_rights), str(property.value),)
268 | Console().print(table)
269 |
270 |
271 | @app.command()
272 | def list_servers(
273 | protocol_mode: ProtocolMode = ProtocolModeOption,
274 | opc_server: str = OpcServerOption,
275 | opc_host: str = OpcHostOption,
276 | gateway_host: str = GatewayHostOption,
277 | gateway_port: int = GatewayPortOption,
278 | log_level: LogLevel = LogLevelOption,
279 | ) -> None:
280 | """
281 | List all OPC DA servers
282 | """
283 | log.setLevel(log_level.upper())
284 | servers = get_connected_da_client(
285 | protocol_mode,
286 | opc_server,
287 | opc_host,
288 | gateway_host,
289 | gateway_port
290 | ).servers()
291 | table = Table(title="Available OPC DA servers")
292 | table.add_column("#", style="green")
293 | table.add_column("Server Name", style="green")
294 |
295 | for i, value in enumerate(servers):
296 | table.add_row(str(i), value)
297 | Console().print(table)
298 |
299 |
300 | @app.command()
301 | def server_info(
302 | protocol_mode: ProtocolMode = ProtocolModeOption,
303 | opc_server: str = OpcServerOption,
304 | opc_host: str = OpcHostOption,
305 | gateway_host: str = GatewayHostOption,
306 | gateway_port: int = GatewayPortOption,
307 | log_level: LogLevel = LogLevelOption,
308 | ) -> None:
309 | """
310 | Display OPC server information
311 | """
312 | log.setLevel(log_level.upper())
313 | response: List[Tuple[str, str]] = get_connected_da_client(
314 | protocol_mode,
315 | opc_server,
316 | opc_host,
317 | gateway_host,
318 | gateway_port
319 | ).info()
320 | table = Table(title="Server info")
321 | table.add_column("Name", style="green")
322 | table.add_column("Value", style="dark_orange3")
323 |
324 | for value in response:
325 | table.add_row(value[0], value[1])
326 | Console().print(table)
327 |
328 | @app.command()
329 | def list_config(
330 | ) -> None:
331 | """
332 | Write values
333 | """
334 | OpenOpcConfig().print_config()
335 |
336 |
337 | def cli() -> None:
338 | """
339 | Command line interface for OpenOPC Gateway Service
340 | """
341 | try:
342 | app()
343 | except NameError:
344 | log.error("com is only supported on Windows. Use --protocol_mode gateway")
345 | except CommunicationError as error:
346 | log.error(f"Could not connect to OPC server: {error}")
347 |
348 |
349 | if __name__ == '__main__':
350 | cli()
351 |
--------------------------------------------------------------------------------
/openopc2/config.py:
--------------------------------------------------------------------------------
1 | import os
2 | from typing import Literal
3 |
4 |
5 | class OpenOpcConfig:
6 | def __init__(
7 | self,
8 | opc_host: str = "localhost",
9 | opc_server: str = os.environ.get("OPC_SERVER", "Matrikon.OPC.Simulation"),
10 | opc_client: str = "OpenOPC",
11 | opc_gateway_host: str = os.environ.get("OPC_GATE_HOST", "192.168.0.115"),
12 | opc_gateway_port: int = int(os.environ.get("OPC_GATE_PORT", 7766)),
13 | opc_class: str = os.environ.get("OPC_CLASS", "Graybox.OPC.DAWrapper"),
14 | opc_mode: Literal["GATEWAY", "COM"] = os.environ.get("OPC_MODE", "gateway"),
15 | opc_timeout: int = int(os.environ.get("OPC_TIMEOUT", 1000)),
16 | ):
17 | self.OPC_HOST = opc_host
18 | self.OPC_SERVER = opc_server
19 | self.OPC_CLIENT = opc_client
20 | self.OPC_GATEWAY_HOST = opc_gateway_host
21 | self.OPC_GATEWAY_PORT = opc_gateway_port
22 | self.OPC_CLASS = opc_class
23 | self.OPC_MODE = opc_mode
24 | self.OPC_TIMEOUT = opc_timeout
25 |
26 | def print_config(self):
27 | print("Open Opc Config:")
28 | for key, value in self.__dict__.items():
29 | print(f"{key:20} : {value}")
30 |
--------------------------------------------------------------------------------
/openopc2/da_client.py:
--------------------------------------------------------------------------------
1 | ###########################################################################
2 | #
3 | # OpenOPC for Python Library Module
4 | #
5 | # Copyright (c) 2007-2012 Barry Barnreiter (barry_b@users.sourceforge.net)
6 | # Copyright (c) 2014 Anton D. Kachalov (mouse@yandex.ru)
7 | # Copyright (c) 2017 José A. Maita (jose.a.maita@gmail.com)
8 | #
9 | ###########################################################################
10 |
11 | import logging
12 | import os
13 | import re
14 | import socket
15 | import string
16 | import sys
17 | import time
18 | import uuid
19 | from multiprocessing import Queue
20 |
21 | import Pyro5.core
22 |
23 | from openopc2 import system_health
24 | from openopc2.config import OpenOpcConfig
25 | from openopc2.exceptions import OPCError
26 | from openopc2.da_com import OpcCom
27 |
28 | from openopc2.logger import log
29 |
30 | SOURCE_CACHE = 1
31 | SOURCE_DEVICE = 2
32 | OPC_STATUS = (0, 'Running', 'Failed', 'NoConfig', 'Suspended', 'Test')
33 | BROWSER_TYPE = {0: 0,
34 | 1: 'Hierarchical',
35 | 2: 'Flat'}
36 |
37 | current_client = None
38 |
39 | # Win32 only modules not needed for 'open' protocol mode
40 | if os.name == 'nt':
41 | try:
42 | import win32com.client
43 | import win32com.server.util
44 | import win32event
45 | import pythoncom
46 | import pywintypes
47 |
48 | # Win32 variant types
49 | pywintypes.datetime = pywintypes.TimeType
50 | vt = dict([(pythoncom.__dict__[vtype], vtype) for vtype in pythoncom.__dict__.keys() if vtype[:2] == "VT"])
51 |
52 | # Allow gencache to create the cached wrapper objects
53 | win32com.client.gencache.is_readonly = False
54 | win32com.client.gencache.Rebuild(verbose=0)
55 |
56 | # So we can work on Windows in "open" protocol mode without the need for the win32com modules
57 | except ImportError as e:
58 | log.exception(e)
59 | win32com_found = False
60 | else:
61 | win32com_found = True
62 | else:
63 | win32com_found = False
64 |
65 |
66 | def type_check(tags):
67 | """Perform a type check on a list of tags"""
68 | single = type(tags) not in (list, tuple)
69 | tags = tags if tags else []
70 | tags = [tags] if single else tags
71 | valid = len([t for t in tags if type(t) not in (str, bytes)]) == 0
72 | return tags, single, valid
73 |
74 |
75 | def wild2regex(string):
76 | """Convert a Unix wildcard glob into a regular expression"""
77 | return string.replace('.', '\.').replace('*', '.*').replace('?', '.').replace('!', '^')
78 |
79 |
80 | def tags2trace(tags):
81 | """Convert a list tags into a formatted string suitable for the trace callback log"""
82 | arg_str = ''
83 | for i, t in enumerate(tags[1:]):
84 | if i > 0: arg_str += ','
85 | arg_str += '%s' % t
86 | return arg_str
87 |
88 |
89 | def exceptional(func, alt_return=None, alt_exceptions=(Exception,), final=None, catch=None):
90 | """Turns exceptions into an alternative return value"""
91 |
92 | def _exceptional(*args, **kwargs):
93 | try:
94 | try:
95 | return func(*args, **kwargs)
96 | except alt_exceptions:
97 | return alt_return
98 | except:
99 | if catch:
100 | return catch(sys.exc_info(), lambda: func(*args, **kwargs))
101 | raise
102 | finally:
103 | if final:
104 | final()
105 |
106 | return _exceptional
107 |
108 |
109 | class GroupEvents:
110 | def __init__(self):
111 | self.client = current_client
112 |
113 | def OnDataChange(self, TransactionID, NumItems, ClientHandles, ItemValues, Qualities, TimeStamps):
114 | self.client.callback_queue.put((TransactionID, ClientHandles, ItemValues, Qualities, TimeStamps))
115 |
116 |
117 | @Pyro5.api.expose # needed for 4.55+
118 | class OpcDaClient:
119 | def __init__(self, open_opc_config: OpenOpcConfig = OpenOpcConfig()):
120 | """Instantiate OPC automation class"""
121 |
122 | self.opc_server = open_opc_config.OPC_SERVER
123 | self.opc_host = open_opc_config.OPC_HOST
124 | self.client_name = open_opc_config.OPC_CLIENT
125 | self.connected = False
126 | self.client_id = uuid.uuid4()
127 | self.config = open_opc_config
128 | self._opc: OpcCom = OpcCom(open_opc_config.OPC_CLASS)
129 | self._groups = {}
130 | self._group_tags = {}
131 | self._group_valid_tags = {}
132 | self._group_server_handles = {}
133 | self._group_handles_tag = {}
134 | self._group_hooks = {}
135 | self._open_serv = None
136 | self._open_self = None
137 | self._open_guid = None
138 | self._prev_serv_time = None
139 | self._tx_id = 0
140 | self.trace = None
141 | self.cpu = None
142 |
143 | self.callback_queue = Queue()
144 | self._event = win32event.CreateEvent(None, 0, 0, None)
145 |
146 | def set_trace(self, trace):
147 | if self._open_serv is None:
148 | self.trace = trace
149 |
150 | def connect(self, opc_server: str | None = None, opc_host: str = 'localhost'):
151 | """Connect to the specified OPC server"""
152 |
153 | log.info(f"OPC DA OpcDaClient connecting to {opc_server} {opc_host}")
154 | self._opc.connect(opc_server, opc_host)
155 | self.connected = True
156 |
157 | # With some OPC servers, the next OPC call immediately after Connect()
158 | # will occationally fail. Sleeping for 1/100 second seems to fix this.
159 | time.sleep(0.01)
160 |
161 | self.opc_host = socket.gethostname() if opc_host == 'localhost' else opc_host
162 |
163 | # On reconnect we need to remove the old group names from OpenOPC's internal
164 | # cache since they are now invalid
165 | self._groups = {}
166 | self._group_tags = {}
167 | self._group_valid_tags = {}
168 | self._group_server_handles = {}
169 | self._group_handles_tag = {}
170 | self._group_hooks = {}
171 |
172 | def GUID(self):
173 | return self._open_guid
174 |
175 | def close(self, del_object=True):
176 | """Disconnect from the currently connected OPC server"""
177 |
178 | try:
179 | self.remove(self.groups())
180 |
181 | except pythoncom.com_error as err:
182 | error_msg = 'Disconnect: %s' % self._get_error_str(err)
183 | raise OPCError(error_msg)
184 |
185 | except OPCError:
186 | pass
187 |
188 | finally:
189 | if self.trace:
190 | self.trace('Disconnect()')
191 | self._opc.disconnect()
192 |
193 | # Remove this object from the open gateway service
194 | if self._open_serv and del_object:
195 | self._open_serv.release_client(self._open_self)
196 |
197 | def iread(self, tags=None, group=None, size=None, pause=0, source='hybrid', update=-1, timeout=5000, sync=False,
198 | include_error=False, rebuild=False):
199 | """Iterable version of read()"""
200 |
201 | def add_items(tags):
202 | names = list(tags)
203 |
204 | names.insert(0, 0)
205 | errors = []
206 |
207 | if self.trace:
208 | self.trace('Validate(%s)' % tags2trace(names))
209 |
210 | try:
211 | errors = opc_items.Validate(len(names) - 1, names)
212 | except:
213 | log.exception(f"Validation error {errors}")
214 | pass
215 |
216 | valid_tags = []
217 | valid_values = []
218 | client_handles = []
219 |
220 | if not sub_group in self._group_handles_tag:
221 | self._group_handles_tag[sub_group] = {}
222 | n = 0
223 | elif len(self._group_handles_tag[sub_group]) > 0:
224 | n = max(self._group_handles_tag[sub_group]) + 1
225 | else:
226 | n = 0
227 |
228 | for i, tag in enumerate(tags):
229 | if errors[i] == 0:
230 | valid_tags.append(tag)
231 | client_handles.append(n)
232 | self._group_handles_tag[sub_group][n] = tag
233 | n += 1
234 | elif include_error:
235 | error_msgs[tag] = self._opc.get_error_string(errors[i])
236 |
237 | if self.trace and errors[i] != 0:
238 | self.trace('%s failed validation' % tag)
239 |
240 | client_handles.insert(0, 0)
241 | valid_tags.insert(0, 0)
242 | server_handles = []
243 | errors = []
244 |
245 | if self.trace:
246 | self.trace('AddItems(%s)' % tags2trace(valid_tags))
247 |
248 | try:
249 | server_handles, errors = opc_items.AddItems(len(client_handles) - 1, valid_tags, client_handles)
250 | except Exception as e:
251 | log.exception("Error adding items to Group", exc_info=True)
252 | pass
253 |
254 | valid_tags_tmp = []
255 | server_handles_tmp = []
256 | valid_tags.pop(0)
257 |
258 | if not sub_group in self._group_server_handles:
259 | self._group_server_handles[sub_group] = {}
260 |
261 | for i, tag in enumerate(valid_tags):
262 | if errors[i] == 0:
263 | valid_tags_tmp.append(tag)
264 | server_handles_tmp.append(server_handles[i])
265 | self._group_server_handles[sub_group][tag] = server_handles[i]
266 | elif include_error:
267 | error_msgs[tag] = self._opc.GetErrorString(errors[i])
268 |
269 | valid_tags = valid_tags_tmp
270 | server_handles = server_handles_tmp
271 |
272 | return valid_tags, server_handles
273 |
274 | def remove_items(tags):
275 | if self.trace:
276 | self.trace('RemoveItems(%s)' % tags2trace([''] + tags))
277 | server_handles = [self._group_server_handles[sub_group][tag] for tag in tags]
278 | server_handles.insert(0, 0)
279 |
280 | try:
281 | errors = opc_items.Remove(len(server_handles) - 1, server_handles)
282 | except pythoncom.com_error as err:
283 | error_msg = 'RemoveItems: %s' % self._get_error_str(err)
284 | raise OPCError(error_msg)
285 |
286 | try:
287 | self._update_tx_time()
288 |
289 | if include_error:
290 | sync = True
291 |
292 | if sync:
293 | update = -1
294 |
295 | tags, single, valid = type_check(tags)
296 | if not valid:
297 | raise TypeError("iread(): 'tags' parameter must be a string or a list of strings")
298 |
299 | # Group exists
300 | if group in self._groups and not rebuild:
301 | num_groups = self._groups[group]
302 | data_source = SOURCE_CACHE
303 |
304 | # Group non-existant
305 | else:
306 | if size:
307 | # Break-up tags into groups of 'size' tags
308 | tag_groups = [tags[i:i + size] for i in range(0, len(tags), size)]
309 | else:
310 | tag_groups = [tags]
311 |
312 | num_groups = len(tag_groups)
313 | data_source = SOURCE_DEVICE
314 |
315 | for gid in range(num_groups):
316 | if gid > 0 and pause > 0:
317 | time.sleep(pause / 1000.0)
318 |
319 | error_msgs = {}
320 | opc_groups = self._opc.groups
321 | opc_groups.DefaultGroupUpdateRate = update
322 |
323 | # Anonymous group
324 | if group is None:
325 | try:
326 | if self.trace:
327 | self.trace('AddGroup()')
328 | opc_group = opc_groups.Add()
329 | except pythoncom.com_error as err:
330 | error_msg = 'AddGroup: %s' % self._get_error_str(err)
331 | raise OPCError(error_msg)
332 | sub_group = group
333 | new_group = True
334 | else:
335 | sub_group = '%s.%d' % (group, gid)
336 |
337 | # Existing named group
338 | try:
339 | if self.trace:
340 | self.trace('GetOPCGroup(%s)' % sub_group)
341 | opc_group = opc_groups.GetOPCGroup(sub_group)
342 | new_group = False
343 |
344 | # New named group
345 | except:
346 | try:
347 | if self.trace:
348 | self.trace('AddGroup(%s)' % sub_group)
349 | opc_group = opc_groups.Add(sub_group)
350 | except pythoncom.com_error as err:
351 | error_msg = 'AddGroup: %s' % self._get_error_str(err)
352 | raise OPCError(error_msg)
353 | self._groups[str(group)] = len(tag_groups)
354 | new_group = True
355 |
356 | opc_items = opc_group.OPCItems
357 |
358 | if new_group:
359 | opc_group.IsSubscribed = 1
360 | opc_group.IsActive = 1
361 | if not sync:
362 | if self.trace:
363 | self.trace('WithEvents(%s)' % opc_group.Name)
364 | global current_client
365 | current_client = self
366 | self._group_hooks[opc_group.Name] = win32com.client.WithEvents(opc_group, GroupEvents)
367 |
368 | tags = tag_groups[gid]
369 |
370 | valid_tags, server_handles = add_items(tags)
371 |
372 | self._group_tags[sub_group] = tags
373 | self._group_valid_tags[sub_group] = valid_tags
374 |
375 | # Rebuild existing group
376 | elif rebuild:
377 | tags = tag_groups[gid]
378 |
379 | valid_tags = self._group_valid_tags[sub_group]
380 | add_tags = [t for t in tags if t not in valid_tags]
381 | del_tags = [t for t in valid_tags if t not in tags]
382 |
383 | if len(add_tags) > 0:
384 | valid_tags, server_handles = add_items(add_tags)
385 | valid_tags = self._group_valid_tags[sub_group] + valid_tags
386 |
387 | if len(del_tags) > 0:
388 | remove_items(del_tags)
389 | valid_tags = [t for t in valid_tags if t not in del_tags]
390 |
391 | self._group_tags[sub_group] = tags
392 | self._group_valid_tags[sub_group] = valid_tags
393 |
394 | if source == 'hybrid':
395 | data_source = SOURCE_DEVICE
396 |
397 | # Existing group
398 | else:
399 | tags = self._group_tags[sub_group]
400 | valid_tags = self._group_valid_tags[sub_group]
401 | if sync:
402 | server_handles = [item.ServerHandle for item in opc_items]
403 |
404 | tag_value = {}
405 | tag_quality = {}
406 | tag_time = {}
407 | tag_error = {}
408 |
409 | # Sync Read
410 | if sync:
411 | values = []
412 | errors = []
413 | qualities = []
414 | timestamps = []
415 |
416 | if len(valid_tags) > 0:
417 | server_handles.insert(0, 0)
418 |
419 | if source != 'hybrid':
420 | data_source = SOURCE_CACHE if source == 'cache' else SOURCE_DEVICE
421 |
422 | if self.trace:
423 | self.trace('SyncRead(%s)' % data_source)
424 |
425 | try:
426 | values, errors, qualities, timestamps = opc_group.SyncRead(data_source,
427 | len(server_handles) - 1,
428 | server_handles)
429 | except pythoncom.com_error as err:
430 | error_msg = f"SyncRead: {self._get_error_str(err)}"
431 | raise OPCError(error_msg)
432 |
433 | for i, tag in enumerate(valid_tags):
434 | tag_value[tag] = values[i]
435 | tag_quality[tag] = qualities[i]
436 | tag_time[tag] = timestamps[i]
437 | tag_error[tag] = errors[i]
438 |
439 | # Async Read
440 | else:
441 | if len(valid_tags) > 0:
442 | if self._tx_id >= 0xFFFF:
443 | self._tx_id = 0
444 | self._tx_id += 1
445 |
446 | if source != 'hybrid':
447 | data_source = SOURCE_CACHE if source == 'cache' else SOURCE_DEVICE
448 |
449 | if self.trace:
450 | self.trace('AsyncRefresh(%s)' % data_source)
451 |
452 | try:
453 | opc_group.AsyncRefresh(data_source, self._tx_id)
454 | except pythoncom.com_error as err:
455 | error_msg = 'AsyncRefresh: %s' % self._get_error_str(err)
456 | raise OPCError(error_msg)
457 |
458 | tx_id = 0
459 | start = time.time() * 1000
460 |
461 | while tx_id != self._tx_id:
462 | now = time.time() * 1000
463 | if now - start > timeout:
464 | raise TimeoutError('Callback: Timeout waiting for data')
465 |
466 | if self.callback_queue.empty():
467 | pythoncom.PumpWaitingMessages()
468 | else:
469 | tx_id, handles, values, qualities, timestamps = self.callback_queue.get()
470 |
471 | for i, h in enumerate(handles):
472 | tag = self._group_handles_tag[sub_group][h]
473 | tag_value[tag] = values[i]
474 | tag_quality[tag] = qualities[i]
475 | tag_time[tag] = timestamps[i]
476 |
477 | for tag in tags:
478 | if tag in tag_value:
479 | if (not sync and len(valid_tags) > 0) or (sync and tag_error[tag] == 0):
480 | value = tag_value[tag]
481 | if type(value) == pywintypes.TimeType:
482 | value = str(value)
483 | quality = OpcCom.get_quality_string(tag_quality[tag])
484 | timestamp = str(tag_time[tag])
485 | else:
486 | value = None
487 | quality = 'Error'
488 | timestamp = None
489 | if include_error:
490 | error_msgs[tag] = self._opc.get_error_string(tag_error[tag]).strip('\r\n')
491 | else:
492 | value = None
493 | quality = 'Error'
494 | timestamp = None
495 | if tag and include_error and not error_msgs:
496 | error_msgs[tag] = ''
497 |
498 | if single:
499 | if include_error:
500 | yield value, quality, timestamp, error_msgs[tag]
501 | else:
502 | yield value, quality, timestamp
503 | else:
504 | if include_error:
505 | yield tag, value, quality, timestamp, error_msgs[tag]
506 | else:
507 | yield tag, value, quality, timestamp
508 |
509 | if group is None:
510 | try:
511 | if not sync and opc_group.Name in self._group_hooks:
512 | if self.trace:
513 | self.trace('CloseEvents(%s)' % opc_group.Name)
514 | self._group_hooks[opc_group.Name].close()
515 |
516 | if self.trace:
517 | self.trace('RemoveGroup(%s)' % opc_group.Name)
518 | opc_groups.Remove(opc_group.Name)
519 |
520 | except pythoncom.com_error as err:
521 | error_msg = 'RemoveGroup: %s' % self._get_error_str(err)
522 | raise OPCError(error_msg)
523 |
524 | except pythoncom.com_error as err:
525 | error_msg = f'read: {self._get_error_str(err)}'
526 | raise OPCError(error_msg)
527 |
528 | def read(self, tags=None, group=None, size=None, pause=0, source='hybrid', update=-1, timeout=5000, sync=False,
529 | include_error=False, rebuild=False):
530 | """Return list of (value, quality, time) tuples for the specified tag(s)"""
531 |
532 | tags_list, single, valid = type_check(tags)
533 | if not valid:
534 | raise TypeError("read(): 'tags' parameter must be a string or a list of strings")
535 |
536 | num_health_tags = len([t for t in tags_list if t[:1] == '@'])
537 | num_opc_tags = len([t for t in tags_list if t[:1] != '@'])
538 |
539 | if num_health_tags > 0:
540 | if num_opc_tags > 0:
541 | raise TypeError("read(): system health and OPC tags cannot be included in the same group")
542 | results = self._read_health(tags)
543 | else:
544 | results = self.iread(tags, group, size, pause, source, update, timeout, sync, include_error, rebuild)
545 |
546 | return list(results)[0] if single else list(results)
547 |
548 | def _read_health(self, tags):
549 | """Return values of special system health monitoring tags"""
550 |
551 | self._update_tx_time()
552 | tags, single, valid = type_check(tags)
553 |
554 | time_str = time.strftime('%x %H:%M:%S')
555 | results = []
556 | #
557 | for t in tags:
558 | # if t == '@MemFree':
559 | # value = system_health.mem_free()
560 | # elif t == '@MemUsed':
561 | # value = system_health.mem_used()
562 | # elif t == '@MemTotal':
563 | # value = system_health.mem_total()
564 | # elif t == '@MemPercent':
565 | # value = system_health.mem_percent()
566 | # elif t == '@DiskFree':
567 | # value = system_health.disk_free()
568 | # elif t == '@SineWave':
569 | # value = system_health.sine_wave()
570 | # elif t == '@SawWave':
571 | # value = system_health.saw_wave()
572 | #
573 | # elif t == '@CpuUsage':
574 | # if self.cpu is None:
575 | # self.cpu = system_health.CPU()
576 | # time.sleep(0.1)
577 | # value = self.cpu.get_usage()
578 | #
579 | # else:
580 | # value = None
581 | #
582 | # m = re.match('@TaskMem\((.*?)\)', t)
583 | # if m:
584 | # image_name = m.group(1)
585 | # value = system_health.task_mem(image_name)
586 | #
587 | # m = re.match('@TaskCpu\((.*?)\)', t)
588 | # if m:
589 | # image_name = m.group(1)
590 | # value = system_health.task_cpu(image_name)
591 | #
592 | # m = re.match('@TaskExists\((.*?)\)', t)
593 | # if m:
594 | # image_name = m.group(1)
595 | # value = system_health.task_exists(image_name)
596 | value = 10000
597 | if value is None:
598 | quality = 'Error'
599 | else:
600 | quality = 'Good'
601 |
602 | if single:
603 | results.append((value, quality, time_str))
604 | else:
605 | results.append((t, value, quality, time_str))
606 |
607 | return results
608 |
609 | def iwrite(self, tag_value_pairs, size=None, pause=0, include_error=False):
610 | """Iterable version of write()"""
611 |
612 | try:
613 | self._update_tx_time()
614 |
615 | def _valid_pair(p):
616 | if type(p) in (list, tuple) and len(p) >= 2 and type(p[0]) in (str, bytes):
617 | return True
618 | else:
619 | return False
620 |
621 | if type(tag_value_pairs) not in (list, tuple):
622 | raise TypeError(
623 | "write(): 'tag_value_pairs' parameter must be a (tag, value) tuple or a list of (tag,value) tuples")
624 |
625 | if tag_value_pairs is None:
626 | tag_value_pairs = ['']
627 | single = False
628 | elif type(tag_value_pairs[0]) in (str, bytes):
629 | tag_value_pairs = [tag_value_pairs]
630 | single = True
631 | else:
632 | single = False
633 |
634 | invalid_pairs = [p for p in tag_value_pairs if not _valid_pair(p)]
635 | if len(invalid_pairs) > 0:
636 | raise TypeError(
637 | "write(): 'tag_value_pairs' parameter must be a (tag, value) tuple or a list of (tag,value) tuples")
638 |
639 | names = [tag[0] for tag in tag_value_pairs]
640 | tags = [tag[0] for tag in tag_value_pairs]
641 | values = [tag[1] for tag in tag_value_pairs]
642 |
643 | # Break-up tags & values into groups of 'size' tags
644 | if size:
645 | name_groups = [names[i:i + size] for i in range(0, len(names), size)]
646 | tag_groups = [tags[i:i + size] for i in range(0, len(tags), size)]
647 | value_groups = [values[i:i + size] for i in range(0, len(values), size)]
648 | else:
649 | name_groups = [names]
650 | tag_groups = [tags]
651 | value_groups = [values]
652 |
653 | num_groups = len(tag_groups)
654 |
655 | status = []
656 |
657 | for gid in range(num_groups):
658 | if gid > 0 and pause > 0:
659 | time.sleep(pause / 1000.0)
660 |
661 | opc_groups = self._opc.groups
662 | opc_group = opc_groups.Add()
663 | opc_items = opc_group.OPCItems
664 |
665 | names = name_groups[gid]
666 | tags = tag_groups[gid]
667 | values = value_groups[gid]
668 |
669 | names.insert(0, 0)
670 | errors = []
671 |
672 | try:
673 | errors = opc_items.Validate(len(names) - 1, names)
674 | except:
675 | log.exception(errors)
676 | pass
677 |
678 | n = 1
679 | valid_tags = []
680 | valid_values = []
681 | client_handles = []
682 | error_msgs = {}
683 |
684 | for i, tag in enumerate(tags):
685 | if errors[i] == 0:
686 | valid_tags.append(tag)
687 | valid_values.append(values[i])
688 | client_handles.append(n)
689 | error_msgs[tag] = ''
690 | n += 1
691 | elif include_error:
692 | error_msgs[tag] = self._opc.get_error_string(errors[i])
693 | pass
694 |
695 | client_handles.insert(0, 0)
696 | valid_tags.insert(0, 0)
697 | server_handles = []
698 | errors = []
699 |
700 | try:
701 | server_handles, errors = opc_items.AddItems(len(client_handles) - 1, valid_tags, client_handles)
702 | except:
703 | pass
704 |
705 | valid_tags_tmp = []
706 | valid_values_tmp = []
707 | server_handles_tmp = []
708 | valid_tags.pop(0)
709 |
710 | for i, tag in enumerate(valid_tags):
711 | if errors[i] == 0:
712 | valid_tags_tmp.append(tag)
713 | valid_values_tmp.append(valid_values[i])
714 | server_handles_tmp.append(server_handles[i])
715 | error_msgs[tag] = ''
716 | elif include_error:
717 | error_msgs[tag] = self._opc.GetErrorString(errors[i])
718 |
719 | valid_tags = valid_tags_tmp
720 | valid_values = valid_values_tmp
721 | server_handles = server_handles_tmp
722 |
723 | server_handles.insert(0, 0)
724 | valid_values.insert(0, 0)
725 | errors = []
726 |
727 | if len(valid_values) > 1:
728 | try:
729 | errors = opc_group.SyncWrite(len(server_handles) - 1, server_handles, valid_values)
730 | except:
731 | pass
732 |
733 | n = 0
734 | for tag in tags:
735 | if tag in valid_tags:
736 | if errors[n] == 0:
737 | status = 'Success'
738 | else:
739 | status = 'Error'
740 | if include_error: error_msgs[tag] = self._opc.get_error_string(errors[n])
741 | n += 1
742 | else:
743 | status = 'Error'
744 |
745 | # OPC servers often include newline and carriage return characters
746 | # in their error message strings, so remove any found.
747 | if include_error: error_msgs[tag] = error_msgs[tag].strip('\r\n')
748 |
749 | if single:
750 | if include_error:
751 | yield (status, error_msgs[tag])
752 | else:
753 | yield status
754 | else:
755 | if include_error:
756 | yield (tag, status, error_msgs[tag])
757 | else:
758 | yield (tag, status)
759 |
760 | opc_groups.Remove(opc_group.Name)
761 |
762 | except pythoncom.com_error as err:
763 | error_msg = 'write: %s' % self._get_error_str(err)
764 | raise OPCError(error_msg)
765 |
766 | def write(self, tag_value_pairs, size=None, pause=0, include_error=False):
767 | """Write list of (tag, value) pair(s) to the server"""
768 | status = list(self.iwrite(tag_value_pairs, size, pause, include_error))
769 | return status
770 |
771 | def groups(self):
772 | """Return a list of active tag groups"""
773 | return self._groups.keys()
774 |
775 | def remove(self, groups):
776 | """Remove the specified tag group(s)"""
777 |
778 | try:
779 |
780 | opc_groups = self._opc.groups
781 |
782 | if type(groups) in (str, bytes):
783 | groups = [groups]
784 | single = True
785 | else:
786 | single = False
787 |
788 | status = []
789 |
790 | for group in groups:
791 | if group in self._groups:
792 | for i in range(self._groups[group]):
793 | sub_group = '%s.%d' % (group, i)
794 |
795 | if sub_group in self._group_hooks:
796 | if self.trace: self.trace('CloseEvents(%s)' % sub_group)
797 | self._group_hooks[sub_group].close()
798 |
799 | try:
800 | if self.trace: self.trace('RemoveGroup(%s)' % sub_group)
801 | errors = opc_groups.Remove(sub_group)
802 | except pythoncom.com_error as err:
803 | error_msg = 'RemoveGroup: %s' % self._get_error_str(err)
804 | raise OPCError(error_msg)
805 |
806 | del (self._group_tags[sub_group])
807 | del (self._group_valid_tags[sub_group])
808 | del (self._group_handles_tag[sub_group])
809 | del (self._group_server_handles[sub_group])
810 | del (self._groups[group])
811 |
812 | except pythoncom.com_error as err:
813 | error_msg = 'remove: %s' % self._get_error_str(err)
814 | raise OPCError(error_msg)
815 |
816 | def iproperties(self, tags, property_ids: list = []):
817 | """Iterable version of properties()"""
818 |
819 | self._update_tx_time()
820 |
821 | tags, single_tag, valid = type_check(tags)
822 | if not valid:
823 | raise TypeError("properties(): 'tags' parameter must be a string or a list of strings")
824 |
825 | for tag in tags:
826 | tag_properties, errors = self._opc.get_tag_properties(tag, property_ids)
827 | yield tag_properties
828 |
829 | def properties(self, tags, id=None):
830 | """Return list of property tuples (id, name, value) for the specified tag(s) """
831 |
832 | single = type(tags) not in (list, tuple) and type(id) not in (type(None), list, tuple)
833 | props = list(self.iproperties(tags, id))
834 | return props[0] if single else props
835 |
836 | def ilist(self, paths='*', recursive: bool = False, flat: bool = False, include_type: bool = False,
837 | access_rights: int = None):
838 | """Iterable version of list()
839 |
840 | """
841 |
842 | try:
843 | self._update_tx_time()
844 |
845 | try:
846 | browser = self._opc.create_browser()
847 | # For OPC servers that don't support browsing
848 | except:
849 | log.exception("This Server does not support Browsing")
850 | return
851 |
852 | if access_rights:
853 | browser.AccessRights = access_rights
854 |
855 | paths, single, valid = type_check(paths)
856 | if not valid:
857 | raise TypeError("list(): 'paths' parameter must be a string or a list of strings")
858 |
859 | if len(paths) == 0:
860 | paths = ['*']
861 | nodes = {}
862 |
863 | for path in paths:
864 |
865 | if flat:
866 | browser.MoveToRoot()
867 | browser.Filter = ''
868 | browser.ShowLeafs(True)
869 | pattern = re.compile('^%s$' % wild2regex(path), re.IGNORECASE)
870 | matches = filter(pattern.search, browser)
871 |
872 | if include_type:
873 | matches = [(x, node_type) for x in matches]
874 |
875 | for node in matches:
876 | yield node
877 | continue
878 |
879 | queue = list()
880 | queue.append(path)
881 |
882 | while len(queue) > 0:
883 | tag = queue.pop(0)
884 |
885 | browser.MoveToRoot()
886 | browser.Filter = ''
887 | pattern = None
888 |
889 | path_str = '/'
890 | path_list = tag.replace('.', '/').split('/')
891 | path_list = [p for p in path_list if len(p) > 0]
892 | found_filter = False
893 | path_postfix = '/'
894 |
895 | for i, p in enumerate(path_list):
896 | if found_filter:
897 | path_postfix += p + '/'
898 | elif p.find('*') >= 0:
899 | pattern = re.compile('^%s$' % wild2regex(p), re.IGNORECASE)
900 | found_filter = True
901 | elif len(p) != 0:
902 | pattern = re.compile('^.*$')
903 | browser.ShowBranches()
904 |
905 | # Branch node, so move down
906 | if len(browser) > 0:
907 | try:
908 | browser.MoveDown(p)
909 | path_str += p + '/'
910 | except:
911 | if i < len(path_list) - 1: return
912 | pattern = re.compile('^%s$' % wild2regex(p), re.IGNORECASE)
913 |
914 | # Leaf node, so append all remaining path parts together
915 | # to form a single search expression
916 | else:
917 | p = string.join(path_list[i:], '.')
918 | pattern = re.compile('^%s$' % wild2regex(p), re.IGNORECASE)
919 | break
920 |
921 | browser.ShowBranches()
922 |
923 | if len(browser) == 0:
924 | browser.ShowLeafs(False)
925 | lowest_level = True
926 | node_type = 'Leaf'
927 | else:
928 | lowest_level = False
929 | node_type = 'Branch'
930 |
931 | matches = filter(pattern.search, browser)
932 |
933 | if not lowest_level and recursive:
934 | queue += [path_str + x + path_postfix for x in matches]
935 | else:
936 | if lowest_level:
937 | matches = [exceptional(browser.GetItemID, x)(x) for x in matches]
938 | if include_type:
939 | matches = [(x, node_type) for x in matches]
940 | for node in matches:
941 | if not node in nodes:
942 | yield node
943 | nodes[node] = True
944 |
945 | except pythoncom.com_error as err:
946 | error_msg = 'list: %s' % self._get_error_str(err)
947 | raise OPCError(error_msg)
948 |
949 | def list(self, paths='*', recursive=False, flat=False, include_type=False, access_rights: int=None):
950 | """Return list of item nodes at specified path(s) (tree browser)"""
951 |
952 | nodes = self.ilist(paths, recursive, flat, include_type, access_rights)
953 | return list(nodes)
954 |
955 | def servers(self, opc_host='localhost'):
956 | """Return list of available OPC servers"""
957 |
958 | try:
959 |
960 | servers = self._opc.get_opc_servers(opc_host)
961 | servers = [s for s in servers if s != None]
962 | return servers
963 |
964 | except pythoncom.com_error as err:
965 | error_msg = 'servers: %s' % self._get_error_str(err)
966 | raise OPCError(error_msg)
967 |
968 | def info(self):
969 | """Return list of (name, value) pairs about the OPC server"""
970 |
971 | try:
972 | self._update_tx_time()
973 |
974 | info_list = []
975 | info_list += [('Protocol', 'gateway' if self._open_serv else 'com')]
976 | info_list += [('Class', self._opc.opc_class)]
977 | info_list += [('Client Name', self._opc.client_name)]
978 | info_list += [('OPC Host', self.opc_host)]
979 | info_list += [('OPC Server', self._opc.server_name)]
980 | info_list += [('State', OPC_STATUS[self._opc.server_state])]
981 | info_list += [
982 | ('Version', f'{self._opc.major_version}.{self._opc.minor_version} (Build{self._opc.build_number})')]
983 |
984 | browser = self._opc.create_browser()
985 | browser_type = BROWSER_TYPE.get(browser.Organization, 'Not Supported')
986 |
987 | info_list += [('Browser', browser_type)]
988 | info_list += [('Start Time', str(self._opc.start_time))]
989 | info_list += [('Current Time', str(self._opc.current_time))]
990 | info_list += [('Vendor', self._opc.vendor_info)]
991 |
992 | return info_list
993 |
994 | except pythoncom.com_error as err:
995 | error_msg = 'info: %s' % self._get_error_str(err)
996 | raise OPCError(error_msg)
997 |
998 | def ping(self):
999 | """Check if we are still talking to the OPC server"""
1000 | try:
1001 | # Convert OPC server time to milliseconds
1002 | opc_serv_time = int(float(self._opc.current_time.timestamp()) * 1000)
1003 | if opc_serv_time == self._prev_serv_time:
1004 | return False
1005 | else:
1006 | self._prev_serv_time = opc_serv_time
1007 | return True
1008 | except pythoncom.com_error:
1009 | return False
1010 |
1011 | def _get_error_str(self, err):
1012 | """Return the error string for a OPC or COM error code"""
1013 |
1014 | hr, msg, exc, arg = err.args
1015 |
1016 | if exc == None:
1017 | error_str = str(msg)
1018 | else:
1019 | scode = exc[5]
1020 |
1021 | try:
1022 | opc_err_str = self._opc.GetErrorString(scode).strip('\r\n')
1023 | except:
1024 | opc_err_str = None
1025 |
1026 | try:
1027 | com_err_str = pythoncom.GetScodeString(scode).strip('\r\n')
1028 | except:
1029 | com_err_str = None
1030 |
1031 | # OPC error codes and COM error codes are overlapping concepts,
1032 | # so we combine them together into a single error message.
1033 |
1034 | if opc_err_str is None and com_err_str is None:
1035 | error_str = str(scode)
1036 | elif opc_err_str is com_err_str:
1037 | error_str = opc_err_str
1038 | elif opc_err_str is None:
1039 | error_str = com_err_str
1040 | elif com_err_str is None:
1041 | error_str = opc_err_str
1042 | else:
1043 | error_str = '%s (%s)' % (opc_err_str, com_err_str)
1044 |
1045 | return error_str
1046 |
1047 | def _update_tx_time(self):
1048 | """Update the session's last transaction time in the Gateway Service"""
1049 | if self._open_serv:
1050 | self._open_serv.tx_times[self._open_guid] = time.time()
1051 |
1052 | def __getitem__(self, key):
1053 | """Read single item (tag as dictionary key)"""
1054 | value, quality, time = self.read(key)
1055 | return value
1056 |
1057 | def __setitem__(self, key, value):
1058 | """Write single item (tag as dictionary key)"""
1059 | self.write((key, value))
1060 | return
1061 |
--------------------------------------------------------------------------------
/openopc2/da_com.py:
--------------------------------------------------------------------------------
1 | import os
2 | import string
3 |
4 | import Pyro5.core
5 |
6 | from openopc2.exceptions import OPCError
7 | from openopc2.opc_types import ACCESS_RIGHTS, OPC_QUALITY, TagPropertyItem, TagProperties
8 | from openopc2.pythoncom_datatypes import VtType
9 | from openopc2.logger import log
10 |
11 | # Win32 only modules not needed for 'open' protocol mode
12 | if os.name == 'nt':
13 | try:
14 | # TODO: chose bewtween pywin pythoncom and wind32 but do not use both
15 | import pythoncom
16 | import pywintypes
17 | import win32com.client
18 | import win32com.server.util
19 |
20 | # Win32 variant types
21 | pywintypes.datetime = pywintypes.TimeType
22 |
23 | # Allow gencache to create the cached wrapper objects
24 | win32com.client.gencache.is_readonly = False
25 | win32com.client.gencache.Rebuild(verbose=0)
26 |
27 | # So we can work on Windows in "gateway" protocol mode without the need for the win32com modules
28 | except ImportError as e:
29 | log.exception(e)
30 | win32com_found = False
31 | else:
32 | win32com_found = True
33 | else:
34 | win32com_found = False
35 |
36 |
37 | @Pyro5.api.expose
38 | class OpcCom:
39 | """
40 | This class handles the com interface of the OPC DA server.
41 | """
42 |
43 | def __init__(self, opc_class: str):
44 | # TODO: Get browser type (hierarchical etc)
45 | self.server: str | None = None
46 | self.host: str = 'localhost'
47 | self.groups = None
48 | self.opc_class = opc_class
49 | self.client_name = None
50 | self.server_name = None
51 | self.server_state = None
52 | self.major_version = None
53 | self.minor_version = None
54 | self.build_number = None
55 | self.start_time = None
56 | self.current_time = None
57 | self.vendor_info = None
58 | self.opc_client = None
59 | self.initialize_client(opc_class)
60 |
61 | def initialize_client(self, opc_class):
62 | try:
63 | log.info(f"Initialize OPC DA OpcDaClient: '{opc_class}'")
64 | pythoncom.CoInitialize()
65 | self.opc_client = win32com.client.gencache.EnsureDispatch(opc_class, 0)
66 | except pythoncom.com_error as err:
67 | # TODO: potential memory leak, destroy pythoncom
68 | log.exception('Error in initialize OpcDaClient')
69 | pythoncom.CoUninitialize()
70 | raise OPCError(f'Dispatch: {err} opc_class:"{opc_class}"')
71 |
72 | def connect(self, server: str | None, host: str):
73 | self.server = server
74 | self.host = host
75 | try:
76 | log.info(f"Connecting OPC Client Com interface: '{self.server}', '{self.host}'")
77 | self.opc_client.Connect(self.server, self.host)
78 | except Exception as error:
79 | log.error(f"Error Connecting OPC Client Com interface: Server: '{self.server}', Host: '{self.host}', Error: '{error}'")
80 |
81 | log.exception('Error connecting OPC Client', exc_info=True)
82 | pass
83 | self.groups = self.opc_client.OPCGroups
84 | self.client_name = self.opc_client.ClientName
85 | self.server_name = self.opc_client.ServerName
86 | self.server_state = self.opc_client.ServerState
87 | self.major_version = self.opc_client.MajorVersion
88 | self.minor_version = self.opc_client.MinorVersion
89 | self.build_number = self.opc_client.BuildNumber
90 | self.start_time = self.opc_client.StartTime
91 | self.current_time = self.opc_client.CurrentTime
92 | self.vendor_info = self.opc_client.VendorInfo
93 |
94 | def create_browser(self):
95 | return self.opc_client.CreateBrowser()
96 |
97 | def disconnect(self):
98 | self.opc_client.Disconnect()
99 |
100 | def server_name(self):
101 | return self.opc_client.ServerName
102 |
103 | def get_opc_servers(self, opc_host):
104 | return self.opc_client.GetOPCServers(opc_host)
105 |
106 | def get_available_properties(self, tag):
107 | """
108 | Return the available properites of that specific server. Be aware that the properties names are different between
109 | different servers, there is no consistency here . It seems that at least the property ids are consistent.
110 | """
111 |
112 | try:
113 | (count, property_id, descriptions, datatypes) = list(self.opc_client.QueryAvailableProperties(tag))
114 | self.available_properties_cache = (count, property_id, descriptions, datatypes)
115 | return count, property_id, descriptions, datatypes
116 | except pythoncom.com_error as err:
117 | error_msg = err#'properties: %s' % self._get_error_str(err)
118 | raise OPCError(error_msg)
119 |
120 |
121 | def _property_value_conversion(self, description, input_value):
122 | value = input_value
123 | # Different servers have different writings
124 | if description in {'Item Canonical DataType', 'Item Canonical Data Type'}:
125 | value = VtType(value).name
126 | elif description in {'Item Timestamp', 'Item TimeStamp'}:
127 | value = str(value)
128 | elif description == 'Item Access Rights':
129 | value = ACCESS_RIGHTS[value]
130 | elif description == 'Item Quality':
131 | if value > 3:
132 | value = 3
133 | value = OPC_QUALITY[value]
134 | else:
135 | pass
136 | #print(f'Error: Could not find description "{description}" and value {input_value}')
137 |
138 | return value
139 |
140 | def get_tag_properties(self, tag, property_ids=[]) -> TagProperties:
141 | """
142 | This method returns the Properties of a tag. If you want to read many tags from a server it is
143 | recommended to only read the property ids that are required. Testing has shown, that this method is
144 | quite slow and leads to crashes on some servers.
145 |
146 | """
147 | property_ids_filter = property_ids
148 | properties_by_id = TagProperties().get_default_tag_properies_by_id()
149 |
150 | if not property_ids:
151 | count, property_ids, descriptions, datatypes = self.get_available_properties(tag)
152 |
153 | for result in zip(property_ids, descriptions, datatypes):
154 | property_item = properties_by_id.get(result[0], TagPropertyItem())
155 | property_item.property_id = result[0]
156 | property_item.description = result[1]
157 | property_item.available = True
158 | property_item.data_type = VtType(result[2]).name
159 | properties_by_id[result[0]] = property_item
160 |
161 | property_ids_cleaned = [p for p in property_ids if p > 0]
162 |
163 | if property_ids_filter:
164 | property_ids_cleaned = [p for p in property_ids if p in property_ids_filter]
165 | try:
166 | # print(f"self.opc_client.GetItemProperties('{item_id}', {len(property_ids_cleaned)},{property_ids_cleaned})")
167 | # GetItemProperties needs property id "0" to work properly.
168 | item_properties_values, errors = self.opc_client.GetItemProperties(tag, len(property_ids_cleaned), [0] + property_ids_cleaned)
169 | except pythoncom.com_error as err:
170 | error_msg = f"Error reading properties of '{tag}' {self._get_error_str(err)}"
171 | raise OPCError(error_msg)
172 |
173 | for (property_id, property_value) in zip(property_ids_cleaned, item_properties_values):
174 | property_item = properties_by_id[property_id]
175 | property_item.value = self._property_value_conversion(property_item.description, property_value)
176 |
177 | tag_properties = TagProperties().from_tag_property_items_by_id(tag, properties_by_id)
178 | return tag_properties, errors
179 |
180 | def get_error_string(self, error_id: int):
181 | return self.opc_client.GetErrorString(error_id)
182 |
183 | def _get_error_str(self, err):
184 | """Return the error string for a OPC or COM error code"""
185 |
186 | hr, msg, exc, arg = err.args
187 |
188 | if exc == None:
189 | error_str = str(msg)
190 | else:
191 | scode = exc[5]
192 |
193 | try:
194 | opc_err_str = self._opc.GetErrorString(scode).strip('\r\n')
195 | except:
196 | opc_err_str = None
197 |
198 | try:
199 | com_err_str = pythoncom.GetScodeString(scode).strip('\r\n')
200 | except:
201 | com_err_str = None
202 |
203 | # OPC error codes and COM error codes are overlapping concepts,
204 | # so we combine them together into a single error message.
205 |
206 | if opc_err_str is None and com_err_str is None:
207 | error_str = str(scode)
208 | elif opc_err_str is com_err_str:
209 | error_str = opc_err_str
210 | elif opc_err_str is None:
211 | error_str = com_err_str
212 | elif com_err_str is None:
213 | error_str = opc_err_str
214 | else:
215 | error_str = '%s (%s)' % (opc_err_str, com_err_str)
216 |
217 | return error_str
218 |
219 | def __str__(self):
220 | return f"OPCCom Object: {self.host} {self.server} {self.major_version}.{self.minor_version}"
221 |
222 | @staticmethod
223 | def get_quality_string(quality_bits):
224 | """Convert OPC quality bits to a descriptive string"""
225 |
226 | quality = (quality_bits >> 6) & 3
227 | return OPC_QUALITY[quality]
228 |
229 | @staticmethod
230 | def get_vt_type(datatype_number: int):
231 | return VtType(datatype_number).name
232 |
--------------------------------------------------------------------------------
/openopc2/exceptions.py:
--------------------------------------------------------------------------------
1 | import Pyro5.api
2 |
3 |
4 | @Pyro5.api.expose
5 | class TimeoutError(Exception):
6 | def __init__(self, txt):
7 | Exception.__init__(self, txt)
8 |
9 | __dict__ = None
10 |
11 |
12 | @Pyro5.api.expose
13 | class OPCError(Exception):
14 | def __init__(self, message):
15 | super(OPCError, self).__init__(self, message)
16 | self.custom_message = message
17 |
18 | def class_to_dict(self):
19 | default = self.__dict__
20 | default["__class__"] = "exceptions.OPCError"
21 | return default
22 |
23 | @classmethod
24 | def dict_to_class(cls, class_name, opc_error_dict):
25 | opc_error_dict.pop("__class__")
26 | p = OPCError(opc_error_dict.get('custom_message','No message'))
27 | return p
28 |
--------------------------------------------------------------------------------
/openopc2/gateway_proxy.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Iterativ GmbH
4 | # http://www.iterativ.ch/
5 | #
6 | # Copyright (c) 2015 Iterativ GmbH. All rights reserved.
7 | #
8 | # Created on 2022-10-24
9 | # @author: lorenz.padberg@iterativ.ch
10 | import Pyro5.client
11 | from Pyro5.api import register_class_to_dict, register_dict_to_class
12 |
13 | from openopc2.opc_types import TagProperties
14 | from openopc2.exceptions import OPCError
15 |
16 |
17 | class OpenOpcGatewayProxy:
18 | def __init__(self, host: str = 'localhost', port: int = 7766):
19 | self.host = host
20 | self.port = port
21 |
22 | # Register custom serializers
23 | register_class_to_dict(TagProperties, TagProperties.class_to_dict)
24 | register_dict_to_class("opc_types.TagProperties", TagProperties.dict_to_class)
25 | register_class_to_dict(OPCError, OPCError.class_to_dict)
26 | register_dict_to_class("exceptions.OPCError", OPCError.dict_to_class)
27 |
28 | def get_server_proxy(self):
29 | with Pyro5.client.Proxy(f"PYRO:OpenOpcGatewayServer@{self.host}:{self.port}") as open_opc_gateway_server:
30 | return open_opc_gateway_server
31 |
32 | def get_opc_da_client_proxy(self):
33 | with Pyro5.client.Proxy(f"PYRO:OpcDaClient@{self.host}:{self.port}") as opc_da_client_proxy:
34 | return opc_da_client_proxy
35 |
--------------------------------------------------------------------------------
/openopc2/gateway_server.py:
--------------------------------------------------------------------------------
1 |
2 | import time
3 |
4 | import Pyro5.server
5 | from Pyro5.api import register_class_to_dict, register_dict_to_class
6 |
7 | from openopc2.config import OpenOpcConfig
8 | from openopc2.da_client import OpcDaClient
9 | from openopc2.opc_types import TagProperties
10 | from openopc2.exceptions import OPCError
11 |
12 | from openopc2.logger import log
13 |
14 | # Do not import Rich print in this context, it will fail while running as a service, really hard to debug!
15 |
16 |
17 |
18 | @Pyro5.api.expose
19 | class OpenOpcGatewayServer:
20 | def __init__(self, host, port):
21 | self.host = str(host)
22 | self.port = int(port)
23 |
24 | self.clients_by_uuid = {}
25 |
26 | self.remote_hosts = {}
27 | self.init_times = {}
28 | self.tx_times = {}
29 | self.pyro_daemon = None
30 | self.uri = None
31 |
32 | # Register custom serializers
33 | register_class_to_dict(TagProperties, TagProperties.class_to_dict)
34 | register_dict_to_class(TagProperties, TagProperties.dict_to_class)
35 | register_class_to_dict(OPCError, OPCError.class_to_dict)
36 | register_dict_to_class(OPCError, OPCError.dict_to_class)
37 |
38 | log.info(f'Initialized OpenOPC gateway Server uri: {self.uri}')
39 |
40 | def print_clients(self):
41 | for client in self.get_clients():
42 | print(client)
43 | print(self.pyro_daemon)
44 |
45 | def get_clients(self):
46 | """Return list of server instances as a list of (GUID,host,time) tuples"""
47 | out_list = []
48 | for client_id, client in self.clients_by_uuid.items():
49 | out_list.append({
50 | 'client_id': client.client_id,
51 | 'tx_time': self.tx_times.get(client.client_id),
52 | 'init_time': self.init_times.get(client.client_id)
53 | })
54 | return out_list
55 |
56 | def create_client(self, open_opc_config: OpenOpcConfig = OpenOpcConfig().OPC_CLASS):
57 | """Create a new OpenOPC instance in the Pyro server"""
58 | print(f"-" * 80)
59 |
60 | opc_da_client = OpcDaClient(open_opc_config)
61 |
62 | client_id = opc_da_client.client_id
63 | # TODO: This seems like a circular object tree...
64 | opc_da_client._open_serv = None
65 | opc_da_client._open_host = self.host
66 | opc_da_client._open_port = self.port
67 | opc_da_client._open_guid = client_id
68 |
69 | self.remote_hosts[client_id] = str(client_id)
70 | self.init_times[client_id] = time.time()
71 | self.tx_times[client_id] = time.time()
72 | self.clients_by_uuid[client_id] = opc_da_client
73 | return opc_da_client
74 |
75 | def release_client(self, obj):
76 | """Release an OpenOPC instance in the Pyro server"""
77 |
78 | self.pyro_daemon.unregister(obj)
79 | del self.remote_hosts[obj.GUID()]
80 | del self.init_times[obj.GUID()]
81 | del self.tx_times[obj.GUID()]
82 | del obj
83 |
84 | def print_config(self):
85 | welcome_message = f"""
86 | Open Opc Gateway server
87 | Version:
88 |
89 | OPC_GATE_HOST: {self.host}
90 | OPC_GATE_PORT: {self.port}
91 | OPC_CLASS: {self.opc_class}
92 | """
93 | print(welcome_message)
94 |
95 | OpenOpcConfig().print_config()
96 |
97 |
98 | def main(host, port):
99 | OpenOpcConfig().print_config()
100 | server = OpenOpcGatewayServer(host, port)
101 | pyro_daemon = Pyro5.server.Daemon(host=host, port=int(port))
102 | pyro_daemon.register(server, objectId="OpenOpcGatewayServer")
103 | pyro_daemon.register(OpcDaClient, objectId="OpcDaClient")
104 | print(f"Open OPC server started {pyro_daemon}")
105 | return pyro_daemon
106 |
107 |
108 | if __name__ == '__main__':
109 | pyro_daemon = main(OpenOpcConfig().OPC_GATEWAY_HOST, OpenOpcConfig().OPC_GATEWAY_PORT)
110 | pyro_daemon.requestLoop()
111 |
--------------------------------------------------------------------------------
/openopc2/gateway_service.py:
--------------------------------------------------------------------------------
1 | ###########################################################################
2 | #
3 | # OpenOPC Gateway Service
4 | #
5 | # A Windows service providing remote access to the OpenOPC library.
6 | #
7 | # Copyright (c) 2007-2012 Barry Barnreiter (barry_b@users.sourceforge.net)
8 | # Copyright (c) 2014 Anton D. Kachalov (mouse@yandex.ru)
9 | # Copyright (c) 2017 José A. Maita (jose.a.maita@gmail.com)
10 | #
11 | ###########################################################################
12 |
13 | import logging
14 | import os
15 | import select
16 | import sys
17 |
18 | import servicemanager
19 | import win32event
20 | import win32service
21 | import win32serviceutil
22 | import winerror
23 |
24 | from openopc2.config import OpenOpcConfig
25 | from openopc2.gateway_server import main as opc_gateway_server_main
26 |
27 | logger = logging.getLogger(__name__)
28 |
29 |
30 | class OpcService(win32serviceutil.ServiceFramework):
31 | _svc_name_ = "zzzOpenOPCService"
32 | _svc_display_name_ = "OpenOPC Gateway Service"
33 |
34 | def __init__(self, args):
35 | win32serviceutil.ServiceFramework.__init__(self, args)
36 | open_opc_config_object = OpenOpcConfig()
37 | self.host = open_opc_config_object.OPC_GATEWAY_HOST
38 | self.port = open_opc_config_object.OPC_GATEWAY_PORT
39 | self.opc_class = open_opc_config_object.OPC_CLASS
40 | self.pyro_daemon = None
41 | self.hWaitStop = win32event.CreateEvent(None, 0, 0, None)
42 |
43 | def SvcStop(self):
44 | self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING)
45 | servicemanager.LogInfoMsg('\nOpenOpcService Stopping service')
46 | self.pyro_daemon.close()
47 | win32event.SetEvent(self.hWaitStop)
48 |
49 | def SvcDoRun(self):
50 | open_opc_config = OpenOpcConfig().print_config()
51 | servicemanager.LogInfoMsg(f'\nOpenOpcService Starting service on port {self.port}')
52 | daemon = opc_gateway_server_main(host=self.host, port=self.port)
53 | socks = daemon.sockets
54 | self.pyro_daemon = daemon
55 | self.ReportServiceStatus(win32service.SERVICE_RUNNING)
56 | daemon.requestLoop()
57 |
58 | # while win32event.WaitForSingleObject(self.hWaitStop, 0) != win32event.WAIT_OBJECT_0:
59 | # ins, outs, exs = select.select(socks, [], [], 1)
60 | # if ins:
61 | # daemon.events(ins)
62 |
63 | # daemon.shutdown()
64 |
65 | def print_config(self):
66 | welcome_message = f"""python
67 |
68 | Started OpenOpcService
69 | Version:
70 |
71 | OPC_GATE_HOST: {self.host}
72 | OPC_GATE_PORT: {self.port}
73 | OPC_CLASS: {self.opc_class}
74 | """
75 |
76 |
77 | if __name__ == '__main__':
78 | if len(sys.argv) == 1:
79 | try:
80 | servicemanager.LogInfoMsg("Starting OpenOPC Gateway Service")
81 | OpenOpcConfig().print_config()
82 | evtsrc_dll = os.path.abspath(servicemanager.__file__)
83 | servicemanager.PrepareToHostSingle(OpcService)
84 | servicemanager.Initialize('zzzOpenOPCService', evtsrc_dll)
85 | servicemanager.StartServiceCtrlDispatcher()
86 | except win32service.error as details:
87 | servicemanager.LogErrorMsg(f"Error Starting OpenOPC Gateway Service {details}")
88 | logger.exception(details)
89 | if details.winerror == winerror.ERROR_FAILED_SERVICE_CONTROLLER_CONNECT:
90 | win32serviceutil.usage()
91 | logger.error(' --foreground: Run OpenOPCService in foreground.')
92 |
93 | else:
94 | servicemanager.LogInfoMsg("Starting OpenOPC Gateway Service Foreground ")
95 |
96 | if sys.argv[1] == '--foreground':
97 | open_opc_config = OpenOpcConfig()
98 | daemon = opc_gateway_server_main(host=open_opc_config.OPC_GATEWAY_HOST,
99 | port=open_opc_config.OPC_GATEWAY_PORT)
100 |
101 | while True:
102 | ins, outs, exs = select.select(daemon.sockets, [], [], 1)
103 | if ins:
104 | daemon.events(ins)
105 | else:
106 | win32serviceutil.HandleCommandLine(OpcService)
107 |
--------------------------------------------------------------------------------
/openopc2/logger.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from rich.logging import RichHandler
3 |
4 | FORMAT = "%(message)s"
5 | logging.basicConfig(
6 | level="WARNING", format=FORMAT, datefmt="[%X]", handlers=[RichHandler()]
7 | )
8 |
9 | log = logging.getLogger("rich")
10 |
--------------------------------------------------------------------------------
/openopc2/opc_types.py:
--------------------------------------------------------------------------------
1 | from collections import namedtuple
2 | from dataclasses import dataclass, asdict
3 | from typing import Dict, List
4 | from enum import Enum
5 |
6 |
7 | class ProtocolMode(str, Enum):
8 | """
9 | Protocol mode
10 | """
11 | COM = "com"
12 | GATEWAY = "gateway"
13 |
14 |
15 | class DataSource(str, Enum):
16 | """
17 | Data source for read
18 | """
19 | CACHE = "cache"
20 | DEVICE = "device"
21 | HYBRID = "hybrid"
22 |
23 |
24 | class LogLevel(str, Enum):
25 | """
26 | Log level
27 | """
28 | CRITICAL = "CRITICAL"
29 | ERROR = "ERROR"
30 | WARNING = "WARNING"
31 | INFO = "INFO"
32 | DEBUG = "DEBUG"
33 |
34 |
35 | ACCESS_RIGHTS = (0, 'Read', 'Write', 'Read/Write')
36 | OPC_QUALITY = ('Bad', 'Uncertain', 'Unknown', 'Good')
37 |
38 |
39 | @dataclass
40 | class TagPropertyItem():
41 | def __init__(self, data_type=None, value=None, description=None, property_id=None):
42 | self.data_type: str = data_type
43 | self.value = value
44 | self.description = description
45 | self.property_id = property_id
46 | self.available = False
47 |
48 | def get_default_tuple(self):
49 | return self.property_id, self.description, self.value
50 |
51 |
52 | @dataclass()
53 | class TagProperties:
54 | """
55 | This is a container for all Properties one Tag can have.
56 | """
57 | tag_name: str = None
58 | data_type: str = None
59 | value: float = None
60 | quality: str = None
61 | timestamp: str = None
62 | access_rights: str = None
63 | server_scan_rate: float = None
64 | eu_type: str = None
65 | eu_info: str = None
66 | description: str = None
67 |
68 | def from_tag_property_items_by_id(self, tag, tag_property_items_by_id):
69 | """
70 | Convert by id since the description varies from server to server
71 | """
72 | default_property_item = TagPropertyItem()
73 | self.tag_name = tag
74 | self.data_type = tag_property_items_by_id.get(1, default_property_item).value
75 | self.value = tag_property_items_by_id.get(2, default_property_item).value
76 | self.quality = tag_property_items_by_id.get(3, default_property_item).value
77 | self.timestamp = tag_property_items_by_id.get(4, default_property_item).value
78 | self.access_rights = tag_property_items_by_id.get(5, default_property_item).value
79 | self.server_scan_rate = tag_property_items_by_id.get(6, default_property_item).value
80 | self.eu_type = tag_property_items_by_id.get(7, default_property_item).value
81 | self.eu_info = tag_property_items_by_id.get(8, default_property_item).value
82 | self.description = tag_property_items_by_id.get(9, default_property_item).value
83 | return self
84 | def get_default_tag_properies_by_id(self):
85 | result = {}
86 | result[1] = TagPropertyItem(property_id=1, description='Item Canonical Data Type', data_type='VT_I2')
87 | result[2] = TagPropertyItem(property_id=2, description='Item Value', data_type='VT_VARIANT')
88 | result[3] = TagPropertyItem(property_id=3, description='Item Quality', data_type='VT_I2')
89 | result[4] = TagPropertyItem(property_id=4, description='Item TimeStamp', data_type='VT_DATE')
90 | result[5] = TagPropertyItem(property_id=5, description='Item Access Rights', data_type='VT_I4')
91 | result[6] = TagPropertyItem(property_id=6, description='Server Scan Rate', data_type='VT_R4')
92 | return result
93 |
94 |
95 |
96 |
97 |
98 |
99 | def class_to_dict(self):
100 | default = asdict(self)
101 | default["__class__"] = "opc_types.TagProperties"
102 | return default
103 |
104 | @classmethod
105 | def dict_to_class(cls, class_name, tag_properties_dictionary):
106 | tag_properties_dictionary.pop("__class__")
107 | p = TagProperties(**tag_properties_dictionary)
108 | return p
109 |
110 |
111 | tag_property_fields = [
112 | 'DataType', 'Value', 'Quality', 'Timestamp', 'AccessRights', 'ServerScanRate', 'ItemEUType', 'ItemEUInfo',
113 | 'Description']
114 | TagPropertyNames = namedtuple('TagProperty', tag_property_fields, defaults=[None] * len(tag_property_fields))
115 |
116 |
117 | class TagPropertyId(Enum):
118 | ItemCanonicalDatatype = 1
119 | ItemValue = 2
120 | ItemQuality = 3
121 | ItemTimeStamp = 4
122 | ItemAccessRights = 5
123 | ServerScanRate = 6
124 | ItemEUType = 7
125 | ItemEUInfo = 8
126 | ItemDescription = 101
127 |
128 | @classmethod
129 | def all_ids(cls):
130 | return [property_id.value for property_id in cls]
131 |
132 | @classmethod
133 | def all_names(cls):
134 | return [property_id.name for property_id in cls]
135 |
--------------------------------------------------------------------------------
/openopc2/pythoncom_datatypes.py:
--------------------------------------------------------------------------------
1 | from enum import IntEnum
2 |
3 | """
4 | The VT type can be generatated with the follwing code
5 |
6 | vt = dict([(pythoncom.__dict__[vtype], vtype) for vtype in pythoncom.__dict__.keys() if vtype[:2] == "VT"])
7 |
8 | """
9 |
10 |
11 | class VtType(IntEnum):
12 | VT_EMPTY = 0
13 | VT_NULL = 1
14 | VT_I2 = 2
15 | VT_I4 = 3
16 | VT_R4 = 4
17 | VT_R8 = 5
18 | VT_CY = 6
19 | VT_DATE = 7
20 | VT_BSTR = 8
21 | VT_DISPATCH = 9
22 | VT_ERROR = 10
23 | VT_BOOL = 11
24 | VT_VARIANT = 12
25 | VT_UNKNOWN = 13
26 | VT_DECIMAL = 14
27 | VT_I1 = 16
28 | VT_UI1 = 17
29 | VT_UI2 = 18
30 | VT_UI4 = 19
31 | VT_I8 = 20
32 | VT_UI8 = 21
33 | VT_INT = 22
34 | VT_UINT = 23
35 | VT_VOID = 24
36 | VT_HRESULT = 25
37 | VT_PTR = 26
38 | VT_SAFEARRAY = 27
39 | VT_CARRAY = 28
40 | VT_USERDEFINED = 29
41 | VT_LPSTR = 30
42 | VT_LPWSTR = 31
43 | VT_RECORD = 36
44 | VT_FILETIME = 64
45 | VT_BLOB = 65
46 | VT_STREAM = 66
47 | VT_STORAGE = 67
48 | VT_STREAMED_OBJECT = 68
49 | VT_STORED_OBJECT = 69
50 | VT_BLOB_OBJECT = 70
51 | VT_CF = 71
52 | VT_CLSID = 72
53 | VT_TYPEMASK = 4095
54 | VT_VECTOR = 4096
55 | VT_ARRAY = 8192
56 | VT_BYREF = 16384
57 | VT_ILLEGAL = 65535
58 | VT_RESERVED = 32768
59 |
60 | @classmethod
61 | def _missing_(cls, value):
62 | return cls.VT_BSTR
63 |
--------------------------------------------------------------------------------
/openopc2/system_health.py:
--------------------------------------------------------------------------------
1 | import ctypes
2 | import math
3 | import os
4 | import time
5 |
6 | try:
7 | import wmi
8 | import pywintypes
9 | import win32pdh
10 | import win32process
11 |
12 | wmi_found = True
13 | except ImportError:
14 | wmi_found = False
15 |
16 |
17 | class CPU:
18 | def __init__(self):
19 | path = win32pdh.MakeCounterPath((None, "Processor", "_Total", None, -1, "% Processor Time"))
20 | self.base = win32pdh.OpenQuery()
21 | self.counter = win32pdh.AddCounter(self.base, path)
22 | self.reset()
23 |
24 | def reset(self):
25 | win32pdh.CollectQueryData(self.base)
26 |
27 | def get_usage(self):
28 | win32pdh.CollectQueryData(self.base)
29 | try:
30 | value = win32pdh.GetFormattedCounterValue(self.counter, win32pdh.PDH_FMT_LONG)[1]
31 | except pywintypes.error:
32 | value = 0
33 | return value
34 |
35 |
36 | def _disk_info():
37 | drive = os.getenv("SystemDrive")
38 | freeuser = ctypes.c_int64()
39 | total = ctypes.c_int64()
40 | free = ctypes.c_int64()
41 | ctypes.windll.kernel32.GetDiskFreeSpaceExW(drive, ctypes.byref(freeuser), ctypes.byref(total), ctypes.byref(free))
42 | return freeuser.value
43 |
44 |
45 | def disk_free():
46 | return int(_disk_info() / 1024)
47 |
48 |
49 | def _mem_info():
50 | kernel32 = ctypes.windll.kernel32
51 | c_ulong = ctypes.c_ulong
52 |
53 | class MEMORYSTATUS(ctypes.Structure):
54 | _fields_ = [
55 | ('dwLength', c_ulong),
56 | ('dwMemoryLoad', c_ulong),
57 | ('dwTotalPhys', c_ulong),
58 | ('dwAvailPhys', c_ulong),
59 | ('dwTotalPageFile', c_ulong),
60 | ('dwAvailPageFile', c_ulong),
61 | ('dwTotalVirtual', c_ulong),
62 | ('dwAvailVirtual', c_ulong)
63 | ]
64 |
65 | memoryStatus = MEMORYSTATUS()
66 | memoryStatus.dwLength = ctypes.sizeof(MEMORYSTATUS)
67 | kernel32.GlobalMemoryStatus(ctypes.byref(memoryStatus))
68 | return (memoryStatus.dwTotalPhys, memoryStatus.dwAvailPhys)
69 |
70 |
71 | def mem_used():
72 | return 0
73 | counter = r'\Memory\Committed Bytes'
74 | machine, thisobject, instance, parentInstance, index, counter = win32pdh.ParseCounterPath(counter)
75 |
76 | instance = None
77 | inum = -1
78 | machine = None
79 |
80 | path = win32pdh.MakeCounterPath((machine, thisobject, instance, None, inum, counter))
81 | hq = win32pdh.OpenQuery()
82 | try:
83 | hc = win32pdh.AddCounter(hq, path)
84 | try:
85 | win32pdh.CollectQueryData(hq)
86 | type_name, val = win32pdh.GetFormattedCounterValue(hc, win32pdh.PDH_FMT_DOUBLE)
87 | return int(val / 1024)
88 | except pywintypes.error:
89 | return 0
90 | finally:
91 | win32pdh.RemoveCounter(hc)
92 | finally:
93 | win32pdh.CloseQuery(hq)
94 |
95 |
96 | def mem_free():
97 | total, free = _mem_info()
98 | return int(free / 1024)
99 |
100 |
101 | def mem_total():
102 | total, free = _mem_info()
103 | return int(total / 1024)
104 |
105 |
106 | def mem_percent():
107 | total, free = _mem_info()
108 | return (float(total - free) / float(total)) * 100.0
109 |
110 |
111 | def _task_list():
112 | psapi = ctypes.windll.psapi
113 | kernel = ctypes.windll.kernel32
114 |
115 | hModule = ctypes.c_ulong()
116 | count = ctypes.c_ulong()
117 | modname = ctypes.c_buffer(30)
118 | PROCESS_QUERY_INFORMATION = 0x0400
119 | PROCESS_VM_READ = 0x0010
120 |
121 | pid_list = win32.process.EnumProcesses()
122 | info_list = []
123 |
124 | for pid in pid_list:
125 |
126 | hProcess = kernel.OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, False, pid)
127 | if hProcess:
128 | psapi.EnumProcessModules(hProcess, ctypes.byref(hModule), ctypes.sizeof(hModule), ctypes.byref(count))
129 | psapi.GetModuleBaseNameA(hProcess, hModule.value, modname, ctypes.sizeof(modname))
130 | pname = ctypes.string_at(modname)
131 |
132 | procmeminfo = win32process.GetProcessMemoryInfo(hProcess)
133 | procmemusage = (procmeminfo["WorkingSetSize"] / 1024)
134 | info_list.append((pid, pname, procmemusage))
135 |
136 | kernel.CloseHandle(hProcess)
137 |
138 | return info_list
139 |
140 |
141 | def task_mem(image_name):
142 | image_name = str.lower(image_name)
143 | if image_name[-4:] != '.exe':
144 | image_name = image_name + '.exe'
145 | return 0 #sum([mem for pid, task_name, mem in _task_list() if str.lower(task_name.decode("utf-8")) == image_name])
146 |
147 |
148 | def task_exists(image_name):
149 | image_name = str.lower(image_name)
150 | if image_name[-4:] != '.exe':
151 | image_name = image_name + '.exe'
152 | return True #len([mem for pid, task_name, mem in _task_list() if str.lower(task_name.decode("utf-8")) == image_name]) > 0
153 |
154 |
155 | def task_cpu(image_name):
156 | if not wmi_found:
157 | return 0.0
158 |
159 | image_name = str.lower(image_name)
160 | if image_name[-4:] == '.exe':
161 | image_name = image_name[:-4]
162 |
163 | wmi_adapter = wmi.WMI()
164 | process_info = {}
165 | pct_cpu_time = 0.0
166 |
167 | for i in range(2):
168 | for p in wmi_adapter.Win32_PerfRawData_PerfProc_Process(name=image_name):
169 | id = int(p.IDProcess)
170 | n1, d1 = int(p.PercentProcessorTime), int(p.Timestamp_Sys100NS)
171 | n0, d0, so_far = process_info.get(id, (0, 0, []))
172 |
173 | try:
174 | pct_cpu_time += (float(n1 - n0) / float(d1 - d0)) * 100.0
175 | except ZeroDivisionError:
176 | pct_cpu_time += 0.0
177 |
178 | so_far.append(pct_cpu_time)
179 | process_info[id] = (n1, d1, so_far)
180 |
181 | if i == 0:
182 | time.sleep(0.1)
183 | pct_cpu_time = 0.0
184 |
185 | num_cpu = int(os.environ['NUMBER_OF_PROCESSORS'])
186 | return min(pct_cpu_time / num_cpu, 100.0)
187 |
188 |
189 | def sine_wave():
190 | min_time = float(time.localtime()[4])
191 | sec = float(time.localtime()[5])
192 | t = (min_time + (sec / 60.0)) % 10.0
193 | return math.sin(2.0 * math.pi * t / 10.0) * 100.0
194 |
195 |
196 | def saw_wave():
197 | min_time = float(time.localtime()[4])
198 | sec = float(time.localtime()[5])
199 | t = (min_time + (sec / 60.0)) % 10.0
200 | return (t / 10.0) * 100.0
201 |
--------------------------------------------------------------------------------
/openopc2/utils.py:
--------------------------------------------------------------------------------
1 | from openopc2.config import OpenOpcConfig
2 | from openopc2.da_client import OpcDaClient
3 | from openopc2.gateway_proxy import OpenOpcGatewayProxy
4 |
5 |
6 | def get_opc_da_client(config: OpenOpcConfig = OpenOpcConfig()) -> OpcDaClient:
7 | if config.OPC_MODE == "gateway":
8 | opc_da_client = OpenOpcGatewayProxy(config.OPC_GATEWAY_HOST, config.OPC_GATEWAY_PORT).get_opc_da_client_proxy()
9 | print("OpenOPC in gateway mode")
10 | else:
11 | opc_da_client = OpcDaClient(config)
12 | print("OpenOPC in com mode")
13 |
14 | opc_da_client.connect(config.OPC_SERVER, config.OPC_HOST)
15 | return opc_da_client
16 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "openopc2"
3 | version = "0.1.18"
4 | description = "OPC library with a Windows gateway enabling non-Windows clients to access OPC-DA calls."
5 | license = "GPL-2.0-or-later"
6 | readme = "README.md"
7 | authors = [
8 | "Lorenz Padberg ",
9 | "Elia Bieri ",
10 | ]
11 | repository = "https://github.com/iterativ/openopc2"
12 | keywords = ["opc", "openopc", "opc-da", "opc classic"]
13 | classifiers = [
14 | "Development Status :: 4 - Beta",
15 | "Programming Language :: Python :: 3",
16 | "Topic :: Scientific/Engineering :: Human Machine Interfaces",
17 | "Topic :: Scientific/Engineering :: Interface Engine/Protocol Translator",
18 | "Intended Audience :: Manufacturing",
19 | ]
20 |
21 | [tool.poetry.dependencies]
22 | python = "<3.13,>=3.8"
23 | pyro5 = "^5.14"
24 | WMI = { version = "^1.5.1", markers = "sys_platform == 'win32'" }
25 | pywin32 = { version = "304", markers = "sys_platform == 'win32'" }
26 | typer = { extras = ["all"], version = "^0.6.1" }
27 | rich = "^12.6.0"
28 |
29 | [tool.poetry.group.dev.dependencies]
30 | coverage = "^6.5.0"
31 | pyinstaller = "^5.6.2"
32 |
33 | [build-system]
34 | requires = ["poetry-core"]
35 | build-backend = "poetry.core.masonry.api"
36 |
--------------------------------------------------------------------------------
/scripts/OpenOpcService.spec:
--------------------------------------------------------------------------------
1 | # -*- mode: python ; coding: utf-8 -*-
2 |
3 |
4 | block_cipher = None
5 |
6 |
7 | a = Analysis(
8 | ['..\\openopc2\\gateway_service.py'],
9 | pathex=['./openopc2'],
10 | binaries=[],
11 | datas=[],
12 | hiddenimports=['json', 'win32timezone', 'pythoncom'],
13 | hookspath=[],
14 | hooksconfig={},
15 | runtime_hooks=[],
16 | excludes=[],
17 | win_no_prefer_redirects=False,
18 | win_private_assemblies=False,
19 | cipher=block_cipher,
20 | noarchive=False,
21 | )
22 | pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
23 |
24 | exe = EXE(
25 | pyz,
26 | a.scripts,
27 | a.binaries,
28 | a.zipfiles,
29 | a.datas,
30 | [],
31 | name='OpenOpcService',
32 | debug=False,
33 | bootloader_ignore_signals=False,
34 | strip=False,
35 | upx=True,
36 | upx_exclude=[],
37 | runtime_tmpdir=None,
38 | console=True,
39 | disable_windowed_traceback=False,
40 | argv_emulation=False,
41 | target_arch=None,
42 | codesign_identity=None,
43 | entitlements_file=None,
44 | )
45 |
--------------------------------------------------------------------------------
/scripts/build_executables.ps1:
--------------------------------------------------------------------------------
1 |
2 | poetry run pyinstaller --onefile `
3 | --clean `
4 | --noconfirm `
5 | --paths ./openopc2 `
6 | --hidden-import=json `
7 | --hidden-import=win32timezone `
8 | --hidden-import=pythoncom `
9 | --name OpenOpcService `
10 | openopc2/gateway_service.py
11 |
12 |
13 | poetry run pyinstaller --onefile `
14 | --clean `
15 | --noconfirm `
16 | --paths ./openopc2 `
17 | --hidden-import=json `
18 | --hidden-import=win32timezone `
19 | --hidden-import=pythoncom `
20 | --name OpenOpcCli `
21 | openopc2/cli.py
22 |
23 | poetry run pyinstaller --onefile `
24 | --clean `
25 | --noconfirm `
26 | --paths ./openopc2 `
27 | --hidden-import=json `
28 | --hidden-import=win32timezone `
29 | --hidden-import=pythoncom `
30 | --name OpenOpcServer `
31 | openopc2/gateway_server.py
--------------------------------------------------------------------------------
/scripts/create_dll_interface.py:
--------------------------------------------------------------------------------
1 | import sys
2 | from win32com.client import makepy
3 | import win32com.client
4 |
5 |
6 |
7 | """
8 | This file shows a test how you can create a Python Class from a dll and therefore create a stub interface.
9 |
10 | """
11 | program_name = "OPC.Automation"
12 | #program_name = "Matrikon.OPC.Simulation"
13 | #program_name = "ABB.AC800MC_OpcDaServer"
14 | obj = win32com.client.GetObject(Pathname="OPC.Automation")
15 |
16 | w = win32com.client.Dispatch(program_name, 1)
17 | print(w)
18 |
19 |
20 | obj = win32com.client.GetObject("OPC.Automation")
21 |
22 | outputFile = f"h{program_name}.interface.py"
23 | comTypeLibraryOrDLL = r"C:\Users\ABB\Downloads\graybox_opc_automation_wrapper\x64\gbda_aut.dll"
24 | sys.argv = ["makepy", "-o", outputFile, comTypeLibraryOrDLL]
25 |
26 | makepy.main()
27 |
--------------------------------------------------------------------------------
/scripts/generate_cli_md.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -e
3 |
4 | # Requires the typer-cli package to be installed
5 | # https://typer.tiangolo.com/typer-cli/
6 | typer openopc2.cli utils docs --output CLI.md --name "openopc2 CLI"
--------------------------------------------------------------------------------
/scripts/opc_com_basic_test.py:
--------------------------------------------------------------------------------
1 |
2 | import pythoncom
3 | import pywintypes
4 | import win32com.client
5 | import win32com.server.util
6 |
7 |
8 | def main():
9 | opc_classes = ['Matrikon.OPC.Automation',
10 | 'Graybox.OPC.DAWrapper',
11 | 'HSCOPC.Automation',
12 | 'RSI.OPCAutomation',
13 | 'OPC.Automation']
14 |
15 | opc_class = "OPC.Automation"
16 |
17 | for opc_class in opc_classes:
18 | server = "Matrikon.OPC.Simulation"
19 | host = 'localhost'
20 | #program_name = "Matrikon.OPC.Simulation"
21 | server = "ABB.AC800MC_OpcDaServer"
22 |
23 | print(f"Trying to connect OPC server with {server}@{host} class {opc_class}")
24 | try:
25 | opc_client = win32com.client.gencache.EnsureDispatch(opc_class, 0)
26 | opc_client.Connect(server, host)
27 |
28 |
29 | info = f"""
30 | SERVER = {server}
31 | HOST = {host}
32 | OPC_CLASS = {opc_class}
33 |
34 | client_name {opc_client.ClientName}
35 | server_name = {opc_client.ServerName}
36 | server_state = {opc_client.ServerState}
37 | version = {opc_client.MajorVersion}.{opc_client.MinorVersion} - {opc_client.BuildNumber}
38 | start_time = {opc_client.StartTime}
39 | current_time = {opc_client.CurrentTime}
40 | vendor_info = {opc_client.VendorInfo}
41 | """
42 | print(info)
43 | except Exception as e:
44 | print(e.__dict__)
45 |
46 |
47 | if __name__ == '__main__':
48 | main()
--------------------------------------------------------------------------------
/scripts/redeploy_open_opc_service.ps1:
--------------------------------------------------------------------------------
1 |
2 | Stop-Service -Name "zzzOpenOpcService"
3 | ./build_executables.ps1
4 | ./dist/OpenOpcService.exe install
5 | ./dist/OpenOpcService.exe start debug
6 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/iterativ/openopc2/a91250fe4b560a22debfd5284c99ae51358b916a/tests/__init__.py
--------------------------------------------------------------------------------
/tests/test_cli.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 | from test_config import test_config
3 |
4 | from typer.testing import CliRunner
5 |
6 | from openopc2.cli import app
7 |
8 | config = test_config()
9 | OPTIONS = ["--protocol-mode", config.OPC_MODE,
10 | "--opc-server", config.OPC_SERVER,
11 | "--gateway-host", config.OPC_GATEWAY_HOST,
12 | "--gateway-port", config.OPC_GATEWAY_PORT]
13 |
14 |
15 | class TestOpcCli(TestCase):
16 | def setUp(self) -> None:
17 | self.runner = CliRunner()
18 |
19 | def test_help(self):
20 | result = self.runner.invoke(app, ["--help"])
21 | assert result.exit_code == 0
22 | assert "--install-completion" in result.stdout
23 |
24 | def test_list(self):
25 | result = self.runner.invoke(app, ["list-tags"] + OPTIONS)
26 | assert result.exit_code == 0
27 |
28 | def test_list_recursive(self):
29 | result = self.runner.invoke(app, ["list-tags", "--recursive"] + OPTIONS)
30 | assert result.exit_code == 0
31 |
32 | def test_read_tag(self):
33 | result = self.runner.invoke(app, ["read", 'Bucket Brigade.Int1'] + OPTIONS)
34 | assert result.exit_code == 0
35 |
36 | def test_read_tags(self):
37 | result = self.runner.invoke(app, ["read", 'Bucket Brigade.Int1', 'Bucket Brigade.Int2'] + OPTIONS)
38 | assert result.exit_code == 0
39 |
40 | def test_server_info(self):
41 | result = self.runner.invoke(app, ["server-info"] + OPTIONS)
42 | assert result.exit_code == 0
43 |
44 | def test_list_servers(self):
45 | result = self.runner.invoke(app, ["list-servers"] + OPTIONS)
46 | assert result.exit_code == 0
47 |
48 | def test_list_config(self):
49 | result = self.runner.invoke(app, ["list-config"])
50 | print(result)
51 | assert result.exit_code == 0
52 |
53 | def test_properties(self):
54 | result = self.runner.invoke(app, ["properties", 'Bucket Brigade.Int1'] + OPTIONS)
55 | assert result.exit_code == 0
56 |
57 | def test_write_tags(self):
58 | result = self.runner.invoke(app, ["write", "Bucket Brigade.Int1=3"] + OPTIONS)
59 | assert result.exit_code == 0
60 |
--------------------------------------------------------------------------------
/tests/test_config.py:
--------------------------------------------------------------------------------
1 | import os
2 | from unittest import TestCase
3 |
4 | from openopc2.config import OpenOpcConfig
5 |
6 |
7 | def test_config():
8 | open_opc_config = OpenOpcConfig()
9 | open_opc_config.OPC_SERVER = "Matrikon.OPC.Simulation.1"
10 | open_opc_config.OPC_GATEWAY_HOST = "192.168.0.115"
11 | open_opc_config.OPC_CLASS = "Graybox.OPC.DAWrapper"
12 | open_opc_config.OPC_MODE = 'com'
13 | return open_opc_config
14 |
15 |
16 | class TestOpenOpcConfig(TestCase):
17 | def test_instantiation(self) -> None:
18 | # Confirm env vars won't mess up assertions
19 | for env_var in ["OPC_SERVER", "OPC_GATE_PORT", "OPC_TIMEOUT"]:
20 | self.assertNotIn(env_var, os.environ)
21 |
22 | default_config = OpenOpcConfig()
23 | self.assertEquals(default_config.OPC_SERVER, "Matrikon.OPC.Simulation")
24 | self.assertIsInstance(default_config.OPC_GATEWAY_PORT, int)
25 | self.assertEquals(default_config.OPC_GATEWAY_PORT, 7766)
26 | self.assertIsInstance(default_config.OPC_TIMEOUT, int)
27 | self.assertEquals(default_config.OPC_TIMEOUT, 1000)
28 |
29 | nondefault_config = OpenOpcConfig(opc_server="Another.Server")
30 | self.assertEquals(nondefault_config.OPC_SERVER, "Another.Server")
31 |
--------------------------------------------------------------------------------
/tests/test_exceptions.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase, skipIf
2 | from openopc2.exceptions import OPCError
3 | from test_config import test_config
4 |
5 |
6 | @skipIf(test_config().OPC_MODE != 'com', "COM interface tests ony if OPC_MODE is com is disabled")
7 | class TestOpcError(TestCase):
8 |
9 | def test_raise(self):
10 | self.assertRaises(OPCError, raise_error)
11 |
12 | def test_serialize_opc_error(self):
13 | import win32com.client
14 | import pythoncom
15 |
16 | try:
17 | pythoncom.CoInitialize()
18 | self.opc_client = win32com.client.gencache.EnsureDispatch('Foo', 0)
19 | except pythoncom.com_error as err:
20 | pythoncom.CoUninitialize()
21 | opc_error = OPCError(f'Dispatch: {err}')
22 | as_dict = opc_error.class_to_dict()
23 | as_exception = OPCError('').dict_to_class('sd', as_dict)
24 | pass
25 |
26 |
27 | def raise_error():
28 | raise OPCError('Bad OPC Error')
--------------------------------------------------------------------------------
/tests/test_list.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 |
3 | from test_config import test_config
4 | from openopc2.utils import get_opc_da_client
5 |
6 |
7 | class TestListTags(TestCase):
8 | def setUp(self) -> None:
9 | self.opc_client = get_opc_da_client(test_config())
10 |
11 | def test_list_flat_return_tags(self):
12 | tags = self.opc_client.list(recursive=False, include_type=False, flat=True)
13 | for p in tags:
14 | print(p)
15 | self.assertIs(type(tags), list)
16 | self.assertIs(type(tags[0]), str)
17 | self.assertTrue("Triangle Waves.UInt4" in tags)
18 |
19 | def test_list_type_return_tags_incude_type(self):
20 | tags = self.opc_client.list(recursive=False, include_type=True, flat=False)
21 | self.assertIs(type(tags), list)
22 | self.assertIs(type(tags[0]), tuple)
23 | self.assertTrue(('Simulation Items', 'Branch') in tags)
24 |
25 | # def test_list_recursive_return_tags_recursive(self):
26 | # tags = self.opc_client.list(recursive=True, include_type=False, flat=False)
27 | # self.assertIs(type(tags), list)
28 | # self.assertIs(type(tags[0]), str)
29 | # self.assertTrue("Triangle Waves.UInt4" in tags)
30 |
--------------------------------------------------------------------------------
/tests/test_opc_com.py:
--------------------------------------------------------------------------------
1 | import time
2 | from unittest import TestCase, skipIf
3 |
4 | from openopc2.da_com import OpcCom
5 | from test_config import test_config
6 | TAG = 'Bucket Brigade.Int1'
7 |
8 |
9 | @skipIf(test_config().OPC_MODE != 'com', "COM interface tests ony if OPC_MODE is com is disabled")
10 | class TestOpenOpcCom(TestCase):
11 | def setUp(self):
12 | self.opccom = OpcCom(test_config().OPC_CLASS)
13 | self.opccom.connect(test_config().OPC_HOST, test_config().OPC_SERVER)
14 |
15 | def test_init(self):
16 | opc_com = OpcCom(test_config().OPC_CLASS)
17 |
18 | def test_connect(self):
19 | opc_com = OpcCom(test_config().OPC_CLASS)
20 | opc_com.connect(test_config().OPC_HOST, test_config().OPC_SERVER)
21 |
22 | def test_create_browser(self):
23 | browser = self.opccom.create_browser()
24 |
25 | def test_disconnect(self):
26 | self.opccom.disconnect()
27 |
28 | def test_server_name(self):
29 | server_name = self.opccom.server_name
30 |
31 | def test_get_opc_servers(self):
32 | opc_servers = self.opccom.get_opc_servers(test_config().OPC_HOST)
33 | self.assertTrue(test_config().OPC_SERVER in opc_servers)
34 | print(opc_servers)
35 |
36 | def test_get_available_properties(self):
37 | properties = self.opccom.get_available_properties(TAG)
38 | self.assertIsInstance(properties, tuple)
39 | print(properties)
40 |
41 | def test_get_tag_properties(self):
42 |
43 | start = time.time()
44 | for i in range(100):
45 | properties = self.opccom.get_tag_properties(TAG, [2, 3])
46 |
47 | print(f"selection {time.time() - start}")
48 |
49 | def test_get_all_tag_properties(self):
50 | start = time.time()
51 | for i in range(100):
52 | print(TAG)
53 | properties = self.opccom.get_tag_properties(TAG)
54 | print(f"all {time.time() - start}")
55 |
--------------------------------------------------------------------------------
/tests/test_opc_gateway_server.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase, skip
2 | from openopc2.gateway_server import OpenOpcGatewayServer
3 | from test_config import test_config
4 |
5 |
6 | @skip("Try not to interfere with the running services")
7 | class TestOpcGatewayServer(TestCase):
8 | def test_init(self):
9 | server = OpenOpcGatewayServer(test_config().OPC_GATEWAY_HOST, test_config().OPC_GATEWAY_PORT)
10 |
11 | def test_create_client(self):
12 | server = OpenOpcGatewayServer(test_config().OPC_GATEWAY_HOST, test_config().OPC_GATEWAY_PORT)
13 | opc_client = server.create_client(test_config())
14 | opc_client.connect(test_config().OPC_SERVER)
15 | tags = opc_client.list()
16 | self.assertEqual(['Simulation Items', 'Configured Aliases'], tags)
17 |
18 | def test_get_clients(self):
19 | server = OpenOpcGatewayServer(test_config().OPC_GATEWAY_HOST, test_config().OPC_GATEWAY_PORT)
20 | opc_client = server.create_client(test_config())
21 | clients = server.get_clients()
22 |
23 | def test_print_clients(self):
24 | server = OpenOpcGatewayServer(test_config().OPC_GATEWAY_HOST, test_config().OPC_GATEWAY_PORT)
25 | server.create_client(test_config())
26 | server.create_client(test_config())
27 | server.print_clients()
28 |
--------------------------------------------------------------------------------
/tests/test_open_opc_service.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase, skipIf
2 |
3 | from openopc2.gateway_proxy import OpenOpcGatewayProxy
4 | from test_config import test_config
5 |
6 | @skipIf(test_config().OPC_MODE != 'gateway', "Skip test if OPC_Mode is not gateway")
7 | class TestOpenOPCService(TestCase):
8 | def test_get_clients(self):
9 | open_opc_gateway_proxy = OpenOpcGatewayProxy(test_config().OPC_HOST).get_server_proxy()
10 | opc_da_client = open_opc_gateway_proxy.create_client(test_config().OPC_CLASS)
11 |
12 | def test_print_clients(self):
13 | open_opc_gateway_proxy = OpenOpcGatewayProxy(test_config().OPC_HOST).get_server_proxy()
14 | opc_da_client = open_opc_gateway_proxy.print_clients()
15 |
--------------------------------------------------------------------------------
/tests/test_properties.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 |
3 | from openopc2.utils import get_opc_da_client
4 | from test_config import test_config
5 |
6 |
7 | class TestProperties(TestCase):
8 | def setUp(self) -> None:
9 | self.opc_client = get_opc_da_client(test_config())
10 | self.tags = self.opc_client.list(recursive=False, include_type=False, flat=True, access_rights=1)
11 | self.no_system_tags = [tag for tag in self.tags if "@" not in tag][0:100]
12 |
13 | def test_read_property(self):
14 | properties = self.opc_client.properties('Bucket Brigade.Int1')
15 | prop = properties[0]
16 | self.assertEqual('VT_I1', prop.data_type)
17 | self.assertEqual('Good', prop.quality)
18 | self.assertEqual(prop.server_scan_rate, 100.0)
19 |
20 | def test_read_properties(self):
21 | properties = self.opc_client.properties(self.no_system_tags[1:24])
22 | print_properties(properties)
23 |
24 | def test_read_properties_id(self):
25 | properties = self.opc_client.properties(self.no_system_tags, id=[1])
26 | print_properties(properties)
27 |
28 |
29 | def print_properties(properties):
30 | for prop in properties:
31 | print(f"{prop}")
32 |
--------------------------------------------------------------------------------
/tests/test_properties_class.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 | from openopc2.opc_types import TagProperties
3 |
4 |
5 | class TestPropertiesClass(TestCase):
6 | def test_to_dict(self):
7 | props = TagProperties()
8 | props.quality = 'Good'
9 | test_dict = props.class_to_dict()
10 | self.assertTrue(test_dict['quality'], 'Good')
11 |
12 | def test_from_dict(self):
13 | test_dict = {'tag_name': None,
14 | 'data_type': 'VT12',
15 | 'value': 3.1415,
16 | 'quality': 'Good',
17 | 'timestamp': None,
18 | 'access_rights': 'Read',
19 | 'server_scan_rate': 100.0,
20 | 'eu_type': 'None',
21 | 'eu_info': 'None',
22 | 'description': "Unknown tag",
23 | '__class__': 'test'}
24 |
25 | props = TagProperties.dict_to_class('test', test_dict)
26 | self.assertTrue(props.value, 3.1415)
27 | self.assertTrue(props.server_scan_rate, 100)
28 |
--------------------------------------------------------------------------------
/tests/test_read.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 |
3 | from test_config import test_config
4 | from openopc2.utils import get_opc_da_client
5 |
6 | READ_TIMEOUT = 500
7 | N = 150
8 |
9 |
10 | class TestReadTags(TestCase):
11 | def setUp(self) -> None:
12 | self.opc_client = get_opc_da_client(test_config())
13 | self.tags = self.opc_client.list(recursive=False, include_type=False, flat=True)
14 | self.no_system_tags = [tag for tag in self.tags if "@" not in tag]
15 |
16 | def test_read_all_tags_single_reads(self):
17 | """
18 | Not really a test but if it runs through it most datatypes work
19 | """
20 | values = [self.opc_client.read(tag, timeout=READ_TIMEOUT) for tag in self.tags]
21 | print_values(values)
22 |
23 | def test_read_tags_list(self):
24 | values = self.opc_client.read(self.no_system_tags)
25 | print_values(values)
26 |
27 | def test_read_specific_tags(self):
28 | specific_tags_results = [('Bucket Brigade.Boolean', 3, 'Good', '2021-12-02 16:35:53.162000+00:00'),
29 | ('Bucket Brigade.Int1', 1, 'Good', '2021-12-02 16:37:41.377000+00:00'),
30 | ('Bucket Brigade.Int2', 10, 'Good', '2021-12-02 16:37:41.377000+00:00'),
31 | ('Bucket Brigade.Int4', 2, 'Good', '2021-12-02 16:35:53.767000+00:00'),
32 | ('Bucket Brigade.Money', 2, 'Good', '2021-12-02 16:35:53.972000+00:00'),
33 | ('Bucket Brigade.Real4', 2.0, 'Good', '2021-12-02 16:35:54.177000+00:00'),
34 | ('Bucket Brigade.Real8', 2.0, 'Good', '2021-12-02 16:35:54.372000+00:00'),
35 | ('Bucket Brigade.String', 'OPC Test', 'Good', '2021-12-02 16:35:54.572000+00:00'),
36 | ('Bucket Brigade.Time', 'OPC Test', 'Good', '2021-12-02 16:35:54.772000+00:00'),
37 | ('Bucket Brigade.UInt1', 2, 'Good', '2021-12-02 16:35:54.972000+00:00'),
38 | ('Bucket Brigade.UInt2', 2, 'Good', '2021-12-02 16:35:55.182000+00:00'),
39 | ('Bucket Brigade.UInt4', 2.0, 'Good', '2021-12-02 16:35:55.377000+00:00')
40 | ]
41 | specific_tag_names = [tag_result[0] for tag_result in specific_tags_results]
42 | values = self.opc_client.read(specific_tag_names)
43 | tag_names = [tag_result[0] for tag_result in specific_tags_results]
44 | self.assertEqual(specific_tag_names, tag_names)
45 | qualities = [tag_result[2] for tag_result in specific_tags_results]
46 | self.assertEqual(qualities, ['Good'] * len(values))
47 |
48 | def test_read_tags_list_sync(self):
49 | values = self.opc_client.read(self.no_system_tags[0:N], sync=True, timeout=READ_TIMEOUT)
50 | print_values(values)
51 |
52 | def test_read_tag_include_error(self):
53 | values = self.opc_client.read(self.no_system_tags[0:N], include_error=True, timeout=READ_TIMEOUT)
54 | print_values(values)
55 |
56 | def test_read_tag_sync(self):
57 | values = self.opc_client.read(self.no_system_tags[0:N], sync=True, timeout=READ_TIMEOUT)
58 | print_values(values)
59 |
60 | def test_read_tags_list_include_error(self):
61 | values = self.opc_client.read(self.no_system_tags[0:N], include_error=True, timeout=READ_TIMEOUT)
62 | print_values(values)
63 |
64 | def test_non_existent_tag_error(self):
65 | value = self.opc_client.read("idont_exist", include_error=True)
66 | self.assertEqual(value, (None, 'Error', None, "The item ID does not conform to the server's syntax. "))
67 |
68 | def test_non_existent_tag(self):
69 | value = self.opc_client.read("idont_exist")
70 | self.assertEqual(value, (None, 'Error', None))
71 |
72 | def test_non_existent_tag_sync(self):
73 | value = self.opc_client.read("idont_exist", sync=True)
74 | self.assertEqual(value, (None, 'Error', None))
75 |
76 | def test_non_existent_tags(self):
77 | values = self.opc_client.read(["idont_exist", "test", 'Bucket Brigade.Int1'], timeout=READ_TIMEOUT)
78 | self.assertEqual(values[0], ("idont_exist", None, 'Error', None))
79 | self.assertEqual(values[1], ("test", None, 'Error', None))
80 | self.assertEqual(values[2][0], "Bucket Brigade.Int1")
81 | self.assertEqual(values[2][2], 'Good')
82 |
83 | def test_non_existent_tags_error(self):
84 | values = self.opc_client.read(["idont_exist", "test", 'Bucket Brigade.Int1'], timeout=READ_TIMEOUT,
85 | include_error=True)
86 | self.assertEqual(values[0],
87 | ("idont_exist", None, 'Error', None, "The item ID does not conform to the server's syntax. "))
88 | self.assertEqual(values[1],
89 | ("test", None, 'Error', None, "The item ID does not conform to the server's syntax. "))
90 |
91 | self.assertEqual(values[2][0], "Bucket Brigade.Int1")
92 | self.assertEqual(values[2][2], 'Good')
93 |
94 | def test_non_existent_tags_sync(self):
95 | values = self.opc_client.read(["idont_exist", "test", 'Bucket Brigade.Int1'], timeout=READ_TIMEOUT, sync=True)
96 | self.assertEqual(values[0], ("idont_exist", None, 'Error', None))
97 | self.assertEqual(values[1], ("test", None, 'Error', None))
98 | self.assertEqual(values[2][0], "Bucket Brigade.Int1")
99 | self.assertEqual(values[2][2], 'Good')
100 |
101 | # def test_group_read(self):
102 | # square_wave_tags = [tag for tag in self.tags if "Square" in tag]
103 | # values = self.opc_client.read(square_wave_tags, group="square_group", timeout=READ_TIMEOUT)
104 | # values_group = self.opc_client.read(square_wave_tags, group='square_group')
105 | # self.assertEqual(len(values_group), len(values))
106 | # self.assertEqual(values_group, values)
107 |
108 | def test_read_system_tags(self):
109 | system_tags = [
110 | '@MemFree', '@MemUsed', '@MemTotal', '@MemPercent', '@MemPercent', '@DiskFree', '@SineWave', '@SawWave',
111 | '@CpuUsage'
112 | ]
113 | system_values = self.opc_client.read(system_tags)
114 | print_values(system_values)
115 |
116 | def test_sytem_tag_task_info(self):
117 | task_name = "OpenOpcService"
118 | task_info_tags = [f"@TaskMem({task_name})", f"@TaskCpu({task_name})", f"@TaskExists({task_name})"]
119 |
120 | system_values = self.opc_client.read(task_info_tags)
121 | print_values(system_values)
122 | self.assertTrue(system_values[0][1] > 1000)
123 |
124 |
125 | def print_values(values):
126 | for k, value in enumerate(values):
127 | print(k, value)
128 |
--------------------------------------------------------------------------------
/tests/test_server.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 |
3 | from test_config import test_config
4 | from openopc2.utils import get_opc_da_client
5 |
6 |
7 | class TestServerInfo(TestCase):
8 | def setUp(self) -> None:
9 | self.opc_client = get_opc_da_client(test_config())
10 |
11 | def test_get_server(self):
12 | available_servers = self.opc_client.servers()
13 | self.assertIsNotNone(available_servers)
14 | self.assertIn( 'Matrikon.OPC.Simulation.1', available_servers)
15 |
16 | def test_get_info(self):
17 | info = self.opc_client.info()
18 | if test_config().OPC_MODE == 'gateway':
19 | self.assertEqual(('Protocol', 'DCOM'), info[0])
20 | self.assertEqual(('Class', "Graybox.OPC.DAWrapper"), info[1])
21 | self.assertEqual('Client Name', info[2][0])
22 | self.assertEqual(('OPC Server', 'Matrikon.OPC.Simulation'), info[4])
23 |
24 | else:
25 | self.assertEqual(('Protocol', 'com'), info[0])
26 | self.assertEqual(('Client Name', ''), info[2])
27 | self.assertEqual('OPC Host', info[3][0], )
28 | self.assertEqual(('Class', "Graybox.OPC.DAWrapper"), info[1])
29 |
30 | def test_ping(self):
31 | ping = self.opc_client.ping()
32 | self.assertTrue(ping)
33 |
--------------------------------------------------------------------------------
/tests/test_write.py:
--------------------------------------------------------------------------------
1 | import numbers
2 | from unittest import TestCase
3 |
4 | from test_config import test_config
5 | from openopc2.utils import get_opc_da_client
6 |
7 |
8 | class TestWriteTags(TestCase):
9 | def setUp(self) -> None:
10 | self.opc_client = get_opc_da_client(test_config())
11 | self.tags = self.opc_client.list(recursive=False, include_type=False, flat=True)
12 | self.no_system_tags = [tag for tag in self.tags if "@" not in tag]
13 | self.writeable_tags = [tag for tag in self.tags if "Bucket Brigade" in tag]
14 |
15 | def test_write_all_tags_single_writes(self):
16 | """
17 | Not really a test but if it runs through most datatypes work
18 | """
19 | for tag in self.writeable_tags:
20 | old_value = self.opc_client.read(tag)[0]
21 | try:
22 | float(old_value)
23 | is_numeric = True
24 | except Exception:
25 | is_numeric = False
26 |
27 | if is_numeric:
28 | new_value = create_new_value(old_value)
29 | write = self.opc_client.write((tag, new_value))
30 | written_value = self.opc_client.read(tag)[0]
31 | print_write_result(write, tag, old_value, written_value)
32 | self.assertEqual(new_value, written_value)
33 |
34 |
35 |
36 | def test_write_all_tags_single_writes_include_error(self):
37 | """
38 | Not really a test but if it runs through most datatypes work
39 | """
40 | for tag in self.writeable_tags:
41 | old_value = self.opc_client.read(tag)[0]
42 | try:
43 | float(old_value)
44 | is_numeric = True
45 | except Exception:
46 | is_numeric = False
47 |
48 | if is_numeric:
49 | new_value = create_new_value(old_value)
50 | write = self.opc_client.write((tag, new_value), include_error=True)
51 | written_value = self.opc_client.read(tag)[0]
52 | print_write_result(write, tag, old_value, written_value)
53 | self.assertEqual(new_value, written_value)
54 |
55 |
56 | def print_write_result(write_result, tag, old, new):
57 | if type(write_result) == list:
58 | write_result = write_result[0]
59 | success = write_result[0] == 'Success'
60 | if success:
61 | print(f"{write_result[0]}: {tag:20} old: {old} new: {new}")
62 | else:
63 | print(write_result)
64 |
65 |
66 | def create_new_value(old_value):
67 | if isinstance(old_value, bool):
68 | return not old_value
69 | if isinstance(old_value, numbers.Number):
70 | return old_value + 1
71 | if isinstance(old_value, str):
72 | return "OPC Test"
73 | if isinstance(old_value, tuple):
74 | return range(len(old_value))
75 |
--------------------------------------------------------------------------------