├── .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 | LinuxSetup 3 |

4 | 5 | [![PyPI version](https://badge.fury.io/py/openopc2.svg)](https://badge.fury.io/py/openopc2) 6 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/openopc2) 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 | WindowsSetup 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 | LinuxSetup 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 | WindowsSetup 161 |

162 | 163 |

164 | WindowsSetup 165 |

166 | 167 |

168 | WindowsSetup 169 |

170 | 171 |

172 | WindowsSetup 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 | --------------------------------------------------------------------------------