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