Repository: mozilla-iot/webthing-node Branch: master Commit: 4a6d1e237301 Files: 44 Total size: 127.8 KB Directory structure: gitextract_p46l49r7/ ├── .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 │ │ ├── board/ │ │ │ ├── artik1020.js │ │ │ ├── artik530.js │ │ │ ├── edison.js │ │ │ ├── flex-phat.js │ │ │ ├── play-phat.js │ │ │ └── traffic-phat.js │ │ ├── gpio/ │ │ │ └── gpio-property.js │ │ ├── package.json │ │ └── pwm/ │ │ └── pwm-property.js │ ├── simplest-thing.js │ └── single-thing.js ├── 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 ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ Dockerfile .git/ #.gitignore contents: *~ *.swp *.tgz node_modules/ ================================================ FILE: .eslintignore ================================================ **/node_modules/ /.eslintrc.js tmp/ lib/*.js lib/*.d.ts index.js index.d.ts webthing.js webthing.d.ts *.eslintrc.js ================================================ FILE: .eslintrc.js ================================================ module.exports = { 'env': { 'browser': true, 'commonjs': true, 'es6': true, 'jasmine': true, 'jest': true, 'mocha': true, 'node': true }, 'extends': [ 'eslint:recommended', 'plugin:@typescript-eslint/eslint-recommended', 'plugin:@typescript-eslint/recommended', 'prettier', 'prettier/@typescript-eslint' ], 'parser': '@typescript-eslint/parser', 'parserOptions': { 'sourceType': 'module' }, 'plugins': [ '@typescript-eslint' ], 'rules': { 'arrow-parens': [ 'error', 'always' ], 'arrow-spacing': 'error', 'block-scoped-var': 'error', 'block-spacing': [ 'error', 'always' ], '@typescript-eslint/brace-style': [ 'error', '1tbs' ], '@typescript-eslint/comma-dangle': [ 'error', 'always-multiline' ], '@typescript-eslint/comma-spacing': 'error', 'comma-style': [ 'error', 'last' ], 'computed-property-spacing': [ 'error', 'never' ], 'curly': 'error', '@typescript-eslint/default-param-last': 'error', 'dot-notation': 'error', 'eol-last': 'error', '@typescript-eslint/explicit-module-boundary-types': [ 'warn', { 'allowArgumentsExplicitlyTypedAsAny': true } ], '@typescript-eslint/explicit-function-return-type': [ 'error', { 'allowExpressions': true } ], '@typescript-eslint/func-call-spacing': [ 'error', 'never' ], '@typescript-eslint/indent': [ 'error', 2, { 'ArrayExpression': 'first', 'CallExpression': { 'arguments': 'first' }, 'FunctionDeclaration': { 'parameters': 'first' }, 'FunctionExpression': { 'parameters': 'first' }, 'ObjectExpression': 'first', 'SwitchCase': 1 } ], 'key-spacing': [ 'error', { 'afterColon': true, 'beforeColon': false, 'mode': 'strict' } ], '@typescript-eslint/keyword-spacing': 'off', 'linebreak-style': [ 'error', 'unix' ], '@typescript-eslint/lines-between-class-members': [ 'error', 'always' ], 'max-len': [ 'error', 100 ], '@typescript-eslint/member-delimiter-style': [ 'error', { 'singleline': { 'delimiter': 'semi', 'requireLast': false }, 'multiline': { 'delimiter': 'semi', 'requireLast': true } } ], 'multiline-ternary': [ 'error', 'always-multiline' ], 'no-console': 0, '@typescript-eslint/no-duplicate-imports': 'error', 'no-eval': 'error', '@typescript-eslint/no-explicit-any': [ 'error', { 'ignoreRestArgs': true } ], 'no-floating-decimal': 'error', 'no-implicit-globals': 'error', 'no-implied-eval': 'error', 'no-lonely-if': 'error', 'no-multi-spaces': [ 'error', { 'ignoreEOLComments': true } ], 'no-multiple-empty-lines': 'error', '@typescript-eslint/no-namespace': [ 'error', { 'allowDeclarations': true } ], '@typescript-eslint/no-non-null-assertion': 'off', 'no-prototype-builtins': 'off', 'no-return-assign': 'error', 'no-script-url': 'error', 'no-self-compare': 'error', 'no-sequences': 'error', 'no-shadow-restricted-names': 'error', 'no-tabs': 'error', 'no-throw-literal': 'error', 'no-trailing-spaces': 'error', 'no-undefined': 'error', 'no-unmodified-loop-condition': 'error', '@typescript-eslint/no-unused-vars': [ 'error', { 'argsIgnorePattern': '^_', 'varsIgnorePattern': '^_' } ], 'no-useless-computed-key': 'error', 'no-useless-concat': 'error', '@typescript-eslint/no-useless-constructor': 'error', 'no-useless-return': 'error', 'no-var': 'error', 'no-void': 'error', 'no-whitespace-before-property': 'error', 'object-curly-newline': [ 'error', { 'consistent': true } ], 'object-curly-spacing': [ 'error', 'always' ], 'object-property-newline': [ 'error', { 'allowMultiplePropertiesPerLine': true } ], 'operator-linebreak': [ 'error', 'after', { 'overrides': { '?': 'before', ':': 'before' } } ], 'padded-blocks': [ 'error', { 'blocks': 'never' } ], 'prefer-const': 'error', '@typescript-eslint/prefer-for-of': 'error', 'prefer-template': 'error', 'quote-props': [ 'error', 'as-needed' ], '@typescript-eslint/quotes': [ 'error', 'single', { 'allowTemplateLiterals': true } ], '@typescript-eslint/semi': [ 'error', 'always' ], 'semi-spacing': [ 'error', { 'after': true, 'before': false } ], 'semi-style': [ 'error', 'last' ], 'space-before-blocks': [ 'error', 'always' ], '@typescript-eslint/space-before-function-paren': [ 'error', { 'anonymous': 'always', 'asyncArrow': 'always', 'named': 'never' } ], 'space-in-parens': [ 'error', 'never' ], '@typescript-eslint/space-infix-ops': 'error', 'space-unary-ops': [ 'error', { 'nonwords': false, 'words': true } ], 'spaced-comment': [ 'error', 'always', { 'block': { 'balanced': true, 'exceptions': [ '*' ] } } ], 'switch-colon-spacing': [ 'error', { 'after': true, 'before': false } ], 'template-curly-spacing': [ 'error', 'never' ], '@typescript-eslint/type-annotation-spacing': 'error', 'yoda': 'error' }, 'overrides': [ { 'files': [ 'example/**/*.js' ], 'rules': { '@typescript-eslint/explicit-function-return-type': 'off', '@typescript-eslint/no-var-requires': 'off' } } ] }; ================================================ FILE: .github/workflows/build.yml ================================================ name: Build on: pull_request: branches: - master push: branches: - master jobs: build: runs-on: ubuntu-latest strategy: matrix: node-version: [ 10, 12, 14, ] steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 with: python-version: '3.9' - uses: actions/setup-node@v1 with: node-version: ${{ matrix.node-version }} - name: Install dependencies run: | npm ci - name: Check formatting run: | npx prettier -c 'src/*.ts' 'example/**/*.{js,ts}' - name: Lint with eslint run: | npm run lint - name: Transpile ts files run: | npm run build - name: Run integration tests run: | ./test.sh ================================================ FILE: .github/workflows/projects.yml ================================================ name: Add new issues to the specified project column on: issues: types: [opened] jobs: add-new-issues-to-project-column: runs-on: ubuntu-latest steps: - name: add-new-issues-to-organization-based-project-column uses: docker://takanabe/github-actions-automate-projects:v0.0.1 env: GITHUB_TOKEN: ${{ secrets.CI_TOKEN }} GITHUB_PROJECT_URL: https://github.com/orgs/WebThingsIO/projects/4 GITHUB_PROJECT_COLUMN_NAME: To do ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: tags: - v[0-9]+.[0-9]+.[0-9]+ jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-node@v1 with: node-version: 12 registry-url: 'https://registry.npmjs.org' - name: Set release version run: echo "RELEASE_VERSION=${GITHUB_REF:11}" >> $GITHUB_ENV - name: Create Release id: create_release uses: actions/create-release@v1.0.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: ${{ github.ref }} release_name: Release ${{ env.RELEASE_VERSION }} draft: false prerelease: false - name: Build project run: | npm ci npm run lint npm run build env: CI: true - name: Publish to npm run: npm publish env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} ================================================ FILE: .gitignore ================================================ **/iotjs_modules **/node_modules *.swp *.tgz *~ .#* index.d.ts index.d.ts.map index.js index.js.map lib/*.js lib/*.js.map lib/*.d.ts lib/*.d.ts.map npm-debug.log tmp/ webthing.d.ts webthing.d.ts.map webthing.js webthing.js.map example/package-lock.json ================================================ FILE: .npmignore ================================================ .eslintrc.js test-server.js *.ts !*.d.ts ================================================ FILE: .prettierrc.json ================================================ { "printWidth": 100, "singleQuote": true } ================================================ FILE: CHANGELOG.md ================================================ # webthing Changelog ## [Unreleased] ## [0.15.0] - 2021-01-05 ### Added - Parameter to disable host validation in server. ## [0.14.0] - 2020-12-14 ### Changed - Converted project to TypeScript. ## [0.13.1] - 2020-11-28 ### Fixed - Test fixes. ## [0.13.0] - 2020-09-23 ### Changed - Update author and URLs to indicate new project home. ## [0.12.3] - 2020-06-18 ### Changed - mDNS record now indicates TLS support. ## [0.12.2] - 2020-05-04 ### Changed - Invalid POST requests to action resources now generate an error status. ## [0.12.1] - 2020-03-27 ### Changed - Updated dependencies. ## [0.12.0] - 2019-07-12 ### Changed - Things now use `title` rather than `name`. - Things now require a unique ID in the form of a URI. ### Added - Support for `id`, `base`, `security`, and `securityDefinitions` keys in thing description. ## [0.11.1] - 2019-06-05 ### Added - Ability to set a base URL path on server. ## [0.11.0] - 2019-01-16 ### Changed - WebThingServer constructor can now take a list of additional API routes. ### Fixed - Properties could not include a custom `links` array at initialization. ## [0.10.0] - 2018-11-30 ### Changed - Property, Action, and Event description now use `links` rather than `href`. - [Spec PR](https://github.com/WebThingsIO/wot/pull/119) [Unreleased]: https://github.com/WebThingsIO/webthing-node/compare/v0.15.0...HEAD [0.15.0]: https://github.com/WebThingsIO/webthing-node/compare/v0.14.0...v0.15.0 [0.14.0]: https://github.com/WebThingsIO/webthing-node/compare/v0.13.1...v0.14.0 [0.13.1]: https://github.com/WebThingsIO/webthing-node/compare/v0.13.0...v0.13.1 [0.13.0]: https://github.com/WebThingsIO/webthing-node/compare/v0.12.3...v0.13.0 [0.12.3]: https://github.com/WebThingsIO/webthing-node/compare/v0.12.2...v0.12.3 [0.12.2]: https://github.com/WebThingsIO/webthing-node/compare/v0.12.1...v0.12.2 [0.12.1]: https://github.com/WebThingsIO/webthing-node/compare/v0.12.0...v0.12.1 [0.12.0]: https://github.com/WebThingsIO/webthing-node/compare/v0.11.1...v0.12.0 [0.11.1]: https://github.com/WebThingsIO/webthing-node/compare/v0.11.0...v0.11.1 [0.11.0]: https://github.com/WebThingsIO/webthing-node/compare/v0.10.0...v0.11.0 [0.10.0]: https://github.com/WebThingsIO/webthing-node/compare/v0.9.1...v0.10.0 ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Community Participation Guidelines This repository is governed by Mozilla's code of conduct and etiquette guidelines. For more details, please read the [Mozilla Community Participation Guidelines](https://www.mozilla.org/about/governance/policies/participation/). ## How to Report 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. ================================================ FILE: Dockerfile ================================================ #!/bin/echo docker build . -f # -*- coding: utf-8 -*- # SPDX-License-Identifier: MPL-2.0 #{ # Copyright: 2018-present Samsung Electronics France SAS, and other contributors # # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/ . #} FROM node:10-buster LABEL maintainer="Philippe Coval (p.coval@samsung.com)" ENV DEBIAN_FRONTEND noninteractive ENV LC_ALL en_US.UTF-8 ENV LANG ${LC_ALL} ENV project webthing-node COPY . /usr/local/${project}/${project} WORKDIR /usr/local/${project}/${project} RUN echo "#log: ${project}: Preparing sources" \ && set -x \ && which npm \ && npm --version \ && npm install \ && npm run test || echo "TODO: check package.json" \ && sync EXPOSE 8888 WORKDIR /usr/local/${project}/${project} ENTRYPOINT [ "/usr/local/bin/npm", "run" ] CMD [ "start" ] ================================================ FILE: LICENSE ================================================ Mozilla Public License Version 2.0 ================================== 1. Definitions -------------- 1.1. "Contributor" means each individual or legal entity that creates, contributes to the creation of, or owns Covered Software. 1.2. "Contributor Version" means the combination of the Contributions of others (if any) used by a Contributor and that particular Contributor's Contribution. 1.3. "Contribution" means Covered Software of a particular Contributor. 1.4. "Covered Software" means Source Code Form to which the initial Contributor has attached the notice in Exhibit A, the Executable Form of such Source Code Form, and Modifications of such Source Code Form, in each case including portions thereof. 1.5. "Incompatible With Secondary Licenses" means (a) that the initial Contributor has attached the notice described in Exhibit B to the Covered Software; or (b) that the Covered Software was made available under the terms of version 1.1 or earlier of the License, but not also under the terms of a Secondary License. 1.6. "Executable Form" means any form of the work other than Source Code Form. 1.7. "Larger Work" means a work that combines Covered Software with other material, in a separate file or files, that is not Covered Software. 1.8. "License" means this document. 1.9. "Licensable" means having the right to grant, to the maximum extent possible, whether at the time of the initial grant or subsequently, any and all of the rights conveyed by this License. 1.10. "Modifications" means any of the following: (a) any file in Source Code Form that results from an addition to, deletion from, or modification of the contents of Covered Software; or (b) any new file in Source Code Form that contains any Covered Software. 1.11. "Patent Claims" of a Contributor means any patent claim(s), including without limitation, method, process, and apparatus claims, in any patent Licensable by such Contributor that would be infringed, but for the grant of the License, by the making, using, selling, offering for sale, having made, import, or transfer of either its Contributions or its Contributor Version. 1.12. "Secondary License" means either the GNU General Public License, Version 2.0, the GNU Lesser General Public License, Version 2.1, the GNU Affero General Public License, Version 3.0, or any later versions of those licenses. 1.13. "Source Code Form" means the form of the work preferred for making modifications. 1.14. "You" (or "Your") means an individual or a legal entity exercising rights under this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with You. For purposes of this definition, "control" means (a) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (b) ownership of more than fifty percent (50%) of the outstanding shares or beneficial ownership of such entity. 2. License Grants and Conditions -------------------------------- 2.1. Grants Each Contributor hereby grants You a world-wide, royalty-free, non-exclusive license: (a) under intellectual property rights (other than patent or trademark) Licensable by such Contributor to use, reproduce, make available, modify, display, perform, distribute, and otherwise exploit its Contributions, either on an unmodified basis, with Modifications, or as part of a Larger Work; and (b) under Patent Claims of such Contributor to make, use, sell, offer for sale, have made, import, and otherwise transfer either its Contributions or its Contributor Version. 2.2. Effective Date The licenses granted in Section 2.1 with respect to any Contribution become effective for each Contribution on the date the Contributor first distributes such Contribution. 2.3. Limitations on Grant Scope The licenses granted in this Section 2 are the only rights granted under this License. No additional rights or licenses will be implied from the distribution or licensing of Covered Software under this License. Notwithstanding Section 2.1(b) above, no patent license is granted by a Contributor: (a) for any code that a Contributor has removed from Covered Software; or (b) for infringements caused by: (i) Your and any other third party's modifications of Covered Software, or (ii) the combination of its Contributions with other software (except as part of its Contributor Version); or (c) under Patent Claims infringed by Covered Software in the absence of its Contributions. This License does not grant any rights in the trademarks, service marks, or logos of any Contributor (except as may be necessary to comply with the notice requirements in Section 3.4). 2.4. Subsequent Licenses No Contributor makes additional grants as a result of Your choice to distribute the Covered Software under a subsequent version of this License (see Section 10.2) or under the terms of a Secondary License (if permitted under the terms of Section 3.3). 2.5. Representation Each Contributor represents that the Contributor believes its Contributions are its original creation(s) or it has sufficient rights to grant the rights to its Contributions conveyed by this License. 2.6. Fair Use This License is not intended to limit any rights You have under applicable copyright doctrines of fair use, fair dealing, or other equivalents. 2.7. Conditions Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in Section 2.1. 3. Responsibilities ------------------- 3.1. Distribution of Source Form All distribution of Covered Software in Source Code Form, including any Modifications that You create or to which You contribute, must be under the terms of this License. You must inform recipients that the Source Code Form of the Covered Software is governed by the terms of this License, and how they can obtain a copy of this License. You may not attempt to alter or restrict the recipients' rights in the Source Code Form. 3.2. Distribution of Executable Form If You distribute Covered Software in Executable Form then: (a) such Covered Software must also be made available in Source Code Form, as described in Section 3.1, and You must inform recipients of the Executable Form how they can obtain a copy of such Source Code Form by reasonable means in a timely manner, at a charge no more than the cost of distribution to the recipient; and (b) You may distribute such Executable Form under the terms of this License, or sublicense it under different terms, provided that the license for the Executable Form does not attempt to limit or alter the recipients' rights in the Source Code Form under this License. 3.3. Distribution of a Larger Work You may create and distribute a Larger Work under terms of Your choice, provided that You also comply with the requirements of this License for the Covered Software. If the Larger Work is a combination of Covered Software with a work governed by one or more Secondary Licenses, and the Covered Software is not Incompatible With Secondary Licenses, this License permits You to additionally distribute such Covered Software under the terms of such Secondary License(s), so that the recipient of the Larger Work may, at their option, further distribute the Covered Software under the terms of either this License or such Secondary License(s). 3.4. Notices You may not remove or alter the substance of any license notices (including copyright notices, patent notices, disclaimers of warranty, or limitations of liability) contained within the Source Code Form of the Covered Software, except that You may alter any license notices to the extent required to remedy known factual inaccuracies. 3.5. Application of Additional Terms You may choose to offer, and to charge a fee for, warranty, support, indemnity or liability obligations to one or more recipients of Covered Software. However, You may do so only on Your own behalf, and not on behalf of any Contributor. You must make it absolutely clear that any such warranty, support, indemnity, or liability obligation is offered by You alone, and You hereby agree to indemnify every Contributor for any liability incurred by such Contributor as a result of warranty, support, indemnity or liability terms You offer. You may include additional disclaimers of warranty and limitations of liability specific to any jurisdiction. 4. Inability to Comply Due to Statute or Regulation --------------------------------------------------- If it is impossible for You to comply with any of the terms of this License with respect to some or all of the Covered Software due to statute, judicial order, or regulation then You must: (a) comply with the terms of this License to the maximum extent possible; and (b) describe the limitations and the code they affect. Such description must be placed in a text file included with all distributions of the Covered Software under this License. Except to the extent prohibited by statute or regulation, such description must be sufficiently detailed for a recipient of ordinary skill to be able to understand it. 5. Termination -------------- 5.1. The rights granted under this License will terminate automatically if You fail to comply with any of its terms. However, if You become compliant, then the rights granted under this License from a particular Contributor are reinstated (a) provisionally, unless and until such Contributor explicitly and finally terminates Your grants, and (b) on an ongoing basis, if such Contributor fails to notify You of the non-compliance by some reasonable means prior to 60 days after You have come back into compliance. Moreover, Your grants from a particular Contributor are reinstated on an ongoing basis if such Contributor notifies You of the non-compliance by some reasonable means, this is the first time You have received notice of non-compliance with this License from such Contributor, and You become compliant prior to 30 days after Your receipt of the notice. 5.2. If You initiate litigation against any entity by asserting a patent infringement claim (excluding declaratory judgment actions, counter-claims, and cross-claims) alleging that a Contributor Version directly or indirectly infringes any patent, then the rights granted to You by any and all Contributors for the Covered Software under Section 2.1 of this License shall terminate. 5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user license agreements (excluding distributors and resellers) which have been validly granted by You or Your distributors under this License prior to termination shall survive termination. ************************************************************************ * * * 6. Disclaimer of Warranty * * ------------------------- * * * * Covered Software is provided under this License on an "as is" * * basis, without warranty of any kind, either expressed, implied, or * * statutory, including, without limitation, warranties that the * * Covered Software is free of defects, merchantable, fit for a * * particular purpose or non-infringing. The entire risk as to the * * quality and performance of the Covered Software is with You. * * Should any Covered Software prove defective in any respect, You * * (not any Contributor) assume the cost of any necessary servicing, * * repair, or correction. This disclaimer of warranty constitutes an * * essential part of this License. No use of any Covered Software is * * authorized under this License except under this disclaimer. * * * ************************************************************************ ************************************************************************ * * * 7. Limitation of Liability * * -------------------------- * * * * Under no circumstances and under no legal theory, whether tort * * (including negligence), contract, or otherwise, shall any * * Contributor, or anyone who distributes Covered Software as * * permitted above, be liable to You for any direct, indirect, * * special, incidental, or consequential damages of any character * * including, without limitation, damages for lost profits, loss of * * goodwill, work stoppage, computer failure or malfunction, or any * * and all other commercial damages or losses, even if such party * * shall have been informed of the possibility of such damages. This * * limitation of liability shall not apply to liability for death or * * personal injury resulting from such party's negligence to the * * extent applicable law prohibits such limitation. Some * * jurisdictions do not allow the exclusion or limitation of * * incidental or consequential damages, so this exclusion and * * limitation may not apply to You. * * * ************************************************************************ 8. Litigation ------------- Any litigation relating to this License may be brought only in the courts of a jurisdiction where the defendant maintains its principal place of business and such litigation shall be governed by laws of that jurisdiction, without reference to its conflict-of-law provisions. Nothing in this Section shall prevent a party's ability to bring cross-claims or counter-claims. 9. Miscellaneous ---------------- This License represents the complete agreement concerning the subject matter hereof. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. Any law or regulation which provides that the language of a contract shall be construed against the drafter shall not be used to construe this License against a Contributor. 10. Versions of the License --------------------------- 10.1. New Versions Mozilla Foundation is the license steward. Except as provided in Section 10.3, no one other than the license steward has the right to modify or publish new versions of this License. Each version will be given a distinguishing version number. 10.2. Effect of New Versions You may distribute the Covered Software under the terms of the version of the License under which You originally received the Covered Software, or under the terms of any subsequent version published by the license steward. 10.3. Modified Versions If you create software not governed by this License, and you want to create a new license for such software, you may create and use a modified version of this License if you rename the license and remove any references to the name of the license steward (except to note that such modified license differs from this License). 10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses If You choose to distribute Source Code Form that is Incompatible With Secondary Licenses under the terms of this version of the License, the notice described in Exhibit B of this License must be attached. Exhibit A - Source Code Form License Notice ------------------------------------------- This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. If it is not possible or desirable to put the notice in a particular file, then You may include the notice in a location (such as a LICENSE file in a relevant directory) where a recipient would be likely to look for such a notice. You may add additional accurate notices of copyright ownership. Exhibit B - "Incompatible With Secondary Licenses" Notice --------------------------------------------------------- This Source Code Form is "Incompatible With Secondary Licenses", as defined by the Mozilla Public License, v. 2.0. ================================================ FILE: Makefile ================================================ #!/bin/make -f # -*- makefile -*- # SPDX-License-Identifier: MPL-2.0 #{ # Copyright 2018-present Samsung Electronics France SAS, and other contributors # # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/.* #} default: help all tmp_dir ?= tmp runtime ?= node export runtime eslint ?= node_modules/eslint/bin/eslint.js tsc ?= node_modules/typescript/bin/tsc srcs ?= $(wildcard *.ts lib/*.ts | sort | uniq) run_args ?= run_timeout ?= 10 main_src ?= example/multiple-things.js NODE_PATH := .:${NODE_PATH} export NODE_PATH port?=8888 url?=http://localhost:${port} help: @echo "## Usage: " @echo "# make start # To start default application" @echo "# make test # To test default application" all: build setup/%: ${@F} node_modules: package.json npm install modules: ${runtime}_modules ls $< package-lock.json: package.json rm -fv "$@" npm install ls "$@" setup/node: node_modules @echo "NODE_PATH=$${NODE_PATH}" node --version npm --version setup: setup/${runtime} build/%: setup @echo "log: $@: $^" build/node: setup node_modules eslint build: build/${runtime} run/%: ${main_src} build ${@F} $< ${run_args} run/npm: ${main_src} setup npm start run: run/${runtime} clean: rm -rf ${tmp_dir} cleanall: clean rm -f *~ distclean: cleanall rm -rf node_modules ${tmp_dir}/rule/test/pid/%: ${main_src} build modules @mkdir -p "${@D}" ${@F} $< & echo $$! > "$@" sleep ${run_timeout} cat $@ test/%: ${tmp_dir}/rule/test/pid/% cat $< curl ${url} || curl -I ${url} @echo "" curl --fail ${url}/0/properties @echo "" curl --fail ${url}/1/properties @echo "" kill $$(cat $<) ||: kill -9 $$(cat $<) ||: test/npm: package.json npm test test: test/${runtime} start: run start/board/%: example/platform/Makefile example/platform/board/%.js ${MAKE} -C ${ console.log('On-State is now', v)), { '@type': 'OnOffProperty', title: 'On/Off', type: 'boolean', description: 'Whether the lamp is turned on', })); ``` 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. ```javascript light.addProperty( new Property( light, 'brightness', new Value(50, v => console.log('Brightness is now', v)), { '@type': 'BrightnessProperty', title: 'Brightness', type: 'number', description: 'The level of light from 0-100', minimum: 0, maximum: 100, unit: 'percent', })); ``` Now we can add our newly created thing to the server and start it: ```javascript // If adding more than one thing, use MultipleThings() with a name. // In the single thing case, the thing's name will be broadcast. const server = new WebThingServer(SingleThing(light), 8888); process.on('SIGINT', () => { server.stop().then(() => process.exit()).catch(() => process.exit()); }); server.start().catch(console.error); ``` 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. ## Sensor Let's now also connect a humidity sensor to the server we set up for our light. 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. First we create a new Thing: ```javascript const sensor = new Thing('urn:dev:ops:my-humidity-sensor-1234', 'My Humidity Sensor', ['MultiLevelSensor'], 'A web connected humidity sensor'); ``` Then we create and add the appropriate property: * `level`: tells us what the sensor is actually reading * 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. ```javascript const level = new Value(0.0); sensor.addProperty( new Property( sensor, 'level', level, { '@type': 'LevelProperty', title: 'Humidity', type: 'number', description: 'The current humidity in %', minimum: 0, maximum: 100, unit: 'percent', readOnly: true, })); ``` 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. ```javascript // Poll the sensor reading every 3 seconds setInterval(() => { // Update the underlying value, which in turn notifies all listeners level.notifyOfExternalUpdate(readFromGPIO()); }, 3000); ``` 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. # Adding to Gateway 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). # Resources * https://iot.mozilla.org/things/ * https://hacks.mozilla.org/2018/05/creating-web-things-with-python-node-js-and-java/ * https://nodejs.org/en/ * https://github.com/rzr/webthing-iotjs/wiki * https://youtu.be/Z-oiFl6gwGw ================================================ FILE: docker-compose.yml ================================================ # -*- coding: utf-8 -*- # SPDX-License-Identifier: MPL-2.0 #{ # Copyright: 2018-present Samsung Electronics France SAS, and other contributors # # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/ . #} version: "2" services: web: build: . command: start volumes: - /sys:/sys ports: - "8888:8888" network_mode: "host" ================================================ FILE: example/multiple-things.js ================================================ // -*- mode: js; js-indent-level:2; -*- // SPDX-License-Identifier: MPL-2.0 const { Action, Event, MultipleThings, Property, Thing, Value, WebThingServer, } = require('webthing'); const { v4: uuidv4 } = require('uuid'); class OverheatedEvent extends Event { constructor(thing, data) { super(thing, 'overheated', data); } } class FadeAction extends Action { constructor(thing, input) { super(uuidv4(), thing, 'fade', input); } performAction() { return new Promise((resolve) => { setTimeout(() => { this.thing.setProperty('brightness', this.input.brightness); this.thing.addEvent(new OverheatedEvent(this.thing, 102)); resolve(); }, this.input.duration); }); } } /** * A dimmable light that logs received commands to stdout. */ class ExampleDimmableLight extends Thing { constructor() { super('urn:dev:ops:my-lamp-1234', 'My Lamp', ['OnOffSwitch', 'Light'], 'A web connected lamp'); this.addProperty( new Property(this, 'on', new Value(true, (v) => console.log('On-State is now', v)), { '@type': 'OnOffProperty', title: 'On/Off', type: 'boolean', description: 'Whether the lamp is turned on', }) ); this.addProperty( new Property(this, 'brightness', new Value(50, (v) => console.log('Brightness is now', v)), { '@type': 'BrightnessProperty', title: 'Brightness', type: 'integer', description: 'The level of light from 0-100', minimum: 0, maximum: 100, unit: 'percent', }) ); this.addAvailableAction( 'fade', { title: 'Fade', description: 'Fade the lamp to a given level', input: { type: 'object', required: ['brightness', 'duration'], properties: { brightness: { type: 'integer', minimum: 0, maximum: 100, unit: 'percent', }, duration: { type: 'integer', minimum: 1, unit: 'milliseconds', }, }, }, }, FadeAction ); this.addAvailableEvent('overheated', { description: 'The lamp has exceeded its safe operating temperature', type: 'number', unit: 'degree celsius', }); } } /** * A humidity sensor which updates its measurement every few seconds. */ class FakeGpioHumiditySensor extends Thing { constructor() { super( 'urn:dev:ops:my-humidity-sensor-1234', 'My Humidity Sensor', ['MultiLevelSensor'], 'A web connected humidity sensor' ); this.level = new Value(0.0); this.addProperty( new Property(this, 'level', this.level, { '@type': 'LevelProperty', title: 'Humidity', type: 'number', description: 'The current humidity in %', minimum: 0, maximum: 100, unit: 'percent', readOnly: true, }) ); // Poll the sensor reading every 3 seconds setInterval(() => { // Update the underlying value, which in turn notifies all listeners const newLevel = this.readFromGPIO(); console.log('setting new humidity level:', newLevel); this.level.notifyOfExternalUpdate(newLevel); }, 3000); } /** * Mimic an actual sensor updating its reading every couple seconds. */ readFromGPIO() { return Math.abs(70.0 * Math.random() * (-0.5 + Math.random())); } } function runServer() { // Create a thing that represents a dimmable light const light = new ExampleDimmableLight(); // Create a thing that represents a humidity sensor const sensor = new FakeGpioHumiditySensor(); // If adding more than one thing, use MultipleThings() with a name. // In the single thing case, the thing's name will be broadcast. const server = new WebThingServer( new MultipleThings([light, sensor], 'LightAndTempDevice'), 8888 ); process.on('SIGINT', () => { server .stop() .then(() => process.exit()) .catch(() => process.exit()); }); server.start().catch(console.error); } runServer(); ================================================ FILE: example/package.json ================================================ { "name": "example", "version": "1.0.0", "author": "WebThingsIO", "license": "MPL-2.0", "dependencies": { "webthing": "file:.." } } ================================================ FILE: example/platform/Makefile ================================================ #!/bin/make -f # -*- makefile -*- # SPDX-License-Identifier: MPL-2.0 #{ # Copyright 2018-present Samsung Electronics France SAS, and other contributors # # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/.* #} default: help all main_src ?= index.js lib_srcs ?= $(wildcard */*.js | sort) runtime ?= node topreldir ?= ../.. topdir ?= ${CURDIR}/${topreldir} run_args ?= npm_args ?= start sudo ?= sudo gpio ?= gpio tmp_dir ?= tmp export PATH NODE_PATH := ${topreldir}:${NODE_PATH} export NODE_PATH rpi_gpio ?= 11 rpi_gpio_list ?= 13 19 26 edison_gpio ?= 12 help: @echo "Usage:" @echo '# make start' @echo '# make $${board}' @echo '# make artik1020' @echo '# make edison' @echo '# make flex-phat' @echo '# make play-phat' @echo '# make traffic-phat' %: %/${runtime} echo "# $@: $^" all: check setup/node: ${topreldir}/node_modules node_modules @echo "NODE_PATH=$${NODE_PATH}" @echo "$@: $^" run/%: /sys/class/gpio/export ${main_src} setup ls -l $< -which ${@F} ${@F} ${main_src} ${run_args} sudo/run/%: /sys/class/gpio/export ${main_src} setup ls -l $< -which ${@F} ${sudo} env "PATH=${PATH}" ${@F} ${main_src} ${run_args} run/npm: /sys/class/gpio/export package.json setup ls -l $< npm run ${npm_args} ${run_args} run: run/${runtime} sync start: run sync force: /sys/kernel/debug/gpio: ${sudo} ls -l $@ /sys/class/gpio/export: /sys/kernel/debug/gpio force ${sudo} cat $< node_modules: package.json -which npm npm --version npm install @mkdir -p "$@" ln -fs ../../.. ${@}/webthing package.json: npm init ${topreldir}/node_modules: ${topreldir}/package.json cd ${@D} && npm install check/%: ${lib_srcs} ${MAKE} setup status=0 ; \ for src in $^; do \ echo "log: check: $${src}: ($@)" ; \ ${@F} $${src} \ && echo "log: check: $${src}: OK" \ || status=1 ; \ done ; \ exit $${status} check: check/${runtime} board/%: ${main_src} board/%.js /sys/class/gpio/export setup ${runtime} $< ${@F} /sys/class/gpio/gpio${edison_gpio}: /sys/class/gpio/export echo ${edison_gpio} | ${sudo} tee $< ls -l $@ artik1020/%: ${main_src} ${MAKE} sudo/run/${@F} run_args="${@D}" edison/%: /sys/class/gpio/gpio${edison_gpio} ${main_src} echo out | ${sudo} tee ${<}/direction echo 0 | ${sudo} tee ${<}/value ${sudo} cat /sys/kernel/debug/gpio_debug/gpio${edison_gpio}/current_pinmux # mode0 echo mode1 | ${sudo} tee /sys/kernel/debug/gpio_debug/gpio${edison_gpio}/current_pinmux ${MAKE} sudo/run/${@F} run_args="${@D}" gpio: /sys/class/gpio/export -${gpio} -v || ${sudo} apt-get install gpio || echo "TODO: install BCM tool" -${gpio} -v flex-phat/%: ${main_src} gpio ${gpio} -g mode ${rpi_gpio} up ${MAKE} run/${@F} run_args="${@D}" play-phat/%: ${main_src} /sys/class/gpio/export -lsmod | grep gpio_keys \ && ${sudo} modprobe -rv gpio_keys \ || echo "log: will use /sys/class/gpio/" ${MAKE} run/${@F} run_args="${@D}" traffic-phat/%: ${main_src} gpio for num in ${rpi_gpio_list} ; do \ ${sudo} ${gpio} export $${num} in ; \ ${sudo} ${gpio} -g mode $${num} up ; \ ${sudo} ${gpio} unexport $${num} ; \ done ${MAKE} run/${@F} run_args="${@D}" ================================================ FILE: example/platform/adc/adc-property.js ================================================ // -*- mode: js; js-indent-level:2; -*- // SPDX-License-Identifier: MPL-2.0 /** * * Copyright 2018-present Samsung Electronics France SAS, and other contributors * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/.* */ const console = require('console'); // Disable logs here by editing to '!console.log' const log = console.log || function () {}; const verbose = !console.log || function () {}; const { Property, Value } = require('webthing'); const adc = require('../adc'); class AdcInProperty extends Property { constructor(thing, name, value, metadata, config) { super(thing, name, new Value(Number(value)), { '@type': 'LevelProperty', title: (metadata && metadata.title) || `Level: ${name}`, type: 'number', readOnly: true, description: (metadata && metadata.description) || `ADC Sensor on pin=${config.pin}`, }); const self = this; config.frequency = config.frequency || 1; config.range = config.range || 0xfff; this.period = 1000.0 / config.frequency; this.config = config; this.port = adc.open(config, (err) => { log(`log: ADC: ${self.getName()}: open: ${err} (null expected)`); if (err) { console.error(`error: ADC: ${self.getName()}: Fail to open:\ ${config.pin}`); return null; } self.inverval = setInterval(() => { let value = Number(self.port.readSync()); verbose(`log: ADC:\ ${self.getName()}: update: 0x${value.toString(0xf)}`); value = Number(Math.floor((100.0 * value) / self.config.range)); if (value !== self.lastValue) { log(`log: ADC: ${self.getName()}: change: ${value}%`); self.value.notifyOfExternalUpdate(value); self.lastValue = value; } }, self.period); }); } close() { try { this.inverval && clearInterval(this.inverval); this.port && this.port.closeSync(); } catch (err) { console.error(`error: ADC: ${this.getName()} close:${err}`); return err; } log(`log: ADC: ${this.getName()}: close:`); } } function AdcProperty(thing, name, value, metadata, config) { if (config.direction === 'in') { return new AdcInProperty(thing, name, value, metadata, config); } throw 'error: Invalid param'; } module.exports = AdcProperty; ================================================ FILE: example/platform/board/artik1020.js ================================================ // -*- mode: js; js-indent-level:2; -*- // SPDX-License-Identifier: MPL-2.0 /** * * Copyright 2018-present Samsung Electronics France SAS, and other contributors * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/ */ const { Thing } = require('webthing'); const AdcProperty = require('../adc/adc-property'); const PwmProperty = require('../pwm/pwm-property'); class ARTIK1020Thing extends Thing { constructor(name, type, description) { super( 'urn:dev:ops:my-artik1020-1234', name || 'ARTIK1020', type || [], description || 'A web connected ARTIK1020' ); const self = this; this.pinProperties = [ new AdcProperty( this, 'ADC0', 0, { description: 'A0 on J24 of board' }, { direction: 'in', device: '/sys/devices/12d10000.adc/iio:device0\ /in_voltage0_raw' } ), new AdcProperty( this, 'ADC1', 0, { description: 'A1 on J24 of board' }, { direction: 'in', device: '/sys/devices/12d10000.adc/iio:device0\ /in_voltage1_raw' } ), new AdcProperty( this, 'ADC2', 0, { description: 'A2 on J24 of board' }, { direction: 'in', device: '/sys/devices/12d10000.adc/iio:device0\ /in_voltage2_raw' } ), new AdcProperty( this, 'ADC3', 0, { description: 'A3 on J24 of board' }, { direction: 'in', device: '/sys/devices/12d10000.adc/iio:device0\ /in_voltage5_raw' } ), new AdcProperty( this, 'ADC4', 0, { description: 'A4 on J24 of board' }, { direction: 'in', device: '/sys/devices/12d10000.adc/iio:device0\ /in_voltage6_raw' } ), new AdcProperty( this, 'ADC5', 0, { description: 'A5 on J24 of board' }, { direction: 'in', device: '/sys/devices/12d10000.adc/iio:device0\ /in_voltage7_raw' } ), new PwmProperty(this, 'PWM0', 50, { description: 'XPWMO1 on J26[6] of board (pwm0)' }), new PwmProperty( this, 'PWM1', 50, { description: 'XPWMO0 on J26[5] of board (pwm1)' }, { pwm: { pin: 1 } } ), ]; this.pinProperties.forEach((property) => { self.addProperty(property); }); } close() { this.pinProperties.forEach((property) => { property.close && property.close(); }); } } module.exports = function () { if (!module.exports.instance) { module.exports.instance = new ARTIK1020Thing(); } return module.exports.instance; }; ================================================ FILE: example/platform/board/artik530.js ================================================ // -*- mode: js; js-indent-level:2; -*- // SPDX-License-Identifier: MPL-2.0 /** * * Copyright 2018-present Samsung Electronics France SAS, and other contributors * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/.* */ const { Thing } = require('webthing'); const AdcProperty = require('../adc/adc-property'); const GpioProperty = require('../gpio/gpio-property'); class ARTIK530Thing extends Thing { constructor(name, type, description) { super( 'urn:dev:ops:my-artik530-1234', name || 'ARTIK530', type || [], description || 'A web connected ARTIK530 or ARTIK720' ); const self = this; this.pinProperties = [ new GpioProperty( this, 'RedLED', false, { description: 'Red LED on interposer board (on GPIO28)' }, { direction: 'out', pin: 28 } ), new GpioProperty( this, 'BlueLED', false, { description: 'Blue LED on interposer board (on GPIO38)' }, { direction: 'out', pin: 38 } ), new GpioProperty( this, 'Up', true, { description: 'SW403 Button: Nearest board edge,\ next to red LED (on GPIO30)' }, { direction: 'in', pin: 30 } ), new GpioProperty( this, 'Down', true, { description: 'SW404 Button: Next to blue LED (on GPIO32)' }, { direction: 'in', pin: 32 } ), new AdcProperty( this, 'ADC0', 0, { description: 'Analog port of ARTIK05x' }, { direction: 'in', device: '/sys/bus/platform/devices\ /c0053000.adc/iio:device0/in_voltage0_raw', } ), new AdcProperty( this, 'ADC1', 0, { description: 'Analog port of ARTIK05x' }, { direction: 'in', device: '/sys/bus/platform/devices/\ c0053000.adc/iio:device0/in_voltage1_raw', } ), ]; this.pinProperties.forEach((property) => { self.addProperty(property); }); } close() { this.pinProperties.forEach((property) => { property.close && property.close(); }); } } module.exports = function () { if (!module.exports.instance) { module.exports.instance = new ARTIK530Thing(); } return module.exports.instance; }; ================================================ FILE: example/platform/board/edison.js ================================================ // -*- mode: js; js-indent-level:2; -*- // SPDX-License-Identifier: MPL-2.0 /** * * Copyright 2018-present Samsung Electronics France SAS, and other contributors * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/.* */ const { Thing } = require('webthing'); const PwmProperty = require('../pwm/pwm-property'); class EdisonThing extends Thing { constructor(name, type, description) { super( 'urn:dev:ops:my-edison-1234', name || 'Edison', type || [], description || 'A web connected Edison' ); const self = this; this.pinProperties = [ new PwmProperty(this, 'PWM0', 50, { description: 'Analog port of Edison', }), ]; this.pinProperties.forEach((property) => { self.addProperty(property); }); } close() { this.pinProperties.forEach((property) => { property.close && property.close(); }); } } module.exports = function () { if (!module.exports.instance) { module.exports.instance = new EdisonThing(); } return module.exports.instance; }; ================================================ FILE: example/platform/board/flex-phat.js ================================================ // -*- mode: js; js-indent-level:2; -*- // SPDX-License-Identifier: MPL-2.0 /** * * Copyright 2018-present Samsung Electronics France SAS, and other contributors * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/.* */ const { Thing } = require('webthing'); const GpioProperty = require('../gpio/gpio-property'); class FlexPHatThing extends Thing { constructor(name, type, description) { super( 'urn:dev:ops:my-flex-phat-1234', name || 'FlexPHat', type || [], description || 'A web connected Flex RaspberryPi Hat' ); const self = this; this.gpioProperties = [ new GpioProperty( this, 'Relay', false, { description: 'Actuator (on GPIO5)' }, { direction: 'out', pin: 5 } ), new GpioProperty( this, 'BlueLED', false, { description: 'Actuator (on GPIO13)' }, { direction: 'out', pin: 13 } ), new GpioProperty( this, 'GreenLED', false, { description: 'Actuator (on GPIO19)' }, { direction: 'out', pin: 19 } ), new GpioProperty( this, 'RedLED', false, { description: 'Actuator (on GPIO26)' }, { direction: 'out', pin: 26 } ), new GpioProperty( this, 'Button', false, { description: 'Push Button (on GPIO11)' }, { direction: 'in', pin: 11 } ), new GpioProperty( this, 'GPIO23', false, { description: 'Input on GPIO 23 (unwired but modable)' }, { direction: 'in', pin: 23 } ), ]; this.gpioProperties.forEach((property) => { self.addProperty(property); }); } close() { this.gpioProperties.forEach((property) => { property.close && property.close(); }); } } module.exports = function () { if (!module.exports.instance) { module.exports.instance = new FlexPHatThing(); } return module.exports.instance; }; ================================================ FILE: example/platform/board/play-phat.js ================================================ // -*- mode: js; js-indent-level:2; -*- // SPDX-License-Identifier: MPL-2.0 /** * * Copyright 2018-present Samsung Electronics France SAS, and other contributors * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/.* */ const { Thing } = require('webthing'); const GpioProperty = require('../gpio/gpio-property'); class PlayPHatThing extends Thing { constructor(name, type, description) { super( 'urn:dev:ops:my-play-phat-1234', name || 'PlayPHat', type || [], description || 'A web connected Play RaspberryPi Hat' ); const self = this; this.gpioProperties = [ new GpioProperty( this, 'Left', false, { description: 'SW1 Sensor Button on GPIO4 (Pin7)' }, { direction: 'in', pin: 4 } ), new GpioProperty( this, 'Right', false, { description: 'SW2 Sensor button on GPIO17 (Pin11)' }, { direction: 'in', pin: 17 } ), new GpioProperty( this, 'Up', false, { description: 'SW3 Sensor button on GPIO22 (Pin15)' }, { direction: 'in', pin: 22 } ), new GpioProperty( this, 'Down', false, { description: 'SW4 Sensor button on GPIO27 (Pin13)' }, { direction: 'in', pin: 27 } ), new GpioProperty( this, 'A', false, { description: 'SW5 Sensor button on GPIO19 (Pin35)' }, { direction: 'in', pin: 19 } ), new GpioProperty( this, 'B', false, { description: 'SW6 Sensor button on GPIO26 (Pin37)' }, { direction: 'in', pin: 26 } ), new GpioProperty( this, 'Start', false, { description: 'SW7 Sensor button on GPIO5 (Pin29)' }, { direction: 'in', pin: 5 } ), new GpioProperty( this, 'Select', false, { description: 'SW8 Sensor button on GPIO6 (Pin31)' }, { direction: 'in', pin: 6 } ), ]; this.gpioProperties.forEach((property) => { self.addProperty(property); }); } close() { this.gpioProperties.forEach((property) => { property.close && property.close(); }); } } module.exports = function () { if (!module.exports.instance) { module.exports.instance = new PlayPHatThing(); } return module.exports.instance; }; ================================================ FILE: example/platform/board/traffic-phat.js ================================================ // -*- mode: js; js-indent-level:2; -*- // SPDX-License-Identifier: MPL-2.0 /** * * Copyright 2018-present Samsung Electronics France SAS, and other contributors * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/.* */ const { Thing } = require('webthing'); const GpioProperty = require('../gpio/gpio-property'); class TrafficPHatThing extends Thing { constructor(name, type, description) { super( 'urn:dev:ops:my-traffic-phat-1234', name || 'TrafficPHat', type || [], description || 'A web connected Traffic RaspberryPi Hat' ); const self = this; this.pinProperties = [ new GpioProperty( this, 'Red', false, { description: 'LED on GPIO2 (Pin2)', }, { direction: 'out', pin: 2, } ), new GpioProperty( this, 'Orange', false, { description: 'LED on GPIO3 (Pin5)', }, { direction: 'out', pin: 3, } ), new GpioProperty( this, 'Green', false, { description: 'LED on GPIO4 (Pin7)', }, { direction: 'out', pin: 4, } ), new GpioProperty( this, 'B1', true, { description: 'SW1 Sensor Button on GPIO3 (Pin33)', }, { direction: 'in', pin: 13, } ), new GpioProperty( this, 'B2', true, { description: 'SW2 Sensor button on GPIO19 (Pin35)', }, { direction: 'in', pin: 19, } ), new GpioProperty( this, 'B3', true, { description: 'SW3 Sensor button on GPIO26 (Pin37)', }, { direction: 'in', pin: 26, } ), ]; this.pinProperties.forEach((property) => { self.addProperty(property); }); } close() { this.pinProperties.forEach((property) => { property.close && property.close(); }); } } module.exports = function () { if (!module.exports.instance) { module.exports.instance = new TrafficPHatThing(); } return module.exports.instance; }; ================================================ FILE: example/platform/gpio/gpio-property.js ================================================ // -*- mode: js; js-indent-level:2; -*- // SPDX-License-Identifier: MPL-2.0 /** * * Copyright 2018-present Samsung Electronics France SAS, and other contributors * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/.* */ const console = require('console'); // Disable logs here by editing to '!console.log' const log = console.log || function () {}; const { Property, Value } = require('webthing'); const gpio = require('gpio'); class GpioOutProperty extends Property { constructor(thing, name, value, metadata, config) { super(thing, name, new Value(Boolean(value)), { '@type': 'OnOffProperty', title: (metadata && metadata.title) || `On/Off: ${name}`, type: 'boolean', description: (metadata && metadata.description) || `GPIO Actuator on pin=${config.pin}`, }); const self = this; this.config = config; this.port = gpio.export(config.pin, { direction: 'out', ready: () => { log(`log: GPIO: ${self.getName()}: open:`); self.value.valueForwarder = (value) => { try { log(`log: GPIO: ${self.getName()}: \ writing: ${value}`); self.port.set(value); } catch (err) { console.error(`error: GPIO: ${self.getName()}: Fail to write: ${err}`); return err; } }; }, }); } close() { try { this.port && this.port.unexport(this.config.pin); } catch (err) { console.error(`error: GPIO: ${this.getName()}: Fail to close: ${err}`); return err; } log(`log: GPIO: ${this.getName()}: close:`); } } class GpioInProperty extends Property { constructor(thing, name, value, metadata, config) { super(thing, name, new Value(Boolean(value)), { '@type': 'BooleanProperty', title: (metadata && metadata.title) || `On/Off: ${name}`, type: 'boolean', readOnly: true, description: (metadata && metadata.description) || `GPIO Sensor on pin=${config.pin}`, }); const self = this; this.config = config; const callback = () => { log(`log: GPIO: ${self.getName()}: open:`); self.port.on('change', (value) => { value = Boolean(value); log(`log: GPIO: ${self.getName()}: change: ${value}`); self.value.notifyOfExternalUpdate(value); }); }; this.port = gpio.export(config.pin, { direction: 'in', ready: callback }); } close() { try { this.port && this.port.unexport(this.config.pin); } catch (err) { console.error(`error: GPIO: ${this.getName()} close:${err}`); return err; } log(`log: GPIO: ${this.getName()}: close:`); } } function GpioProperty(thing, name, value, metadata, config) { if (config.direction === 'out') { return new GpioOutProperty(thing, name, value, metadata, config); } else if (config.direction === 'in') { return new GpioInProperty(thing, name, value, metadata, config); } throw 'error: Invalid param'; } module.exports = GpioProperty; ================================================ FILE: example/platform/package.json ================================================ { "name": "board-webthings", "version": "0.0.0", "description": "Various Single Board computers's I/O implemented as webthings", "main": "index.js", "scripts": { "start": "NODE_PATH=.:../.. node index", "flex-phat": "NODE_PATH=.:../.. node index flex-phat", "play-phat": "NODE_PATH=.:../.. node index play-phat" }, "author": "Philippe Coval ", "license": "MPL-2.0", "repository": { "type": "git", "url": "git+https://github.com/rzr/webthing-iotjs.git" }, "keywords": [ "webthing", "wot", "raspberry-pi", "ARTIK" ], "bugs": { "url": "https://github.com/rzr/webthing-iotjs/issues" }, "bin": { "board-webthings": "./index.js" }, "homepage": "https://github.com/rzr/webthing-iotjs", "dependencies": {}, "optionalDependencies": { "gpio": "^0.2.10", "pwm": "0.0.3" } } ================================================ FILE: example/platform/pwm/pwm-property.js ================================================ // -*- mode: js; js-indent-level:2; -*- // SPDX-License-Identifier: MPL-2.0 /** * * Copyright 2018-present Samsung Electronics France SAS, and other contributors * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/ */ const console = require('console'); // Disable logs here by editing to '!console.log' const log = console.log || function () {}; const verbose = !console.log || function () {}; const { Property, Value } = require('webthing'); const pwm = require('pwm'); class PwmOutProperty extends Property { constructor(thing, name, value, metadata, config) { if (typeof config === 'undefined') { config = {}; } super(thing, name, new Value(Number(value)), { '@type': 'LevelProperty', title: (metadata && metadata.title) || `PWM: ${name} (dutyCycle)`, type: 'integer', minimum: config.minimum || 0, maximum: config.maximum || 100, readOnly: false, unit: 'percent', description: (metadata && metadata.description) || `PWM DutyCycle`, }); const self = this; this.config = config; if (typeof this.config.pwm == 'undefined') { this.config.pwm = {}; } if (typeof this.config.pwm.pin == 'undefined') { this.config.pwm.pin = 0; } if (typeof this.config.pwm.chip == 'undefined') { this.config.pwm.chip = 0; } // secs (eg: 50Hz = 20 ms = 0.02 sec) if (typeof this.config.pwm.period == 'undefined') { this.config.pwm.period = 0.02; } // [0..1] if (typeof this.config.pwm.dutyCycle == 'undefined') { this.config.pwm.dutyCycle = 0.5; } verbose(`log: opening: ${this.getName()}`); this.port = pwm.export(this.config.pwm.chip, this.config.pwm.pin, (err) => { verbose(`log: PWM: ${self.getName()}: open: ${err}`); if (err) { console.error(`error: PWM: ${self.getName()}: open: ${err}`); throw err; } self.port.freq = 1 / self.config.pwm.period; // Linux sysfs uses usecs units self.port.setPeriod(self.config.pwm.period * 1e9, () => { self.port.setDutyCycle(self.config.pwm.dutyCycle * (self.config.pwm.period * 1e9), () => { self.port.setEnable(1, () => { verbose(`log: ${self.getName()}: Enabled`); }); }); }); self.value.valueForwarder = (value) => { const usec = Math.floor(self.config.pwm.period * 1e9 * (Number(value) / 100.0)); self.port.setDutyCycle(usec, () => { verbose(`log: setDutyCycle: usec=${usec}`); }); }; }); } close() { verbose(`log: PWM: ${this.getName()}: close:`); try { this.port && this.port.unexport(); } catch (err) { console.error(`error: PWM: ${this.getName()} close:${err}`); return err; } log(`log: PWM: ${this.getName()}: close:`); } } module.exports = PwmOutProperty; if (module.parent === null) { new PwmOutProperty(); } ================================================ FILE: example/simplest-thing.js ================================================ // -*- mode: js; js-indent-level:2; -*- // SPDX-License-Identifier: MPL-2.0 /** * * Copyright 2018-present Samsung Electronics France SAS, and other contributors * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/.* */ const { Property, SingleThing, Thing, Value, WebThingServer } = require('webthing'); function makeThing() { const thing = new Thing( 'urn:dev:ops:my-actuator-1234', 'ActuatorExample', ['OnOffSwitch'], 'An actuator example that just log' ); thing.addProperty( new Property(thing, 'on', new Value(true, (update) => console.log(`change: ${update}`)), { '@type': 'OnOffProperty', title: 'On/Off', type: 'boolean', description: 'Whether the output is changed', }) ); return thing; } function runServer() { const port = process.argv[2] ? Number(process.argv[2]) : 8888; const url = `http://localhost:${port}/properties/on`; console.log(`Usage:\n ${process.argv[0]} ${process.argv[1]} [port] Try: curl -X PUT -H 'Content-Type: application/json' --data '{"on": true }' ${url} `); const thing = makeThing(); const server = new WebThingServer(new SingleThing(thing), port); process.on('SIGINT', () => { server .stop() .then(() => process.exit()) .catch(() => process.exit()); }); server.start().catch(console.error); } runServer(); ================================================ FILE: example/single-thing.js ================================================ // -*- mode: js; js-indent-level:2; -*- // SPDX-License-Identifier: MPL-2.0 const { Action, Event, Property, SingleThing, Thing, Value, WebThingServer } = require('webthing'); const { v4: uuidv4 } = require('uuid'); class OverheatedEvent extends Event { constructor(thing, data) { super(thing, 'overheated', data); } } class FadeAction extends Action { constructor(thing, input) { super(uuidv4(), thing, 'fade', input); } performAction() { return new Promise((resolve) => { setTimeout(() => { this.thing.setProperty('brightness', this.input.brightness); this.thing.addEvent(new OverheatedEvent(this.thing, 102)); resolve(); }, this.input.duration); }); } } function makeThing() { const thing = new Thing( 'urn:dev:ops:my-lamp-1234', 'My Lamp', ['OnOffSwitch', 'Light'], 'A web connected lamp' ); thing.addProperty( new Property(thing, 'on', new Value(true), { '@type': 'OnOffProperty', title: 'On/Off', type: 'boolean', description: 'Whether the lamp is turned on', }) ); thing.addProperty( new Property(thing, 'brightness', new Value(50), { '@type': 'BrightnessProperty', title: 'Brightness', type: 'integer', description: 'The level of light from 0-100', minimum: 0, maximum: 100, unit: 'percent', }) ); thing.addAvailableAction( 'fade', { title: 'Fade', description: 'Fade the lamp to a given level', input: { type: 'object', required: ['brightness', 'duration'], properties: { brightness: { type: 'integer', minimum: 0, maximum: 100, unit: 'percent', }, duration: { type: 'integer', minimum: 1, unit: 'milliseconds', }, }, }, }, FadeAction ); thing.addAvailableEvent('overheated', { description: 'The lamp has exceeded its safe operating temperature', type: 'number', unit: 'degree celsius', }); return thing; } function runServer() { const thing = makeThing(); // If adding more than one thing, use MultipleThings() with a name. // In the single thing case, the thing's name will be broadcast. const server = new WebThingServer(new SingleThing(thing), 8888); process.on('SIGINT', () => { server .stop() .then(() => process.exit()) .catch(() => process.exit()); }); server.start().catch(console.error); } runServer(); ================================================ FILE: package.json ================================================ { "name": "webthing", "version": "0.15.0", "description": "HTTP Web Thing implementation", "main": "lib/webthing.js", "scripts": { "lint": "tsc --noEmit && eslint . --ext .ts", "node": "NODE_PATH=. node", "start": "NODE_PATH=. node example/multiple-things", "test": "make test", "simplest": "NODE_PATH=. node example/simplest-thing", "prettier": "npx prettier -w 'src/*.ts' 'example/**/*.{js,ts}'", "build": "tsc -p ." }, "repository": { "type": "git", "url": "git+https://github.com/WebThingsIO/webthing-node.git" }, "keywords": [ "iot", "web", "thing", "webthing" ], "author": "WebThingsIO", "license": "MPL-2.0", "bugs": { "url": "https://github.com/WebThingsIO/webthing-node/issues" }, "homepage": "https://github.com/WebThingsIO/webthing-node#readme", "types": "lib/index.d.ts", "dependencies": { "ajv": "^7.0.4", "body-parser": "^1.19.0", "dnssd": "^0.4.1", "express": "^4.17.1", "express-ws": "^4.0.0", "prettier": "^2.2.1" }, "devDependencies": { "@types/body-parser": "^1.19.0", "@types/dnssd": "^0.4.1", "@types/express": "^4.17.11", "@types/express-ws": "^3.0.0", "@types/node": "^14.14.25", "@typescript-eslint/eslint-plugin": "^4.14.2", "@typescript-eslint/parser": "^4.14.2", "babel-eslint": "^10.1.0", "eslint": "^7.19.0", "eslint-config-prettier": "^7.2.0", "typescript": "^4.1.3", "uuid": "^8.3.2" } } ================================================ FILE: src/action.ts ================================================ /** * High-level Action base class implementation. */ import * as utils from './utils'; import { AnyType, Link, PrimitiveJsonType } from './types'; import Thing from './thing'; /** * An Action represents an individual action on a thing. */ class Action { private id: string; private thing: Thing; private name: string; private input: InputType; private hrefPrefix: string; private href: string; private status: string; private timeRequested: string; private timeCompleted: string | null; constructor(id: string, thing: Thing, name: string, input: InputType) { /** * Initialize the object. * * @param {String} id ID of this action * @param {Object} thing Thing this action belongs to * @param {String} name Name of the action * @param {Object} input Any action inputs */ this.id = id; this.thing = thing; this.name = name; this.input = input; this.hrefPrefix = ''; this.href = `/actions/${this.name}/${this.id}`; this.status = 'created'; this.timeRequested = utils.timestamp(); this.timeCompleted = null; } /** * Get the action description. * * @returns {Object} Description of the action as an object. */ asActionDescription(): Action.ActionDescription { const description: Action.ActionDescription = { [this.name]: { href: this.hrefPrefix + this.href, timeRequested: this.timeRequested, status: this.status, }, }; if (this.input !== null) { description[this.name].input = (this.input); } if (this.timeCompleted !== null) { description[this.name].timeCompleted = this.timeCompleted; } return description; } /** * Set the prefix of any hrefs associated with this action. * * @param {String} prefix The prefix */ setHrefPrefix(prefix: string): void { this.hrefPrefix = prefix; } /** * Get this action's ID. * * @returns {String} The ID. */ getId(): string { return this.id; } /** * Get this action's name. * * @returns {String} The name. */ getName(): string { return this.name; } /** * Get this action's href. * * @returns {String} The href. */ getHref(): string { return this.hrefPrefix + this.href; } /** * Get this action's status. * * @returns {String} The status. */ getStatus(): string { return this.status; } /** * Get the thing associated with this action. * * @returns {Object} The thing. */ getThing(): Thing { return this.thing; } /** * Get the time the action was requested. * * @returns {String} The time. */ getTimeRequested(): string { return this.timeRequested; } /** * Get the time the action was completed. * * @returns {String} The time. */ getTimeCompleted(): string | null { return this.timeCompleted; } /** * Get the inputs for this action. * * @returns {Object} The inputs. */ getInput(): InputType { return this.input; } /** * Start performing the action. */ start(): void { this.status = 'pending'; this.thing.actionNotify(>(this)); this.performAction().then( () => this.finish(), () => this.finish() ); } /** * Override this with the code necessary to perform the action. * * @returns {Object} Promise that resolves when the action is finished. */ performAction(): Promise { return Promise.resolve(); } /** * Override this with the code necessary to cancel the action. * * @returns {Object} Promise that resolves when the action is cancelled. */ cancel(): Promise { return Promise.resolve(); } /** * Finish performing the action. */ finish(): void { this.status = 'completed'; this.timeCompleted = utils.timestamp(); this.thing.actionNotify(>(this)); } } // eslint-disable-next-line @typescript-eslint/no-namespace declare namespace Action { interface ActionMetadata { title?: string; description?: string; links?: Link[]; input?: { type?: PrimitiveJsonType; minimum?: number; maximum?: number; multipleOf?: number; enum?: readonly string[] | readonly number[]; }; } interface ActionDescription { [name: string]: { href: string; timeRequested: string; status: string; input?: InputType; timeCompleted?: string; }; } export interface ActionTypeClass { new (thing: Thing, input: InputType): Action; } } export = Action; ================================================ FILE: src/event.ts ================================================ /** * High-level Event base class implementation. */ import Thing from './thing'; import * as utils from './utils'; import { AnyType, PrimitiveJsonType, Link } from './types'; /** * An Event represents an individual event from a thing. */ class Event { private thing: Thing; private name: string; private data: Data | null; private time: string; /** * Initialize the object. * * @param {Object} thing Thing this event belongs to * @param {String} name Name of the event * @param {*} data (Optional) Data associated with the event */ constructor(thing: Thing, name: string, data?: Data) { this.thing = thing; this.name = name; this.data = typeof data !== 'undefined' ? data : null; this.time = utils.timestamp(); } /** * Get the event description. * * @returns {Object} Description of the event as an object. */ asEventDescription(): Event.EventDescription { const description: Event.EventDescription = { [this.name]: { timestamp: this.time, }, }; if (this.data !== null) { description[this.name].data = (this.data); } return description; } /** * Get the thing associated with this event. * * @returns {Object} The thing. */ getThing(): Thing { return this.thing; } /** * Get the event's name. * * @returns {String} The name. */ getName(): string { return this.name; } /** * Get the event's data. * * @returns {*} The data. */ getData(): Data | null { return this.data; } /** * Get the event's timestamp. * * @returns {String} The time. */ getTime(): string { return this.time; } } // eslint-disable-next-line @typescript-eslint/no-namespace declare namespace Event { interface EventDescription { [name: string]: { timestamp: string; data?: AnyType; }; } interface EventMetadata { type?: PrimitiveJsonType; '@type'?: string; unit?: string; title?: string; description?: string; links?: Link[]; minimum?: number; maximum?: number; multipleOf?: number; enum?: readonly string[] | readonly number[]; } } export = Event; ================================================ FILE: src/index.ts ================================================ export * from './webthing'; ================================================ FILE: src/property.ts ================================================ /** * High-level Property base class implementation. */ import Ajv, { ValidateFunction } from 'ajv'; import Thing from './thing'; import Value from './value'; import { AnyType, PrimitiveJsonType, Link } from './types'; const ajv = new Ajv(); /** * A Property represents an individual state value of a thing. */ class Property { private thing: Thing; private name: string; private value: Value; private metadata: Property.PropertyMetadata; private href: string; private hrefPrefix: string; private validate: ValidateFunction; /** * Initialize the object. * * @param {Thing} thing Thing this property belongs to * @param {String} name Name of the property * @param {Value} value Value object to hold the property value * @param {Object} metadata Property metadata, i.e. type, description, unit, * etc., as an object. */ constructor( thing: Thing, name: string, value: Value, metadata: Property.PropertyMetadata ) { this.thing = thing; this.name = name; this.value = value; this.hrefPrefix = ''; this.href = `/properties/${this.name}`; this.metadata = JSON.parse(JSON.stringify(metadata || {})); delete metadata.title; delete metadata.unit; delete metadata['@type']; this.validate = ajv.compile(metadata); // Add the property change observer to notify the Thing about a property // change. this.value.on('update', () => this.thing.propertyNotify(>(this))); } /** * Validate new property value before setting it. * * @param {*} value - New value * @throws Error if the property is readonly or is invalid */ validateValue(value: ValueType): void { if (this.metadata.hasOwnProperty('readOnly') && this.metadata.readOnly) { throw new Error('Read-only property'); } const valid = this.validate(value); if (!valid) { throw new Error('Invalid property value'); } } /** * Get the property description. * * @returns {Object} Description of the property as an object. */ asPropertyDescription(): Property.PropertyDescription { const description = JSON.parse(JSON.stringify(this.metadata)); if (!description.hasOwnProperty('links')) { description.links = []; } description.links.push({ rel: 'property', href: this.hrefPrefix + this.href, }); return description; } /** * Set the prefix of any hrefs associated with this property. * * @param {String} prefix The prefix */ setHrefPrefix(prefix: string): void { this.hrefPrefix = prefix; } /** * Get the href of this property. * * @returns {String} The href */ getHref(): string { return `${this.hrefPrefix}${this.href}`; } /** * Get the current property value. * * @returns {*} The current value */ getValue(): ValueType { return this.value.get(); } /** * Set the current value of the property. * * @param {*} value The value to set */ setValue(value: ValueType): void { this.validateValue(value); this.value.set(value); } /** * Get the name of this property. * * @returns {String} The property name. */ getName(): string { return this.name; } /** * Get the thing associated with this property. * * @returns {Object} The thing. */ getThing(): Thing { return this.thing; } /** * Get the metadata associated with this property * * @returns {Object} The metadata */ getMetadata(): Property.PropertyMetadata { return this.metadata; } } // eslint-disable-next-line @typescript-eslint/no-namespace declare namespace Property { // could we use .type to strongly type the enum, minimum and maximum? interface PropertyMetadata { type?: PrimitiveJsonType; '@type'?: string; unit?: string; title?: string; description?: string; links?: Link[]; enum?: AnyType[]; readOnly?: boolean; minimum?: number; maximum?: number; multipleOf?: number; } interface PropertyDescription extends PropertyMetadata { links: Link[]; } } export = Property; ================================================ FILE: src/server.ts ================================================ /** * Node Web Thing server implementation. */ import bodyParser from 'body-parser'; import * as dnssd from 'dnssd'; import express from 'express'; import expressWs from 'express-ws'; import * as http from 'http'; import * as https from 'https'; import * as os from 'os'; import * as utils from './utils'; import Thing from './thing'; import { AnyType } from './types'; /** * A container for a single thing. */ export class SingleThing { private thing: Thing; /** * Initialize the container. * * @param {Object} thing The thing to store */ constructor(thing: Thing) { this.thing = thing; } /** * Get the thing at the given index. */ getThing(): Thing { return this.thing; } /** * Get the list of things. */ getThings(): Thing[] { return [this.thing]; } /** * Get the mDNS server name. */ getName(): string { return this.thing.getTitle(); } } /** * A container for multiple things. */ export class MultipleThings { private things: Thing[]; private name: string; /** * Initialize the container. * * @param {Object} things The things to store * @param {String} name The mDNS server name */ constructor(things: Thing[], name: string) { this.things = things; this.name = name; } /** * Get the thing at the given index. * * @param {Number|String} idx The index */ getThing(idx?: number | string): Thing | null { idx = parseInt(idx as string); if (isNaN(idx) || idx < 0 || idx >= this.things.length) { return null; } return this.things[idx]; } /** * Get the list of things. */ getThings(): Thing[] { return this.things; } /** * Get the mDNS server name. */ getName(): string { return this.name; } } /** * Base handler that is initialized with a list of things. */ abstract class BaseHandler { protected things: SingleThing | MultipleThings; /** * Initialize the handler. * * @param {Object} things List of Things managed by the server */ constructor(things: SingleThing | MultipleThings) { this.things = things; } abstract get(req: express.Request, res: express.Response): void; /** * Get the thing this request is for. * * @param {Object} req The request object * @returns {Object} The thing, or null if not found. */ getThing(req: express.Request): Thing | null { return this.things.getThing(req.params.thingId); } } /** * Handle a request to / when the server manages multiple things. */ class ThingsHandler extends BaseHandler { /** * Handle a GET request. * * @param {Object} req The request object * @param {Object} res The response object */ get(req: express.Request, res: express.Response): void { const wsHref = `${req.secure ? 'wss' : 'ws'}://${req.headers.host}`; res.json( this.things.getThings().map((thing) => { const description = thing.asThingDescription(); description.href = thing.getHref(); description.links.push({ rel: 'alternate', href: `${wsHref}${thing.getHref()}`, }); description.base = `${req.protocol}://${req.headers.host}${thing.getHref()}`; description.securityDefinitions = { nosec_sc: { scheme: 'nosec', }, }; description.security = 'nosec_sc'; return description; }) ); } } /** * Handle a request to /. */ class ThingHandler extends BaseHandler { /** * Handle a GET request. * * @param {Object} req The request object * @param {Object} res The response object */ get(req: express.Request, res: express.Response): void { const thing = this.getThing(req); if (thing === null) { res.status(404).end(); return; } const wsHref = `${req.secure ? 'wss' : 'ws'}://${req.headers.host}`; const description = thing.asThingDescription(); description.links.push({ rel: 'alternate', href: `${wsHref}${thing.getHref()}`, }); description.base = `${req.protocol}://${req.headers.host}${thing.getHref()}`; description.securityDefinitions = { nosec_sc: { scheme: 'nosec', }, }; description.security = 'nosec_sc'; res.json(description); } /** * Handle a websocket request. * * @param {Object} ws The websocket object * @param {Object} req The request object */ ws(ws: import('ws'), req: express.Request): void { const thing = this.getThing(req); if (thing === null) { ws.send( JSON.stringify({ messageType: 'error', data: { status: '404 Not Found', message: 'The requested thing was not found', }, }) ); return; } thing.addSubscriber(ws); ws.on('error', () => thing.removeSubscriber(ws)); ws.on('close', () => thing.removeSubscriber(ws)); ws.on('message', (msg) => { let message: { messageType: string; data: Record; }; try { message = JSON.parse(msg as string); } catch (e1) { try { ws.send( JSON.stringify({ messageType: 'error', data: { status: '400 Bad Request', message: 'Parsing request failed', }, }) ); } catch (e2) { // do nothing } return; } if (!message.hasOwnProperty('messageType') || !message.hasOwnProperty('data')) { try { ws.send( JSON.stringify({ messageType: 'error', data: { status: '400 Bad Request', message: 'Invalid message', }, }) ); } catch (e) { // do nothing } return; } const messageType = message.messageType; switch (messageType) { case 'setProperty': { for (const propertyName in message.data) { try { thing.setProperty(propertyName, message.data[propertyName]); } catch (e) { ws.send( JSON.stringify({ messageType: 'error', data: { status: '400 Bad Request', message: e.message, }, }) ); } } break; } case 'requestAction': { for (const actionName in message.data) { let input = null; const actionData = >message.data[actionName]; if (actionData.hasOwnProperty('input')) { input = actionData.input; } const action = thing.performAction(actionName, input); if (action) { action.start(); } else { ws.send( JSON.stringify({ messageType: 'error', data: { status: '400 Bad Request', message: 'Invalid action request', request: message, }, }) ); } } break; } case 'addEventSubscription': { for (const eventName in message.data) { thing.addEventSubscriber(eventName, ws); } break; } default: { try { ws.send( JSON.stringify({ messageType: 'error', data: { status: '400 Bad Request', message: `Unknown messageType: ${messageType}`, request: message, }, }) ); } catch (e) { // do nothing } } } }); } } /** * Handle a request to /properties. */ class PropertiesHandler extends BaseHandler { /** * Handle a GET request. * * @param {Object} req The request object * @param {Object} res The response object */ get(req: express.Request, res: express.Response): void { const thing = this.getThing(req); if (thing === null) { res.status(404).end(); return; } res.json(thing.getProperties()); } } /** * Handle a request to /properties/. */ class PropertyHandler extends BaseHandler { /** * Handle a GET request. * * @param {Object} req The request object * @param {Object} res The response object */ get(req: express.Request, res: express.Response): void { const thing = this.getThing(req); if (thing === null) { res.status(404).end(); return; } const propertyName = req.params.propertyName; if (thing.hasProperty(propertyName)) { res.json({ [propertyName]: thing.getProperty(propertyName) }); } else { res.status(404).end(); } } /** * Handle a PUT request. * * @param {Object} req The request object * @param {Object} res The response object */ put(req: express.Request, res: express.Response): void { const thing = this.getThing(req); if (thing === null) { res.status(404).end(); return; } const propertyName = req.params.propertyName; if (!req.body.hasOwnProperty(propertyName)) { res.status(400).end(); return; } if (thing.hasProperty(propertyName)) { try { thing.setProperty(propertyName, req.body[propertyName]); } catch (e) { res.status(400).end(); return; } res.json({ [propertyName]: thing.getProperty(propertyName) }); } else { res.status(404).end(); } } } /** * Handle a request to /actions. */ class ActionsHandler extends BaseHandler { /** * Handle a GET request. * * @param {Object} req The request object * @param {Object} res The response object */ get(req: express.Request, res: express.Response): void { const thing = this.getThing(req); if (thing === null) { res.status(404).end(); return; } res.json(thing.getActionDescriptions()); } /** * Handle a POST request. * * @param {Object} req The request object * @param {Object} res The response object */ post(req: express.Request, res: express.Response): void { const thing = this.getThing(req); if (thing === null) { res.status(404).end(); return; } const keys = Object.keys(req.body); if (keys.length !== 1) { res.status(400).end(); return; } const actionName = keys[0]; let input = null; if (req.body[actionName].hasOwnProperty('input')) { input = req.body[actionName].input; } const action = thing.performAction(actionName, input); if (action) { const response = action.asActionDescription(); action.start(); res.status(201); res.json(response); } else { res.status(400).end(); } } } /** * Handle a request to /actions/. */ class ActionHandler extends BaseHandler { /** * Handle a GET request. * * @param {Object} req The request object * @param {Object} res The response object */ get(req: express.Request, res: express.Response): void { const thing = this.getThing(req); if (thing === null) { res.status(404).end(); return; } const actionName = req.params.actionName; res.json(thing.getActionDescriptions(actionName)); } /** * Handle a POST request. * * @param {Object} req The request object * @param {Object} res The response object */ post(req: express.Request, res: express.Response): void { const thing = this.getThing(req); if (thing === null) { res.status(404).end(); return; } const actionName = req.params.actionName; const keys = Object.keys(req.body); if (keys.length !== 1) { res.status(400).end(); return; } if (keys[0] !== actionName) { res.status(400).end(); return; } let input = null; if (req.body[actionName].hasOwnProperty('input')) { input = req.body[actionName].input; } const action = thing.performAction(actionName, input); if (action) { const response = action.asActionDescription(); action.start(); res.status(201); res.json(response); } else { res.status(400).end(); } } } /** * Handle a request to /actions//. */ class ActionIDHandler extends BaseHandler { /** * Handle a GET request. * * @param {Object} req The request object * @param {Object} res The response object */ get(req: express.Request, res: express.Response): void { const thing = this.getThing(req); if (thing === null) { res.status(404).end(); return; } const actionName = req.params.actionName; const actionId = req.params.actionId; const action = thing.getAction(actionName, actionId); if (action === null) { res.status(404).end(); return; } res.json(action.asActionDescription()); } /** * Handle a PUT request. * * @param {Object} req The request object * @param {Object} res The response object */ put(req: express.Request, res: express.Response): void { const thing = this.getThing(req); if (thing === null) { res.status(404).end(); return; } // TODO: this is not yet defined in the spec res.status(200).end(); } /** * Handle a DELETE request. * * @param {Object} req The request object * @param {Object} res The response object */ delete(req: express.Request, res: express.Response): void { const thing = this.getThing(req); if (thing === null) { res.status(404).end(); return; } const actionName = req.params.actionName; const actionId = req.params.actionId; if (thing.removeAction(actionName, actionId)) { res.status(204).end(); } else { res.status(404).end(); } } } /** * Handle a request to /events. */ class EventsHandler extends BaseHandler { /** * Handle a GET request. * * @param {Object} req The request object * @param {Object} res The response object */ get(req: express.Request, res: express.Response): void { const thing = this.getThing(req); if (thing === null) { res.status(404).end(); return; } res.json(thing.getEventDescriptions()); } } /** * Handle a request to /events/. */ class EventHandler extends BaseHandler { /** * Handle a GET request. * * @param {Object} req The request object * @param {Object} res The response object */ get(req: express.Request, res: express.Response): void { const thing = this.getThing(req); if (thing === null) { res.status(404).end(); return; } const eventName = req.params.eventName; res.json(thing.getEventDescriptions(eventName)); } } /** * Server to represent a Web Thing over HTTP. */ export class WebThingServer { things: SingleThing | MultipleThings; name: string; port: number; hostname: string | null; basePath: string; disableHostValidation: boolean; hosts: string[]; app: express.Express & { isTls?: boolean }; // HACK because the express types are weird server: http.Server | https.Server; router: expressWs.Router; mdns!: dnssd.Advertisement; /** * Initialize the WebThingServer. * * For documentation on the additional route handlers, see: * http://expressjs.com/en/4x/api.html#app.use * * @param {Object} things Things managed by this server -- should be of type * SingleThing or MultipleThings * @param {Number} port Port to listen on (defaults to 80) * @param {String} hostname Optional host name, i.e. mything.com * @param {Object} sslOptions SSL options to pass to the express server * @param {Object[]} additionalRoutes List of additional routes to add to * server, i.e. [{path: '..', handler: ..}] * @param {String} basePath Base URL path to use, rather than '/' * @param {Boolean} disableHostValidation Whether or not to disable host * validation -- note that this can * lead to DNS rebinding attacks */ constructor( things: SingleThing | MultipleThings, port: number | null = null, hostname: string | null = null, sslOptions: https.ServerOptions | null = null, additionalRoutes: Record[] | null = null, basePath = '/', disableHostValidation = false ) { this.things = things; this.name = things.getName(); this.port = Number(port) || (sslOptions ? 443 : 80); this.hostname = hostname; this.basePath = basePath.replace(/\/$/, ''); this.disableHostValidation = !!disableHostValidation; const systemHostname = os.hostname().toLowerCase(); this.hosts = [ 'localhost', `localhost:${port}`, `${systemHostname}.local`, `${systemHostname}.local:${port}`, ]; utils.getAddresses().forEach((address) => { this.hosts.push(address, `${address}:${port}`); }); if (hostname) { hostname = hostname.toLowerCase(); this.hosts.push(hostname, `${hostname}:${port}`); } if (things instanceof MultipleThings) { const list = things.getThings(); for (let i = 0; i < list.length; i++) { const thing = list[i]; thing.setHrefPrefix(`${this.basePath}/${i}`); } } else { things.getThing().setHrefPrefix(this.basePath); } this.app = express(); this.app.use(bodyParser.json()); // Validate Host header this.app.use((request, response, next: () => unknown) => { const host = request.headers.host; if (this.disableHostValidation || (host && this.hosts.includes(host.toLowerCase()))) { next(); } else { response.status(403).send('Forbidden'); } }); // Set CORS headers this.app.use((_request, response, next) => { response.setHeader('Access-Control-Allow-Origin', '*'); response.setHeader( 'Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept' ); response.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, PUT, POST, DELETE'); next(); }); if (sslOptions) { this.server = https.createServer(sslOptions); this.app.isTls = true; } else { this.server = http.createServer(); this.app.isTls = false; } expressWs(this.app, this.server); const thingsHandler = new ThingsHandler(this.things); const thingHandler = new ThingHandler(this.things); const propertiesHandler = new PropertiesHandler(this.things); const propertyHandler = new PropertyHandler(this.things); const actionsHandler = new ActionsHandler(this.things); const actionHandler = new ActionHandler(this.things); const actionIdHandler = new ActionIDHandler(this.things); const eventsHandler = new EventsHandler(this.things); const eventHandler = new EventHandler(this.things); this.router = express.Router(); if (Array.isArray(additionalRoutes)) { for (const route of additionalRoutes) { this.router.use(route.path, route.handler); } } if (this.things instanceof MultipleThings) { this.router.get('/', (req, res) => thingsHandler.get(req, res)); this.router.get('/:thingId', (req, res) => thingHandler.get(req, res)); this.router.ws('/:thingId', (ws, req) => thingHandler.ws(ws, req)); this.router.get('/:thingId/properties', (req, res) => propertiesHandler.get(req, res)); this.router.get('/:thingId/properties/:propertyName', (req, res) => propertyHandler.get(req, res) ); this.router.put('/:thingId/properties/:propertyName', (req, res) => propertyHandler.put(req, res) ); this.router.get('/:thingId/actions', (req, res) => actionsHandler.get(req, res)); this.router.post('/:thingId/actions', (req, res) => actionsHandler.post(req, res)); this.router.get('/:thingId/actions/:actionName', (req, res) => actionHandler.get(req, res)); this.router.post('/:thingId/actions/:actionName', (req, res) => actionHandler.post(req, res)); this.router.get('/:thingId/actions/:actionName/:actionId', (req, res) => actionIdHandler.get(req, res) ); this.router.put('/:thingId/actions/:actionName/:actionId', (req, res) => actionIdHandler.put(req, res) ); this.router.delete('/:thingId/actions/:actionName/:actionId', (req, res) => actionIdHandler.delete(req, res) ); this.router.get('/:thingId/events', (req, res) => eventsHandler.get(req, res)); this.router.get('/:thingId/events/:eventName', (req, res) => eventHandler.get(req, res)); } else { this.router.get('/', (req, res) => thingHandler.get(req, res)); this.router.ws('/', (ws, req) => thingHandler.ws(ws, req)); this.router.get('/properties', (req, res) => propertiesHandler.get(req, res)); this.router.get('/properties/:propertyName', (req, res) => propertyHandler.get(req, res)); this.router.put('/properties/:propertyName', (req, res) => propertyHandler.put(req, res)); this.router.get('/actions', (req, res) => actionsHandler.get(req, res)); this.router.post('/actions', (req, res) => actionsHandler.post(req, res)); this.router.get('/actions/:actionName', (req, res) => actionHandler.get(req, res)); this.router.post('/actions/:actionName', (req, res) => actionHandler.post(req, res)); this.router.get('/actions/:actionName/:actionId', (req, res) => actionIdHandler.get(req, res) ); this.router.put('/actions/:actionName/:actionId', (req, res) => actionIdHandler.put(req, res) ); this.router.delete('/actions/:actionName/:actionId', (req, res) => actionIdHandler.delete(req, res) ); this.router.get('/events', (req, res) => eventsHandler.get(req, res)); this.router.get('/events/:eventName', (req, res) => eventHandler.get(req, res)); } this.app.use(this.basePath || '/', this.router); this.server.on('request', this.app); } /** * Start listening for incoming connections. * * @returns {Promise} Promise which resolves once the server is started. */ start(): Promise { const opts: dnssd.Options = { name: this.name, txt: { path: '/', }, }; if (this.app.isTls) { opts.txt.tls = '1'; } this.mdns = new dnssd.Advertisement(new dnssd.ServiceType('_webthing._tcp'), this.port!, opts); this.mdns.on('error', (e) => { console.debug(`mDNS error: ${e}`); setTimeout(() => { this.mdns.start(); }, 10000); }); this.mdns.start(); return new Promise((resolve) => { this.server.listen({ port: this.port }, resolve); }); } /** * Stop listening. * * @param {boolean?} force - Whether or not to force shutdown immediately. * @returns {Promise} Promise which resolves once the server is stopped. */ stop(force = false): Promise { const promises: Promise[] = []; if (this.mdns) { promises.push( new Promise((resolve, reject) => { this.mdns.stop(force, (error?: unknown) => { if (error) { reject(error); } else { resolve(); } }); }) ); } promises.push( new Promise((resolve, reject) => { this.server.close((error) => { if (error) { reject(error); } else { resolve(); } }); }) ); return Promise.all(promises); } } ================================================ FILE: src/thing.ts ================================================ /** * High-level Thing base class implementation. */ import Ajv from 'ajv'; import Property from './property'; import Event from './event'; import Action from './action'; import { AnyType, Link, Subscriber } from './types'; const ajv = new Ajv(); /** * A Web Thing. */ class Thing { private id: string; private title: string; private type: string[]; private context: string; private description: string; private properties: { [name: string]: Property }; private availableActions: { [actionName: string]: { metadata: Action.ActionMetadata; class: Action.ActionTypeClass; }; }; private availableEvents: { [name: string]: { metadata: Event.EventMetadata; subscribers: Set; }; }; private actions: { [name: string]: Action[] }; private events: Event[]; private subscribers = new Set(); private hrefPrefix: string; private uiHref: string | null; /** * Initialize the object. * * @param {String} id The thing's unique ID - must be a URI * @param {String} title The thing's title * @param {String} type (Optional) The thing's type(s) * @param {String} description (Optional) Description of the thing */ constructor(id: string, title: string, type: string | string[], description: string) { if (!Array.isArray(type)) { type = [type]; } this.id = id; this.title = title; this.context = 'https://webthings.io/schemas'; this.type = type || []; this.description = description || ''; this.properties = {}; this.availableActions = {}; this.availableEvents = {}; this.actions = {}; this.events = []; this.subscribers = new Set(); this.hrefPrefix = ''; this.uiHref = null; } /** * Return the thing state as a Thing Description. * * @returns {Object} Current thing state */ asThingDescription(): Thing.ThingDescription { const thing: Omit = { id: this.id, title: this.title, '@context': this.context, '@type': this.type, properties: this.getPropertyDescriptions(), actions: {}, events: {}, links: [ { rel: 'properties', href: `${this.hrefPrefix}/properties`, }, { rel: 'actions', href: `${this.hrefPrefix}/actions`, }, { rel: 'events', href: `${this.hrefPrefix}/events`, }, ], }; for (const name in this.availableActions) { thing.actions[name] = this.availableActions[name].metadata; thing.actions[name].links = [ { rel: 'action', href: `${this.hrefPrefix}/actions/${name}`, }, ]; } for (const name in this.availableEvents) { thing.events[name] = this.availableEvents[name].metadata; thing.events[name].links = [ { rel: 'event', href: `${this.hrefPrefix}/events/${name}`, }, ]; } if (this.uiHref) { thing.links.push({ rel: 'alternate', mediaType: 'text/html', href: this.uiHref, }); } if (this.description) { thing.description = this.description; } return thing as Thing.ThingDescription; } /** * Get this thing's href. * * @returns {String} The href. */ getHref(): string { if (this.hrefPrefix) { return this.hrefPrefix; } return '/'; } /** * Get this thing's UI href. * * @returns {String|null} The href. */ getUiHref(): string | null { return this.uiHref; } /** * Set the prefix of any hrefs associated with this thing. * * @param {String} prefix The prefix */ setHrefPrefix(prefix: string): void { this.hrefPrefix = prefix; for (const property of Object.values(this.properties)) { property.setHrefPrefix(prefix); } for (const actionName in this.actions) { for (const action of this.actions[actionName]) { action.setHrefPrefix(prefix); } } } /** * Set the href of this thing's custom UI. * * @param {String} href The href */ setUiHref(href: string): void { this.uiHref = href; } /** * Get the ID of the thing. * * @returns {String} The ID. */ getId(): string { return this.id; } /** * Get the title of the thing. * * @returns {String} The title. */ getTitle(): string { return this.title; } /** * Get the type context of the thing. * * @returns {String} The context. */ getContext(): string { return this.context; } /** * Get the type(s) of the thing. * * @returns {String[]} The type(s). */ getType(): string[] { return this.type; } /** * Get the description of the thing. * * @returns {String} The description. */ getDescription(): string { return this.description; } /** * Get the thing's properties as an object. * * @returns {Object} Properties, i.e. name -> description */ getPropertyDescriptions(): { [name: string]: Property.PropertyDescription } { const descriptions: { [name: string]: Property.PropertyDescription } = {}; for (const name in this.properties) { descriptions[name] = this.properties[name].asPropertyDescription(); } return descriptions; } /** * Get the thing's actions as an array. * * @param {String?} actionName Optional action name to get descriptions for * * @returns {Object} Action descriptions. */ getActionDescriptions(actionName?: string | null): Action.ActionDescription[] { const descriptions: Action.ActionDescription[] = []; if (!actionName) { for (const name in this.actions) { for (const action of this.actions[name]) { descriptions.push(action.asActionDescription()); } } } else if (this.actions.hasOwnProperty(actionName)) { for (const action of this.actions[actionName]) { descriptions.push(action.asActionDescription()); } } return descriptions; } /** * Get the thing's events as an array. * * @param {String?} eventName Optional event name to get descriptions for * * @returns {Object} Event descriptions. */ getEventDescriptions(eventName?: string | null): Event.EventDescription[] { if (!eventName) { return this.events.map((e) => e.asEventDescription()); } else { return this.events .filter((e) => e.getName() === eventName) .map((e) => e.asEventDescription()); } } /** * Add a property to this thing. * * @param {Object} property Property to add */ addProperty(property: Property): void { property.setHrefPrefix(this.hrefPrefix); this.properties[property.getName()] = property; } /** * Remove a property from this thing. * * @param {Object} property Property to remove */ removeProperty(property: Property): void { if (this.properties.hasOwnProperty(property.getName())) { delete this.properties[property.getName()]; } } /** * Find a property by name. * * @param {String} propertyName Name of the property to find * * @returns {(Object|null)} Property if found, else null */ findProperty(propertyName: string): Property | null { if (this.properties.hasOwnProperty(propertyName)) { return this.properties[propertyName]; } return null; } /** * Get a property's value. * * @param {String} propertyName Name of the property to get the value of * * @returns {*} Current property value if found, else null */ getProperty(propertyName: string): unknown | null { const prop = this.findProperty(propertyName); if (prop) { return prop.getValue(); } return null; } /** * Get a mapping of all properties and their values. * * Returns an object of propertyName -> value. */ getProperties(): Record { const props: Record = {}; for (const name in this.properties) { props[name] = this.properties[name].getValue(); } return props; } /** * Determine whether or not this thing has a given property. * * @param {String} propertyName The property to look for * * @returns {Boolean} Indication of property presence */ hasProperty(propertyName: string): boolean { return this.properties.hasOwnProperty(propertyName); } /** * Set a property value. * * @param {String} propertyName Name of the property to set * @param {*} value Value to set */ setProperty(propertyName: string, value: AnyType): void { const prop = this.findProperty(propertyName); if (!prop) { return; } prop.setValue(value); } /** * Get an action. * * @param {String} actionName Name of the action * @param {String} actionId ID of the action * @returns {Object} The requested action if found, else null */ getAction(actionName: string, actionId: string): Action | null { if (!this.actions.hasOwnProperty(actionName)) { return null; } for (const action of this.actions[actionName]) { if (action.getId() === actionId) { return action; } } return null; } /** * Add a new event and notify subscribers. * * @param {Object} event The event that occurred */ addEvent(event: Event): void { this.events.push(event); this.eventNotify(event); } /** * Add an available event. * * @param {String} name Name of the event * @param {Object} metadata Event metadata, i.e. type, description, etc., as * an object. */ addAvailableEvent(name: string, metadata: Event.EventMetadata): void { if (!metadata) { metadata = {}; } this.availableEvents[name] = { metadata: metadata, subscribers: new Set(), }; } /** * Perform an action on the thing. * * @param {String} actionName Name of the action * @param {Object} input Any action inputs * @returns {Object} The action that was created. */ performAction( actionName: string, input: InputType | null ): Action | undefined { input = input || null; if (!this.availableActions.hasOwnProperty(actionName)) { return; } const actionType = this.availableActions[actionName]; if (actionType.metadata.hasOwnProperty('input')) { const schema = JSON.parse(JSON.stringify(actionType.metadata.input)); if (schema.hasOwnProperty('properties')) { const props: Record[] = Object.values(schema.properties); for (const prop of props) { delete prop.title; delete prop.unit; delete prop['@type']; } } const valid = ajv.validate(schema, input); if (!valid) { return; } } const action: Action = >( new actionType.class(this, (input)) ); action.setHrefPrefix(this.hrefPrefix); this.actionNotify(>(action)); this.actions[actionName].push(>(action)); return action; } /** * Remove an existing action. * * @param {String} actionName Name of the action * @param {String} actionId ID of the action * @returns boolean indicating the presence of the action. */ removeAction(actionName: string, actionId: string): boolean { const action = this.getAction(actionName, actionId); if (action === null) { return false; } action.cancel(); for (let i = 0; i < this.actions[actionName].length; ++i) { if (this.actions[actionName][i].getId() === actionId) { this.actions[actionName].splice(i, 1); break; } } return true; } /** * Add an available action. * * @param {String} name Name of the action * @param {Object} metadata Action metadata, i.e. type, description, etc., as * an object. * @param {Object} cls Class to instantiate for this action */ addAvailableAction( name: string, metadata: Action.ActionMetadata | null, cls: Action.ActionTypeClass ): void { if (!metadata) { metadata = {}; } this.availableActions[name] = { metadata: metadata, class: cls, }; this.actions[name] = []; } /** * Add a new websocket subscriber. * * @param {Object} ws The websocket */ addSubscriber(ws: Subscriber): void { this.subscribers.add(ws); } /** * Remove a websocket subscriber. * */ removeSubscriber(ws: Subscriber): void { if (this.subscribers.has(ws)) { this.subscribers.delete(ws); } for (const name in this.availableEvents) { this.removeEventSubscriber(name, ws); } } /** * Add a new websocket subscriber to an event. * * @param {String} name Name of the event * @param {Subscriber} ws The websocket */ addEventSubscriber(name: string, ws: Subscriber): void { if (this.availableEvents.hasOwnProperty(name)) { this.availableEvents[name].subscribers.add(ws); } } /** * Remove a websocket subscriber from an event. * * @param {String} name Name of the event * @param {Object} ws The websocket */ removeEventSubscriber(name: string, ws: Subscriber): void { if ( this.availableEvents.hasOwnProperty(name) && this.availableEvents[name].subscribers.has(ws) ) { this.availableEvents[name].subscribers.delete(ws); } } /** * Notify all subscribers of a property change. * * @param {Object} property The property that changed */ propertyNotify(property: Property): void { const message = JSON.stringify({ messageType: 'propertyStatus', data: { [property.getName()]: property.getValue(), }, }); for (const subscriber of this.subscribers) { try { subscriber.send(message); } catch (e) { // do nothing } } } /** * Notify all subscribers of an action status change. * * @param {Object} action The action whose status changed */ actionNotify(action: Action): void { const message = JSON.stringify({ messageType: 'actionStatus', data: action.asActionDescription(), }); for (const subscriber of this.subscribers) { try { subscriber.send(message); } catch (e) { // do nothing } } } /** * Notify all subscribers of an event. * * @param {Object} event The event that occurred */ eventNotify(event: Event): void { if (!this.availableEvents.hasOwnProperty(event.getName())) { return; } const message = JSON.stringify({ messageType: 'event', data: event.asEventDescription(), }); for (const subscriber of this.availableEvents[event.getName()].subscribers) { try { subscriber.send(message); } catch (e) { // do nothing } } } } // eslint-disable-next-line @typescript-eslint/no-namespace declare namespace Thing { export interface SecurityScheme { '@type'?: string | string[]; scheme: string; description?: string; descriptions?: { [lang: string]: string }; proxy?: string; } export interface ThingDescription { id: string; title: string; name: string; href: string; '@context': string; '@type': string[]; properties: { [name: string]: Property.PropertyDescription }; links: Link[]; actions: { [name: string]: Action.ActionMetadata }; events: { [name: string]: Event.EventMetadata }; description?: string; base?: string; securityDefinitions?: { [security: string]: SecurityScheme }; security?: string; } } export = Thing; ================================================ FILE: src/types.ts ================================================ export type PrimitiveJsonType = | 'null' | 'boolean' | 'object' | 'array' | 'number' | 'integer' | 'string'; export type AnyType = null | boolean | number | string | Record | unknown[]; export interface Link { rel: string; href: string; mediaType?: string; } export interface Subscriber { send(message: string): void; } ================================================ FILE: src/utils.ts ================================================ /** * Utility functions. */ import * as os from 'os'; /** * Get the current time. * * @returns {String} The current time in the form YYYY-mm-ddTHH:MM:SS+00:00 */ export function timestamp(): string { const date = new Date().toISOString(); return date.replace(/\.\d{3}Z/, '+00:00'); } /** * Get all IP addresses. * * @returns {string[]} Array of addresses. */ export function getAddresses(): string[] { const addresses = new Set(); const ifaces = os.networkInterfaces(); Object.keys(ifaces).forEach((iface) => { ifaces[iface]!.forEach((addr) => { const address = addr.address.toLowerCase(); // Filter out link-local addresses. if (addr.family === 'IPv6' && !address.startsWith('fe80:')) { addresses.add(`[${address}]`); } else if (addr.family === 'IPv4' && !address.startsWith('169.254.')) { addresses.add(address); } }); }); return Array.from(addresses).sort(); } ================================================ FILE: src/value.ts ================================================ /** * An observable, settable value interface. */ import { EventEmitter } from 'events'; import { AnyType } from './types'; /** * A property value. * * This is used for communicating between the Thing representation and the * actual physical thing implementation. * * Notifies all observers when the underlying value changes through an external * update (command to turn the light off) or if the underlying sensor reports a * new value. */ class Value extends EventEmitter { private lastValue: ValueType; private valueForwarder: Value.Forwarder | null; /** * Initialize the object. * * @param {*} initialValue The initial value * @param {function?} valueForwarder The method that updates the actual value * on the thing */ constructor(initialValue: ValueType, valueForwarder: Value.Forwarder | null = null) { super(); this.lastValue = initialValue; this.valueForwarder = valueForwarder; } /** * Set a new value for this thing. * * @param {*} value Value to set */ set(value: ValueType): void { if (this.valueForwarder) { this.valueForwarder(value); } this.notifyOfExternalUpdate(value); } /** * Return the last known value from the underlying thing. * * @returns the value. */ get(): ValueType { return this.lastValue; } /** * Notify observers of a new value. * * @param {*} value New value */ notifyOfExternalUpdate(value: ValueType): void { if (typeof value !== 'undefined' && value !== null && value !== this.lastValue) { this.lastValue = value; this.emit('update', value); } } } declare namespace Value { export type Forwarder = (value: T) => void; } export = Value; ================================================ FILE: src/webthing.ts ================================================ import Action from './action'; import Event from './event'; import Property from './property'; import Thing from './thing'; import Value from './value'; export { Action, Event, Property, Thing, Value }; export * from './server'; ================================================ FILE: test.sh ================================================ #!/bin/bash -e pushd example npm install popd # clone the webthing-tester if [ ! -d webthing-tester ]; then git clone https://github.com/WebThingsIO/webthing-tester fi pip3 install --user -r webthing-tester/requirements.txt export NODE_PATH=. # build and test the single-thing example node example/single-thing.js & EXAMPLE_PID=$! sleep 5 ./webthing-tester/test-client.py kill -15 $EXAMPLE_PID # build and test the multiple-things example node example/multiple-things.js & EXAMPLE_PID=$! sleep 5 ./webthing-tester/test-client.py --path-prefix "/0" kill -15 $EXAMPLE_PID ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "es2018", "module": "commonjs", "moduleResolution": "node", "lib": [ "es2018", "dom" ], "declaration": true, "declarationMap": true, "sourceMap": true, "rootDir": "src", "outDir": "lib", "strict": true, "noImplicitAny": true, "strictNullChecks": true, "strictFunctionTypes": true, "strictBindCallApply": true, "strictPropertyInitialization": true, "noImplicitThis": true, "alwaysStrict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "esModuleInterop": true } }