├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── NOTICE ├── README.md ├── THIRD-PARTY-LICENSES ├── aws-osi-pi-streaming-connector-developers-guide.pdf ├── images └── aws-osi-pi-data-architecture.png └── src ├── .eslintignore ├── .eslintrc.js ├── configs ├── componentName.js ├── pubsubTopics.js └── shadowConfigDefault.js ├── controllers ├── core │ ├── awsIoTShadowController.js │ ├── awsPubsubController.js │ ├── osiPiSdkController.js │ └── systemTelemetryController.js └── functions │ ├── osiPiPointWriter.js │ └── osiPiStreamingDataController.js ├── gdk-config.json ├── index.js ├── osi-pi-sdk ├── README.md ├── awsSecretsManager.js ├── awsSitewiseAssetManager.js ├── awsSitewisePublisher.js ├── images │ └── aws-osi-pi-data-architecture.png ├── pi-objects │ ├── piAssetDatabase.js │ ├── piAssetElement.js │ ├── piAssetElementAttribute.js │ ├── piAssetElementParent.js │ ├── piAssetElementTemplate.js │ ├── piAssetElementTemplateAttribute.js │ ├── piAssetServer.js │ ├── piBaseObject.js │ ├── piDataPoint.js │ ├── piDataServer.js │ ├── piServerRoot.js │ └── piWebsocketChannel.js ├── piWebIdDeSer.js ├── piWebSdk.js └── piWebSocketManager.js ├── package.json ├── recipe.json └── routes ├── pubsubControlRoutes.js └── pubsubFunctionRoutes.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/node 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=node 3 | 4 | # Greengrass GDK / Build tools files 5 | zip-build 6 | greengrass-build 7 | 8 | ### Node ### 9 | # Logs 10 | logs 11 | *.log 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | lerna-debug.log* 16 | .pnpm-debug.log* 17 | 18 | # Diagnostic reports (https://nodejs.org/api/report.html) 19 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 20 | 21 | # Runtime data 22 | pids 23 | *.pid 24 | *.seed 25 | *.pid.lock 26 | 27 | # Directory for instrumented libs generated by jscoverage/JSCover 28 | lib-cov 29 | 30 | # Coverage directory used by tools like istanbul 31 | coverage 32 | *.lcov 33 | 34 | # nyc test coverage 35 | .nyc_output 36 | 37 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 38 | .grunt 39 | 40 | # Bower dependency directory (https://bower.io/) 41 | bower_components 42 | 43 | # node-waf configuration 44 | .lock-wscript 45 | 46 | # Compiled binary addons (https://nodejs.org/api/addons.html) 47 | build/Release 48 | 49 | # Dependency directories 50 | node_modules/ 51 | jspm_packages/ 52 | 53 | # Snowpack dependency directory (https://snowpack.dev/) 54 | web_modules/ 55 | 56 | # TypeScript cache 57 | *.tsbuildinfo 58 | 59 | # Optional npm cache directory 60 | .npm 61 | 62 | # Optional eslint cache 63 | .eslintcache 64 | 65 | # Optional stylelint cache 66 | .stylelintcache 67 | 68 | # Microbundle cache 69 | .rpt2_cache/ 70 | .rts2_cache_cjs/ 71 | .rts2_cache_es/ 72 | .rts2_cache_umd/ 73 | 74 | # Optional REPL history 75 | .node_repl_history 76 | 77 | # Output of 'npm pack' 78 | *.tgz 79 | 80 | # Yarn Integrity file 81 | .yarn-integrity 82 | 83 | # dotenv environment variable files 84 | .env 85 | .env.development.local 86 | .env.test.local 87 | .env.production.local 88 | .env.local 89 | 90 | # parcel-bundler cache (https://parceljs.org/) 91 | .cache 92 | .parcel-cache 93 | 94 | # Next.js build output 95 | .next 96 | out 97 | 98 | # Nuxt.js build / generate output 99 | .nuxt 100 | dist 101 | 102 | # Gatsby files 103 | .cache/ 104 | # Comment in the public line in if your project uses Gatsby and not Next.js 105 | # https://nextjs.org/blog/next-9-1#public-directory-support 106 | # public 107 | 108 | # vuepress build output 109 | .vuepress/dist 110 | 111 | # vuepress v2.x temp and cache directory 112 | .temp 113 | 114 | # Docusaurus cache and generated files 115 | .docusaurus 116 | 117 | # Serverless directories 118 | .serverless/ 119 | 120 | # FuseBox cache 121 | .fusebox/ 122 | 123 | # DynamoDB Local files 124 | .dynamodb/ 125 | 126 | # TernJS port file 127 | .tern-port 128 | 129 | # Stores VSCode versions used for testing VSCode extensions 130 | .vscode-test 131 | 132 | # yarn v2 133 | .yarn/cache 134 | .yarn/unplugged 135 | .yarn/build-state.yml 136 | .yarn/install-state.gz 137 | .pnp.* 138 | 139 | ### Node Patch ### 140 | # Serverless Webpack directories 141 | .webpack/ 142 | 143 | # Optional stylelint cache 144 | 145 | # SvelteKit build / generate output 146 | .svelte-kit 147 | 148 | ### Stupid Mac Files ####### 149 | .DS_Store 150 | .AppleDouble 151 | .LSOverride 152 | 153 | # Icon must end with two \r 154 | Icon 155 | 156 | # Thumbnails 157 | ._* 158 | 159 | # Files that might appear in the root of a volume 160 | .DocumentRevisions-V100 161 | .fseventsd 162 | .Spotlight-V100 163 | .TemporaryItems 164 | .Trashes 165 | .VolumeIcon.icns 166 | .com.apple.timemachine.donotpresent 167 | 168 | # Directories potentially created on remote AFP share 169 | .AppleDB 170 | .AppleDesktop 171 | Network Trash Folder 172 | Temporary Items 173 | .apdisk 174 | 175 | 176 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OSI Pi Streaming Data Connector 2 | 3 | The OSI Pi Streaming Data Connector provides real-time data ingestion from OSI Pi to the AWS Industrial Data Architecture shown below. The connector is an [AWS IoT Greengrass component](https://docs.aws.amazon.com/iot/latest/developerguide/what-is-aws-iot.html) that integrates to the Rest'ful PiWebAPI to access and ingest real-time streaming data over WebSocket’s from OSI Pi to AWS IoT Sitewise. 4 | 5 | ![AWS OSI Pi Data Architecture](images/aws-osi-pi-data-architecture.png) 6 | 7 | ## Getting Started and Usage 8 | 9 | For detailed deployment and usage guide see the [AWS OSI Pi Streaming Data Collector Developers Guide](aws-osi-pi-streaming-connector-developers-guide.pdf) 10 | 11 | ## Giving Feedback and Contributions 12 | 13 | * [Contributions Guidelines](CONTRIBUTING.md) 14 | * Submit [Issues, Feature Requests or Bugs](https://github.com/awslabs/osi-pi-streaming-data-connector/issues) 15 | 16 | ## AWS IoT Resources 17 | 18 | * [AWS IoT Core Documentation](https://docs.aws.amazon.com/iot/) 19 | * [AWS IoT Developer Guide](https://docs.aws.amazon.com/iot/latest/developerguide/what-is-aws-iot.html) 20 | * [AWS IoT Greengrass Documentation](https://docs.aws.amazon.com/greengrass/) 21 | * [AWS IoT Greengrass Developer Guide](https://docs.aws.amazon.com/greengrass/v2/developerguide/what-is-iot-greengrass.html) 22 | * [AWS IoT Sitewise](https://aws.amazon.com/iot-sitewise/) 23 | 24 | ## Security 25 | 26 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. 27 | 28 | ## License 29 | 30 | This project is licensed under the Apache-2.0 License. 31 | -------------------------------------------------------------------------------- /THIRD-PARTY-LICENSES: -------------------------------------------------------------------------------- 1 | ├─ @aws-crypto/util@3.0.0 2 | │ ├─ licenses: Apache-2.0 3 | │ ├─ repository: https://github.com/aws/aws-sdk-js-crypto-helpers 4 | │ ├─ publisher: AWS Crypto Tools Team 5 | │ ├─ email: aws-cryptools@amazon.com 6 | │ ├─ url: https://docs.aws.amazon.com/aws-crypto-tools/index.html?id=docs_gateway#lang/en_us 7 | │ ├─ path: /src/node_modules/@aws-crypto/util 8 | │ └─ licenseFile: /src/node_modules/@aws-crypto/util/LICENSE 9 | ├─ @aws-sdk/credential-provider-node@3.347.0 10 | │ ├─ licenses: Apache-2.0 11 | │ ├─ repository: https://github.com/aws/aws-sdk-js-v3 12 | │ ├─ publisher: AWS SDK for JavaScript Team 13 | │ ├─ url: https://aws.amazon.com/javascript/ 14 | │ ├─ path: /src/node_modules/@aws-sdk/credential-provider-node 15 | │ └─ licenseFile: /src/node_modules/@aws-sdk/credential-provider-node/LICENSE 16 | ├─ @eslint-community/regexpp@4.5.1 17 | │ ├─ licenses: MIT 18 | │ ├─ repository: https://github.com/eslint-community/regexpp 19 | │ ├─ publisher: Toru Nagashima 20 | │ ├─ path: /src/node_modules/@eslint-community/regexpp 21 | │ └─ licenseFile: /src/node_modules/@eslint-community/regexpp/LICENSE 22 | ├─ @eslint/js@8.42.0 23 | │ ├─ licenses: MIT 24 | │ ├─ repository: https://github.com/eslint/eslint 25 | │ ├─ path: /src/node_modules/@eslint/js 26 | │ └─ licenseFile: /src/node_modules/@eslint/js/LICENSE 27 | ├─ @httptoolkit/websocket-stream@6.0.1 28 | │ ├─ licenses: BSD-2-Clause 29 | │ ├─ repository: https://github.com/httptoolkit/websocket-stream 30 | │ ├─ path: /src/node_modules/@httptoolkit/websocket-stream 31 | │ └─ licenseFile: /src/node_modules/@httptoolkit/websocket-stream/LICENSE 32 | ├─ @humanwhocodes/module-importer@1.0.1 33 | │ ├─ licenses: Apache-2.0 34 | │ ├─ repository: https://github.com/humanwhocodes/module-importer 35 | │ ├─ publisher: Nicholas C. Zaks 36 | │ ├─ path: /src/node_modules/@humanwhocodes/module-importer 37 | │ └─ licenseFile: /src/node_modules/@humanwhocodes/module-importer/LICENSE 38 | ├─ @nodelib/fs.stat@2.0.5 39 | │ ├─ licenses: MIT 40 | │ ├─ repository: https://github.com/nodelib/nodelib/tree/master/packages/fs/fs.stat 41 | │ ├─ path: /src/node_modules/@nodelib/fs.stat 42 | │ └─ licenseFile: /src/node_modules/@nodelib/fs.stat/LICENSE 43 | ├─ @smithy/types@1.0.0 44 | │ ├─ licenses: Apache-2.0 45 | │ ├─ repository: https://github.com/awslabs/smithy-typescript 46 | │ ├─ publisher: AWS Smithy Team 47 | │ ├─ url: https://smithy.io 48 | │ ├─ path: /src/node_modules/@smithy/types 49 | │ └─ licenseFile: /src/node_modules/@smithy/types/LICENSE 50 | ├─ @types/ms@0.7.31 51 | │ ├─ licenses: MIT 52 | │ ├─ repository: https://github.com/DefinitelyTyped/DefinitelyTyped 53 | │ ├─ path: /src/node_modules/@types/ms 54 | │ └─ licenseFile: /src/node_modules/@types/ms/LICENSE 55 | ├─ axios@1.4.0 56 | │ ├─ licenses: MIT 57 | │ ├─ repository: https://github.com/axios/axios 58 | │ ├─ publisher: Matt Zabriskie 59 | │ ├─ path: /src/node_modules/axios 60 | │ └─ licenseFile: /src/node_modules/axios/LICENSE 61 | ├─ com.amazon.osi-pi-streaming-data-connector@1.0.0 62 | │ ├─ licenses: Apache-2.0 63 | │ ├─ publisher: Dean Colcott 64 | │ ├─ email: https://www.linkedin.com/in/deancolcott 65 | │ └─ path: /src 66 | └─ tslib@1.14.1 67 | ├─ licenses: 0BSD 68 | ├─ repository: https://github.com/Microsoft/tslib 69 | ├─ publisher: Microsoft Corp. 70 | ├─ path: /src/node_modules/@aws-crypto/util/node_modules/tslib 71 | └─ licenseFile: /src/node_modules/@aws-crypto/util/node_modules/tslib/LICENSE.txt 72 | 73 | -------------------------------------------------------------------------------- /aws-osi-pi-streaming-connector-developers-guide.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awslabs/aws-greengrass-labs-osi-pi-streaming-data-connector/bc74c6d8ad47daa152d75296c73c06bbbb0a8ffc/aws-osi-pi-streaming-connector-developers-guide.pdf -------------------------------------------------------------------------------- /images/aws-osi-pi-data-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awslabs/aws-greengrass-labs-osi-pi-streaming-data-connector/bc74c6d8ad47daa152d75296c73c06bbbb0a8ffc/images/aws-osi-pi-data-architecture.png -------------------------------------------------------------------------------- /src/.eslintignore: -------------------------------------------------------------------------------- 1 | 2 | # Ignore Greengrass build and Zip directories. 3 | # Greengrass GDK / Build tools files 4 | zip-build 5 | greengrass-build 6 | 7 | # Ignore node modules 8 | node_modules 9 | 10 | # Any copied reference files starting with OG (original) 11 | OG* -------------------------------------------------------------------------------- /src/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "node": true, 4 | "commonjs": true, 5 | "es2021": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "overrides": [ 9 | ], 10 | "parserOptions": { 11 | "ecmaVersion": "latest" 12 | }, 13 | "rules": { 14 | "no-invalid-this": "error", 15 | "indent": [ 16 | "error", 17 | 2, 18 | { "SwitchCase": 1 } 19 | ], 20 | "linebreak-style": [ 21 | "error", 22 | "unix" 23 | ], 24 | "quotes": [ 25 | "error", 26 | "double" 27 | ], 28 | "semi": [ 29 | "error", 30 | "always" 31 | ] 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /src/configs/componentName.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0. 4 | * 5 | * @author Dean Colcott 6 | */ 7 | 8 | const componentShortName = "streaming-data-connector"; 9 | const componentLongName = `com.amazon.osi-pi-${componentShortName}`; 10 | const componentHumanName = "OSI Pi Streaming Data Connector"; 11 | 12 | module.exports = { 13 | componentShortName, 14 | componentLongName, 15 | componentHumanName 16 | }; 17 | -------------------------------------------------------------------------------- /src/configs/pubsubTopics.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0. 4 | * 5 | * @author Dean Colcott 6 | */ 7 | 8 | const { componentShortName } = require("../configs/componentName"); 9 | 10 | const iotThingName = process.env.AWS_IOT_THING_NAME; 11 | 12 | // PubSub MQTT Control Topics. 13 | const commandEgressTopic = `osi-pi/${componentShortName}/${iotThingName}/egress`; 14 | const commandIngressTopic = `osi-pi/${componentShortName}/${iotThingName}/ingress`; 15 | 16 | // All component group admin ingress topic. 17 | const groupAdminIngressTopic = `osi-pi/${componentShortName}/ingress`; 18 | 19 | // Async control / telemetry update topics. 20 | const piWebsocketStateChangeTopic = `osi-pi/${componentShortName}/${iotThingName}/websocket-state`; 21 | const telemetryUpdateTopic = `osi-pi/${componentShortName}/${iotThingName}/telemetry`; 22 | 23 | // PubSub Config Shadow Subscribe Topics 24 | const configShadowName = `osi-pi-${componentShortName}-config`; 25 | const shadowBaseTopic = `$aws/things/${iotThingName}/shadow`; 26 | const configShadow = `${shadowBaseTopic}/name/${configShadowName}`; 27 | 28 | const configShadowGet = `${configShadow}/get`; 29 | const configShadowGetAccepted = `${configShadowGet}/accepted`; 30 | const configShadowGetRejected = `${configShadowGet}/rejected`; 31 | 32 | const configShadowUpdate = `${configShadow}/update`; 33 | const configShadowUpdateAccepted = `${configShadowUpdate}/accepted`; 34 | const configShadowUpdateDelta = `${configShadowUpdate}/delta`; 35 | 36 | // Group the PubSub subscribe topics. 37 | const pubSubSubscribeTopics = [ 38 | commandIngressTopic, 39 | groupAdminIngressTopic, 40 | configShadowGetAccepted, 41 | configShadowGetRejected, 42 | configShadowUpdateDelta 43 | ]; 44 | 45 | module.exports = { 46 | commandIngressTopic, 47 | groupAdminIngressTopic, 48 | commandEgressTopic, 49 | piWebsocketStateChangeTopic, 50 | telemetryUpdateTopic, 51 | shadowBaseTopic, 52 | configShadowName, 53 | configShadowUpdate, 54 | configShadowGet, 55 | configShadowGetAccepted, 56 | configShadowGetRejected, 57 | configShadowUpdateAccepted, 58 | configShadowUpdateDelta, 59 | pubSubSubscribeTopics 60 | }; 61 | -------------------------------------------------------------------------------- /src/configs/shadowConfigDefault.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0. 4 | * 5 | * @author Dean Colcott 6 | */ 7 | 8 | const defaultShadowConfig = { 9 | "region": "ap-southeast-2", 10 | "osiPiServerConfig": { 11 | "piServerUrl": "", 12 | "piApiRootPath": "piwebapi", 13 | "maxPiApiRequestPerSec": 25, 14 | "maxPiApiQueryResponseItems": 1000, 15 | "authMode": "basic", 16 | "verifySsl": 1 17 | }, 18 | "awsSitewisePublisherConfig": { 19 | "sitewiseMaxTqvPublishRate": 5000, 20 | "sitewisePropertyAliasBatchSize": 10, 21 | "sitewisePropertyValueBatchSize": 10, 22 | "sitewisePropertyPublishMaxAgeSecs": 300 23 | }, 24 | "osiPiWebSocketManagerConfig": { 25 | "maxPiDataPointWebSockets": 5000, 26 | "maxPiPointsPerWebSocket": 100 27 | }, 28 | "awsSeceretsManagerConfig": { 29 | "piCredentialsAwsSecretsArn": "" 30 | }, 31 | "systemTelemetryConfig": { 32 | "telemetryUpdateSecs": 10 33 | } 34 | }; 35 | 36 | module.exports = { 37 | defaultShadowConfig 38 | }; 39 | -------------------------------------------------------------------------------- /src/controllers/core/awsIoTShadowController.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0. 4 | * 5 | * Provides interface and remote config management from the AWS IoT Shadow service. 6 | * 7 | * @author Dean Colcott 8 | */ 9 | 10 | const topics = require("../../configs/pubsubTopics"); 11 | const { iotShadowControllerRoutes } = require("../../routes/pubsubControlRoutes"); 12 | const { defaultShadowConfig } = require("../../configs/shadowConfigDefault"); 13 | 14 | // Initialize a null reported state and empty desired state. 15 | let desiredState = {}; 16 | let reportedState = {}; 17 | let isShadowInitilized; 18 | 19 | /** 20 | * Clears any persistent shadow reported state, then requests the AWS IoT Shadow configuration from 21 | * AWS IoT Core. The response triggers a workflow to create / initialise the default shadow config 22 | * and / or read the existing requested config and apply to the component controllers. 23 | */ 24 | async function initIoTShadowConfig() { 25 | 26 | console.log("\n[INFO] Initializing AWS IoT Shadow Config...."); 27 | 28 | //Prevents processing shadow updates during the initialization procedure. 29 | isShadowInitilized = false; 30 | 31 | // Publish null reported state to clear any persistent configs. 32 | // This will also create a empty shadow document if it doesn't exist. 33 | console.log("\n[INFO] Clearing any persistent shadow reported state."); 34 | let shadowUpdate = {}; 35 | shadowUpdate.state = {}; 36 | shadowUpdate.state.reported = null; 37 | await publishRawMessage(topics.configShadowUpdate, shadowUpdate); 38 | 39 | // Publish Shadow Get Request, the response triggers the shadow initialization workflow. 40 | await publishRawMessage(topics.configShadowGet, {}); 41 | } 42 | 43 | /** 44 | * PubSub message router for the shadow controller. 45 | * @param {*} topic 46 | * @param {*} payload 47 | */ 48 | async function shadowControllerMessageRouter(topic, payload) { 49 | 50 | try { 51 | 52 | switch (topic) { 53 | 54 | case topics.configShadowGetAccepted: 55 | await getShadowAccepted(payload); 56 | break; 57 | 58 | case topics.configShadowUpdateDelta: 59 | await shadowDelta(payload); 60 | break; 61 | 62 | case topics.configShadowGetRejected: 63 | await getShadowRejected(payload); 64 | break; 65 | 66 | default: 67 | console.log(`[INFO] Received unmanaged IoT shadow Message on Topic: ${topic} - Message:`); 68 | console.log(payload); 69 | } 70 | 71 | } catch (err) { 72 | publishErrorResponse(iotShadowControllerRoutes.errorRoute, err); 73 | } 74 | } 75 | 76 | /** 77 | * Processes a get Shadow accepted request by extracting the desired state and forwarding to updateShadowConfig() 78 | * as the state to apply. Is used in the initial component initialization but can force a shadow config refresh 79 | * by want externally generated Shadow Get request to this components config shadow. 80 | * 81 | * @param {*} payload 82 | * @returns 83 | */ 84 | async function getShadowAccepted(payload) { 85 | 86 | console.log("[INFO] Processing AWS IoT shadow GET Accepted response. "); 87 | 88 | // If no desired state found during initilisation then create the default shadow, otherwise is an error state. 89 | if (!(payload.state && payload.state.desired)) { 90 | 91 | if (!isShadowInitilized) { 92 | await createDefaultShadow(); 93 | return; 94 | } else { 95 | throw new Error("Config Shadow GET Accepted missing or empty desired state"); 96 | } 97 | } 98 | 99 | // If received a Get Shadow Accepts with a desired state attached is end of this initialization workflow process. 100 | isShadowInitilized = true; 101 | 102 | // Update the shadow config with received desired state. 103 | await updateShadowConfig(payload.state.desired); 104 | } 105 | 106 | async function shadowDelta(payload) { 107 | 108 | // Ignore shadow delta updates untill completed initilizing the shadow doc. 109 | if (!isShadowInitilized) return; 110 | 111 | if (!payload.state) throw new Error("Config Shadow DELTA missing payload state node"); 112 | 113 | console.log("\n[INFO] Received AWS IoT config shadow Delta Update:"); 114 | console.log(payload.state); 115 | 116 | await updateShadowConfig(payload.state); 117 | } 118 | 119 | async function getShadowRejected(payload) { 120 | 121 | console.log("[INFO] Processing AWS IoT config shadow GET Rejected response."); 122 | 123 | // If requested shadow not found then create with default values, otherwise just notify the GET reject message. 124 | if (payload.code === 404) { 125 | await createDefaultShadow(); 126 | } else { 127 | publishErrorResponse(iotShadowControllerRoutes.errorRoute, { "action": "get-shadow-config-rejected", "payload": payload }); 128 | } 129 | } 130 | 131 | /** 132 | * Create a default shadow document with desired state from the default Shadow template. 133 | * Creating a desired state will trigger a shadow updated responds that will be processed as normal. 134 | */ 135 | async function createDefaultShadow() { 136 | 137 | try { 138 | 139 | console.log("[INFO] Updating Default Shadow from template. "); 140 | 141 | // Set desired and reported state to a deep copy of the default config. 142 | deepMergeObject(desiredState, {...defaultShadowConfig}); 143 | deepMergeObject(reportedState, {...defaultShadowConfig}); 144 | 145 | // Publish the Shadow update to AWS IoT Shadow Service 146 | let shadowUpdate = {}; 147 | shadowUpdate.state = {}; 148 | shadowUpdate.state.desired = desiredState; 149 | shadowUpdate.state.reported = reportedState; 150 | await publishRawMessage(topics.configShadowUpdate, shadowUpdate); 151 | 152 | let message = {}; 153 | message.action = "created-default-shadow-config"; 154 | message.shadow = topics.configShadowName; 155 | message.shadowUpdate = shadowUpdate; 156 | await publishFormattedMessage(iotShadowControllerRoutes.actionRoute, message); 157 | 158 | // This is the end of this shadow initialization workflow where needed to create a default config. 159 | isShadowInitilized = true; 160 | 161 | } catch (err) { 162 | publishErrorResponse(iotShadowControllerRoutes.errorRoute, err); 163 | } 164 | } 165 | 166 | async function updateShadowConfig(deltaConfig) { 167 | 168 | try { 169 | 170 | // Create a merged config candidate with new delta confg and existing reportedState 171 | // with precedent of new delta overwriting any existing reported state 172 | 173 | let configCandidate = {}; 174 | deepMergeObject(configCandidate, {...reportedState}); 175 | deepMergeObject(configCandidate, {...deltaConfig}); 176 | 177 | // Delete any unsupported keys provided by user in configCandidate 178 | deleteUnsupportedConfigKeys(defaultShadowConfig, configCandidate); 179 | 180 | // Get / log the desiredStateCandidate. 181 | console.log("\n[INFO] Applying AWS IoT Shadow Configuration Candidate:"); 182 | console.log(configCandidate); 183 | 184 | // Don't process default config updates as is just a template for users. 185 | if (deepEqual(configCandidate, defaultShadowConfig)) { 186 | console.log("[INFO] Default config found in requested update, returning without publishing change."); 187 | return; 188 | } 189 | 190 | // Don't process configCandidate if doesn't change reported state to avoid update loop. 191 | if (deepEqual(configCandidate, reportedState)) { 192 | console.log("[INFO] Calculated Configuration candidate will not result in any changes, returning without processing update."); 193 | return; 194 | } 195 | 196 | // Apply config - will validate all fields in configCandidate and throw error before updating reported if fail. 197 | 198 | // Get OSI Pi credentials stored in AWS Secrets Manager 199 | const region = configCandidate.region; 200 | const awsSeceretsManagerConfig = configCandidate.awsSeceretsManagerConfig; 201 | const piSecrets = await getPiSecrets(region, awsSeceretsManagerConfig); 202 | 203 | // Update PiWebSdk Config 204 | const osiPiServerConfig = configCandidate.osiPiServerConfig; 205 | piWebSdkUpdateConfig(piSecrets, osiPiServerConfig); 206 | 207 | // Update Sitewise Publisher Config 208 | const awsSitewisePublisherConfig = configCandidate.awsSitewisePublisherConfig; 209 | sitewisePublisherUpdateConfig(region, awsSitewisePublisherConfig); 210 | 211 | // Update OSI Pi WebSocket Manager Config 212 | const osiPiWebSocketManagerConfig = configCandidate.osiPiWebSocketManagerConfig; 213 | webSocketManagerUpdateConfig(piSecrets, osiPiServerConfig, osiPiWebSocketManagerConfig, onWebsocketMessage, onWebsocketChangedState); 214 | 215 | // Update System Telemetry Config 216 | const systemTelemetryConfig = configCandidate.systemTelemetryConfig; 217 | setTelemetryUpdateInterval(systemTelemetryConfig); 218 | 219 | console.log("[INFO] Applying AWS IoT Shadow Update Desired State Candidate - COMPLETE"); 220 | 221 | // If configCandidate all applied successfully then update to reportedState and publish to AWS IoT Shadow service. 222 | console.log("\n[INFO] Updating and publishing applied reported Shadow state."); 223 | reportedState = configCandidate; 224 | 225 | // Publish the reported state applied to AWS IoT Shadow Service 226 | let shadowUpdate = {}; 227 | shadowUpdate.state = {}; 228 | shadowUpdate.state.reported = reportedState; 229 | 230 | // Publish the Shadow update to shadow update topic. 231 | await publishRawMessage(topics.configShadowUpdate, shadowUpdate); 232 | 233 | // Publish the update to the control topic 234 | let message = {}; 235 | message.action = "applied-shadow-config-success"; 236 | message.shadow = topics.configShadowName; 237 | message.shadowUpdate = shadowUpdate; 238 | await publishFormattedMessage(iotShadowControllerRoutes.actionRoute, message); 239 | 240 | } catch (err) { 241 | 242 | // Publish the error to the control topic. 243 | publishErrorResponse(iotShadowControllerRoutes.errorRoute, err); 244 | } 245 | } 246 | 247 | // Shadow object merge / comparison Helpers. 248 | 249 | function deepEqual(object1, object2) { 250 | const keys1 = Object.keys(object1); 251 | const keys2 = Object.keys(object2); 252 | 253 | if (keys1.length !== keys2.length) { 254 | return false; 255 | } 256 | 257 | for (const key of keys1) { 258 | const val1 = object1[key]; 259 | const val2 = object2[key]; 260 | const areObjects = isObject(val1) && isObject(val2); 261 | if ( 262 | areObjects && !deepEqual(val1, val2) || 263 | !areObjects && val1 !== val2 264 | ) { 265 | return false; 266 | } 267 | } 268 | 269 | return true; 270 | } 271 | 272 | function deepMergeObject(target = {}, source = {}) { 273 | 274 | // Iterating through all the keys of source object 275 | Object.keys(source).forEach((key) => { 276 | 277 | if (isObjectNotArray(source[key])) { 278 | 279 | // If source property has nested object, call the function recursively. 280 | if (!target[key]) target[key] = {}; 281 | deepMergeObject(target[key], { ...source[key] }); 282 | 283 | } else if (isObjectIsArray(source[key])) { 284 | 285 | // If source property has nested object, call the function recursively. 286 | target[key] = [...source[key]]; 287 | 288 | } else { 289 | // else merge the object source to target 290 | target[key] = source[key]; 291 | } 292 | }); 293 | 294 | } 295 | 296 | function deleteUnsupportedConfigKeys({ ...referenceObject }, compareObject) { 297 | 298 | for (const testKey of Object.keys(compareObject)) { 299 | 300 | if (testKey in referenceObject) { 301 | 302 | // If the referenceObject value is a nested object then recurse this function. 303 | if (isObjectNotArray(referenceObject[testKey])) { 304 | deleteUnsupportedConfigKeys(referenceObject[testKey], compareObject[testKey]); 305 | } 306 | 307 | } else { 308 | // If compare key not in referenceObject then delete from compareObject. 309 | delete compareObject[testKey]; 310 | } 311 | } 312 | } 313 | 314 | function isObject(object) { 315 | return object != null && typeof object === "object"; 316 | } 317 | 318 | function isObjectNotArray(item) { 319 | return (item && typeof item === "object" && !Array.isArray(item)); 320 | } 321 | 322 | function isObjectIsArray(item) { 323 | return (item && typeof item === "object" && Array.isArray(item)); 324 | } 325 | 326 | module.exports = { 327 | initIoTShadowConfig, 328 | shadowControllerMessageRouter 329 | }; 330 | 331 | // No easy way to remove circular dependencies between PubSub Tx and Rx consumers on the 332 | // same Greengrass client so add require statements after all exports completed. 333 | const { publishRawMessage, publishFormattedMessage, publishErrorResponse } = require("./awsPubsubController"); 334 | const { onWebsocketMessage, onWebsocketChangedState } = require("../functions/osiPiStreamingDataController"); 335 | const { setTelemetryUpdateInterval } = require("./systemTelemetryController"); 336 | 337 | const { getPiSecrets } = require("../../osi-pi-sdk/awsSecretsManager"); 338 | const { piWebSdkUpdateConfig } = require("../../osi-pi-sdk/piWebSdk"); 339 | const { sitewisePublisherUpdateConfig } = require("../../osi-pi-sdk/awsSitewisePublisher"); 340 | const { webSocketManagerUpdateConfig } = require("../../osi-pi-sdk/piWebSocketManager"); 341 | -------------------------------------------------------------------------------- /src/controllers/core/awsPubsubController.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0. 4 | * 5 | * Handles all ingress PubSub message routing and common model for PubSub publish functionality. 6 | * 7 | * Use this class to expose of hide functionality of the AWS OSI Pi Integration library for any given 8 | * Connector by commenting out the unwanted function routes. 9 | * 10 | * 11 | * @author Dean Colcott 12 | */ 13 | 14 | const topics = require("../../configs/pubsubTopics"); 15 | const { osiPiSdkControllerRouteMap, 16 | osiPiStreamingDataControllerRouteMap, 17 | osiPiPointDataWriterRouteMap 18 | } = require("../../routes/pubsubFunctionRoutes"); 19 | const { pubsubControllerRoutes } = require("../../routes/pubsubControlRoutes"); 20 | 21 | const { once } = require("events"); 22 | const { toUtf8 } = require("@aws-sdk/util-utf8-browser"); 23 | const { greengrasscoreipc } = require("aws-iot-device-sdk-v2"); 24 | 25 | let msgIdCnt = 0; 26 | let greengrassClient; 27 | 28 | async function activatePubsubController() { 29 | 30 | console.log("[INFO] Initializing and connecting to Greengrass IPC"); 31 | 32 | // Init the Greengrass SDK Client. 33 | greengrassClient = greengrasscoreipc.createClient(); 34 | 35 | // Connect the Greengrass IPC client. 36 | await greengrassClient.connect(); 37 | 38 | // Init Greengrass PubSub MQTT Subscribed Message Handlers 39 | for (let topic of topics.pubSubSubscribeTopics) { 40 | 41 | console.log(`[INFO] Subscribing to MQTT PubSub Topic: ${topic}`); 42 | await greengrassClient.subscribeToIoTCore({ 43 | topicName: topic, 44 | qos: greengrasscoreipc.model.QOS.AT_LEAST_ONCE 45 | 46 | // On PubSub message callback 47 | }).on("message", (message) => pubsubMessageRouter(message)).activate(); 48 | 49 | console.log(`[INFO] Subscribing to MQTT PubSub Topic: ${topic} - COMPLETE`); 50 | } 51 | 52 | console.log("[INFO] Initializing and connecting to Greengrass IPC - COMPLETE"); 53 | } 54 | 55 | //==================================================== 56 | // PubSub Message Handlers / Routers and Publishers 57 | //==================================================== 58 | 59 | function pubsubMessageRouter(rawPayload) { 60 | 61 | try { 62 | 63 | // Validate message payload structure 64 | if (!(rawPayload.message && rawPayload.message.payload && rawPayload.message.topicName)) { 65 | throw new Error("PubSub Received invalid message format."); 66 | } 67 | 68 | // Get the topic 69 | let topic = rawPayload.message.topicName; 70 | 71 | // Parse the raw payload to JSON. 72 | const payload = JSON.parse(toUtf8(new Uint8Array(rawPayload.message.payload))); 73 | 74 | // Log the message 75 | console.log(`[DEBUG]: PubSub Message Received Topic: ${topic} - Payload: `); 76 | console.log(payload); 77 | 78 | // Route Shadow Topics to Shadow Controller 79 | if (topic.startsWith(topics.shadowBaseTopic)) { 80 | 81 | shadowControllerMessageRouter(topic, payload); 82 | 83 | // Route Control Ingress topic via the payload route field. 84 | } else if (topic === topics.commandIngressTopic || topic === topics.groupAdminIngressTopic) { 85 | 86 | const route = payload.route; 87 | const params = payload.params; 88 | 89 | // Route OSI PI SDK Messages 90 | if (osiPiSdkControllerRouteMap.includes(route)) { 91 | osiPiSdkMessageRouter(route, params); 92 | 93 | // Route OSI PI Streaming Data Controller Messages 94 | } else if (osiPiStreamingDataControllerRouteMap.includes(route)) { 95 | osiPiStreamingDataMessageRouter(route, params); 96 | 97 | // Uncomment below to expose PiPoint create / write functionality 98 | // Route OSI PI Point Data writer Messages 99 | // } else if (osiPiPointDataWriterRouteMap.includes(route)) { 100 | // osiPiPointWriterMessageRouter(route, params); 101 | 102 | } else { 103 | throw new Error(`Received PubSub message on unsupported Route: ${route}`); 104 | } 105 | 106 | } else { 107 | throw new Error(`Received PubSub message on unsupported topic: ${topic}`); 108 | } 109 | 110 | } catch (err) { 111 | publishErrorResponse(pubsubControllerRoutes.errorRoute, err); 112 | } 113 | } 114 | 115 | // Publish message functions 116 | async function publishRawMessage(topic, pubMsg, logMsg = true) { 117 | 118 | try { 119 | 120 | if (logMsg) { 121 | console.log(`[DEBUG] PubSub Message Published - topic: ${topic}`); 122 | console.log(pubMsg); 123 | } 124 | 125 | let jsonMessage = JSON.stringify(pubMsg); 126 | 127 | await greengrassClient.publishToIoTCore({ 128 | topicName: topic, 129 | payload: jsonMessage, 130 | qos: greengrasscoreipc.model.QOS.AT_LEAST_ONCE 131 | }); 132 | 133 | } catch (err) { 134 | console.log("[ERROR] Publish Raw message failed: Error Message:"); 135 | console.log(err); 136 | } 137 | } 138 | 139 | async function publishFormattedMessage(route, messageObject, status = 200, topic = topics.commandEgressTopic, logMsg = true) { 140 | 141 | try { 142 | 143 | let pubMsg = { 144 | "id": ++msgIdCnt, 145 | "route": route, 146 | "status": status, 147 | "response": messageObject 148 | }; 149 | 150 | // Set logMsg to log on status error codes or log on request messages to help debugging. 151 | logMsg = logMsg || status < 200 || status > 299; 152 | 153 | await publishRawMessage(topic, pubMsg, logMsg); 154 | 155 | } catch (err) { 156 | console.log("[ERROR] Publish Formatted message failed: Error Message:"); 157 | console.log(err); 158 | } 159 | } 160 | 161 | async function publishErrorResponse(route, publishError) { 162 | 163 | try { 164 | let status = 500; 165 | // If is a Axios response with status code then assign that to status. 166 | if (publishError.response && publishError.response.status) { 167 | status = publishError.response.status; 168 | } 169 | 170 | const errMessage = typeof publishError === "object" ? publishError.toString() : publishError; 171 | 172 | await publishFormattedMessage(route, errMessage, status); 173 | 174 | } catch (err) { 175 | console.log("[ERROR] Publish Error Reponses message failed: Error Message:"); 176 | console.log(err); 177 | } 178 | } 179 | 180 | async function awaitConnectionClose() { 181 | 182 | // Wait until the Greengrass connection is killed or dropped, use this to hold up the process. 183 | await once(greengrassClient, greengrasscoreipc.Client.DISCONNECTION); 184 | } 185 | 186 | async function closeConnection() { 187 | if (greengrassClient) greengrassClient.close(); 188 | } 189 | 190 | module.exports = { 191 | activatePubsubController, 192 | publishRawMessage, 193 | publishFormattedMessage, 194 | publishErrorResponse, 195 | awaitConnectionClose, 196 | closeConnection 197 | }; 198 | 199 | // No easy way to remove circular dependencies between PubSub Tx and Rx consumers on the 200 | // same Greengrass client so add require statements after all exports completed. 201 | 202 | const { shadowControllerMessageRouter } = require("./awsIoTShadowController"); 203 | const { osiPiSdkMessageRouter } = require("./osiPiSdkController"); 204 | const { osiPiPointWriterMessageRouter } = require("../functions/osiPiPointWriter"); 205 | const { osiPiStreamingDataMessageRouter } = require("../functions/osiPiStreamingDataController"); 206 | -------------------------------------------------------------------------------- /src/controllers/core/osiPiSdkController.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0. 4 | * 5 | * @author Dean Colcott 6 | */ 7 | 8 | // OSI Pi: PiPoints search options. 9 | const searchOptions = { 10 | "contains": 1, 11 | "exact-match": 2, 12 | "starts-with": 3, 13 | "ends-with": 4, 14 | }; 15 | 16 | /** 17 | * Receive and process PubSub MQTT Message Callback 18 | * 19 | * @param {*} payload 20 | */ 21 | function osiPiSdkMessageRouter(route, params) { 22 | 23 | try { 24 | 25 | switch (route) { 26 | 27 | case "publish-pi-root-path": 28 | publishPiRootPath(route); 29 | break; 30 | 31 | // Pi Asset Server Functions. 32 | case "publish-pi-asset-servers": 33 | publishPiAssetServers(route, params); 34 | break; 35 | 36 | case "publish-pi-asset-server-by-param": 37 | publishPiAssetServerByParam(route, params); 38 | break; 39 | 40 | // Pi Asset Database Functions. 41 | case "publish-pi-asset-database-by-param": 42 | publishPiAssetDatabaseByParam(route, params); 43 | break; 44 | 45 | case "publish-pi-asset-databases-by-asset-server-webid": 46 | publishPiAssetDatabasesByAssetServerWebId(route, params); 47 | break; 48 | 49 | // Pi Asset Elements Functions. 50 | case "publish-pi-asset-element-by-param": 51 | publishPiAssetElementByParam(route, params); 52 | break; 53 | 54 | case "publish-pi-asset-elements-by-query": 55 | publishPiAssetElementsByQuery(route, params); 56 | break; 57 | 58 | case "publish-number-asset-elements-by-query": 59 | publishNumberAssetElementsByQuery(route, params); 60 | break; 61 | 62 | case "publish-pi-asset-elements-by-asset-database-webid": 63 | publishPiAssetElementsByAssetDatabaseWebId(route, params); 64 | break; 65 | 66 | case "publish-pi-asset-element-children-by-webid": 67 | publishPiAssetElementChildrenByWebId(route, params); 68 | break; 69 | 70 | // Pi Asset Element Attrbute Functions. 71 | case "publish-pi-attribute-by-param": 72 | publishPiAttributeByParam(route, params); 73 | break; 74 | 75 | case "publish-pi-attribute-by-element-webid": 76 | publishPiAttributesByElementWebId(route, params); 77 | break; 78 | 79 | // Pi Asset Template Functions. 80 | case "publish-pi-element-template-by-param": 81 | publishPiElementTemplateByParam(route, params); 82 | break; 83 | 84 | case "publish-pi-element-templates-by-asset-database-webid": 85 | publishPiElementTemplatesByAssetDatabaseWebId(route, params); 86 | break; 87 | 88 | // Pi Asset Template Attribute Functions. 89 | case "publish-pi-template-attribute-by-param": 90 | publishPiTemplateAttributeByParam(route, params); 91 | break; 92 | 93 | case "publish-pi-template-attributes-by-template-webid": 94 | publishPiTemplateAttributesByTemplateWebId(route, params); 95 | break; 96 | 97 | // Pi Data Archive / Data Server Functions. 98 | case "publish-pi-data-servers": 99 | publishPiDataServers(route, params); 100 | break; 101 | 102 | case "publish-pi-data-server-by-param": 103 | publishPiDataServerByParam(route, params); 104 | break; 105 | 106 | // Pi Data Archive / Pi (data) Points Functions. 107 | case "publish-pi-points-by-query": 108 | publishPiPointsByQuery(route, params); 109 | break; 110 | 111 | case "publish-number-pi-points-by-query": 112 | publishNumberPiPointsByQuery(route, params); 113 | break; 114 | 115 | default: 116 | throw new Error(`Unknown message Route received by OSI Pi SDK Controller: ${route}`); 117 | } 118 | 119 | } catch (err) { 120 | publishErrorResponse(route, err); 121 | } 122 | } 123 | 124 | //=================================================== 125 | // OSI Pi SDK Functions 126 | 127 | async function publishPiRootPath(route) { 128 | 129 | try { 130 | 131 | let piRootPath = await piWebSdk.getPiApiRoot(); 132 | 133 | // Publish this iteration of PiPoints to PubSub 134 | let message = {}; 135 | message.piRootPath = piRootPath; 136 | 137 | // Publish the PubSub message. 138 | await publishFormattedMessage(route, message); 139 | 140 | } catch (err) { 141 | publishErrorResponse(route, err); 142 | } 143 | 144 | } 145 | 146 | // OSI Pi Asset Framework Server Functions. 147 | 148 | async function publishPiAssetServers(route) { 149 | 150 | try { 151 | 152 | let piAssetServers = await piWebSdk.getPiAssetServers(); 153 | 154 | // Publish this iteration of PiPoints to PubSub 155 | let message = {}; 156 | message.numberPiAssetServers = piAssetServers.length; 157 | message.piAssetServers = piAssetServers; 158 | 159 | // Publish the PubSub message. 160 | await publishFormattedMessage(route, message); 161 | 162 | } catch (err) { 163 | publishErrorResponse(route, err); 164 | } 165 | 166 | } 167 | 168 | async function publishPiAssetServerByParam(route, params) { 169 | 170 | try { 171 | const webid = params.webid; 172 | const path = params.path; 173 | const name = params.name; 174 | 175 | // Get the Data Server Object. 176 | let piAssetServer = await piWebSdk.getPiAssetServerByParam(webid, path, name); 177 | 178 | // Publish the result 179 | let message = {}; 180 | if (webid) message.webid = webid; 181 | if (path) message.path = path; 182 | if (name) message.name = name; 183 | message.piAssetServer = piAssetServer; 184 | 185 | // Publish the PubSub message. 186 | await publishFormattedMessage(route, message); 187 | 188 | } catch (err) { 189 | publishErrorResponse(route, err); 190 | } 191 | 192 | } 193 | 194 | // OSI Pi Asset Database Functions. 195 | 196 | async function publishPiAssetDatabasesByAssetServerWebId(route, params) { 197 | 198 | try { 199 | let assetServerWebId = params.assetServerWebId; 200 | if (!assetServerWebId) throw new Error("Must include 'assetServerWebId' value. List all Pi asset server WebIds via publish-pi-asset-servers route"); 201 | 202 | let piAssetDatabases = await piWebSdk.getPiAssetDatabasesByAssetServerWebId(assetServerWebId); 203 | 204 | // Publish this iteration of PiPoints to PubSub 205 | let message = {}; 206 | message.assetServerWebId = assetServerWebId; 207 | message.numberPiAssetDatabases = piAssetDatabases.length; 208 | message.piAssetDatabases = piAssetDatabases; 209 | 210 | // Publish the PubSub message. 211 | await publishFormattedMessage(route, message); 212 | 213 | } catch (err) { 214 | publishErrorResponse(route, err); 215 | } 216 | 217 | } 218 | 219 | async function publishPiAssetDatabaseByParam(route, params) { 220 | 221 | try { 222 | let webid = params.webid; 223 | let path = params.path; 224 | 225 | // Get the Data Server Object. 226 | let piAssetServer = await piWebSdk.getPiAssetDatabaseByParam(webid, path); 227 | 228 | // Publish the result 229 | let message = {}; 230 | if (webid) message.webid = webid; 231 | if (path) message.path = path; 232 | message.piAssetServer = piAssetServer; 233 | 234 | // Publish the PubSub message. 235 | await publishFormattedMessage(route, message); 236 | 237 | } catch (err) { 238 | publishErrorResponse(route, err); 239 | } 240 | 241 | } 242 | 243 | // OSI Pi Asset Element Functions. 244 | 245 | async function publishPiAssetElementByParam(route, params) { 246 | 247 | try { 248 | let webid = params.webid; 249 | let path = params.path; 250 | 251 | // Get the Data Server Object. 252 | let piAssetElement = await piWebSdk.getPiAssetElementByParam(webid, path); 253 | 254 | // Publish the result 255 | let message = {}; 256 | if (webid) message.webid = webid; 257 | if (path) message.path = path; 258 | message.piAssetElement = piAssetElement; 259 | 260 | // Publish the PubSub message. 261 | await publishFormattedMessage(route, message); 262 | 263 | } catch (err) { 264 | publishErrorResponse(route, err); 265 | } 266 | 267 | } 268 | 269 | async function publishPiAssetElementsByQuery(route, params) { 270 | 271 | try { 272 | await publishFormattedMessage(route, "request-ack-processing", 202); 273 | 274 | let startIndex = 0, piAssetElements = []; 275 | const databaseWebId = params.databaseWebId; 276 | const queryString = params.queryString; 277 | 278 | if (!databaseWebId) throw new Error("Must include databaseWebId value, list all Asset Database details via publish-pi-asset-databases-by-asset-server-webid route"); 279 | if (!queryString) throw new Error("Must include a queryString. For example: Enter 'name:=*' to return all Pi Asset Elements (use cautiously and at your own risk!)"); 280 | 281 | // Manually reduce MaxItems that will return in a call to fit in a single MQTT response message. 282 | const mqttPublishMaxItemCount = 250; 283 | 284 | do { 285 | // Get this iteration of matching piPoints from current startIndex to maxItemCount 286 | 287 | piAssetElements = await piWebSdk.getPiAssetElementsByQuery(databaseWebId, queryString, startIndex, mqttPublishMaxItemCount); 288 | 289 | // Don't send last message after startIndex with zero return values. 290 | if (piAssetElements.length === 0) break; 291 | 292 | // Publish this iteration of Pi Elements to PubSub 293 | let message = {}; 294 | message.numberPiAssetElements = piAssetElements.length; 295 | message.startIndex = startIndex; 296 | message.queryString = queryString; 297 | message.piAssetElements = piAssetElements; 298 | 299 | // Publish the 206 partial response update message. 300 | await publishFormattedMessage(route, message, 206); 301 | 302 | // Update startIndex for next iteration. 303 | startIndex += piAssetElements.length; 304 | 305 | } while (piAssetElements.length > 0); 306 | 307 | await publishFormattedMessage(route, { "databaseWebId": databaseWebId, "queryString": queryString, "itemsReturned": startIndex }); 308 | 309 | } catch (err) { 310 | publishErrorResponse(route, err); 311 | } 312 | 313 | } 314 | 315 | async function publishNumberAssetElementsByQuery(route, params) { 316 | 317 | try { 318 | await publishFormattedMessage(route, "request-ack-processing", 202); 319 | 320 | let startIndex = 0, assetElementsReturned; 321 | const databaseWebId = params.databaseWebId; 322 | const queryString = params.queryString; 323 | 324 | if (!databaseWebId) throw new Error("Must include databaseWebId value, list all Asset Database details via publish-pi-asset-databases-by-asset-server-webid route"); 325 | if (!queryString) throw new Error("Must include a queryString. For example: Enter 'name:=*' to return all Pi Asset Elements (use cautiously and at your own risk!)"); 326 | 327 | do { 328 | // Get this iteration of returned Pi Points and add to count. 329 | assetElementsReturned = await piWebSdk.getNumberAssetElementsByQuery(databaseWebId, queryString, startIndex); 330 | startIndex += assetElementsReturned; 331 | 332 | } while (assetElementsReturned > 0); 333 | 334 | let message = {}; 335 | message.databaseWebId = databaseWebId; 336 | message.queryString = queryString; 337 | message.returnedAssetElements = startIndex; 338 | await publishFormattedMessage(route, message); 339 | 340 | } catch (err) { 341 | publishErrorResponse(route, err); 342 | } 343 | 344 | } 345 | 346 | async function publishPiAssetElementsByAssetDatabaseWebId(route, params) { 347 | 348 | try { 349 | await publishFormattedMessage(route, "request-ack-processing", 202); 350 | 351 | let startIndex = 0, piAssetElements = []; 352 | const assetDatabaseWebId = params.assetDatabaseWebId; 353 | let searchFullHierarchy = params.searchFullHierarchy; 354 | 355 | if (!assetDatabaseWebId) throw new Error("Must inclue 'assetDatabaseWebId' value. List all Pi asset server WebIds via publish-pi-asset-databases-by-asset-server-webid route"); 356 | 357 | // Default searchFullHierarchy to false 358 | if (searchFullHierarchy == null) searchFullHierarchy = false; 359 | 360 | // Manually reduce MaxItems that will return in a call to fit in a single MQTT response message. 361 | const mqttPublishMaxItemCount = 250; 362 | 363 | do { 364 | 365 | piAssetElements = await piWebSdk.getPiAssetElementsByAssetDatabaseWebId(assetDatabaseWebId, searchFullHierarchy, startIndex, mqttPublishMaxItemCount); 366 | 367 | // Don't send last message after startIndex with zero return values. 368 | if (piAssetElements.length === 0) break; 369 | 370 | // Publish this iteration of PiPoints to PubSub 371 | let message = {}; 372 | message.startIndex = startIndex; 373 | message.assetDatabaseWebId = assetDatabaseWebId; 374 | message.numberPiAssetElements = piAssetElements.length; 375 | message.piAssetElements = piAssetElements; 376 | 377 | // Publish the 206 partial response update message. 378 | await publishFormattedMessage(route, message, 206); 379 | 380 | // Update startIndex for next iteration. 381 | startIndex += piAssetElements.length; 382 | 383 | } while (piAssetElements.length > 0); 384 | 385 | await publishFormattedMessage(route, { "assetDatabaseWebId": assetDatabaseWebId, "itemsReturned": startIndex }); 386 | 387 | } catch (err) { 388 | publishErrorResponse(route, err); 389 | } 390 | } 391 | 392 | async function publishPiAssetElementChildrenByWebId(route, params) { 393 | 394 | try { 395 | await publishFormattedMessage(route, "request-ack-processing", 202); 396 | 397 | let startIndex = 0, piAssetElements = []; 398 | let piElementWebId = params.piElementWebId; 399 | let searchFullHierarchy = params.searchFullHierarchy; 400 | 401 | if (!piElementWebId) throw new Error("Must inclue 'piElementWebId' value. List all Pi asset database Element WebIds via publish-pi-asset-element-templates-by-asset-database-webid route"); 402 | 403 | // Default searchFullHierarchy to false 404 | if (searchFullHierarchy == null) searchFullHierarchy = false; 405 | 406 | // Manually reduce MaxItems that will return in a call to fit in a single MQTT response message. 407 | const mqttPublishMaxItemCount = 250; 408 | 409 | do { 410 | 411 | piAssetElements = await piWebSdk.getPiAssetElementChildrenByWebId(piElementWebId, searchFullHierarchy, startIndex, mqttPublishMaxItemCount); 412 | 413 | // Don't send last message after startIndex with zero return values. 414 | if (piAssetElements.length === 0) break; 415 | 416 | // Publish this iteration of PiPoints to PubSub 417 | let message = {}; 418 | message.numberPiAssetElements = piAssetElements.length; 419 | message.startIndex = startIndex; 420 | message.piElementWebId = piElementWebId; 421 | message.piAssetElements = piAssetElements; 422 | 423 | // Publish the 206 partial response update messages. 424 | await publishFormattedMessage(route, message, 206); 425 | 426 | // Update startIndex for next iteration. 427 | startIndex += piAssetElements.length; 428 | 429 | } while (piAssetElements.length > 0); 430 | 431 | await publishFormattedMessage(route, { "piElementWebId": piElementWebId, "itemsReturned": startIndex }); 432 | 433 | } catch (err) { 434 | publishErrorResponse(route, err); 435 | } 436 | } 437 | 438 | // OSI Pi Element Attribute Functions. 439 | 440 | async function publishPiAttributeByParam(route, params) { 441 | 442 | try { 443 | let webid = params.webid; 444 | let path = params.path; 445 | 446 | // Get the Data Server Object. 447 | let piAttribute = await piWebSdk.getPiAttributeByParam(webid, path); 448 | 449 | // Publish the result 450 | let message = {}; 451 | message.piAttribute = piAttribute; 452 | 453 | // Publish the PubSub message. 454 | await publishFormattedMessage(route, message); 455 | 456 | } catch (err) { 457 | publishErrorResponse(route, err); 458 | } 459 | 460 | } 461 | 462 | async function publishPiAttributesByElementWebId(route, params) { 463 | 464 | try { 465 | await publishFormattedMessage(route, "request-ack-processing", 202); 466 | 467 | let startIndex = 0, piAttributes = []; 468 | const elementWebId = params.elementWebId; 469 | 470 | if (!elementWebId) throw new Error("Must include elementWebId value, list all Asset Elements details via publish-pi-asset-elements-by-query route"); 471 | 472 | // Manually reduce MaxItems that will return in a call to fit in a single MQTT response message. 473 | const mqttPublishMaxItemCount = 200; 474 | 475 | do { 476 | 477 | // Get this iteration of returned Pi Points and add to count. 478 | piAttributes = await piWebSdk.getPiAssetAttributesByElementWebId(elementWebId, startIndex, mqttPublishMaxItemCount); 479 | 480 | // Don't send last message after startIndex with zero return values. 481 | if (piAttributes.length === 0) break; 482 | 483 | // Publish this iteration of Pi Attributes to PubSub 484 | let message = {}; 485 | message.startIndex = startIndex; 486 | message.numberPiApiAttributes = piAttributes.length; 487 | message.piAttributes = piAttributes; 488 | 489 | // Publish the 206 partial response update message. 490 | await publishFormattedMessage(route, message, 206); 491 | 492 | // Update startIndex for next iteration. 493 | startIndex += piAttributes.length; 494 | 495 | } while (piAttributes.length > 0); 496 | 497 | await publishFormattedMessage(route, { "elementWebId": elementWebId, "itemsReturned": startIndex }); 498 | 499 | } catch (err) { 500 | publishErrorResponse(route, err); 501 | } 502 | } 503 | 504 | // OSI Pi Asset Element Template Functions. 505 | 506 | async function publishPiElementTemplatesByAssetDatabaseWebId(route, params) { 507 | 508 | try { 509 | await publishFormattedMessage(route, "request-ack-processing", 202); 510 | 511 | const assetDatabaseWebId = params.assetDatabaseWebId; 512 | if (!assetDatabaseWebId) throw new Error("Must inclue 'assetDatabaseWebId' value. List all Pi asset server WebIds via publish-pi-asset-databases-by-asset-server-webid route"); 513 | 514 | // NOTE: GetElementTemnplate doesn't support startIndex so assume will only return < mqttPublishMaxItemCount items. 515 | // Means can't use the same do / while loop pattern and instead need to break down what is returned. 516 | const piElementTemplates = await piWebSdk.getPiElementTemplatesByAssetDatabaseWebId(assetDatabaseWebId); 517 | const numTempates = piElementTemplates.length; 518 | 519 | // Manually reduce MaxItems that will return in a call to fit in a single MQTT response message. 520 | const mqttPublishMaxItemCount = 100; 521 | let startIndex = 0, publishPiElementTemplates = []; 522 | do { 523 | 524 | publishPiElementTemplates = piElementTemplates.splice(0, mqttPublishMaxItemCount); 525 | 526 | // Don't send last message after startIndex with zero return values. 527 | if (publishPiElementTemplates.length === 0) break; 528 | 529 | // Publish this iteration of PiPoints to PubSub 530 | let message = {}; 531 | message.startIndex = startIndex; 532 | message.numberPiElementTemplates = publishPiElementTemplates.length; 533 | message.piElementTemplates = publishPiElementTemplates; 534 | 535 | // Publish the 206 partial response update messages. 536 | await publishFormattedMessage(route, message, 206); 537 | 538 | startIndex += publishPiElementTemplates.length; 539 | 540 | } while (publishPiElementTemplates.length > 0); 541 | 542 | await publishFormattedMessage(route, { "assetDatabaseWebId": assetDatabaseWebId, "itemsReturned": numTempates}); 543 | 544 | } catch (err) { 545 | publishErrorResponse(route, err); 546 | } 547 | } 548 | 549 | async function publishPiElementTemplateByParam(route, params) { 550 | 551 | try { 552 | let webid = params.webid; 553 | let path = params.path; 554 | 555 | // Get the Data Server Object. 556 | let piElementTemplate = await piWebSdk.getPiElementTemplateByParam(webid, path); 557 | 558 | // Publish the result 559 | let message = {}; 560 | message.piElementTemplate = piElementTemplate; 561 | 562 | // Publish the PubSub message. 563 | await publishFormattedMessage(route, message); 564 | 565 | } catch (err) { 566 | publishErrorResponse(route, err); 567 | } 568 | } 569 | 570 | // OSI Pi Element Template Attribute Functions. 571 | 572 | async function publishPiTemplateAttributeByParam(route, params) { 573 | 574 | try { 575 | let webid = params.webid; 576 | let path = params.path; 577 | 578 | // Get the Data Server Object. 579 | let piTemplateAttribute = await piWebSdk.getPiTemplateAttributeByParam(webid, path); 580 | 581 | // Publish the result 582 | let message = {}; 583 | message.piTemplateAttribute = piTemplateAttribute; 584 | 585 | // Publish the PubSub message. 586 | await publishFormattedMessage(route, message); 587 | 588 | } catch (err) { 589 | publishErrorResponse(route, err); 590 | } 591 | } 592 | 593 | async function publishPiTemplateAttributesByTemplateWebId(route, params) { 594 | 595 | try { 596 | await publishFormattedMessage(route, "request-ack-processing", 202); 597 | 598 | let startIndex = 0, piTemplateAttributes = []; 599 | const templateWebid = params.templateWebid; 600 | 601 | if (!templateWebid) throw new Error("Must include templateWebid value, list all Asset Element Templates details via publish-pi-element-templates-by-asset-database-webid route"); 602 | 603 | // Manually reduce MaxItems that will return in a call to fit in a single MQTT response message. 604 | const mqttPublishMaxItemCount = 200; 605 | 606 | do { 607 | 608 | // Get this iteration of returned Pi Points and add to count. 609 | piTemplateAttributes = await piWebSdk.getPiTemplateAttributesByTemplateWebId(templateWebid, startIndex, mqttPublishMaxItemCount); 610 | 611 | // Don't send last message after startIndex with zero return values. 612 | if (piTemplateAttributes.length === 0) break; 613 | 614 | // Publish this iteration of Pi Attributes to PubSub 615 | let message = {}; 616 | message.startIndex = startIndex; 617 | message.numberPiTemplateAttributes = piTemplateAttributes.length; 618 | message.templateWebid = templateWebid; 619 | message.piTemplateAttributes = piTemplateAttributes; 620 | 621 | // Publish the 206 partial response update messages. 622 | await publishFormattedMessage(route, message, 206); 623 | 624 | // Update startIndex for next iteration. 625 | startIndex += piTemplateAttributes.length; 626 | 627 | } while (piTemplateAttributes.length > 0); 628 | 629 | await publishFormattedMessage(route, { "templateWebid": templateWebid, "itemsReturned": startIndex }); 630 | 631 | } catch (err) { 632 | publishErrorResponse(route, err); 633 | } 634 | } 635 | 636 | // OSI Pi Data Archive Server Functions. 637 | 638 | async function publishPiDataServers(route) { 639 | 640 | try { 641 | let piDataServers = await piWebSdk.getPiDataServers(route); 642 | 643 | // Publish this iteration of PiPoints to PubSub 644 | let message = {}; 645 | message.numberPiDataServers = piDataServers.length; 646 | message.piDataServers = piDataServers; 647 | 648 | // Publish the PubSub message. 649 | await publishFormattedMessage(route, message); 650 | 651 | } catch (err) { 652 | publishErrorResponse(route, err); 653 | } 654 | } 655 | 656 | async function publishPiDataServerByParam(route, params) { 657 | 658 | try { 659 | let webid = params.webid; 660 | let path = params.path; 661 | let name = params.name; 662 | 663 | // Get the Data Server Object. 664 | let piDataServer = await piWebSdk.getPiDataServerByParam(webid, path, name); 665 | 666 | // Publish the result 667 | let message = {}; 668 | message.webid = webid; 669 | message.piDataServer = piDataServer; 670 | 671 | // Publish the PubSub message. 672 | await publishFormattedMessage(route, message); 673 | 674 | } catch (err) { 675 | publishErrorResponse(route, err); 676 | } 677 | } 678 | 679 | // OSI PiPoint from Data Archive Server Query Functions. 680 | 681 | async function publishPiPointsByQuery(route, params) { 682 | 683 | try { 684 | await publishFormattedMessage(route, "request-ack-processing", 202); 685 | 686 | let startIndex = 0, piPoints = []; 687 | const dataServerWebId = params.dataServerWebId; 688 | const queryString = params.queryString; 689 | const searchOption = params.searchOption; 690 | 691 | if (!dataServerWebId) throw new Error("Must include dataServerWebId value, list all data server details via publish-pi-data-servers route"); 692 | if (!queryString) throw new Error("Must include a queryString. For example: Enter 'tag:=*' to return all values (use cautiously and at your own risk!)"); 693 | 694 | // Get the optional Pi searchOption value and convert to required in val. Sed default 1 (search value 'contains' queryString) 695 | let searchOptionVal = searchOptions[searchOption]; 696 | if (!searchOptionVal) searchOptionVal = 1; 697 | 698 | // Manually reduce MaxItems that will return in a call to fit in a single MQTT response message. 699 | const mqttPublishMaxItemCount = 250; 700 | 701 | do { 702 | // Get this iteration of matching piPoints from current startIndex to maxItemCount 703 | 704 | piPoints = await piWebSdk.getPiPointsByQuery(dataServerWebId, queryString, searchOptionVal, startIndex, mqttPublishMaxItemCount); 705 | 706 | // Don't send last message after startIndex with zero return values. 707 | if (piPoints.length === 0) break; 708 | 709 | // Publish this iteration of PiPoints to PubSub 710 | let message = {}; 711 | message.startIndex = startIndex; 712 | message.numberPiPoints = piPoints.length; 713 | message.dataServerWebId = dataServerWebId; 714 | message.queryString = queryString; 715 | message.searchOption = Object.keys(searchOptions).find(key => searchOptions[key] === searchOptionVal); 716 | message.piPointItems = piPoints; 717 | 718 | // Publish the 206 partial response update message. 719 | await publishFormattedMessage(route, message, 206); 720 | 721 | // Update startIndex for next iteration. 722 | startIndex += piPoints.length; 723 | 724 | } while (piPoints.length > 0); 725 | 726 | await publishFormattedMessage(route, { "dataServerWebId": dataServerWebId, "queryString": queryString, "itemsReturned": startIndex }); 727 | 728 | } catch (err) { 729 | publishErrorResponse(route, err); 730 | } 731 | } 732 | 733 | async function publishNumberPiPointsByQuery(route, params) { 734 | 735 | try { 736 | await publishFormattedMessage(route, "request-ack-processing", 202); 737 | 738 | let startIndex = 0, piPointsReturned = 0; 739 | const dataServerWebId = params.dataServerWebId; 740 | const queryString = params.queryString; 741 | const searchOption = params.searchOption; 742 | 743 | if (!dataServerWebId) throw new Error("Must include dataServerWebId value, list all data servers via publish-pi-data-servers route"); 744 | if (!queryString) throw new Error("Must include a queryString. For example: Enter 'tag:=*' to return all values (use cautiously and at your own risk!)"); 745 | 746 | // Get the optional Pi searchOption val and convert to required in val. Sed default 1 (search value 'contains' queryString) 747 | let searchOptionVal = searchOptions[searchOption]; 748 | if (!searchOptionVal) searchOptionVal = 1; 749 | 750 | do { 751 | 752 | // Get this iteration of returned Pi Points and add to count. 753 | piPointsReturned = await piWebSdk.getNumberPiPointsByQuery(dataServerWebId, queryString, searchOptionVal, startIndex); 754 | 755 | startIndex += piPointsReturned; 756 | 757 | } while (piPointsReturned > 0); 758 | 759 | let message = {}; 760 | message.dataServerWebId = dataServerWebId; 761 | message.queryString = queryString; 762 | message.searchOption = Object.keys(searchOptions).find(key => searchOptions[key] === searchOptionVal); 763 | message.piPointsReturned = startIndex; 764 | await publishFormattedMessage(route, message); 765 | 766 | } catch (err) { 767 | publishErrorResponse(route, err); 768 | } 769 | } 770 | 771 | module.exports = { 772 | osiPiSdkMessageRouter 773 | }; 774 | 775 | // No easy way to remove circular dependencies between PubSub Tx and Rx consumers on the 776 | // same Greengrass client so add require statements after all exports completed. 777 | const { publishFormattedMessage, publishErrorResponse } = require("./awsPubsubController"); 778 | const piWebSdk = require("../../osi-pi-sdk/piWebSdk"); 779 | 780 | 781 | -------------------------------------------------------------------------------- /src/controllers/core/systemTelemetryController.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0. 4 | * 5 | * @author Dean Colcott 6 | */ 7 | 8 | const v8 = require("node:v8"); 9 | const topics = require("../../configs/pubsubTopics"); 10 | const { systemTelemetryControllerRoutes } = require("../../routes/pubsubControlRoutes"); 11 | 12 | let telemetryTimerId, telemetryUpdateSecs; 13 | let cpuUsage = process.cpuUsage(); 14 | let processTime = process.hrtime(); 15 | 16 | function setTelemetryUpdateInterval(systemTelemetryConfig) { 17 | 18 | console.log("[INFO] Updating System and Telemetry Publish Interval...."); 19 | 20 | // Set and basic validation of telemetryUpdateSecs 21 | const telemetryUpdateSecsCandidate = systemTelemetryConfig.telemetryUpdateSecs; 22 | if (isNaN(telemetryUpdateSecsCandidate) || telemetryUpdateSecsCandidate < 5 || telemetryUpdateSecsCandidate > 60) { 23 | throw new Error("'telemetryUpdateSecs' not provided or is an invalid value (int: 5 - 60)"); 24 | } 25 | telemetryUpdateSecs = telemetryUpdateSecsCandidate; 26 | 27 | // If telemetryUpdateIntervalSec validated then trigger a new timer. 28 | if (telemetryTimerId) clearTimeout(telemetryTimerId); 29 | telemetryTimerId = setInterval(publishTelemetery, telemetryUpdateSecs * 1000); 30 | 31 | console.log("[INFO] Updating System and Telemetry Publish Interval - COMPLETE"); 32 | 33 | } 34 | 35 | function publishTelemetery() { 36 | 37 | try { 38 | 39 | //======================================== 40 | // Add System CPU / Memory usage stats to telemetry message 41 | const message = {}; 42 | message.timestamp = Date.now(); 43 | 44 | // System / process memory stats 45 | const memStats = v8.getHeapStatistics(); 46 | message.system = memStats; 47 | message.system.memoryPercentUsed = ((memStats.total_heap_size / memStats.heap_size_limit) * 100).toFixed(2); 48 | 49 | cpuUsage = process.cpuUsage(cpuUsage); 50 | processTime = process.hrtime(processTime); 51 | 52 | const elapTimeMS = secNSec2ms(processTime); 53 | const elapUserMS = secNSec2ms(cpuUsage.user); 54 | const elapSystMS = secNSec2ms(cpuUsage.system); 55 | const cpuPercent = Math.round(100 * (elapUserMS + elapSystMS) / elapTimeMS); 56 | 57 | message.system.cpuUsage = cpuUsage; 58 | message.system.cpuUsage.percent = cpuPercent; 59 | 60 | //======================================== 61 | // Add AWS IoT Sitewise Publisher Telemetry for data producer components. 62 | message.sitewise = {}; 63 | message.osipi = {}; 64 | const sitewiseTelemetry = getSitewiseTelemetryData(true); 65 | 66 | // Calculate and format metrics in per/second values based on averaging telemetry UpdateMs 67 | 68 | if (!isNaN(sitewiseTelemetry.receivedOsiPiPointsCount)) { 69 | const receivedOsiPiPointsCount = sitewiseTelemetry.receivedOsiPiPointsCount / telemetryUpdateSecs; 70 | message.osipi.receivedPiPointsPerSec = receivedOsiPiPointsCount.toFixed(2); 71 | } 72 | 73 | if (!isNaN(sitewiseTelemetry.numPublishes)) { 74 | const sitewisePublishPerSec = sitewiseTelemetry.numPublishes / telemetryUpdateSecs; 75 | message.sitewise.publishPerSec = sitewisePublishPerSec.toFixed(2); 76 | } 77 | 78 | if (!isNaN(sitewiseTelemetry.publishedPropertyAlias)) { 79 | const sitewisePropAliasPerSec = sitewiseTelemetry.publishedPropertyAlias / telemetryUpdateSecs; 80 | message.sitewise.propAliasPerSec = sitewisePropAliasPerSec.toFixed(2); 81 | } 82 | 83 | if (!isNaN(sitewiseTelemetry.publishedPropertyValues)) { 84 | const sitewisePropValuesPerSec = sitewiseTelemetry.publishedPropertyValues / telemetryUpdateSecs; 85 | message.sitewise.propValuesPerSec = sitewisePropValuesPerSec.toFixed(2); 86 | } 87 | 88 | if (!isNaN(sitewiseTelemetry.sitewiseQueuedPropAlias)) { 89 | message.sitewise.queuedPropAlias = sitewiseTelemetry.sitewiseQueuedPropAlias; 90 | } 91 | 92 | if (!isNaN(sitewiseTelemetry.publishedPropertyValues) && !isNaN(sitewiseTelemetry.publishedPropertyAlias)) { 93 | let sitewisePropValuesPerAlias = sitewiseTelemetry.publishedPropertyValues / sitewiseTelemetry.publishedPropertyAlias; 94 | if (isNaN(sitewisePropValuesPerAlias)) sitewisePropValuesPerAlias = 0; // If publishedPropertyAlias == 0. 95 | message.sitewise.propValuesPerAlias = sitewisePropValuesPerAlias.toFixed(2); 96 | } 97 | 98 | if (!isNaN(sitewiseTelemetry.sitewisePublishErrorCount)) { 99 | message.sitewise.publishErrorCount = sitewiseTelemetry.sitewisePublishErrorCount; 100 | } 101 | 102 | if (typeof sitewiseTelemetry.sitewisePublishErrorReceived === "object") { 103 | const errsLen = Object.keys(sitewiseTelemetry.sitewisePublishErrorReceived).length; 104 | if (errsLen > 0) message.sitewise.publishErrorReceived = sitewiseTelemetry.sitewisePublishErrorReceived; 105 | } 106 | 107 | // publish telemetry message. 108 | publishFormattedMessage(systemTelemetryControllerRoutes.systemTelemetryRoute, message, 200, topics.telemetryUpdateTopic, false); 109 | 110 | } catch (err) { 111 | publishErrorResponse(systemTelemetryControllerRoutes.errorRoute, err); 112 | } 113 | 114 | } 115 | 116 | // CPU clock timer helpers 117 | function secNSec2ms(secNSec) { 118 | if (Array.isArray(secNSec)) { 119 | return secNSec[0] * 1000 + secNSec[1] / 1000000; 120 | } 121 | return secNSec / 1000; 122 | } 123 | 124 | module.exports = { 125 | setTelemetryUpdateInterval 126 | }; 127 | 128 | // No easy way to remove circular dependencies between PubSub Tx and Rx consumers on the 129 | // same Greengrass client so add require statements after all exports completed. 130 | const { publishFormattedMessage, publishErrorResponse } = require("./awsPubsubController"); 131 | const { getSitewiseTelemetryData } = require("../../osi-pi-sdk/awsSitewisePublisher"); 132 | 133 | 134 | -------------------------------------------------------------------------------- /src/controllers/functions/osiPiPointWriter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0. 4 | * 5 | * @author Dean Colcott 6 | */ 7 | 8 | /** 9 | * Receive and process PubSub MQTT Message Callback 10 | * 11 | * @param {*} payload 12 | */ 13 | function osiPiPointWriterMessageRouter(route, params) { 14 | 15 | try { 16 | 17 | if (!(piWebSdk)) { 18 | throw new Error("OSI Pi SDK Manager not initialized. Ensure AWS IoT Shadow configuration is complete and correct."); 19 | } 20 | 21 | switch (route) { 22 | 23 | // Publish OSI PiPoint Data-Writer Functions. 24 | 25 | case "create-pi-point": 26 | createPiPoint(route, params); 27 | break; 28 | 29 | case "write-pi-point": 30 | writePiPoint(route, params); 31 | break; 32 | 33 | default: 34 | throw new Error(`Unknown message Route received by OSI Pi Point Data Writer: ${route}`); 35 | } 36 | 37 | } catch (err) { 38 | publishErrorResponse(route, err); 39 | } 40 | } 41 | 42 | // PubSub Managed calls to manage OSI Pi Streaming Data Connector. 43 | // Create / Update Pi data points functions. 44 | 45 | async function createPiPoint(route, params) { 46 | 47 | try { 48 | 49 | await publishFormattedMessage(route, "request-ack-processing", 202); 50 | 51 | // Mandatory params. PiSDK will error if not provoded valid value. 52 | const dataServerWebId = params.dataServerWebId; 53 | const piPointName = params.piPointName; 54 | 55 | // Optional params - PiSDK will provode defaut values shown if not provided 56 | const pointDescription = params.pointDescription; // Default: "" 57 | const pointClass = params.pointClass; // Default: "classic" 58 | const pointType = params.pointType; // Default: "Float32" 59 | const engineeringUnits = params.engineeringUnits; // Default: "" 60 | 61 | // Get response to write PiPoint request 62 | const response = await piWebSdk.createPiPoint(dataServerWebId, piPointName, pointDescription, pointClass, pointType, engineeringUnits); 63 | 64 | // Publish response - Axios API call will throw an error if not a 2xx response. 65 | await publishFormattedMessage(route, {"status" : response.status, "respnse" : response.data }); 66 | 67 | } catch (err) { 68 | 69 | console.log; 70 | publishErrorResponse(route, err); 71 | } 72 | } 73 | 74 | async function writePiPoint(route, params) { 75 | 76 | try { 77 | 78 | await publishFormattedMessage(route, "request-ack-processing", 202); 79 | 80 | // Mandatory params. PiSDK will error if not provided valid value. 81 | const webid = params.webid; 82 | const timestamp = params.timestamp; 83 | const piPointValue = params.piPointValue; 84 | 85 | console.log(`##### [DEBUG] piPointValue: ${piPointValue}`); 86 | 87 | // Optional params - PiSDK will provode defaut values shown if not provided 88 | const unitsAbrev = params.unitsAbrev; // Default: "" 89 | const goodQuality = params.goodQuality; // Default: true 90 | const questionableQuality = params.questionableQuality; // Default: false 91 | 92 | // Get response to write PiPoint request 93 | const response = await piWebSdk.writePiPoint(webid, timestamp, piPointValue, unitsAbrev, goodQuality, questionableQuality); 94 | 95 | // Publish response - Axios API call will throw an error if not a 2xx response. 96 | await publishFormattedMessage(route, {"status" : response.status, "respnse" : response.data }); 97 | 98 | } catch (err) { 99 | publishErrorResponse(route, err); 100 | } 101 | } 102 | 103 | module.exports = { 104 | osiPiPointWriterMessageRouter, 105 | createPiPoint, 106 | writePiPoint 107 | }; 108 | 109 | // No easy way to remove circular dependencies between PubSub Tx and Rx consumers on the 110 | // same Greengrass client so add require statements after all exports completed. 111 | const { publishFormattedMessage, publishErrorResponse } = require("../core/awsPubsubController"); 112 | const piWebSdk = require("../../osi-pi-sdk/piWebSdk"); 113 | -------------------------------------------------------------------------------- /src/controllers/functions/osiPiStreamingDataController.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0. 4 | * 5 | * @author Dean Colcott 6 | */ 7 | 8 | const v8 = require("node:v8"); 9 | const topics = require("../../configs/pubsubTopics"); 10 | 11 | // OSI Pi: PiPoints search options. 12 | const searchOptions = { 13 | "contains": 1, 14 | "exact-match": 2, 15 | "starts-with": 3, 16 | "ends-with": 4, 17 | }; 18 | 19 | let memoryUsage = 0, dropDataPoints = false; 20 | const memoryUsageDropTrigger = 45, memoryUsageRestartTrigger = 44; 21 | 22 | setInterval(memoryMonitor, 20000); 23 | 24 | /** 25 | * Receive and process PubSub MQTT Message Callback 26 | * 27 | * @param {*} payload 28 | */ 29 | function osiPiStreamingDataMessageRouter(route, params) { 30 | 31 | try { 32 | 33 | if (!(piWebSdk)) { 34 | throw new Error("OSI Pi SDK Manager not initialized. Ensure AWS IoT Shadow configuration is complete and correct."); 35 | } 36 | 37 | switch (route) { 38 | 39 | // Publish OSI PiPoint WebSocket Manager Channel Functions. 40 | 41 | case "publish-channels": 42 | publishChannels(route, params); 43 | break; 44 | 45 | case "publish-channel-stats": 46 | publishChannelStats(route, params); 47 | break; 48 | 49 | case "publish-channels-by-state": 50 | publishChannelsByState(route, params); 51 | break; 52 | 53 | case "publish-channel-by-channel-id": 54 | publishChannelByChannelId(route, params); 55 | break; 56 | 57 | case "publish-channel-by-pi-point-webid": 58 | publishChannelByPiPointWebId(route, params); 59 | break; 60 | 61 | case "publish-channel-by-pi-point-path": 62 | publishChannelByPiPointPath(route, params); 63 | break; 64 | 65 | case "publish-channels-by-pi-point-path-regex": 66 | publishChannelsByPiPointPathRegEx(route, params); 67 | break; 68 | 69 | case "publish-all-channel-numbers": 70 | publishAllChannelNumbers(route, params); 71 | break; 72 | 73 | case "publish-channel-numbers-by-pi-point-path-regex": 74 | publishChannelNumbersByPiPointPathRegEx(route, params); 75 | break; 76 | 77 | // Stream / Queue Pi Points for Streaming Data Functions. 78 | 79 | case "queue-pi-points-for-streaming-by-query": 80 | queuePiPointsForStreamingByQuery(route, params); 81 | break; 82 | 83 | case "publish-queued-pi-points": 84 | publishQueuedPiPoints(route, params); 85 | break; 86 | 87 | case "publish-number-queued-pi-points": 88 | publishNumberQueuedPiPoints(route, params); 89 | break; 90 | 91 | case "clear-queued-pi-points-for-streaming": 92 | clearQueuedPiPoints(route, params); 93 | break; 94 | 95 | case "activate-queued-pi-points-for-streaming": 96 | activateQueuedPiPointsForStreaming(route, params); 97 | break; 98 | 99 | // Close WebSocket Channels Functions. 100 | 101 | case "close-all-channels": 102 | closeAllChannels(route, params); 103 | break; 104 | 105 | case "close-channel-by-channel-id": 106 | closeChannelByChannelId(route, params); 107 | break; 108 | 109 | case "close-channel-by-pi-point-webid": 110 | closeChannelByPiPointWebId(route, params); 111 | break; 112 | 113 | case "close-channels-by-pi-point-path-regex": 114 | closeChannelsByPiPointPathRegEx(route, params); 115 | break; 116 | 117 | // Re/Open WebSocket Channels Functions. 118 | 119 | case "open-channel-by-channel-id": 120 | openChannelByChannelId(route, params); 121 | break; 122 | 123 | case "open-all-closed-channels": 124 | openAllClosedChannels(route, params); 125 | break; 126 | 127 | // Delete WebSocket Channels Functions. 128 | 129 | case "delete-all-channels": 130 | deleteAllChannels(route, params); 131 | break; 132 | 133 | case "delete-channel-by-channel-id": 134 | deleteChannelByChannelId(route, params); 135 | break; 136 | 137 | case "delete-channels-by-pi-point-path-regex": 138 | deleteChannelsByPiPointPathRegEx(route, params); 139 | break; 140 | 141 | // Delete / Clear Pi Data points from buffer 142 | case "delete-pi-data-buffer-queue": 143 | deletePiDataQueue(route, params); 144 | break; 145 | 146 | default: 147 | throw new Error(`Unknown message Route received by OSI Pi Streaming Data Controller: ${route}`); 148 | } 149 | 150 | } catch (err) { 151 | publishErrorResponse(route, err); 152 | } 153 | } 154 | 155 | // WebSocket Manager message and state changed callbacks 156 | 157 | function onWebsocketMessage(event) { 158 | 159 | try { 160 | // If reached memory threshold prevent new data points from being buffered 161 | // until the queue is cleared back to a usable level. 162 | if (dropDataPoints) return; 163 | 164 | const piData = JSON.parse(event.data); 165 | awsSitewisePublisher.queuePiData(piData); 166 | 167 | } catch (err) { 168 | console.log("[ERROR]: Parsing or Queuing OSI PI WebSocket Data!"); 169 | } 170 | } 171 | 172 | function onWebsocketChangedState(state, channelId, event) { 173 | 174 | try { 175 | 176 | const message = {}; 177 | message.channelId = channelId; 178 | message.websocketStatus = state; 179 | message.event = (typeof event == "object") ? JSON.stringify(event) : event; 180 | 181 | const topic = `${topics.piWebsocketStateChangeTopic}/${state}`; 182 | publishFormattedMessage("web-socket-state-changed", message, 200, topic); 183 | 184 | } catch (err) { 185 | //Catch (async / callback) errors in processing WebSocket changes 186 | console.log("[ERROR] WebSocket Status update error. Error:"); 187 | console.log(err); 188 | } 189 | } 190 | 191 | function memoryMonitor() { 192 | 193 | try { 194 | 195 | // Get current memory stats usage. 196 | const memStats = v8.getHeapStatistics(); 197 | const memoryUsageCandicate = Math.round((memStats.total_heap_size / memStats.heap_size_limit) * 100); 198 | 199 | // Warn on transition from under to over threshold: 200 | 201 | const isOverThreasholdTrigger = (memoryUsageCandicate >= memoryUsageDropTrigger && memoryUsage <= memoryUsageDropTrigger); 202 | if (isOverThreasholdTrigger) { 203 | dropDataPoints = true; 204 | const msg = `[WARN]: Memory utilization ${memoryUsageCandicate}% exceeding threshold - dropping new OSI Pi data points.`; 205 | publishFormattedMessage("memory-utilisation-event", msg, 199); 206 | } 207 | 208 | // Notify in transition back below memory threshold 209 | const isUnderThreasholdTrigger = (memoryUsageCandicate <= memoryUsageRestartTrigger && memoryUsage >= memoryUsageRestartTrigger); 210 | if (isUnderThreasholdTrigger) { 211 | dropDataPoints = false; 212 | const msg = `[INFO]: Memory utilization ${memoryUsageCandicate}% returning below restart data threshold, accepting new OSI Pi data points.`; 213 | publishFormattedMessage("memory-utilisation-event", msg, 199); 214 | } 215 | 216 | memoryUsage = memoryUsageCandicate; 217 | 218 | } catch (err) { 219 | console.log("[ERROR] Error calculating and / or processing memory utilisation management. Error: "); 220 | console.log(err); 221 | } 222 | } 223 | 224 | // PubSub Managed calls to manage OSI Pi Streaming Data Connector. 225 | // Publish OSI PiPoint WebSocket Manager Channel Functions. 226 | 227 | async function publishChannels(route) { 228 | 229 | try { 230 | 231 | await publishFormattedMessage(route, "request-ack-processing", 202); 232 | 233 | const channels = piWebSocketManager.getChannels(); 234 | const numChannels = channels.length; 235 | 236 | // Split the channels into groups of 100 to make sure they fit in a single MQTT message 237 | const pointsPerMessage = 100; 238 | for (let startIndex = 0; startIndex < numChannels; startIndex += pointsPerMessage) { 239 | 240 | const messagechannels = channels.slice(startIndex, startIndex + pointsPerMessage); 241 | 242 | const message = {}; 243 | message.startIndex = startIndex; 244 | message.numberChannels = messagechannels.length; 245 | 246 | const channelDetail = []; 247 | for (const channel of messagechannels) { 248 | channelDetail.push({ 249 | "channelId": channel.getChannelId(), 250 | "websocket-status": channel.getWebsocketState(), 251 | "numPiPoints": channel.getNumberPiPoints() 252 | }); 253 | } 254 | message.channels = channelDetail; 255 | 256 | // Publish the current message. 257 | await publishFormattedMessage(route, message, 206); 258 | } 259 | 260 | await publishFormattedMessage(route, { "itemsReturned": channels.length }); 261 | 262 | } catch (err) { 263 | publishErrorResponse(route, err); 264 | } 265 | } 266 | 267 | async function publishChannelStats(route) { 268 | 269 | try { 270 | 271 | // Create objects to store per channel state and PiPoint counts. 272 | const channelStateOptions = ["connecting", "open", "closing", "closed"]; 273 | const channelStatesCount = {}, channelPiPointsCount = {}; 274 | 275 | // initialize the WebSocket State and Channel PiPoint Counts to zero 276 | for (let channelState of channelStateOptions) { 277 | channelStatesCount[channelState] = 0; 278 | channelPiPointsCount[channelState] = 0; 279 | } 280 | 281 | // Get list of all available channels on system and add totals counts/ 282 | const channels = piWebSocketManager.getChannels(); 283 | const totalChannels = channels.length; 284 | let totalPiPoints = 0; 285 | 286 | // Iterate and allocate channels by state. 287 | for (const channel of channels) { 288 | 289 | const channelState = channel.getWebsocketState(); 290 | const numChannelPiPoints = channel.getNumberPiPoints(); 291 | 292 | // Add counts to per channel / points stats states 293 | channelStatesCount[channelState]++; 294 | channelPiPointsCount[channelState] += numChannelPiPoints; 295 | 296 | // Add counts to total PiPoints 297 | totalPiPoints += numChannelPiPoints; 298 | } 299 | 300 | // Return stats message. 301 | const message = {}; 302 | message.channelStatesCount = channelStatesCount; 303 | message.channelPiPointsCount = channelPiPointsCount; 304 | message.totalChannels = totalChannels; 305 | message.totalPiPoints = totalPiPoints; 306 | 307 | await publishFormattedMessage(route, message); 308 | 309 | } catch (err) { 310 | publishErrorResponse(route, err); 311 | } 312 | } 313 | 314 | async function publishChannelsByState(route, params) { 315 | 316 | try { 317 | 318 | const websocketState = params.websocketState; 319 | if (!websocketState) throw new Error("Must include websocketState value in params."); 320 | 321 | await publishFormattedMessage(route, "request-ack-processing", 202); 322 | 323 | const channels = piWebSocketManager.getChannelByWebsocketState(websocketState); 324 | const numChannels = channels.length; 325 | 326 | // Split the channels into groups of 100 to make sure they fit in a single MQTT message 327 | const pointsPerMessage = 100; 328 | for (let startIndex = 0; startIndex < numChannels; startIndex += pointsPerMessage) { 329 | 330 | const messagechannels = channels.slice(startIndex, startIndex + pointsPerMessage); 331 | 332 | const message = {}; 333 | message.startIndex = startIndex; 334 | message.numberChannels = messagechannels.length; 335 | 336 | const channelDetail = []; 337 | for (const channel of messagechannels) { 338 | channelDetail.push({ 339 | "channelId": channel.getChannelId(), 340 | "websocket-status": channel.getWebsocketState(), 341 | "numPiPoints": channel.getNumberPiPoints() 342 | }); 343 | } 344 | message.channels = channelDetail; 345 | 346 | // Publish the current message. 347 | await publishFormattedMessage(route, message, 206); 348 | } 349 | 350 | await publishFormattedMessage(route, { "itemsReturned": channels.length }); 351 | 352 | } catch (err) { 353 | publishErrorResponse(route, err); 354 | } 355 | } 356 | 357 | async function publishChannelByChannelId(route, params) { 358 | 359 | try { 360 | 361 | const channelId = params.channelId; 362 | if (!channelId) throw new Error("Must include channelId value in params."); 363 | 364 | // Returns channel object or throws and error if doesn't exist. 365 | const channel = piWebSocketManager.getChannelByChannelId(channelId); 366 | 367 | const message = {}; 368 | message.channelid = channelId; 369 | message.channel = channel; 370 | await publishFormattedMessage(route, message); 371 | 372 | } catch (err) { 373 | publishErrorResponse(route, err); 374 | } 375 | } 376 | 377 | async function publishChannelByPiPointWebId(route, params) { 378 | 379 | try { 380 | 381 | const piPointWebId = params.piPointWebId; 382 | if (!piPointWebId) throw new Error("Must include piPointWebId value in params."); 383 | 384 | const channel = piWebSocketManager.getChannelByPiPointWebId(piPointWebId); 385 | 386 | if (!channel) { 387 | throw new Error(`PiPoint WebId: ${piPointWebId} isn't registered in a streaming channel session`); 388 | } 389 | 390 | const message = {}; 391 | message.webid = piPointWebId; 392 | message.channel = channel; 393 | await publishFormattedMessage(route, message); 394 | 395 | } catch (err) { 396 | publishErrorResponse(route, err); 397 | } 398 | } 399 | 400 | async function publishChannelByPiPointPath(route, params) { 401 | 402 | try { 403 | 404 | const piPointPath = params.piPointPath; 405 | if (!piPointPath) throw new Error("Must include piPointPath value in params."); 406 | 407 | const channel = piWebSocketManager.getChannelByPiPointPath(piPointPath); 408 | 409 | if (!channel) { 410 | throw new Error(`PiPoint Path: ${piPointPath} isn't registered in a streaming channel session`); 411 | } 412 | 413 | const message = {}; 414 | message.channel = channel; 415 | await publishFormattedMessage(route, message); 416 | 417 | } catch (err) { 418 | publishErrorResponse(route, err); 419 | } 420 | } 421 | 422 | async function publishChannelsByPiPointPathRegEx(route, params) { 423 | 424 | try { 425 | 426 | const piPointPathRegex = params.piPointPathRegex; 427 | if (!piPointPathRegex) throw new Error("Must include piPointPathRegex value in params."); 428 | 429 | const channels = piWebSocketManager.getChannelsByPiPointPathRegex(piPointPathRegex); 430 | const response = []; 431 | let totalNumPiPoints = 0; 432 | 433 | // Return a simplified channel object without the full list of attached PiPoints for brevity. 434 | for (const channel of channels) { 435 | 436 | totalNumPiPoints += channel.getNumberPiPoints(); 437 | 438 | response.push({ 439 | "channelId": channel.getChannelId(), 440 | "websocket-status": channel.getWebsocketState(), 441 | "numPiPoints": channel.getNumberPiPoints() 442 | }); 443 | } 444 | 445 | const message = {}; 446 | message.piPointPathRegex = piPointPathRegex; 447 | message.numChannels = channels.length; 448 | message.totalNumPiPoints = totalNumPiPoints; 449 | message.channels = response; 450 | await publishFormattedMessage(route, message); 451 | 452 | } catch (err) { 453 | publishErrorResponse(route, err); 454 | } 455 | } 456 | 457 | async function publishChannelNumbersByPiPointPathRegEx(route, params) { 458 | 459 | try { 460 | 461 | const piPointPathRegex = params.piPointPathRegex; 462 | if (!piPointPathRegex) throw new Error("Must include piPointPathRegex value in params."); 463 | 464 | const channelNumbers = piWebSocketManager.getChannelNumbersByPiPointPathRegex(piPointPathRegex); 465 | 466 | const message = {}; 467 | message.channelNumbers = channelNumbers; 468 | await publishFormattedMessage(route, message); 469 | 470 | } catch (err) { 471 | publishErrorResponse(route, err); 472 | } 473 | } 474 | 475 | async function publishAllChannelNumbers(route) { 476 | 477 | try { 478 | // Set Regex to all for publish all Channel numbers 479 | const piPointPathRegex = "/*"; 480 | 481 | const channelNumbers = piWebSocketManager.getChannelNumbersByPiPointPathRegex(piPointPathRegex); 482 | 483 | const message = {}; 484 | message.channelNumbers = channelNumbers; 485 | await publishFormattedMessage(route, message); 486 | 487 | } catch (err) { 488 | publishErrorResponse(route, err); 489 | } 490 | } 491 | 492 | // Stream / Queue Pi Points for Streaming Data Functions. 493 | async function queuePiPointsForStreamingByQuery(route, params) { 494 | 495 | try { 496 | 497 | await publishFormattedMessage(route, "request-ack-processing", 202); 498 | 499 | let startIndex = 0, piPoints = []; 500 | const dataServerWebId = params.dataServerWebId; 501 | const queryString = params.queryString; 502 | const searchOption = params.searchOption; 503 | 504 | if (!dataServerWebId) throw new Error("Must include dataServerWebId value, list all data servers via publish-pi-data-servers route"); 505 | if (!queryString) throw new Error("Must include a queryString. For example: Enter 'tag:=*' to return all values (use cautiously and at your own risk!)"); 506 | 507 | // Get the optional Pi searchOption val and convert to required in val. Set default 1 (search value 'contains' queryString) 508 | const searchOptionVal = searchOptions[searchOption] || 1; 509 | 510 | do { 511 | 512 | // Get this iteration of matching piPoints from current startIndex to maxItemCount 513 | piPoints = await piWebSdk.getPiPointsByQuery(dataServerWebId, queryString, searchOptionVal, startIndex); 514 | 515 | // Don't send last message after startIndex with zero return values. 516 | if (piPoints.length === 0) break; 517 | 518 | // Queue the PiPoint WebId to have a WebSocket opened to start receiving streaming data from Pi Server. 519 | for (const piPoint of piPoints) { 520 | piWebSocketManager.queueStreamPiPointRequest(piPoint); 521 | } 522 | 523 | // Publish this iteration of Queued PiPoints to PubSub 524 | const message = {}; 525 | message.startIndex = startIndex; 526 | message.numberPiPointsQueued = piPoints.length; 527 | message.totalPiPointsQueued = piWebSocketManager.getNumberQueuedPiPoints(); 528 | 529 | // Publish the 206 partial response update messages. 530 | await publishFormattedMessage(route, message, 206); 531 | 532 | // Update startIndex for next iteration. 533 | startIndex += piPoints.length; 534 | 535 | } while (piPoints.length > 0); 536 | 537 | // Publish the final tallies for queued PiPoints 538 | const message = {}; 539 | message.queryString = queryString; 540 | message.searchOption = Object.keys(searchOptions).find(key => searchOptions[key] === searchOptionVal); 541 | message.piPointsQueued = startIndex; 542 | message.totalPiPointsQueued = piWebSocketManager.getNumberQueuedPiPoints(); 543 | await publishFormattedMessage(route, message); 544 | 545 | } catch (err) { 546 | publishErrorResponse(route, err); 547 | } 548 | } 549 | 550 | async function clearQueuedPiPoints(route) { 551 | try { 552 | const response = piWebSocketManager.clearQueueStreamPiPointRequest(); 553 | await publishFormattedMessage(route, response, 200); 554 | 555 | } catch (err) { 556 | publishErrorResponse(route, err); 557 | } 558 | } 559 | 560 | /** 561 | * Get list of Queued PiPoints waiting to be processed and publish result. 562 | * 563 | * @param {'*'} route 564 | */ 565 | async function publishQueuedPiPoints(route) { 566 | 567 | try { 568 | await publishFormattedMessage(route, "request-ack-processing", 202); 569 | 570 | const queuedPiPoints = piWebSocketManager.getQueuedPiPoints(); 571 | const numPiPoints = queuedPiPoints.length; 572 | 573 | const pointsPerMessage = 250; 574 | 575 | for (let startIndex = 0; startIndex < numPiPoints; startIndex += pointsPerMessage) { 576 | 577 | const messagePoints = queuedPiPoints.slice(startIndex, startIndex + pointsPerMessage); 578 | 579 | const message = {}; 580 | message.startIndex = startIndex; 581 | message.numberQueuedPiPoints = messagePoints.length; 582 | message.queuedPiPoints = messagePoints; 583 | await publishFormattedMessage(route, message, 206); 584 | } 585 | 586 | await publishFormattedMessage(route, { "itemsReturned": numPiPoints }); 587 | 588 | } catch (err) { 589 | publishErrorResponse(route, err); 590 | } 591 | } 592 | 593 | async function publishNumberQueuedPiPoints(route) { 594 | 595 | try { 596 | 597 | const totalPiPointsQueued = piWebSocketManager.getNumberQueuedPiPoints(); 598 | 599 | const message = {}; 600 | message.totalPiPointsQueued = totalPiPointsQueued; 601 | await publishFormattedMessage(route, message); 602 | 603 | } catch (err) { 604 | publishErrorResponse(route, err); 605 | } 606 | } 607 | 608 | async function activateQueuedPiPointsForStreaming(route) { 609 | try { 610 | 611 | await publishFormattedMessage(route, "request-ack-processing - Monitor WebSocket state change messages for connections status updates", 202); 612 | const msg = await piWebSocketManager.activateQueuedPiPointsForStreaming(); 613 | await publishFormattedMessage(route, msg, 200); 614 | 615 | } catch (err) { 616 | publishErrorResponse(route, err); 617 | } 618 | } 619 | 620 | // Close WebSocket Channels Functions. 621 | 622 | async function closeAllChannels(route) { 623 | 624 | try { 625 | 626 | await publishFormattedMessage(route, "request-ack-processing", 202); 627 | 628 | const response = piWebSocketManager.closeAllChannels(); 629 | 630 | const message = {}; 631 | message.response = response; 632 | await publishFormattedMessage(route, message); 633 | 634 | } catch (err) { 635 | publishErrorResponse(route, err); 636 | } 637 | } 638 | 639 | async function closeChannelByChannelId(route, params) { 640 | 641 | try { 642 | 643 | await publishFormattedMessage(route, "request-ack-processing", 202); 644 | 645 | const channelId = params.channelId; 646 | if (!channelId) throw new Error("Must include channelId value in params."); 647 | 648 | // Closes Channel Id (or throws Error if doesn't exist.) 649 | const response = piWebSocketManager.closeChannelByChannelId(channelId); 650 | 651 | const message = {}; 652 | message.response = response; 653 | await publishFormattedMessage(route, message); 654 | 655 | } catch (err) { 656 | publishErrorResponse(route, err); 657 | } 658 | } 659 | 660 | async function closeChannelByPiPointWebId(route, params) { 661 | 662 | try { 663 | 664 | await publishFormattedMessage(route, "request-ack-processing", 202); 665 | 666 | const piPointWebId = params.piPointWebId; 667 | if (!piPointWebId) throw new Error("Must include piPointWebId value in params."); 668 | 669 | const channel = piWebSocketManager.getChannelByPiPointWebId(piPointWebId); 670 | 671 | if (!channel) { 672 | throw new Error(`PiPoint WebId: ${piPointWebId} isn't registered in a streaming channel session`); 673 | } 674 | 675 | // Closes Channel Id 676 | const response = piWebSocketManager.closeChannelByChannelId(channel.getChannelId()); 677 | 678 | const message = {}; 679 | message.response = response; 680 | await publishFormattedMessage(route, message); 681 | 682 | } catch (err) { 683 | publishErrorResponse(route, err); 684 | } 685 | } 686 | 687 | async function closeChannelsByPiPointPathRegEx(route, params) { 688 | 689 | try { 690 | 691 | await publishFormattedMessage(route, "request-ack-processing", 202); 692 | 693 | const piPointPathRegex = params.piPointPathRegex; 694 | if (!piPointPathRegex) throw new Error("Must include piPointPathRegex value in params."); 695 | 696 | const response = piWebSocketManager.closeChannelsByPiPointPathRegEx(piPointPathRegex); 697 | 698 | const message = {}; 699 | message.response = response; 700 | await publishFormattedMessage(route, message); 701 | 702 | } catch (err) { 703 | publishErrorResponse(route, err); 704 | } 705 | } 706 | 707 | // Open WebSocket Channels Functions. 708 | 709 | async function openChannelByChannelId(route, params) { 710 | 711 | try { 712 | 713 | await publishFormattedMessage(route, "request-ack-processing", 202); 714 | 715 | const channelId = params.channelId; 716 | if (!channelId) throw new Error("Must include channelId value in params."); 717 | 718 | // Opens Channel WebSocket 719 | const response = await piWebSocketManager.openChannelByChannelId(channelId); 720 | 721 | const message = {}; 722 | message.response = response; 723 | await publishFormattedMessage(route, message); 724 | 725 | } catch (err) { 726 | publishErrorResponse(route, err); 727 | } 728 | } 729 | 730 | async function openAllClosedChannels(route) { 731 | 732 | try { 733 | 734 | await publishFormattedMessage(route, "request-ack-processing", 202); 735 | 736 | // Opens all Channel WebSockets currently in closed state 737 | const response = await piWebSocketManager.openAllClosedChannels(); 738 | 739 | const message = {}; 740 | message.response = response; 741 | await publishFormattedMessage(route, message); 742 | 743 | } catch (err) { 744 | publishErrorResponse(route, err); 745 | } 746 | } 747 | 748 | // Delete WebSocket Channels Functions. 749 | 750 | async function deleteAllChannels(route) { 751 | 752 | try { 753 | 754 | await publishFormattedMessage(route, "request-ack-processing", 202); 755 | 756 | const response = piWebSocketManager.deleteAllChannels(); 757 | 758 | const message = {}; 759 | message.response = response; 760 | await publishFormattedMessage(route, message); 761 | 762 | } catch (err) { 763 | publishErrorResponse(route, err); 764 | } 765 | } 766 | 767 | async function deleteChannelByChannelId(route, params) { 768 | 769 | try { 770 | 771 | await publishFormattedMessage(route, "request-ack-processing", 202); 772 | 773 | const channelId = params.channelId; 774 | if (!channelId) throw new Error("Must include channelId value in params."); 775 | 776 | // Closes Channel Id (or throws Error if doesn't exist.) 777 | const response = piWebSocketManager.deleteChannelByChannelId(channelId); 778 | 779 | const message = {}; 780 | message.response = response; 781 | await publishFormattedMessage(route, message); 782 | 783 | } catch (err) { 784 | publishErrorResponse(route, err); 785 | } 786 | } 787 | 788 | async function deleteChannelsByPiPointPathRegEx(route, params) { 789 | 790 | try { 791 | 792 | await publishFormattedMessage(route, "request-ack-processing", 202); 793 | 794 | const piPointPathRegex = params.piPointPathRegex; 795 | if (!piPointPathRegex) throw new Error("Must include piPointPathRegex value in params."); 796 | 797 | const response = piWebSocketManager.deleteChannelsByPiPointPathRegEx(piPointPathRegex); 798 | 799 | const message = {}; 800 | message.response = response; 801 | await publishFormattedMessage(route, message); 802 | 803 | } catch (err) { 804 | publishErrorResponse(route, err); 805 | } 806 | } 807 | 808 | // Clear / Delete Sitewise Publish Buffer 809 | 810 | async function deletePiDataQueue(route, params) { 811 | 812 | try { 813 | 814 | await publishFormattedMessage(route, "request-ack-processing", 202); 815 | 816 | const deletePercentQueue = params.deletePercentQueue; 817 | if (isNaN(deletePercentQueue) || deletePercentQueue < 1 || deletePercentQueue > 100) { 818 | throw new Error("'deletePercentQueue' missing from params or invalid value received. (int: 1 - 100)"); 819 | } 820 | 821 | awsSitewisePublisher.deletePiDataQueue(deletePercentQueue); 822 | 823 | const message = {}; 824 | message.deletePercentQueue = deletePercentQueue; 825 | await publishFormattedMessage(route, message); 826 | 827 | } catch (err) { 828 | publishErrorResponse(route, err); 829 | } 830 | } 831 | 832 | module.exports = { 833 | osiPiStreamingDataMessageRouter, 834 | onWebsocketMessage, 835 | onWebsocketChangedState, 836 | }; 837 | 838 | // No easy way to remove circular dependencies between PubSub Tx and Rx consumers on the 839 | // same Greengrass client so add require statements after all exports completed. 840 | const { publishFormattedMessage, publishErrorResponse } = require("../core/awsPubsubController"); 841 | const piWebSocketManager = require("../../osi-pi-sdk/piWebSocketManager"); 842 | const awsSitewisePublisher = require("../../osi-pi-sdk/awsSitewisePublisher"); 843 | const piWebSdk = require("../../osi-pi-sdk/piWebSdk"); 844 | -------------------------------------------------------------------------------- /src/gdk-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "component" : { 3 | "com.amazon.osi-pi-streaming-data-connector": { 4 | "author": "Dean Colcott", 5 | "version": "NEXT_PATCH", 6 | "build": { 7 | "build_system" :"zip", 8 | "options": { 9 | "excludes": ["node_modules", "**/node_modules", "dist", "**/dist", "package-lock.json", "**/package-lock.json"] 10 | } 11 | }, 12 | "publish": { 13 | "bucket": "aws-greengrass-components", 14 | "region": "us-east-1" 15 | } 16 | } 17 | }, 18 | "gdk_version": "1.5.0" 19 | } -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0. 4 | * 5 | * AWS IoT Greengrass managed edge connector to ingest real time OSI Pi data over Websockets into AWS IoT Sitewise. 6 | * 7 | * @author Dean Colcott 8 | */ 9 | 10 | const { componentRoutes } = require("./routes/pubsubControlRoutes"); 11 | const awsPubSubController = require("./controllers/core/awsPubsubController"); 12 | const awsIoTShadowController = require("./controllers/core/awsIoTShadowController"); 13 | const { componentHumanName } = require("./configs/componentName"); 14 | 15 | // Init and run the component 16 | const initRunAndAwaitComponent = async () => { 17 | 18 | try { 19 | 20 | console.log(`[INFO] ${componentHumanName} Initialization Started.....`); 21 | 22 | // Activate the Greengrass IPC connection and subscribe to configured topics 23 | await awsPubSubController.activatePubsubController(); 24 | 25 | // AWS IoT Shadow manager to get (or created default) component configuration. 26 | await awsIoTShadowController.initIoTShadowConfig(); 27 | 28 | console.log(`[INFO] ${componentHumanName} Initialisation - COMPLETE`); 29 | awsPubSubController.publishFormattedMessage(componentRoutes.actionRoute, `${componentHumanName} successfully initialized!`); 30 | 31 | // DEBUG ONLY: Print Greengrass Env Vars to emulate in IDE 32 | // console.log(`[DEBUG]: Greengrass SVCUID: ${process.env.SVCUID}`); 33 | // console.log(`[DEBUG]: Greengrass AWS_GG_NUCLEUS_DOMAIN_SOCKET_FILEPATH_FOR_COMPONENT: ${process.env.AWS_GG_NUCLEUS_DOMAIN_SOCKET_FILEPATH_FOR_COMPONENT}`); 34 | 35 | // Hold process until the Greengrass IPC disconnects 36 | await awsPubSubController.awaitConnectionClose(); 37 | 38 | } catch (err) { 39 | // Log errors. 40 | let errMsg = { 41 | "error": `ERROR Initializing ${componentHumanName}`, 42 | "message": err.toString() 43 | }; 44 | console.log(errMsg); 45 | 46 | } finally { 47 | 48 | // Attempt Greengrass re-connect / re-run initRunAndAwaitComponent. 49 | console.log("Connectivity was lost from the AWS Greengrass Device. Will wait 10 sec and attempt to re-establish"); 50 | awsPubSubController.closeConnection(); 51 | 52 | // Wait 10 sec and retry to re-init the component and the Greengrass connection. 53 | await new Promise(resolve => { setTimeout(resolve, 10000); }); 54 | await initRunAndAwaitComponent(); 55 | } 56 | 57 | }; 58 | 59 | 60 | // Init and run the component 61 | (async () => { 62 | 63 | try { 64 | await initRunAndAwaitComponent(); 65 | 66 | } catch (err) { 67 | 68 | console.log("[ERROR]: Running / Initaiting Component failed......"); 69 | console.log(err); 70 | } 71 | 72 | })(); 73 | -------------------------------------------------------------------------------- /src/osi-pi-sdk/README.md: -------------------------------------------------------------------------------- 1 | # AWS OSI Pi SDK 2 | 3 | This library is an SDK wrapper around the [Pi WebAPI](https://docs.aveva.com/en-US/bundle/pi-web-api-reference/page/help/getting-started.html), a RESTful interface to the OSI Pi system. This SDK provides methods to query the OSI Pi Asset Framework for asset inventory and meta-data and access to the list of assets and data tags stored in the OSI Pi Data Archive. 4 | 5 | This library is not a stand-alone solution and is intended to be a common import in a range of connector solutions that interface customers OSI Pi data management systems with a modern data architecture on AWS. 6 | 7 | ## AWS OSI Pi Historian Industrial Data Architecture 8 | 9 | ![AWS OSI Pi data Architecture](images/aws-osi-pi-data-architecture.png) 10 | 11 | ## OSI Pi: Industrial Historian Overview 12 | OSI PI (Now Aveva Pi) is an industrial data historian for collecting, enriching, storing, and accessing industrial data. [Analysist](https://www.verdantix.com/insights/blogs/aveva-s-5-billion-osisoft-acquisition-reshapes-the-industrial-software-landscape) indicate that elements of the PI System are deployed at more than 20,000 sites worldwide, have been adopted by over 1,000 power utilities and manage data flows from close to 2 billion real-time sensors. 13 | 14 | The OSI Pi systems consists of a number of software and data management solutions, primarily the OSI Pi Data Archive and the OSI Pi Asset Framework. The OSI Pi Data Archive is a proprietary industrial time-series database that manages data points that are referred to as data tags (or just tags). These data tags are generated from sensors and physical devices, a large Pi Data Archive can maintain data for over a million data tags. The Asset framework allows industrial control engineers to manage asset inventory and hierarchy, data point Unit of Measures (UoM) and other relevant data point meta-data. The Asset Framework backed is by MS-SQL. Asset entries in the Asset Framework can maintain a reference to a data tag in the Pi Data Archive that adds meaning to the otherwise non-contextualised timeseries data. 15 | 16 | The OSI Pi system is deployed on the Window Server operating system and provides a number of interfaces and integrations to access both the Pi Asset Framework and the Pi Data Archive. These include OLDBC / SQL connectors, .NET based SDK (AF-SDK) and a REST'ful web interface via the Pi WebAPI. 17 | 18 | ## Installation 19 | This particular library is not individually deployable. It is intended to be a submodule in other OSI Pi connectors from within the AWS OSI Pi Integration Libraries to provide access to the OSI Pi data and asset management systems. 20 | 21 | ## Contributing 22 | 23 | We need your help in making this SDK great. Please participate in the community and contribute to this effort by submitting issues, participating in discussion forums and submitting pull requests through the following channels. 24 | 25 | TBA of Open Source Repo: 26 | Contributions Guidelines 27 | Submit Issues, Feature Requests or Bugs 28 | 29 | ## License 30 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 31 | 32 | -------------------------------------------------------------------------------- /src/osi-pi-sdk/awsSecretsManager.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0. 4 | * 5 | * @author Dean Colcott 6 | */ 7 | 8 | const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager"); 9 | 10 | async function getPiSecrets(region, awsSeceretsManagerConfig) { 11 | 12 | console.log("[INFO] Requesting OSI Pi credentials from AWS Secret Manager...."); 13 | 14 | // Validate region 15 | if (!(typeof region === "string" && region.length >= 9)) { 16 | throw new Error("'region' not provided or invalid value in AWS IoT Shadow config update"); 17 | } 18 | 19 | // Validate piCredentialsAwsSecretsArn 20 | const piCredentialsAwsSecretsArn = awsSeceretsManagerConfig.piCredentialsAwsSecretsArn; 21 | if (!(typeof piCredentialsAwsSecretsArn === "string" && piCredentialsAwsSecretsArn.startsWith("arn"))) { 22 | throw new Error("'piCredentialsAwsSecretsArn' not provided or invalid value in Secrets Manager config update"); 23 | } 24 | 25 | // Init AWS Secrets manager and get PISecrets credentials. 26 | const secretsClient = new SecretsManagerClient({ region: region }); 27 | const secretsCommand = new GetSecretValueCommand({ SecretId: piCredentialsAwsSecretsArn }); 28 | 29 | // Below is a hot mess but getting error on Greengrass reload and GG not getting a security token 30 | // and so, the first AWS API command (this one) is failing with CredentialsProviderError. 31 | // Only happens when the device or the Greengrass process is restarted - not when re-deploying the component. 32 | let response; 33 | const maxErrorCnt = 3; 34 | for (let i = 0; i < maxErrorCnt; i++) { 35 | try { 36 | response = await secretsClient.send(secretsCommand); 37 | // If command doesn't throw an error, then break from the loop 38 | console.log(`[INFO]: Successfully read Secrets Manager Entry with ARN: ${piCredentialsAwsSecretsArn}`); 39 | break; 40 | 41 | } catch (err) { 42 | console.log(`[ERROR]: Error Reading Secret Manager Entry: ${err.toString()}`); 43 | console.log(`Error count: ${i} - will retry in 3 sec.......`); 44 | await new Promise(resolve => setTimeout(resolve, 3000)); 45 | } 46 | } 47 | 48 | // Check return status was returned after maxErrorCnt tries and status code is 200 OK. 49 | if (!(response && (response["$metadata"]))) { 50 | throw new Error(`Failed to access Pi Server Credentials at AWS Secrets Manager ARN ${piCredentialsAwsSecretsArn}`); 51 | } 52 | 53 | if (response["$metadata"].httpStatusCode !== 200) { 54 | console.log("[ERROR] Secrets Manager error:"); 55 | console.log(response); 56 | throw new Error(`Failed to access Pi Server Credentials at AWS Secrets Manager ARN ${piCredentialsAwsSecretsArn}`); 57 | } 58 | 59 | // Parse piSecrets to Object 60 | const piSecrets = JSON.parse(response.SecretString); 61 | 62 | if (!(piSecrets.username && piSecrets.password)) { 63 | throw new Error(`AWS Secret Manager ARN ${piCredentialsAwsSecretsArn} provided doesn't contain mandatory username and / or password keys.`); 64 | } 65 | 66 | console.log("[INFO] Requesting OSI Pi credentials from AWS Secret Manager - COMPLETE"); 67 | 68 | // If all validations passed then return piSecrets 69 | return piSecrets; 70 | 71 | } 72 | 73 | module.exports = { 74 | getPiSecrets 75 | }; 76 | -------------------------------------------------------------------------------- /src/osi-pi-sdk/awsSitewiseAssetManager.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0. 4 | * 5 | * @author Dean Colcott 6 | */ 7 | 8 | const { 9 | IoTSiteWiseClient, 10 | 11 | ListAssetsCommand, 12 | DescribeAssetCommand, 13 | UpdateAssetModelCommand, 14 | CreateAssetCommand, 15 | DeleteAssetModelCommand, 16 | 17 | ListAssetModelsCommand, 18 | DescribeAssetModelCommand, 19 | CreateAssetModelCommand, 20 | DeleteAssetCommand, 21 | 22 | ListAssociatedAssetsCommand, 23 | AssociateAssetsCommand, 24 | DisassociateAssetsCommand, 25 | 26 | AssociateTimeSeriesToAssetPropertyCommand 27 | 28 | } = require("@aws-sdk/client-iotsitewise"); 29 | 30 | const piWebIdMarkers = { 31 | "Template": "ET", 32 | "Element": "Em" 33 | }; 34 | 35 | // AWSS Iot Sitewise Client and region 36 | let region; 37 | let sitewiseClient; 38 | 39 | function osiPiAssetSyncUpdateConfig(regionCandidate) { 40 | 41 | console.log("[INFO] Initializing OSI Pi <> AWS IoT Sitewise Asset Framework Synchronization Manager....."); 42 | 43 | // Validate region - update to reportedCandidate if no errors. 44 | if (!(typeof regionCandidate === "string" && regionCandidate.length >= 9)) { 45 | throw new Error("'region' not provided or invalid value in OSI Pi Asset Framework Synchronization Manager config update"); 46 | } 47 | 48 | // If Regin has changed (or had initial update) then re/initilise the Sitewise Client. 49 | if (region !== regionCandidate) { 50 | // Create AWS Iot Sitewise Client 51 | region = regionCandidate; 52 | sitewiseClient = new IoTSiteWiseClient({ region: region }); 53 | } 54 | } 55 | 56 | // Create Sitewise Objects Functions. 57 | 58 | async function createSitewiseModelFromPiTemplate(piElementTemplate) { 59 | 60 | // Set Template fields amd Measurememts from Template Attributes. 61 | let input = {}; 62 | input.assetModelName = piElementTemplate.getName(); 63 | 64 | //Use Description field to store WebID as is only free from field that is in the list Model response. 65 | let piTemplateWebId = piElementTemplate.getWebId(); 66 | input.assetModelDescription = piTemplateWebId; 67 | 68 | // Get all Pi Template attributes and add as Sitewise Model Properties 69 | input.assetModelProperties = []; 70 | const piTemplateAttributes = piElementTemplate.getAttributes(); 71 | for (const piTemplateAttribute of piTemplateAttributes) { 72 | 73 | let assetModelProperty = {}; 74 | assetModelProperty.name = piTemplateAttribute.getName(); 75 | assetModelProperty.dataType = piTemplateAttribute.getSitewiseType(); 76 | 77 | // Get unit if provided 78 | let unit = piTemplateAttribute.getDefaultUnitsNameAbbreviation(); 79 | if (unit) assetModelProperty.unit = unit; 80 | 81 | assetModelProperty.type = { 82 | "measurement": { 83 | "processingConfig": { 84 | "forwardingConfig": { 85 | "state": "ENABLED" 86 | } 87 | } 88 | } 89 | }; 90 | 91 | input.assetModelProperties.push(assetModelProperty); 92 | } 93 | 94 | const command = new CreateAssetModelCommand(input); 95 | const response = await sitewiseClient.send(command); 96 | 97 | // Throw Error if not 2xx message response 98 | let httpStatusCode = response["$metadata"].httpStatusCode; 99 | if (httpStatusCode < 200 || httpStatusCode > 299) { 100 | throw new Error({ "Error": "Error creating AWS IoT Sitewise model.", "response": response["$metadata"] }); 101 | } 102 | 103 | let model = {}; 104 | model.id = response.assetModelId; 105 | model.arn = response.assetModelArn; 106 | model.name = input.assetModelName; 107 | model.piTemplateWebId = piTemplateWebId; 108 | 109 | return model; 110 | } 111 | 112 | async function createSitewiseAssetFromPiElement(piElement, sitewiseModelId) { 113 | 114 | // Set Template fields amd Measurememts from Template Attributes. 115 | let input = {}; 116 | input.assetModelId = sitewiseModelId; 117 | input.assetName = piElement.getName(); 118 | //Use Description field to store WebID as is only spare field that is in the list Asset response. 119 | input.assetDescription = piElement.getWebId(); 120 | 121 | const command = new CreateAssetCommand(input); 122 | const response = await sitewiseClient.send(command); 123 | 124 | let asset = {}; 125 | asset.arn = response.assetArn; 126 | asset.id = response.assetId; 127 | asset.name = response.name; 128 | asset.piElementWebId = piElement.getWebId(); 129 | return asset; 130 | } 131 | 132 | // Sitewise Model Getter / Helper Commands 133 | 134 | async function getSitewiseModelList() { 135 | 136 | // Create command input object 137 | let input = {}; 138 | input.maxResults = 100; 139 | 140 | // Iterate through response nextToken untill have all avliable models from given Sitewise instance. 141 | let sitewiseModels = {}; 142 | do { 143 | // Send Sitewise ListAssetModelsCommand 144 | const command = new ListAssetModelsCommand(input); 145 | const response = await sitewiseClient.send(command); 146 | 147 | // Throw Error if not 2xx message response 148 | let httpStatusCode = response["$metadata"].httpStatusCode; 149 | if (httpStatusCode < 200 || httpStatusCode > 299) { 150 | throw new Error({ "Error": "Error reading AWS IoT Sitewise Model list.", "response": response["$metadata"] }); 151 | } 152 | 153 | // Add returned Sitewise Asset Models to local object sitewiseModels with WebId as key. 154 | for (const model of response.assetModelSummaries) { 155 | 156 | // Only add models that were created from OSI Pi templates and skip any user defined models. 157 | const piTemplateWebId = model.description; 158 | if (isSitewiseObjectOsiPiObjectType(piTemplateWebId, piWebIdMarkers.Template)) { 159 | // Here we use the Sitewise model description field to store the associated Pi Template WebId as only spare user definable field available 160 | sitewiseModels[piTemplateWebId] = { 161 | "arn": model.arn, 162 | "id": model.id, 163 | "name": model.name, 164 | "piTemplateWebId": piTemplateWebId 165 | }; 166 | } 167 | } 168 | 169 | input.nextToken = response.nextToken; 170 | 171 | } while (input.nextToken); 172 | 173 | return sitewiseModels; 174 | } 175 | 176 | /** 177 | * Loads to cache and returns the full Sitewise model detail of the model Id given using the Describe SDK command 178 | * 179 | * @param {*} modelId 180 | * @param {*} excludeProperties 181 | * @returns 182 | */ 183 | async function getSitewiseModelByModelId(modelId, checkIsOsiPiGenerated = true, excludeProperties = false) { 184 | 185 | // Create command input object and send Sitewise DescribeAssetModelCommand 186 | let input = {}; 187 | input.assetModelId = modelId; 188 | input.excludeProperties = excludeProperties; 189 | 190 | const command = new DescribeAssetModelCommand(input); 191 | const response = await sitewiseClient.send(command); 192 | 193 | // Throw Error if not 2xx message response. 194 | let httpStatusCode = response["$metadata"].httpStatusCode; 195 | if (httpStatusCode < 200 || httpStatusCode > 299) { 196 | throw new Error({ "Error": "Error reading AWS IoT Sitewise model detail.", "response": response["$metadata"] }); 197 | } 198 | 199 | // Create the model response. 200 | let model = {}; 201 | model.id = response.assetModelId; 202 | model.arn = response.assetModelArn; 203 | model.name = response.assetModelName; 204 | model.status = response.assetModelStatus; 205 | model.piTemplateWebId = response.assetModelDescription; 206 | model.hierarchies = response.assetModelHierarchies; 207 | model.properties = response.assetModelProperties; 208 | 209 | // Check this model is created from a Pi Template Sync and not a user defined object. 210 | if (checkIsOsiPiGenerated && !isSitewiseObjectOsiPiObjectType(model.piTemplateWebId, piWebIdMarkers.Template)) { 211 | throw new Error(`Sitewise model ${model.assetModelName} was not created from a OSI Pi Template and not valid in this system`); 212 | } 213 | 214 | // Return model detail. 215 | return model; 216 | } 217 | 218 | /** 219 | * Updates a Sitewise Model with the values provided. 220 | * 221 | * @param {*} sitewiseModel 222 | * @returns 223 | */ 224 | async function updateSitewiseModel(sitewiseModel) { 225 | 226 | // Create command input object and send Sitewise DescribeAssetModelCommand 227 | let input = {}; 228 | input.assetModelId = sitewiseModel.id; 229 | input.assetModelName = sitewiseModel.name; 230 | input.assetModelDescription = sitewiseModel.piTemplateWebId; 231 | input.assetModelProperties = sitewiseModel.properties; 232 | input.assetModelHierarchies = sitewiseModel.hierarchies; 233 | 234 | const command = new UpdateAssetModelCommand(input); 235 | const response = await sitewiseClient.send(command); 236 | 237 | // Throw Error if not 2xx message response. 238 | let httpStatusCode = response["$metadata"].httpStatusCode; 239 | if (httpStatusCode < 200 || httpStatusCode > 299) { 240 | throw new Error({ "Error": "Error reading AWS IoT Sitewise model detail.", "response": response["$metadata"] }); 241 | } 242 | 243 | // Return model details for async responses processing 244 | return sitewiseModel; 245 | } 246 | 247 | /** 248 | * Queries AWS IoT Sitewise for the requested modelId to determine if is on theh system. 249 | * Intended use is to check if a delete Model command has be completed. 250 | * 251 | * @param {*} modelId 252 | * @param {*} checkIsOsiPiGenerated 253 | * @returns 254 | */ 255 | async function getSitewiseModelExistsByModelId(modelId, checkIsOsiPiGenerated = true) { 256 | 257 | try { 258 | // Request the model object to check if is available / on system 259 | await getSitewiseModelByModelId(modelId, checkIsOsiPiGenerated); 260 | 261 | // Return true if request doesn't throw any exceptions 262 | return true; 263 | 264 | } catch (err) { 265 | // Return false if ResourceNotFoundException 266 | if (err.name === "ResourceNotFoundException") { 267 | return false; 268 | } else { 269 | throw new Error(err); 270 | } 271 | } 272 | } 273 | 274 | async function deleteSitewiseModelByModelId(sitewiseModel) { 275 | 276 | // Create command input object and send Sitewise DeleteAssetModelCommand 277 | let input = {}; 278 | input.assetModelId = sitewiseModel.id; 279 | 280 | const command = new DeleteAssetModelCommand(input); 281 | const response = await sitewiseClient.send(command); 282 | 283 | // Throw Error if not 2xx message response. 284 | let httpStatusCode = response["$metadata"].httpStatusCode; 285 | if (httpStatusCode < 200 || httpStatusCode > 299) { 286 | throw new Error({ "Error": "Error deleting AWS IoT Sitewise Model.", "response": response["$metadata"] }); 287 | } 288 | 289 | return sitewiseModel.piTemplateWebId; 290 | 291 | } 292 | 293 | // Sitewise Asset Getter / Helper Commands 294 | 295 | async function getSitewiseAssetListByModelId(assetModelId) { 296 | 297 | // Create command input object and send Sitewise ListAssetsCommand 298 | let input = {}; 299 | input.maxResults = 100; 300 | input.assetModelId = assetModelId; 301 | 302 | // Iterate through response nextToken untill have all avliable models from given Sitewise instance. 303 | let sitewiseAssets = {}; 304 | 305 | // Iterate over this model Id for all dependent assets. 306 | do { 307 | 308 | const command = new ListAssetsCommand(input); 309 | const response = await sitewiseClient.send(command); 310 | 311 | // Throw Error if not 2xx message response 312 | let httpStatusCode = response["$metadata"].httpStatusCode; 313 | if (httpStatusCode < 200 || httpStatusCode > 299) { 314 | throw new Error({ "Error": "Error reading AWS IoT Sitewise Asset list.", "response": response["$metadata"] }); 315 | } 316 | 317 | // Add returned Sitewise Asset Models to local object sitewiseModels with WebId as key. 318 | for (const asset of response.assetSummaries) { 319 | 320 | // Only add models that were created from OSI Pi templates and skip any user defined models. 321 | const piElementWebId = asset.description; 322 | if (isSitewiseObjectOsiPiObjectType(piElementWebId, piWebIdMarkers.Element)) { 323 | // Here we use the Sitewise model description field to store the associated Pi Template WebId as only spare user definable field available 324 | sitewiseAssets[piElementWebId] = { 325 | "arn": asset.arn, 326 | "id": asset.id, 327 | "name": asset.name, 328 | "assetModelId": asset.assetModelId 329 | }; 330 | } 331 | } 332 | 333 | input.nextToken = response.nextToken; 334 | 335 | } while (input.nextToken); 336 | 337 | return sitewiseAssets; 338 | } 339 | 340 | /** 341 | * Loads to cache and returns the full Sitewise model detail of the model Id given using the Describe SDK command 342 | * 343 | * @param {*} modelId 344 | * @param {*} excludeProperties 345 | * @returns 346 | */ 347 | async function getSitewiseAssetByAssetId(assetId, checkIsOsiPiGenerated = true, excludeProperties = false) { 348 | 349 | // Create command input object and send Sitewise DescribeAssetCommand 350 | let input = {}; 351 | input.assetId = assetId; 352 | input.excludeProperties = excludeProperties; 353 | 354 | const command = new DescribeAssetCommand(input); 355 | const response = await sitewiseClient.send(command); 356 | 357 | // Throw Error if not 2xx message response. 358 | let httpStatusCode = response["$metadata"].httpStatusCode; 359 | if (httpStatusCode < 200 || httpStatusCode > 299) { 360 | throw new Error({ "Error": "Error reading AWS IoT Sitewise Asset.", "response": response["$metadata"] }); 361 | } 362 | 363 | // Create the model response. 364 | let asset = {}; 365 | asset.id = response.assetId; 366 | asset.arn = response.assetArn; 367 | asset.name = response.assetName; 368 | asset.assetModelId = response.assetModelId; 369 | asset.piElementWebId = response.assetDescription; 370 | asset.status = response.assetStatus; 371 | asset.hierarchies = response.assetHierarchies; 372 | asset.properties = response.assetProperties; 373 | 374 | // Check this model is created from a Pi Template Sync and not a user defined object. 375 | if (checkIsOsiPiGenerated && !isSitewiseObjectOsiPiObjectType(asset.piElementWebId, piWebIdMarkers.Element)) { 376 | throw new Error(`Sitewise Asset ${asset.name} was not created from a OSI Pi Asset Element and not valid in this system`); 377 | } 378 | 379 | // Return model detail. 380 | return asset; 381 | } 382 | 383 | /** 384 | * * Returns the requested Sitewise Asset status: 385 | * i.e: ACTIVE, CREATING, DELETING, FAILED, PROPAGATING, UPDATING 386 | * 387 | * @param {*} modelId 388 | * @param {*} checkIsOsiPiGenerated 389 | * @returns 390 | */ 391 | async function getSitewiseAssetStatusByAssetlId(assetId, checkIsOsiPiGenerated = true) { 392 | 393 | const asset = await getSitewiseAssetByAssetId(assetId, checkIsOsiPiGenerated); 394 | return asset.status.state; 395 | } 396 | 397 | /** 398 | * Queries AWS IoT Sitewise for the requested assetId to determine if is on the system. 399 | * Intended use is to check if a delete Asset command has be completed. 400 | * 401 | * @param {*} modelId 402 | * @param {*} checkIsOsiPiGenerated 403 | * @returns 404 | */ 405 | async function getSitewiseAssetExistsByAssetlId(assetId, checkIsOsiPiGenerated = true) { 406 | 407 | try { 408 | // Request the Asset object to check if is available / on system 409 | await getSitewiseAssetByAssetId(assetId, checkIsOsiPiGenerated); 410 | 411 | // Return true if request doesn't throw any exceptions 412 | return true; 413 | 414 | } catch (err) { 415 | // Return false if ResourceNotFoundException 416 | if (err.name === "ResourceNotFoundException") { 417 | return false; 418 | } else { 419 | throw new Error(err); 420 | } 421 | } 422 | } 423 | 424 | async function deleteSitewiseAssetByAssetId(sitewiseAsset) { 425 | 426 | // Create command input object and send Sitewise DeleteAssetCommand 427 | let input = {}; 428 | input.assetId = sitewiseAsset.id; 429 | 430 | const command = new DeleteAssetCommand(input); 431 | const response = await sitewiseClient.send(command); 432 | 433 | // Throw Error if not 2xx message response. 434 | let httpStatusCode = response["$metadata"].httpStatusCode; 435 | if (httpStatusCode < 200 || httpStatusCode > 299) { 436 | throw new Error({ "Error": "Error deleting AWS IoT Sitewise Asset.", "response": response["$metadata"] }); 437 | } 438 | 439 | return sitewiseAsset.piElementWebId; 440 | 441 | } 442 | 443 | // Sitewise Asset Association Getter / Helper Commands 444 | 445 | async function getSitewiseAssetAssociatedChildAssets(assetId, hierarchyId) { 446 | 447 | // Create command input object and send Sitewise DescribeAssetModelCommand 448 | let input = {}; 449 | input.assetId = assetId; 450 | input.hierarchyId = hierarchyId; 451 | 452 | const command = new ListAssociatedAssetsCommand(input); 453 | const response = await sitewiseClient.send(command); 454 | 455 | // Throw Error if not 2xx message response. 456 | let httpStatusCode = response["$metadata"].httpStatusCode; 457 | if (httpStatusCode < 200 || httpStatusCode > 299) { 458 | throw new Error({ "Error": "Error Listing Sitewise Child Assets.", "response": response["$metadata"] }); 459 | } 460 | 461 | return response.assetSummaries; 462 | 463 | } 464 | 465 | async function associateSitewiseAssets(assetId, childAssetId, hierarchyId) { 466 | 467 | // Create command input object and send Sitewise DescribeAssetModelCommand 468 | let input = {}; 469 | input.assetId = assetId; 470 | input.childAssetId = childAssetId; 471 | input.hierarchyId = hierarchyId; 472 | 473 | const command = new AssociateAssetsCommand(input); 474 | const response = await sitewiseClient.send(command); 475 | 476 | // Throw Error if not 2xx message response. 477 | let httpStatusCode = response["$metadata"].httpStatusCode; 478 | if (httpStatusCode < 200 || httpStatusCode > 299) { 479 | throw new Error({ "Error": "Error Associating Sitewise Assets.", "response": response["$metadata"] }); 480 | } 481 | } 482 | 483 | async function disassociateSitewiseChildAsset(assetId, childAssetId, hierarchyId) { 484 | 485 | // Create command input object and send Sitewise DescribeAssetModelCommand 486 | let input = {}; 487 | input.assetId = assetId; 488 | input.childAssetId = childAssetId; 489 | input.hierarchyId = hierarchyId; 490 | 491 | const command = new DisassociateAssetsCommand(input); 492 | const response = await sitewiseClient.send(command); 493 | 494 | // Throw Error if not 2xx message response. 495 | let httpStatusCode = response["$metadata"].httpStatusCode; 496 | if (httpStatusCode < 200 || httpStatusCode > 299) { 497 | throw new Error({ "Error": "Error Disassociating Sitewise Assets.", "response": response["$metadata"] }); 498 | } 499 | } 500 | 501 | // Sitewise Assset Property Data Stream Association Getter / Helper Commands 502 | 503 | async function associateTimeSeriesToSitewiseAssetProperty(assetId, propertyId, streamAlias) { 504 | 505 | // Create command input object and send Sitewise DescribeAssetModelCommand 506 | let input = {}; 507 | input.assetId = assetId; 508 | input.propertyId = propertyId; 509 | input.alias = streamAlias; 510 | 511 | const command = new AssociateTimeSeriesToAssetPropertyCommand(input); 512 | const response = await sitewiseClient.send(command); 513 | 514 | // Throw Error if not 2xx message response. 515 | let httpStatusCode = response["$metadata"].httpStatusCode; 516 | if (httpStatusCode < 200 || httpStatusCode > 299) { 517 | throw new Error({ "Error": "Error Associating Sitewise Assets Property to TimeSeries Alias.", "response": response["$metadata"] }); 518 | } 519 | } 520 | 521 | // Helpers. 522 | 523 | /** 524 | * In this system we use the Sitewise Objects description field to store a reference to the Pi Asset Element WebId as 525 | * is the only user defined field avliable and only Full format Pi WebIds are used. 526 | * 527 | * In OSI Pi, Full format WebIds for Asset Element ojbects are a string that is in the format: 528 | * F[Ver#][2 Char Marker Field]xxxxxx. 529 | 530 | * Tgis finctuion checks the Sitewise Object description field has this pattern as a basic validation to identify Sitewise 531 | * models that were created by a previous OSI Pi Asset sync and not a seperate user defined models. 532 | 533 | * @param {*} assetModel 534 | * @returns 535 | */ 536 | function isSitewiseObjectOsiPiObjectType(sitewiseDescriptionField, piObjectWebIdMarker) { 537 | 538 | return typeof sitewiseDescriptionField === "string" && 539 | sitewiseDescriptionField.length > 5 && 540 | sitewiseDescriptionField.startsWith("F") && 541 | sitewiseDescriptionField.substring(2, 4) === piObjectWebIdMarker; 542 | } 543 | 544 | module.exports = { 545 | osiPiAssetSyncUpdateConfig, 546 | 547 | getSitewiseModelList, 548 | getSitewiseModelByModelId, 549 | getSitewiseModelExistsByModelId, 550 | updateSitewiseModel, 551 | createSitewiseModelFromPiTemplate, 552 | deleteSitewiseModelByModelId, 553 | 554 | getSitewiseAssetListByModelId, 555 | getSitewiseAssetByAssetId, 556 | getSitewiseAssetStatusByAssetlId, 557 | getSitewiseAssetExistsByAssetlId, 558 | createSitewiseAssetFromPiElement, 559 | deleteSitewiseAssetByAssetId, 560 | 561 | getSitewiseAssetAssociatedChildAssets, 562 | associateSitewiseAssets, 563 | disassociateSitewiseChildAsset, 564 | 565 | associateTimeSeriesToSitewiseAssetProperty 566 | }; 567 | -------------------------------------------------------------------------------- /src/osi-pi-sdk/awsSitewisePublisher.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0. 4 | * 5 | * @author Dean Colcott 6 | */ 7 | 8 | const { IoTSiteWiseClient, BatchPutAssetPropertyValueCommand } = require("@aws-sdk/client-iotsitewise"); 9 | 10 | // Sitewise data publish queue. 11 | const sitewisePublishQueue = new Map(); 12 | 13 | // AWS IoT Sitewise Client 14 | let sitewiseClient; 15 | 16 | // Sitewise publish and memory monitor timerId. 17 | let publishTimerId; 18 | 19 | // Region and SiteWise publish batching params 20 | let region, propertyAliasBatchSize, propertyValueBatchSize, propertyPublishMaxAgeSecs; 21 | 22 | // Sitewise telemetry params. 23 | let telemetry = {}; 24 | telemetry.numPublishes = 0; 25 | telemetry.receivedOsiPiPointsCount = 0; 26 | telemetry.publishedPropertyAlias = 0; 27 | telemetry.publishedPropertyValues = 0; 28 | telemetry.sitewisePublishErrorCount = 0; 29 | telemetry.sitewisePublishErrorReceived = {}; 30 | 31 | // DEBUG ONLY: 32 | //setInterval(debugLogSitewisePublishQueue, 10000); 33 | 34 | /** 35 | * Update the local config parameters and initialize Sitewise Client. 36 | * If region changed, the publish queue is flushed and the Sitewise client is re-initiated. 37 | * 38 | * @param {*} region 39 | * @param {*} sitewiseMaxTqvPublishRate 40 | * @param {*} propertyAliasBatchSize 41 | * @param {*} propertyValueBatchSize 42 | */ 43 | function sitewisePublisherUpdateConfig(regionCandidate, awsSitewisePublisherConfig) { 44 | 45 | console.log("[INFO] Initializing AWS IoT Sitewise Data Publisher Configuration....."); 46 | 47 | // Validate region - update to reportedCandidate if no errors. 48 | if (!(typeof regionCandidate === "string" && regionCandidate.length >= 9)) { 49 | throw new Error("'region' not provided or invalid value in Sitewise Publisher config update"); 50 | } 51 | 52 | // Validate sitewiseMaxTqvPublishRate - update to reportedCandidate if no errors. 53 | const sitewiseMaxTqvPublishRate = awsSitewisePublisherConfig.sitewiseMaxTqvPublishRate; 54 | if (isNaN(sitewiseMaxTqvPublishRate) || sitewiseMaxTqvPublishRate < 1000 || sitewiseMaxTqvPublishRate > 100000) { 55 | throw new Error("'sitewiseMaxTqvPublishRate' is not provided or invalid value (int: 1000 - 100000)"); 56 | } 57 | 58 | // Validate propertyAliasBatchSizeConf - update to reportedCandidate if no errors. 59 | const propertyAliasBatchSizeCandidate = awsSitewisePublisherConfig.sitewisePropertyAliasBatchSize; 60 | if (isNaN(propertyAliasBatchSizeCandidate) || propertyAliasBatchSizeCandidate < 1 || propertyAliasBatchSizeCandidate > 10) { 61 | throw new Error("'sitewisePropertyAliasBatchSize' is not provided or invalid value (int: 1 - 10)"); 62 | } 63 | propertyAliasBatchSize = propertyAliasBatchSizeCandidate; 64 | 65 | // Validate propertyValueBatchSizeConf - update to reportedCandidate if no errors. 66 | const propertyValueBatchSizeCandidate = awsSitewisePublisherConfig.sitewisePropertyValueBatchSize; 67 | if (isNaN(propertyValueBatchSizeCandidate) || propertyValueBatchSizeCandidate < 1 || propertyValueBatchSizeCandidate > 10) { 68 | throw new Error("'sitewisePropertyValueBatchSize' is not provided or invalid value (int: 1 - 10)"); 69 | } 70 | propertyValueBatchSize = propertyValueBatchSizeCandidate; 71 | 72 | // Validate propertyPublishMaxAgeSecsConf - update to reportedCandidate if no errors. 73 | const propertyPublishMaxAgeSecsCandidate = awsSitewisePublisherConfig.sitewisePropertyPublishMaxAgeSecs; 74 | if (isNaN(propertyPublishMaxAgeSecsCandidate) || propertyPublishMaxAgeSecsCandidate < 30 || propertyPublishMaxAgeSecsCandidate > 360) { 75 | throw new Error("'sitewisePropertyPublishMaxAgeSecs' is not provided or invalid value (int: 30 - 3600)"); 76 | } 77 | propertyPublishMaxAgeSecs = propertyPublishMaxAgeSecsCandidate; 78 | 79 | // If changing or initializing region then clear existing queues and reinitialize Sitewise Client. 80 | if (region !== regionCandidate) { 81 | sitewiseClient = null; 82 | region = regionCandidate; 83 | sitewisePublishQueue.clear; 84 | sitewiseClient = new IoTSiteWiseClient({ region: region }); 85 | } 86 | region = regionCandidate; 87 | 88 | // Configure Sitewise publish interval timer to publish up to a calculated maximum TQV rate. 89 | // Practical limit of timer is 1mS. This puts a theoretical limit on publish rate dependent on min batch size. 90 | const publishInterval = 1000 / (sitewiseMaxTqvPublishRate / (propertyAliasBatchSize * propertyValueBatchSize)); 91 | 92 | // Clear any previous publish timer and reinitialize 93 | if (publishTimerId) clearInterval(publishTimerId); 94 | publishTimerId = setInterval(publishToSitewise, publishInterval); 95 | console.log(`[INFO] Setting Min AWS IoT Sitewise Publish Timer Interval to: ${publishInterval} mS`); 96 | 97 | console.log("[INFO] Initializing AWS IoT Sitewise Data Publisher Configuration - COMPLETE."); 98 | } 99 | 100 | /** 101 | * Creates / resets telemetry metrics to 0 102 | */ 103 | function getSitewiseTelemetryData(resetTelemetryCounts = true) { 104 | 105 | // Set dynamic sitewiseQueuedPropAlias value and return telemetry 106 | telemetry.sitewiseQueuedPropAlias = sitewisePublishQueue.size; 107 | 108 | // Get a deep copy of the telemetry object in current state 109 | const telemetryClone = { ...telemetry }; 110 | telemetryClone.sitewisePublishErrorReceived = { ...telemetry.sitewisePublishErrorReceived }; 111 | 112 | // Reset telemetery counts / objects. 113 | if (resetTelemetryCounts) { 114 | telemetry.numPublishes = 0; 115 | telemetry.receivedOsiPiPointsCount = 0; 116 | telemetry.publishedPropertyAlias = 0; 117 | telemetry.publishedPropertyValues = 0; 118 | telemetry.sitewisePublishErrorCount = 0; 119 | telemetry.sitewisePublishErrorReceived = {}; 120 | } 121 | 122 | return telemetryClone; 123 | } 124 | 125 | /** 126 | * Accepts OSI Pi WebSocket receive message format as specified by Pi Channel Data at: 127 | * https://docs.aveva.com/bundle/pi-web-api-reference/page/help/topics/channels.html 128 | * 129 | * Formats each PiPoint item to an AWS IoT Sitewise BatchPutAssetPropertyValueCommandInput model: 130 | * https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-iotsitewise/classes/batchputassetpropertyvaluecommand.html 131 | * 132 | * Then stores in the sitewisePublishQueue for the Sitewise publish timer to pick up once it meets publish batch configurations. 133 | * If any publish conditions are met, the publisher will batch as many data points as Sitewise limits will allow above batch configurations set. 134 | * 135 | * Note: Javascript doesn't distinguish between Int’s and Double's so inferred data type for any numerical value is sent as a Double to Sitewise. 136 | * 137 | * @param {*} piDataPointObject 138 | */ 139 | function queuePiData(piDataPointObject) { 140 | 141 | try { 142 | 143 | if (!piDataPointObject.Items)throw new Error("Received unexpected data format from OSI Pi Server"); 144 | 145 | // Format the OSI Pi WebSocket channel data format to AWS IoT Sitewise BatchPutAssetPropertyValueCommandInput model 146 | for (const piDataItem of piDataPointObject.Items) { 147 | 148 | // if this propertyAlias exists in sitewisePublishQueue then assign to the given sitewiseEntry, else create new sitewiseEntry. 149 | const sitewiseEntry = sitewisePublishQueue.get(piDataItem.Path); 150 | const propValues = getSitewiseFormattedPropertyValues(piDataItem.Items); 151 | telemetry.receivedOsiPiPointsCount += propValues.length; // Update PiPoint received telemetry value. 152 | if (sitewiseEntry) { 153 | sitewiseEntry.propertyValues.push(...propValues); 154 | 155 | } else { 156 | const newSitewiseEntry = {}; 157 | newSitewiseEntry.propertyAlias = piDataItem.Path; 158 | newSitewiseEntry.propertyValues = propValues; 159 | sitewisePublishQueue.set(piDataItem.Path, newSitewiseEntry); 160 | } 161 | } 162 | 163 | } catch (err) { 164 | console.log("[ERROR]: Queuing OSI PI Data Points to Sitewise Publisher. Error"); 165 | console.log(err); 166 | } 167 | } 168 | 169 | function deletePiDataQueue(deletePercent) { 170 | 171 | const queueSize = sitewisePublishQueue.size; 172 | const deleteNumKeys = Math.round(queueSize * (deletePercent / 100)); 173 | 174 | console.log(`[WARN] Deleting ${deletePercent}% or ${deleteNumKeys} of ${queueSize} keys from Sitewise Publish Queue`); 175 | 176 | if (deletePercent === 100){ 177 | sitewisePublishQueue.clear(); 178 | return; 179 | } 180 | 181 | let cnt = 0; 182 | for (const piEntry of sitewisePublishQueue.keys()) { 183 | sitewisePublishQueue.delete(piEntry); 184 | cnt += 1; 185 | if (cnt >= deleteNumKeys) break; 186 | } 187 | } 188 | 189 | /** 190 | * Reformats OSI Pi WebSocket channel data to Time Quality Value (TQV) format suitable for upload to AWS IoT Sitewise. 191 | * 192 | * @param {*} propertyValues 193 | * @returns 194 | */ 195 | function getSitewiseFormattedPropertyValues(piPointDataItems) { 196 | 197 | const propertyValues = []; 198 | 199 | for (const piPointDataItem of piPointDataItems) { 200 | 201 | // Init the Sitewise propertyValue object 202 | const propertyValue = {}; 203 | propertyValue.value = {}; 204 | propertyValue.timestamp = {}; 205 | 206 | // TQV: Value 207 | // Get the PiPoint data value 208 | const dataPoint = piPointDataItem.Value; 209 | 210 | // TQV: Type 211 | // Infer the PiPoint data type and set Sitewise input value object 212 | 213 | //Note: 214 | // If is number, return Double as can't determine if is double or int in JS which only stores floats. 215 | const dataType = typeof dataPoint; 216 | switch (dataType) { 217 | case "number": 218 | propertyValue.value.doubleValue = dataPoint; 219 | break; 220 | 221 | case "string": 222 | propertyValue.value.stringValue = dataPoint; 223 | break; 224 | 225 | case "boolean": 226 | propertyValue.value.booleanValue = dataPoint; 227 | break; 228 | 229 | default: 230 | throw new Error(`Received unknown data type ${dataType} - value: ${JSON.stringify(dataPoint)}`); 231 | } 232 | 233 | // TQV: Time 234 | // Get the OSI Pi String formatted DateTime to Sitewise 10-digit (Second granularity) and nanoSecond offset timestamp. 235 | 236 | const fullTimestmp = new Date(piPointDataItem.Timestamp).getTime(); 237 | const secondTimestmp = Math.floor(fullTimestmp / 1000); 238 | // Timestamp is to 100's of Nanoseconds. Get the Nano second offset from the fullTimestamp. 239 | const nsOffsetTimestmp = fullTimestmp % 1000 * 100; 240 | 241 | propertyValue.timestamp.timeInSeconds = secondTimestmp; 242 | propertyValue.timestamp.offsetInNanos = nsOffsetTimestmp; 243 | 244 | // TQV: Quality 245 | // OSI Pi doesn't give BAD as an option so anything other than GOOD is marked as UNCERTAIN. 246 | propertyValue.quality = (piPointDataItem.Good) ? "GOOD" : "UNCERTAIN"; 247 | 248 | // Push to array and repeat! 249 | propertyValues.push(propertyValue); 250 | } 251 | 252 | return propertyValues; 253 | } 254 | 255 | /** 256 | * Publish / upload FiFo TQVs to Sitewise. 257 | * @returns 258 | */ 259 | async function publishToSitewise() { 260 | 261 | try { 262 | 263 | // Get the data entries to publish and remove from the Queue. 264 | const publishEntries = getNextSitewisePublishEntries(); 265 | 266 | // If publish batching conditions aren’t met just return. 267 | if (!publishEntries) return; 268 | 269 | //========================================== 270 | // Publish to Sitewise. 271 | //========================================== 272 | 273 | // Crete publish command and publish data batch data,. 274 | const command = new BatchPutAssetPropertyValueCommand({ "entries": publishEntries }); 275 | const response = await sitewiseClient.send(command); 276 | 277 | // Update Sitewise Publish telemetry 278 | // TODO: Need to remove from telemetry count if error was returned in errorEntries 279 | telemetry.numPublishes += 1; 280 | telemetry.publishedPropertyAlias += publishEntries.length; 281 | 282 | for (const publishedPropAlias of publishEntries) { 283 | telemetry.publishedPropertyValues += publishedPropAlias.propertyValues.length; 284 | } 285 | 286 | // Record any errors from the Sitewise Publish command. 287 | const errorEntries = response.errorEntries; 288 | if (Object.keys(errorEntries).length > 0) { 289 | 290 | // Log Sitewise errors to console. 291 | debugLogSitewisePublishErrors(errorEntries); 292 | 293 | for (const errorEntry of errorEntries) { 294 | 295 | telemetry.sitewisePublishErrorCount += 1; 296 | for (const error of errorEntry.errors) { 297 | // Set the error code and error message. 298 | telemetry.sitewisePublishErrorReceived[error.errorCode] = error.errorMessage; 299 | } 300 | } 301 | } 302 | 303 | } catch (err) { 304 | console.log("[ERROR]: Error publishing to AWS IoT Sitewise - Error:"); 305 | console.log(err.toString()); 306 | } 307 | } 308 | 309 | /** 310 | * Returns Pi data entries that meet the publish criteria from SiteWise Publish queue. 311 | * Re-queues any to back of list that are processed but with data points remaining. 312 | * 313 | * Returns False if publish criteria not met. 314 | * 315 | * @returns 316 | */ 317 | function getNextSitewisePublishEntries() { 318 | 319 | const publishEntries = []; 320 | let isAnyEntryAged = false; 321 | 322 | // Calculate timestamp to trigger publish based on data point age. 323 | const agedPublishTimestamp = Math.round(Date.now() / 1000) - propertyPublishMaxAgeSecs; 324 | 325 | for (const publishCandidate of sitewisePublishQueue.values()) { 326 | 327 | // Evaluate data entry against publish criteria of entry in sitewisePublishQueue 328 | const isAged = publishCandidate.propertyValues[0].timestamp.timeInSeconds <= agedPublishTimestamp; 329 | const isPropCnt = publishCandidate.propertyValues.length >= propertyValueBatchSize; 330 | 331 | // Continue to next iteration if publishCandidate doesn't meet publish criteria. 332 | if (!(isPropCnt || isAged)) continue; 333 | 334 | // Update isAnyEntryAged value. 335 | isAnyEntryAged = isAged || isAnyEntryAged; 336 | 337 | // Deep copy the publishCandidate with up to 10 data values as is the Sitewise publish command limit. 338 | const publishEntry = {}; 339 | publishEntry.entryId = Math.random().toString(36).slice(2); 340 | publishEntry.propertyAlias = publishCandidate.propertyAlias; 341 | publishEntry.propertyValues = publishCandidate.propertyValues.splice(0, 10); 342 | 343 | // Push the data entry to the publish array. 344 | publishEntries.push(publishEntry); 345 | 346 | // Dequeue the publishCandidate from head of the Sitewise publish queue after has been processed. 347 | sitewisePublishQueue.set(publishCandidate.propertyAlias, null); 348 | sitewisePublishQueue.delete(publishCandidate.propertyAlias); 349 | 350 | // If the publishCandidate has property values remaining then requeue to back of the list. 351 | if (publishCandidate.propertyValues.length > 0) { 352 | sitewisePublishQueue.set(publishCandidate.propertyAlias, publishCandidate); 353 | } 354 | 355 | // Break loop and return if have reached propertyAliasBatchSize number of dataEntries 356 | if (publishEntries.length >= propertyAliasBatchSize) return publishEntries; 357 | } 358 | 359 | // If any aged entries found then return even if not meeting batch criteria. 360 | if (isAnyEntryAged) return publishEntries; 361 | 362 | // Otherwise if not required batch number of entries found then return false. 363 | return false; 364 | } 365 | 366 | function debugLogSitewisePublishErrors(errorEntries, publishFullErrorTimestamps = false) { 367 | 368 | console.log("\n\n###[DEBUG LOG]: Sitewise Publish errorEntries: "); 369 | 370 | // Log Sitewise Publish Error Response. 371 | for (const errorEntry of errorEntries) { 372 | console.log(`entryId: ${errorEntry.entryId}`); 373 | 374 | for (const error of errorEntry.errors) { 375 | // Set or reset the error code or message.) 376 | console.log(`errorCode: ${error.errorCode}`); 377 | console.log(`errorMessage: ${error.errorMessage}`); 378 | 379 | if (publishFullErrorTimestamps) { 380 | for (const timestamp of error.timestamps) { 381 | console.log("timestamp:"); 382 | console.log(timestamp); 383 | } 384 | } 385 | } 386 | } 387 | } 388 | 389 | function debugLogSitewisePublishEntries(publishEntries) { 390 | 391 | console.log("\n\n###[DEBUG LOG]: Publish to AWS IoT Sitewise entries"); 392 | 393 | for (const entry of publishEntries) { 394 | console.log(`\nEntryID: ${entry.entryId}`); 395 | console.log(`PropertyAlias: ${entry.propertyAlias}`); 396 | 397 | let cnt = 1; 398 | console.log("PropertyValues:"); 399 | for (const value of entry.propertyValues) { 400 | console.log(`Entry ${cnt}: Timestamp: ${value.timestamp.timeInSeconds} - Value: ${value.value.doubleValue}`); 401 | cnt += 1; 402 | } 403 | } 404 | } 405 | 406 | function debugLogSitewisePublishEntriesTotals(publishEntries) { 407 | 408 | if (!publishEntries.length) return; 409 | 410 | let propValCnt = 0; 411 | const numberEntries = publishEntries.length; 412 | for (const publishEntry of publishEntries) { 413 | propValCnt += publishEntry.propertyValues.length; 414 | } 415 | 416 | console.log(`\n\n###[DEBUG LOG]: Sitewise Publish entry Stats - ${Date.now()}: Property Alisas: ${numberEntries} - Total Property Values: ${propValCnt}`); 417 | 418 | } 419 | 420 | function debugLogQueuePiDataUpdate(piDataPointObject) { 421 | 422 | let numPiTags = piDataPointObject.Items.length; 423 | let totalPiPoints = 0; 424 | 425 | for (const piDataItem of piDataPointObject.Items) { 426 | totalPiPoints += piDataItem.Items.length; 427 | } 428 | 429 | console.log(`\n\n###[DEBUG LOG]: Queued OSI PiPoint entry - ${Date.now()}: Total Tags: ${numPiTags} - Total Data Points: ${totalPiPoints}`); 430 | 431 | } 432 | 433 | function debugLogSitewisePublishQueue() { 434 | 435 | let toalQueuedDataPoints = 0; 436 | const queuedTags = sitewisePublishQueue.size; 437 | const queuedDataPointDistribution = {}; 438 | 439 | sitewisePublishQueue.forEach((sitewiseEntry) => { 440 | 441 | const numPropVals = sitewiseEntry.propertyValues.length; 442 | toalQueuedDataPoints += numPropVals; 443 | 444 | // If this number if properties doesn't have a listed distribution, then initialize. 445 | if (!queuedDataPointDistribution[numPropVals]) queuedDataPointDistribution[numPropVals] = 0; 446 | 447 | // + 1 to this distribution. 448 | queuedDataPointDistribution[numPropVals] += 1; 449 | 450 | }); 451 | 452 | console.log(`\n\n###[DEBUG LOG]: Sitewise Queue - ${Date.now()}: Queued Tags: ${queuedTags} - Queued Data Points: ${toalQueuedDataPoints} - Tag Data Point Distribution:`); 453 | console.log(queuedDataPointDistribution); 454 | 455 | } 456 | 457 | module.exports = { 458 | sitewisePublisherUpdateConfig, 459 | getSitewiseTelemetryData, 460 | queuePiData, 461 | deletePiDataQueue 462 | }; 463 | 464 | 465 | -------------------------------------------------------------------------------- /src/osi-pi-sdk/images/aws-osi-pi-data-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awslabs/aws-greengrass-labs-osi-pi-streaming-data-connector/bc74c6d8ad47daa152d75296c73c06bbbb0a8ffc/src/osi-pi-sdk/images/aws-osi-pi-data-architecture.png -------------------------------------------------------------------------------- /src/osi-pi-sdk/pi-objects/piAssetDatabase.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0. 4 | * 5 | * @author Dean Colcott 6 | */ 7 | 8 | const PiAssetElementParent = require("./piAssetElementParent"); 9 | 10 | class PiAssetDatabase extends PiAssetElementParent { 11 | 12 | constructor(webid, name, path) { 13 | super(webid, name, path); 14 | } 15 | } 16 | 17 | module.exports = PiAssetDatabase; 18 | -------------------------------------------------------------------------------- /src/osi-pi-sdk/pi-objects/piAssetElement.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0. 4 | * 5 | * @author Dean Colcott 6 | */ 7 | 8 | const PiAssetElementParent = require("./piAssetElementParent"); 9 | 10 | class PiAssetElement extends PiAssetElementParent { 11 | 12 | constructor(webid, name, path, templateName, templateLink, parentLink, categoryNames, extendedProperties, hasChildren) { 13 | 14 | super(webid, name, path); 15 | 16 | this.templateName = templateName; 17 | 18 | // Is likly error prone but Pi doesn't return the Template (path or WebId) so need to spilt from the complete Template URL Link (which for some reason it does return)! 19 | this.templateWebId=undefined; 20 | if (templateLink) { 21 | this.templateWebId = templateLink.split("/").pop(); 22 | } 23 | 24 | // Same for Parent Object 25 | this.parentWebId=undefined; 26 | if (parentLink) { 27 | this.parentWebId = parentLink.split("/").pop(); 28 | } 29 | 30 | this.categoryNames = categoryNames; 31 | this.extendedProperties = extendedProperties; 32 | this.hasChildren = hasChildren; 33 | } 34 | 35 | 36 | 37 | //============================================== 38 | // Getters 39 | getTemplateName() { return this.templateName; } 40 | getTemplateWebId() { return this.templateWebId; } 41 | getParentWebId() { return this.parentWebId; } 42 | getCategoryNames() { return this.categoryNames; } 43 | getExtendedProperties() { return this.extendedProperties; } 44 | getHasChildren() { return this.hasChildren; } 45 | } 46 | 47 | module.exports = PiAssetElement; 48 | -------------------------------------------------------------------------------- /src/osi-pi-sdk/pi-objects/piAssetElementAttribute.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0. 4 | * 5 | * @author Dean Colcott 6 | */ 7 | 8 | const PiBaseObject = require("./piBaseObject"); 9 | const stringTypes = ["string", "guid", "datetime", "timestamp"]; 10 | const numberTypes = ["byte", "int", "float", "single", "double"]; 11 | 12 | class PiAssetElementAttribute extends PiBaseObject { 13 | 14 | constructor(webid, name, path, type, defaultUnitsName, defaultUnitsNameAbbreviation, hasChildren, piPointLink) { 15 | 16 | super(webid, name, path); 17 | 18 | this.type = type; 19 | this.defaultUnitsName = defaultUnitsName; 20 | this.defaultUnitsNameAbbreviation = defaultUnitsNameAbbreviation; 21 | this.hasChildren = hasChildren; 22 | 23 | // Is likly error prone but Pi doesn't return the attached PiPoint Path so need to spilt from the complete PiPoint URL Link (which for some reason it does return)! 24 | this.piPointWebId = undefined; 25 | if (piPointLink) { 26 | this.piPointWebId = piPointLink.split("/").pop(); 27 | } 28 | } 29 | 30 | //============================================== 31 | // Getters 32 | getType() { return this.type; } 33 | getDefaultUnitsName() { return this.defaultUnitsName; } 34 | getDefaultUnitsNameAbbreviation() { return this.defaultUnitsNameAbbreviation; } 35 | getHasChildren() { return this.hasChildren; } 36 | getPiPointWebId() { return this.piPointWebId; } 37 | 38 | getSitewiseType() { 39 | 40 | let lowerType = this.type.toLowerCase(); 41 | 42 | // Return DOUBLE for all number type. 43 | for (let numberType of numberTypes) { 44 | if (lowerType.includes(numberType)) return "DOUBLE"; 45 | } 46 | 47 | // Return STRING for all number type. 48 | for (let stringType of stringTypes) { 49 | if (lowerType.includes(stringType)) return "STRING"; 50 | } 51 | 52 | // Return for BOOELAN type 53 | if (lowerType === "boolean") return "BOOLEAN"; 54 | 55 | // Else throw error for unknown type 56 | throw new Error(`Unknown Data Type: ${this.type}`); 57 | 58 | } 59 | 60 | } 61 | 62 | module.exports = PiAssetElementAttribute; 63 | -------------------------------------------------------------------------------- /src/osi-pi-sdk/pi-objects/piAssetElementParent.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0. 4 | * 5 | * @author Dean Colcott 6 | */ 7 | 8 | const PiBaseObject = require("./piBaseObject"); 9 | 10 | class PiAssetElementParent extends PiBaseObject { 11 | 12 | constructor(webid, name, path) { 13 | 14 | super(webid, name, path); 15 | } 16 | } 17 | 18 | module.exports = PiAssetElementParent; 19 | -------------------------------------------------------------------------------- /src/osi-pi-sdk/pi-objects/piAssetElementTemplate.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0. 4 | * 5 | * @author Dean Colcott 6 | */ 7 | 8 | const PiBaseObject = require("./piBaseObject"); 9 | 10 | class PiAssetElementTemplate extends PiBaseObject { 11 | 12 | constructor(webid, name, path, baseTemplate, namingPattern, categoryNames, instanceType, extendedProperties) { 13 | 14 | super(webid, name, path); 15 | 16 | this.baseTemplate = baseTemplate; 17 | this.namingPattern = namingPattern; 18 | this.categoryNames = categoryNames; 19 | this.instanceType = instanceType; 20 | this.extendedProperties = extendedProperties; 21 | 22 | // Attributes are queried separately in the Pi WebApi. 23 | this.attributes = []; 24 | } 25 | 26 | //============================================== 27 | // Setters 28 | setAttributes(attributes) { this.attributes = attributes; } 29 | 30 | //============================================== 31 | // Getters 32 | getBaseTemplate() { return this.baseTemplate; } 33 | getNamingPattern() { return this.namingPattern; } 34 | getCategoryNames() { return this.categoryNames; } 35 | getInstanceType() { return this.instanceType; } 36 | getExtendedProperties() { return this.extendedProperties; } 37 | getAttributes() { return this.attributes; } 38 | } 39 | 40 | module.exports = PiAssetElementTemplate; 41 | -------------------------------------------------------------------------------- /src/osi-pi-sdk/pi-objects/piAssetElementTemplateAttribute.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0. 4 | * 5 | * @author Dean Colcott 6 | */ 7 | 8 | const PiBaseObject = require("./piBaseObject"); 9 | const stringTypes = ["string", "guid", "datetime", "timestamp"]; 10 | const numberTypes = ["byte", "int", "float", "single", "double"]; 11 | 12 | class PiAssetElementTemplateAttribute extends PiBaseObject { 13 | 14 | constructor(webid, name, path, type, defaultUnitsName, defaultUnitsNameAbbreviation, defaultValue, hasChildren) { 15 | 16 | super(webid, name, path); 17 | 18 | this.type = type; 19 | this.defaultUnitsName = defaultUnitsName; 20 | this.defaultUnitsNameAbbreviation = defaultUnitsNameAbbreviation; 21 | this.defaultValue = defaultValue; 22 | this.hasChildren = hasChildren; 23 | } 24 | 25 | //============================================== 26 | // Getters 27 | getType() { return this.type; } 28 | getDefaultUnitsName() { return this.defaultUnitsName; } 29 | getDefaultUnitsNameAbbreviation() { return this.defaultUnitsNameAbbreviation; } 30 | getDefaultValue() { return this.defaultValue; } 31 | getHasChildren() { return this.hasChildren; } 32 | 33 | getSitewiseType() { 34 | 35 | let lowerType = this.type.toLowerCase(); 36 | 37 | // Return DOUBLE for all number types. 38 | for (let numberType of numberTypes){ 39 | if (lowerType.includes(numberType))return "DOUBLE"; 40 | } 41 | 42 | // Return STRING for all String types. 43 | for (let stringType of stringTypes){ 44 | if (lowerType.includes(stringType))return "STRING"; 45 | } 46 | 47 | // Return for BOOELAN type 48 | //if (lowerType === "boolean") return "BOOLEAN"; 49 | // Changed Boolean to Double as Pi seralizes True/False to 1/0 in JSON making it appear as a number. 50 | if (lowerType === "boolean") return "DOUBLE"; 51 | 52 | // Else throw error for Unsupported data type 53 | throw new Error(`Unsupported Data Type: ${this.type}`); 54 | 55 | } 56 | 57 | } 58 | 59 | module.exports = PiAssetElementTemplateAttribute; 60 | -------------------------------------------------------------------------------- /src/osi-pi-sdk/pi-objects/piAssetServer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0. 4 | * 5 | * @author Dean Colcott 6 | */ 7 | 8 | const PiBaseObject = require("./piBaseObject"); 9 | 10 | class PiAssetServer extends PiBaseObject { 11 | 12 | constructor(webid, name, path, id, isConnected, serverVersion, serverTime) { 13 | super(webid, name, path); 14 | 15 | this.id = id; 16 | this.isConnected = isConnected; 17 | this.serverVersion = serverVersion; 18 | this.serverTime = serverTime; 19 | } 20 | 21 | getId() { return this.id; } 22 | getIsConnected() { return this.isConnected; } 23 | getServerVersion() { return this.serverVersion; } 24 | getServerTime() { return this.serverTime; } 25 | } 26 | 27 | module.exports = PiAssetServer; 28 | -------------------------------------------------------------------------------- /src/osi-pi-sdk/pi-objects/piBaseObject.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0. 4 | * 5 | * @author Dean Colcott 6 | */ 7 | 8 | class PiBaseObject { 9 | 10 | constructor(webid, name, path) { 11 | this.webid = webid; 12 | this.name = name; 13 | this.path = path; 14 | } 15 | 16 | getWebId() { 17 | return this.webid; 18 | } 19 | 20 | getName() { 21 | return this.name; 22 | } 23 | 24 | getPath() { 25 | return this.path; 26 | } 27 | } 28 | 29 | module.exports = PiBaseObject; 30 | -------------------------------------------------------------------------------- /src/osi-pi-sdk/pi-objects/piDataPoint.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0. 4 | * 5 | * @author Dean Colcott 6 | */ 7 | 8 | const PiBaseObject = require("./piBaseObject"); 9 | 10 | class PiDataPoint extends PiBaseObject { 11 | 12 | constructor(webid, name, path, pointClass, pointType, engUnits, zero, span, future) { 13 | super(webid, name, path); 14 | 15 | this.pointClass = pointClass; 16 | this.pointType = pointType; 17 | this.engUnits = engUnits; 18 | this.zero = zero; 19 | this.span = span; 20 | this.future = future; 21 | } 22 | 23 | getPointClass() { return this.pointClass; } 24 | getPointType() { return this.pointType; } 25 | getEngineeringUnits() { return this.engUnits; } 26 | getZero() { return this.zero; } 27 | getSpan() { return this.span; } 28 | getFuture() { return this.future; } 29 | } 30 | 31 | module.exports = PiDataPoint; 32 | -------------------------------------------------------------------------------- /src/osi-pi-sdk/pi-objects/piDataServer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0. 4 | * 5 | * @author Dean Colcott 6 | */ 7 | 8 | const PiBaseObject = require("./piBaseObject"); 9 | 10 | class PiDataServer extends PiBaseObject { 11 | 12 | constructor(webid, name, path, id, isConnected, serverVersion, serverTime) { 13 | super(webid, name, path); 14 | 15 | this.id = id; 16 | this.isConnected = isConnected; 17 | this.serverVersion = serverVersion; 18 | this.serverTime = serverTime; 19 | } 20 | 21 | getId() { return this.id; } 22 | getIsConnected() { return this.isConnected; } 23 | getServerVersion() { return this.serverVersion; } 24 | getServerTime() { return this.serverTime; } 25 | } 26 | 27 | module.exports = PiDataServer; 28 | -------------------------------------------------------------------------------- /src/osi-pi-sdk/pi-objects/piServerRoot.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0. 4 | * 5 | * @author Dean Colcott 6 | */ 7 | 8 | class PiServerRoot { 9 | 10 | constructor(links) { 11 | this.links = links; 12 | } 13 | 14 | getRootLinks() { return this.links; } 15 | 16 | } 17 | 18 | module.exports = PiServerRoot; 19 | -------------------------------------------------------------------------------- /src/osi-pi-sdk/pi-objects/piWebsocketChannel.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0. 4 | * 5 | * @author Dean Colcott 6 | * 7 | * Presents the state and mapping of an OSI Pi StreamSet: 8 | * (https://docs.aveva.com/bundle/pi-web-api-reference/page/help/controllers/streamset.html) 9 | * 10 | * This is a WebSocket connection to the OSI Pi server with requested PiPoints 11 | * to stream on-change data in real time. 12 | * 13 | * Note: This is an abstraction of the WebSocket state for user requests, 14 | * it's not a reference to the WebSocket itself. 15 | * 16 | */ 17 | 18 | // Channel WebSocket Connection state. 19 | const webSocketStateOptions = { 20 | 0: "connecting", 21 | 1: "open", 22 | 2: "closing", 23 | 3: "closed" 24 | }; 25 | 26 | class PiWebSocketChannel { 27 | 28 | constructor(channelId, websocketState, piPoints ) { 29 | 30 | this.channelId = channelId; 31 | this.websocketState = webSocketStateOptions[websocketState]; 32 | this.piPoints = piPoints; 33 | this.numberPiPoints = piPoints.length; 34 | } 35 | 36 | getChannelId() { return this.channelId; } 37 | getWebsocketState() { return this.websocketState; } 38 | getPiPoints() { return this.piPoints; } 39 | getNumberPiPoints() { return this.numberPiPoints; } 40 | } 41 | 42 | module.exports = PiWebSocketChannel; 43 | -------------------------------------------------------------------------------- /src/osi-pi-sdk/piWebIdDeSer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0. 4 | * 5 | * @author Dean Colcott 6 | * 7 | * Based on example given at: 8 | * https://pisquare.osisoft.com/s/Blog-Detail/a8r1I000000Gv9JQAS/pi-web-api-using-web-id-20-to-optimize-your-applications 9 | * 10 | * 11 | * Note: This is a work in progress. 12 | * Intent is to be able to programmatically create PiPoint (and other) WebIds from the Point Paths instead of users 13 | * needing to know the WebID themsemves but to create PiPoint WebID also need the Point ID (index value) which is just as hard 14 | * as the WebId to get. Pending this question with OSI Pi. 15 | * 16 | * For now it is just able to decode a few WebIds that make it helpful to get the attrubute or point path fomr the WebID 17 | * 18 | **/ 19 | 20 | // Test WebIds: 21 | 22 | // Pi Data Server WebIds 23 | // "name": "EC2AMAZ-5B1B4RL", 24 | // "path": "\\\\PIServers[EC2AMAZ-5B1B4RL]", 25 | //let = "F1DStNdEETiNm02M4FYFgorKtQRUMyQU1BWi01QjFCNFJM"; 26 | 27 | // PiPoint Full WebIds 28 | // "name": "PumpingSite01.PumpingStation01.Pump01.CurrentDraw", 29 | // "path": "\\\\EC2AMAZ-5B1B4RL\\PumpingSite01.PumpingStation01.Pump01.CurrentDraw", 30 | //let piPointWebId = "F1DPtNdEETiNm02M4FYFgorKtQQwAAAARUMyQU1BWi01QjFCNFJMXFBVTVBJTkdTSVRFMDEuUFVNUElOR1NUQVRJT04wMS5QVU1QMDcuRkxPV01FU0FVUkVNRU5U"; 31 | 32 | // "name": "PumpingSite01.PumpingStation01.Pump01.CalculatedEfficiency", 33 | // "path": "\\\\EC2AMAZ-5B1B4RL\\PumpingSite01.PumpingStation01.Pump01.CalculatedEfficiency", 34 | //let piPointWebId = "F1DPtNdEETiNm02M4FYFgorKtQCQAAAARUMyQU1BWi01QjFCNFJMXFBVTVBJTkdTSVRFMDEuUFVNUElOR1NUQVRJT04wMS5QVU1QMDEuQ0FMQ1VMQVRFREVGRklDSUVOQ1k"; 35 | 36 | // "name": "PumpingSite01.PumpingStation01.Pump01.BearingTemp", 37 | // "path": "\\\\EC2AMAZ-5B1B4RL\\PumpingSite01.PumpingStation01.Pump01.BearingTemp", 38 | //let piPointWebId = "F1DPtNdEETiNm02M4FYFgorKtQCgAAAARUMyQU1BWi01QjFCNFJMXFBVTVBJTkdTSVRFMDEuUFVNUElOR1NUQVRJT04wMS5QVU1QMDEuQkVBUklOR1RFTVA"; 39 | 40 | class PiWebIdDeSer { 41 | 42 | decodePiDataServerWebId( webId ) { 43 | 44 | let type = webId.slice(0, 1); 45 | let version = webId.slice(1, 2); 46 | let marker = webId.slice(2, 4); 47 | let serverId = webId.slice(4, 26); 48 | let serverName = webId.slice(26, webId.length); 49 | 50 | console.log(`Web ID, Type ${type}`); 51 | console.log(`Web ID, Version ${version}`); 52 | console.log(`Web ID, Marker ${marker}`); 53 | 54 | console.log(`point.Server.ID: ${serverId} -> ${this.decodeGUID(serverId)}`); 55 | console.log(`serverName -> ${serverName} -> ${this.decodeString(serverName)}`); 56 | } 57 | 58 | decodePiPointFullWebId( webId ) { 59 | 60 | let type = webId.slice(0, 1); 61 | let version = webId.slice(1, 2); 62 | let marker = webId.slice(2, 4); 63 | let serverId = webId.slice(4, 26); 64 | let pointId = webId.slice(26, 32); 65 | let payload = webId.slice(32, webId.length); 66 | 67 | console.log(`Web ID, Type ${type}`); 68 | console.log(`Web ID, Version ${version}`); 69 | console.log(`Web ID, Marker ${marker}`); 70 | 71 | console.log(`point.Server.ID: ${serverId} -> ${this.decodeGUID(serverId)}`); 72 | console.log(`point.ID: ${pointId} -> ${this.decodeInt32(pointId)}`); 73 | console.log(`payload -> ${payload} -> ${this.decodeString(payload)}`); 74 | } 75 | 76 | //=================================== 77 | // Pi WebID Encode / Decode Helper Functions 78 | //=================================== 79 | 80 | decodeString(strDecode) { 81 | var decodestring = strDecode.replace("-", "+").replace("_", "/"); 82 | var padneeded = decodestring.length % 4; 83 | for (var i = 0; i < padneeded; i++) { 84 | decodestring += "="; 85 | } 86 | 87 | return Buffer.from(decodestring, "base64").toString("utf8"); 88 | } 89 | 90 | decodeInt32(urlEncodeInt32) { 91 | 92 | let bytes = this.base64ToArrayBuffer(urlEncodeInt32); 93 | let uncodedbytes = new Uint8Array(bytes); 94 | // Reverse for little to big endian 95 | uncodedbytes = uncodedbytes.reverse(); 96 | // Byte array to Integer (value) 97 | let value = 0; 98 | for (var i = 0; i < uncodedbytes.length; i++) { 99 | value = (value << 8) | uncodedbytes[i]; 100 | } 101 | 102 | return value; 103 | } 104 | 105 | base64ToArrayBuffer(base64) { 106 | //var binary_string = atob(base64); 107 | var binary_string = Buffer.from(base64, "base64").toString("binary"); 108 | var len = binary_string.length; 109 | var bytes = new Uint8Array(len); 110 | for (var i = 0; i < len; i++) { 111 | bytes[i] = binary_string.charCodeAt(i); 112 | } 113 | return bytes.buffer; 114 | } 115 | 116 | decodeGUID(strDecode) { 117 | 118 | var bytes = this.base64ToArrayBuffer(strDecode); 119 | var uncodedbytes = new Uint8Array(bytes); 120 | 121 | var guidstr = ""; 122 | 123 | for (var i = 3; i >= 0; i--) { 124 | if (uncodedbytes[i] < 17) { 125 | guidstr += "0" + uncodedbytes[i].toString(16); 126 | } else { 127 | guidstr += uncodedbytes[i].toString(16); 128 | } 129 | } 130 | guidstr += "-"; 131 | if (uncodedbytes[5] < 17) { 132 | guidstr += "0" + uncodedbytes[5].toString(16); 133 | } else { 134 | guidstr += uncodedbytes[5].toString(16); 135 | } 136 | if (uncodedbytes[4] < 17) { 137 | guidstr += "0" + uncodedbytes[4].toString(16); 138 | } else { 139 | guidstr += uncodedbytes[4].toString(16); 140 | } 141 | guidstr += "-"; 142 | if (uncodedbytes[7] < 17) { 143 | guidstr += "0" + uncodedbytes[7].toString(16); 144 | } else { 145 | guidstr += uncodedbytes[7].toString(16); 146 | } 147 | if (uncodedbytes[6] < 17) { 148 | guidstr += "0" + uncodedbytes[6].toString(16); 149 | } else { 150 | guidstr += uncodedbytes[6].toString(16); 151 | } 152 | guidstr += "-"; 153 | if (uncodedbytes[8] < 17) { 154 | guidstr += "0" + uncodedbytes[8].toString(16); 155 | } else { 156 | guidstr += uncodedbytes[8].toString(16); 157 | } 158 | if (uncodedbytes[9] < 17) { 159 | guidstr += "0" + uncodedbytes[9].toString(16); 160 | } else { 161 | guidstr += uncodedbytes[9].toString(16); 162 | } 163 | guidstr += "-"; 164 | for (i = 10; i < 16; i++) { 165 | if (uncodedbytes[i] < 17) { 166 | guidstr += "0" + uncodedbytes[i].toString(16); 167 | } else { 168 | guidstr += uncodedbytes[i].toString(16); 169 | } 170 | } 171 | 172 | return guidstr; 173 | } 174 | } 175 | 176 | module.exports = PiWebIdDeSer; 177 | 178 | 179 | // Just for testing 180 | // let piWebIdDeSer = new PiWebIdDeSer(); 181 | 182 | // console.log("\n\n#############################\nPiData Server WebID Decode\n#############################") 183 | // piWebIdDeSer.decodePiDataServerWebId(piDataServerWebId); 184 | // console.log("\n\n#############################\nPiPoint WebID Decode\n#############################") 185 | // piWebIdDeSer.decodePiPointFullWebId(piPointWebId); 186 | -------------------------------------------------------------------------------- /src/osi-pi-sdk/piWebSocketManager.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0. 4 | * 5 | * @author Dean Colcott 6 | * 7 | * Creates and maintains WebSocket connections to the OSI Pi server. 8 | * 9 | * Each WebSocket is allocated a number of OSI PI data points (PiPoints) that will be 10 | * streamed (on-change data) over the connection. OSI Pi refer to this as a StreamSet: 11 | * https://docs.aveva.com/bundle/pi-web-api-reference/page/help/controllers/streamset.html 12 | * 13 | * Here, we call a WebSocket with associated PiPoinst a 'Channel' Each channel described 14 | * maintains the WebSocket Object as a reference to the associated PiPoints. 15 | * 16 | * 17 | */ 18 | 19 | const WebSocket = require("ws"); 20 | const PiWebSocketChannel = require("./pi-objects/piWebsocketChannel"); 21 | 22 | let wssRootUrl, verifySsl; 23 | let maxPiDataPointWebSockets, maxPiPointsPerWebSocket; 24 | let onWebsocketMessage, onWebsocketChangedState; 25 | 26 | // Pi Data Point stream request queue. 27 | let queuedPiPointRequests = []; 28 | 29 | // OSI Pi Websocket / PiPoint channel map. 30 | let websocketChannelMap = new Map(); 31 | 32 | const websocketReadyStateMap = { 33 | "connecting": 0, 34 | "open": 1, 35 | "closing": 2, 36 | "closed": 3 37 | }; 38 | 39 | /** 40 | * Updates the config parameters for the class. 41 | * If piSecrets, piServerURL, piApiRootPath or verifySsl are changed then any queuedPiPointRequests and websocketChannelMap are flushed 42 | * and any open connector is gracefully closed. 43 | * 44 | */ 45 | function webSocketManagerUpdateConfig(piSecrets, osiPiServerConfig, osiPiWebSocketManagerConfig, onWebsocketMessageCb, onWebsocketChangedStateCb) { 46 | 47 | console.log("[INFO] Updating OSI Pi WebSocket Client Manager Configuration...."); 48 | 49 | // Basic validation of piSecrets 50 | if (!(piSecrets && piSecrets.username && piSecrets.password)) { 51 | throw new Error("AWS Secret provided is missing, invalid or doesn't contain username and / or password keys."); 52 | } 53 | 54 | // Validate piServerUrl 55 | const piServerUrl = osiPiServerConfig.piServerUrl; 56 | if (!(typeof piServerUrl === "string" && piServerUrl.length > 3)) { 57 | throw new Error("'piServerUrl' not provided or invalid value in WebSockets Manager config update."); 58 | } 59 | 60 | // Validate piApiRootPath 61 | const piApiRootPath = osiPiServerConfig.piApiRootPath; 62 | if (!(typeof piApiRootPath === "string" && piApiRootPath.length > 0)) { 63 | throw new Error("'piApiRootPath' not provided or invalid value in WebSockets Manager config update."); 64 | } 65 | 66 | // Validate verifySsl 67 | const verifySslCandidate = osiPiServerConfig.verifySsl; 68 | if (isNaN(verifySslCandidate) || verifySslCandidate < 0 || verifySslCandidate > 1) { 69 | throw new Error("'verifySsl' not provided or invalid value in WebSockets Manager config update (int: 0 or 1)"); 70 | } 71 | 72 | // Validate maxPiDataPointWebSockets - update to reportedCandidate if no errors. 73 | const maxPiDataPointWebSocketsCandidate = osiPiWebSocketManagerConfig.maxPiDataPointWebSockets; 74 | if (isNaN(maxPiDataPointWebSocketsCandidate) || maxPiDataPointWebSocketsCandidate < 1 || maxPiDataPointWebSocketsCandidate > 10000) { 75 | throw new Error("'maxPiDataPointWebSockets' not provided or invalid value (int: 1 - 10000)"); 76 | } 77 | 78 | // Validate maxPiPointsPerWebSocket - update to reportedCandidate if no errors. 79 | const maxPiPointsPerWebSocketCandidate = osiPiWebSocketManagerConfig.maxPiPointsPerWebSocket; 80 | if (isNaN(maxPiPointsPerWebSocketCandidate) || maxPiPointsPerWebSocketCandidate < 1 || maxPiPointsPerWebSocketCandidate > 100) { 81 | throw new Error("'maxPiPointsPerWebSocket' not provided or invalid value (int: 1 - 100)"); 82 | } 83 | 84 | // Validate onWebsocketMessage callback is a function. 85 | if (typeof onWebsocketMessageCb !== "function") { 86 | throw new Error("'onWebsocketMessage' callback not provided or not a function"); 87 | } 88 | 89 | // Validate onWebsocketChangedState callback is a function. 90 | if (typeof onWebsocketChangedStateCb !== "function") { 91 | throw new Error("'onWebsocketChangedState' callback not provided or not a function"); 92 | } 93 | 94 | // If all validations passed: 95 | 96 | // Calculate the WSS URL based on passed parameters. 97 | let calculatedWssRootUrl = `wss://${piSecrets.username}:${piSecrets.password}@${piServerUrl}/${piApiRootPath}`; 98 | 99 | // If WSS params changed (i.e: wssRootUrl (including username, password, piServerUrl or piApiRootPath) or verifySsl), then clear all current connections. 100 | if (calculatedWssRootUrl !== wssRootUrl || verifySsl !== verifySslCandidate) { 101 | 102 | // reset queuedPiPointRequests 103 | queuedPiPointRequests = []; 104 | 105 | // Delete any existing WebSocket / channels 106 | deleteAllChannels(); 107 | 108 | // Reset websocketChannelMap 109 | websocketChannelMap.clear; 110 | } 111 | 112 | // Update local state params: 113 | 114 | // URL parameters 115 | wssRootUrl = calculatedWssRootUrl; 116 | verifySsl = verifySslCandidate; 117 | 118 | // URL parameters 119 | maxPiDataPointWebSockets = maxPiDataPointWebSocketsCandidate; 120 | maxPiPointsPerWebSocket = maxPiPointsPerWebSocketCandidate; 121 | 122 | // Message callbacks 123 | onWebsocketChangedState = onWebsocketChangedStateCb; 124 | onWebsocketMessage = onWebsocketMessageCb; 125 | 126 | console.log("[INFO] Updating OSI Pi WebSocket Client Manager Configuration - COMPLETE"); 127 | } 128 | 129 | //==================================== 130 | // PiPoint user requests queuing / processor 131 | 132 | function queueStreamPiPointRequest(piPoint) { 133 | 134 | const piPointWebId = piPoint.getWebId(); 135 | const piPointName = piPoint.getName(); 136 | const piPointPath = piPoint.getPath(); 137 | 138 | // Input validation 139 | if (!(piPointWebId && piPointPath)) { 140 | throw new Error("Must provide valid piPoint object - No PiPoint WebId or Path found."); 141 | } 142 | 143 | // TODO: Need to also support Asset Framework Element attributes as data points. 144 | // TODO: below checks are clunky loops. Need to update. 145 | 146 | // Check PiPoint is not already streaming / requested. Return without re-queuing if it is. 147 | if (getChannelByPiPointWebId(piPointWebId)) return; 148 | 149 | // Check PiPoint is not already queued Return without re-queuing if it is. 150 | for (const queuedPoint of queuedPiPointRequests) { 151 | if (queuedPoint.webid === piPointWebId) return; 152 | } 153 | 154 | // Add to PiPoint request queue for processing. (If already queued will just overwrite). 155 | queuedPiPointRequests.push({ "webid": piPointWebId, "name": piPointName, "path": piPointPath }); 156 | } 157 | 158 | function clearQueueStreamPiPointRequest() { 159 | 160 | //Clear any PiPoints queued for streaming but no processed yet. 161 | const queuedLen = queuedPiPointRequests.length; 162 | queuedPiPointRequests = []; 163 | return `${queuedLen} PiPoints cleared from streaming queue`; 164 | } 165 | 166 | /** 167 | * Asynchronously processes the requested PiPoint queue. 168 | * Creates a channel with a WebSocket to the OSI server and assigns the next batch of PiPoints to the 169 | * channel in a group. Once the WebSocket is open, the associated PiPoints will instantly 170 | * begin streaming on-change data. 171 | * 172 | * @returns 173 | */ 174 | async function activateQueuedPiPointsForStreaming() { 175 | 176 | // Iterate through queuedPiPointRequests and create the Pi StreamSets Channel WebSocket’s for the requested PiPoints. 177 | let processedChannels = 0, processedPiPoints = 0; 178 | while (queuedPiPointRequests.length > 0) { 179 | 180 | // Throws error back to calling function 181 | if (websocketChannelMap.size >= maxPiDataPointWebSockets) { 182 | throw new Error("Exceeded max allowed WebSockets, must close existing Channels before opening any more."); 183 | } 184 | 185 | // Generate random ID for the channel. 186 | let channelId = "channel-" + Math.random().toString(36).slice(2); 187 | 188 | // Catch Websocket create errors in execution loop and moves to next iteration. 189 | try { 190 | 191 | // Copy and delete the next batch of PiPoints from the queuedPiPointRequests list. 192 | let channelGroupPiPoints = queuedPiPointRequests.splice(0, maxPiPointsPerWebSocket); 193 | 194 | // Create the Websocket with the given PiPoints 195 | let webSocket = await _createWebSocket(channelId, channelGroupPiPoints); 196 | 197 | // If the WebSocket initiates without exception then store the channel in the websocketChannelMap list. 198 | websocketChannelMap.set(channelId, { "websocket": webSocket, "piPoints": channelGroupPiPoints }); 199 | 200 | processedChannels++; 201 | processedPiPoints += channelGroupPiPoints.length; 202 | 203 | } catch (err) { 204 | // This if the WebSocket failed to initiate all together such as when the local OS won't accept any 205 | // more TCP connections. The WebSocket won't be added to the initiated list so is effectively deleted from the queue. 206 | onWebsocketChangedState("failed-deleted", channelId, err.toString()); 207 | } 208 | } 209 | 210 | const message = {}; 211 | message.processedChannels = processedChannels; 212 | message.processedPiPoints = processedPiPoints; 213 | 214 | return message; 215 | } 216 | 217 | async function _createWebSocket(channelId, channelGroupPiPoints) { 218 | 219 | // Create the Pi WebSocket (wss) StreamSet URL and add WebId query for all channel PiPoints 220 | let channelUrl = `${wssRootUrl}/streamsets/channel?heartbeatRate=10`; 221 | for (const piPoint of channelGroupPiPoints) { 222 | channelUrl += `&webid=${piPoint["webid"]}`; 223 | } 224 | 225 | // Create the channels WebSocket with the attached PiPoint WebIds, this will try to create the connection immediately. 226 | // TODO: Set rejectUnauthorized == SSL cert verification? use shadow-config variable to set if is - set to true otherwise. 227 | const webSocket = new WebSocket(channelUrl, { rejectUnauthorized: false }); 228 | 229 | // Add WebSocket state-change callbacks 230 | webSocket.onopen = function (event) { 231 | onWebsocketChangedState("open", channelId, event); 232 | }; 233 | 234 | webSocket.onerror = function (event) { 235 | onWebsocketChangedState("errored", channelId, event); 236 | }; 237 | 238 | webSocket.onclose = function (event) { 239 | onWebsocketChangedState("close", channelId, event); 240 | }; 241 | 242 | webSocket.onmessage = function (event) { 243 | onWebsocketMessage(event); 244 | }; 245 | 246 | // Wait for up to 100mS (10 Cnt x 10mS) if the socket isn't connected yet. 247 | // This prevents excessive load on the Pi Server and local OS from blocking new connections. 248 | await new Promise(resolve => setTimeout(resolve, 10)); 249 | for (let loopCnt = 0; loopCnt < 10; loopCnt++) { 250 | if (webSocket.readyState === webSocket.CONNECTING) { 251 | await new Promise(resolve => setTimeout(resolve, 10)); 252 | } else { 253 | break; 254 | } 255 | } 256 | 257 | return webSocket; 258 | } 259 | 260 | // Queued Pi Point Getters 261 | 262 | /** 263 | * Return a deep copy of the Queued Pi Points list. 264 | * @returns 265 | */ 266 | function getQueuedPiPoints() { 267 | return queuedPiPointRequests.slice(0); 268 | } 269 | 270 | function getNumberQueuedPiPoints() { 271 | return queuedPiPointRequests.length; 272 | } 273 | 274 | // Channel / WebSocket Getters 275 | 276 | function getChannels() { 277 | 278 | const channels = []; 279 | 280 | for (const [channelId, channelMap] of websocketChannelMap.entries()) { 281 | let webSocketState = channelMap.websocket.readyState; 282 | channels.push(new PiWebSocketChannel(channelId, webSocketState, channelMap.piPoints)); 283 | } 284 | 285 | return channels; 286 | } 287 | 288 | /** 289 | * Returns a representation of a (OSI Pi WebSocket / StreamSet) Channel with matching websocket state. 290 | * 291 | * Here, a Channel is a mapping of WebSocket and allocated PiPoints in following format: 292 | * channel: { "websocket": webSocket, "piPoints": channelGroupPiPoints } 293 | * channelGroupPiPoints: [{ "webid": piPointWebId, "path": piPointPath }] 294 | * 295 | * @param {*} piPointWebId 296 | * @returns 297 | */ 298 | function getChannelByWebsocketState(websocketState) { 299 | 300 | const channels = []; 301 | 302 | // Convert human readable state to WS integer state 303 | const websocketStateInt = websocketReadyStateMap[websocketState.toLowerCase()]; 304 | 305 | for (const [channelId, channelMap] of websocketChannelMap.entries()) { 306 | 307 | const currentState = channelMap.websocket.readyState; 308 | 309 | if (currentState === websocketStateInt) { 310 | channels.push(new PiWebSocketChannel(channelId, currentState, channelMap.piPoints)); 311 | } 312 | } 313 | 314 | return channels; 315 | } 316 | 317 | /** 318 | * Returns a representation of a (OSI Pi WebSocket / StreamSet) Channel with matching Channel ID. 319 | * 320 | * Errors if no matching ChannelId found. 321 | * 322 | * Here, a Channel is a mapping of WebSocket and allocated PiPoints in following format: 323 | * channel: { "websocket": webSocket, "piPoints": channelGroupPiPoints } 324 | * channelGroupPiPoints: [{ "webid": piPointWebId, "path": piPointPath }] 325 | * 326 | * @param {*} piPointWebId 327 | * @returns 328 | */ 329 | function getChannelByChannelId(channelId) { 330 | 331 | if (!websocketChannelMap.has(channelId)) { 332 | throw new Error(`Channel ID ${channelId} does not exist.`); 333 | } 334 | 335 | let channelMap = websocketChannelMap.get(channelId); 336 | let webSocketState = channelMap.websocket.readyState; 337 | return new PiWebSocketChannel(channelId, webSocketState, channelMap.piPoints); 338 | 339 | } 340 | 341 | /** 342 | * Returns a representation of a (OSI Pi Websocket / StreamSet) Channel that 343 | * is configured to stream data from the given PiPoint WebId. 344 | * 345 | * Returns false if no matching PiPoint WebId found. 346 | * 347 | * Here, a Channel is a mapping of WebSocket and allocated PiPoints in following format: 348 | * channel: { "websocket": webSocket, "piPoints": channelGroupPiPoints } 349 | * channelGroupPiPoints: [{ "webid": piPointWebId, "path": piPointPath }] 350 | * 351 | * @param {*} piPointWebId 352 | * @returns 353 | */ 354 | function getChannelByPiPointWebId(piPointWebId) { 355 | 356 | // Iterate through all channels and associated PiPoints and return Channel representation if match found. 357 | for (const [channelId, channelMap] of websocketChannelMap.entries()) { 358 | for (const piPoints of channelMap.piPoints) { 359 | if (piPointWebId == piPoints.webid) { 360 | let webSocketState = channelMap.websocket.readyState; 361 | return new PiWebSocketChannel(channelId, webSocketState, channelMap.piPoints); 362 | } 363 | } 364 | } 365 | 366 | // Return false if not found. 367 | return false; 368 | } 369 | 370 | /** 371 | * Returns a representation of a (OSI Pi WebSocket / StreamSet) Channel that 372 | * is configured to stream data from the given PiPoint Path. 373 | * 374 | * Returns false if no matching PiPoint path found. 375 | * 376 | * Here, a Channel is a mapping of WebSocket and allocated PiPoints in following format: 377 | * channel: { "websocket": webSocket, "piPoints": channelGroupPiPoints } 378 | * channelGroupPiPoints: [{ "webid": piPointWebId, "path": piPointPath }] 379 | * 380 | * @param {*} piPointPath 381 | * @returns 382 | */ 383 | function getChannelByPiPointPath(piPointPath) { 384 | 385 | // Iterate through all channels and associated PiPoints and return Channel representation if match found. 386 | for (const [channelId, channelMap] of websocketChannelMap.entries()) { 387 | for (const piPoints of channelMap.piPoints) { 388 | if (piPointPath == piPoints.path) { 389 | const webSocketState = channelMap.websocket.readyState; 390 | return new PiWebSocketChannel(channelId, webSocketState, channelMap.piPoints); 391 | } 392 | } 393 | } 394 | 395 | // Return false if not found. 396 | return false; 397 | } 398 | 399 | /** 400 | * Returns the representation of an array of (OSI Pi Websocket / StreamSet) Channels that 401 | * are configured to stream data from any PiPoint Path that matches the given piPointPathRegex. 402 | * 403 | * @param {*} piPointPathRegex 404 | * @returns 405 | */ 406 | function getChannelsByPiPointPathRegex(piPointPathRegex) { 407 | 408 | const channels = {}; 409 | const regex = new RegExp(piPointPathRegex); 410 | 411 | // Iterate through all channels and associated PiPoints and return Channel representations is any regEx match found. 412 | for (const [channelId, channelMap] of websocketChannelMap.entries()) { 413 | for (const piPoints of channelMap.piPoints) { 414 | if (regex.test(piPoints.path)) { 415 | 416 | // Don't add the same channel multiple times (even though may have multiple matching PiPoint paths.) 417 | if (!(channelId in channels)) { 418 | 419 | let webSocketState = channelMap.websocket.readyState; 420 | channels[channelId] = new PiWebSocketChannel(channelId, webSocketState, channelMap.piPoints); 421 | 422 | } 423 | } 424 | } 425 | } 426 | 427 | return Object.values(channels); 428 | } 429 | 430 | function getChannelNumbersByPiPointPathRegex(piPointPathRegex) { 431 | let channels = getChannelsByPiPointPathRegex(piPointPathRegex); 432 | 433 | let channelCnt = channels.length; 434 | let piPointCnt = 0; 435 | 436 | for (const channel of channels) { 437 | piPointCnt += channel.getPiPoints().length; 438 | } 439 | 440 | return { 441 | "channels": channelCnt, 442 | "piPoints": piPointCnt 443 | }; 444 | } 445 | 446 | // Close Channel / WebSocket Functions 447 | 448 | /** 449 | * Closes the WebSocket of all channels, leaves a reference to the closed channels 450 | * on the system so they can be re-opend with the same channelId and PiPoints. 451 | * 452 | * @returns 453 | */ 454 | function closeAllChannels() { 455 | 456 | let numChannels = 0, numPiPoints = 0; 457 | 458 | for (const [channelId, channelMap] of websocketChannelMap.entries()) { 459 | 460 | numChannels++; 461 | numPiPoints += channelMap.piPoints.length; 462 | closeChannelByChannelId(channelId); 463 | } 464 | 465 | return `Closed ${numChannels} WebSocket Channels and ${numPiPoints} PiPoints streaming data sessions.`; 466 | } 467 | 468 | /** 469 | * Closes the WebSocket channel with the given channelId. 470 | * 471 | * Errors if the channelId doesn't exist. 472 | * 473 | * @param {*} channelId 474 | * @returns 475 | */ 476 | function closeChannelByChannelId(channelId) { 477 | 478 | if (!websocketChannelMap.has(channelId)) { 479 | throw new Error(`Channel ID ${channelId} does not exist.`); 480 | } 481 | 482 | // Gets channel object or error if doesn't exist. 483 | const channelMap = websocketChannelMap.get(channelId); 484 | const numPiPoints = channelMap.piPoints.length; 485 | const webSocket = channelMap.websocket; 486 | 487 | // Set manuallyClosed so won't be automatically reopened if enabled. 488 | channelMap.manuallyClosed = true; 489 | 490 | // close the WebSocket 491 | webSocket.close(); 492 | 493 | return `Closed OSI Pi WebSocket Channel ${channelId} - ${numPiPoints} associated PiPoints removed.`; 494 | } 495 | 496 | /** 497 | * Closes the WebSocket channel containing the PiPoint with the given WebId. 498 | * Leaves a reference to the closed channels on the system so they can be 499 | * re-opend with the same channelId and PiPoints. 500 | * 501 | * Note: This closes the WebSocket and effects all PiPoints associated with this channel. 502 | * 503 | * @param {*} channelId 504 | * @returns 505 | */ 506 | function closeChannelByPiPointWebId(piPointWebId) { 507 | 508 | // Get channel 509 | let channel = getChannelByPiPointWebId(piPointWebId); 510 | 511 | if (!channel) { 512 | throw new Error(`Request to close Channel By PiPoint WebId ${piPointWebId} failed. Channel ID does not exist.`); 513 | } 514 | 515 | return closeChannelByChannelId(channel.getChannelId()); 516 | } 517 | 518 | /** 519 | * Closes any WebSocket channels containing PiPoint paths that match the given piPointPathRegex. 520 | * Note: This closes the WebSocket and affects all PiPoints associated with this channel. 521 | * 522 | * 523 | * @param {*} channelId 524 | * @returns 525 | */ 526 | function closeChannelsByPiPointPathRegEx(piPointPathRegex) { 527 | 528 | let channels = getChannelsByPiPointPathRegex(piPointPathRegex); 529 | 530 | let channelCnt = channels.length; 531 | let piPointCnt = 0; 532 | 533 | for (const channel of channels) { 534 | piPointCnt += channel.getPiPoints().length; 535 | closeChannelByChannelId(channel.getChannelId()); 536 | } 537 | 538 | return `Closed ${channelCnt} OSI Pi WebSocket Channel/s - ${piPointCnt} associated PiPoints removed.`; 539 | 540 | } 541 | 542 | // Re/Open Channel / WebSocket Functions 543 | async function openChannelByChannelId(channelId) { 544 | 545 | try { 546 | 547 | // Validate channel exists. 548 | if (!websocketChannelMap.has(channelId)) { 549 | throw new Error(`Channel ID ${channelId} does not exist.`); 550 | } 551 | 552 | // Gets channel object 553 | const channelMap = websocketChannelMap.get(channelId); 554 | 555 | // Remove the manuallyClosed param if set from a previous close function 556 | channelMap.manuallyClosed = null; 557 | 558 | // Re-init the channels WebSocket with given channel PiPoints. 559 | channelMap.websocket = await _createWebSocket(channelId, channelMap.piPoints); 560 | 561 | return `Open OSI Pi WebSocket Channel ${channelId} request complete - monitor websocket-state change topics for status.`; 562 | 563 | } catch (err) { 564 | // This if the WebSocket failed to initiate all together such as when the local OS won't accept any 565 | // more TCP connections. The WebSocket won't be added to the initiated list so is effectively deleted from the queue. 566 | onWebsocketChangedState("failed-deleted", channelId, err.toString()); 567 | } 568 | } 569 | 570 | async function openAllClosedChannels() { 571 | 572 | // Get all closed channels 573 | let closedChannels = getChannelByWebsocketState("closed"); 574 | 575 | let piPointCnt = 0; 576 | for (const channel of closedChannels) { 577 | 578 | // Opens Channel WebSocket 579 | await openChannelByChannelId(channel.getChannelId()); 580 | piPointCnt += channel.getNumberPiPoints(); 581 | } 582 | 583 | return `Open OSI Pi WebSockets for ${closedChannels.length} Channels with ${piPointCnt} total PiPoints request complete - monitor websocket-state change topics for status.`; 584 | } 585 | 586 | // Delete Channel / WebSocket Functions 587 | 588 | /** 589 | * Closeds and deletes all Websocket channels on the system. 590 | * @returns 591 | */ 592 | function deleteAllChannels() { 593 | 594 | let numChannels = 0, numPiPoints = 0; 595 | 596 | // close and delete the channel 597 | for (const [channelId, channelMap] of websocketChannelMap.entries()) { 598 | 599 | // Close the channel 600 | numChannels++; 601 | numPiPoints += channelMap.piPoints.length; 602 | closeChannelByChannelId(channelId); 603 | 604 | // Delete the channel from the channel list. 605 | websocketChannelMap.delete(channelId); 606 | } 607 | 608 | return `Closed and Deleted ${numChannels} WebSocket Channels and ${numPiPoints} PiPoints streaming data sessions.`; 609 | } 610 | 611 | /** 612 | * Closes and Deletes the WebSocket channel with the given channelId. 613 | * 614 | * Errors if the channelId doesn't exist. 615 | * 616 | * @param {*} channelId 617 | * @returns 618 | */ 619 | function deleteChannelByChannelId(channelId) { 620 | 621 | // Close the channel 622 | let closeResponse = closeChannelByChannelId(channelId); 623 | 624 | // Delete the channel from the channel list. 625 | websocketChannelMap.delete(channelId); 626 | 627 | return `Closed and Deleted and ${closeResponse}`; 628 | } 629 | 630 | /** 631 | * Closes and Deletes any WebSocket channels containing PiPoint paths that match the given piPointPathRegex. 632 | * Note: This closes the WebSocket and affects all PiPoints associated with this channel. 633 | * 634 | * 635 | * @param {*} channelId 636 | * @returns 637 | */ 638 | function deleteChannelsByPiPointPathRegEx(piPointPathRegex) { 639 | 640 | let channels = getChannelsByPiPointPathRegex(piPointPathRegex); 641 | 642 | let channelCnt = channels.length; 643 | let piPointCnt = 0; 644 | 645 | for (const channel of channels) { 646 | piPointCnt += channel.getPiPoints().length; 647 | deleteChannelByChannelId(channel.getChannelId()); 648 | } 649 | 650 | return `Closed ${channelCnt} OSI Pi WebSocket Channel/s - ${piPointCnt} associated PiPoints removed.`; 651 | 652 | } 653 | 654 | 655 | module.exports = { 656 | webSocketManagerUpdateConfig, 657 | queueStreamPiPointRequest, 658 | clearQueueStreamPiPointRequest, 659 | activateQueuedPiPointsForStreaming, 660 | getQueuedPiPoints, 661 | getNumberQueuedPiPoints, 662 | getChannels, 663 | getChannelByWebsocketState, 664 | getChannelByChannelId, 665 | getChannelByPiPointWebId, 666 | getChannelByPiPointPath, 667 | getChannelsByPiPointPathRegex, 668 | getChannelNumbersByPiPointPathRegex, 669 | closeAllChannels, 670 | closeChannelByChannelId, 671 | closeChannelByPiPointWebId, 672 | closeChannelsByPiPointPathRegEx, 673 | openChannelByChannelId, 674 | openAllClosedChannels, 675 | deleteAllChannels, 676 | deleteChannelByChannelId, 677 | deleteChannelsByPiPointPathRegEx 678 | }; 679 | -------------------------------------------------------------------------------- /src/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "com.amazon.osi-pi-streaming-data-connector", 3 | "version": "1.0.0", 4 | "description": "AWS IoT Greengrass managed edge connector to ingest real time OSI Pi data over Websockets into AWS IoT Sitewise.", 5 | "author": "Dean Colcott ", 6 | "license": "Apache-2.0", 7 | "main": "src/index.js", 8 | "scripts": { 9 | "test": "echo \"Error: no test specified\" && exit 1", 10 | "start": "NODE_ENV=production NODE_OPTIONS=--max-old-space-size=1024 DEBUG=osi-pi-streaming-data-connector:* node --dns-result-order=ipv4first --use_strict ./index.js", 11 | "start-trace-dev": "NODE_ENV=development NODE_OPTIONS=--max-old-space-size=1024 DEBUG=osi-pi-streaming-data-connector:* node --dns-result-order=ipv4first --use_strict --trace_gc ./index.js", 12 | "lint": "./node_modules/.bin/eslint './**/*.js'", 13 | "lint-fix": "./node_modules/.bin/eslint './**/*.js' --fix && ./node_modules/.bin/eslint './**/*.js'" 14 | }, 15 | "keywords": [ 16 | "OSI", 17 | "Pi", 18 | "PiPoint", 19 | "WebSocket", 20 | "Channel", 21 | "AWS", 22 | "IoT", 23 | "Greengrass", 24 | "Sitewise", 25 | "Data", 26 | "Analytics" 27 | ], 28 | "devDependencies": { 29 | "eslint": "^8.39.0", 30 | "eslint-config-standard": "^17.0.0", 31 | "eslint-plugin-import": "^2.27.5", 32 | "eslint-plugin-n": "^15.7.0", 33 | "eslint-plugin-promise": "^6.1.1" 34 | }, 35 | "dependencies": { 36 | "@aws-sdk/client-iotsitewise": "^3.326.0", 37 | "@aws-sdk/client-secrets-manager": "^3.328.0", 38 | "@aws-sdk/util-utf8-browser": "^3.109.0", 39 | "aws-iot-device-sdk-v2": "^1.13.0", 40 | "axios": "^1.3.5", 41 | "axios-debug-log": "^1.0.0", 42 | "axios-request-throttle": "^1.0.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/recipe.json: -------------------------------------------------------------------------------- 1 | { 2 | "RecipeFormatVersion": "2020-01-25", 3 | "ComponentName": "com.amazon.osi-pi-streaming-data-connector", 4 | "ComponentVersion": "0.0.7", 5 | "ComponentDescription": "AWS IoT Greengrass managed edge data connector to stream OSI Pi on-change data over WebSockets to AWS IoT Sitewise.", 6 | "ComponentPublisher": "Amazon", 7 | "ComponentConfiguration": { 8 | "DefaultConfiguration": { 9 | "accessControl": { 10 | "aws.greengrass.ipc.mqttproxy": { 11 | "com.amazon.osi-pi-streaming-data-connector:mqttproxy:1": { 12 | "policyDescription": "Allows access to all MQTT Topics - update as / if needed", 13 | "operations": [ 14 | "*" 15 | ], 16 | "resources": [ 17 | "*" 18 | ] 19 | } 20 | } 21 | } 22 | } 23 | }, 24 | "ComponentDependencies": { 25 | "aws.greengrass.TokenExchangeService": { 26 | "VersionRequirement": "^2.0.3", 27 | "DependencyType": "HARD" 28 | } 29 | }, 30 | "Manifests": [ 31 | { 32 | "Platform": { 33 | "os": "linux" 34 | }, 35 | "Artifacts": [ 36 | { 37 | "URI": "s3://aws-greengrass-components/src.zip", 38 | "Unarchive": "ZIP" 39 | } 40 | ], 41 | "Lifecycle": { 42 | "Install": "npm install --omit=dev --prefix {artifacts:decompressedPath}/src/", 43 | "Run": "npm run start --prefix {artifacts:decompressedPath}/src/", 44 | "RequiresPrivilege": "false" 45 | } 46 | } 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /src/routes/pubsubControlRoutes.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0. 4 | * 5 | * Provides mapping from MQTT Route (command) field to calling function across all 6 | * AWS OSI Pi Integration Library components. 7 | * 8 | * @author Dean Colcott 9 | */ 10 | 11 | const { componentShortName } = require("../configs/componentName"); 12 | 13 | const componentRoutes = { 14 | actionRoute : `osi-pi-${componentShortName}-action`, 15 | errorRoute : `osi-pi-${componentShortName}-error` 16 | }; 17 | 18 | // Pubsub Controller control Routes 19 | const pubsubControllerRoutes = { 20 | actionRoute : "aws-pubsub-controller-action", 21 | errorRoute : "aws-pubsub-controller-error" 22 | }; 23 | 24 | // IoT Shadow Controller control Routes 25 | const iotShadowControllerRoutes = { 26 | actionRoute : "aws-iot-shadow-controller-action", 27 | errorRoute : "aws-iot-shadow-controller-error" 28 | }; 29 | 30 | // Secrets Manager Controller Control Routes 31 | const secretManagerControllerRoutes = { 32 | actionRoute : "aws-secerets-manager-controller-action", 33 | errorRoute : "aws-secerets-manager-controller-error" 34 | }; 35 | 36 | // OSI Pi SDK Controller Control Routes 37 | const osiPiSdkControllerRoutes = { 38 | actionRoute : "osi-pi-sdk-controller-action", 39 | errorRoute : "osi-pi-sdk-controller-error" 40 | }; 41 | 42 | // System Telemetry Controller Control Routes 43 | const systemTelemetryControllerRoutes = { 44 | actionRoute : "system-telemetry-controller-action", 45 | errorRoute : "system-telemetry-controller-error", 46 | systemTelemetryRoute : "system-telemetry-update" 47 | }; 48 | 49 | module.exports = { 50 | componentRoutes, 51 | pubsubControllerRoutes, 52 | iotShadowControllerRoutes, 53 | secretManagerControllerRoutes, 54 | osiPiSdkControllerRoutes, 55 | systemTelemetryControllerRoutes 56 | }; 57 | -------------------------------------------------------------------------------- /src/routes/pubsubFunctionRoutes.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 3 | * SPDX-License-Identifier: Apache-2.0. 4 | * 5 | * Provides mapping from MQTT Route (command) field to calling function across all 6 | * AWS OSI Pi Integration Library components. 7 | * 8 | * @author Dean Colcott 9 | */ 10 | 11 | const osiPiSdkControllerRouteMap = [ 12 | 13 | // Pi Asset Server Functions. 14 | "publish-pi-root-path", 15 | 16 | // Pi Asset Server Functions. 17 | "publish-pi-asset-servers", 18 | "publish-pi-asset-server-by-param", 19 | 20 | // Pi Asset Database Functions. 21 | "publish-pi-asset-database-by-param", 22 | "publish-pi-asset-databases-by-asset-server-webid", 23 | 24 | // Pi Asset Elements Functions. 25 | "publish-pi-asset-element-by-param", 26 | "publish-pi-asset-elements-by-query", 27 | "publish-number-asset-elements-by-query", 28 | "publish-pi-asset-elements-by-asset-database-webid", 29 | "publish-pi-asset-element-children-by-webid", 30 | 31 | // Pi Asset Element Attribute Functions. 32 | "publish-pi-attribute-by-param", 33 | "publish-pi-attribute-by-element-webid", 34 | 35 | // Pi Asset Template Functions. 36 | "publish-pi-element-template-by-param", 37 | "publish-pi-element-templates-by-asset-database-webid", 38 | 39 | // Pi Asset Template Attribute Functions. 40 | "publish-pi-template-attribute-by-param", 41 | "publish-pi-template-attributes-by-template-webid", 42 | 43 | // Pi Data Archive / Data Server Functions. 44 | "publish-pi-data-servers", 45 | "publish-pi-data-server-by-param", 46 | 47 | // Pi Data Archive / Pi (data) Points Functions. 48 | "publish-pi-points-by-query", 49 | "publish-number-pi-points-by-query" 50 | ]; 51 | 52 | const osiPiStreamingDataControllerRouteMap = [ 53 | 54 | // Stream / Queue Pi Points for data streaming over WebSockets 55 | "queue-pi-points-for-streaming-by-query", 56 | "publish-queued-pi-points", 57 | "publish-number-queued-pi-points", 58 | "clear-queued-pi-points-for-streaming", 59 | "activate-queued-pi-points-for-streaming", 60 | 61 | // Publish OSI PiPoint WebSocket Manager Channel Functions. 62 | "publish-channels", 63 | "publish-channel-stats", 64 | "publish-channels-by-state", 65 | "publish-channel-by-channel-id", 66 | "publish-channel-by-pi-point-webid", 67 | "publish-channel-by-pi-point-path", 68 | "publish-channels-by-pi-point-path-regex", 69 | "publish-all-channel-numbers", 70 | "publish-channel-numbers-by-pi-point-path-regex", 71 | 72 | // Close WebSocket Channels 73 | "close-all-channels", 74 | "close-channel-by-channel-id", 75 | "close-channel-by-pi-point-webid", 76 | "close-channels-by-pi-point-path-regex", 77 | 78 | // Open WebSocket Channels 79 | "open-channel-by-channel-id", 80 | "open-all-closed-channels", 81 | 82 | // Close and Delete reference to WebSocket Channels 83 | "delete-all-channels", 84 | "delete-channel-by-channel-id", 85 | "delete-channels-by-pi-point-path-regex", 86 | 87 | // Delete / Clear Pi Data points from buffer 88 | "delete-pi-data-buffer-queue" 89 | ]; 90 | 91 | const osiPiPointDataWriterRouteMap = [ 92 | 93 | // Create / Write / Update Pi Data Points. 94 | "create-pi-point", 95 | "write-pi-point" 96 | ]; 97 | 98 | module.exports = { 99 | osiPiSdkControllerRouteMap, 100 | osiPiStreamingDataControllerRouteMap, 101 | osiPiPointDataWriterRouteMap 102 | }; 103 | 104 | --------------------------------------------------------------------------------