├── .dockerignore ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ ├── build.yml │ ├── projects.yml │ └── release.yml ├── .gitignore ├── .npmignore ├── .prettierrc.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── docker-compose.yml ├── example ├── multiple-things.js ├── package.json ├── platform │ ├── Makefile │ ├── adc │ │ ├── adc-property.js │ │ └── index.js │ ├── board │ │ ├── artik1020.js │ │ ├── artik530.js │ │ ├── edison.js │ │ ├── flex-phat.js │ │ ├── play-phat.js │ │ └── traffic-phat.js │ ├── gpio │ │ └── gpio-property.js │ ├── index.js │ ├── package-lock.json │ ├── package.json │ └── pwm │ │ └── pwm-property.js ├── simplest-thing.js └── single-thing.js ├── package-lock.json ├── package.json ├── src ├── action.ts ├── event.ts ├── index.ts ├── property.ts ├── server.ts ├── thing.ts ├── types.ts ├── utils.ts ├── value.ts └── webthing.ts ├── test.sh └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .git/ 3 | #.gitignore contents: 4 | *~ 5 | *.swp 6 | *.tgz 7 | node_modules/ 8 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/node_modules/ 2 | /.eslintrc.js 3 | tmp/ 4 | lib/*.js 5 | lib/*.d.ts 6 | index.js 7 | index.d.ts 8 | webthing.js 9 | webthing.d.ts 10 | *.eslintrc.js 11 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'env': { 3 | 'browser': true, 4 | 'commonjs': true, 5 | 'es6': true, 6 | 'jasmine': true, 7 | 'jest': true, 8 | 'mocha': true, 9 | 'node': true 10 | }, 11 | 'extends': [ 12 | 'eslint:recommended', 13 | 'plugin:@typescript-eslint/eslint-recommended', 14 | 'plugin:@typescript-eslint/recommended', 15 | 'prettier', 16 | 'prettier/@typescript-eslint' 17 | ], 18 | 'parser': '@typescript-eslint/parser', 19 | 'parserOptions': { 20 | 'sourceType': 'module' 21 | }, 22 | 'plugins': [ 23 | '@typescript-eslint' 24 | ], 25 | 'rules': { 26 | 'arrow-parens': [ 27 | 'error', 28 | 'always' 29 | ], 30 | 'arrow-spacing': 'error', 31 | 'block-scoped-var': 'error', 32 | 'block-spacing': [ 33 | 'error', 34 | 'always' 35 | ], 36 | '@typescript-eslint/brace-style': [ 37 | 'error', 38 | '1tbs' 39 | ], 40 | '@typescript-eslint/comma-dangle': [ 41 | 'error', 42 | 'always-multiline' 43 | ], 44 | '@typescript-eslint/comma-spacing': 'error', 45 | 'comma-style': [ 46 | 'error', 47 | 'last' 48 | ], 49 | 'computed-property-spacing': [ 50 | 'error', 51 | 'never' 52 | ], 53 | 'curly': 'error', 54 | '@typescript-eslint/default-param-last': 'error', 55 | 'dot-notation': 'error', 56 | 'eol-last': 'error', 57 | '@typescript-eslint/explicit-module-boundary-types': [ 58 | 'warn', 59 | { 60 | 'allowArgumentsExplicitlyTypedAsAny': true 61 | } 62 | ], 63 | '@typescript-eslint/explicit-function-return-type': [ 64 | 'error', 65 | { 66 | 'allowExpressions': true 67 | } 68 | ], 69 | '@typescript-eslint/func-call-spacing': [ 70 | 'error', 71 | 'never' 72 | ], 73 | '@typescript-eslint/indent': [ 74 | 'error', 75 | 2, 76 | { 77 | 'ArrayExpression': 'first', 78 | 'CallExpression': { 79 | 'arguments': 'first' 80 | }, 81 | 'FunctionDeclaration': { 82 | 'parameters': 'first' 83 | }, 84 | 'FunctionExpression': { 85 | 'parameters': 'first' 86 | }, 87 | 'ObjectExpression': 'first', 88 | 'SwitchCase': 1 89 | } 90 | ], 91 | 'key-spacing': [ 92 | 'error', 93 | { 94 | 'afterColon': true, 95 | 'beforeColon': false, 96 | 'mode': 'strict' 97 | } 98 | ], 99 | '@typescript-eslint/keyword-spacing': 'off', 100 | 'linebreak-style': [ 101 | 'error', 102 | 'unix' 103 | ], 104 | '@typescript-eslint/lines-between-class-members': [ 105 | 'error', 106 | 'always' 107 | ], 108 | 'max-len': [ 109 | 'error', 110 | 100 111 | ], 112 | '@typescript-eslint/member-delimiter-style': [ 113 | 'error', 114 | { 115 | 'singleline': { 116 | 'delimiter': 'semi', 117 | 'requireLast': false 118 | }, 119 | 'multiline': { 120 | 'delimiter': 'semi', 121 | 'requireLast': true 122 | } 123 | } 124 | ], 125 | 'multiline-ternary': [ 126 | 'error', 127 | 'always-multiline' 128 | ], 129 | 'no-console': 0, 130 | '@typescript-eslint/no-duplicate-imports': 'error', 131 | 'no-eval': 'error', 132 | '@typescript-eslint/no-explicit-any': [ 133 | 'error', 134 | { 135 | 'ignoreRestArgs': true 136 | } 137 | ], 138 | 'no-floating-decimal': 'error', 139 | 'no-implicit-globals': 'error', 140 | 'no-implied-eval': 'error', 141 | 'no-lonely-if': 'error', 142 | 'no-multi-spaces': [ 143 | 'error', 144 | { 145 | 'ignoreEOLComments': true 146 | } 147 | ], 148 | 'no-multiple-empty-lines': 'error', 149 | '@typescript-eslint/no-namespace': [ 150 | 'error', 151 | { 152 | 'allowDeclarations': true 153 | } 154 | ], 155 | '@typescript-eslint/no-non-null-assertion': 'off', 156 | 'no-prototype-builtins': 'off', 157 | 'no-return-assign': 'error', 158 | 'no-script-url': 'error', 159 | 'no-self-compare': 'error', 160 | 'no-sequences': 'error', 161 | 'no-shadow-restricted-names': 'error', 162 | 'no-tabs': 'error', 163 | 'no-throw-literal': 'error', 164 | 'no-trailing-spaces': 'error', 165 | 'no-undefined': 'error', 166 | 'no-unmodified-loop-condition': 'error', 167 | '@typescript-eslint/no-unused-vars': [ 168 | 'error', 169 | { 170 | 'argsIgnorePattern': '^_', 171 | 'varsIgnorePattern': '^_' 172 | } 173 | ], 174 | 'no-useless-computed-key': 'error', 175 | 'no-useless-concat': 'error', 176 | '@typescript-eslint/no-useless-constructor': 'error', 177 | 'no-useless-return': 'error', 178 | 'no-var': 'error', 179 | 'no-void': 'error', 180 | 'no-whitespace-before-property': 'error', 181 | 'object-curly-newline': [ 182 | 'error', 183 | { 184 | 'consistent': true 185 | } 186 | ], 187 | 'object-curly-spacing': [ 188 | 'error', 189 | 'always' 190 | ], 191 | 'object-property-newline': [ 192 | 'error', 193 | { 194 | 'allowMultiplePropertiesPerLine': true 195 | } 196 | ], 197 | 'operator-linebreak': [ 198 | 'error', 199 | 'after', 200 | { 201 | 'overrides': { 202 | '?': 'before', 203 | ':': 'before' 204 | } 205 | } 206 | ], 207 | 'padded-blocks': [ 208 | 'error', 209 | { 210 | 'blocks': 'never' 211 | } 212 | ], 213 | 'prefer-const': 'error', 214 | '@typescript-eslint/prefer-for-of': 'error', 215 | 'prefer-template': 'error', 216 | 'quote-props': [ 217 | 'error', 218 | 'as-needed' 219 | ], 220 | '@typescript-eslint/quotes': [ 221 | 'error', 222 | 'single', 223 | { 224 | 'allowTemplateLiterals': true 225 | } 226 | ], 227 | '@typescript-eslint/semi': [ 228 | 'error', 229 | 'always' 230 | ], 231 | 'semi-spacing': [ 232 | 'error', 233 | { 234 | 'after': true, 235 | 'before': false 236 | } 237 | ], 238 | 'semi-style': [ 239 | 'error', 240 | 'last' 241 | ], 242 | 'space-before-blocks': [ 243 | 'error', 244 | 'always' 245 | ], 246 | '@typescript-eslint/space-before-function-paren': [ 247 | 'error', 248 | { 249 | 'anonymous': 'always', 250 | 'asyncArrow': 'always', 251 | 'named': 'never' 252 | } 253 | ], 254 | 'space-in-parens': [ 255 | 'error', 256 | 'never' 257 | ], 258 | '@typescript-eslint/space-infix-ops': 'error', 259 | 'space-unary-ops': [ 260 | 'error', 261 | { 262 | 'nonwords': false, 263 | 'words': true 264 | } 265 | ], 266 | 'spaced-comment': [ 267 | 'error', 268 | 'always', 269 | { 270 | 'block': { 271 | 'balanced': true, 272 | 'exceptions': [ 273 | '*' 274 | ] 275 | } 276 | } 277 | ], 278 | 'switch-colon-spacing': [ 279 | 'error', 280 | { 281 | 'after': true, 282 | 'before': false 283 | } 284 | ], 285 | 'template-curly-spacing': [ 286 | 'error', 287 | 'never' 288 | ], 289 | '@typescript-eslint/type-annotation-spacing': 'error', 290 | 'yoda': 'error' 291 | }, 292 | 'overrides': [ 293 | { 294 | 'files': [ 295 | 'example/**/*.js' 296 | ], 297 | 'rules': { 298 | '@typescript-eslint/explicit-function-return-type': 'off', 299 | '@typescript-eslint/no-var-requires': 'off' 300 | } 301 | } 302 | ] 303 | }; 304 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | push: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | node-version: [ 17 | 10, 18 | 12, 19 | 14, 20 | ] 21 | steps: 22 | - uses: actions/checkout@v2 23 | - uses: actions/setup-python@v2 24 | with: 25 | python-version: '3.9' 26 | - uses: actions/setup-node@v1 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | - name: Install dependencies 30 | run: | 31 | npm ci 32 | - name: Check formatting 33 | run: | 34 | npx prettier -c 'src/*.ts' 'example/**/*.{js,ts}' 35 | - name: Lint with eslint 36 | run: | 37 | npm run lint 38 | - name: Transpile ts files 39 | run: | 40 | npm run build 41 | - name: Run integration tests 42 | run: | 43 | ./test.sh 44 | -------------------------------------------------------------------------------- /.github/workflows/projects.yml: -------------------------------------------------------------------------------- 1 | name: Add new issues to the specified project column 2 | 3 | on: 4 | issues: 5 | types: [opened] 6 | 7 | jobs: 8 | add-new-issues-to-project-column: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: add-new-issues-to-organization-based-project-column 12 | uses: docker://takanabe/github-actions-automate-projects:v0.0.1 13 | env: 14 | GITHUB_TOKEN: ${{ secrets.CI_TOKEN }} 15 | GITHUB_PROJECT_URL: https://github.com/orgs/WebThingsIO/projects/4 16 | GITHUB_PROJECT_COLUMN_NAME: To do 17 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v[0-9]+.[0-9]+.[0-9]+ 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | 14 | - uses: actions/setup-node@v1 15 | with: 16 | node-version: 12 17 | registry-url: 'https://registry.npmjs.org' 18 | 19 | - name: Set release version 20 | run: echo "RELEASE_VERSION=${GITHUB_REF:11}" >> $GITHUB_ENV 21 | 22 | - name: Create Release 23 | id: create_release 24 | uses: actions/create-release@v1.0.0 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | with: 28 | tag_name: ${{ github.ref }} 29 | release_name: Release ${{ env.RELEASE_VERSION }} 30 | draft: false 31 | prerelease: false 32 | 33 | - name: Build project 34 | run: | 35 | npm ci 36 | npm run lint 37 | npm run build 38 | env: 39 | CI: true 40 | 41 | - name: Publish to npm 42 | run: npm publish 43 | env: 44 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/iotjs_modules 2 | **/node_modules 3 | *.swp 4 | *.tgz 5 | *~ 6 | .#* 7 | index.d.ts 8 | index.d.ts.map 9 | index.js 10 | index.js.map 11 | lib/*.js 12 | lib/*.js.map 13 | lib/*.d.ts 14 | lib/*.d.ts.map 15 | npm-debug.log 16 | tmp/ 17 | webthing.d.ts 18 | webthing.d.ts.map 19 | webthing.js 20 | webthing.js.map 21 | example/package-lock.json 22 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .eslintrc.js 2 | test-server.js 3 | *.ts 4 | !*.d.ts 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # webthing Changelog 2 | 3 | ## [Unreleased] 4 | 5 | ## [0.15.0] - 2021-01-05 6 | ### Added 7 | - Parameter to disable host validation in server. 8 | 9 | ## [0.14.0] - 2020-12-14 10 | ### Changed 11 | - Converted project to TypeScript. 12 | 13 | ## [0.13.1] - 2020-11-28 14 | ### Fixed 15 | - Test fixes. 16 | 17 | ## [0.13.0] - 2020-09-23 18 | ### Changed 19 | - Update author and URLs to indicate new project home. 20 | 21 | ## [0.12.3] - 2020-06-18 22 | ### Changed 23 | - mDNS record now indicates TLS support. 24 | 25 | ## [0.12.2] - 2020-05-04 26 | ### Changed 27 | - Invalid POST requests to action resources now generate an error status. 28 | 29 | ## [0.12.1] - 2020-03-27 30 | ### Changed 31 | - Updated dependencies. 32 | 33 | ## [0.12.0] - 2019-07-12 34 | ### Changed 35 | - Things now use `title` rather than `name`. 36 | - Things now require a unique ID in the form of a URI. 37 | ### Added 38 | - Support for `id`, `base`, `security`, and `securityDefinitions` keys in thing description. 39 | 40 | ## [0.11.1] - 2019-06-05 41 | ### Added 42 | - Ability to set a base URL path on server. 43 | 44 | ## [0.11.0] - 2019-01-16 45 | ### Changed 46 | - WebThingServer constructor can now take a list of additional API routes. 47 | ### Fixed 48 | - Properties could not include a custom `links` array at initialization. 49 | 50 | ## [0.10.0] - 2018-11-30 51 | ### Changed 52 | - Property, Action, and Event description now use `links` rather than `href`. - [Spec PR](https://github.com/WebThingsIO/wot/pull/119) 53 | 54 | [Unreleased]: https://github.com/WebThingsIO/webthing-node/compare/v0.15.0...HEAD 55 | [0.15.0]: https://github.com/WebThingsIO/webthing-node/compare/v0.14.0...v0.15.0 56 | [0.14.0]: https://github.com/WebThingsIO/webthing-node/compare/v0.13.1...v0.14.0 57 | [0.13.1]: https://github.com/WebThingsIO/webthing-node/compare/v0.13.0...v0.13.1 58 | [0.13.0]: https://github.com/WebThingsIO/webthing-node/compare/v0.12.3...v0.13.0 59 | [0.12.3]: https://github.com/WebThingsIO/webthing-node/compare/v0.12.2...v0.12.3 60 | [0.12.2]: https://github.com/WebThingsIO/webthing-node/compare/v0.12.1...v0.12.2 61 | [0.12.1]: https://github.com/WebThingsIO/webthing-node/compare/v0.12.0...v0.12.1 62 | [0.12.0]: https://github.com/WebThingsIO/webthing-node/compare/v0.11.1...v0.12.0 63 | [0.11.1]: https://github.com/WebThingsIO/webthing-node/compare/v0.11.0...v0.11.1 64 | [0.11.0]: https://github.com/WebThingsIO/webthing-node/compare/v0.10.0...v0.11.0 65 | [0.10.0]: https://github.com/WebThingsIO/webthing-node/compare/v0.9.1...v0.10.0 66 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Community Participation Guidelines 2 | 3 | This repository is governed by Mozilla's code of conduct and etiquette guidelines. 4 | For more details, please read the 5 | [Mozilla Community Participation Guidelines](https://www.mozilla.org/about/governance/policies/participation/). 6 | 7 | ## How to Report 8 | For more information on how to report violations of the Community Participation Guidelines, please read our '[How to Report](https://www.mozilla.org/about/governance/policies/participation/reporting/)' page. 9 | 10 | 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | #!/bin/echo docker build . -f 2 | # -*- coding: utf-8 -*- 3 | # SPDX-License-Identifier: MPL-2.0 4 | #{ 5 | # Copyright: 2018-present Samsung Electronics France SAS, and other contributors 6 | # 7 | # This Source Code Form is subject to the terms of the Mozilla Public 8 | # License, v. 2.0. If a copy of the MPL was not distributed with this 9 | # file, You can obtain one at http://mozilla.org/MPL/2.0/ . 10 | #} 11 | 12 | FROM node:10-buster 13 | LABEL maintainer="Philippe Coval (p.coval@samsung.com)" 14 | 15 | ENV DEBIAN_FRONTEND noninteractive 16 | ENV LC_ALL en_US.UTF-8 17 | ENV LANG ${LC_ALL} 18 | 19 | ENV project webthing-node 20 | COPY . /usr/local/${project}/${project} 21 | WORKDIR /usr/local/${project}/${project} 22 | RUN echo "#log: ${project}: Preparing sources" \ 23 | && set -x \ 24 | && which npm \ 25 | && npm --version \ 26 | && npm install \ 27 | && npm run test || echo "TODO: check package.json" \ 28 | && sync 29 | 30 | EXPOSE 8888 31 | WORKDIR /usr/local/${project}/${project} 32 | ENTRYPOINT [ "/usr/local/bin/npm", "run" ] 33 | CMD [ "start" ] 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | #!/bin/make -f 2 | # -*- makefile -*- 3 | # SPDX-License-Identifier: MPL-2.0 4 | #{ 5 | # Copyright 2018-present Samsung Electronics France SAS, and other contributors 6 | # 7 | # This Source Code Form is subject to the terms of the Mozilla Public 8 | # License, v. 2.0. If a copy of the MPL was not distributed with this 9 | # file, You can obtain one at http://mozilla.org/MPL/2.0/.* 10 | #} 11 | 12 | default: help all 13 | 14 | tmp_dir ?= tmp 15 | runtime ?= node 16 | export runtime 17 | eslint ?= node_modules/eslint/bin/eslint.js 18 | tsc ?= node_modules/typescript/bin/tsc 19 | srcs ?= $(wildcard *.ts lib/*.ts | sort | uniq) 20 | run_args ?= 21 | run_timeout ?= 10 22 | 23 | main_src ?= example/multiple-things.js 24 | NODE_PATH := .:${NODE_PATH} 25 | export NODE_PATH 26 | 27 | 28 | port?=8888 29 | url?=http://localhost:${port} 30 | 31 | help: 32 | @echo "## Usage: " 33 | @echo "# make start # To start default application" 34 | @echo "# make test # To test default application" 35 | 36 | all: build 37 | 38 | setup/%: 39 | ${@F} 40 | 41 | node_modules: package.json 42 | npm install 43 | 44 | modules: ${runtime}_modules 45 | ls $< 46 | 47 | package-lock.json: package.json 48 | rm -fv "$@" 49 | npm install 50 | ls "$@" 51 | 52 | setup/node: node_modules 53 | @echo "NODE_PATH=$${NODE_PATH}" 54 | node --version 55 | npm --version 56 | 57 | setup: setup/${runtime} 58 | 59 | build/%: setup 60 | @echo "log: $@: $^" 61 | 62 | build/node: setup node_modules eslint 63 | 64 | build: build/${runtime} 65 | 66 | run/%: ${main_src} build 67 | ${@F} $< ${run_args} 68 | 69 | run/npm: ${main_src} setup 70 | npm start 71 | 72 | run: run/${runtime} 73 | 74 | clean: 75 | rm -rf ${tmp_dir} 76 | 77 | cleanall: clean 78 | rm -f *~ 79 | 80 | distclean: cleanall 81 | rm -rf node_modules 82 | 83 | ${tmp_dir}/rule/test/pid/%: ${main_src} build modules 84 | @mkdir -p "${@D}" 85 | ${@F} $< & echo $$! > "$@" 86 | sleep ${run_timeout} 87 | cat $@ 88 | 89 | test/%: ${tmp_dir}/rule/test/pid/% 90 | cat $< 91 | curl ${url} || curl -I ${url} 92 | @echo "" 93 | curl --fail ${url}/0/properties 94 | @echo "" 95 | curl --fail ${url}/1/properties 96 | @echo "" 97 | kill $$(cat $<) ||: 98 | kill -9 $$(cat $<) ||: 99 | 100 | test/npm: package.json 101 | npm test 102 | 103 | test: test/${runtime} 104 | 105 | start: run 106 | 107 | start/board/%: example/platform/Makefile example/platform/board/%.js 108 | ${MAKE} -C ${ console.log('On-State is now', v)), 48 | { 49 | '@type': 'OnOffProperty', 50 | title: 'On/Off', 51 | type: 'boolean', 52 | description: 'Whether the lamp is turned on', 53 | })); 54 | ``` 55 | 56 | The **`brightness`** property reports the brightness level of the light and sets the level. Like before, instead of actually setting the level of a light, we just log the level. 57 | 58 | ```javascript 59 | light.addProperty( 60 | new Property( 61 | light, 62 | 'brightness', 63 | new Value(50, v => console.log('Brightness is now', v)), 64 | { 65 | '@type': 'BrightnessProperty', 66 | title: 'Brightness', 67 | type: 'number', 68 | description: 'The level of light from 0-100', 69 | minimum: 0, 70 | maximum: 100, 71 | unit: 'percent', 72 | })); 73 | ``` 74 | 75 | Now we can add our newly created thing to the server and start it: 76 | 77 | ```javascript 78 | // If adding more than one thing, use MultipleThings() with a name. 79 | // In the single thing case, the thing's name will be broadcast. 80 | const server = new WebThingServer(SingleThing(light), 8888); 81 | 82 | process.on('SIGINT', () => { 83 | server.stop().then(() => process.exit()).catch(() => process.exit()); 84 | }); 85 | 86 | server.start().catch(console.error); 87 | ``` 88 | 89 | This will start the server, making the light available via the WoT REST API and announcing it as a discoverable resource on your local network via mDNS. 90 | 91 | ## Sensor 92 | 93 | Let's now also connect a humidity sensor to the server we set up for our light. 94 | 95 | A [`MultiLevelSensor`](https://iot.mozilla.org/schemas/#MultiLevelSensor) (a sensor that returns a level instead of just on/off) has one required property (besides the name, type, and optional description): **`level`**. We want to monitor this property and get notified if the value changes. 96 | 97 | First we create a new Thing: 98 | 99 | ```javascript 100 | const sensor = new Thing('urn:dev:ops:my-humidity-sensor-1234', 101 | 'My Humidity Sensor', 102 | ['MultiLevelSensor'], 103 | 'A web connected humidity sensor'); 104 | ``` 105 | 106 | Then we create and add the appropriate property: 107 | * `level`: tells us what the sensor is actually reading 108 | * Contrary to the light, the value cannot be set via an API call, as it wouldn't make much sense, to SET what a sensor is reading. Therefore, we are creating a *readOnly* property. 109 | 110 | ```javascript 111 | const level = new Value(0.0); 112 | 113 | sensor.addProperty( 114 | new Property( 115 | sensor, 116 | 'level', 117 | level, 118 | { 119 | '@type': 'LevelProperty', 120 | title: 'Humidity', 121 | type: 'number', 122 | description: 'The current humidity in %', 123 | minimum: 0, 124 | maximum: 100, 125 | unit: 'percent', 126 | readOnly: true, 127 | })); 128 | ``` 129 | 130 | Now we have a sensor that constantly reports 0%. To make it usable, we need a thread or some kind of input when the sensor has a new reading available. For this purpose we start a thread that queries the physical sensor every few seconds. For our purposes, it just calls a fake method. 131 | 132 | ```javascript 133 | // Poll the sensor reading every 3 seconds 134 | setInterval(() => { 135 | // Update the underlying value, which in turn notifies all listeners 136 | level.notifyOfExternalUpdate(readFromGPIO()); 137 | }, 3000); 138 | ``` 139 | 140 | This will update our `Value` object with the sensor readings via the `this.level.notifyOfExternalUpdate(readFromGPIO());` call. The `Value` object now notifies the property and the thing that the value has changed, which in turn notifies all websocket listeners. 141 | 142 | # Adding to Gateway 143 | 144 | To add your web thing to the WebThings Gateway, install the "Web Thing" add-on and follow the instructions [here](https://github.com/WebThingsIO/thing-url-adapter#readme). 145 | 146 | # Resources 147 | 148 | * https://iot.mozilla.org/things/ 149 | * https://hacks.mozilla.org/2018/05/creating-web-things-with-python-node-js-and-java/ 150 | * https://nodejs.org/en/ 151 | * https://github.com/rzr/webthing-iotjs/wiki 152 | * https://youtu.be/Z-oiFl6gwGw 153 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # SPDX-License-Identifier: MPL-2.0 3 | #{ 4 | # Copyright: 2018-present Samsung Electronics France SAS, and other contributors 5 | # 6 | # This Source Code Form is subject to the terms of the Mozilla Public 7 | # License, v. 2.0. If a copy of the MPL was not distributed with this 8 | # file, You can obtain one at http://mozilla.org/MPL/2.0/ . 9 | #} 10 | 11 | version: "2" 12 | 13 | services: 14 | web: 15 | build: . 16 | command: start 17 | volumes: 18 | - /sys:/sys 19 | ports: 20 | - "8888:8888" 21 | network_mode: "host" 22 | -------------------------------------------------------------------------------- /example/multiple-things.js: -------------------------------------------------------------------------------- 1 | // -*- mode: js; js-indent-level:2; -*- 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | const { 5 | Action, 6 | Event, 7 | MultipleThings, 8 | Property, 9 | Thing, 10 | Value, 11 | WebThingServer, 12 | } = require('webthing'); 13 | const { v4: uuidv4 } = require('uuid'); 14 | 15 | class OverheatedEvent extends Event { 16 | constructor(thing, data) { 17 | super(thing, 'overheated', data); 18 | } 19 | } 20 | 21 | class FadeAction extends Action { 22 | constructor(thing, input) { 23 | super(uuidv4(), thing, 'fade', input); 24 | } 25 | 26 | performAction() { 27 | return new Promise((resolve) => { 28 | setTimeout(() => { 29 | this.thing.setProperty('brightness', this.input.brightness); 30 | this.thing.addEvent(new OverheatedEvent(this.thing, 102)); 31 | resolve(); 32 | }, this.input.duration); 33 | }); 34 | } 35 | } 36 | 37 | /** 38 | * A dimmable light that logs received commands to stdout. 39 | */ 40 | class ExampleDimmableLight extends Thing { 41 | constructor() { 42 | super('urn:dev:ops:my-lamp-1234', 'My Lamp', ['OnOffSwitch', 'Light'], 'A web connected lamp'); 43 | 44 | this.addProperty( 45 | new Property(this, 'on', new Value(true, (v) => console.log('On-State is now', v)), { 46 | '@type': 'OnOffProperty', 47 | title: 'On/Off', 48 | type: 'boolean', 49 | description: 'Whether the lamp is turned on', 50 | }) 51 | ); 52 | 53 | this.addProperty( 54 | new Property(this, 'brightness', new Value(50, (v) => console.log('Brightness is now', v)), { 55 | '@type': 'BrightnessProperty', 56 | title: 'Brightness', 57 | type: 'integer', 58 | description: 'The level of light from 0-100', 59 | minimum: 0, 60 | maximum: 100, 61 | unit: 'percent', 62 | }) 63 | ); 64 | 65 | this.addAvailableAction( 66 | 'fade', 67 | { 68 | title: 'Fade', 69 | description: 'Fade the lamp to a given level', 70 | input: { 71 | type: 'object', 72 | required: ['brightness', 'duration'], 73 | properties: { 74 | brightness: { 75 | type: 'integer', 76 | minimum: 0, 77 | maximum: 100, 78 | unit: 'percent', 79 | }, 80 | duration: { 81 | type: 'integer', 82 | minimum: 1, 83 | unit: 'milliseconds', 84 | }, 85 | }, 86 | }, 87 | }, 88 | FadeAction 89 | ); 90 | 91 | this.addAvailableEvent('overheated', { 92 | description: 'The lamp has exceeded its safe operating temperature', 93 | type: 'number', 94 | unit: 'degree celsius', 95 | }); 96 | } 97 | } 98 | 99 | /** 100 | * A humidity sensor which updates its measurement every few seconds. 101 | */ 102 | class FakeGpioHumiditySensor extends Thing { 103 | constructor() { 104 | super( 105 | 'urn:dev:ops:my-humidity-sensor-1234', 106 | 'My Humidity Sensor', 107 | ['MultiLevelSensor'], 108 | 'A web connected humidity sensor' 109 | ); 110 | 111 | this.level = new Value(0.0); 112 | this.addProperty( 113 | new Property(this, 'level', this.level, { 114 | '@type': 'LevelProperty', 115 | title: 'Humidity', 116 | type: 'number', 117 | description: 'The current humidity in %', 118 | minimum: 0, 119 | maximum: 100, 120 | unit: 'percent', 121 | readOnly: true, 122 | }) 123 | ); 124 | 125 | // Poll the sensor reading every 3 seconds 126 | setInterval(() => { 127 | // Update the underlying value, which in turn notifies all listeners 128 | const newLevel = this.readFromGPIO(); 129 | console.log('setting new humidity level:', newLevel); 130 | this.level.notifyOfExternalUpdate(newLevel); 131 | }, 3000); 132 | } 133 | 134 | /** 135 | * Mimic an actual sensor updating its reading every couple seconds. 136 | */ 137 | readFromGPIO() { 138 | return Math.abs(70.0 * Math.random() * (-0.5 + Math.random())); 139 | } 140 | } 141 | 142 | function runServer() { 143 | // Create a thing that represents a dimmable light 144 | const light = new ExampleDimmableLight(); 145 | 146 | // Create a thing that represents a humidity sensor 147 | const sensor = new FakeGpioHumiditySensor(); 148 | 149 | // If adding more than one thing, use MultipleThings() with a name. 150 | // In the single thing case, the thing's name will be broadcast. 151 | const server = new WebThingServer( 152 | new MultipleThings([light, sensor], 'LightAndTempDevice'), 153 | 8888 154 | ); 155 | 156 | process.on('SIGINT', () => { 157 | server 158 | .stop() 159 | .then(() => process.exit()) 160 | .catch(() => process.exit()); 161 | }); 162 | 163 | server.start().catch(console.error); 164 | } 165 | 166 | runServer(); 167 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "author": "WebThingsIO", 5 | "license": "MPL-2.0", 6 | "dependencies": { 7 | "webthing": "file:.." 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /example/platform/Makefile: -------------------------------------------------------------------------------- 1 | #!/bin/make -f 2 | # -*- makefile -*- 3 | # SPDX-License-Identifier: MPL-2.0 4 | #{ 5 | # Copyright 2018-present Samsung Electronics France SAS, and other contributors 6 | # 7 | # This Source Code Form is subject to the terms of the Mozilla Public 8 | # License, v. 2.0. If a copy of the MPL was not distributed with this 9 | # file, You can obtain one at http://mozilla.org/MPL/2.0/.* 10 | #} 11 | 12 | default: help all 13 | 14 | main_src ?= index.js 15 | lib_srcs ?= $(wildcard */*.js | sort) 16 | runtime ?= node 17 | topreldir ?= ../.. 18 | topdir ?= ${CURDIR}/${topreldir} 19 | run_args ?= 20 | npm_args ?= start 21 | sudo ?= sudo 22 | gpio ?= gpio 23 | tmp_dir ?= tmp 24 | 25 | export PATH 26 | NODE_PATH := ${topreldir}:${NODE_PATH} 27 | export NODE_PATH 28 | 29 | rpi_gpio ?= 11 30 | rpi_gpio_list ?= 13 19 26 31 | edison_gpio ?= 12 32 | 33 | help: 34 | @echo "Usage:" 35 | @echo '# make start' 36 | @echo '# make $${board}' 37 | @echo '# make artik1020' 38 | @echo '# make edison' 39 | @echo '# make flex-phat' 40 | @echo '# make play-phat' 41 | @echo '# make traffic-phat' 42 | 43 | %: %/${runtime} 44 | echo "# $@: $^" 45 | 46 | all: check 47 | 48 | setup/node: ${topreldir}/node_modules node_modules 49 | @echo "NODE_PATH=$${NODE_PATH}" 50 | @echo "$@: $^" 51 | 52 | run/%: /sys/class/gpio/export ${main_src} setup 53 | ls -l $< 54 | -which ${@F} 55 | ${@F} ${main_src} ${run_args} 56 | 57 | sudo/run/%: /sys/class/gpio/export ${main_src} setup 58 | ls -l $< 59 | -which ${@F} 60 | ${sudo} env "PATH=${PATH}" ${@F} ${main_src} ${run_args} 61 | 62 | run/npm: /sys/class/gpio/export package.json setup 63 | ls -l $< 64 | npm run ${npm_args} ${run_args} 65 | 66 | run: run/${runtime} 67 | sync 68 | 69 | start: run 70 | sync 71 | 72 | force: 73 | 74 | /sys/kernel/debug/gpio: 75 | ${sudo} ls -l $@ 76 | 77 | /sys/class/gpio/export: /sys/kernel/debug/gpio force 78 | ${sudo} cat $< 79 | 80 | node_modules: package.json 81 | -which npm 82 | npm --version 83 | npm install 84 | @mkdir -p "$@" 85 | ln -fs ../../.. ${@}/webthing 86 | 87 | package.json: 88 | npm init 89 | 90 | ${topreldir}/node_modules: ${topreldir}/package.json 91 | cd ${@D} && npm install 92 | 93 | 94 | check/%: ${lib_srcs} 95 | ${MAKE} setup 96 | status=0 ; \ 97 | for src in $^; do \ 98 | echo "log: check: $${src}: ($@)" ; \ 99 | ${@F} $${src} \ 100 | && echo "log: check: $${src}: OK" \ 101 | || status=1 ; \ 102 | done ; \ 103 | exit $${status} 104 | 105 | check: check/${runtime} 106 | 107 | board/%: ${main_src} board/%.js /sys/class/gpio/export setup 108 | ${runtime} $< ${@F} 109 | 110 | /sys/class/gpio/gpio${edison_gpio}: /sys/class/gpio/export 111 | echo ${edison_gpio} | ${sudo} tee $< 112 | ls -l $@ 113 | 114 | artik1020/%: ${main_src} 115 | ${MAKE} sudo/run/${@F} run_args="${@D}" 116 | 117 | edison/%: /sys/class/gpio/gpio${edison_gpio} ${main_src} 118 | echo out | ${sudo} tee ${<}/direction 119 | echo 0 | ${sudo} tee ${<}/value 120 | ${sudo} cat /sys/kernel/debug/gpio_debug/gpio${edison_gpio}/current_pinmux # mode0 121 | echo mode1 | ${sudo} tee /sys/kernel/debug/gpio_debug/gpio${edison_gpio}/current_pinmux 122 | ${MAKE} sudo/run/${@F} run_args="${@D}" 123 | 124 | gpio: /sys/class/gpio/export 125 | -${gpio} -v || ${sudo} apt-get install gpio || echo "TODO: install BCM tool" 126 | -${gpio} -v 127 | 128 | flex-phat/%: ${main_src} gpio 129 | ${gpio} -g mode ${rpi_gpio} up 130 | ${MAKE} run/${@F} run_args="${@D}" 131 | 132 | play-phat/%: ${main_src} /sys/class/gpio/export 133 | -lsmod | grep gpio_keys \ 134 | && ${sudo} modprobe -rv gpio_keys \ 135 | || echo "log: will use /sys/class/gpio/" 136 | ${MAKE} run/${@F} run_args="${@D}" 137 | 138 | traffic-phat/%: ${main_src} gpio 139 | for num in ${rpi_gpio_list} ; do \ 140 | ${sudo} ${gpio} export $${num} in ; \ 141 | ${sudo} ${gpio} -g mode $${num} up ; \ 142 | ${sudo} ${gpio} unexport $${num} ; \ 143 | done 144 | ${MAKE} run/${@F} run_args="${@D}" 145 | -------------------------------------------------------------------------------- /example/platform/adc/adc-property.js: -------------------------------------------------------------------------------- 1 | // -*- mode: js; js-indent-level:2; -*- 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | /** 5 | * 6 | * Copyright 2018-present Samsung Electronics France SAS, and other contributors 7 | * 8 | * This Source Code Form is subject to the terms of the Mozilla Public 9 | * License, v. 2.0. If a copy of the MPL was not distributed with this 10 | * file, You can obtain one at http://mozilla.org/MPL/2.0/.* 11 | */ 12 | 13 | const console = require('console'); 14 | 15 | // Disable logs here by editing to '!console.log' 16 | const log = console.log || function () {}; 17 | const verbose = !console.log || function () {}; 18 | 19 | const { Property, Value } = require('webthing'); 20 | 21 | const adc = require('../adc'); 22 | 23 | class AdcInProperty extends Property { 24 | constructor(thing, name, value, metadata, config) { 25 | super(thing, name, new Value(Number(value)), { 26 | '@type': 'LevelProperty', 27 | title: (metadata && metadata.title) || `Level: ${name}`, 28 | type: 'number', 29 | readOnly: true, 30 | description: (metadata && metadata.description) || `ADC Sensor on pin=${config.pin}`, 31 | }); 32 | const self = this; 33 | config.frequency = config.frequency || 1; 34 | config.range = config.range || 0xfff; 35 | this.period = 1000.0 / config.frequency; 36 | this.config = config; 37 | this.port = adc.open(config, (err) => { 38 | log(`log: ADC: ${self.getName()}: open: ${err} (null expected)`); 39 | if (err) { 40 | console.error(`error: ADC: ${self.getName()}: Fail to open:\ 41 | ${config.pin}`); 42 | return null; 43 | } 44 | self.inverval = setInterval(() => { 45 | let value = Number(self.port.readSync()); 46 | verbose(`log: ADC:\ 47 | ${self.getName()}: update: 0x${value.toString(0xf)}`); 48 | value = Number(Math.floor((100.0 * value) / self.config.range)); 49 | if (value !== self.lastValue) { 50 | log(`log: ADC: ${self.getName()}: change: ${value}%`); 51 | self.value.notifyOfExternalUpdate(value); 52 | self.lastValue = value; 53 | } 54 | }, self.period); 55 | }); 56 | } 57 | 58 | close() { 59 | try { 60 | this.inverval && clearInterval(this.inverval); 61 | this.port && this.port.closeSync(); 62 | } catch (err) { 63 | console.error(`error: ADC: ${this.getName()} close:${err}`); 64 | return err; 65 | } 66 | log(`log: ADC: ${this.getName()}: close:`); 67 | } 68 | } 69 | 70 | function AdcProperty(thing, name, value, metadata, config) { 71 | if (config.direction === 'in') { 72 | return new AdcInProperty(thing, name, value, metadata, config); 73 | } 74 | throw 'error: Invalid param'; 75 | } 76 | 77 | module.exports = AdcProperty; 78 | -------------------------------------------------------------------------------- /example/platform/adc/index.js: -------------------------------------------------------------------------------- 1 | // -*- mode: js; js-indent-level:2; -*- 2 | // SPDX-License-Identifier: ISC 3 | /** 4 | * Copyright 2018-present Samsung Electronics France SAS, and other contributors 5 | * 6 | * This Source Code Form is subject to the terms of the ICS Licence: 7 | * https://spdx.org/licenses/ISC.html#licenseText 8 | */ 9 | 10 | const fs = require('fs'); 11 | 12 | class Adc { 13 | constructor(config, callback) { 14 | this.config = config; 15 | fs.access(config.device, fs.R_OK, callback); 16 | } 17 | 18 | readSync() { 19 | const contents = fs.readFileSync(this.config.device, 'ascii'); 20 | return contents; 21 | } 22 | 23 | closeSync() {} 24 | } 25 | 26 | module.exports.open = function (config, callback) { 27 | return new Adc(config, callback); 28 | }; 29 | -------------------------------------------------------------------------------- /example/platform/board/artik1020.js: -------------------------------------------------------------------------------- 1 | // -*- mode: js; js-indent-level:2; -*- 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | /** 5 | * 6 | * Copyright 2018-present Samsung Electronics France SAS, and other contributors 7 | * 8 | * This Source Code Form is subject to the terms of the Mozilla Public 9 | * License, v. 2.0. If a copy of the MPL was not distributed with this 10 | * file, You can obtain one at http://mozilla.org/MPL/2.0/ 11 | */ 12 | 13 | const { Thing } = require('webthing'); 14 | 15 | const AdcProperty = require('../adc/adc-property'); 16 | const PwmProperty = require('../pwm/pwm-property'); 17 | 18 | class ARTIK1020Thing extends Thing { 19 | constructor(name, type, description) { 20 | super( 21 | 'urn:dev:ops:my-artik1020-1234', 22 | name || 'ARTIK1020', 23 | type || [], 24 | description || 'A web connected ARTIK1020' 25 | ); 26 | const self = this; 27 | this.pinProperties = [ 28 | new AdcProperty( 29 | this, 30 | 'ADC0', 31 | 0, 32 | { description: 'A0 on J24 of board' }, 33 | { direction: 'in', device: '/sys/devices/12d10000.adc/iio:device0\ 34 | /in_voltage0_raw' } 35 | ), 36 | new AdcProperty( 37 | this, 38 | 'ADC1', 39 | 0, 40 | { description: 'A1 on J24 of board' }, 41 | { direction: 'in', device: '/sys/devices/12d10000.adc/iio:device0\ 42 | /in_voltage1_raw' } 43 | ), 44 | new AdcProperty( 45 | this, 46 | 'ADC2', 47 | 0, 48 | { description: 'A2 on J24 of board' }, 49 | { direction: 'in', device: '/sys/devices/12d10000.adc/iio:device0\ 50 | /in_voltage2_raw' } 51 | ), 52 | new AdcProperty( 53 | this, 54 | 'ADC3', 55 | 0, 56 | { description: 'A3 on J24 of board' }, 57 | { direction: 'in', device: '/sys/devices/12d10000.adc/iio:device0\ 58 | /in_voltage5_raw' } 59 | ), 60 | new AdcProperty( 61 | this, 62 | 'ADC4', 63 | 0, 64 | { description: 'A4 on J24 of board' }, 65 | { direction: 'in', device: '/sys/devices/12d10000.adc/iio:device0\ 66 | /in_voltage6_raw' } 67 | ), 68 | new AdcProperty( 69 | this, 70 | 'ADC5', 71 | 0, 72 | { description: 'A5 on J24 of board' }, 73 | { direction: 'in', device: '/sys/devices/12d10000.adc/iio:device0\ 74 | /in_voltage7_raw' } 75 | ), 76 | new PwmProperty(this, 'PWM0', 50, { description: 'XPWMO1 on J26[6] of board (pwm0)' }), 77 | 78 | new PwmProperty( 79 | this, 80 | 'PWM1', 81 | 50, 82 | { description: 'XPWMO0 on J26[5] of board (pwm1)' }, 83 | { pwm: { pin: 1 } } 84 | ), 85 | ]; 86 | this.pinProperties.forEach((property) => { 87 | self.addProperty(property); 88 | }); 89 | } 90 | 91 | close() { 92 | this.pinProperties.forEach((property) => { 93 | property.close && property.close(); 94 | }); 95 | } 96 | } 97 | 98 | module.exports = function () { 99 | if (!module.exports.instance) { 100 | module.exports.instance = new ARTIK1020Thing(); 101 | } 102 | return module.exports.instance; 103 | }; 104 | -------------------------------------------------------------------------------- /example/platform/board/artik530.js: -------------------------------------------------------------------------------- 1 | // -*- mode: js; js-indent-level:2; -*- 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | /** 5 | * 6 | * Copyright 2018-present Samsung Electronics France SAS, and other contributors 7 | * 8 | * This Source Code Form is subject to the terms of the Mozilla Public 9 | * License, v. 2.0. If a copy of the MPL was not distributed with this 10 | * file, You can obtain one at http://mozilla.org/MPL/2.0/.* 11 | */ 12 | 13 | const { Thing } = require('webthing'); 14 | 15 | const AdcProperty = require('../adc/adc-property'); 16 | const GpioProperty = require('../gpio/gpio-property'); 17 | 18 | class ARTIK530Thing extends Thing { 19 | constructor(name, type, description) { 20 | super( 21 | 'urn:dev:ops:my-artik530-1234', 22 | name || 'ARTIK530', 23 | type || [], 24 | description || 'A web connected ARTIK530 or ARTIK720' 25 | ); 26 | const self = this; 27 | this.pinProperties = [ 28 | new GpioProperty( 29 | this, 30 | 'RedLED', 31 | false, 32 | { description: 'Red LED on interposer board (on GPIO28)' }, 33 | { direction: 'out', pin: 28 } 34 | ), 35 | new GpioProperty( 36 | this, 37 | 'BlueLED', 38 | false, 39 | { description: 'Blue LED on interposer board (on GPIO38)' }, 40 | { direction: 'out', pin: 38 } 41 | ), 42 | new GpioProperty( 43 | this, 44 | 'Up', 45 | true, 46 | { description: 'SW403 Button: Nearest board edge,\ 47 | next to red LED (on GPIO30)' }, 48 | { direction: 'in', pin: 30 } 49 | ), 50 | new GpioProperty( 51 | this, 52 | 'Down', 53 | true, 54 | { description: 'SW404 Button: Next to blue LED (on GPIO32)' }, 55 | { direction: 'in', pin: 32 } 56 | ), 57 | new AdcProperty( 58 | this, 59 | 'ADC0', 60 | 0, 61 | { description: 'Analog port of ARTIK05x' }, 62 | { 63 | direction: 'in', 64 | device: '/sys/bus/platform/devices\ 65 | /c0053000.adc/iio:device0/in_voltage0_raw', 66 | } 67 | ), 68 | new AdcProperty( 69 | this, 70 | 'ADC1', 71 | 0, 72 | { description: 'Analog port of ARTIK05x' }, 73 | { 74 | direction: 'in', 75 | device: '/sys/bus/platform/devices/\ 76 | c0053000.adc/iio:device0/in_voltage1_raw', 77 | } 78 | ), 79 | ]; 80 | this.pinProperties.forEach((property) => { 81 | self.addProperty(property); 82 | }); 83 | } 84 | 85 | close() { 86 | this.pinProperties.forEach((property) => { 87 | property.close && property.close(); 88 | }); 89 | } 90 | } 91 | 92 | module.exports = function () { 93 | if (!module.exports.instance) { 94 | module.exports.instance = new ARTIK530Thing(); 95 | } 96 | return module.exports.instance; 97 | }; 98 | -------------------------------------------------------------------------------- /example/platform/board/edison.js: -------------------------------------------------------------------------------- 1 | // -*- mode: js; js-indent-level:2; -*- 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | /** 5 | * 6 | * Copyright 2018-present Samsung Electronics France SAS, and other contributors 7 | * 8 | * This Source Code Form is subject to the terms of the Mozilla Public 9 | * License, v. 2.0. If a copy of the MPL was not distributed with this 10 | * file, You can obtain one at http://mozilla.org/MPL/2.0/.* 11 | */ 12 | 13 | const { Thing } = require('webthing'); 14 | 15 | const PwmProperty = require('../pwm/pwm-property'); 16 | 17 | class EdisonThing extends Thing { 18 | constructor(name, type, description) { 19 | super( 20 | 'urn:dev:ops:my-edison-1234', 21 | name || 'Edison', 22 | type || [], 23 | description || 'A web connected Edison' 24 | ); 25 | const self = this; 26 | this.pinProperties = [ 27 | new PwmProperty(this, 'PWM0', 50, { 28 | description: 'Analog port of Edison', 29 | }), 30 | ]; 31 | this.pinProperties.forEach((property) => { 32 | self.addProperty(property); 33 | }); 34 | } 35 | 36 | close() { 37 | this.pinProperties.forEach((property) => { 38 | property.close && property.close(); 39 | }); 40 | } 41 | } 42 | 43 | module.exports = function () { 44 | if (!module.exports.instance) { 45 | module.exports.instance = new EdisonThing(); 46 | } 47 | return module.exports.instance; 48 | }; 49 | -------------------------------------------------------------------------------- /example/platform/board/flex-phat.js: -------------------------------------------------------------------------------- 1 | // -*- mode: js; js-indent-level:2; -*- 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | /** 5 | * 6 | * Copyright 2018-present Samsung Electronics France SAS, and other contributors 7 | * 8 | * This Source Code Form is subject to the terms of the Mozilla Public 9 | * License, v. 2.0. If a copy of the MPL was not distributed with this 10 | * file, You can obtain one at http://mozilla.org/MPL/2.0/.* 11 | */ 12 | 13 | const { Thing } = require('webthing'); 14 | 15 | const GpioProperty = require('../gpio/gpio-property'); 16 | 17 | class FlexPHatThing extends Thing { 18 | constructor(name, type, description) { 19 | super( 20 | 'urn:dev:ops:my-flex-phat-1234', 21 | name || 'FlexPHat', 22 | type || [], 23 | description || 'A web connected Flex RaspberryPi Hat' 24 | ); 25 | const self = this; 26 | this.gpioProperties = [ 27 | new GpioProperty( 28 | this, 29 | 'Relay', 30 | false, 31 | { description: 'Actuator (on GPIO5)' }, 32 | { direction: 'out', pin: 5 } 33 | ), 34 | new GpioProperty( 35 | this, 36 | 'BlueLED', 37 | false, 38 | { description: 'Actuator (on GPIO13)' }, 39 | { direction: 'out', pin: 13 } 40 | ), 41 | new GpioProperty( 42 | this, 43 | 'GreenLED', 44 | false, 45 | { description: 'Actuator (on GPIO19)' }, 46 | { direction: 'out', pin: 19 } 47 | ), 48 | new GpioProperty( 49 | this, 50 | 'RedLED', 51 | false, 52 | { description: 'Actuator (on GPIO26)' }, 53 | { direction: 'out', pin: 26 } 54 | ), 55 | new GpioProperty( 56 | this, 57 | 'Button', 58 | false, 59 | { description: 'Push Button (on GPIO11)' }, 60 | { direction: 'in', pin: 11 } 61 | ), 62 | new GpioProperty( 63 | this, 64 | 'GPIO23', 65 | false, 66 | { description: 'Input on GPIO 23 (unwired but modable)' }, 67 | { direction: 'in', pin: 23 } 68 | ), 69 | ]; 70 | this.gpioProperties.forEach((property) => { 71 | self.addProperty(property); 72 | }); 73 | } 74 | 75 | close() { 76 | this.gpioProperties.forEach((property) => { 77 | property.close && property.close(); 78 | }); 79 | } 80 | } 81 | 82 | module.exports = function () { 83 | if (!module.exports.instance) { 84 | module.exports.instance = new FlexPHatThing(); 85 | } 86 | return module.exports.instance; 87 | }; 88 | -------------------------------------------------------------------------------- /example/platform/board/play-phat.js: -------------------------------------------------------------------------------- 1 | // -*- mode: js; js-indent-level:2; -*- 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | /** 5 | * 6 | * Copyright 2018-present Samsung Electronics France SAS, and other contributors 7 | * 8 | * This Source Code Form is subject to the terms of the Mozilla Public 9 | * License, v. 2.0. If a copy of the MPL was not distributed with this 10 | * file, You can obtain one at http://mozilla.org/MPL/2.0/.* 11 | */ 12 | 13 | const { Thing } = require('webthing'); 14 | 15 | const GpioProperty = require('../gpio/gpio-property'); 16 | 17 | class PlayPHatThing extends Thing { 18 | constructor(name, type, description) { 19 | super( 20 | 'urn:dev:ops:my-play-phat-1234', 21 | name || 'PlayPHat', 22 | type || [], 23 | description || 'A web connected Play RaspberryPi Hat' 24 | ); 25 | const self = this; 26 | this.gpioProperties = [ 27 | new GpioProperty( 28 | this, 29 | 'Left', 30 | false, 31 | { description: 'SW1 Sensor Button on GPIO4 (Pin7)' }, 32 | { direction: 'in', pin: 4 } 33 | ), 34 | new GpioProperty( 35 | this, 36 | 'Right', 37 | false, 38 | { description: 'SW2 Sensor button on GPIO17 (Pin11)' }, 39 | { direction: 'in', pin: 17 } 40 | ), 41 | new GpioProperty( 42 | this, 43 | 'Up', 44 | false, 45 | { description: 'SW3 Sensor button on GPIO22 (Pin15)' }, 46 | { direction: 'in', pin: 22 } 47 | ), 48 | new GpioProperty( 49 | this, 50 | 'Down', 51 | false, 52 | { description: 'SW4 Sensor button on GPIO27 (Pin13)' }, 53 | { direction: 'in', pin: 27 } 54 | ), 55 | new GpioProperty( 56 | this, 57 | 'A', 58 | false, 59 | { description: 'SW5 Sensor button on GPIO19 (Pin35)' }, 60 | { direction: 'in', pin: 19 } 61 | ), 62 | new GpioProperty( 63 | this, 64 | 'B', 65 | false, 66 | { description: 'SW6 Sensor button on GPIO26 (Pin37)' }, 67 | { direction: 'in', pin: 26 } 68 | ), 69 | new GpioProperty( 70 | this, 71 | 'Start', 72 | false, 73 | { description: 'SW7 Sensor button on GPIO5 (Pin29)' }, 74 | { direction: 'in', pin: 5 } 75 | ), 76 | new GpioProperty( 77 | this, 78 | 'Select', 79 | false, 80 | { description: 'SW8 Sensor button on GPIO6 (Pin31)' }, 81 | { direction: 'in', pin: 6 } 82 | ), 83 | ]; 84 | this.gpioProperties.forEach((property) => { 85 | self.addProperty(property); 86 | }); 87 | } 88 | 89 | close() { 90 | this.gpioProperties.forEach((property) => { 91 | property.close && property.close(); 92 | }); 93 | } 94 | } 95 | 96 | module.exports = function () { 97 | if (!module.exports.instance) { 98 | module.exports.instance = new PlayPHatThing(); 99 | } 100 | return module.exports.instance; 101 | }; 102 | -------------------------------------------------------------------------------- /example/platform/board/traffic-phat.js: -------------------------------------------------------------------------------- 1 | // -*- mode: js; js-indent-level:2; -*- 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | /** 5 | * 6 | * Copyright 2018-present Samsung Electronics France SAS, and other contributors 7 | * 8 | * This Source Code Form is subject to the terms of the Mozilla Public 9 | * License, v. 2.0. If a copy of the MPL was not distributed with this 10 | * file, You can obtain one at http://mozilla.org/MPL/2.0/.* 11 | */ 12 | 13 | const { Thing } = require('webthing'); 14 | 15 | const GpioProperty = require('../gpio/gpio-property'); 16 | 17 | class TrafficPHatThing extends Thing { 18 | constructor(name, type, description) { 19 | super( 20 | 'urn:dev:ops:my-traffic-phat-1234', 21 | name || 'TrafficPHat', 22 | type || [], 23 | description || 'A web connected Traffic RaspberryPi Hat' 24 | ); 25 | const self = this; 26 | this.pinProperties = [ 27 | new GpioProperty( 28 | this, 29 | 'Red', 30 | false, 31 | { 32 | description: 'LED on GPIO2 (Pin2)', 33 | }, 34 | { 35 | direction: 'out', 36 | pin: 2, 37 | } 38 | ), 39 | new GpioProperty( 40 | this, 41 | 'Orange', 42 | false, 43 | { 44 | description: 'LED on GPIO3 (Pin5)', 45 | }, 46 | { 47 | direction: 'out', 48 | pin: 3, 49 | } 50 | ), 51 | new GpioProperty( 52 | this, 53 | 'Green', 54 | false, 55 | { 56 | description: 'LED on GPIO4 (Pin7)', 57 | }, 58 | { 59 | direction: 'out', 60 | pin: 4, 61 | } 62 | ), 63 | new GpioProperty( 64 | this, 65 | 'B1', 66 | true, 67 | { 68 | description: 'SW1 Sensor Button on GPIO3 (Pin33)', 69 | }, 70 | { 71 | direction: 'in', 72 | pin: 13, 73 | } 74 | ), 75 | new GpioProperty( 76 | this, 77 | 'B2', 78 | true, 79 | { 80 | description: 'SW2 Sensor button on GPIO19 (Pin35)', 81 | }, 82 | { 83 | direction: 'in', 84 | pin: 19, 85 | } 86 | ), 87 | new GpioProperty( 88 | this, 89 | 'B3', 90 | true, 91 | { 92 | description: 'SW3 Sensor button on GPIO26 (Pin37)', 93 | }, 94 | { 95 | direction: 'in', 96 | pin: 26, 97 | } 98 | ), 99 | ]; 100 | this.pinProperties.forEach((property) => { 101 | self.addProperty(property); 102 | }); 103 | } 104 | 105 | close() { 106 | this.pinProperties.forEach((property) => { 107 | property.close && property.close(); 108 | }); 109 | } 110 | } 111 | 112 | module.exports = function () { 113 | if (!module.exports.instance) { 114 | module.exports.instance = new TrafficPHatThing(); 115 | } 116 | return module.exports.instance; 117 | }; 118 | -------------------------------------------------------------------------------- /example/platform/gpio/gpio-property.js: -------------------------------------------------------------------------------- 1 | // -*- mode: js; js-indent-level:2; -*- 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | /** 5 | * 6 | * Copyright 2018-present Samsung Electronics France SAS, and other contributors 7 | * 8 | * This Source Code Form is subject to the terms of the Mozilla Public 9 | * License, v. 2.0. If a copy of the MPL was not distributed with this 10 | * file, You can obtain one at http://mozilla.org/MPL/2.0/.* 11 | */ 12 | 13 | const console = require('console'); 14 | 15 | // Disable logs here by editing to '!console.log' 16 | const log = console.log || function () {}; 17 | 18 | const { Property, Value } = require('webthing'); 19 | 20 | const gpio = require('gpio'); 21 | 22 | class GpioOutProperty extends Property { 23 | constructor(thing, name, value, metadata, config) { 24 | super(thing, name, new Value(Boolean(value)), { 25 | '@type': 'OnOffProperty', 26 | title: (metadata && metadata.title) || `On/Off: ${name}`, 27 | type: 'boolean', 28 | description: (metadata && metadata.description) || `GPIO Actuator on pin=${config.pin}`, 29 | }); 30 | const self = this; 31 | this.config = config; 32 | this.port = gpio.export(config.pin, { 33 | direction: 'out', 34 | ready: () => { 35 | log(`log: GPIO: ${self.getName()}: open:`); 36 | self.value.valueForwarder = (value) => { 37 | try { 38 | log(`log: GPIO: ${self.getName()}: \ 39 | writing: ${value}`); 40 | self.port.set(value); 41 | } catch (err) { 42 | console.error(`error: GPIO: 43 | ${self.getName()}: Fail to write: ${err}`); 44 | return err; 45 | } 46 | }; 47 | }, 48 | }); 49 | } 50 | 51 | close() { 52 | try { 53 | this.port && this.port.unexport(this.config.pin); 54 | } catch (err) { 55 | console.error(`error: GPIO: ${this.getName()}: Fail to close: ${err}`); 56 | return err; 57 | } 58 | log(`log: GPIO: ${this.getName()}: close:`); 59 | } 60 | } 61 | 62 | class GpioInProperty extends Property { 63 | constructor(thing, name, value, metadata, config) { 64 | super(thing, name, new Value(Boolean(value)), { 65 | '@type': 'BooleanProperty', 66 | title: (metadata && metadata.title) || `On/Off: ${name}`, 67 | type: 'boolean', 68 | readOnly: true, 69 | description: (metadata && metadata.description) || `GPIO Sensor on pin=${config.pin}`, 70 | }); 71 | const self = this; 72 | this.config = config; 73 | const callback = () => { 74 | log(`log: GPIO: ${self.getName()}: open:`); 75 | self.port.on('change', (value) => { 76 | value = Boolean(value); 77 | log(`log: GPIO: ${self.getName()}: change: ${value}`); 78 | self.value.notifyOfExternalUpdate(value); 79 | }); 80 | }; 81 | this.port = gpio.export(config.pin, { direction: 'in', ready: callback }); 82 | } 83 | 84 | close() { 85 | try { 86 | this.port && this.port.unexport(this.config.pin); 87 | } catch (err) { 88 | console.error(`error: GPIO: ${this.getName()} close:${err}`); 89 | return err; 90 | } 91 | log(`log: GPIO: ${this.getName()}: close:`); 92 | } 93 | } 94 | 95 | function GpioProperty(thing, name, value, metadata, config) { 96 | if (config.direction === 'out') { 97 | return new GpioOutProperty(thing, name, value, metadata, config); 98 | } else if (config.direction === 'in') { 99 | return new GpioInProperty(thing, name, value, metadata, config); 100 | } 101 | throw 'error: Invalid param'; 102 | } 103 | 104 | module.exports = GpioProperty; 105 | -------------------------------------------------------------------------------- /example/platform/index.js: -------------------------------------------------------------------------------- 1 | // -*- mode: js; js-indent-level:2; -*- 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | /** 5 | * 6 | * Copyright 2018-present Samsung Electronics France SAS, and other contributors 7 | * 8 | * This Source Code Form is subject to the terms of the Mozilla Public 9 | * License, v. 2.0. If a copy of the MPL was not distributed with this 10 | * file, You can obtain one at http://mozilla.org/MPL/2.0/.* 11 | */ 12 | 13 | const console = require('console'); 14 | 15 | // TODO: disable logs here by editing to '!console.log' 16 | const log = console.log || function () {}; 17 | 18 | const { SingleThing, WebThingServer } = require('webthing'); 19 | 20 | // Update with different board here if needed 21 | let board = 'artik530'; 22 | if (process.argv.length > 2) { 23 | board = String(process.argv[2]); 24 | } 25 | 26 | log(`log: board: ${board}: Loading`); 27 | const BoardThing = require(`./board/${board}`); 28 | 29 | function runServer() { 30 | const port = process.argv[3] ? Number(process.argv[3]) : 8888; 31 | const url = `http://localhost:${port}`; 32 | 33 | log(`Usage:\n\ 34 | ${process.argv[0]} ${process.argv[1]} [board] [port]\n\ 35 | Try:\ncurl -H "Accept: application/json" ${url}\ 36 | \n`); 37 | const thing = new BoardThing(); 38 | const server = new WebThingServer(new SingleThing(thing), port); 39 | process.on('SIGINT', () => { 40 | const cleanup = () => { 41 | thing && thing.close(); 42 | log(`log: board: ${board}: Exit`); 43 | process.exit(); 44 | }; 45 | server.stop().then(cleanup).catch(cleanup); 46 | }); 47 | server.start().catch(console.error); 48 | log(`log: board: ${board}: Started`); 49 | } 50 | 51 | if (module.parent === null) { 52 | runServer(); 53 | } 54 | -------------------------------------------------------------------------------- /example/platform/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "board-webthings", 3 | "version": "0.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "gpio": { 8 | "version": "0.2.10", 9 | "resolved": "https://registry.npmjs.org/gpio/-/gpio-0.2.10.tgz", 10 | "integrity": "sha512-VBFvAye/pK/oWHDUCa2F8jyrtHhnHXR/zHxMz3REEgznfMCQyzy6yRU8hFNuMSO+AG3YN4fH71Suj6mvM3cG3w==", 11 | "optional": true 12 | }, 13 | "pwm": { 14 | "version": "0.0.3", 15 | "resolved": "https://registry.npmjs.org/pwm/-/pwm-0.0.3.tgz", 16 | "integrity": "sha1-CSk3iakNRIIhPnZ5DSNziqi01TE=", 17 | "optional": true 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /example/platform/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "board-webthings", 3 | "version": "0.0.0", 4 | "description": "Various Single Board computers's I/O implemented as webthings", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "NODE_PATH=.:../.. node index", 8 | "flex-phat": "NODE_PATH=.:../.. node index flex-phat", 9 | "play-phat": "NODE_PATH=.:../.. node index play-phat" 10 | }, 11 | "author": "Philippe Coval ", 12 | "license": "MPL-2.0", 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/rzr/webthing-iotjs.git" 16 | }, 17 | "keywords": [ 18 | "webthing", 19 | "wot", 20 | "raspberry-pi", 21 | "ARTIK" 22 | ], 23 | "bugs": { 24 | "url": "https://github.com/rzr/webthing-iotjs/issues" 25 | }, 26 | "bin": { 27 | "board-webthings": "./index.js" 28 | }, 29 | "homepage": "https://github.com/rzr/webthing-iotjs", 30 | "dependencies": {}, 31 | "optionalDependencies": { 32 | "gpio": "^0.2.10", 33 | "pwm": "0.0.3" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /example/platform/pwm/pwm-property.js: -------------------------------------------------------------------------------- 1 | // -*- mode: js; js-indent-level:2; -*- 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | /** 5 | * 6 | * Copyright 2018-present Samsung Electronics France SAS, and other contributors 7 | * 8 | * This Source Code Form is subject to the terms of the Mozilla Public 9 | * License, v. 2.0. If a copy of the MPL was not distributed with this 10 | * file, You can obtain one at http://mozilla.org/MPL/2.0/ 11 | */ 12 | const console = require('console'); 13 | 14 | // Disable logs here by editing to '!console.log' 15 | const log = console.log || function () {}; 16 | const verbose = !console.log || function () {}; 17 | 18 | const { Property, Value } = require('webthing'); 19 | 20 | const pwm = require('pwm'); 21 | 22 | class PwmOutProperty extends Property { 23 | constructor(thing, name, value, metadata, config) { 24 | if (typeof config === 'undefined') { 25 | config = {}; 26 | } 27 | super(thing, name, new Value(Number(value)), { 28 | '@type': 'LevelProperty', 29 | title: (metadata && metadata.title) || `PWM: ${name} (dutyCycle)`, 30 | type: 'integer', 31 | minimum: config.minimum || 0, 32 | maximum: config.maximum || 100, 33 | readOnly: false, 34 | unit: 'percent', 35 | description: (metadata && metadata.description) || `PWM DutyCycle`, 36 | }); 37 | const self = this; 38 | this.config = config; 39 | if (typeof this.config.pwm == 'undefined') { 40 | this.config.pwm = {}; 41 | } 42 | if (typeof this.config.pwm.pin == 'undefined') { 43 | this.config.pwm.pin = 0; 44 | } 45 | 46 | if (typeof this.config.pwm.chip == 'undefined') { 47 | this.config.pwm.chip = 0; 48 | } 49 | // secs (eg: 50Hz = 20 ms = 0.02 sec) 50 | if (typeof this.config.pwm.period == 'undefined') { 51 | this.config.pwm.period = 0.02; 52 | } 53 | // [0..1] 54 | if (typeof this.config.pwm.dutyCycle == 'undefined') { 55 | this.config.pwm.dutyCycle = 0.5; 56 | } 57 | verbose(`log: opening: ${this.getName()}`); 58 | this.port = pwm.export(this.config.pwm.chip, this.config.pwm.pin, (err) => { 59 | verbose(`log: PWM: ${self.getName()}: open: ${err}`); 60 | if (err) { 61 | console.error(`error: PWM: ${self.getName()}: open: ${err}`); 62 | throw err; 63 | } 64 | self.port.freq = 1 / self.config.pwm.period; 65 | // Linux sysfs uses usecs units 66 | self.port.setPeriod(self.config.pwm.period * 1e9, () => { 67 | self.port.setDutyCycle(self.config.pwm.dutyCycle * (self.config.pwm.period * 1e9), () => { 68 | self.port.setEnable(1, () => { 69 | verbose(`log: ${self.getName()}: Enabled`); 70 | }); 71 | }); 72 | }); 73 | 74 | self.value.valueForwarder = (value) => { 75 | const usec = Math.floor(self.config.pwm.period * 1e9 * (Number(value) / 100.0)); 76 | self.port.setDutyCycle(usec, () => { 77 | verbose(`log: setDutyCycle: usec=${usec}`); 78 | }); 79 | }; 80 | }); 81 | } 82 | 83 | close() { 84 | verbose(`log: PWM: ${this.getName()}: close:`); 85 | try { 86 | this.port && this.port.unexport(); 87 | } catch (err) { 88 | console.error(`error: PWM: ${this.getName()} close:${err}`); 89 | return err; 90 | } 91 | log(`log: PWM: ${this.getName()}: close:`); 92 | } 93 | } 94 | 95 | module.exports = PwmOutProperty; 96 | 97 | if (module.parent === null) { 98 | new PwmOutProperty(); 99 | } 100 | -------------------------------------------------------------------------------- /example/simplest-thing.js: -------------------------------------------------------------------------------- 1 | // -*- mode: js; js-indent-level:2; -*- 2 | // SPDX-License-Identifier: MPL-2.0 3 | /** 4 | * 5 | * Copyright 2018-present Samsung Electronics France SAS, and other contributors 6 | * 7 | * This Source Code Form is subject to the terms of the Mozilla Public 8 | * License, v. 2.0. If a copy of the MPL was not distributed with this 9 | * file, You can obtain one at http://mozilla.org/MPL/2.0/.* 10 | */ 11 | const { Property, SingleThing, Thing, Value, WebThingServer } = require('webthing'); 12 | 13 | function makeThing() { 14 | const thing = new Thing( 15 | 'urn:dev:ops:my-actuator-1234', 16 | 'ActuatorExample', 17 | ['OnOffSwitch'], 18 | 'An actuator example that just log' 19 | ); 20 | 21 | thing.addProperty( 22 | new Property(thing, 'on', new Value(true, (update) => console.log(`change: ${update}`)), { 23 | '@type': 'OnOffProperty', 24 | title: 'On/Off', 25 | type: 'boolean', 26 | description: 'Whether the output is changed', 27 | }) 28 | ); 29 | return thing; 30 | } 31 | 32 | function runServer() { 33 | const port = process.argv[2] ? Number(process.argv[2]) : 8888; 34 | const url = `http://localhost:${port}/properties/on`; 35 | 36 | console.log(`Usage:\n 37 | ${process.argv[0]} ${process.argv[1]} [port] 38 | 39 | Try: 40 | curl -X PUT -H 'Content-Type: application/json' --data '{"on": true }' ${url} 41 | `); 42 | 43 | const thing = makeThing(); 44 | const server = new WebThingServer(new SingleThing(thing), port); 45 | process.on('SIGINT', () => { 46 | server 47 | .stop() 48 | .then(() => process.exit()) 49 | .catch(() => process.exit()); 50 | }); 51 | server.start().catch(console.error); 52 | } 53 | 54 | runServer(); 55 | -------------------------------------------------------------------------------- /example/single-thing.js: -------------------------------------------------------------------------------- 1 | // -*- mode: js; js-indent-level:2; -*- 2 | // SPDX-License-Identifier: MPL-2.0 3 | 4 | const { Action, Event, Property, SingleThing, Thing, Value, WebThingServer } = require('webthing'); 5 | const { v4: uuidv4 } = require('uuid'); 6 | 7 | class OverheatedEvent extends Event { 8 | constructor(thing, data) { 9 | super(thing, 'overheated', data); 10 | } 11 | } 12 | 13 | class FadeAction extends Action { 14 | constructor(thing, input) { 15 | super(uuidv4(), thing, 'fade', input); 16 | } 17 | 18 | performAction() { 19 | return new Promise((resolve) => { 20 | setTimeout(() => { 21 | this.thing.setProperty('brightness', this.input.brightness); 22 | this.thing.addEvent(new OverheatedEvent(this.thing, 102)); 23 | resolve(); 24 | }, this.input.duration); 25 | }); 26 | } 27 | } 28 | 29 | function makeThing() { 30 | const thing = new Thing( 31 | 'urn:dev:ops:my-lamp-1234', 32 | 'My Lamp', 33 | ['OnOffSwitch', 'Light'], 34 | 'A web connected lamp' 35 | ); 36 | 37 | thing.addProperty( 38 | new Property(thing, 'on', new Value(true), { 39 | '@type': 'OnOffProperty', 40 | title: 'On/Off', 41 | type: 'boolean', 42 | description: 'Whether the lamp is turned on', 43 | }) 44 | ); 45 | thing.addProperty( 46 | new Property(thing, 'brightness', new Value(50), { 47 | '@type': 'BrightnessProperty', 48 | title: 'Brightness', 49 | type: 'integer', 50 | description: 'The level of light from 0-100', 51 | minimum: 0, 52 | maximum: 100, 53 | unit: 'percent', 54 | }) 55 | ); 56 | 57 | thing.addAvailableAction( 58 | 'fade', 59 | { 60 | title: 'Fade', 61 | description: 'Fade the lamp to a given level', 62 | input: { 63 | type: 'object', 64 | required: ['brightness', 'duration'], 65 | properties: { 66 | brightness: { 67 | type: 'integer', 68 | minimum: 0, 69 | maximum: 100, 70 | unit: 'percent', 71 | }, 72 | duration: { 73 | type: 'integer', 74 | minimum: 1, 75 | unit: 'milliseconds', 76 | }, 77 | }, 78 | }, 79 | }, 80 | FadeAction 81 | ); 82 | 83 | thing.addAvailableEvent('overheated', { 84 | description: 'The lamp has exceeded its safe operating temperature', 85 | type: 'number', 86 | unit: 'degree celsius', 87 | }); 88 | 89 | return thing; 90 | } 91 | 92 | function runServer() { 93 | const thing = makeThing(); 94 | 95 | // If adding more than one thing, use MultipleThings() with a name. 96 | // In the single thing case, the thing's name will be broadcast. 97 | const server = new WebThingServer(new SingleThing(thing), 8888); 98 | 99 | process.on('SIGINT', () => { 100 | server 101 | .stop() 102 | .then(() => process.exit()) 103 | .catch(() => process.exit()); 104 | }); 105 | 106 | server.start().catch(console.error); 107 | } 108 | 109 | runServer(); 110 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webthing", 3 | "version": "0.15.0", 4 | "description": "HTTP Web Thing implementation", 5 | "main": "lib/webthing.js", 6 | "scripts": { 7 | "lint": "tsc --noEmit && eslint . --ext .ts", 8 | "node": "NODE_PATH=. node", 9 | "start": "NODE_PATH=. node example/multiple-things", 10 | "test": "make test", 11 | "simplest": "NODE_PATH=. node example/simplest-thing", 12 | "prettier": "npx prettier -w 'src/*.ts' 'example/**/*.{js,ts}'", 13 | "build": "tsc -p ." 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/WebThingsIO/webthing-node.git" 18 | }, 19 | "keywords": [ 20 | "iot", 21 | "web", 22 | "thing", 23 | "webthing" 24 | ], 25 | "author": "WebThingsIO", 26 | "license": "MPL-2.0", 27 | "bugs": { 28 | "url": "https://github.com/WebThingsIO/webthing-node/issues" 29 | }, 30 | "homepage": "https://github.com/WebThingsIO/webthing-node#readme", 31 | "types": "lib/index.d.ts", 32 | "dependencies": { 33 | "ajv": "^7.0.4", 34 | "body-parser": "^1.19.0", 35 | "dnssd": "^0.4.1", 36 | "express": "^4.17.1", 37 | "express-ws": "^4.0.0", 38 | "prettier": "^2.2.1" 39 | }, 40 | "devDependencies": { 41 | "@types/body-parser": "^1.19.0", 42 | "@types/dnssd": "^0.4.1", 43 | "@types/express": "^4.17.11", 44 | "@types/express-ws": "^3.0.0", 45 | "@types/node": "^14.14.25", 46 | "@typescript-eslint/eslint-plugin": "^4.14.2", 47 | "@typescript-eslint/parser": "^4.14.2", 48 | "babel-eslint": "^10.1.0", 49 | "eslint": "^7.19.0", 50 | "eslint-config-prettier": "^7.2.0", 51 | "typescript": "^4.1.3", 52 | "uuid": "^8.3.2" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/action.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * High-level Action base class implementation. 3 | */ 4 | 5 | import * as utils from './utils'; 6 | import { AnyType, Link, PrimitiveJsonType } from './types'; 7 | import Thing from './thing'; 8 | 9 | /** 10 | * An Action represents an individual action on a thing. 11 | */ 12 | class Action { 13 | private id: string; 14 | 15 | private thing: Thing; 16 | 17 | private name: string; 18 | 19 | private input: InputType; 20 | 21 | private hrefPrefix: string; 22 | 23 | private href: string; 24 | 25 | private status: string; 26 | 27 | private timeRequested: string; 28 | 29 | private timeCompleted: string | null; 30 | 31 | constructor(id: string, thing: Thing, name: string, input: InputType) { 32 | /** 33 | * Initialize the object. 34 | * 35 | * @param {String} id ID of this action 36 | * @param {Object} thing Thing this action belongs to 37 | * @param {String} name Name of the action 38 | * @param {Object} input Any action inputs 39 | */ 40 | this.id = id; 41 | this.thing = thing; 42 | this.name = name; 43 | this.input = input; 44 | this.hrefPrefix = ''; 45 | this.href = `/actions/${this.name}/${this.id}`; 46 | this.status = 'created'; 47 | this.timeRequested = utils.timestamp(); 48 | this.timeCompleted = null; 49 | } 50 | 51 | /** 52 | * Get the action description. 53 | * 54 | * @returns {Object} Description of the action as an object. 55 | */ 56 | asActionDescription(): Action.ActionDescription { 57 | const description: Action.ActionDescription = { 58 | [this.name]: { 59 | href: this.hrefPrefix + this.href, 60 | timeRequested: this.timeRequested, 61 | status: this.status, 62 | }, 63 | }; 64 | 65 | if (this.input !== null) { 66 | description[this.name].input = (this.input); 67 | } 68 | 69 | if (this.timeCompleted !== null) { 70 | description[this.name].timeCompleted = this.timeCompleted; 71 | } 72 | 73 | return description; 74 | } 75 | 76 | /** 77 | * Set the prefix of any hrefs associated with this action. 78 | * 79 | * @param {String} prefix The prefix 80 | */ 81 | setHrefPrefix(prefix: string): void { 82 | this.hrefPrefix = prefix; 83 | } 84 | 85 | /** 86 | * Get this action's ID. 87 | * 88 | * @returns {String} The ID. 89 | */ 90 | getId(): string { 91 | return this.id; 92 | } 93 | 94 | /** 95 | * Get this action's name. 96 | * 97 | * @returns {String} The name. 98 | */ 99 | getName(): string { 100 | return this.name; 101 | } 102 | 103 | /** 104 | * Get this action's href. 105 | * 106 | * @returns {String} The href. 107 | */ 108 | getHref(): string { 109 | return this.hrefPrefix + this.href; 110 | } 111 | 112 | /** 113 | * Get this action's status. 114 | * 115 | * @returns {String} The status. 116 | */ 117 | getStatus(): string { 118 | return this.status; 119 | } 120 | 121 | /** 122 | * Get the thing associated with this action. 123 | * 124 | * @returns {Object} The thing. 125 | */ 126 | getThing(): Thing { 127 | return this.thing; 128 | } 129 | 130 | /** 131 | * Get the time the action was requested. 132 | * 133 | * @returns {String} The time. 134 | */ 135 | getTimeRequested(): string { 136 | return this.timeRequested; 137 | } 138 | 139 | /** 140 | * Get the time the action was completed. 141 | * 142 | * @returns {String} The time. 143 | */ 144 | getTimeCompleted(): string | null { 145 | return this.timeCompleted; 146 | } 147 | 148 | /** 149 | * Get the inputs for this action. 150 | * 151 | * @returns {Object} The inputs. 152 | */ 153 | getInput(): InputType { 154 | return this.input; 155 | } 156 | 157 | /** 158 | * Start performing the action. 159 | */ 160 | start(): void { 161 | this.status = 'pending'; 162 | this.thing.actionNotify(>(this)); 163 | this.performAction().then( 164 | () => this.finish(), 165 | () => this.finish() 166 | ); 167 | } 168 | 169 | /** 170 | * Override this with the code necessary to perform the action. 171 | * 172 | * @returns {Object} Promise that resolves when the action is finished. 173 | */ 174 | performAction(): Promise { 175 | return Promise.resolve(); 176 | } 177 | 178 | /** 179 | * Override this with the code necessary to cancel the action. 180 | * 181 | * @returns {Object} Promise that resolves when the action is cancelled. 182 | */ 183 | cancel(): Promise { 184 | return Promise.resolve(); 185 | } 186 | 187 | /** 188 | * Finish performing the action. 189 | */ 190 | finish(): void { 191 | this.status = 'completed'; 192 | this.timeCompleted = utils.timestamp(); 193 | this.thing.actionNotify(>(this)); 194 | } 195 | } 196 | 197 | // eslint-disable-next-line @typescript-eslint/no-namespace 198 | declare namespace Action { 199 | interface ActionMetadata { 200 | title?: string; 201 | description?: string; 202 | links?: Link[]; 203 | input?: { 204 | type?: PrimitiveJsonType; 205 | minimum?: number; 206 | maximum?: number; 207 | multipleOf?: number; 208 | enum?: readonly string[] | readonly number[]; 209 | }; 210 | } 211 | 212 | interface ActionDescription { 213 | [name: string]: { 214 | href: string; 215 | timeRequested: string; 216 | status: string; 217 | input?: InputType; 218 | timeCompleted?: string; 219 | }; 220 | } 221 | 222 | export interface ActionTypeClass { 223 | new (thing: Thing, input: InputType): Action; 224 | } 225 | } 226 | 227 | export = Action; 228 | -------------------------------------------------------------------------------- /src/event.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * High-level Event base class implementation. 3 | */ 4 | 5 | import Thing from './thing'; 6 | import * as utils from './utils'; 7 | import { AnyType, PrimitiveJsonType, Link } from './types'; 8 | 9 | /** 10 | * An Event represents an individual event from a thing. 11 | */ 12 | class Event { 13 | private thing: Thing; 14 | 15 | private name: string; 16 | 17 | private data: Data | null; 18 | 19 | private time: string; 20 | 21 | /** 22 | * Initialize the object. 23 | * 24 | * @param {Object} thing Thing this event belongs to 25 | * @param {String} name Name of the event 26 | * @param {*} data (Optional) Data associated with the event 27 | */ 28 | constructor(thing: Thing, name: string, data?: Data) { 29 | this.thing = thing; 30 | this.name = name; 31 | this.data = typeof data !== 'undefined' ? data : null; 32 | this.time = utils.timestamp(); 33 | } 34 | 35 | /** 36 | * Get the event description. 37 | * 38 | * @returns {Object} Description of the event as an object. 39 | */ 40 | asEventDescription(): Event.EventDescription { 41 | const description: Event.EventDescription = { 42 | [this.name]: { 43 | timestamp: this.time, 44 | }, 45 | }; 46 | 47 | if (this.data !== null) { 48 | description[this.name].data = (this.data); 49 | } 50 | 51 | return description; 52 | } 53 | 54 | /** 55 | * Get the thing associated with this event. 56 | * 57 | * @returns {Object} The thing. 58 | */ 59 | getThing(): Thing { 60 | return this.thing; 61 | } 62 | 63 | /** 64 | * Get the event's name. 65 | * 66 | * @returns {String} The name. 67 | */ 68 | getName(): string { 69 | return this.name; 70 | } 71 | 72 | /** 73 | * Get the event's data. 74 | * 75 | * @returns {*} The data. 76 | */ 77 | getData(): Data | null { 78 | return this.data; 79 | } 80 | 81 | /** 82 | * Get the event's timestamp. 83 | * 84 | * @returns {String} The time. 85 | */ 86 | getTime(): string { 87 | return this.time; 88 | } 89 | } 90 | 91 | // eslint-disable-next-line @typescript-eslint/no-namespace 92 | declare namespace Event { 93 | interface EventDescription { 94 | [name: string]: { 95 | timestamp: string; 96 | data?: AnyType; 97 | }; 98 | } 99 | 100 | interface EventMetadata { 101 | type?: PrimitiveJsonType; 102 | '@type'?: string; 103 | unit?: string; 104 | title?: string; 105 | description?: string; 106 | links?: Link[]; 107 | minimum?: number; 108 | maximum?: number; 109 | multipleOf?: number; 110 | enum?: readonly string[] | readonly number[]; 111 | } 112 | } 113 | 114 | export = Event; 115 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './webthing'; 2 | -------------------------------------------------------------------------------- /src/property.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * High-level Property base class implementation. 3 | */ 4 | 5 | import Ajv, { ValidateFunction } from 'ajv'; 6 | import Thing from './thing'; 7 | import Value from './value'; 8 | import { AnyType, PrimitiveJsonType, Link } from './types'; 9 | 10 | const ajv = new Ajv(); 11 | 12 | /** 13 | * A Property represents an individual state value of a thing. 14 | */ 15 | class Property { 16 | private thing: Thing; 17 | 18 | private name: string; 19 | 20 | private value: Value; 21 | 22 | private metadata: Property.PropertyMetadata; 23 | 24 | private href: string; 25 | 26 | private hrefPrefix: string; 27 | 28 | private validate: ValidateFunction; 29 | 30 | /** 31 | * Initialize the object. 32 | * 33 | * @param {Thing} thing Thing this property belongs to 34 | * @param {String} name Name of the property 35 | * @param {Value} value Value object to hold the property value 36 | * @param {Object} metadata Property metadata, i.e. type, description, unit, 37 | * etc., as an object. 38 | */ 39 | constructor( 40 | thing: Thing, 41 | name: string, 42 | value: Value, 43 | metadata: Property.PropertyMetadata 44 | ) { 45 | this.thing = thing; 46 | this.name = name; 47 | this.value = value; 48 | this.hrefPrefix = ''; 49 | this.href = `/properties/${this.name}`; 50 | this.metadata = JSON.parse(JSON.stringify(metadata || {})); 51 | 52 | delete metadata.title; 53 | delete metadata.unit; 54 | delete metadata['@type']; 55 | this.validate = ajv.compile(metadata); 56 | 57 | // Add the property change observer to notify the Thing about a property 58 | // change. 59 | this.value.on('update', () => this.thing.propertyNotify(>(this))); 60 | } 61 | 62 | /** 63 | * Validate new property value before setting it. 64 | * 65 | * @param {*} value - New value 66 | * @throws Error if the property is readonly or is invalid 67 | */ 68 | validateValue(value: ValueType): void { 69 | if (this.metadata.hasOwnProperty('readOnly') && this.metadata.readOnly) { 70 | throw new Error('Read-only property'); 71 | } 72 | 73 | const valid = this.validate(value); 74 | if (!valid) { 75 | throw new Error('Invalid property value'); 76 | } 77 | } 78 | 79 | /** 80 | * Get the property description. 81 | * 82 | * @returns {Object} Description of the property as an object. 83 | */ 84 | asPropertyDescription(): Property.PropertyDescription { 85 | const description = JSON.parse(JSON.stringify(this.metadata)); 86 | 87 | if (!description.hasOwnProperty('links')) { 88 | description.links = []; 89 | } 90 | 91 | description.links.push({ 92 | rel: 'property', 93 | href: this.hrefPrefix + this.href, 94 | }); 95 | return description; 96 | } 97 | 98 | /** 99 | * Set the prefix of any hrefs associated with this property. 100 | * 101 | * @param {String} prefix The prefix 102 | */ 103 | setHrefPrefix(prefix: string): void { 104 | this.hrefPrefix = prefix; 105 | } 106 | 107 | /** 108 | * Get the href of this property. 109 | * 110 | * @returns {String} The href 111 | */ 112 | getHref(): string { 113 | return `${this.hrefPrefix}${this.href}`; 114 | } 115 | 116 | /** 117 | * Get the current property value. 118 | * 119 | * @returns {*} The current value 120 | */ 121 | getValue(): ValueType { 122 | return this.value.get(); 123 | } 124 | 125 | /** 126 | * Set the current value of the property. 127 | * 128 | * @param {*} value The value to set 129 | */ 130 | setValue(value: ValueType): void { 131 | this.validateValue(value); 132 | this.value.set(value); 133 | } 134 | 135 | /** 136 | * Get the name of this property. 137 | * 138 | * @returns {String} The property name. 139 | */ 140 | getName(): string { 141 | return this.name; 142 | } 143 | 144 | /** 145 | * Get the thing associated with this property. 146 | * 147 | * @returns {Object} The thing. 148 | */ 149 | getThing(): Thing { 150 | return this.thing; 151 | } 152 | 153 | /** 154 | * Get the metadata associated with this property 155 | * 156 | * @returns {Object} The metadata 157 | */ 158 | getMetadata(): Property.PropertyMetadata { 159 | return this.metadata; 160 | } 161 | } 162 | 163 | // eslint-disable-next-line @typescript-eslint/no-namespace 164 | declare namespace Property { 165 | // could we use .type to strongly type the enum, minimum and maximum? 166 | interface PropertyMetadata { 167 | type?: PrimitiveJsonType; 168 | '@type'?: string; 169 | unit?: string; 170 | title?: string; 171 | description?: string; 172 | links?: Link[]; 173 | enum?: AnyType[]; 174 | readOnly?: boolean; 175 | minimum?: number; 176 | maximum?: number; 177 | multipleOf?: number; 178 | } 179 | 180 | interface PropertyDescription extends PropertyMetadata { 181 | links: Link[]; 182 | } 183 | } 184 | 185 | export = Property; 186 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Node Web Thing server implementation. 3 | */ 4 | 5 | import bodyParser from 'body-parser'; 6 | import * as dnssd from 'dnssd'; 7 | import express from 'express'; 8 | import expressWs from 'express-ws'; 9 | import * as http from 'http'; 10 | import * as https from 'https'; 11 | import * as os from 'os'; 12 | import * as utils from './utils'; 13 | import Thing from './thing'; 14 | import { AnyType } from './types'; 15 | 16 | /** 17 | * A container for a single thing. 18 | */ 19 | export class SingleThing { 20 | private thing: Thing; 21 | 22 | /** 23 | * Initialize the container. 24 | * 25 | * @param {Object} thing The thing to store 26 | */ 27 | constructor(thing: Thing) { 28 | this.thing = thing; 29 | } 30 | 31 | /** 32 | * Get the thing at the given index. 33 | */ 34 | getThing(): Thing { 35 | return this.thing; 36 | } 37 | 38 | /** 39 | * Get the list of things. 40 | */ 41 | getThings(): Thing[] { 42 | return [this.thing]; 43 | } 44 | 45 | /** 46 | * Get the mDNS server name. 47 | */ 48 | getName(): string { 49 | return this.thing.getTitle(); 50 | } 51 | } 52 | 53 | /** 54 | * A container for multiple things. 55 | */ 56 | export class MultipleThings { 57 | private things: Thing[]; 58 | 59 | private name: string; 60 | 61 | /** 62 | * Initialize the container. 63 | * 64 | * @param {Object} things The things to store 65 | * @param {String} name The mDNS server name 66 | */ 67 | constructor(things: Thing[], name: string) { 68 | this.things = things; 69 | this.name = name; 70 | } 71 | 72 | /** 73 | * Get the thing at the given index. 74 | * 75 | * @param {Number|String} idx The index 76 | */ 77 | getThing(idx?: number | string): Thing | null { 78 | idx = parseInt(idx as string); 79 | if (isNaN(idx) || idx < 0 || idx >= this.things.length) { 80 | return null; 81 | } 82 | 83 | return this.things[idx]; 84 | } 85 | 86 | /** 87 | * Get the list of things. 88 | */ 89 | getThings(): Thing[] { 90 | return this.things; 91 | } 92 | 93 | /** 94 | * Get the mDNS server name. 95 | */ 96 | getName(): string { 97 | return this.name; 98 | } 99 | } 100 | 101 | /** 102 | * Base handler that is initialized with a list of things. 103 | */ 104 | abstract class BaseHandler { 105 | protected things: SingleThing | MultipleThings; 106 | 107 | /** 108 | * Initialize the handler. 109 | * 110 | * @param {Object} things List of Things managed by the server 111 | */ 112 | constructor(things: SingleThing | MultipleThings) { 113 | this.things = things; 114 | } 115 | 116 | abstract get(req: express.Request, res: express.Response): void; 117 | 118 | /** 119 | * Get the thing this request is for. 120 | * 121 | * @param {Object} req The request object 122 | * @returns {Object} The thing, or null if not found. 123 | */ 124 | getThing(req: express.Request): Thing | null { 125 | return this.things.getThing(req.params.thingId); 126 | } 127 | } 128 | 129 | /** 130 | * Handle a request to / when the server manages multiple things. 131 | */ 132 | class ThingsHandler extends BaseHandler { 133 | /** 134 | * Handle a GET request. 135 | * 136 | * @param {Object} req The request object 137 | * @param {Object} res The response object 138 | */ 139 | get(req: express.Request, res: express.Response): void { 140 | const wsHref = `${req.secure ? 'wss' : 'ws'}://${req.headers.host}`; 141 | res.json( 142 | this.things.getThings().map((thing) => { 143 | const description = thing.asThingDescription(); 144 | description.href = thing.getHref(); 145 | description.links.push({ 146 | rel: 'alternate', 147 | href: `${wsHref}${thing.getHref()}`, 148 | }); 149 | description.base = `${req.protocol}://${req.headers.host}${thing.getHref()}`; 150 | description.securityDefinitions = { 151 | nosec_sc: { 152 | scheme: 'nosec', 153 | }, 154 | }; 155 | description.security = 'nosec_sc'; 156 | return description; 157 | }) 158 | ); 159 | } 160 | } 161 | 162 | /** 163 | * Handle a request to /. 164 | */ 165 | class ThingHandler extends BaseHandler { 166 | /** 167 | * Handle a GET request. 168 | * 169 | * @param {Object} req The request object 170 | * @param {Object} res The response object 171 | */ 172 | get(req: express.Request, res: express.Response): void { 173 | const thing = this.getThing(req); 174 | if (thing === null) { 175 | res.status(404).end(); 176 | return; 177 | } 178 | 179 | const wsHref = `${req.secure ? 'wss' : 'ws'}://${req.headers.host}`; 180 | const description = thing.asThingDescription(); 181 | description.links.push({ 182 | rel: 'alternate', 183 | href: `${wsHref}${thing.getHref()}`, 184 | }); 185 | description.base = `${req.protocol}://${req.headers.host}${thing.getHref()}`; 186 | description.securityDefinitions = { 187 | nosec_sc: { 188 | scheme: 'nosec', 189 | }, 190 | }; 191 | description.security = 'nosec_sc'; 192 | 193 | res.json(description); 194 | } 195 | 196 | /** 197 | * Handle a websocket request. 198 | * 199 | * @param {Object} ws The websocket object 200 | * @param {Object} req The request object 201 | */ 202 | ws(ws: import('ws'), req: express.Request): void { 203 | const thing = this.getThing(req); 204 | if (thing === null) { 205 | ws.send( 206 | JSON.stringify({ 207 | messageType: 'error', 208 | data: { 209 | status: '404 Not Found', 210 | message: 'The requested thing was not found', 211 | }, 212 | }) 213 | ); 214 | return; 215 | } 216 | 217 | thing.addSubscriber(ws); 218 | 219 | ws.on('error', () => thing.removeSubscriber(ws)); 220 | ws.on('close', () => thing.removeSubscriber(ws)); 221 | 222 | ws.on('message', (msg) => { 223 | let message: { 224 | messageType: string; 225 | data: Record; 226 | }; 227 | try { 228 | message = JSON.parse(msg as string); 229 | } catch (e1) { 230 | try { 231 | ws.send( 232 | JSON.stringify({ 233 | messageType: 'error', 234 | data: { 235 | status: '400 Bad Request', 236 | message: 'Parsing request failed', 237 | }, 238 | }) 239 | ); 240 | } catch (e2) { 241 | // do nothing 242 | } 243 | 244 | return; 245 | } 246 | 247 | if (!message.hasOwnProperty('messageType') || !message.hasOwnProperty('data')) { 248 | try { 249 | ws.send( 250 | JSON.stringify({ 251 | messageType: 'error', 252 | data: { 253 | status: '400 Bad Request', 254 | message: 'Invalid message', 255 | }, 256 | }) 257 | ); 258 | } catch (e) { 259 | // do nothing 260 | } 261 | 262 | return; 263 | } 264 | 265 | const messageType = message.messageType; 266 | switch (messageType) { 267 | case 'setProperty': { 268 | for (const propertyName in message.data) { 269 | try { 270 | thing.setProperty(propertyName, message.data[propertyName]); 271 | } catch (e) { 272 | ws.send( 273 | JSON.stringify({ 274 | messageType: 'error', 275 | data: { 276 | status: '400 Bad Request', 277 | message: e.message, 278 | }, 279 | }) 280 | ); 281 | } 282 | } 283 | 284 | break; 285 | } 286 | case 'requestAction': { 287 | for (const actionName in message.data) { 288 | let input = null; 289 | const actionData = >message.data[actionName]; 290 | if (actionData.hasOwnProperty('input')) { 291 | input = actionData.input; 292 | } 293 | 294 | const action = thing.performAction(actionName, input); 295 | if (action) { 296 | action.start(); 297 | } else { 298 | ws.send( 299 | JSON.stringify({ 300 | messageType: 'error', 301 | data: { 302 | status: '400 Bad Request', 303 | message: 'Invalid action request', 304 | request: message, 305 | }, 306 | }) 307 | ); 308 | } 309 | } 310 | 311 | break; 312 | } 313 | case 'addEventSubscription': { 314 | for (const eventName in message.data) { 315 | thing.addEventSubscriber(eventName, ws); 316 | } 317 | 318 | break; 319 | } 320 | default: { 321 | try { 322 | ws.send( 323 | JSON.stringify({ 324 | messageType: 'error', 325 | data: { 326 | status: '400 Bad Request', 327 | message: `Unknown messageType: ${messageType}`, 328 | request: message, 329 | }, 330 | }) 331 | ); 332 | } catch (e) { 333 | // do nothing 334 | } 335 | } 336 | } 337 | }); 338 | } 339 | } 340 | 341 | /** 342 | * Handle a request to /properties. 343 | */ 344 | class PropertiesHandler extends BaseHandler { 345 | /** 346 | * Handle a GET request. 347 | * 348 | * @param {Object} req The request object 349 | * @param {Object} res The response object 350 | */ 351 | get(req: express.Request, res: express.Response): void { 352 | const thing = this.getThing(req); 353 | if (thing === null) { 354 | res.status(404).end(); 355 | return; 356 | } 357 | 358 | res.json(thing.getProperties()); 359 | } 360 | } 361 | 362 | /** 363 | * Handle a request to /properties/. 364 | */ 365 | class PropertyHandler extends BaseHandler { 366 | /** 367 | * Handle a GET request. 368 | * 369 | * @param {Object} req The request object 370 | * @param {Object} res The response object 371 | */ 372 | get(req: express.Request, res: express.Response): void { 373 | const thing = this.getThing(req); 374 | if (thing === null) { 375 | res.status(404).end(); 376 | return; 377 | } 378 | 379 | const propertyName = req.params.propertyName; 380 | if (thing.hasProperty(propertyName)) { 381 | res.json({ [propertyName]: thing.getProperty(propertyName) }); 382 | } else { 383 | res.status(404).end(); 384 | } 385 | } 386 | 387 | /** 388 | * Handle a PUT request. 389 | * 390 | * @param {Object} req The request object 391 | * @param {Object} res The response object 392 | */ 393 | put(req: express.Request, res: express.Response): void { 394 | const thing = this.getThing(req); 395 | if (thing === null) { 396 | res.status(404).end(); 397 | return; 398 | } 399 | 400 | const propertyName = req.params.propertyName; 401 | if (!req.body.hasOwnProperty(propertyName)) { 402 | res.status(400).end(); 403 | return; 404 | } 405 | 406 | if (thing.hasProperty(propertyName)) { 407 | try { 408 | thing.setProperty(propertyName, req.body[propertyName]); 409 | } catch (e) { 410 | res.status(400).end(); 411 | return; 412 | } 413 | 414 | res.json({ [propertyName]: thing.getProperty(propertyName) }); 415 | } else { 416 | res.status(404).end(); 417 | } 418 | } 419 | } 420 | 421 | /** 422 | * Handle a request to /actions. 423 | */ 424 | class ActionsHandler extends BaseHandler { 425 | /** 426 | * Handle a GET request. 427 | * 428 | * @param {Object} req The request object 429 | * @param {Object} res The response object 430 | */ 431 | get(req: express.Request, res: express.Response): void { 432 | const thing = this.getThing(req); 433 | if (thing === null) { 434 | res.status(404).end(); 435 | return; 436 | } 437 | 438 | res.json(thing.getActionDescriptions()); 439 | } 440 | 441 | /** 442 | * Handle a POST request. 443 | * 444 | * @param {Object} req The request object 445 | * @param {Object} res The response object 446 | */ 447 | post(req: express.Request, res: express.Response): void { 448 | const thing = this.getThing(req); 449 | if (thing === null) { 450 | res.status(404).end(); 451 | return; 452 | } 453 | 454 | const keys = Object.keys(req.body); 455 | if (keys.length !== 1) { 456 | res.status(400).end(); 457 | return; 458 | } 459 | 460 | const actionName = keys[0]; 461 | let input = null; 462 | if (req.body[actionName].hasOwnProperty('input')) { 463 | input = req.body[actionName].input; 464 | } 465 | 466 | const action = thing.performAction(actionName, input); 467 | if (action) { 468 | const response = action.asActionDescription(); 469 | action.start(); 470 | 471 | res.status(201); 472 | res.json(response); 473 | } else { 474 | res.status(400).end(); 475 | } 476 | } 477 | } 478 | 479 | /** 480 | * Handle a request to /actions/. 481 | */ 482 | class ActionHandler extends BaseHandler { 483 | /** 484 | * Handle a GET request. 485 | * 486 | * @param {Object} req The request object 487 | * @param {Object} res The response object 488 | */ 489 | get(req: express.Request, res: express.Response): void { 490 | const thing = this.getThing(req); 491 | if (thing === null) { 492 | res.status(404).end(); 493 | return; 494 | } 495 | 496 | const actionName = req.params.actionName; 497 | 498 | res.json(thing.getActionDescriptions(actionName)); 499 | } 500 | 501 | /** 502 | * Handle a POST request. 503 | * 504 | * @param {Object} req The request object 505 | * @param {Object} res The response object 506 | */ 507 | post(req: express.Request, res: express.Response): void { 508 | const thing = this.getThing(req); 509 | if (thing === null) { 510 | res.status(404).end(); 511 | return; 512 | } 513 | 514 | const actionName = req.params.actionName; 515 | 516 | const keys = Object.keys(req.body); 517 | if (keys.length !== 1) { 518 | res.status(400).end(); 519 | return; 520 | } 521 | 522 | if (keys[0] !== actionName) { 523 | res.status(400).end(); 524 | return; 525 | } 526 | 527 | let input = null; 528 | if (req.body[actionName].hasOwnProperty('input')) { 529 | input = req.body[actionName].input; 530 | } 531 | 532 | const action = thing.performAction(actionName, input); 533 | if (action) { 534 | const response = action.asActionDescription(); 535 | action.start(); 536 | 537 | res.status(201); 538 | res.json(response); 539 | } else { 540 | res.status(400).end(); 541 | } 542 | } 543 | } 544 | 545 | /** 546 | * Handle a request to /actions//. 547 | */ 548 | class ActionIDHandler extends BaseHandler { 549 | /** 550 | * Handle a GET request. 551 | * 552 | * @param {Object} req The request object 553 | * @param {Object} res The response object 554 | */ 555 | get(req: express.Request, res: express.Response): void { 556 | const thing = this.getThing(req); 557 | if (thing === null) { 558 | res.status(404).end(); 559 | return; 560 | } 561 | 562 | const actionName = req.params.actionName; 563 | const actionId = req.params.actionId; 564 | 565 | const action = thing.getAction(actionName, actionId); 566 | if (action === null) { 567 | res.status(404).end(); 568 | return; 569 | } 570 | 571 | res.json(action.asActionDescription()); 572 | } 573 | 574 | /** 575 | * Handle a PUT request. 576 | * 577 | * @param {Object} req The request object 578 | * @param {Object} res The response object 579 | */ 580 | put(req: express.Request, res: express.Response): void { 581 | const thing = this.getThing(req); 582 | if (thing === null) { 583 | res.status(404).end(); 584 | return; 585 | } 586 | 587 | // TODO: this is not yet defined in the spec 588 | res.status(200).end(); 589 | } 590 | 591 | /** 592 | * Handle a DELETE request. 593 | * 594 | * @param {Object} req The request object 595 | * @param {Object} res The response object 596 | */ 597 | delete(req: express.Request, res: express.Response): void { 598 | const thing = this.getThing(req); 599 | if (thing === null) { 600 | res.status(404).end(); 601 | return; 602 | } 603 | 604 | const actionName = req.params.actionName; 605 | const actionId = req.params.actionId; 606 | 607 | if (thing.removeAction(actionName, actionId)) { 608 | res.status(204).end(); 609 | } else { 610 | res.status(404).end(); 611 | } 612 | } 613 | } 614 | 615 | /** 616 | * Handle a request to /events. 617 | */ 618 | class EventsHandler extends BaseHandler { 619 | /** 620 | * Handle a GET request. 621 | * 622 | * @param {Object} req The request object 623 | * @param {Object} res The response object 624 | */ 625 | get(req: express.Request, res: express.Response): void { 626 | const thing = this.getThing(req); 627 | if (thing === null) { 628 | res.status(404).end(); 629 | return; 630 | } 631 | 632 | res.json(thing.getEventDescriptions()); 633 | } 634 | } 635 | 636 | /** 637 | * Handle a request to /events/. 638 | */ 639 | class EventHandler extends BaseHandler { 640 | /** 641 | * Handle a GET request. 642 | * 643 | * @param {Object} req The request object 644 | * @param {Object} res The response object 645 | */ 646 | get(req: express.Request, res: express.Response): void { 647 | const thing = this.getThing(req); 648 | if (thing === null) { 649 | res.status(404).end(); 650 | return; 651 | } 652 | 653 | const eventName = req.params.eventName; 654 | 655 | res.json(thing.getEventDescriptions(eventName)); 656 | } 657 | } 658 | 659 | /** 660 | * Server to represent a Web Thing over HTTP. 661 | */ 662 | export class WebThingServer { 663 | things: SingleThing | MultipleThings; 664 | 665 | name: string; 666 | 667 | port: number; 668 | 669 | hostname: string | null; 670 | 671 | basePath: string; 672 | 673 | disableHostValidation: boolean; 674 | 675 | hosts: string[]; 676 | 677 | app: express.Express & { isTls?: boolean }; 678 | 679 | // HACK because the express types are weird 680 | server: http.Server | https.Server; 681 | 682 | router: expressWs.Router; 683 | 684 | mdns!: dnssd.Advertisement; 685 | 686 | /** 687 | * Initialize the WebThingServer. 688 | * 689 | * For documentation on the additional route handlers, see: 690 | * http://expressjs.com/en/4x/api.html#app.use 691 | * 692 | * @param {Object} things Things managed by this server -- should be of type 693 | * SingleThing or MultipleThings 694 | * @param {Number} port Port to listen on (defaults to 80) 695 | * @param {String} hostname Optional host name, i.e. mything.com 696 | * @param {Object} sslOptions SSL options to pass to the express server 697 | * @param {Object[]} additionalRoutes List of additional routes to add to 698 | * server, i.e. [{path: '..', handler: ..}] 699 | * @param {String} basePath Base URL path to use, rather than '/' 700 | * @param {Boolean} disableHostValidation Whether or not to disable host 701 | * validation -- note that this can 702 | * lead to DNS rebinding attacks 703 | */ 704 | constructor( 705 | things: SingleThing | MultipleThings, 706 | port: number | null = null, 707 | hostname: string | null = null, 708 | sslOptions: https.ServerOptions | null = null, 709 | additionalRoutes: Record[] | null = null, 710 | basePath = '/', 711 | disableHostValidation = false 712 | ) { 713 | this.things = things; 714 | this.name = things.getName(); 715 | this.port = Number(port) || (sslOptions ? 443 : 80); 716 | this.hostname = hostname; 717 | this.basePath = basePath.replace(/\/$/, ''); 718 | this.disableHostValidation = !!disableHostValidation; 719 | 720 | const systemHostname = os.hostname().toLowerCase(); 721 | this.hosts = [ 722 | 'localhost', 723 | `localhost:${port}`, 724 | `${systemHostname}.local`, 725 | `${systemHostname}.local:${port}`, 726 | ]; 727 | 728 | utils.getAddresses().forEach((address) => { 729 | this.hosts.push(address, `${address}:${port}`); 730 | }); 731 | 732 | if (hostname) { 733 | hostname = hostname.toLowerCase(); 734 | this.hosts.push(hostname, `${hostname}:${port}`); 735 | } 736 | 737 | if (things instanceof MultipleThings) { 738 | const list = things.getThings(); 739 | for (let i = 0; i < list.length; i++) { 740 | const thing = list[i]; 741 | thing.setHrefPrefix(`${this.basePath}/${i}`); 742 | } 743 | } else { 744 | things.getThing().setHrefPrefix(this.basePath); 745 | } 746 | 747 | this.app = express(); 748 | this.app.use(bodyParser.json()); 749 | 750 | // Validate Host header 751 | this.app.use((request, response, next: () => unknown) => { 752 | const host = request.headers.host; 753 | if (this.disableHostValidation || (host && this.hosts.includes(host.toLowerCase()))) { 754 | next(); 755 | } else { 756 | response.status(403).send('Forbidden'); 757 | } 758 | }); 759 | 760 | // Set CORS headers 761 | this.app.use((_request, response, next) => { 762 | response.setHeader('Access-Control-Allow-Origin', '*'); 763 | response.setHeader( 764 | 'Access-Control-Allow-Headers', 765 | 'Origin, X-Requested-With, Content-Type, Accept' 766 | ); 767 | response.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, PUT, POST, DELETE'); 768 | next(); 769 | }); 770 | 771 | if (sslOptions) { 772 | this.server = https.createServer(sslOptions); 773 | this.app.isTls = true; 774 | } else { 775 | this.server = http.createServer(); 776 | this.app.isTls = false; 777 | } 778 | 779 | expressWs(this.app, this.server); 780 | 781 | const thingsHandler = new ThingsHandler(this.things); 782 | const thingHandler = new ThingHandler(this.things); 783 | const propertiesHandler = new PropertiesHandler(this.things); 784 | const propertyHandler = new PropertyHandler(this.things); 785 | const actionsHandler = new ActionsHandler(this.things); 786 | const actionHandler = new ActionHandler(this.things); 787 | const actionIdHandler = new ActionIDHandler(this.things); 788 | const eventsHandler = new EventsHandler(this.things); 789 | const eventHandler = new EventHandler(this.things); 790 | 791 | this.router = express.Router(); 792 | 793 | if (Array.isArray(additionalRoutes)) { 794 | for (const route of additionalRoutes) { 795 | this.router.use(route.path, route.handler); 796 | } 797 | } 798 | if (this.things instanceof MultipleThings) { 799 | this.router.get('/', (req, res) => thingsHandler.get(req, res)); 800 | this.router.get('/:thingId', (req, res) => thingHandler.get(req, res)); 801 | this.router.ws('/:thingId', (ws, req) => thingHandler.ws(ws, req)); 802 | this.router.get('/:thingId/properties', (req, res) => propertiesHandler.get(req, res)); 803 | this.router.get('/:thingId/properties/:propertyName', (req, res) => 804 | propertyHandler.get(req, res) 805 | ); 806 | this.router.put('/:thingId/properties/:propertyName', (req, res) => 807 | propertyHandler.put(req, res) 808 | ); 809 | this.router.get('/:thingId/actions', (req, res) => actionsHandler.get(req, res)); 810 | this.router.post('/:thingId/actions', (req, res) => actionsHandler.post(req, res)); 811 | this.router.get('/:thingId/actions/:actionName', (req, res) => actionHandler.get(req, res)); 812 | this.router.post('/:thingId/actions/:actionName', (req, res) => actionHandler.post(req, res)); 813 | this.router.get('/:thingId/actions/:actionName/:actionId', (req, res) => 814 | actionIdHandler.get(req, res) 815 | ); 816 | this.router.put('/:thingId/actions/:actionName/:actionId', (req, res) => 817 | actionIdHandler.put(req, res) 818 | ); 819 | this.router.delete('/:thingId/actions/:actionName/:actionId', (req, res) => 820 | actionIdHandler.delete(req, res) 821 | ); 822 | this.router.get('/:thingId/events', (req, res) => eventsHandler.get(req, res)); 823 | this.router.get('/:thingId/events/:eventName', (req, res) => eventHandler.get(req, res)); 824 | } else { 825 | this.router.get('/', (req, res) => thingHandler.get(req, res)); 826 | this.router.ws('/', (ws, req) => thingHandler.ws(ws, req)); 827 | this.router.get('/properties', (req, res) => propertiesHandler.get(req, res)); 828 | this.router.get('/properties/:propertyName', (req, res) => propertyHandler.get(req, res)); 829 | this.router.put('/properties/:propertyName', (req, res) => propertyHandler.put(req, res)); 830 | this.router.get('/actions', (req, res) => actionsHandler.get(req, res)); 831 | this.router.post('/actions', (req, res) => actionsHandler.post(req, res)); 832 | this.router.get('/actions/:actionName', (req, res) => actionHandler.get(req, res)); 833 | this.router.post('/actions/:actionName', (req, res) => actionHandler.post(req, res)); 834 | this.router.get('/actions/:actionName/:actionId', (req, res) => 835 | actionIdHandler.get(req, res) 836 | ); 837 | this.router.put('/actions/:actionName/:actionId', (req, res) => 838 | actionIdHandler.put(req, res) 839 | ); 840 | this.router.delete('/actions/:actionName/:actionId', (req, res) => 841 | actionIdHandler.delete(req, res) 842 | ); 843 | this.router.get('/events', (req, res) => eventsHandler.get(req, res)); 844 | this.router.get('/events/:eventName', (req, res) => eventHandler.get(req, res)); 845 | } 846 | 847 | this.app.use(this.basePath || '/', this.router); 848 | this.server.on('request', this.app); 849 | } 850 | 851 | /** 852 | * Start listening for incoming connections. 853 | * 854 | * @returns {Promise} Promise which resolves once the server is started. 855 | */ 856 | start(): Promise { 857 | const opts: dnssd.Options = { 858 | name: this.name, 859 | txt: { 860 | path: '/', 861 | }, 862 | }; 863 | 864 | if (this.app.isTls) { 865 | opts.txt.tls = '1'; 866 | } 867 | 868 | this.mdns = new dnssd.Advertisement(new dnssd.ServiceType('_webthing._tcp'), this.port!, opts); 869 | this.mdns.on('error', (e) => { 870 | console.debug(`mDNS error: ${e}`); 871 | setTimeout(() => { 872 | this.mdns.start(); 873 | }, 10000); 874 | }); 875 | this.mdns.start(); 876 | 877 | return new Promise((resolve) => { 878 | this.server.listen({ port: this.port }, resolve); 879 | }); 880 | } 881 | 882 | /** 883 | * Stop listening. 884 | * 885 | * @param {boolean?} force - Whether or not to force shutdown immediately. 886 | * @returns {Promise} Promise which resolves once the server is stopped. 887 | */ 888 | stop(force = false): Promise { 889 | const promises: Promise[] = []; 890 | 891 | if (this.mdns) { 892 | promises.push( 893 | new Promise((resolve, reject) => { 894 | this.mdns.stop(force, (error?: unknown) => { 895 | if (error) { 896 | reject(error); 897 | } else { 898 | resolve(); 899 | } 900 | }); 901 | }) 902 | ); 903 | } 904 | 905 | promises.push( 906 | new Promise((resolve, reject) => { 907 | this.server.close((error) => { 908 | if (error) { 909 | reject(error); 910 | } else { 911 | resolve(); 912 | } 913 | }); 914 | }) 915 | ); 916 | 917 | return Promise.all(promises); 918 | } 919 | } 920 | -------------------------------------------------------------------------------- /src/thing.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * High-level Thing base class implementation. 3 | */ 4 | 5 | import Ajv from 'ajv'; 6 | import Property from './property'; 7 | import Event from './event'; 8 | import Action from './action'; 9 | import { AnyType, Link, Subscriber } from './types'; 10 | 11 | const ajv = new Ajv(); 12 | 13 | /** 14 | * A Web Thing. 15 | */ 16 | class Thing { 17 | private id: string; 18 | 19 | private title: string; 20 | 21 | private type: string[]; 22 | 23 | private context: string; 24 | 25 | private description: string; 26 | 27 | private properties: { [name: string]: Property }; 28 | 29 | private availableActions: { 30 | [actionName: string]: { 31 | metadata: Action.ActionMetadata; 32 | class: Action.ActionTypeClass; 33 | }; 34 | }; 35 | 36 | private availableEvents: { 37 | [name: string]: { 38 | metadata: Event.EventMetadata; 39 | subscribers: Set; 40 | }; 41 | }; 42 | 43 | private actions: { [name: string]: Action[] }; 44 | 45 | private events: Event[]; 46 | 47 | private subscribers = new Set(); 48 | 49 | private hrefPrefix: string; 50 | 51 | private uiHref: string | null; 52 | 53 | /** 54 | * Initialize the object. 55 | * 56 | * @param {String} id The thing's unique ID - must be a URI 57 | * @param {String} title The thing's title 58 | * @param {String} type (Optional) The thing's type(s) 59 | * @param {String} description (Optional) Description of the thing 60 | */ 61 | constructor(id: string, title: string, type: string | string[], description: string) { 62 | if (!Array.isArray(type)) { 63 | type = [type]; 64 | } 65 | 66 | this.id = id; 67 | this.title = title; 68 | this.context = 'https://webthings.io/schemas'; 69 | this.type = type || []; 70 | this.description = description || ''; 71 | this.properties = {}; 72 | this.availableActions = {}; 73 | this.availableEvents = {}; 74 | this.actions = {}; 75 | this.events = []; 76 | this.subscribers = new Set(); 77 | this.hrefPrefix = ''; 78 | this.uiHref = null; 79 | } 80 | 81 | /** 82 | * Return the thing state as a Thing Description. 83 | * 84 | * @returns {Object} Current thing state 85 | */ 86 | asThingDescription(): Thing.ThingDescription { 87 | const thing: Omit = { 88 | id: this.id, 89 | title: this.title, 90 | '@context': this.context, 91 | '@type': this.type, 92 | properties: this.getPropertyDescriptions(), 93 | actions: {}, 94 | events: {}, 95 | links: [ 96 | { 97 | rel: 'properties', 98 | href: `${this.hrefPrefix}/properties`, 99 | }, 100 | { 101 | rel: 'actions', 102 | href: `${this.hrefPrefix}/actions`, 103 | }, 104 | { 105 | rel: 'events', 106 | href: `${this.hrefPrefix}/events`, 107 | }, 108 | ], 109 | }; 110 | 111 | for (const name in this.availableActions) { 112 | thing.actions[name] = this.availableActions[name].metadata; 113 | thing.actions[name].links = [ 114 | { 115 | rel: 'action', 116 | href: `${this.hrefPrefix}/actions/${name}`, 117 | }, 118 | ]; 119 | } 120 | 121 | for (const name in this.availableEvents) { 122 | thing.events[name] = this.availableEvents[name].metadata; 123 | thing.events[name].links = [ 124 | { 125 | rel: 'event', 126 | href: `${this.hrefPrefix}/events/${name}`, 127 | }, 128 | ]; 129 | } 130 | 131 | if (this.uiHref) { 132 | thing.links.push({ 133 | rel: 'alternate', 134 | mediaType: 'text/html', 135 | href: this.uiHref, 136 | }); 137 | } 138 | 139 | if (this.description) { 140 | thing.description = this.description; 141 | } 142 | 143 | return thing as Thing.ThingDescription; 144 | } 145 | 146 | /** 147 | * Get this thing's href. 148 | * 149 | * @returns {String} The href. 150 | */ 151 | getHref(): string { 152 | if (this.hrefPrefix) { 153 | return this.hrefPrefix; 154 | } 155 | 156 | return '/'; 157 | } 158 | 159 | /** 160 | * Get this thing's UI href. 161 | * 162 | * @returns {String|null} The href. 163 | */ 164 | getUiHref(): string | null { 165 | return this.uiHref; 166 | } 167 | 168 | /** 169 | * Set the prefix of any hrefs associated with this thing. 170 | * 171 | * @param {String} prefix The prefix 172 | */ 173 | setHrefPrefix(prefix: string): void { 174 | this.hrefPrefix = prefix; 175 | 176 | for (const property of Object.values(this.properties)) { 177 | property.setHrefPrefix(prefix); 178 | } 179 | 180 | for (const actionName in this.actions) { 181 | for (const action of this.actions[actionName]) { 182 | action.setHrefPrefix(prefix); 183 | } 184 | } 185 | } 186 | 187 | /** 188 | * Set the href of this thing's custom UI. 189 | * 190 | * @param {String} href The href 191 | */ 192 | setUiHref(href: string): void { 193 | this.uiHref = href; 194 | } 195 | 196 | /** 197 | * Get the ID of the thing. 198 | * 199 | * @returns {String} The ID. 200 | */ 201 | getId(): string { 202 | return this.id; 203 | } 204 | 205 | /** 206 | * Get the title of the thing. 207 | * 208 | * @returns {String} The title. 209 | */ 210 | getTitle(): string { 211 | return this.title; 212 | } 213 | 214 | /** 215 | * Get the type context of the thing. 216 | * 217 | * @returns {String} The context. 218 | */ 219 | getContext(): string { 220 | return this.context; 221 | } 222 | 223 | /** 224 | * Get the type(s) of the thing. 225 | * 226 | * @returns {String[]} The type(s). 227 | */ 228 | getType(): string[] { 229 | return this.type; 230 | } 231 | 232 | /** 233 | * Get the description of the thing. 234 | * 235 | * @returns {String} The description. 236 | */ 237 | getDescription(): string { 238 | return this.description; 239 | } 240 | 241 | /** 242 | * Get the thing's properties as an object. 243 | * 244 | * @returns {Object} Properties, i.e. name -> description 245 | */ 246 | getPropertyDescriptions(): { [name: string]: Property.PropertyDescription } { 247 | const descriptions: { [name: string]: Property.PropertyDescription } = {}; 248 | for (const name in this.properties) { 249 | descriptions[name] = this.properties[name].asPropertyDescription(); 250 | } 251 | 252 | return descriptions; 253 | } 254 | 255 | /** 256 | * Get the thing's actions as an array. 257 | * 258 | * @param {String?} actionName Optional action name to get descriptions for 259 | * 260 | * @returns {Object} Action descriptions. 261 | */ 262 | getActionDescriptions(actionName?: string | null): Action.ActionDescription[] { 263 | const descriptions: Action.ActionDescription[] = []; 264 | 265 | if (!actionName) { 266 | for (const name in this.actions) { 267 | for (const action of this.actions[name]) { 268 | descriptions.push(action.asActionDescription()); 269 | } 270 | } 271 | } else if (this.actions.hasOwnProperty(actionName)) { 272 | for (const action of this.actions[actionName]) { 273 | descriptions.push(action.asActionDescription()); 274 | } 275 | } 276 | 277 | return descriptions; 278 | } 279 | 280 | /** 281 | * Get the thing's events as an array. 282 | * 283 | * @param {String?} eventName Optional event name to get descriptions for 284 | * 285 | * @returns {Object} Event descriptions. 286 | */ 287 | getEventDescriptions(eventName?: string | null): Event.EventDescription[] { 288 | if (!eventName) { 289 | return this.events.map((e) => e.asEventDescription()); 290 | } else { 291 | return this.events 292 | .filter((e) => e.getName() === eventName) 293 | .map((e) => e.asEventDescription()); 294 | } 295 | } 296 | 297 | /** 298 | * Add a property to this thing. 299 | * 300 | * @param {Object} property Property to add 301 | */ 302 | addProperty(property: Property): void { 303 | property.setHrefPrefix(this.hrefPrefix); 304 | this.properties[property.getName()] = property; 305 | } 306 | 307 | /** 308 | * Remove a property from this thing. 309 | * 310 | * @param {Object} property Property to remove 311 | */ 312 | removeProperty(property: Property): void { 313 | if (this.properties.hasOwnProperty(property.getName())) { 314 | delete this.properties[property.getName()]; 315 | } 316 | } 317 | 318 | /** 319 | * Find a property by name. 320 | * 321 | * @param {String} propertyName Name of the property to find 322 | * 323 | * @returns {(Object|null)} Property if found, else null 324 | */ 325 | findProperty(propertyName: string): Property | null { 326 | if (this.properties.hasOwnProperty(propertyName)) { 327 | return this.properties[propertyName]; 328 | } 329 | 330 | return null; 331 | } 332 | 333 | /** 334 | * Get a property's value. 335 | * 336 | * @param {String} propertyName Name of the property to get the value of 337 | * 338 | * @returns {*} Current property value if found, else null 339 | */ 340 | getProperty(propertyName: string): unknown | null { 341 | const prop = this.findProperty(propertyName); 342 | if (prop) { 343 | return prop.getValue(); 344 | } 345 | 346 | return null; 347 | } 348 | 349 | /** 350 | * Get a mapping of all properties and their values. 351 | * 352 | * Returns an object of propertyName -> value. 353 | */ 354 | getProperties(): Record { 355 | const props: Record = {}; 356 | for (const name in this.properties) { 357 | props[name] = this.properties[name].getValue(); 358 | } 359 | 360 | return props; 361 | } 362 | 363 | /** 364 | * Determine whether or not this thing has a given property. 365 | * 366 | * @param {String} propertyName The property to look for 367 | * 368 | * @returns {Boolean} Indication of property presence 369 | */ 370 | hasProperty(propertyName: string): boolean { 371 | return this.properties.hasOwnProperty(propertyName); 372 | } 373 | 374 | /** 375 | * Set a property value. 376 | * 377 | * @param {String} propertyName Name of the property to set 378 | * @param {*} value Value to set 379 | */ 380 | setProperty(propertyName: string, value: AnyType): void { 381 | const prop = this.findProperty(propertyName); 382 | if (!prop) { 383 | return; 384 | } 385 | 386 | prop.setValue(value); 387 | } 388 | 389 | /** 390 | * Get an action. 391 | * 392 | * @param {String} actionName Name of the action 393 | * @param {String} actionId ID of the action 394 | * @returns {Object} The requested action if found, else null 395 | */ 396 | getAction(actionName: string, actionId: string): Action | null { 397 | if (!this.actions.hasOwnProperty(actionName)) { 398 | return null; 399 | } 400 | 401 | for (const action of this.actions[actionName]) { 402 | if (action.getId() === actionId) { 403 | return action; 404 | } 405 | } 406 | 407 | return null; 408 | } 409 | 410 | /** 411 | * Add a new event and notify subscribers. 412 | * 413 | * @param {Object} event The event that occurred 414 | */ 415 | addEvent(event: Event): void { 416 | this.events.push(event); 417 | this.eventNotify(event); 418 | } 419 | 420 | /** 421 | * Add an available event. 422 | * 423 | * @param {String} name Name of the event 424 | * @param {Object} metadata Event metadata, i.e. type, description, etc., as 425 | * an object. 426 | */ 427 | addAvailableEvent(name: string, metadata: Event.EventMetadata): void { 428 | if (!metadata) { 429 | metadata = {}; 430 | } 431 | 432 | this.availableEvents[name] = { 433 | metadata: metadata, 434 | subscribers: new Set(), 435 | }; 436 | } 437 | 438 | /** 439 | * Perform an action on the thing. 440 | * 441 | * @param {String} actionName Name of the action 442 | * @param {Object} input Any action inputs 443 | * @returns {Object} The action that was created. 444 | */ 445 | performAction( 446 | actionName: string, 447 | input: InputType | null 448 | ): Action | undefined { 449 | input = input || null; 450 | 451 | if (!this.availableActions.hasOwnProperty(actionName)) { 452 | return; 453 | } 454 | 455 | const actionType = this.availableActions[actionName]; 456 | 457 | if (actionType.metadata.hasOwnProperty('input')) { 458 | const schema = JSON.parse(JSON.stringify(actionType.metadata.input)); 459 | 460 | if (schema.hasOwnProperty('properties')) { 461 | const props: Record[] = Object.values(schema.properties); 462 | 463 | for (const prop of props) { 464 | delete prop.title; 465 | delete prop.unit; 466 | delete prop['@type']; 467 | } 468 | } 469 | 470 | const valid = ajv.validate(schema, input); 471 | if (!valid) { 472 | return; 473 | } 474 | } 475 | 476 | const action: Action = >( 477 | new actionType.class(this, (input)) 478 | ); 479 | action.setHrefPrefix(this.hrefPrefix); 480 | this.actionNotify(>(action)); 481 | this.actions[actionName].push(>(action)); 482 | return action; 483 | } 484 | 485 | /** 486 | * Remove an existing action. 487 | * 488 | * @param {String} actionName Name of the action 489 | * @param {String} actionId ID of the action 490 | * @returns boolean indicating the presence of the action. 491 | */ 492 | removeAction(actionName: string, actionId: string): boolean { 493 | const action = this.getAction(actionName, actionId); 494 | if (action === null) { 495 | return false; 496 | } 497 | 498 | action.cancel(); 499 | for (let i = 0; i < this.actions[actionName].length; ++i) { 500 | if (this.actions[actionName][i].getId() === actionId) { 501 | this.actions[actionName].splice(i, 1); 502 | break; 503 | } 504 | } 505 | 506 | return true; 507 | } 508 | 509 | /** 510 | * Add an available action. 511 | * 512 | * @param {String} name Name of the action 513 | * @param {Object} metadata Action metadata, i.e. type, description, etc., as 514 | * an object. 515 | * @param {Object} cls Class to instantiate for this action 516 | */ 517 | addAvailableAction( 518 | name: string, 519 | metadata: Action.ActionMetadata | null, 520 | cls: Action.ActionTypeClass 521 | ): void { 522 | if (!metadata) { 523 | metadata = {}; 524 | } 525 | 526 | this.availableActions[name] = { 527 | metadata: metadata, 528 | class: cls, 529 | }; 530 | this.actions[name] = []; 531 | } 532 | 533 | /** 534 | * Add a new websocket subscriber. 535 | * 536 | * @param {Object} ws The websocket 537 | */ 538 | addSubscriber(ws: Subscriber): void { 539 | this.subscribers.add(ws); 540 | } 541 | 542 | /** 543 | * Remove a websocket subscriber. 544 | * 545 | */ 546 | removeSubscriber(ws: Subscriber): void { 547 | if (this.subscribers.has(ws)) { 548 | this.subscribers.delete(ws); 549 | } 550 | 551 | for (const name in this.availableEvents) { 552 | this.removeEventSubscriber(name, ws); 553 | } 554 | } 555 | 556 | /** 557 | * Add a new websocket subscriber to an event. 558 | * 559 | * @param {String} name Name of the event 560 | * @param {Subscriber} ws The websocket 561 | */ 562 | addEventSubscriber(name: string, ws: Subscriber): void { 563 | if (this.availableEvents.hasOwnProperty(name)) { 564 | this.availableEvents[name].subscribers.add(ws); 565 | } 566 | } 567 | 568 | /** 569 | * Remove a websocket subscriber from an event. 570 | * 571 | * @param {String} name Name of the event 572 | * @param {Object} ws The websocket 573 | */ 574 | removeEventSubscriber(name: string, ws: Subscriber): void { 575 | if ( 576 | this.availableEvents.hasOwnProperty(name) && 577 | this.availableEvents[name].subscribers.has(ws) 578 | ) { 579 | this.availableEvents[name].subscribers.delete(ws); 580 | } 581 | } 582 | 583 | /** 584 | * Notify all subscribers of a property change. 585 | * 586 | * @param {Object} property The property that changed 587 | */ 588 | propertyNotify(property: Property): void { 589 | const message = JSON.stringify({ 590 | messageType: 'propertyStatus', 591 | data: { 592 | [property.getName()]: property.getValue(), 593 | }, 594 | }); 595 | 596 | for (const subscriber of this.subscribers) { 597 | try { 598 | subscriber.send(message); 599 | } catch (e) { 600 | // do nothing 601 | } 602 | } 603 | } 604 | 605 | /** 606 | * Notify all subscribers of an action status change. 607 | * 608 | * @param {Object} action The action whose status changed 609 | */ 610 | actionNotify(action: Action): void { 611 | const message = JSON.stringify({ 612 | messageType: 'actionStatus', 613 | data: action.asActionDescription(), 614 | }); 615 | 616 | for (const subscriber of this.subscribers) { 617 | try { 618 | subscriber.send(message); 619 | } catch (e) { 620 | // do nothing 621 | } 622 | } 623 | } 624 | 625 | /** 626 | * Notify all subscribers of an event. 627 | * 628 | * @param {Object} event The event that occurred 629 | */ 630 | eventNotify(event: Event): void { 631 | if (!this.availableEvents.hasOwnProperty(event.getName())) { 632 | return; 633 | } 634 | 635 | const message = JSON.stringify({ 636 | messageType: 'event', 637 | data: event.asEventDescription(), 638 | }); 639 | 640 | for (const subscriber of this.availableEvents[event.getName()].subscribers) { 641 | try { 642 | subscriber.send(message); 643 | } catch (e) { 644 | // do nothing 645 | } 646 | } 647 | } 648 | } 649 | 650 | // eslint-disable-next-line @typescript-eslint/no-namespace 651 | declare namespace Thing { 652 | export interface SecurityScheme { 653 | '@type'?: string | string[]; 654 | scheme: string; 655 | description?: string; 656 | descriptions?: { [lang: string]: string }; 657 | proxy?: string; 658 | } 659 | 660 | export interface ThingDescription { 661 | id: string; 662 | title: string; 663 | name: string; 664 | href: string; 665 | '@context': string; 666 | '@type': string[]; 667 | properties: { [name: string]: Property.PropertyDescription }; 668 | links: Link[]; 669 | actions: { [name: string]: Action.ActionMetadata }; 670 | events: { [name: string]: Event.EventMetadata }; 671 | description?: string; 672 | base?: string; 673 | securityDefinitions?: { [security: string]: SecurityScheme }; 674 | security?: string; 675 | } 676 | } 677 | 678 | export = Thing; 679 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type PrimitiveJsonType = 2 | | 'null' 3 | | 'boolean' 4 | | 'object' 5 | | 'array' 6 | | 'number' 7 | | 'integer' 8 | | 'string'; 9 | 10 | export type AnyType = null | boolean | number | string | Record | unknown[]; 11 | 12 | export interface Link { 13 | rel: string; 14 | href: string; 15 | mediaType?: string; 16 | } 17 | 18 | export interface Subscriber { 19 | send(message: string): void; 20 | } 21 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Utility functions. 3 | */ 4 | 5 | import * as os from 'os'; 6 | 7 | /** 8 | * Get the current time. 9 | * 10 | * @returns {String} The current time in the form YYYY-mm-ddTHH:MM:SS+00:00 11 | */ 12 | export function timestamp(): string { 13 | const date = new Date().toISOString(); 14 | return date.replace(/\.\d{3}Z/, '+00:00'); 15 | } 16 | 17 | /** 18 | * Get all IP addresses. 19 | * 20 | * @returns {string[]} Array of addresses. 21 | */ 22 | export function getAddresses(): string[] { 23 | const addresses = new Set(); 24 | 25 | const ifaces = os.networkInterfaces(); 26 | Object.keys(ifaces).forEach((iface) => { 27 | ifaces[iface]!.forEach((addr) => { 28 | const address = addr.address.toLowerCase(); 29 | 30 | // Filter out link-local addresses. 31 | if (addr.family === 'IPv6' && !address.startsWith('fe80:')) { 32 | addresses.add(`[${address}]`); 33 | } else if (addr.family === 'IPv4' && !address.startsWith('169.254.')) { 34 | addresses.add(address); 35 | } 36 | }); 37 | }); 38 | 39 | return Array.from(addresses).sort(); 40 | } 41 | -------------------------------------------------------------------------------- /src/value.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * An observable, settable value interface. 3 | */ 4 | 5 | import { EventEmitter } from 'events'; 6 | import { AnyType } from './types'; 7 | 8 | /** 9 | * A property value. 10 | * 11 | * This is used for communicating between the Thing representation and the 12 | * actual physical thing implementation. 13 | * 14 | * Notifies all observers when the underlying value changes through an external 15 | * update (command to turn the light off) or if the underlying sensor reports a 16 | * new value. 17 | */ 18 | class Value extends EventEmitter { 19 | private lastValue: ValueType; 20 | 21 | private valueForwarder: Value.Forwarder | null; 22 | 23 | /** 24 | * Initialize the object. 25 | * 26 | * @param {*} initialValue The initial value 27 | * @param {function?} valueForwarder The method that updates the actual value 28 | * on the thing 29 | */ 30 | constructor(initialValue: ValueType, valueForwarder: Value.Forwarder | null = null) { 31 | super(); 32 | this.lastValue = initialValue; 33 | this.valueForwarder = valueForwarder; 34 | } 35 | 36 | /** 37 | * Set a new value for this thing. 38 | * 39 | * @param {*} value Value to set 40 | */ 41 | set(value: ValueType): void { 42 | if (this.valueForwarder) { 43 | this.valueForwarder(value); 44 | } 45 | 46 | this.notifyOfExternalUpdate(value); 47 | } 48 | 49 | /** 50 | * Return the last known value from the underlying thing. 51 | * 52 | * @returns the value. 53 | */ 54 | get(): ValueType { 55 | return this.lastValue; 56 | } 57 | 58 | /** 59 | * Notify observers of a new value. 60 | * 61 | * @param {*} value New value 62 | */ 63 | notifyOfExternalUpdate(value: ValueType): void { 64 | if (typeof value !== 'undefined' && value !== null && value !== this.lastValue) { 65 | this.lastValue = value; 66 | this.emit('update', value); 67 | } 68 | } 69 | } 70 | 71 | declare namespace Value { 72 | export type Forwarder = (value: T) => void; 73 | } 74 | 75 | export = Value; 76 | -------------------------------------------------------------------------------- /src/webthing.ts: -------------------------------------------------------------------------------- 1 | import Action from './action'; 2 | import Event from './event'; 3 | import Property from './property'; 4 | import Thing from './thing'; 5 | import Value from './value'; 6 | 7 | export { Action, Event, Property, Thing, Value }; 8 | 9 | export * from './server'; 10 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | pushd example 4 | npm install 5 | popd 6 | 7 | # clone the webthing-tester 8 | if [ ! -d webthing-tester ]; then 9 | git clone https://github.com/WebThingsIO/webthing-tester 10 | fi 11 | pip3 install --user -r webthing-tester/requirements.txt 12 | 13 | export NODE_PATH=. 14 | # build and test the single-thing example 15 | node example/single-thing.js & 16 | EXAMPLE_PID=$! 17 | sleep 5 18 | ./webthing-tester/test-client.py 19 | kill -15 $EXAMPLE_PID 20 | 21 | # build and test the multiple-things example 22 | node example/multiple-things.js & 23 | EXAMPLE_PID=$! 24 | sleep 5 25 | ./webthing-tester/test-client.py --path-prefix "/0" 26 | kill -15 $EXAMPLE_PID 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "lib": [ 7 | "es2018", 8 | "dom" 9 | ], 10 | "declaration": true, 11 | "declarationMap": true, 12 | "sourceMap": true, 13 | "rootDir": "src", 14 | "outDir": "lib", 15 | "strict": true, 16 | "noImplicitAny": true, 17 | "strictNullChecks": true, 18 | "strictFunctionTypes": true, 19 | "strictBindCallApply": true, 20 | "strictPropertyInitialization": true, 21 | "noImplicitThis": true, 22 | "alwaysStrict": true, 23 | "noUnusedLocals": true, 24 | "noUnusedParameters": true, 25 | "noImplicitReturns": true, 26 | "noFallthroughCasesInSwitch": true, 27 | "esModuleInterop": true 28 | } 29 | } 30 | --------------------------------------------------------------------------------