├── global.d.ts ├── .czrc ├── .npmignore ├── .commitlintrc.yaml ├── __mocks__ ├── axios.ts └── http-signature.ts ├── .editorconfig ├── .vscode └── settings.json ├── jest.config.ts ├── .github ├── ISSUE_TEMPLATE │ ├── question_template.md │ ├── bug_report_template.md │ └── feature_request_template.md ├── workflows │ ├── stale.yaml │ └── ci-cd.yaml └── pull_request_template.md ├── .changeset ├── config.json └── README.md ├── tsconfig-test.json ├── test └── unit │ ├── data │ ├── installedapps │ │ ├── delete.ts │ │ ├── post.ts │ │ └── put.ts │ ├── scenes │ │ └── post.ts │ ├── modes │ │ ├── post.ts │ │ ├── delete.ts │ │ ├── put.ts │ │ └── get.ts │ ├── rooms │ │ ├── post.ts │ │ ├── put.ts │ │ └── delete.ts │ ├── capabilities │ │ ├── post.ts │ │ ├── put.ts │ │ └── get.ts │ ├── schedules │ │ ├── get.ts │ │ └── post.ts │ ├── deviceprofiles │ │ ├── delete.ts │ │ ├── post.ts │ │ └── put.ts │ ├── subscriptions │ │ ├── delete.ts │ │ ├── get.ts │ │ └── post.ts │ ├── services │ │ ├── delete.ts │ │ └── post.ts │ ├── signature │ │ ├── post.ts │ │ └── models.ts │ ├── organizations │ │ └── get.ts │ └── presentation │ │ └── models.ts │ ├── helpers │ └── utils.ts │ ├── organizations.test.ts │ ├── endpoint.test.ts │ ├── st-client.test.ts │ ├── invites-schemaApp.test.ts │ ├── scenes.test.ts │ ├── drivers.test.ts │ ├── subscriptions.test.ts │ ├── locations.test.ts │ ├── schedules.test.ts │ ├── rules.test.ts │ ├── presentation.test.ts │ ├── signature.test.ts │ ├── hubdevices.test.ts │ ├── virtualdevices.test.ts │ ├── rooms.test.ts │ ├── apps.test.ts │ ├── schema.test.ts │ ├── modes.test.ts │ └── channels.test.ts ├── tsconfig.json ├── src ├── endpoint.ts ├── index.ts ├── types.ts ├── endpoint │ ├── organizations.ts │ ├── invites-schemaApp.ts │ ├── notifications.ts │ ├── virtualdevices.ts │ ├── scenes.ts │ ├── rooms.ts │ ├── channels.ts │ ├── modes.ts │ ├── hubdevices.ts │ ├── history.ts │ ├── deviceprofiles.ts │ ├── locations.ts │ └── drivers.ts ├── rest-client.ts ├── logger.ts ├── pagination.ts ├── signature.ts └── authenticator.ts ├── tsdoc.json ├── .gitignore ├── package.json ├── eslint.config.ts └── CONTRIBUTING.md /global.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'http-signature' 2 | -------------------------------------------------------------------------------- /.czrc: -------------------------------------------------------------------------------- 1 | { 2 | "path": "cz-conventional-changelog" 3 | } 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | tsconfig.json 3 | tslint.json 4 | .prettierrc 5 | -------------------------------------------------------------------------------- /.commitlintrc.yaml: -------------------------------------------------------------------------------- 1 | extends: 2 | - "@commitlint/config-conventional" 3 | rules: 4 | body-max-line-length: [1, "always", 100] 5 | -------------------------------------------------------------------------------- /__mocks__/axios.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | get: jest.fn(() => Promise.resolve({ status: 200, data: {} })), 3 | request: jest.fn(() => Promise.resolve({ status: 200, data: {} })), 4 | } 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 4 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.{yml,yaml}] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /__mocks__/http-signature.ts: -------------------------------------------------------------------------------- 1 | import httpSignature from 'http-signature' 2 | 3 | 4 | export default { 5 | parseRequest: jest.fn((request, options) => httpSignature.parseRequest(request, options)), 6 | verifySignature: jest.fn((signature, publicKey) => httpSignature.verifySignature(signature, publicKey)), 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "childdevice", 4 | "commitlint", 5 | "conventionalcommits", 6 | "devicepreferences", 7 | "deviceprofiles", 8 | "Hubdevices", 9 | "installedapps", 10 | "PENGYOU", 11 | "smartthingspi", 12 | "sshpk" 13 | ], 14 | "typescript.tsdk": "node_modules/typescript/lib" 15 | } 16 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import { Config } from 'jest' 2 | 3 | 4 | const config: Config = { 5 | preset: 'ts-jest', 6 | moduleFileExtensions: [ 7 | 'ts', 8 | 'js', 9 | ], 10 | transform: { 11 | '^.+\\.(ts|tsx)$': 'ts-jest', 12 | }, 13 | collectCoverageFrom: ['src/**/*.ts'], 14 | testEnvironment: 'node', 15 | } 16 | 17 | export default config 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question_template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question Template 3 | about: Ask a question about usage or design 4 | title: 'question: ' 5 | labels: question 6 | 7 | --- 8 | 9 | **What is your question? Please describe.** 10 | A clear and concise question. 11 | 12 | **Additional context** 13 | Add any other context or screenshots about the question here. 14 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.2.0/schema.json", 3 | "changelog": [ 4 | "@changesets/changelog-github", 5 | { 6 | "repo": "SmartThingsCommunity/smartthings-core-sdk" 7 | } 8 | ], 9 | "commit": false, 10 | "fixed": [], 11 | "linked": [], 12 | "access": "public", 13 | "baseBranch": "main", 14 | "updateInternalDependencies": "patch", 15 | "ignore": [] 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig-test.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "strict": true, 6 | "declaration": true, 7 | "strictNullChecks": true, 8 | "esModuleInterop": true, 9 | "noImplicitReturns": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "declarationMap": true, 12 | "outDir": "./dist_test", 13 | }, 14 | "include": [ 15 | "./test/**/*.ts", 16 | "./jest.config.ts", 17 | ], 18 | } 19 | -------------------------------------------------------------------------------- /test/unit/data/installedapps/delete.ts: -------------------------------------------------------------------------------- 1 | export const delete_installedapps_5336bd07_435f_4b6c_af1d_fddba55c1c24 = { 2 | 'request': { 3 | 'url': 'https://api.smartthings.com/installedapps/5336bd07-435f-4b6c-af1d-fddba55c1c24', 4 | 'method': 'delete', 5 | 'headers': { 6 | 'Content-Type': 'application/json;charset=utf-8', 7 | 'Accept': 'application/json', 8 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 9 | }, 10 | }, 11 | 'response': { 12 | 'count': 1, 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /test/unit/data/scenes/post.ts: -------------------------------------------------------------------------------- 1 | export const post_scenes_13a63ff0_587e_45d2_8c4e_b40525f2093c_execute = { 2 | 'request': { 3 | 'url': 'https://api.smartthings.com/scenes/13a63ff0-587e-45d2-8c4e-b40525f2093c/execute', 4 | 'method': 'post', 5 | 'headers': { 6 | 'Content-Type': 'application/json;charset=utf-8', 7 | 'Accept': 'application/json', 8 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 9 | }, 10 | }, 11 | 'response': { 12 | 'status': 'success', 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "lib": ["es2020", "DOM"], 5 | "target": "ES2018", 6 | "strict": true, 7 | "declaration": true, 8 | "strictNullChecks": true, 9 | "esModuleInterop": true, 10 | "noImplicitReturns": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "declarationMap": true, 13 | "outDir": "./dist", 14 | "useUnknownInCatchVariables": false, 15 | }, 16 | "include": [ 17 | "./global.d.ts", 18 | "./src/**/*.ts", 19 | ], 20 | } 21 | -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.github/workflows/stale.yaml: -------------------------------------------------------------------------------- 1 | name: Close stale issues 2 | 3 | on: 4 | schedule: 5 | - cron: '30 1 * * *' 6 | 7 | permissions: 8 | issues: write 9 | 10 | jobs: 11 | stale: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/stale@v5 15 | with: 16 | stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days.' 17 | exempt-issue-labels: 'reviewed' 18 | close-issue-reason: 'not_planned' 19 | -------------------------------------------------------------------------------- /src/endpoint.ts: -------------------------------------------------------------------------------- 1 | import { EndpointClient } from './endpoint-client' 2 | 3 | 4 | export class Endpoint { 5 | constructor(protected client: EndpointClient) {} 6 | 7 | locationId(id?: string): string { 8 | const result = id || this.client.config.locationId 9 | if (result) { 10 | return result 11 | } 12 | throw Error('Location ID not defined') 13 | } 14 | 15 | installedAppId(id?: string): string { 16 | const result = id || this.client.config.installedAppId 17 | if (result) { 18 | return result 19 | } 20 | throw Error('Installed App ID not defined') 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report_template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Actual behavior** 20 | A clear and concise description of what actually happens. 21 | 22 | **Additional context** 23 | Add any other context about the problem here. 24 | -------------------------------------------------------------------------------- /test/unit/data/modes/post.ts: -------------------------------------------------------------------------------- 1 | export const post_locations_95efee9b_6073_4871_b5ba_de6642187293_modes = { 2 | 'request': { 3 | 'url': 'https://api.smartthings.com/locations/95efee9b-6073-4871-b5ba-de6642187293/modes', 4 | 'method': 'post', 5 | 'headers': { 6 | 'Content-Type': 'application/json;charset=utf-8', 7 | 'Accept': 'application/json', 8 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 9 | }, 10 | 'data': { 11 | 'label': 'Mode 4', 12 | }, 13 | }, 14 | 'response': { 15 | 'id': '7b7ca378-03ed-419d-93c1-76d3bb41c8b3', 16 | 'label': 'Mode 4', 17 | 'name': 'Mode 4', 18 | }, 19 | } 20 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Types of Changes 4 | 5 | 6 | 7 | - [ ] Bug fix (non-breaking change which fixes an issue) 8 | - [ ] New feature (non-breaking change which adds functionality) 9 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 10 | - [ ] I have read the **[CONTRIBUTING](/CONTRIBUTING.md)** document 11 | - [ ] My code follows the code style of this project (`npm run lint` produces no warnings/errors) 12 | - [ ] Any required documentation has been added 13 | - [ ] I have added tests to cover my changes 14 | -------------------------------------------------------------------------------- /test/unit/data/rooms/post.ts: -------------------------------------------------------------------------------- 1 | export const post_locations_95efee9b_6073_4871_b5ba_de6642187293_rooms = { 2 | 'request': { 3 | 'url': 'https://api.smartthings.com/locations/95efee9b-6073-4871-b5ba-de6642187293/rooms', 4 | 'method': 'post', 5 | 'headers': { 6 | 'Content-Type': 'application/json;charset=utf-8', 7 | 'Accept': 'application/json', 8 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 9 | }, 10 | 'data': { 11 | 'name': 'Test Room', 12 | }, 13 | }, 14 | 'response': { 15 | 'roomId': 'f32f1b48-58ab-441b-8240-10860cc52618', 16 | 'locationId': '95efee9b-6073-4871-b5ba-de6642187293', 17 | 'name': 'Test Room', 18 | 'backgroundImage': null, 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request_template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /test/unit/data/rooms/put.ts: -------------------------------------------------------------------------------- 1 | export const put_locations_95efee9b_6073_4871_b5ba_de6642187293_rooms_f32f1b48 = { 2 | 'request': { 3 | 'url': 'https://api.smartthings.com/locations/95efee9b-6073-4871-b5ba-de6642187293/rooms/f32f1b48-58ab-441b-8240-10860cc52618', 4 | 'method': 'put', 5 | 'headers': { 6 | 'Content-Type': 'application/json;charset=utf-8', 7 | 'Accept': 'application/json', 8 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 9 | }, 10 | 'data': { 11 | 'name': 'Test Room Renamed', 12 | }, 13 | }, 14 | 'response': { 15 | 'roomId': 'f32f1b48-58ab-441b-8240-10860cc52618', 16 | 'locationId': '95efee9b-6073-4871-b5ba-de6642187293', 17 | 'name': 'Test Room Renamed', 18 | 'backgroundImage': null, 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /test/unit/data/capabilities/post.ts: -------------------------------------------------------------------------------- 1 | export const post_capability = { 2 | name: 'Color Temperature', 3 | attributes: { 4 | colorTemperature: { 5 | schema: { 6 | type: 'object', 7 | properties: { 8 | value: { 9 | type: 'integer', 10 | minimum: 1, 11 | maximum: 30000, 12 | }, 13 | unit: { 14 | type: 'string', 15 | enum: ['K'], 16 | default: 'K', 17 | }, 18 | }, 19 | additionalProperties: false, 20 | }, 21 | required: 'value', 22 | }, 23 | }, 24 | commands: { 25 | setColorTemperature: { 26 | name: 'setColorTemperature', 27 | arguments: [{ 28 | name: 'temperature', 29 | schema: { 30 | type: 'integer', 31 | minimum: 1, 32 | maximum: 30000, 33 | }, 34 | }], 35 | }, 36 | }, 37 | } 38 | -------------------------------------------------------------------------------- /test/unit/data/schedules/get.ts: -------------------------------------------------------------------------------- 1 | export const get_daily_location = { 2 | 'request': { 3 | 'url': 'https://api.smartthings.com/locations/0bcbe542-d340-42a9-b00a-a2067170810e', 4 | 'method': 'get', 5 | 'headers': { 6 | 'Content-Type': 'application/json;charset=utf-8', 7 | 'Accept': 'application/json', 8 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 9 | }, 10 | }, 11 | 'response': { 12 | 'locationId': '0bcbe542-d340-42a9-b00a-a2067170810e', 13 | 'name': 'Test Location', 14 | 'countryCode': 'USA', 15 | 'latitude': 37.402418282078415, 16 | 'longitude': -122.04800345246598, 17 | 'regionRadius': 150, 18 | 'temperatureScale': 'F', 19 | 'timeZoneId': 'America/Los_Angeles', 20 | 'locale': 'en_US', 21 | 'backgroundImage': null, 22 | 'additionalProperties': {}, 23 | }, 24 | } 25 | -------------------------------------------------------------------------------- /test/unit/helpers/utils.ts: -------------------------------------------------------------------------------- 1 | import { AxiosRequestConfig } from 'axios' 2 | 3 | 4 | export function expectedRequest(config: AxiosRequestConfig): AxiosRequestConfig { 5 | return { 6 | data: undefined, 7 | params: undefined, 8 | ...config, 9 | paramsSerializer: expect.objectContaining({ serialize: expect.any(Function) }), 10 | } 11 | } 12 | 13 | export function buildRequest(path?: string, params?: unknown, data?: object, method = 'get'): AxiosRequestConfig { 14 | return { 15 | url: `https://api.smartthings.com/${path}`, 16 | method: method, 17 | headers: { 18 | 'Content-Type': 'application/json;charset=utf-8', 19 | 'Accept': 'application/json', 20 | }, 21 | data: data, 22 | params: params, 23 | paramsSerializer: expect.objectContaining({ serialize: expect.any(Function) }), 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /test/unit/data/deviceprofiles/delete.ts: -------------------------------------------------------------------------------- 1 | export const delete_deviceprofiles_149476cd_3ca9_4e62_ba40_a399e558b2bf = { 2 | request: { 3 | 'url': 'https://api.smartthings.com/deviceprofiles/149476cd-3ca9-4e62-ba40-a399e558b2bf', 4 | 'method': 'delete', 5 | 'headers': { 6 | 'Content-Type': 'application/json;charset=utf-8', 7 | 'Accept': 'application/json', 8 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 9 | }, 10 | }, 11 | response: {}, 12 | } 13 | 14 | export const delete_deviceprofiles_3acbf2fc_6be2_4be0_aeb5_c10f4ff357bb_i18n_fr = { 15 | request: { 16 | 'url': 'https://api.smartthings.com/deviceprofiles/3acbf2fc-6be2-4be0-aeb5-c10f4ff357bb/i18n/fr', 17 | 'method': 'delete', 18 | 'headers': { 19 | 'Content-Type': 'application/json;charset=utf-8', 20 | 'Accept': 'application/json', 21 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 22 | }, 23 | }, 24 | response: {}, 25 | } 26 | -------------------------------------------------------------------------------- /test/unit/data/subscriptions/delete.ts: -------------------------------------------------------------------------------- 1 | export const delete_installedapps_subscriptions_one = { 2 | 'request': { 3 | 'url': 'https://api.smartthings.com/installedapps/5336bd07-435f-4b6c-af1d-fddba55c1c24/subscriptions/eventHandler', 4 | 'method': 'delete', 5 | 'headers': { 6 | 'Content-Type': 'application/json;charset=utf-8', 7 | 'Accept': 'application/json', 8 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 9 | }, 10 | }, 11 | 'response': { 12 | 'count': 1, 13 | }, 14 | } 15 | 16 | export const delete_installedapps_subscriptions_all = { 17 | 'request': { 18 | 'url': 'https://api.smartthings.com/installedapps/5336bd07-435f-4b6c-af1d-fddba55c1c24/subscriptions', 19 | 'method': 'delete', 20 | 'headers': { 21 | 'Content-Type': 'application/json;charset=utf-8', 22 | 'Accept': 'application/json', 23 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 24 | }, 25 | }, 26 | 'response': { 27 | 'count': 3, 28 | }, 29 | } 30 | -------------------------------------------------------------------------------- /tsdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tags": 3 | { 4 | "allowUnknownTags" : true 5 | }, 6 | "plugins":[ 7 | "plugins/markdown" 8 | ,"/home/jody/.npm/_npx/1266/lib/node_modules/tsdoc/template/plugins/TSDoc.js" 9 | ], 10 | "opts": 11 | { 12 | "template" :"/home/jody/.npm/_npx/1266/lib/node_modules/tsdoc/template", 13 | "recurse" :"true" 14 | }, 15 | "templates" : { 16 | "cleverLinks" : false, 17 | "monospaceLinks" : false 18 | }, 19 | "source": 20 | { 21 | "includePattern": "(\\.d)?\\.ts$" 22 | }, 23 | "markdown" : { 24 | "parser" : "gfm", 25 | "hardwrap" : true 26 | }, 27 | "tsdoc":{ 28 | "source" :"/mnt/c/Users/jody/projects/rest-client-node/src/", 29 | "destination" :"/mnt/c/Users/jody/projects/rest-client-node/docs", 30 | "tutorials" :"", 31 | "systemName" : "smartthings-core", 32 | "footer" : "", 33 | "copyright" : "SmartThingsCore Copyright © 2019 Samsung Electronics Co., LTD.", 34 | "outputSourceFiles" : true, 35 | "commentsOnly": true 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test/unit/data/rooms/delete.ts: -------------------------------------------------------------------------------- 1 | export const delete_locations_95efee9b_6073_4871_b5ba_de6642187293_rooms = { 2 | 'request': { 3 | 'url': 'https://api.smartthings.com/locations/95efee9b-6073-4871-b5ba-de6642187293/rooms/f32f1b48-58ab-441b-8240-10860cc52618', 4 | 'method': 'delete', 5 | 'headers': { 6 | 'Content-Type': 'application/json;charset=utf-8', 7 | 'Accept': 'application/json', 8 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 9 | }, 10 | }, 11 | 'response': {}, 12 | } 13 | 14 | export const delete_locations_b4db3e54_14f3_4bf4_b217_b8583757d446_rooms = { 15 | 'request': { 16 | 'url': 'https://api.smartthings.com/locations/b4db3e54-14f3-4bf4-b217-b8583757d446/rooms/f32f1b48-58ab-441b-8240-10860cc52618', 17 | 'method': 'delete', 18 | 'headers': { 19 | 'Content-Type': 'application/json;charset=utf-8', 20 | 'Accept': 'application/json', 21 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 22 | }, 23 | }, 24 | 'response': {}, 25 | } 26 | -------------------------------------------------------------------------------- /test/unit/data/subscriptions/get.ts: -------------------------------------------------------------------------------- 1 | export const get_installedapps_subscriptions = { 2 | 'request': { 3 | 'url': 'https://api.smartthings.com/installedapps/5336bd07-435f-4b6c-af1d-fddba55c1c24/subscriptions', 4 | 'method': 'get', 5 | 'headers': { 6 | 'Content-Type': 'application/json;charset=utf-8', 7 | 'Accept': 'application/json', 8 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 9 | }, 10 | }, 11 | 'response': { 12 | 'items': [ 13 | { 14 | 'id': '3a06a46a-a4cd-4cf6-88f1-2657e499e43c', 15 | 'installedAppId': '5336bd07-435f-4b6c-af1d-fddba55c1c24', 16 | 'sourceType': 'DEVICE', 17 | 'device': { 18 | 'deviceId': '5d5a44a6-8859-4574-adc7-03a28171a76d', 19 | 'componentId': 'main', 20 | 'capability': 'switch', 21 | 'attribute': 'switch', 22 | 'value': '*', 23 | 'stateChangeOnly': true, 24 | 'subscriptionName': 'eventHandler_0', 25 | 'modes': [], 26 | }, 27 | }, 28 | ], 29 | '_links': {}, 30 | }, 31 | } 32 | -------------------------------------------------------------------------------- /test/unit/data/modes/delete.ts: -------------------------------------------------------------------------------- 1 | export const delete_locations_95efee9b_6073_4871_b5ba_de6642187293_modes = { 2 | 'request': { 3 | 'url': 'https://api.smartthings.com/locations/95efee9b-6073-4871-b5ba-de6642187293/modes/7b7ca378-03ed-419d-93c1-76d3bb41c8b3', 4 | 'method': 'delete', 5 | 'headers': { 6 | 'Content-Type': 'application/json;charset=utf-8', 7 | 'Accept': 'application/json', 8 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 9 | }, 10 | }, 11 | 'response': '', 12 | } 13 | 14 | export const delete_locations_b4db3e54_14f3_4bf4_b217_b8583757d446_modes = { 15 | 'request': { 16 | 'url': 'https://api.smartthings.com/locations/b4db3e54-14f3-4bf4-b217-b8583757d446/modes/7b7ca378-03ed-419d-93c1-76d3bb41c8b3', 17 | 'method': 'delete', 18 | 'headers': { 19 | 'Content-Type': 'application/json;charset=utf-8', 20 | 'Accept': 'application/json', 21 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 22 | }, 23 | }, 24 | 'response': '', 25 | } 26 | 27 | -------------------------------------------------------------------------------- /test/unit/data/capabilities/put.ts: -------------------------------------------------------------------------------- 1 | export const put_translations = { 2 | 'tag': 'fr', 3 | 'label': 'Output Modulation', 4 | 'attributes': { 5 | 'outputModulation': { 6 | 'label': 'La modulation de sortie', 7 | 'description': 'Power supply output modulation, i.e. AC frequency or DC', 8 | 'displayTemplate': '{{attribute}} de {{device.label}} est de {{value}}', 9 | 'i18n': { 10 | 'value': { 11 | '50hz': { 12 | 'label': '50 Hz', 13 | }, 14 | '60hz': { 15 | 'label': '60 Hz', 16 | }, 17 | '400hz': { 18 | 'label': '400 Hz', 19 | }, 20 | 'dc': { 21 | 'label': 'DC', 22 | }, 23 | }, 24 | }, 25 | }, 26 | }, 27 | 'commands': { 28 | 'setOutputModulation': { 29 | 'label': 'Set Output Modulation', 30 | 'description': 'Set the output modulation to the specified value', 31 | 'arguments': { 32 | 'outputModulation': { 33 | 'i18n': {}, 34 | 'label': 'Output Modulation', 35 | 'description': 'The desired output modulation', 36 | }, 37 | }, 38 | }, 39 | }, 40 | } 41 | -------------------------------------------------------------------------------- /test/unit/data/services/delete.ts: -------------------------------------------------------------------------------- 1 | export const delete_subscription = { 2 | 'request': { 3 | 'url': 'https://api.smartthings.com/services/coordinate/locations/95efee9b-6073-4871-b5ba-de6642187293/subscriptions/43357bf4-2687-4f9f-8ae6-5ba92c745cab', 4 | 'method': 'delete', 5 | 'headers': { 6 | 'Content-Type': 'application/json;charset=utf-8', 7 | 'Accept': 'application/json', 8 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 9 | }, 10 | 'params': { 11 | 'isaId': '881c4ddf-5399-4576-8ff6-df9f582e737a', 12 | }, 13 | }, 14 | 'response': '', 15 | } 16 | 17 | export const delete_subscriptions = { 18 | 'request': { 19 | 'url': 'https://api.smartthings.com/services/coordinate/locations/95efee9b-6073-4871-b5ba-de6642187293/subscriptions', 20 | 'method': 'delete', 21 | 'headers': { 22 | 'Content-Type': 'application/json;charset=utf-8', 23 | 'Accept': 'application/json', 24 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 25 | }, 26 | 'params': { 27 | 'isaId': '881c4ddf-5399-4576-8ff6-df9f582e737a', 28 | }, 29 | }, 30 | 'response': '', 31 | } 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | *.pub 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | 9 | # Intellij 10 | .idea 11 | *.iml 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | docs/ 41 | 42 | # Typescript v1 declaration files 43 | typings/ 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # compile dir 55 | dist 56 | 57 | # Output of 'npm pack' 58 | *.tgz 59 | 60 | # Yarn Integrity file 61 | .yarn-integrity 62 | 63 | # dotenv environment variables file 64 | .env 65 | # don't ignore the example 66 | !.env.example 67 | 68 | # next.js build output 69 | .next 70 | 71 | .DS_Store 72 | .*.swp 73 | -------------------------------------------------------------------------------- /test/unit/data/modes/put.ts: -------------------------------------------------------------------------------- 1 | export const put_locations_95efee9b_6073_4871_b5ba_de6642187293_modes = { 2 | 'request': { 3 | 'url': 'https://api.smartthings.com/locations/95efee9b-6073-4871-b5ba-de6642187293/modes/7b7ca378-03ed-419d-93c1-76d3bb41c8b3', 4 | 'method': 'put', 5 | 'headers': { 6 | 'Content-Type': 'application/json;charset=utf-8', 7 | 'Accept': 'application/json', 8 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 9 | }, 10 | 'data': { 11 | 'label': 'Mode Four', 12 | }, 13 | }, 14 | 'response': { 15 | 'id': '7b7ca378-03ed-419d-93c1-76d3bb41c8b3', 16 | 'label': 'Mode Four', 17 | 'name': 'Mode 4', 18 | }, 19 | } 20 | 21 | export const put_locations_95efee9b_6073_4871_b5ba_de6642187293_modes_current = { 22 | 'request': { 23 | 'url': 'https://api.smartthings.com/locations/95efee9b-6073-4871-b5ba-de6642187293/modes/current', 24 | 'method': 'put', 25 | 'headers': { 26 | 'Content-Type': 'application/json;charset=utf-8', 27 | 'Accept': 'application/json', 28 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 29 | }, 30 | 'data': { 31 | 'modeId': '7b7ca378-03ed-419d-93c1-76d3bb41c8b3', 32 | }, 33 | }, 34 | 'response': { 35 | 'id': '7b7ca378-03ed-419d-93c1-76d3bb41c8b3', 36 | 'label': 'Mode Four', 37 | 'name': 'Mode 4', 38 | }, 39 | } 40 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './authenticator' 2 | export * from './endpoint-client' 3 | export * from './endpoint' 4 | export * from './logger' 5 | export * from './signature' 6 | export * from './st-client' 7 | export * from './rest-client' 8 | export * from './types' 9 | export * from './endpoint' 10 | export * from './pagination' 11 | 12 | export * from './endpoint/apps' 13 | export * from './endpoint/capabilities' 14 | export * from './endpoint/channels' 15 | export * from './endpoint/devicepreferences' 16 | export * from './endpoint/deviceprofiles' 17 | export * from './endpoint/devices' 18 | export * from './endpoint/drivers' 19 | export * from './endpoint/history' 20 | export * from './endpoint/hubdevices' 21 | export * from './endpoint/installedapps' 22 | export * from './endpoint/invites-schemaApp' 23 | export * from './endpoint/locations' 24 | export * from './endpoint/modes' 25 | export * from './endpoint/notifications' 26 | export * from './endpoint/organizations' 27 | export * from './endpoint/presentation' 28 | export * from './endpoint/rooms' 29 | export * from './endpoint/rules' 30 | export * from './endpoint/scenes' 31 | export * from './endpoint/schedules' 32 | export * from './endpoint/schema' 33 | export * from './endpoint/services' 34 | export * from './endpoint/subscriptions' 35 | export * from './endpoint/virtualdevices' 36 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface Link { 2 | href: string 3 | } 4 | 5 | export interface Links { 6 | next?: Link 7 | previous?: Link 8 | } 9 | 10 | export interface Count { 11 | count: number 12 | } 13 | 14 | export enum OwnerType { 15 | USER = 'USER', 16 | SYSTEM = 'SYSTEM', 17 | IMPLICIT = 'IMPLICIT', 18 | } 19 | 20 | export interface Owner { 21 | /** 22 | * The account type which owns the specific domain item. 23 | */ 24 | ownerType: OwnerType 25 | /** 26 | * A global identifier for owner. 27 | */ 28 | ownerId: string 29 | } 30 | 31 | export enum PrincipalType { 32 | LOCATION = 'LOCATION', 33 | USER_LEVEL = 'USER_LEVEL', 34 | } 35 | 36 | export interface Status { 37 | status: string 38 | } 39 | 40 | export const SuccessStatusValue = { 41 | status: 'success', 42 | } 43 | 44 | export interface IconImage { 45 | url?: string 46 | } 47 | 48 | export interface LocaleReference { 49 | /** The tag of the locale as defined in [RFC bcp47](http://www.rfc-editor.org/rfc/bcp/bcp47.txt). */ 50 | tag: LocaleTag 51 | } 52 | 53 | /** 54 | * The tag of the locale as defined in [RFC bcp47](http://www.rfc-editor.org/rfc/bcp/bcp47.txt). 55 | * @example en 56 | */ 57 | export type LocaleTag = string 58 | 59 | /** 60 | * The default SDK response for APIs that return empty JSON bodies 61 | */ 62 | export type SuccessResponse = Promise 63 | -------------------------------------------------------------------------------- /src/endpoint/organizations.ts: -------------------------------------------------------------------------------- 1 | import { Endpoint } from '../endpoint' 2 | import { EndpointClient, EndpointClientConfig } from '../endpoint-client' 3 | 4 | 5 | export interface OrganizationUpdateRequest { 6 | label?: string 7 | warehouseGroupId?: string 8 | } 9 | 10 | export interface OrganizationCreateRequest extends OrganizationUpdateRequest { 11 | name: string 12 | manufacturerName?: string 13 | mnid?: string 14 | } 15 | 16 | export interface OrganizationResponse extends OrganizationCreateRequest { 17 | /** 18 | * A generated UUID for an organization. 19 | */ 20 | organizationId: string 21 | 22 | /** 23 | * The user group for organization developers. 24 | */ 25 | developerGroupId?: string 26 | 27 | /** 28 | * The user group for organization admins. 29 | */ 30 | adminGroupId?: string 31 | 32 | /** 33 | * Denotes whether this is the default user org for the caller. 34 | */ 35 | isDefaultUserOrg?: boolean 36 | } 37 | 38 | export class OrganizationsEndpoint extends Endpoint { 39 | constructor(config: EndpointClientConfig) { 40 | super(new EndpointClient('organizations', config)) 41 | } 42 | 43 | public list(): Promise{ 44 | return this.client.getPagedItems('') 45 | } 46 | 47 | public get(id: string): Promise{ 48 | return this.client.get(id) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /test/unit/data/subscriptions/post.ts: -------------------------------------------------------------------------------- 1 | import { SubscriptionSource } from '../../../../src' 2 | 3 | 4 | export const post_installedapps_subscriptions = { 5 | 'request': { 6 | 'url': 'https://api.smartthings.com/installedapps/5336bd07-435f-4b6c-af1d-fddba55c1c24/subscriptions', 7 | 'method': 'post', 8 | 'headers': { 9 | 'Content-Type': 'application/json;charset=utf-8', 10 | 'Accept': 'application/json', 11 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 12 | }, 13 | 'data': { 14 | 'sourceType': SubscriptionSource.DEVICE, 15 | 'device': { 16 | 'deviceId': '736e3903-001c-4d40-b408-ff40d162a06b', 17 | 'componentId': 'freezer', 18 | 'capability': 'temperatureMeasurement', 19 | 'attribute': 'temperature', 20 | 'stateChangeOnly': true, 21 | 'modes': [ 22 | 'e34b57fb-e73a-4228-8819-e99502d17890', 23 | 'cfa3a42e-5f52-452e-9515-c32bcbea48ce', 24 | ], 25 | }, 26 | }, 27 | }, 28 | 'response': { 29 | 'id': '5e1b134b-bd85-4125-9c25-4a8291e754aa', 30 | 'installedAppId': 'fb05c874-cf1d-406a-930c-69a081e0eaee', 31 | 'sourceType': 'DEVICE', 32 | 'device': { 33 | 'componentId': 'main', 34 | 'deviceId': 'e457978e-5e37-43e6-979d-18112e12c961,', 35 | 'capability': 'contactSensor,', 36 | 'attribute': 'contact,', 37 | 'stateChangeOnly': 'true,', 38 | 'subscriptionName': 'contact_subscription', 39 | 'value': '*', 40 | }, 41 | }, 42 | } 43 | -------------------------------------------------------------------------------- /src/rest-client.ts: -------------------------------------------------------------------------------- 1 | import { Authenticator } from './authenticator' 2 | import { EndpointClientConfig, HttpClientHeaders, SmartThingsURLProvider, 3 | defaultSmartThingsURLProvider, WarningFromHeader } from './endpoint-client' 4 | import { Logger } from './logger' 5 | 6 | 7 | export interface RESTClientConfig { 8 | logger?: Logger 9 | loggingId?: string 10 | version?: string 11 | headers?: HttpClientHeaders 12 | urlProvider?: SmartThingsURLProvider 13 | locationId?: string 14 | installedAppId?: string 15 | warningLogger?: (warnings: WarningFromHeader[] | string) => void | Promise 16 | } 17 | 18 | 19 | // TODO: 20 | // add site version specification and at other levels; need to support header and url versions 21 | // server specification 22 | export class RESTClient { 23 | protected static defaultHeaders: HttpClientHeaders = { 24 | 'Content-Type': 'application/json;charset=utf-8', 25 | Accept: 'application/json', 26 | } 27 | 28 | public config: EndpointClientConfig 29 | 30 | constructor(authenticator: Authenticator, config?: RESTClientConfig) { 31 | const defaultConfig = { 32 | authenticator, 33 | urlProvider: defaultSmartThingsURLProvider, 34 | useAuth: true, 35 | } 36 | const headers = (config && config.headers) 37 | ? { ...RESTClient.defaultHeaders, ...config.headers } 38 | : { ...RESTClient.defaultHeaders } 39 | this.config = { ...defaultConfig, ...config, headers } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test/unit/organizations.test.ts: -------------------------------------------------------------------------------- 1 | import axios from '../../__mocks__/axios' 2 | import { 3 | BearerTokenAuthenticator, 4 | SmartThingsClient, 5 | OrganizationResponse, 6 | } from '../../src' 7 | import { expectedRequest } from './helpers/utils' 8 | import { 9 | get_organizations as list, 10 | get_an_organization as get, 11 | } from './data/organizations/get' 12 | 13 | 14 | const authenticator = new BearerTokenAuthenticator('00000000-0000-0000-0000-000000000000') 15 | const client = new SmartThingsClient(authenticator) 16 | 17 | describe('Organizations', () => { 18 | afterEach(() => { 19 | axios.request.mockReset() 20 | }) 21 | 22 | it('list', async () => { 23 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: list.response })) 24 | const response: OrganizationResponse[] = await client.organizations.list() 25 | expect(axios.request).toHaveBeenCalledTimes(1) 26 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(list.request)) 27 | expect(response).toBe(list.response.items) 28 | }) 29 | 30 | it('explicit get', async () => { 31 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: get.response })) 32 | const response: OrganizationResponse = await client.organizations.get('00000000-0000-0000-0000-000000000000') 33 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(get.request)) 34 | expect(response).toBe(get.response) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /test/unit/endpoint.test.ts: -------------------------------------------------------------------------------- 1 | import { Endpoint, EndpointClient } from '../../src' 2 | 3 | 4 | describe('Endpoint', () => { 5 | const client = { config: { 6 | locationId: 'default-location-id', 7 | installedAppId: 'default-installed-app-id', 8 | } } as unknown as EndpointClient 9 | const endpoint = new Endpoint(client) 10 | 11 | describe('locationId', () => { 12 | it('returns result passed in when defined', () => { 13 | expect(endpoint.locationId('passed-in-location-id')).toEqual('passed-in-location-id') 14 | }) 15 | 16 | it('falls back on default location id', () => { 17 | expect(endpoint.locationId()).toEqual('default-location-id') 18 | }) 19 | 20 | it('throws exception when no location id available', () => { 21 | client.config.locationId = undefined 22 | expect(() => endpoint.locationId()).toThrow('Location ID not defined') 23 | }) 24 | }) 25 | 26 | describe('installedAppId', () => { 27 | it('returns result passed in when defined', () => { 28 | expect(endpoint.installedAppId('passed-in-installed-app-id')).toEqual('passed-in-installed-app-id') 29 | }) 30 | 31 | it('falls back on default installed app id', () => { 32 | expect(endpoint.installedAppId()).toEqual('default-installed-app-id') 33 | }) 34 | 35 | it('throws exception when no installed app id available', () => { 36 | client.config.installedAppId = undefined 37 | expect(() => endpoint.installedAppId()).toThrow('Installed App ID not defined') 38 | }) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /src/endpoint/invites-schemaApp.ts: -------------------------------------------------------------------------------- 1 | import { EndpointClient, EndpointClientConfig } from '../endpoint-client' 2 | import { Endpoint } from '../endpoint' 3 | 4 | 5 | export type SchemaAppId = { 6 | schemaAppId: string 7 | } 8 | 9 | export type SchemaAppInvitationId = { 10 | invitationId: string 11 | } 12 | 13 | export type SchemaAppInvitationSummary = { 14 | invitationId: string 15 | acceptUrl: string 16 | } 17 | 18 | export type SchemaAppInvitationCreate = SchemaAppId & { 19 | description?: string 20 | acceptLimit?: number 21 | } 22 | 23 | export type SchemaAppInvitation = SchemaAppId & { 24 | id: string 25 | description?: string 26 | expiration?: number 27 | acceptUrl?: string 28 | acceptances: number 29 | declineUrl?: string 30 | shortCode?: string 31 | } 32 | 33 | 34 | export class InvitesSchemaAppEndpoint extends Endpoint { 35 | constructor(config: EndpointClientConfig) { 36 | super(new EndpointClient('invites/schemaApp', config)) 37 | } 38 | 39 | public async create(schemaAppInvitation: SchemaAppInvitationCreate): Promise { 40 | return this.client.post('', schemaAppInvitation) 41 | } 42 | 43 | public async list(schemaAppId: string): Promise { 44 | try { 45 | return await this.client.getPagedItems('', { schemaAppId }) 46 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 47 | } catch(error: any) { 48 | if (error.response?.status === 403) { 49 | return [] 50 | } 51 | throw error 52 | } 53 | } 54 | 55 | public async revoke(invitationId: string): Promise { 56 | await this.client.delete(invitationId) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A generic logger interface that is compatible with log4js. Using this keeps 3 | * dependencies on an external logger out when users don't want to include one. 4 | */ 5 | export interface Logger { 6 | level: string 7 | 8 | /* eslint-disable @typescript-eslint/no-explicit-any */ 9 | trace(message: any, ...args: any[]): void 10 | debug(message: any, ...args: any[]): void 11 | info(message: any, ...args: any[]): void 12 | warn(message: any, ...args: any[]): void 13 | error(message: any, ...args: any[]): void 14 | fatal(message: any, ...args: any[]): void 15 | /* eslint-enable */ 16 | 17 | isTraceEnabled(): boolean 18 | isDebugEnabled(): boolean 19 | isInfoEnabled(): boolean 20 | isWarnEnabled(): boolean 21 | isErrorEnabled(): boolean 22 | isFatalEnabled(): boolean 23 | } 24 | 25 | 26 | /** 27 | * A simple implementation of the Logger interface that does not log anything. 28 | */ 29 | export class NoLogLogger implements Logger { 30 | public level = 'off' 31 | 32 | /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars, @typescript-eslint/explicit-module-boundary-types */ 33 | trace(message: any, ...args: any[]): void { 34 | // no-op 35 | } 36 | debug(message: any, ...args: any[]): void { 37 | // no-op 38 | } 39 | info(message: any, ...args: any[]): void { 40 | // no-op 41 | } 42 | warn(message: any, ...args: any[]): void { 43 | // no-op 44 | } 45 | error(message: any, ...args: any[]): void { 46 | // no-op 47 | } 48 | fatal(message: any, ...args: any[]): void { 49 | // no-op 50 | } 51 | /* eslint-enable */ 52 | 53 | isTraceEnabled(): boolean { 54 | return false 55 | } 56 | isDebugEnabled(): boolean { 57 | return false 58 | } 59 | isInfoEnabled(): boolean { 60 | return false 61 | } 62 | isWarnEnabled(): boolean { 63 | return false 64 | } 65 | isErrorEnabled(): boolean { 66 | return false 67 | } 68 | isFatalEnabled(): boolean { 69 | return false 70 | } 71 | } 72 | 73 | export const noLogLogger = new NoLogLogger() 74 | -------------------------------------------------------------------------------- /test/unit/st-client.test.ts: -------------------------------------------------------------------------------- 1 | import axios from '../../__mocks__/axios' 2 | 3 | import { BearerTokenAuthenticator } from '../../src/authenticator' 4 | import { SmartThingsClient } from '../../src/st-client' 5 | 6 | 7 | describe('SmartThingsClient', () => { 8 | afterEach(() => { 9 | axios.request.mockReset() 10 | }) 11 | 12 | test('construction with no location', async () => { 13 | const client = new SmartThingsClient( 14 | new BearerTokenAuthenticator('00000000-0000-0000-0000-000000000000')) 15 | expect(client.config.locationId).toBeUndefined() 16 | }) 17 | 18 | test('construction with location ID', async () => { 19 | const client = new SmartThingsClient( 20 | new BearerTokenAuthenticator('00000000-0000-0000-0000-000000000000'), 21 | { locationId: 'locationId' }) 22 | expect(client.config.locationId).toBe('locationId') 23 | }) 24 | 25 | test('construction with setLocation', async () => { 26 | const client = new SmartThingsClient( 27 | new BearerTokenAuthenticator('00000000-0000-0000-0000-000000000000')) 28 | client.setLocation('d52f4bd1-700b-4730-a05a-e1fbe999ee8d') 29 | expect(client.config.locationId).toBe('d52f4bd1-700b-4730-a05a-e1fbe999ee8d') 30 | }) 31 | 32 | it('returns cloned client with new and existing headers merged', async () => { 33 | const client = new SmartThingsClient( 34 | new BearerTokenAuthenticator('00000000-0000-0000-0000-000000000000'), 35 | { headers: { 'User-Agent': 'userAgent', 'X-Test': 'test' } }, 36 | ) 37 | 38 | const headers = { 'X-ST-Organization': 'organizationId' } 39 | const clone = client.clone(headers) 40 | 41 | expect(client.config.headers).toBeDefined() 42 | if (client.config.headers) { 43 | expect(client.config.headers['X-ST-Organization']).toBeUndefined() 44 | } 45 | 46 | expect(clone.config.headers).toBeDefined() 47 | if (clone.config.headers) { 48 | expect(clone.config.headers).toEqual(expect.objectContaining(client.config.headers)) 49 | expect(clone.config.headers['X-ST-Organization']).toBe('organizationId') 50 | } 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /test/unit/data/signature/post.ts: -------------------------------------------------------------------------------- 1 | export const post_verify = { 2 | method: 'POST', 3 | headers: { 4 | 'content-type': 'application/json', 5 | 'accept': 'application/json', 6 | 'authorization': 'Signature keyId="/pl/useast2/1b-0d-f2-69-ad-fb-1b-c4-4e-ac-5a-1f-f7-b6-dd-a9-c4-e8-c8-98",signature="W8og1Vqzq8eYBWwzPapqsWKkO45lUHzDBPdML1EZRhPf5B0l9wtFGxTxqJgIFLzF38bCNo38AHlzyZQYIfx4IPNXLqHV1K3v6pXQKNLg0abWOO4VL+hx1jJ+O3+Uu7GwKraWo3nPcTdU/9xih8PdOTwlau8SkVSKNTJVyycxgjzfqJyJXR63fZZMPLdO9NJEBSLaSI6eH4Jc7Z+s6g3ib9ay6GujC5KncZOKyeLJpAPfNoUX4v6bpJz8z5DxIrz2+QlmXW2AyXFIUDTp6DTblS3G0M6WSlI+QU3xCwC2Wb/3PxcsHlKpXvvJ2ET908JXaZfpuagPYJku5Xf+nM9OIg==",headers="(request-target) digest date",algorithm="rsa-sha256"', 7 | 'digest': 'SHA256=aLAlinhTVIeMWN5sMCEU2Jn2x27o8bOaKmuCT6YWW+w=', 8 | 'date': 'Tue, 24 Mar 2020 13:23:37 UTC', 9 | 'x-st-correlation': '386bce60-9484-4fdf-b746-5f7d2003799d', 10 | 'x-b3-traceid': '98b2c93f885ab5f3', 11 | 'x-b3-spanid': '50e6b186afb67d55', 12 | 'x-b3-parentspanid': '98b2c93f885ab5f3', 13 | 'x-b3-sampled': '0', 14 | 'content-length': '567', 15 | 'host': 'node-st.ngrok.io', 16 | 'user-agent': 'AHC/2.1', 17 | 'x-forwarded-proto': 'https', 18 | 'x-forwarded-for': '52.14.19.244', 19 | }, 20 | } 21 | 22 | export const post_verify_parsed = { 23 | 'scheme': 'Signature', 24 | 'params': { 25 | 'keyId': '/pl/useast2/1b-0d-f2-69-ad-fb-1b-c4-4e-ac-5a-1f-f7-b6-dd-a9-c4-e8-c8-98', 26 | 'signature': 'bqu6KxHNR5d+1nBVUAHW4K57kpFX8NBfCRWt3SOPt2yz+h2k+eYC81xPMriQtwR3votrDIZy0f1oZw8P0kOhlcujionvRk3xcYtRrR7FHVsgvzIqQMPQZ84F4BxaXnA/qWS+11fBFQHRICM3quYVDEgeVqTtqTEFHIF9WXTKVxYYcG7AI914urwiZCKa+3lWLrxsJW4AqBLXuIhFgkJ5TzYQaeW863gjJ5Vvs0RVOa3upI/Y0wF3c9L2AGsZtFfrMo8+souij3/9g4Xt9x/lbkxUwDpK5yRtbG63mV9PqIF5t4Ws4tW8sxHA9GQhBIYC2vkf+7EoQOrOFcfzHimLHA==', 27 | 'headers': [ 28 | '(request-target)', 29 | 'digest', 30 | 'date', 31 | ], 32 | 'algorithm': 'rsa-sha256', 33 | }, 34 | 'signingString': '(request-target): post /\ndigest: SHA256=hin4p0p7cm2NbwUCSTnRSMyE9QOt2gBd9WesU0Y7U/I=\ndate: Tue, 24 Mar 2020 13:11:22 UTC', 35 | 'algorithm': 'RSA-SHA256', 36 | 'keyId': '/pl/useast2/1b-0d-f2-69-ad-fb-1b-c4-4e-ac-5a-1f-f7-b6-dd-a9-c4-e8-c8-98', 37 | } 38 | -------------------------------------------------------------------------------- /test/unit/invites-schemaApp.test.ts: -------------------------------------------------------------------------------- 1 | import { NoOpAuthenticator } from '../../src/authenticator' 2 | import { EndpointClient } from '../../src/endpoint-client' 3 | import { InvitesSchemaAppEndpoint, SchemaAppInvitation } from '../../src/endpoint/invites-schemaApp' 4 | 5 | 6 | afterEach(() => { 7 | jest.clearAllMocks() 8 | }) 9 | 10 | const postSpy = jest.spyOn(EndpointClient.prototype, 'post').mockImplementation() 11 | const getPagedItemsSpy = jest.spyOn(EndpointClient.prototype, 'getPagedItems').mockImplementation() 12 | const deleteSpy = jest.spyOn(EndpointClient.prototype, 'delete') 13 | 14 | const authenticator = new NoOpAuthenticator() 15 | const invitesEndpoint = new InvitesSchemaAppEndpoint( { authenticator }) 16 | 17 | test('create', async () => { 18 | const invitationId = { invitationId: 'my-invitation-id' } 19 | const createData = { schemaAppId: 'schema-app-id' } 20 | 21 | postSpy.mockResolvedValueOnce(invitationId) 22 | 23 | expect(await invitesEndpoint.create(createData)).toBe(invitationId) 24 | 25 | expect(postSpy).toHaveBeenCalledTimes(1) 26 | expect(postSpy).toHaveBeenCalledWith('', createData) 27 | }) 28 | 29 | test('list', async () => { 30 | const invitations = [{ id: 'my-invitation-id' } as SchemaAppInvitation] 31 | 32 | getPagedItemsSpy.mockResolvedValueOnce(invitations) 33 | 34 | expect(await invitesEndpoint.list('schema-app-id')).toBe(invitations) 35 | 36 | expect(getPagedItemsSpy).toHaveBeenCalledTimes(1) 37 | expect(getPagedItemsSpy).toHaveBeenCalledWith('', { schemaAppId: 'schema-app-id' }) 38 | }) 39 | 40 | test('list with 403 error', async () => { 41 | getPagedItemsSpy.mockImplementationOnce(() => { 42 | throw { response: { status: 403 } } 43 | }) 44 | 45 | expect(await invitesEndpoint.list('schema-app-id')).toStrictEqual([]) 46 | 47 | expect(getPagedItemsSpy).toHaveBeenCalledTimes(1) 48 | expect(getPagedItemsSpy).toHaveBeenCalledWith('', { schemaAppId: 'schema-app-id' }) 49 | }) 50 | 51 | test('revoke', async () => { 52 | await expect(invitesEndpoint.revoke('schema-app-id')).resolves.not.toThrow() 53 | 54 | expect(deleteSpy).toHaveBeenCalledTimes(1) 55 | expect(deleteSpy).toHaveBeenCalledWith('schema-app-id') 56 | }) 57 | -------------------------------------------------------------------------------- /src/pagination.ts: -------------------------------------------------------------------------------- 1 | import { EndpointClient } from './endpoint-client' 2 | import { Links } from './types' 3 | 4 | 5 | export interface PagedResult { 6 | items: T[] 7 | _links?: Links 8 | } 9 | 10 | export class PaginatedListIterator implements AsyncIterator { 11 | private index: number 12 | 13 | constructor(private client: EndpointClient, private page: PagedResult) { 14 | this.index = 0 15 | } 16 | 17 | async next(): Promise> { 18 | if (this.index < this.page.items.length) { 19 | let done = false 20 | const value = this.page.items[this.index++] 21 | if (this.index === this.page.items.length) { 22 | if (this.page?._links?.next?.href) { 23 | this.index = 0 24 | this.page = await this.client.get>(this.page._links.next.href) 25 | } else { 26 | done = true 27 | } 28 | } 29 | return { done, value } 30 | } 31 | return { done: true, value: undefined } 32 | } 33 | } 34 | 35 | export class PaginatedList { 36 | public items: Array 37 | 38 | constructor(private page: PagedResult, private client: EndpointClient ) { 39 | this.items = page.items 40 | } 41 | 42 | public [Symbol.asyncIterator](): PaginatedListIterator { 43 | return new PaginatedListIterator(this.client, this.page) 44 | } 45 | 46 | public hasNext(): boolean { 47 | return !!this.page._links?.next 48 | } 49 | 50 | public hasPrevious(): boolean { 51 | return !!this.page._links?.previous 52 | } 53 | 54 | public next(): Promise { 55 | if (this.page._links?.next?.href) { 56 | return this.client.get>(this.page._links.next.href).then(response => { 57 | this.items = response.items 58 | this.page = response 59 | return !!response._links?.next 60 | }) 61 | } 62 | return Promise.reject(new Error('No next results')) 63 | } 64 | 65 | public previous(): Promise { 66 | if (this.page._links?.previous) { 67 | return this.client.get>(this.page._links.previous.href).then(response => { 68 | this.items = response.items 69 | this.page = response 70 | return !!response._links?.previous 71 | }) 72 | } 73 | return Promise.reject(new Error('No previous results')) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /.github/workflows/ci-cd.yaml: -------------------------------------------------------------------------------- 1 | name: CI/CD 2 | 3 | on: 4 | push: 5 | pull_request: 6 | workflow_dispatch: 7 | branches: 8 | - main 9 | 10 | jobs: 11 | lint-commits: 12 | if: github.event_name == 'pull_request' 13 | 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | with: 19 | fetch-depth: 0 20 | - uses: actions/setup-node@v3 21 | with: 22 | node-version: 22 23 | cache: "npm" 24 | - run: npm ci --ignore-scripts 25 | - run: npx commitlint --from HEAD~${{ github.event.pull_request.commits }} --to HEAD 26 | 27 | build: 28 | runs-on: ubuntu-latest 29 | 30 | strategy: 31 | matrix: 32 | # add/remove versions as we move support forward 33 | node-version: [18, 20, 22] 34 | 35 | steps: 36 | - uses: actions/checkout@v3 37 | - name: Use Node.js ${{ matrix.node-version }} 38 | uses: actions/setup-node@v3 39 | with: 40 | node-version: ${{ matrix.node-version }} 41 | cache: "npm" 42 | - run: npm ci 43 | - run: npm run build 44 | - run: npm run lint 45 | - run: npm test 46 | 47 | release: 48 | needs: build 49 | 50 | # don't run on forks 51 | if: ${{ github.repository_owner == 'SmartThingsCommunity' && github.ref == 'refs/heads/main' }} 52 | 53 | runs-on: ubuntu-latest 54 | 55 | steps: 56 | - name: Checkout Repo 57 | uses: actions/checkout@v3 58 | 59 | - name: Setup Node.js 60 | uses: actions/setup-node@v3 61 | with: 62 | node-version: 22 63 | 64 | - name: Install Dependencies 65 | run: npm ci 66 | 67 | - name: Create Release Pull Request or Publish to npm 68 | id: changesets 69 | uses: changesets/action@v1 70 | with: 71 | version: npm run version 72 | publish: npm run release 73 | commit: "chore(changesets): version packages" 74 | title: "chore(changesets): version packages" 75 | createGithubReleases: true 76 | env: 77 | # We can't use the built-in GITHUB_TOKEN for releases, at least for now. 78 | # https://github.com/github-community/community/discussions/13836 79 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 80 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 81 | -------------------------------------------------------------------------------- /test/unit/data/organizations/get.ts: -------------------------------------------------------------------------------- 1 | export const get_organizations = { 2 | request: { 3 | 'url': 'https://api.smartthings.com/organizations', 4 | 'method': 'get', 5 | 'headers': { 6 | 'Content-Type': 'application/json;charset=utf-8', 7 | 'Accept': 'application/json', 8 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 9 | }, 10 | }, 11 | response: { 12 | items: [ 13 | { 14 | 'label': 'default user organization', 15 | 'warehouseGroupId': '00000000-0000-0000-0000-000000000000', 16 | 'name': 'orgnamespace1', 17 | 'developerGroupId': '00000000-0000-0000-0000-000000000000', 18 | 'adminGroupId': '00000000-0000-0000-0000-000000000000', 19 | 'organizationId': '00000000-0000-0000-0000-000000000000', 20 | 'isDefaultUserOrg': true, 21 | }, 22 | { 23 | 'label': 'Organization Two', 24 | 'warehouseGroupId': '00000000-0000-0000-0000-000000000000', 25 | 'name': 'orgnamespace2', 26 | 'developerGroupId': '00000000-0000-0000-0000-000000000000', 27 | 'adminGroupId': '00000000-0000-0000-0000-000000000000', 28 | 'organizationId': '00000000-0000-0000-0000-000000000002', 29 | 'isDefaultUserOrg': false, 30 | }, 31 | { 32 | 'label': 'Organization Three', 33 | 'warehouseGroupId': '00000000-0000-0000-0000-000000000000', 34 | 'name': 'orgnamespace3', 35 | 'developerGroupId': '00000000-0000-0000-0000-000000000000', 36 | 'adminGroupId': '00000000-0000-0000-0000-000000000000', 37 | 'organizationId': '00000000-0000-0000-0000-000000000003', 38 | 'isDefaultUserOrg': false, 39 | }, 40 | ], 41 | '_links': null, 42 | }, 43 | } 44 | 45 | export const get_an_organization = { 46 | request: { 47 | 'url': 'https://api.smartthings.com/organizations/00000000-0000-0000-0000-000000000000', 48 | 'method': 'get', 49 | 'headers': { 50 | 'Content-Type': 'application/json;charset=utf-8', 51 | 'Accept': 'application/json', 52 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 53 | }, 54 | }, 55 | response: { 56 | 'label': 'default user organization', 57 | 'warehouseGroupId': '00000000-0000-0000-0000-000000000000', 58 | 'name': 'orgnamespace1', 59 | 'developerGroupId': '00000000-0000-0000-0000-000000000000', 60 | 'adminGroupId': '00000000-0000-0000-0000-000000000000', 61 | 'organizationId': '00000000-0000-0000-0000-000000000000', 62 | 'isDefaultUserOrg': true, 63 | }, 64 | } 65 | -------------------------------------------------------------------------------- /test/unit/data/services/post.ts: -------------------------------------------------------------------------------- 1 | export const post_subscription = { 2 | 'request': { 3 | 'url': 'https://api.smartthings.com/services/coordinate/locations/95efee9b-6073-4871-b5ba-de6642187293/subscriptions', 4 | 'method': 'post', 5 | 'headers': { 6 | 'Content-Type': 'application/json;charset=utf-8', 7 | 'Accept': 'application/json', 8 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 9 | }, 10 | 'data': { 11 | 'type': 'DIRECT', 12 | 'isaId': '881c4ddf-5399-4576-8ff6-df9f582e737a', 13 | 'capabilities': [ 14 | 'weather', 15 | ], 16 | 'predicate': 'weather.temperature.value <= 12', 17 | }, 18 | }, 19 | 'response': { 20 | 'locationId': '95efee9b-6073-4871-b5ba-de6642187293', 21 | 'subscriptionId': '55fca73f-6d70-447c-ba4e-47bcf98e7f7f', 22 | }, 23 | } 24 | 25 | export const post_subscription_explicit = { 26 | 'request': { 27 | 'url': 'https://api.smartthings.com/services/coordinate/locations/95efee9b-6073-4871-b5ba-de6642187293/subscriptions', 28 | 'method': 'post', 29 | 'headers': { 30 | 'Content-Type': 'application/json;charset=utf-8', 31 | 'Accept': 'application/json', 32 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 33 | }, 34 | 'data': { 35 | 'type': 'DIRECT', 36 | 'isaId': '43357bf4-2687-4f9f-8ae6-5ba92c745cab', 37 | 'capabilities': [ 38 | 'weather', 39 | ], 40 | 'predicate': 'weather.temperature.value <= 12', 41 | }, 42 | }, 43 | 'response': { 44 | 'locationId': '95efee9b-6073-4871-b5ba-de6642187293', 45 | 'subscriptionId': '55fca73f-6d70-447c-ba4e-47bcf98e7f7f', 46 | }, 47 | } 48 | 49 | export const post_subscription_execution = { 50 | 'request': { 51 | 'url': 'https://api.smartthings.com/services/coordinate/locations/95efee9b-6073-4871-b5ba-de6642187293/subscriptions', 52 | 'method': 'post', 53 | 'headers': { 54 | 'Content-Type': 'application/json;charset=utf-8', 55 | 'Accept': 'application/json', 56 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 57 | }, 58 | 'data': { 59 | 'type': 'EXECUTION', 60 | 'isaId': '881c4ddf-5399-4576-8ff6-df9f582e737a', 61 | 'capabilities': [ 62 | 'weather', 63 | ], 64 | 'predicate': 'weather.temperature.value <= 12', 65 | }, 66 | }, 67 | 'response': { 68 | 'locationId': '95efee9b-6073-4871-b5ba-de6642187293', 69 | 'subscriptionId': '55fca73f-6d70-447c-ba4e-47bcf98e7f7f', 70 | }, 71 | } 72 | -------------------------------------------------------------------------------- /test/unit/scenes.test.ts: -------------------------------------------------------------------------------- 1 | import axios from '../../__mocks__/axios' 2 | import { 3 | BearerTokenAuthenticator, 4 | SmartThingsClient, 5 | SceneSummary, 6 | Status, 7 | SuccessStatusValue, 8 | } from '../../src' 9 | import { expectedRequest } from './helpers/utils' 10 | import { 11 | get_scenes as listAll, 12 | get_scenes_locationId_95efee9b_6073_4871_b5ba_de6642187293 as listForLocation, 13 | get_scenes_13a63ffo as get, 14 | } from './data/scenes/get' 15 | import { 16 | post_scenes_13a63ff0_587e_45d2_8c4e_b40525f2093c_execute as execute, 17 | } from './data/scenes/post' 18 | 19 | 20 | const client = new SmartThingsClient( 21 | new BearerTokenAuthenticator('00000000-0000-0000-0000-000000000000'), 22 | { locationId: '95efee9b-6073-4871-b5ba-de6642187293' }) 23 | 24 | const nonIsaClient = new SmartThingsClient( 25 | new BearerTokenAuthenticator('00000000-0000-0000-0000-000000000000')) 26 | 27 | describe('Scenes', () => { 28 | afterEach(() => { 29 | axios.request.mockReset() 30 | }) 31 | 32 | it('List all', async () => { 33 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: listAll.response })) 34 | const response: SceneSummary[] = await nonIsaClient.scenes.list() 35 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(listAll.request)) 36 | expect(response).toBe(listAll.response.items) 37 | }) 38 | 39 | it('List for location', async () => { 40 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: listForLocation.response })) 41 | const response: SceneSummary[] = await client.scenes.list() 42 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(listForLocation.request)) 43 | expect(response).toBe(listForLocation.response.items) 44 | }) 45 | 46 | it('get', async () => { 47 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: get.response })) 48 | const response: SceneSummary = await client.scenes.get('13a63ff0-587e-45d2-8c4e-b40525f2093c') 49 | expect(axios.request).toHaveBeenCalledTimes(1) 50 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(get.request)) 51 | expect(response.sceneName).toEqual('Good Morning') 52 | }) 53 | 54 | it('Execute', async () => { 55 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: execute.response })) 56 | const response: Status = await client.scenes.execute('13a63ff0-587e-45d2-8c4e-b40525f2093c') 57 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(execute.request)) 58 | expect(response).toEqual(SuccessStatusValue) 59 | }) 60 | 61 | }) 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@smartthings/core-sdk", 3 | "version": "8.4.1", 4 | "description": "JavaScript/TypeScript library for using SmartThings APIs", 5 | "author": "Samsung Electronics Co., LTD.", 6 | "homepage": "https://github.com/SmartThingsCommunity/smartthings-core-sdk", 7 | "bugs": "https://github.com/SmartThingsCommunity/smartthings-core-sdk/issues", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/SmartThingsCommunity/smartthings-core-sdk.git" 11 | }, 12 | "main": "dist/index.js", 13 | "types": "dist/index.d.ts", 14 | "license": "Apache-2.0", 15 | "engines": { 16 | "node": ">=22" 17 | }, 18 | "files": [ 19 | "dist/**/*" 20 | ], 21 | "dependencies": { 22 | "async-mutex": "^0.5.0", 23 | "axios": "^1.8.3", 24 | "http-signature": "^1.4.0", 25 | "lodash.isdate": "^4.0.1", 26 | "lodash.isstring": "^4.0.1", 27 | "qs": "^6.14.0", 28 | "sshpk": "^1.18.0" 29 | }, 30 | "devDependencies": { 31 | "@changesets/changelog-github": "^0.5.1", 32 | "@changesets/cli": "^2.28.1", 33 | "@commitlint/cli": "^19.8.0", 34 | "@commitlint/config-conventional": "^19.8.0", 35 | "@eslint/js": "^9.22.0", 36 | "@stylistic/eslint-plugin": "^4.2.0", 37 | "@types/jest": "^29.5.14", 38 | "@types/lodash.isdate": "^4.0.9", 39 | "@types/lodash.isstring": "^4.0.9", 40 | "@types/node": "^18.19.80", 41 | "@types/qs": "^6.9.18", 42 | "@types/sshpk": "^1.17.4", 43 | "@typescript-eslint/eslint-plugin": "^8.26.1", 44 | "@typescript-eslint/parser": "^8.26.1", 45 | "conventional-changelog-conventionalcommits": "^8.0.0", 46 | "cz-conventional-changelog": "^3.3.0", 47 | "eslint": "^9.22.0", 48 | "eslint-plugin-import": "^2.31.0", 49 | "eslint-plugin-jest": "^28.11.0", 50 | "jest": "^29.7.0", 51 | "jiti": "^2.4.2", 52 | "prettier": "^3.5.3", 53 | "ts-jest": "^29.2.6", 54 | "ts-node": "^10.9.2", 55 | "typedoc": "^0.27.9", 56 | "typescript": "^5.8.2", 57 | "typescript-eslint": "^8.26.1" 58 | }, 59 | "scripts": { 60 | "format": "eslint --ext .ts src test --fix", 61 | "lint": "eslint --ext .ts src test", 62 | "test": "jest", 63 | "test-coverage": "jest --coverage", 64 | "test-watch": "jest --watch", 65 | "clean": "rm -rf dist && rm -f tsconfig.tsbuildinfo", 66 | "compile": "tsc -b", 67 | "watch": "tsc -b -w", 68 | "build": "npm run clean && npm run compile", 69 | "docs-gen": "typedoc --out docs src/index.ts", 70 | "json-docs-gen": "typedoc --json dist/docs.json src/index.ts", 71 | "version": "changeset version && npm i --package-lock-only && npm run build", 72 | "release": "npm run build && changeset publish" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /test/unit/drivers.test.ts: -------------------------------------------------------------------------------- 1 | import { NoOpAuthenticator } from '../../src/authenticator' 2 | import { DriversEndpoint, EdgeDriver, EdgeDriverSummary } from '../../src/endpoint/drivers' 3 | import { EndpointClient } from '../../src/endpoint-client' 4 | 5 | 6 | describe('DriversEndpoint', () => { 7 | afterEach(() => { 8 | jest.clearAllMocks() 9 | }) 10 | 11 | const getSpy = jest.spyOn(EndpointClient.prototype, 'get').mockImplementation() 12 | const deleteSpy = jest.spyOn(EndpointClient.prototype, 'delete').mockImplementation() 13 | const getPagedItemsSpy = jest.spyOn(EndpointClient.prototype, 'getPagedItems').mockImplementation() 14 | const requestSpy = jest.spyOn(EndpointClient.prototype, 'request').mockImplementation() 15 | 16 | const authenticator = new NoOpAuthenticator() 17 | const driversEndpoint = new DriversEndpoint({ authenticator }) 18 | 19 | test('get', async () => { 20 | const driver = { driverId: 'driver-id' } 21 | getSpy.mockResolvedValueOnce(driver) 22 | 23 | expect(await driversEndpoint.get('requested-driver-id')).toBe(driver) 24 | 25 | expect(getSpy).toHaveBeenCalledWith('requested-driver-id') 26 | }) 27 | 28 | test('getRevision', async () => { 29 | const driver = { driverId: 'driver-id' } 30 | getSpy.mockResolvedValueOnce(driver) 31 | 32 | expect(await driversEndpoint.getRevision('requested-driver-id', 'requested-version')) 33 | 34 | expect(getSpy).toHaveBeenCalledWith('requested-driver-id/versions/requested-version') 35 | }) 36 | 37 | test('delete', async () => { 38 | await expect(driversEndpoint.delete('id-to-delete')).resolves.not.toThrow() 39 | 40 | expect(deleteSpy).toHaveBeenCalledTimes(1) 41 | expect(deleteSpy).toHaveBeenCalledWith('id-to-delete') 42 | }) 43 | 44 | test('list', async () => { 45 | const drivers = [{ driverId: 'listed-in-channel-id' }] as EdgeDriverSummary[] 46 | getPagedItemsSpy.mockResolvedValueOnce(drivers) 47 | 48 | expect(await driversEndpoint.list()).toBe(drivers) 49 | 50 | expect(getPagedItemsSpy).toHaveBeenCalledWith('') 51 | }) 52 | 53 | test('listDefault', async () => { 54 | const drivers = [{ driverId: 'listed-in-channel-id' }] as EdgeDriver[] 55 | getPagedItemsSpy.mockResolvedValueOnce(drivers) 56 | 57 | expect(await driversEndpoint.listDefault()).toBe(drivers) 58 | 59 | expect(getPagedItemsSpy).toHaveBeenCalledWith('default') 60 | }) 61 | 62 | test('upload', async () => { 63 | const driver = { driverId: 'driver-id' } 64 | requestSpy.mockResolvedValueOnce(driver) 65 | const archiveData = new Uint8Array(7).fill(13) 66 | 67 | expect(await driversEndpoint.upload(archiveData)).toBe(driver) 68 | 69 | expect(requestSpy).toHaveBeenCalledWith('post', 'package', archiveData, undefined, 70 | { headerOverrides: { 'Content-Type': 'application/zip' } }) 71 | }) 72 | }) 73 | -------------------------------------------------------------------------------- /test/unit/data/modes/get.ts: -------------------------------------------------------------------------------- 1 | export const get_locations_95efee9b_6073_4871_b5ba_de6642187293_modes = { 2 | 'request': { 3 | 'url': 'https://api.smartthings.com/locations/95efee9b-6073-4871-b5ba-de6642187293/modes', 4 | 'method': 'get', 5 | 'headers': { 6 | 'Content-Type': 'application/json;charset=utf-8', 7 | 'Accept': 'application/json', 8 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 9 | }, 10 | }, 11 | 'response': { 12 | 'items': [ 13 | { 14 | 'id': '801c7097-800c-46dd-b17d-1145cd1922c2', 15 | 'label': 'Night', 16 | 'name': 'Night', 17 | }, 18 | { 19 | 'id': 'ab7d4dc0-c0de-4276-a5dc-6b3a230f1bc7', 20 | 'label': 'Home', 21 | 'name': 'Home', 22 | }, 23 | { 24 | 'id': 'a2114683-3145-4acd-a4ce-e988cf362918', 25 | 'label': 'Away', 26 | 'name': 'Away', 27 | }, 28 | ], 29 | }, 30 | } 31 | 32 | export const get_locations_b4db3e54_14f3_4bf4_b217_b8583757d446_modes = { 33 | 'request': { 34 | 'url': 'https://api.smartthings.com/locations/b4db3e54-14f3-4bf4-b217-b8583757d446/modes', 35 | 'method': 'get', 36 | 'headers': { 37 | 'Content-Type': 'application/json;charset=utf-8', 38 | 'Accept': 'application/json', 39 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 40 | }, 41 | }, 42 | 'response': { 43 | 'items': [ 44 | { 45 | 'id': 'c6b6ff59-9112-4709-87cd-fba20afc2df7', 46 | 'label': 'Night', 47 | 'name': 'Night', 48 | }, 49 | { 50 | 'id': 'c0a015c4-2b42-44b9-9968-f789aadfca1c', 51 | 'label': 'Away', 52 | 'name': 'Away', 53 | }, 54 | { 55 | 'id': 'da0813bd-5d85-48e0-9ef0-c214af8b14aa', 56 | 'label': 'Home', 57 | 'name': 'Home', 58 | }, 59 | ], 60 | }, 61 | } 62 | 63 | export const get_locations_95efee9b_6073_4871_b5ba_de6642187293_modes_current = { 64 | 'request': { 65 | 'url': 'https://api.smartthings.com/locations/95efee9b-6073-4871-b5ba-de6642187293/modes/current', 66 | 'method': 'get', 67 | 'headers': { 68 | 'Content-Type': 'application/json;charset=utf-8', 69 | 'Accept': 'application/json', 70 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 71 | }, 72 | }, 73 | 'response': { 74 | 'id': '801c7097-800c-46dd-b17d-1145cd1922c2', 75 | 'label': 'Night', 76 | 'name': 'Night', 77 | }, 78 | } 79 | 80 | export const get_locations_b4db3e54_14f3_4bf4_b217_b8583757d446_modes_current = { 81 | 'request': { 82 | 'url': 'https://api.smartthings.com/locations/b4db3e54-14f3-4bf4-b217-b8583757d446/modes/current', 83 | 'method': 'get', 84 | 'headers': { 85 | 'Content-Type': 'application/json;charset=utf-8', 86 | 'Accept': 'application/json', 87 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 88 | }, 89 | }, 90 | 'response': { 91 | 'id': 'a2114683-3145-4acd-a4ce-e988cf362918', 92 | 'label': 'Away', 93 | 'name': 'Away', 94 | }, 95 | } 96 | -------------------------------------------------------------------------------- /src/endpoint/notifications.ts: -------------------------------------------------------------------------------- 1 | import { Endpoint } from '../endpoint' 2 | import { EndpointClient, EndpointClientConfig } from '../endpoint-client' 3 | 4 | 5 | export enum NotificationRequestType { 6 | ALERT = 'ALERT', 7 | SUGGESTED_ACTION = 'SUGGESTED_ACTION', 8 | EVENT_LOGGING = 'EVENT_LOGGING', 9 | AUTOMATION_INFO = 'AUTOMATION_INFO', 10 | } 11 | 12 | export interface NotificationMessage { 13 | title?: string 14 | body?: string 15 | } 16 | 17 | export interface NotificationItem { 18 | [localeCode: string]: NotificationMessage 19 | } 20 | 21 | export interface ReplacementsObject { 22 | key?: string 23 | value?: string 24 | } 25 | 26 | export enum DeepLinkType { 27 | device = 'device', 28 | installedApp = 'installedApp', 29 | location = 'location' 30 | } 31 | 32 | export interface DeepLink { 33 | type: DeepLinkType 34 | id: string 35 | } 36 | 37 | export interface NotificationRequest { 38 | /** 39 | * The target location of sending push message. 40 | */ 41 | locationId?: string 42 | 43 | /** 44 | * The notification indicator type. The type determines the type of alerts 45 | * the user sees on the device. 46 | */ 47 | type: NotificationRequestType 48 | 49 | /** 50 | * The title and content that you want to display with the push message. 51 | * Individual supported language sets may be added in the form of ISO 52 | * standard {language code}_{country code} and are shown to a user 53 | * according to the settings of the mobile device. If you add a default 54 | * set here, you can set the default language when it does not match with 55 | * the actual setting. 56 | */ 57 | messages: NotificationItem[] 58 | 59 | /** 60 | * Supports the ability to replace the custom variable in a title or body. 61 | * The format of 'key' must be of the form ${...}. If you want to show the 62 | * location nickname that the platform has, put it as the form of 63 | * ${System.locationNickname} in the title or body. 64 | */ 65 | replacements?: ReplacementsObject[] 66 | 67 | /** 68 | * Supports the ability to launch the specific plugin on your SmartThings app. 69 | */ 70 | deepLink?: DeepLink 71 | 72 | /** 73 | * Notification image url. 74 | */ 75 | imageUrl?: string 76 | } 77 | 78 | export interface NotificationResponse { 79 | code: number 80 | message: string 81 | } 82 | 83 | export class NotificationsEndpoint extends Endpoint { 84 | constructor(config: EndpointClientConfig) { 85 | super(new EndpointClient('notification', config)) 86 | } 87 | 88 | /** 89 | * Sends a push notification to mobile apps belonging to the location 90 | * @param data the notification request. If the client has been configured with a location ID it can be omitted 91 | * from this request. 92 | */ 93 | public async create(data: NotificationRequest): Promise { 94 | data.locationId = this.locationId(data.locationId) 95 | return this.client.post(undefined, data) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/endpoint/virtualdevices.ts: -------------------------------------------------------------------------------- 1 | import { Endpoint } from '../endpoint' 2 | import { EndpointClient, EndpointClientConfig, HttpClientParams } from '../endpoint-client' 3 | import { CommandMappings, Device, DeviceEvent } from './devices' 4 | import { DeviceProfileCreateRequest } from './deviceprofiles' 5 | 6 | 7 | export interface VirtualDeviceOwner { 8 | ownerType: VirtualDeviceOwnerTypeEnum 9 | ownerId: string 10 | } 11 | 12 | export type VirtualDeviceOwnerTypeEnum = 'USER' | 'LOCATION' 13 | 14 | export type ExecutionTarget = 'CLOUD' | 'LOCAL' 15 | 16 | export interface VirtualDeviceCreateRequest { 17 | name: string 18 | owner: VirtualDeviceOwner 19 | deviceProfileId?: string 20 | deviceProfile?: DeviceProfileCreateRequest 21 | roomId?: string 22 | commandMappings?: CommandMappings 23 | executionTarget?: ExecutionTarget 24 | hubId?: string 25 | driverId?: string 26 | channelId?: string 27 | } 28 | 29 | export interface VirtualDeviceStandardCreateRequest { 30 | name: string 31 | owner: VirtualDeviceOwner 32 | prototype: string 33 | roomId?: string 34 | executionTarget?: ExecutionTarget 35 | hubId?: string 36 | driverId?: string 37 | channelId?: string 38 | } 39 | 40 | export interface VirtualDeviceListOptions { 41 | locationId?: string 42 | } 43 | 44 | export interface VirtualDeviceEventsResponse { 45 | stateChanges: boolean[] 46 | } 47 | 48 | export class VirtualDevicesEndpoint extends Endpoint { 49 | constructor(config: EndpointClientConfig) { 50 | super(new EndpointClient('virtualdevices', config)) 51 | } 52 | 53 | /** 54 | * Returns list of virtual devices. 55 | * @param options map of filter options. Currently only 'locationId' is supported. 56 | */ 57 | public list(options: VirtualDeviceListOptions = {}): Promise { 58 | const params: HttpClientParams = {} 59 | if ('locationId' in options && options.locationId) { 60 | params.locationId = options.locationId 61 | } else if (this.client.config.locationId) { 62 | params.locationId = this.client.config.locationId 63 | } 64 | return this.client.getPagedItems(undefined, params) 65 | } 66 | 67 | /** 68 | * Create a virtual device from a device profile. An existing device profile can be designated by ID, or the 69 | * definition of a device profile can be provided inline. 70 | */ 71 | public create(definition: VirtualDeviceCreateRequest): Promise { 72 | return this.client.post('', definition) 73 | } 74 | 75 | /** 76 | * Creates a virtual device from a standard prototype. 77 | */ 78 | public createStandard(definition: VirtualDeviceStandardCreateRequest): Promise { 79 | return this.client.post('prototypes', definition) 80 | } 81 | 82 | /** 83 | * Creates events for the specified device 84 | * @param id UUID of the device 85 | * @param deviceEvents list of events 86 | */ 87 | public createEvents(id: string, deviceEvents: DeviceEvent[]): Promise { 88 | return this.client.post(`${id}/events`, { deviceEvents }) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/signature.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import sshpk from 'sshpk' 3 | import httpSignature from 'http-signature' 4 | import { SmartThingsURLProvider, defaultSmartThingsURLProvider } from './endpoint-client' 5 | import { Logger } from './logger' 6 | 7 | 8 | export interface KeyCache { 9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 10 | get(keyId: string): any 11 | set(keyId: string, keyValue: string, cacheTTL: number): void 12 | } 13 | 14 | export interface KeyResolverConfig { 15 | urlProvider?: SmartThingsURLProvider 16 | keyCache?: KeyCache 17 | keyCacheTTL?: number 18 | } 19 | 20 | export class HttpKeyResolver { 21 | private keyApiUrl: string 22 | private keyCache?: KeyCache 23 | private keyCacheTTL: number 24 | 25 | constructor(config?: KeyResolverConfig ) { 26 | this.keyApiUrl = defaultSmartThingsURLProvider.keyApiURL 27 | this.keyCache = undefined 28 | this.keyCacheTTL = (24 * 60 * 60 * 1000) 29 | if (config) { 30 | if (config.urlProvider) { 31 | this.keyApiUrl = config.urlProvider.keyApiURL 32 | } 33 | if (config.keyCacheTTL) { 34 | this.keyCacheTTL = config.keyCacheTTL || (24 * 60 * 60 * 1000) 35 | } 36 | this.keyCache = config.keyCache 37 | } 38 | } 39 | 40 | /** 41 | * Get Public Key for specified Key ID. 42 | * 43 | * @param {String} keyId The Key ID as specified on Authorization header. 44 | * @returns {Promise.} Promise of Public key or null if no key available. 45 | */ 46 | public async getKey(keyId: string): Promise { 47 | const cache = this.keyCache 48 | if (!keyId) { 49 | return null 50 | } 51 | 52 | let publicKey = cache ? cache.get(keyId) : undefined 53 | 54 | if (publicKey) { 55 | return publicKey 56 | } 57 | 58 | const response = await axios.get(`${this.keyApiUrl}${keyId}`) 59 | const cert = sshpk.parseCertificate(response.data, 'pem') 60 | if (cert && cert.subjectKey) { 61 | publicKey = cert.subjectKey 62 | } 63 | 64 | if (publicKey) { 65 | if (cache) { 66 | cache.set(keyId, publicKey, this.keyCacheTTL) 67 | } 68 | return publicKey 69 | } 70 | 71 | return null 72 | } 73 | } 74 | 75 | export interface SignedHttpRequest { 76 | method: string 77 | headers: { [key: string]: string } 78 | } 79 | 80 | export class SignatureVerifier { 81 | constructor(private keyResolver: HttpKeyResolver, private logger?: Logger) { 82 | } 83 | 84 | async isAuthorized(request: SignedHttpRequest): Promise { 85 | try { 86 | const keyResolver = this.keyResolver 87 | const parsed = httpSignature.parseRequest(request, undefined) 88 | const publicKey = await keyResolver.getKey(parsed.keyId) 89 | 90 | return httpSignature.verifySignature(parsed, publicKey) 91 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 92 | } catch (error: any) { 93 | if (this.logger) { 94 | this.logger.error(error.message | error) 95 | } 96 | } 97 | return false 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /test/unit/data/capabilities/get.ts: -------------------------------------------------------------------------------- 1 | import { CustomCapabilityStatus, CapabilitySchemaPropertyName } from '../../../../src' 2 | 3 | 4 | export const get_capability = { 5 | id: 'namespace.colorTemperature', 6 | version: 1, 7 | status: CustomCapabilityStatus.PROPOSED, 8 | name: 'Color Temperature', 9 | attributes: { 10 | colorTemperature: { 11 | schema: { 12 | type: 'object', 13 | properties: { 14 | value: { 15 | type: 'integer', 16 | minimum: 1, 17 | maximum: 30000, 18 | }, 19 | unit: { 20 | type: 'string', 21 | enum: ['K'], 22 | default: 'K', 23 | }, 24 | }, 25 | additionalProperties: false, 26 | required: [CapabilitySchemaPropertyName.VALUE], 27 | }, 28 | }, 29 | }, 30 | commands: { 31 | setColorTemperature: { 32 | name: 'setColorTemperature', 33 | arguments: [ 34 | { 35 | name: 'temperature', 36 | schema: { 37 | type: 'integer', 38 | minimum: 1, 39 | maximum: 30000, 40 | }, 41 | }, 42 | ], 43 | }, 44 | }, 45 | } 46 | 47 | export const get_locales = { 48 | 'items': [ 49 | { 50 | 'tag': 'en', 51 | }, 52 | { 53 | 'tag': 'it', 54 | }, 55 | { 56 | 'tag': 'fr', 57 | }, 58 | { 59 | 'tag': 'es', 60 | }, 61 | ], 62 | } 63 | 64 | export const list_namespaces = [ 65 | { 66 | 'name': 'testnamespace12345', 67 | 'ownerType': 'user', 68 | 'ownerId': '0000-0000-0000-0000', 69 | }, 70 | ] 71 | 72 | export const list_capabilities = { 73 | items: [ 74 | { 75 | id: 'switch', 76 | version: 1, 77 | }, 78 | ], 79 | '_links': {}, 80 | } 81 | 82 | export const list_capabilities_1 = { 83 | items: [ 84 | { 85 | id: 'switch', 86 | version: 1, 87 | }, 88 | ], 89 | '_links': { 90 | 'next': { 91 | 'href': 'https://api.smartthings.com/capabilities/namespaces/testNamespace?page=1&max=200', 92 | }, 93 | }, 94 | } 95 | 96 | export const list_capabilities_2 = { 97 | items: [ 98 | { 99 | id: 'switch', 100 | version: 2, 101 | }, 102 | ], 103 | '_links': { 104 | 'previous': { 105 | 'href': 'https://api.smartthings.com/capabilities/namespace/testNamespace?page=0&max=200', 106 | }, 107 | }, 108 | } 109 | 110 | export const list_capabilities_3 = { 111 | items: [ 112 | { 113 | id: 'switch', 114 | version: 1, 115 | }, 116 | { 117 | id: 'switch', 118 | version: 2, 119 | }, 120 | ], 121 | '_links': { 122 | 'previous': { 123 | 'href': 'https://api.smartthings.com/capabilities?page=0&max=200', 124 | }, 125 | }, 126 | } 127 | 128 | export const list_standard = { 129 | 'items': [ 130 | { 131 | 'id': 'demandResponseLoadControl', 132 | 'version': 1, 133 | 'status': 'proposed', 134 | }, 135 | { 136 | 'id': 'airQualitySensor', 137 | 'version': 1, 138 | 'status': 'live', 139 | }, 140 | { 141 | 'id': 'thermostatFanMode', 142 | 'version': 1, 143 | 'status': 'live', 144 | }, 145 | ], 146 | '_links': {}, 147 | } 148 | -------------------------------------------------------------------------------- /src/endpoint/scenes.ts: -------------------------------------------------------------------------------- 1 | import { Endpoint } from '../endpoint' 2 | import { EndpointClient, EndpointClientConfig, HttpClientParams } from '../endpoint-client' 3 | import { Status } from '../types' 4 | 5 | 6 | export interface SceneSummary { 7 | /** 8 | * The unique identifier of the Scene 9 | */ 10 | sceneId?: string 11 | /** 12 | * The user-defined name of the Scene 13 | */ 14 | sceneName?: string 15 | /** 16 | * The name of the icon 17 | */ 18 | sceneIcon?: string 19 | /** 20 | * The color of the icon 21 | */ 22 | sceneColor?: string 23 | /** 24 | * Location of the Scene 25 | */ 26 | locationId?: string 27 | /** 28 | * The unique identifier of the user that created the scene 29 | */ 30 | createdBy?: string 31 | /** 32 | * The date the scene was created 33 | */ 34 | createdDate?: Date 35 | /** 36 | * The date the scene was last updated 37 | */ 38 | lastUpdatedDate?: Date 39 | /** 40 | * The date the scene was last executed 41 | */ 42 | lastExecutedDate?: Date 43 | /** 44 | * Whether or not this scene can be edited by the logged in user using the version of the app that made the request 45 | */ 46 | editable?: boolean 47 | apiVersion?: string 48 | } 49 | 50 | export interface SceneListOptions { 51 | locationId?: string[] 52 | max?: number 53 | page?: number 54 | } 55 | 56 | export class ScenesEndpoint extends Endpoint { 57 | constructor(config: EndpointClientConfig) { 58 | super(new EndpointClient('scenes', config)) 59 | } 60 | 61 | /** 62 | * Returns a list of scenes filterd by the specified options. If a location ID is included in the options or the 63 | * client has been configured with a location ID, then only the scenes in that location are returned. If there is 64 | * no locationId configured or specified, then the scenes in all locations accessible to the principal are returned. 65 | * @param options optional filtering options accepting the location Id 66 | */ 67 | public list(options: SceneListOptions = {}): Promise { 68 | const params: HttpClientParams = {} 69 | if ('locationId' in options && options.locationId) { 70 | params.locationId = options.locationId 71 | } else if (this.client.config.locationId) { 72 | params.locationId = this.client.config.locationId 73 | } 74 | return this.client.getPagedItems(undefined, params) 75 | } 76 | 77 | /** 78 | * Get a specific scene 79 | * @param id UUID of the scene 80 | */ 81 | public async get(id: string): Promise { 82 | const list: SceneSummary[] = await this.client.getPagedItems() 83 | if (list) { 84 | const item = list.find(it => it.sceneId === id) 85 | if (item) { 86 | return item 87 | } 88 | } 89 | throw Error(`Scene ${id} not found`) 90 | } 91 | 92 | /** 93 | * Execute the actions specified in a scene 94 | * @param id 95 | */ 96 | public execute(id: string): Promise { 97 | return this.client.post(`${id}/execute`) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /test/unit/data/signature/models.ts: -------------------------------------------------------------------------------- 1 | export const keyId = '/pl/useast2/1b-0d-f2-69-ad-fb-1b-c4-4e-ac-5a-1f-f7-b6-dd-a9-c4-e8-c8-98' 2 | 3 | export const publicKey = { 4 | 'type': 'rsa', 5 | 'parts': [ 6 | { 7 | 'name': 'e', 8 | 'data': { 9 | 'type': 'Buffer', 10 | 'data': [1, 0, 1], 11 | }, 12 | }, 13 | { 14 | 'name': 'n', 15 | 'data': { 16 | 'type': 'Buffer', 17 | 'data': [0, 187, 116, 17, 71, 220, 49, 113, 129, 234, 191, 129, 185, 149, 101, 130, 223, 158, 58, 127, 18, 201, 196, 101, 107, 68, 129, 199, 189, 158, 225, 202, 197, 193, 227, 41, 66, 132, 7, 0, 47, 25, 103, 224, 64, 48, 161, 92, 2, 15, 12, 153, 161, 62, 180, 53, 83, 97, 249, 5, 255, 17, 50, 152, 174, 125, 211, 85, 151, 180, 183, 181, 99, 141, 188, 50, 168, 211, 212, 107, 254, 225, 251, 113, 235, 200, 0, 205, 236, 208, 145, 29, 163, 200, 236, 25, 193, 228, 58, 78, 87, 19, 140, 254, 169, 183, 13, 42, 85, 2, 168, 94, 187, 232, 169, 200, 69, 189, 153, 37, 253, 239, 0, 163, 234, 92, 203, 161, 150, 6, 131, 173, 186, 50, 137, 112, 251, 170, 136, 251, 90, 26, 89, 128, 222, 136, 126, 72, 199, 71, 101, 115, 36, 26, 233, 232, 71, 142, 181, 197, 113, 118, 192, 2, 151, 232, 233, 176, 119, 122, 2, 180, 80, 213, 166, 238, 44, 92, 199, 120, 13, 14, 81, 164, 88, 15, 131, 204, 38, 146, 70, 174, 15, 194, 1, 173, 205, 103, 100, 13, 161, 223, 23, 139, 156, 64, 179, 255, 100, 145, 124, 185, 152, 35, 101, 168, 31, 193, 185, 173, 45, 92, 113, 68, 188, 218, 186, 108, 114, 124, 208, 138, 67, 85, 240, 148, 84, 42, 227, 204, 195, 128, 138, 19, 171, 142, 141, 250, 148, 224, 243, 88, 51, 31, 179, 165, 109], 18 | }, 19 | }, 20 | ], 21 | 'part': { 22 | 'e': { 23 | 'name': 'e', 24 | 'data': { 25 | 'type': 'Buffer', 26 | 'data': [1, 0, 1], 27 | }, 28 | }, 29 | 'n': { 30 | 'name': 'n', 31 | 'data': { 32 | 'type': 'Buffer', 33 | 'data': [0, 187, 116, 17, 71, 220, 49, 113, 129, 234, 191, 129, 185, 149, 101, 130, 223, 158, 58, 127, 18, 201, 196, 101, 107, 68, 129, 199, 189, 158, 225, 202, 197, 193, 227, 41, 66, 132, 7, 0, 47, 25, 103, 224, 64, 48, 161, 92, 2, 15, 12, 153, 161, 62, 180, 53, 83, 97, 249, 5, 255, 17, 50, 152, 174, 125, 211, 85, 151, 180, 183, 181, 99, 141, 188, 50, 168, 211, 212, 107, 254, 225, 251, 113, 235, 200, 0, 205, 236, 208, 145, 29, 163, 200, 236, 25, 193, 228, 58, 78, 87, 19, 140, 254, 169, 183, 13, 42, 85, 2, 168, 94, 187, 232, 169, 200, 69, 189, 153, 37, 253, 239, 0, 163, 234, 92, 203, 161, 150, 6, 131, 173, 186, 50, 137, 112, 251, 170, 136, 251, 90, 26, 89, 128, 222, 136, 126, 72, 199, 71, 101, 115, 36, 26, 233, 232, 71, 142, 181, 197, 113, 118, 192, 2, 151, 232, 233, 176, 119, 122, 2, 180, 80, 213, 166, 238, 44, 92, 199, 120, 13, 14, 81, 164, 88, 15, 131, 204, 38, 146, 70, 174, 15, 194, 1, 173, 205, 103, 100, 13, 161, 223, 23, 139, 156, 64, 179, 255, 100, 145, 124, 185, 152, 35, 101, 168, 31, 193, 185, 173, 45, 92, 113, 68, 188, 218, 186, 108, 114, 124, 208, 138, 67, 85, 240, 148, 84, 42, 227, 204, 195, 128, 138, 19, 171, 142, 141, 250, 148, 224, 243, 88, 51, 31, 179, 165, 109], 34 | }, 35 | }, 36 | }, 37 | '_hashCache': {}, 38 | 'size': 2048, 39 | } 40 | -------------------------------------------------------------------------------- /src/endpoint/rooms.ts: -------------------------------------------------------------------------------- 1 | import { Endpoint } from '../endpoint' 2 | import { EndpointClient, EndpointClientConfig } from '../endpoint-client' 3 | import { Status, SuccessStatusValue } from '../types' 4 | import { Device } from './devices' 5 | 6 | 7 | export class RoomRequest { 8 | /** 9 | * A name given for the room (eg. Living Room) 10 | */ 11 | name?: string 12 | } 13 | 14 | export class Room extends RoomRequest { 15 | /** 16 | * The ID of the parent location. 17 | */ 18 | locationId?: string 19 | 20 | /** 21 | * The ID of the room. 22 | */ 23 | roomId?: string 24 | } 25 | 26 | export class RoomsEndpoint extends Endpoint { 27 | constructor(config: EndpointClientConfig) { 28 | super(new EndpointClient('locations', config)) 29 | } 30 | 31 | /** 32 | * List the rooms in a location 33 | * @param locationId UUID of the location 34 | */ 35 | public list(locationId?: string): Promise{ 36 | return this.client.getPagedItems(`${this.locationId(locationId)}/rooms`) 37 | } 38 | 39 | /** 40 | * Get a specific room in a location 41 | * @param id UUID of the room 42 | * @param locationId UUID of the location. If the client is configured with a location ID this parameter 43 | * can be omitted 44 | */ 45 | public get(id: string, locationId?: string): Promise{ 46 | return this.client.get(`${this.locationId(locationId)}/rooms/${id}`) 47 | } 48 | 49 | /** 50 | * Create a room in a location 51 | * @param data request containing the room name 52 | * @param locationId UUID of the location. If the client is configured with a location ID this parameter 53 | * can be omitted 54 | */ 55 | public create(data: RoomRequest, locationId?: string): Promise { 56 | return this.client.post(`${this.locationId(locationId)}/rooms`, data) 57 | } 58 | 59 | /** 60 | * Update a room 61 | * @param id UUID of the room 62 | * @param data request containing the name of the room 63 | * @param locationId UUID of the location. If the client is configured with a location ID this parameter 64 | * can be omitted 65 | */ 66 | public update(id: string, data: RoomRequest, locationId?: string): Promise { 67 | return this.client.put(`${this.locationId(locationId)}/rooms/${id}`, data) 68 | } 69 | 70 | /** 71 | * Delete a room from a location 72 | * @param id UUID of the room 73 | * @param locationId UUID of the location. If the client is configured with a location ID this parameter 74 | * can be omitted 75 | */ 76 | public async delete(id: string, locationId?: string): Promise { 77 | await this.client.delete(`${this.locationId(locationId)}/rooms/${id}`) 78 | return SuccessStatusValue 79 | } 80 | 81 | /** 82 | * Returns a list of all the devices in a room 83 | * @param id UUID of the room 84 | * @param locationId UUID of the location. If the client is configured with a location ID this parameter 85 | * can be omitted 86 | */ 87 | public listDevices(id: string, locationId?: string): Promise { 88 | return this.client.getPagedItems(`${this.locationId(locationId)}/rooms/${id}/devices`) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /test/unit/subscriptions.test.ts: -------------------------------------------------------------------------------- 1 | import axios from '../../__mocks__/axios' 2 | import { 3 | BearerTokenAuthenticator, 4 | Count, 5 | SmartThingsClient, 6 | Subscription, 7 | } from '../../src' 8 | import { expectedRequest } from './helpers/utils' 9 | import { 10 | get_installedapps_subscriptions as list, 11 | } from './data/subscriptions/get' 12 | import { 13 | post_installedapps_subscriptions as create, 14 | } from './data/subscriptions/post' 15 | import { 16 | delete_installedapps_subscriptions_one as deleteOne, 17 | delete_installedapps_subscriptions_all as deleteAll, 18 | } from './data/subscriptions/delete' 19 | 20 | 21 | const client = new SmartThingsClient( 22 | new BearerTokenAuthenticator('00000000-0000-0000-0000-000000000000'), 23 | { locationId: '95efee9b-6073-4871-b5ba-de6642187293', installedAppId: '5336bd07-435f-4b6c-af1d-fddba55c1c24' }) 24 | 25 | describe('Subscriptions', () => { 26 | afterEach(() => { 27 | axios.request.mockReset() 28 | }) 29 | 30 | it('List all', async () => { 31 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: list.response })) 32 | const response: Subscription[] = await client.subscriptions.list() 33 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(list.request)) 34 | expect(response).toBe(list.response.items) 35 | }) 36 | 37 | it('Delete one', async () => { 38 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: deleteOne.response })) 39 | const response: Count = await client.subscriptions.delete('eventHandler') 40 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(deleteOne.request)) 41 | expect(response.count).toEqual(1) 42 | }) 43 | 44 | it('Delete all', async () => { 45 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: deleteAll.response })) 46 | const response: Count = await client.subscriptions.delete() 47 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(deleteAll.request)) 48 | expect(response.count).toEqual(3) 49 | }) 50 | 51 | it('Unsubscribe', async () => { 52 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: deleteOne.response })) 53 | const response: Count = await client.subscriptions.unsubscribe('eventHandler') 54 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(deleteOne.request)) 55 | expect(response.count).toEqual(1) 56 | }) 57 | 58 | it('Unsubscribe all', async () => { 59 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: deleteAll.response })) 60 | const response: Count = await client.subscriptions.unsubscribeAll() 61 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(deleteAll.request)) 62 | expect(response.count).toEqual(3) 63 | }) 64 | 65 | it('Create', async () => { 66 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: create.response })) 67 | const response: Subscription = await client.subscriptions.create(create.request.data) 68 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(create.request)) 69 | expect(response).toBe(create.response) 70 | }) 71 | }) 72 | -------------------------------------------------------------------------------- /test/unit/data/deviceprofiles/post.ts: -------------------------------------------------------------------------------- 1 | export const post_deviceprofiles = { 2 | request: { 3 | 'url': 'https://api.smartthings.com/deviceprofiles', 4 | 'method': 'post', 5 | 'headers': { 6 | 'Content-Type': 'application/json;charset=utf-8', 7 | 'Accept': 'application/json', 8 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 9 | }, 10 | 'data': { 11 | 'name': 'Functional Test Switch', 12 | 'components': [ 13 | { 14 | 'id': 'main', 15 | 'capabilities': [ 16 | { 17 | 'id': 'switch', 18 | 'version': 1, 19 | }, 20 | ], 21 | 'categories': [], 22 | }, 23 | ], 24 | 'metadata': { 25 | 'vid': 'simple-switch', 26 | 'deviceType': 'Switch', 27 | 'ocfDeviceType': 'oic.d.switch', 28 | 'mnmn': 'fIIT', 29 | 'deviceTypeId': 'Switch', 30 | 'ocfSpecVer': 'core 1.1.0', 31 | 'mnid': 'fIIT', 32 | 'mnId': 'fIIT', 33 | }, 34 | }, 35 | }, 36 | response: { 37 | 'id': '149476cd-3ca9-4e62-ba40-a399e558b2bf', 38 | 'name': 'Functional Test Switch', 39 | 'owner': { 40 | 'ownerType': 'USER', 41 | 'ownerId': 'c257d2c7-332b-d60d-808d-550bfbd54556', 42 | }, 43 | 'components': [ 44 | { 45 | 'label': 'main', 46 | 'id': 'main', 47 | 'capabilities': [ 48 | { 49 | 'id': 'switch', 50 | 'version': 1, 51 | }, 52 | ], 53 | 'categories': [], 54 | }, 55 | ], 56 | 'metadata': { 57 | 'vid': 'simple-switch', 58 | 'deviceType': 'Switch', 59 | 'ocfDeviceType': 'oic.d.switch', 60 | 'mnmn': 'fIIT', 61 | 'deviceTypeId': 'Switch', 62 | 'ocfSpecVer': 'core 1.1.0', 63 | 'mnid': 'fIIT', 64 | 'mnId': 'fIIT', 65 | }, 66 | 'status': 'DEVELOPMENT', 67 | }, 68 | } 69 | 70 | export const post_deviceprofiles_149476cd_3ca9_4e62_ba40_a399e558b2bf_status = { 71 | request: { 72 | 'url': 'https://api.smartthings.com/deviceprofiles/149476cd-3ca9-4e62-ba40-a399e558b2bf/status', 73 | 'method': 'post', 74 | 'headers': { 75 | 'Content-Type': 'application/json;charset=utf-8', 76 | 'Accept': 'application/json', 77 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 78 | }, 79 | 'data': { 80 | 'deviceProfileStatus': 'PUBLISHED', 81 | }, 82 | }, 83 | response: { 84 | 'id': '149476cd-3ca9-4e62-ba40-a399e558b2bf', 85 | 'name': 'Functional Test Switch', 86 | 'owner': { 87 | 'ownerType': 'USER', 88 | 'ownerId': 'c257d2c7-332b-d60d-808d-550bfbd54556', 89 | }, 90 | 'components': [ 91 | { 92 | 'label': 'main', 93 | 'id': 'main', 94 | 'capabilities': [ 95 | { 96 | 'id': 'switch', 97 | 'version': 1, 98 | }, 99 | { 100 | 'id': 'switchLevel', 101 | 'version': 1, 102 | }, 103 | ], 104 | 'categories': [], 105 | }, 106 | ], 107 | 'metadata': { 108 | 'vid': 'simple-dimmer', 109 | 'deviceType': 'Light', 110 | 'ocfDeviceType': 'oic.d.light', 111 | 'mnmn': 'fIIT', 112 | 'deviceTypeId': 'Light', 113 | 'ocfSpecVer': 'core 1.1.0', 114 | 'mnid': 'fIIT', 115 | 'mnId': 'fIIT', 116 | }, 117 | 'status': 'PUBLISHED', 118 | }, 119 | } 120 | -------------------------------------------------------------------------------- /test/unit/locations.test.ts: -------------------------------------------------------------------------------- 1 | import { NoOpAuthenticator } from '../../src/authenticator' 2 | import { EndpointClient } from '../../src/endpoint-client' 3 | import { LocationItem, Location, LocationsEndpoint, LocationCreate, LocationUpdate } from '../../src/endpoint/locations' 4 | import { SuccessStatusValue } from '../../src/types' 5 | 6 | 7 | const MOCK_LOCATION_LIST = [{ name: 'locationItem' }] as LocationItem[] 8 | const MOCK_LOCATION = { name: 'location' } as Location 9 | const MOCK_LOCATION_CREATE = { name: 'locationCreate' } as LocationCreate 10 | const MOCK_LOCATION_UPDATE = { name: 'locationUpdate' } as LocationUpdate 11 | 12 | describe('LocationsEndpoint', () => { 13 | const authenticator = new NoOpAuthenticator() 14 | const locationId = 'locationId' 15 | const locations = new LocationsEndpoint({ authenticator, locationId }) 16 | 17 | const getSpy = jest.spyOn(EndpointClient.prototype, 'get') 18 | const getPagedItemsSpy = jest.spyOn(EndpointClient.prototype, 'getPagedItems') 19 | const postSpy = jest.spyOn(EndpointClient.prototype, 'post') 20 | const putSpy = jest.spyOn(EndpointClient.prototype, 'put') 21 | const deleteSpy = jest.spyOn(EndpointClient.prototype, 'delete') 22 | 23 | afterEach(() => { 24 | jest.clearAllMocks() 25 | }) 26 | 27 | test('list', async () => { 28 | getPagedItemsSpy.mockResolvedValueOnce(MOCK_LOCATION_LIST) 29 | 30 | const response = await locations.list() 31 | 32 | expect(response).toStrictEqual(MOCK_LOCATION_LIST) 33 | }) 34 | 35 | test('explicit get', async () => { 36 | getSpy.mockResolvedValueOnce(MOCK_LOCATION) 37 | 38 | const response = await locations.get('explicitId') 39 | 40 | expect(getSpy).toBeCalledWith('explicitId', expect.objectContaining({ allowed: 'false' })) 41 | expect(response).toStrictEqual(MOCK_LOCATION) 42 | }) 43 | 44 | test('implicit get', async () => { 45 | getSpy.mockResolvedValueOnce(MOCK_LOCATION) 46 | 47 | const response = await locations.get() 48 | 49 | expect(getSpy).toBeCalledWith(locationId, expect.objectContaining({ allowed: 'false' })) 50 | expect(response).toStrictEqual(MOCK_LOCATION) 51 | }) 52 | 53 | it('accepts "allowed" query parameter', async () => { 54 | getSpy.mockResolvedValueOnce(MOCK_LOCATION) 55 | 56 | const response = await locations.get(locationId, { allowed: true }) 57 | 58 | expect(getSpy).toBeCalledWith(locationId, expect.objectContaining({ allowed: 'true' })) 59 | expect(response).toStrictEqual(MOCK_LOCATION) 60 | }) 61 | 62 | test('create', async () => { 63 | postSpy.mockResolvedValueOnce(MOCK_LOCATION) 64 | 65 | const response = await locations.create(MOCK_LOCATION_CREATE) 66 | 67 | expect(postSpy).toBeCalledWith(undefined, MOCK_LOCATION_CREATE) 68 | expect(response).toStrictEqual(MOCK_LOCATION) 69 | }) 70 | 71 | test('update', async () => { 72 | putSpy.mockResolvedValueOnce(MOCK_LOCATION) 73 | 74 | const response = await locations.update(locationId, MOCK_LOCATION_UPDATE) 75 | 76 | expect(putSpy).toBeCalledWith(locationId, MOCK_LOCATION_UPDATE) 77 | expect(response).toStrictEqual(MOCK_LOCATION) 78 | }) 79 | 80 | test('delete', async () => { 81 | deleteSpy.mockResolvedValueOnce({}) 82 | 83 | const response = await locations.delete(locationId) 84 | 85 | expect(deleteSpy).toBeCalledWith(locationId) 86 | expect(response).toStrictEqual(SuccessStatusValue) 87 | }) 88 | }) 89 | -------------------------------------------------------------------------------- /test/unit/data/deviceprofiles/put.ts: -------------------------------------------------------------------------------- 1 | export const put_deviceprofiles_149476cd_3ca9_4e62_ba40_a399e558b2bf = { 2 | request: { 3 | 'url': 'https://api.smartthings.com/deviceprofiles/149476cd-3ca9-4e62-ba40-a399e558b2bf', 4 | 'method': 'put', 5 | 'headers': { 6 | 'Content-Type': 'application/json;charset=utf-8', 7 | 'Accept': 'application/json', 8 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 9 | }, 10 | 'data': { 11 | 'components': [ 12 | { 13 | 'id': 'main', 14 | 'capabilities': [ 15 | { 16 | 'id': 'switch', 17 | 'version': 1, 18 | }, 19 | { 20 | 'id': 'switchLevel', 21 | 'version': 1, 22 | }, 23 | ], 24 | 'categories': [], 25 | }, 26 | ], 27 | 'metadata': { 28 | 'vid': 'simple-dimmer', 29 | 'deviceType': 'Light', 30 | 'ocfDeviceType': 'oic.d.light', 31 | 'mnmn': 'fIIT', 32 | 'deviceTypeId': 'Light', 33 | 'ocfSpecVer': 'core 1.1.0', 34 | 'mnid': 'fIIT', 35 | 'mnId': 'fIIT', 36 | }, 37 | }, 38 | }, 39 | response: { 40 | 'id': '149476cd-3ca9-4e62-ba40-a399e558b2bf', 41 | 'name': 'Functional Test Switch', 42 | 'owner': { 43 | 'ownerType': 'USER', 44 | 'ownerId': 'c257d2c7-332b-d60d-808d-550bfbd54556', 45 | }, 46 | 'components': [ 47 | { 48 | 'label': 'main', 49 | 'id': 'main', 50 | 'capabilities': [ 51 | { 52 | 'id': 'switch', 53 | 'version': 1, 54 | }, 55 | { 56 | 'id': 'switchLevel', 57 | 'version': 1, 58 | }, 59 | ], 60 | 'categories': [], 61 | }, 62 | ], 63 | 'metadata': { 64 | 'vid': 'simple-dimmer', 65 | 'deviceType': 'Light', 66 | 'ocfDeviceType': 'oic.d.light', 67 | 'mnmn': 'fIIT', 68 | 'deviceTypeId': 'Light', 69 | 'ocfSpecVer': 'core 1.1.0', 70 | 'mnid': 'fIIT', 71 | 'mnId': 'fIIT', 72 | }, 73 | 'status': 'DEVELOPMENT', 74 | }, 75 | } 76 | 77 | export const put_deviceprofiles_3acbf2fc_6be2_4be0_aeb5_c10f4ff357bb_i18n_fr = { 78 | request: { 79 | 'url': 'https://api.smartthings.com/deviceprofiles/3acbf2fc-6be2-4be0-aeb5-c10f4ff357bb/i18n/fr', 80 | 'method': 'put', 81 | 'headers': { 82 | 'Content-Type': 'application/json;charset=utf-8', 83 | 'Accept': 'application/json', 84 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 85 | }, 86 | 'data': { 87 | 'tag': 'fr', 88 | 'components': { 89 | 'main': { 90 | 'label': 'Alimentation Principale', 91 | 'description': 'Contrôle l\'alimentation de toutes les prises', 92 | }, 93 | 'outlet1': { 94 | 'label': 'Sortie Un', 95 | 'description': 'Prise de courant commutable 1', 96 | }, 97 | 'outlet2': { 98 | 'label': 'Sortie Deux', 99 | 'description': 'Prise de courant commutable 2', 100 | }, 101 | }, 102 | }, 103 | }, 104 | response: { 105 | 'tag': 'fr', 106 | 'components': { 107 | 'main': { 108 | 'label': 'Alimentation Principale', 109 | 'description': 'Contrôle l\'alimentation de toutes les prises', 110 | }, 111 | 'outlet1': { 112 | 'label': 'Sortie Un', 113 | 'description': 'Prise de courant commutable 1', 114 | }, 115 | 'outlet2': { 116 | 'label': 'Sortie Deux', 117 | 'description': 'Prise de courant commutable 2', 118 | }, 119 | }, 120 | }, 121 | } 122 | -------------------------------------------------------------------------------- /test/unit/schedules.test.ts: -------------------------------------------------------------------------------- 1 | import axios from '../../__mocks__/axios' 2 | import { 3 | BearerTokenAuthenticator, 4 | Schedule, 5 | SmartThingsClient, 6 | } from '../../src' 7 | import { expectedRequest } from './helpers/utils' 8 | import { 9 | get_daily_location as getDailyLocation, 10 | } from './data/schedules/get' 11 | import { 12 | post_daily as postDaily, 13 | post_daily_simple as postDailySimple, 14 | post_daily_date as postDailyDate, 15 | post_daily_location as postDailyLocation, 16 | post_once as postOnce, 17 | } from './data/schedules/post' 18 | 19 | 20 | const client = new SmartThingsClient( 21 | new BearerTokenAuthenticator('00000000-0000-0000-0000-000000000000'), 22 | { locationId: '0bcbe542-d340-42a9-b00a-a2067170810e', installedAppId: '39d84b7a-edf8-4213-b256-122d90a94b3e' }) 23 | 24 | describe('Schedules', () => { 25 | afterEach(() => { 26 | axios.request.mockReset() 27 | }) 28 | 29 | it('Run daily ISO', async () => { 30 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: postDaily.response })) 31 | const response: Schedule = await client.schedules.runDaily('onSchedule', '2020-02-08T16:35:00.000-0800', 'PST') 32 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(postDaily.request)) 33 | expect(response).toBe(postDaily.response) 34 | }) 35 | 36 | it('Run daily simple', async () => { 37 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: postDailySimple.response })) 38 | const response: Schedule = await client.schedules.runDaily('onSchedule', '9:45', 'PST') 39 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(postDailySimple.request)) 40 | expect(response).toBe(postDailySimple.response) 41 | }) 42 | 43 | it('Run daily date', async () => { 44 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: postDailyDate.response })) 45 | const date: Date = new Date(Date.parse('04 Apr 2020 14:30:00 GMT')) 46 | const response: Schedule = await client.schedules.runDaily('onSchedule', date) 47 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(postDailyDate.request)) 48 | expect(response).toBe(postDailyDate.response) 49 | }) 50 | 51 | it('Run daily location', async () => { 52 | axios.request 53 | .mockImplementationOnce(() => Promise.resolve({ status: 200, data: getDailyLocation.response })) 54 | .mockImplementationOnce(() => Promise.resolve({ status: 200, data: postDailyLocation.response })) 55 | const response: Schedule = await client.schedules.runDaily('onSchedule', '2020-02-08T16:35:00.000-0800') 56 | expect(axios.request).toHaveBeenCalledTimes(2) 57 | expect(axios.request).toHaveBeenNthCalledWith(1, expectedRequest(getDailyLocation.request)) 58 | expect(axios.request).toHaveBeenNthCalledWith(2, expectedRequest(postDailyLocation.request)) 59 | expect(response).toBe(postDailyLocation.response) 60 | }) 61 | 62 | it('Run once time', async () => { 63 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: postOnce.response })) 64 | const response: Schedule = await client.schedules.runOnce('onOnce', 1584891000000) 65 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(postOnce.request)) 66 | expect(response).toBe(postOnce.response) 67 | }) 68 | 69 | it('Run once Date', async () => { 70 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: postOnce.response })) 71 | const response: Schedule = await client.schedules.runOnce('onOnce', new Date(1584891000000)) 72 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(postOnce.request)) 73 | expect(response).toBe(postOnce.response) 74 | }) 75 | 76 | }) 77 | -------------------------------------------------------------------------------- /src/endpoint/channels.ts: -------------------------------------------------------------------------------- 1 | import { Endpoint } from '../endpoint' 2 | import { EndpointClient, EndpointClientConfig, HttpClientParams } from '../endpoint-client' 3 | import { EdgeDriver } from './drivers' 4 | 5 | 6 | export interface ChannelBase { 7 | name: string 8 | description: string 9 | termsOfServiceUrl: string 10 | } 11 | 12 | export interface ChannelCreate extends ChannelBase { 13 | type?: 'DRIVER' 14 | } 15 | 16 | export interface Channel extends ChannelCreate { 17 | channelId: string 18 | createdDate: string 19 | lastModifiedDate: string 20 | } 21 | 22 | export type ChannelUpdate = ChannelBase 23 | 24 | export type SubscriberType = 'HUB' 25 | export interface ListOptions { 26 | /** 27 | * Filter channels by subscriber type. 28 | */ 29 | subscriberType?: SubscriberType 30 | 31 | /** 32 | * Filter channels based on the subscriber id (e.g. hub id). 33 | * 34 | * Requires `subscriberType` to also be specified. 35 | */ 36 | subscriberId?: string 37 | 38 | /** 39 | * Include channels that have been subscribed to as well as user-owned channels. 40 | */ 41 | includeReadOnly?: boolean 42 | } 43 | 44 | export interface DriverChannelDetails { 45 | channelId: string 46 | driverId: string 47 | version: string 48 | createdDate: string 49 | lastModifiedDate: string 50 | } 51 | 52 | export class ChannelsEndpoint extends Endpoint { 53 | constructor(config: EndpointClientConfig) { 54 | super(new EndpointClient('distchannels', config)) 55 | } 56 | 57 | public async create(data: ChannelCreate): Promise { 58 | return this.client.post('', data) 59 | } 60 | 61 | public async delete(id: string): Promise { 62 | await this.client.delete(id) 63 | } 64 | 65 | public async update(id: string, data: ChannelUpdate): Promise { 66 | return this.client.put(id, data) 67 | } 68 | 69 | public async get(id: string): Promise { 70 | return this.client.get(id) 71 | } 72 | 73 | public async getDriverChannelMetaInfo(channelId: string, driverId: string): Promise { 74 | return this.client.get(`${channelId}/drivers/${driverId}/meta`) 75 | } 76 | 77 | public async list(options: ListOptions = {}): Promise { 78 | const params: HttpClientParams = {} 79 | if (options.subscriberType) { 80 | params.type = options.subscriberType 81 | } 82 | if (options.subscriberId) { 83 | if (!options.subscriberType) { 84 | throw Error('specifying a subscriberId requires also specifying a subscriberType') 85 | } 86 | params.subscriberId = options.subscriberId 87 | } 88 | if (typeof(options.includeReadOnly) === 'boolean') { 89 | params.includeReadOnly = options.includeReadOnly.toString() 90 | } 91 | return this.client.getPagedItems('', params) 92 | } 93 | 94 | public async listAssignedDrivers(channelId: string): Promise { 95 | return this.client.getPagedItems(`${channelId}/drivers`) 96 | } 97 | 98 | /** 99 | * Assign or publish a driver to a channel. 100 | * 101 | * NOTE: This method also works to update the driver version assigned to a channel. 102 | */ 103 | public async assignDriver(channelId: string, driverId: string, version: string): Promise { 104 | return this.client.post(`${channelId}/drivers`, { driverId, version }) 105 | } 106 | 107 | public async unassignDriver(channelId: string, driverId: string): Promise { 108 | await this.client.delete(`${channelId}/drivers/${driverId}`) 109 | } 110 | 111 | public async enrollHub(channelId: string, hubId: string): Promise { 112 | await this.client.post(`${channelId}/hubs/${hubId}`) 113 | } 114 | 115 | public async unenrollHub(channelId: string, hubId: string): Promise { 116 | await this.client.delete(`${channelId}/hubs/${hubId}`) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/endpoint/modes.ts: -------------------------------------------------------------------------------- 1 | import { Endpoint } from '../endpoint' 2 | import { EndpointClient, EndpointClientConfig } from '../endpoint-client' 3 | import { Status, SuccessStatusValue } from '../types' 4 | 5 | 6 | export interface ModeRequest { 7 | /** 8 | * A name provided by the User. Unique per location, updatable. 9 | */ 10 | label?: string 11 | } 12 | 13 | export interface Mode extends ModeRequest { 14 | /** 15 | * 16 | * Globally unique id for the mode. 17 | */ 18 | id: string 19 | 20 | /** 21 | * A name provided when the mode was created. The name is unique per location, and can not be updated. 22 | */ 23 | name?: string 24 | } 25 | 26 | export class ModesEndpoint extends Endpoint { 27 | constructor(config: EndpointClientConfig) { 28 | super(new EndpointClient('locations', config)) 29 | } 30 | 31 | /** 32 | * Returns a list of the modes defined for a location 33 | * @param locationId UUID of the location. If the client is configured with a locationId this parameter is 34 | * not necessary. 35 | */ 36 | public list(locationId?: string): Promise { 37 | return this.client.getPagedItems(`${this.locationId(locationId)}/modes`) 38 | } 39 | 40 | /** 41 | * Returns a specific mode 42 | * @param id UUID of the mode 43 | * @param locationId UUID of the location. If the client is configured with a locationId this parameter is 44 | * not necessary. 45 | */ 46 | public async get(id: string, locationId?: string): Promise { 47 | const list = await this.list(locationId) 48 | if (list) { 49 | const item = list.find(it => it.id === id) 50 | if (item) { 51 | return item 52 | } 53 | } 54 | throw Error(`Mode ${id} not found`) 55 | } 56 | 57 | /** 58 | * Returns the currently active mode of a location 59 | * @param locationId UUID of the location. If the client is configured with a locationId this parameter is 60 | * not necessary. 61 | */ 62 | public getCurrent(locationId?: string): Promise { 63 | return this.client.get(`${this.locationId(locationId)}/modes/current`) 64 | } 65 | 66 | /** 67 | * Sets the currently active mode of a location 68 | * @param id UUID of the mode 69 | * @param locationId UUID of the location. If the client is configured with a locationId this parameter is 70 | * not necessary. 71 | */ 72 | public setCurrent(id: string, locationId?: string): Promise { 73 | return this.client.put(`${this.locationId(locationId)}/modes/current`, { modeId: id }) 74 | } 75 | 76 | /** 77 | * Create a new mode in a location 78 | * @param data definition specifying the name of the new mode 79 | * @param locationId UUID of the location. If the client is configured with a locationId this parameter is 80 | * not necessary. 81 | */ 82 | public create(data: ModeRequest, locationId?: string): Promise { 83 | return this.client.post(`${this.locationId(locationId)}/modes`, data) 84 | } 85 | 86 | /** 87 | * Updates the name of a mode 88 | * @param id UUID of the mode 89 | * @param data definition specifying the new mode name 90 | * @param locationId UUID of the location. If the client is configured with a locationId this parameter is 91 | * not necessary. 92 | */ 93 | public update(id: string, data: ModeRequest, locationId?: string): Promise { 94 | return this.client.put(`${this.locationId(locationId)}/modes/${id}`, data) 95 | } 96 | 97 | /** 98 | * Delete a mode 99 | * @param id UUID of the mode 100 | * @param locationId UUID of the location. If the client is configured with a locationId this parameter is 101 | * not necessary. 102 | */ 103 | public async delete(id: string, locationId?: string): Promise { 104 | await this.client.delete(`${this.locationId(locationId)}/modes/${id}`) 105 | return SuccessStatusValue 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /test/unit/rules.test.ts: -------------------------------------------------------------------------------- 1 | import { NoOpAuthenticator } from '../../src/authenticator' 2 | import { Rule, RuleExecutionResponse, RuleRequest, RulesEndpoint } from '../../src/endpoint/rules' 3 | import { EndpointClient } from '../../src/endpoint-client' 4 | 5 | 6 | describe('RulesEndpoint', () => { 7 | afterEach(() => { 8 | jest.clearAllMocks() 9 | }) 10 | 11 | const getSpy = jest.spyOn(EndpointClient.prototype, 'get').mockImplementation() 12 | const postSpy = jest.spyOn(EndpointClient.prototype, 'post').mockImplementation() 13 | const putSpy = jest.spyOn(EndpointClient.prototype, 'put').mockImplementation() 14 | const deleteSpy = jest.spyOn(EndpointClient.prototype, 'delete') 15 | const getPagedItemsSpy = jest.spyOn(EndpointClient.prototype, 'getPagedItems').mockImplementation() 16 | 17 | const locationIdMock = jest.fn() 18 | .mockReturnValue('final-location-id') 19 | 20 | const authenticator = new NoOpAuthenticator() 21 | const rulesEndpoint = new RulesEndpoint({ authenticator }) 22 | rulesEndpoint.locationId = locationIdMock 23 | 24 | const rulesList = [{ id: 'listed-rule' }] as Rule[] 25 | 26 | test('list', async () => { 27 | getPagedItemsSpy.mockResolvedValueOnce(rulesList) 28 | 29 | expect(await rulesEndpoint.list('input-location-id')).toBe(rulesList) 30 | 31 | expect(getPagedItemsSpy).toHaveBeenCalledWith(undefined, { locationId: 'final-location-id' }) 32 | expect(locationIdMock).toHaveBeenCalledTimes(1) 33 | expect(locationIdMock).toHaveBeenCalledWith('input-location-id') 34 | }) 35 | 36 | test('get', async () => { 37 | const rule = { id: 'rule-id' } 38 | getSpy.mockResolvedValueOnce(rule) 39 | 40 | expect(await rulesEndpoint.get('requested-rule-id', 'input-location-id')).toBe(rule) 41 | 42 | expect(getSpy).toHaveBeenCalledWith('requested-rule-id', { locationId: 'final-location-id' }) 43 | expect(locationIdMock).toHaveBeenCalledTimes(1) 44 | expect(locationIdMock).toHaveBeenCalledWith('input-location-id') 45 | }) 46 | 47 | test('delete', async () => { 48 | const rule = { id: 'rule-to-delete-id' } 49 | deleteSpy.mockResolvedValueOnce(rule) 50 | expect(await rulesEndpoint.delete('id-to-delete', 'input-location-id')).toBe(rule) 51 | 52 | expect(deleteSpy).toHaveBeenCalledTimes(1) 53 | expect(deleteSpy).toHaveBeenCalledWith('id-to-delete', { locationId: 'final-location-id' }) 54 | expect(locationIdMock).toHaveBeenCalledTimes(1) 55 | expect(locationIdMock).toHaveBeenCalledWith('input-location-id') 56 | }) 57 | 58 | test('create', async () => { 59 | const createRequest = { name: 'rule-to-create' } as RuleRequest 60 | const createdRule = { id: 'created-rule' } as Rule 61 | postSpy.mockResolvedValueOnce(createdRule) 62 | 63 | expect(await rulesEndpoint.create(createRequest, 'input-location-id')).toBe(createdRule) 64 | 65 | expect(postSpy).toHaveBeenCalledTimes(1) 66 | expect(postSpy).toHaveBeenCalledWith(undefined, createRequest, { locationId: 'final-location-id' }) 67 | }) 68 | 69 | test('update', async () => { 70 | const updateRequest = { name: 'rule-to-update' } as RuleRequest 71 | const updatedRule = { id: 'updated-rule' } as Rule 72 | putSpy.mockResolvedValueOnce(updatedRule) 73 | 74 | expect(await rulesEndpoint.update('input-rule-id', updateRequest, 'input-location-id')).toBe(updatedRule) 75 | 76 | expect(putSpy).toHaveBeenCalledTimes(1) 77 | expect(putSpy).toHaveBeenCalledWith('input-rule-id', updateRequest, { locationId: 'final-location-id' }) 78 | }) 79 | 80 | test('execute', async () => { 81 | const executeResponse = {} as RuleExecutionResponse 82 | postSpy.mockResolvedValue(executeResponse) 83 | 84 | expect(await rulesEndpoint.execute('id-of-rule-to-execute', 'input-location-id')).toBe(executeResponse) 85 | 86 | expect(postSpy).toHaveBeenCalledTimes(1) 87 | expect(postSpy).toHaveBeenCalledWith('execute/id-of-rule-to-execute', undefined, { locationId: 'final-location-id' }) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /test/unit/data/installedapps/post.ts: -------------------------------------------------------------------------------- 1 | import { ConfigValueType, InstallConfigurationStatus, InstalledAppType } from '../../../../src' 2 | 3 | 4 | export const post_installedapps = { 5 | 'request': { 6 | 'url': 'https://api.smartthings.com/installedapps', 7 | 'method': 'post', 8 | 'headers': { 9 | 'Content-Type': 'application/json;charset=utf-8', 10 | 'Accept': 'application/json', 11 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 12 | }, 13 | 'data': { 14 | 'appId': '1c593873-ef7d-4665-8f0d-e1da25861e02', 15 | 'locationId': '95efee9b-6073-4871-b5ba-de6642187293', 16 | 'installedAppType': InstalledAppType.WEBHOOK_SMART_APP, 17 | 'configurationStatus': InstallConfigurationStatus.DONE, 18 | 'config': { 19 | 'triggerSwitch': [ 20 | { 21 | 'valueType': ConfigValueType.DEVICE, 22 | 'deviceConfig': { 23 | 'deviceId': '385931b6-0121-4848-bcc8-54cb76436de1', 24 | 'componentId': 'main', 25 | 'permissions': [ 26 | 'r:devices:385931b6-0121-4848-bcc8-54cb76436de1', 27 | ], 28 | }, 29 | }, 30 | ], 31 | 'targetSwitch': [ 32 | { 33 | 'valueType': ConfigValueType.DEVICE, 34 | 'deviceConfig': { 35 | 'deviceId': 'b97058f4-c642-4162-8c2d-15009fdf5bfc', 36 | 'componentId': 'main', 37 | 'permissions': [ 38 | 'r:devices:b97058f4-c642-4162-8c2d-15009fdf5bfc', 39 | 'x:devices:b97058f4-c642-4162-8c2d-15009fdf5bfc', 40 | ], 41 | }, 42 | }, 43 | ], 44 | }, 45 | }, 46 | }, 47 | 'response': { 48 | 'installedApp': { 49 | 'installedAppId': 'e09af197-4a51-42d9-8fd9-a39a67049d4a', 50 | 'installedAppType': 'WEBHOOK_SMART_APP', 51 | 'installedAppStatus': 'PENDING', 52 | 'displayName': 'Functional Test Switch Reflector', 53 | 'appId': '1c593873-ef7d-4665-8f0d-e1da25861e02', 54 | 'referenceId': null, 55 | 'locationId': '95efee9b-6073-4871-b5ba-de6642187293', 56 | 'owner': { 57 | 'ownerType': 'USER', 58 | 'ownerId': 'c257d2c7-332b-d60d-808d-550bfbd54556', 59 | }, 60 | 'notices': [], 61 | 'createdDate': '2020-02-29T15:51:17Z', 62 | 'lastUpdatedDate': '2020-02-29T15:51:17Z', 63 | 'ui': { 64 | 'pluginId': null, 65 | 'pluginUri': null, 66 | 'dashboardCardsEnabled': false, 67 | 'preInstallDashboardCardsEnabled': false, 68 | }, 69 | 'iconImage': { 70 | 'url': null, 71 | }, 72 | 'classifications': [ 73 | 'AUTOMATION', 74 | ], 75 | 'principalType': 'LOCATION', 76 | 'singleInstance': false, 77 | }, 78 | 'configurationDetail': { 79 | 'installedAppId': 'e09af197-4a51-42d9-8fd9-a39a67049d4a', 80 | 'configurationId': 'f1c9ddca-cc1f-4391-a955-5485d849c23e', 81 | 'configurationStatus': 'DONE', 82 | 'config': { 83 | 'triggerSwitch': [ 84 | { 85 | 'valueType': 'DEVICE', 86 | 'stringConfig': null, 87 | 'deviceConfig': { 88 | 'deviceId': '385931b6-0121-4848-bcc8-54cb76436de1', 89 | 'componentId': 'main', 90 | 'permissions': [ 91 | 'r:devices:385931b6-0121-4848-bcc8-54cb76436de1', 92 | ], 93 | }, 94 | 'permissionConfig': null, 95 | 'modeConfig': null, 96 | 'sceneConfig': null, 97 | 'messageConfig': null, 98 | }, 99 | ], 100 | 'targetSwitch': [ 101 | { 102 | 'valueType': 'DEVICE', 103 | 'stringConfig': null, 104 | 'deviceConfig': { 105 | 'deviceId': 'b97058f4-c642-4162-8c2d-15009fdf5bfc', 106 | 'componentId': 'main', 107 | 'permissions': [ 108 | 'r:devices:b97058f4-c642-4162-8c2d-15009fdf5bfc', 109 | 'x:devices:b97058f4-c642-4162-8c2d-15009fdf5bfc', 110 | ], 111 | }, 112 | 'permissionConfig': null, 113 | 'modeConfig': null, 114 | 'sceneConfig': null, 115 | 'messageConfig': null, 116 | }, 117 | ], 118 | }, 119 | 'createdDate': '2020-02-29T15:51:17Z', 120 | 'lastUpdatedDate': '2020-02-29T15:51:17Z', 121 | }, 122 | }, 123 | } 124 | -------------------------------------------------------------------------------- /eslint.config.ts: -------------------------------------------------------------------------------- 1 | 2 | import eslint from '@eslint/js' 3 | import tseslint from 'typescript-eslint' 4 | import importPlugin from 'eslint-plugin-import' 5 | import jestPlugin from 'jest' 6 | import stylistic from '@stylistic/eslint-plugin' 7 | 8 | 9 | export default tseslint.config( 10 | eslint.configs.recommended, 11 | tseslint.configs.recommended, 12 | importPlugin.flatConfigs.recommended, 13 | { 14 | languageOptions: { 15 | parserOptions: { 16 | ecmaVersion: 2019, 17 | tsconfigRootDir: __dirname, 18 | project: ['./tsconfig.json', './tsconfig-test.json'], 19 | }, 20 | }, 21 | plugins: { 22 | jestPlugin, 23 | '@stylistic': stylistic, 24 | }, 25 | rules: { 26 | indent: 'off', 27 | '@stylistic/indent': [ 28 | 'error', 29 | 'tab', 30 | { 31 | FunctionDeclaration: { body: 1, parameters: 2 }, 32 | FunctionExpression: { body: 1, parameters: 2 }, 33 | SwitchCase: 1, 34 | }, 35 | ], 36 | 'linebreak-style': ['error', 'unix'], 37 | '@stylistic/quotes': [ 38 | 'error', 39 | 'single', 40 | { avoidEscape: true }, 41 | ], 42 | curly: ['error', 'all'], 43 | 'comma-dangle': [ 44 | 'error', 45 | 'always-multiline', 46 | ], 47 | 'no-console': 'error', 48 | 'no-process-exit': 'error', 49 | 'no-template-curly-in-string': 'error', 50 | 'require-await': 'off', 51 | '@stylistic/semi': ['error', 'never'], 52 | '@stylistic/member-delimiter-style': [ 53 | 'error', 54 | { 55 | multiline: { 56 | delimiter: 'none', 57 | requireLast: true, 58 | }, 59 | singleline: { 60 | delimiter: 'semi', 61 | requireLast: false, 62 | }, 63 | }, 64 | ], 65 | // Temporarily disabling while we switch from interface to type. 66 | // see https://github.com/SmartThingsCommunity/smartthings-core-sdk/issues/207 67 | // '@typescript-eslint/consistent-type-definitions': ['error', 'type'], 68 | '@typescript-eslint/explicit-function-return-type': ['error', { 69 | allowExpressions: true, 70 | }], 71 | '@typescript-eslint/explicit-module-boundary-types': 'error', 72 | '@typescript-eslint/no-explicit-any': 'error', 73 | '@typescript-eslint/no-non-null-assertion': 'error', 74 | 'no-use-before-define': 'off', 75 | '@typescript-eslint/no-use-before-define': [ 76 | 'error', 77 | { functions: false, classes: false, enums: false, variables: true }, 78 | ], 79 | '@typescript-eslint/no-var-requires': 'error', 80 | '@typescript-eslint/ban-ts-comment': 'error', 81 | '@typescript-eslint/no-floating-promises': 'error', 82 | '@stylistic/space-infix-ops': 'error', 83 | '@stylistic/object-curly-spacing': ['error', 'always'], 84 | '@stylistic/comma-spacing': ['error'], 85 | '@stylistic/type-annotation-spacing': 'error', 86 | 87 | // disallow non-import statements appearing before import statements 88 | 'import/first': 'error', 89 | // Require a newline after the last import/require in a group 90 | 'import/newline-after-import': ['error', { 'count': 2 }], 91 | // Forbid import of modules using absolute paths 92 | 'import/no-absolute-path': 'error', 93 | // disallow AMD require/define 94 | 'import/no-amd': 'error', 95 | // Forbid the use of extraneous packages 96 | 'import/no-extraneous-dependencies': [ 97 | 'error', { 98 | devDependencies: true, 99 | peerDependencies: true, 100 | optionalDependencies: false, 101 | }, 102 | ], 103 | // Forbid mutable exports 104 | 'import/no-mutable-exports': 'error', 105 | // Prevent importing the default as if it were named 106 | 'import/no-named-default': 'error', 107 | // Prohibit named exports 108 | 'import/no-named-export': 'off', // we want everything to be a named export 109 | // Forbid a module from importing itself 110 | 'import/no-self-import': 'error', 111 | // Require modules with a single export to use a default export 112 | 'import/prefer-default-export': 'off', // we want everything to be named 113 | 'import/named': 'off', 114 | 'import/no-unresolved': ['off'], 115 | }, 116 | }, 117 | ) 118 | -------------------------------------------------------------------------------- /src/endpoint/hubdevices.ts: -------------------------------------------------------------------------------- 1 | import { Endpoint } from '../endpoint' 2 | import { EndpointClient, EndpointClientConfig } from '../endpoint-client' 3 | 4 | 5 | export interface EnrolledChannel { 6 | channelId: string 7 | name: string 8 | description?: string 9 | 10 | /** 11 | * ISO-8601 timestamp of creation of channel. 12 | */ 13 | createdDate?: string 14 | 15 | /** 16 | * ISO-8601 timestamp of last modification of channel 17 | */ 18 | lastModifiedDate?: string 19 | 20 | /** 21 | * URL to web interface to modify channel subscriptions. 22 | */ 23 | subscriptionUrl?: string 24 | } 25 | 26 | export interface InstalledDriver { 27 | driverId: string 28 | name: string 29 | description?: string 30 | version: string 31 | channelId: string 32 | developer: string 33 | 34 | /** 35 | * Information on how to reach the vendor. 36 | */ 37 | vendorSupportInformation: string 38 | 39 | /** 40 | * map of permissions and attributes used by the driver. 41 | * 42 | * Format: 43 | * {"permissions":{"perm1":{...}, "perm2"{...}}} 44 | */ 45 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 46 | permissions: { [name: string]: any } 47 | } 48 | 49 | export interface Hub { 50 | id: string 51 | name: string 52 | eui: string 53 | owner: string 54 | serialNumber: string 55 | firmwareVersion: string 56 | } 57 | 58 | export interface HubCharacteristics { 59 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 60 | [key: string]: any 61 | } 62 | 63 | export class HubdevicesEndpoint extends Endpoint { 64 | constructor(config: EndpointClientConfig) { 65 | super(new EndpointClient('hubdevices', config)) 66 | } 67 | 68 | /** 69 | * Get a hub record 70 | * @param hubId 71 | */ 72 | public async get(hubId: string): Promise { 73 | return this.client.get(`${hubId}`) 74 | } 75 | 76 | /** 77 | * Get the characteristics of a hub 78 | * @param hubId 79 | */ 80 | public async getCharacteristics(hubId: string): Promise { 81 | return this.client.get(`${hubId}/characteristics`) 82 | } 83 | 84 | /** 85 | * Install driver on a hub. The primary use case of this functionality is to install a 86 | * self-published driver to be included in generic discovery (e.g. scanning). 87 | */ 88 | public async installDriver(driverId: string, hubId: string, channelId: string): Promise { 89 | return this.client.put(`${hubId}/drivers/${driverId}`, { channelId }) 90 | } 91 | 92 | /** 93 | * Change the driver for a device to the one specified by driverId. 94 | */ 95 | public async switchDriver(driverId: string, hubId: string, deviceId: string, forceUpdate = false): Promise { 96 | return this.client.patch(`${hubId}/childdevice/${deviceId}`, { driverId }, 97 | forceUpdate ? { forceUpdate: 'true' } : undefined) 98 | } 99 | 100 | /** 101 | * Removes a driver from being installed on a hub. This will allow the hub to clean up the 102 | * deleted driver. However, all dependent devices need to be removed for cleanup to fully occur. 103 | */ 104 | public async uninstallDriver(driverId: string, hubId: string): Promise { 105 | return this.client.delete(`${hubId}/drivers/${driverId}`) 106 | } 107 | 108 | /** 109 | * List drivers installed on the hub. 110 | * 111 | * @param deviceId When included, limit the drivers to those marked as matching the specified device. 112 | */ 113 | public async listInstalled(hubId: string, deviceId?: string): Promise { 114 | const params = deviceId ? { deviceId } : undefined 115 | return this.client.get(`${hubId}/drivers`, params) 116 | } 117 | 118 | public async getInstalled(hubId: string, driverId: string): Promise { 119 | return this.client.get(`${hubId}/drivers/${driverId}`) 120 | } 121 | 122 | /** 123 | * Returns the list of driver channels the hub is currently subscribed to. 124 | * Currently only returns the driver channel type. 125 | */ 126 | public async enrolledChannels(hubId: string): Promise { 127 | return this.client.get(`${hubId}/channels`, { channelType: 'DRIVERS' }) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for contributing! Our community of developers is what put SmartThings on the map! 4 | 5 | ## How Can I Contribute? 6 | 7 | ### Improve Documentation 8 | 9 | As a user of the Core SDK, you're the perfect candidate to help us improve our documentation. Error fixes, typo corrections, better explanations, more examples, etc. Open issues for things that could be improved – anything. Even improvements to this document. 10 | 11 | ### Give Feedback on Issues 12 | 13 | We're always looking for more opinions on discussions in the issue tracker. It's a good opportunity to influence the future direction of this SDK. 14 | 15 | ### Submitting an Issue or Feature Request 16 | 17 | - Search the issue tracker before opening an issue 18 | - Ensure you're using the latest version 19 | - Use a clear and descriptive title 20 | - Include as much information as possible by filling out the issue template 21 | - The more time you put into an issue, the more we will 22 | 23 | ### Submitting a Pull Request 24 | 25 | - Non-trivial changes are often best discussed in an issue first, to prevent you from doing unnecessary work. 26 | - For ambitious tasks, you should try to get your work in front of the community for feedback as soon as possible. Open a pull request as soon as you have done the minimum needed to demonstrate your idea. At this early stage, don't worry about making things perfect, or 100% complete. Describe what you still need to do and submit a [draft pull request](https://github.blog/2019-02-14-introducing-draft-pull-requests/). This lets reviewers know not to nit-pick small details or point out improvements you already know you need to make. 27 | - Don't include unrelated changes 28 | - Pull requests should include only a single commit. You can use `git rebase -i main` to combine multiple commits into a single one if necessary. 29 | - New features should be accompanied with tests and documentation 30 | - Commit messages 31 | - Use a clear and descriptive title for the pull request and commits. 32 | - Commit messages must be formatted properly using [conventional commit format](https://www.conventionalcommits.org/en/v1.0.0/). 33 | - We use changesets to automatically version and publish releases, and generate release notes. 34 | Please include one with your pull request by following the instructions to 35 | [add a changeset](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md). 36 | We have a bot that will remind you to include one with your pull request if you forget. 37 | - This repo is [commitizen friendly](https://github.com/commitizen/cz-cli), so you can use the `cz` cli to help create your commits. 38 | - Lint and test before submitting the pull request 39 | - `npm run lint` 40 | - `npm run test` 41 | - Write a convincing description of why we should land your pull request. Answer _why_ it's needed and provide use-cases. 42 | - Make the pull request from a [topic branch](https://alvinalexander.com/git/git-topic-branch-workflow-pattern-pro-git/) (not main) 43 | - You might be asked to do changes to your pull request. There's never a need to open another pull request – [just update the existing one.](https://github.com/RichardLitt/knowledge/blob/master/github/amending-a-commit-guide.md) 44 | 45 | ## Finding Contributions to Work On 46 | 47 | Look at the existing issues for areas of contribution. Searching for issues labeled `help wanted` would be a great place to start. 48 | 49 | --- 50 | 51 | ## More About SmartThings 52 | 53 | If you are not familiar with SmartThings, we have 54 | [extensive on-line documentation](https://developer.smartthings.com/docs/getting-started/welcome). 55 | 56 | To create and manage your services and devices on SmartThings, create an account in the 57 | [developer workspace](https://smartthings.developer.samsung.com/workspace/). 58 | 59 | The [SmartThings Community](https://community.smartthings.com/) is a good place to share and 60 | ask questions. 61 | 62 | There is also a [SmartThings reddit community](https://www.reddit.com/r/SmartThings/) where you 63 | can read and share information. 64 | 65 | ## License and Copyright 66 | 67 | Licensed under the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0) 68 | 69 | Copyright 2023 Samsung Electronics Co., LTD. 70 | -------------------------------------------------------------------------------- /test/unit/presentation.test.ts: -------------------------------------------------------------------------------- 1 | import axios from '../../__mocks__/axios' 2 | import { NoOpAuthenticator, SmartThingsClient, PresentationDeviceConfig, PresentationDevicePresentation } from '../../src' 3 | import { deviceConfig, presentation } from './data/presentation/models' 4 | import { buildRequest } from './helpers/utils' 5 | 6 | 7 | const authenticator = new NoOpAuthenticator() 8 | const client = new SmartThingsClient(authenticator, {}) 9 | 10 | const profileId = '0000-0000-0000-0000' 11 | 12 | describe('Presentation', () => { 13 | afterEach(() => { 14 | axios.request.mockReset() 15 | }) 16 | 17 | it('generate', async () => { 18 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: deviceConfig })) 19 | const response: PresentationDeviceConfig = await client.presentation.generate(profileId) 20 | 21 | expect(axios.request).toHaveBeenCalledTimes(1) 22 | expect(axios.request).toHaveBeenCalledWith(buildRequest(`presentation/types/${profileId}/deviceconfig`, undefined)) 23 | expect(response).toBe(deviceConfig) 24 | }) 25 | 26 | it('generate with optional params', async () => { 27 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: deviceConfig })) 28 | const response: PresentationDeviceConfig = await client.presentation.generate(profileId, 29 | { typeIntegration: 'dth' }) 30 | 31 | expect(axios.request).toHaveBeenCalledTimes(1) 32 | expect(axios.request).toHaveBeenCalledWith(buildRequest(`presentation/types/${profileId}/deviceconfig`, 33 | { typeIntegration: 'dth' })) 34 | expect(response).toBe(deviceConfig) 35 | }) 36 | 37 | it('get config', async () => { 38 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: deviceConfig })) 39 | const response: PresentationDeviceConfig = await client.presentation.get('d8469d5c-3ca2-4601-9f21-2b7a0ccd44a5') 40 | 41 | expect(axios.request).toHaveBeenCalledTimes(1) 42 | expect(axios.request).toHaveBeenCalledWith(buildRequest('presentation/deviceconfig', { 43 | presentationId: 'd8469d5c-3ca2-4601-9f21-2b7a0ccd44a5', 44 | })) 45 | expect(response).toBe(deviceConfig) 46 | }) 47 | 48 | it('get config with manufacturer', async () => { 49 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: deviceConfig })) 50 | const response: PresentationDeviceConfig = await client.presentation.get('d8469d5c-3ca2-4601-9f21-2b7a0ccd44a5', 'aXyz1') 51 | 52 | expect(axios.request).toHaveBeenCalledTimes(1) 53 | expect(axios.request).toHaveBeenCalledWith(buildRequest('presentation/deviceconfig', { 54 | presentationId: 'd8469d5c-3ca2-4601-9f21-2b7a0ccd44a5', manufacturerName: 'aXyz1', 55 | })) 56 | expect(response).toBe(deviceConfig) 57 | }) 58 | 59 | it('get presentation', async () => { 60 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: presentation })) 61 | const response: PresentationDevicePresentation = await client.presentation.getPresentation('d8469d5c-3ca2-4601-9f21-2b7a0ccd44a5') 62 | 63 | expect(axios.request).toHaveBeenCalledTimes(1) 64 | expect(axios.request).toHaveBeenCalledWith(buildRequest('presentation', { 65 | presentationId: 'd8469d5c-3ca2-4601-9f21-2b7a0ccd44a5', 66 | })) 67 | expect(response).toBe(presentation) 68 | }) 69 | 70 | it('get presentation with manufacturer', async () => { 71 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: presentation })) 72 | const response: PresentationDevicePresentation = await client.presentation.getPresentation('d8469d5c-3ca2-4601-9f21-2b7a0ccd44a5', 'aXyz1') 73 | 74 | expect(axios.request).toHaveBeenCalledTimes(1) 75 | expect(axios.request).toHaveBeenCalledWith(buildRequest('presentation', { 76 | presentationId: 'd8469d5c-3ca2-4601-9f21-2b7a0ccd44a5', manufacturerName: 'aXyz1', 77 | })) 78 | expect(response).toBe(presentation) 79 | }) 80 | 81 | it('create', async () => { 82 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: deviceConfig })) 83 | const response: PresentationDeviceConfig = await client.presentation.create(deviceConfig) 84 | 85 | expect(axios.request).toHaveBeenCalledTimes(1) 86 | expect(axios.request).toHaveBeenCalledWith(buildRequest('presentation/deviceconfig', 87 | undefined, deviceConfig, 'post')) 88 | expect(response).toBe(deviceConfig) 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /test/unit/data/schedules/post.ts: -------------------------------------------------------------------------------- 1 | export const post_daily = { 2 | 'request': { 3 | 'url': 'https://api.smartthings.com/installedapps/39d84b7a-edf8-4213-b256-122d90a94b3e/schedules', 4 | 'method': 'post', 5 | 'headers': { 6 | 'Content-Type': 'application/json;charset=utf-8', 7 | 'Accept': 'application/json', 8 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 9 | }, 10 | 'data': { 11 | 'name': 'onSchedule', 12 | 'cron': { 13 | 'expression': '35 16 * * ? *', 14 | 'timezone': 'PST', 15 | }, 16 | }, 17 | }, 18 | 'response': { 19 | 'scheduledExecutions': [ 20 | 1583763662000, 21 | ], 22 | 'name': 'refreshHandler', 23 | 'cron': { 24 | 'expression': '35 16 * * ? *', 25 | 'timezone': 'PST', 26 | }, 27 | 'installedAppId': '39d84b7a-edf8-4213-b256-122d90a94b3e', 28 | 'locationId': '0bcbe542-d340-42a9-b00a-a2067170810e', 29 | }, 30 | } 31 | 32 | export const post_daily_simple = { 33 | 'request': { 34 | 'url': 'https://api.smartthings.com/installedapps/39d84b7a-edf8-4213-b256-122d90a94b3e/schedules', 35 | 'method': 'post', 36 | 'headers': { 37 | 'Content-Type': 'application/json;charset=utf-8', 38 | 'Accept': 'application/json', 39 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 40 | }, 41 | 'data': { 42 | 'name': 'onSchedule', 43 | 'cron': { 44 | 'expression': '45 9 * * ? *', 45 | 'timezone': 'PST', 46 | }, 47 | }, 48 | }, 49 | 'response': { 50 | 'scheduledExecutions': [ 51 | 1583763662000, 52 | ], 53 | 'name': 'refreshHandler', 54 | 'cron': { 55 | 'expression': '45 9 * * ? *', 56 | 'timezone': 'PST', 57 | }, 58 | 'installedAppId': '39d84b7a-edf8-4213-b256-122d90a94b3e', 59 | 'locationId': '0bcbe542-d340-42a9-b00a-a2067170810e', 60 | }, 61 | } 62 | 63 | export const post_daily_date = { 64 | 'request': { 65 | 'url': 'https://api.smartthings.com/installedapps/39d84b7a-edf8-4213-b256-122d90a94b3e/schedules', 66 | 'method': 'post', 67 | 'headers': { 68 | 'Content-Type': 'application/json;charset=utf-8', 69 | 'Accept': 'application/json', 70 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 71 | }, 72 | 'data': { 73 | 'name': 'onSchedule', 74 | 'cron': { 75 | 'expression': '30 14 * * ? *', 76 | 'timezone': 'UTC', 77 | }, 78 | }, 79 | }, 80 | 'response': { 81 | 'scheduledExecutions': [ 82 | 1583763662000, 83 | ], 84 | 'name': 'refreshHandler', 85 | 'cron': { 86 | 'expression': '30 14 * * ? *', 87 | 'timezone': 'UTC', 88 | }, 89 | 'installedAppId': '39d84b7a-edf8-4213-b256-122d90a94b3e', 90 | 'locationId': '0bcbe542-d340-42a9-b00a-a2067170810e', 91 | }, 92 | } 93 | 94 | export const post_daily_location = { 95 | 'request': { 96 | 'url': 'https://api.smartthings.com/installedapps/39d84b7a-edf8-4213-b256-122d90a94b3e/schedules', 97 | 'method': 'post', 98 | 'headers': { 99 | 'Content-Type': 'application/json;charset=utf-8', 100 | 'Accept': 'application/json', 101 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 102 | }, 103 | 'data': { 104 | 'name': 'onSchedule', 105 | 'cron': { 106 | 'expression': '35 16 * * ? *', 107 | 'timezone': 'America/Los_Angeles', 108 | }, 109 | }, 110 | }, 111 | 'response': { 112 | 'scheduledExecutions': [ 113 | 1583763662000, 114 | ], 115 | 'name': 'refreshHandler', 116 | 'cron': { 117 | 'expression': '35 16 * * ? *', 118 | 'timezone': 'America/Los_Angeles', 119 | }, 120 | 'installedAppId': '39d84b7a-edf8-4213-b256-122d90a94b3e', 121 | 'locationId': '0bcbe542-d340-42a9-b00a-a2067170810e', 122 | }, 123 | } 124 | 125 | export const post_once = { 126 | 'request': { 127 | 'url': 'https://api.smartthings.com/installedapps/39d84b7a-edf8-4213-b256-122d90a94b3e/schedules', 128 | 'method': 'post', 129 | 'headers': { 130 | 'Content-Type': 'application/json;charset=utf-8', 131 | 'Accept': 'application/json', 132 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 133 | }, 134 | 'data': { 135 | 'name': 'onOnce', 136 | 'once': { 137 | 'time': 1584891000000, 138 | 'overwrite': true, 139 | }, 140 | }, 141 | }, 142 | 'response': { 143 | 'scheduledExecutions': null, 144 | 'name': 'preGameStart', 145 | 'cron': null, 146 | 'installedAppId': '39d84b7a-edf8-4213-b256-122d90a94b3e', 147 | 'locationId': '0bcbe542-d340-42a9-b00a-a2067170810e', 148 | }, 149 | } 150 | -------------------------------------------------------------------------------- /test/unit/signature.test.ts: -------------------------------------------------------------------------------- 1 | import axios from '../../__mocks__/axios' 2 | import httpSignature from '../../__mocks__/http-signature' 3 | import { KeyCache, HttpKeyResolver, SignatureVerifier } from '../../src' 4 | import { keyId, publicKey } from './data/signature/models' 5 | import { 6 | get_certificate as cert, 7 | get_certificate_parsed as parsedCert, 8 | } from './data/signature/get' 9 | import { 10 | post_verify as verify, 11 | post_verify_parsed as verifyParsed, 12 | } from './data/signature/post' 13 | 14 | 15 | class MockKeyCache implements KeyCache { 16 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 17 | constructor(public keyId?: string, public keyValue?: any, public cacheTTL?: number) { 18 | 19 | } 20 | 21 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 22 | public get(keyId: string): any { 23 | return this.keyId === keyId ? this.keyValue : undefined 24 | } 25 | 26 | public set(keyId: string, keyValue: string, cacheTTL: number): void { 27 | this.keyId = keyId 28 | this.keyValue = keyValue 29 | this.cacheTTL = cacheTTL 30 | } 31 | } 32 | describe('ST Padlock', () => { 33 | 34 | it('HTTP no cache', async () => { 35 | axios.get.mockImplementationOnce(() => Promise.resolve({ status: 200, data: cert })) 36 | const resolver = new HttpKeyResolver() 37 | const key = await resolver.getKey(keyId) 38 | expect(axios.get).toHaveBeenCalledWith(`https://key.smartthings.com${keyId}`) 39 | expect(JSON.parse(JSON.stringify(key))).toEqual(parsedCert.subjectKey) 40 | }) 41 | 42 | it('HTTP no cache custom URL', async () => { 43 | axios.get.mockImplementationOnce(() => Promise.resolve({ status: 200, data: cert })) 44 | const resolver = new HttpKeyResolver({ 45 | urlProvider: { 46 | baseURL: 'https://api.smartthings.com', 47 | authURL: 'https://auth-global.api.smartthings.com/oauth/token', 48 | keyApiURL: 'https://keys.smartthingsdev.com', 49 | }, 50 | }) 51 | const key = await resolver.getKey(keyId) 52 | expect(axios.get).toHaveBeenCalledWith(`https://keys.smartthingsdev.com${keyId}`) 53 | expect(JSON.parse(JSON.stringify(key))).toEqual(parsedCert.subjectKey) 54 | }) 55 | 56 | it('HTTP resolver cache hit', async () => { 57 | const keyCache = new MockKeyCache( 58 | '/pl/useast2/1b-0d-f2-69-ad-fb-1b-c4-4e-ac-5a-1f-f7-b6-dd-a9-c4-e8-c8-98', 59 | 'yyyyy') 60 | const resolver = new HttpKeyResolver({ keyCache: keyCache }) 61 | const key = await resolver.getKey( 62 | '/pl/useast2/1b-0d-f2-69-ad-fb-1b-c4-4e-ac-5a-1f-f7-b6-dd-a9-c4-e8-c8-98') 63 | expect(key).toEqual('yyyyy') 64 | }) 65 | 66 | it('HTTP resolver cache miss', async () => { 67 | axios.get.mockImplementationOnce(() => Promise.resolve({ status: 200, data: cert })) 68 | const keyCache = new MockKeyCache( 69 | '/pl/useast2/1b-0d-f2-69-ad-fb-1b-c4-4e-ac-5a-1f-f7-b6-dd-a9-c4-e8-c8-99', 70 | 'yyyyy') 71 | const resolver = new HttpKeyResolver({ keyCache: keyCache }) 72 | const key = await resolver.getKey(keyId) 73 | expect(axios.get).toHaveBeenCalledWith(`https://key.smartthings.com${keyId}`) 74 | expect(JSON.parse(JSON.stringify(key))).toEqual(parsedCert.subjectKey) 75 | expect(keyCache.cacheTTL).toEqual(24 * 60 * 60 * 1000) 76 | }) 77 | 78 | it('HTTP resolver cache miss custom TTL', async () => { 79 | axios.get.mockImplementationOnce(() => Promise.resolve({ status: 200, data: cert })) 80 | const keyCache = new MockKeyCache( 81 | '/pl/useast2/1b-0d-f2-69-ad-fb-1b-c4-4e-ac-5a-1f-f7-b6-dd-a9-c4-e8-c8-99', 82 | 'yyyyy') 83 | const resolver = new HttpKeyResolver({ keyCache: keyCache, keyCacheTTL: 3600 * 1000 }) 84 | const key = await resolver.getKey(keyId) 85 | expect(axios.get).toHaveBeenCalledWith(`https://key.smartthings.com${keyId}`) 86 | expect(JSON.parse(JSON.stringify(key))).toEqual(parsedCert.subjectKey) 87 | expect(keyCache.cacheTTL).toEqual(60 * 60 * 1000) 88 | }) 89 | 90 | it('Verify authorized', async () => { 91 | httpSignature.parseRequest.mockImplementationOnce(() => verifyParsed) 92 | httpSignature.verifySignature.mockImplementationOnce(() => true) 93 | const keyCache = new MockKeyCache( 94 | '/pl/useast2/1b-0d-f2-69-ad-fb-1b-c4-4e-ac-5a-1f-f7-b6-dd-a9-c4-e8-c8-98', 95 | publicKey) 96 | const resolver = new HttpKeyResolver({ keyCache: keyCache }) 97 | const verifier = new SignatureVerifier(resolver) 98 | const authorized = await verifier.isAuthorized(verify) 99 | expect(authorized).toEqual(true) 100 | }) 101 | }) 102 | -------------------------------------------------------------------------------- /test/unit/hubdevices.test.ts: -------------------------------------------------------------------------------- 1 | import { NoOpAuthenticator } from '../../src/authenticator' 2 | import { HubdevicesEndpoint } from '../../src/endpoint/hubdevices' 3 | import { EndpointClient } from '../../src/endpoint-client' 4 | 5 | 6 | 7 | describe('HubdevicesEndpoint', () => { 8 | afterEach(() => { 9 | jest.clearAllMocks() 10 | }) 11 | 12 | const getSpy = jest.spyOn(EndpointClient.prototype, 'get').mockImplementation() 13 | const putSpy = jest.spyOn(EndpointClient.prototype, 'put').mockImplementation() 14 | const patchSpy = jest.spyOn(EndpointClient.prototype, 'patch').mockImplementation() 15 | const deleteSpy = jest.spyOn(EndpointClient.prototype, 'delete') 16 | 17 | const authenticator = new NoOpAuthenticator() 18 | const hubdevicesEndpoint = new HubdevicesEndpoint({ authenticator }) 19 | 20 | test('get', async () => { 21 | putSpy.mockImplementationOnce(() => Promise.resolve()) 22 | 23 | await expect(hubdevicesEndpoint.get('hub-id')).resolves.not.toThrow() 24 | 25 | expect(getSpy).toHaveBeenCalledTimes(1) 26 | expect(getSpy).toHaveBeenCalledWith('hub-id') 27 | }) 28 | 29 | test('getCharacteristics', async () => { 30 | putSpy.mockImplementationOnce(() => Promise.resolve()) 31 | 32 | await expect(hubdevicesEndpoint.getCharacteristics('hub-id')).resolves.not.toThrow() 33 | 34 | expect(getSpy).toHaveBeenCalledTimes(1) 35 | expect(getSpy).toHaveBeenCalledWith('hub-id/characteristics') 36 | }) 37 | 38 | test('installDriver', async () => { 39 | putSpy.mockImplementationOnce(() => Promise.resolve()) 40 | 41 | await expect(hubdevicesEndpoint.installDriver('driver-id', 'hub-id', 'channel-id')).resolves.not.toThrow() 42 | 43 | expect(putSpy).toHaveBeenCalledTimes(1) 44 | expect(putSpy).toHaveBeenCalledWith('hub-id/drivers/driver-id', { channelId: 'channel-id' }) 45 | }) 46 | 47 | describe('switchDriver', () => { 48 | it('calls patch with driver id', async () => { 49 | patchSpy.mockImplementationOnce(() => Promise.resolve()) 50 | 51 | await expect(hubdevicesEndpoint.switchDriver('driver-id', 'hub-id', 'device-id')) 52 | .resolves.not.toThrow() 53 | 54 | expect(patchSpy).toHaveBeenCalledTimes(1) 55 | expect(patchSpy).toHaveBeenCalledWith('hub-id/childdevice/device-id', 56 | { driverId: 'driver-id' }, undefined) 57 | }) 58 | 59 | it('includes forceUpdate query parameter when specified', async () => { 60 | patchSpy.mockImplementationOnce(() => Promise.resolve()) 61 | 62 | await expect(hubdevicesEndpoint.switchDriver('driver-id', 'hub-id', 'device-id', true)) 63 | .resolves.not.toThrow() 64 | 65 | expect(patchSpy).toHaveBeenCalledTimes(1) 66 | expect(patchSpy).toHaveBeenCalledWith('hub-id/childdevice/device-id', 67 | { driverId: 'driver-id' }, { forceUpdate: 'true' }) 68 | }) 69 | }) 70 | 71 | test('uninstallDriver', async () => { 72 | deleteSpy.mockImplementationOnce(() => Promise.resolve()) 73 | 74 | await expect(hubdevicesEndpoint.uninstallDriver('driver-id', 'hub-id')).resolves.not.toThrow() 75 | 76 | expect(deleteSpy).toHaveBeenCalledTimes(1) 77 | expect(deleteSpy).toHaveBeenCalledWith('hub-id/drivers/driver-id') 78 | }) 79 | 80 | describe('listInstalled', () => { 81 | it('allows for no device', async () => { 82 | getSpy.mockImplementationOnce(() => Promise.resolve()) 83 | 84 | await expect(hubdevicesEndpoint.listInstalled('hub-id')).resolves.not.toThrow() 85 | 86 | expect(getSpy).toHaveBeenCalledTimes(1) 87 | expect(getSpy).toHaveBeenCalledWith('hub-id/drivers', undefined) 88 | }) 89 | 90 | it('includes device when specified', async () => { 91 | getSpy.mockImplementationOnce(() => Promise.resolve()) 92 | 93 | await expect(hubdevicesEndpoint.listInstalled('hub-id', 'device-id')).resolves.not.toThrow() 94 | 95 | expect(getSpy).toHaveBeenCalledTimes(1) 96 | expect(getSpy).toHaveBeenCalledWith('hub-id/drivers', { deviceId: 'device-id' }) 97 | }) 98 | }) 99 | 100 | test('getInstalled', async () => { 101 | getSpy.mockImplementationOnce(() => Promise.resolve()) 102 | 103 | await expect(hubdevicesEndpoint.getInstalled('hub-id', 'driver-id')).resolves.not.toThrow() 104 | 105 | expect(getSpy).toHaveBeenCalledTimes(1) 106 | expect(getSpy).toHaveBeenCalledWith('hub-id/drivers/driver-id') 107 | }) 108 | 109 | test('enrolledChannels', async () => { 110 | getSpy.mockImplementationOnce(() => Promise.resolve()) 111 | 112 | await expect(hubdevicesEndpoint.enrolledChannels('hub-id')).resolves.not.toThrow() 113 | 114 | expect(getSpy).toHaveBeenCalledTimes(1) 115 | expect(getSpy).toHaveBeenCalledWith('hub-id/channels', { channelType: 'DRIVERS' }) 116 | }) 117 | }) 118 | -------------------------------------------------------------------------------- /test/unit/virtualdevices.test.ts: -------------------------------------------------------------------------------- 1 | import { Device, DeviceEvent } from '../../src/endpoint/devices' 2 | import { 3 | VirtualDeviceCreateRequest, 4 | VirtualDeviceStandardCreateRequest, 5 | VirtualDevicesEndpoint, 6 | } from '../../src/endpoint/virtualdevices' 7 | import { BearerTokenAuthenticator } from '../../src/authenticator' 8 | import { EndpointClient } from '../../src/endpoint-client' 9 | 10 | 11 | const authenticator = new BearerTokenAuthenticator('00000000-0000-0000-0000-000000000000') 12 | 13 | describe('VirtualDevicesEndpoint', () => { 14 | afterEach(() => { 15 | jest.clearAllMocks() 16 | }) 17 | 18 | const postSpy = jest.spyOn(EndpointClient.prototype, 'post').mockImplementation() 19 | const getPagedItemsSpy = jest.spyOn(EndpointClient.prototype, 'getPagedItems').mockImplementation() 20 | 21 | const virtualDevicesEndpoint = new VirtualDevicesEndpoint({ authenticator }) 22 | 23 | const deviceList = [{ listed: 'device' }] as unknown as Device[] 24 | 25 | describe('list', () => { 26 | getPagedItemsSpy.mockResolvedValue(deviceList) 27 | 28 | it('works without options', async () => { 29 | expect(await virtualDevicesEndpoint.list()).toBe(deviceList) 30 | 31 | expect(getPagedItemsSpy).toHaveBeenCalledTimes(1) 32 | expect(getPagedItemsSpy).toHaveBeenCalledWith(undefined, {}) 33 | }) 34 | 35 | it('includes configured locationId', async () => { 36 | const devices = new VirtualDevicesEndpoint({ authenticator, locationId: 'configured-location-id' }) 37 | expect(await devices.list()).toBe(deviceList) 38 | 39 | expect(getPagedItemsSpy).toHaveBeenCalledTimes(1) 40 | expect(getPagedItemsSpy).toHaveBeenCalledWith(undefined, { locationId: 'configured-location-id' }) 41 | }) 42 | 43 | it('include wanted locationId', async () => { 44 | expect(await virtualDevicesEndpoint.list({ locationId: 'wanted-locationId' })).toBe(deviceList) 45 | 46 | expect(getPagedItemsSpy).toHaveBeenCalledTimes(1) 47 | expect(getPagedItemsSpy).toHaveBeenCalledWith(undefined, { locationId: 'wanted-locationId' }) 48 | }) 49 | }) 50 | 51 | describe('create', () => { 52 | it('creates from device profile ID', async () => { 53 | const device = { new: 'device' } 54 | postSpy.mockResolvedValueOnce(device) 55 | 56 | const deviceCreate: VirtualDeviceCreateRequest = { 57 | owner: { 58 | ownerId: 'owner-id', 59 | ownerType: 'LOCATION', 60 | }, 61 | name: 'Living room light', 62 | roomId: 'room-id', 63 | deviceProfileId: 'profile-id', 64 | } 65 | 66 | const expectedData = deviceCreate 67 | 68 | expect(await virtualDevicesEndpoint.create(deviceCreate)).toBe(device) 69 | 70 | expect(postSpy).toHaveBeenCalledTimes(1) 71 | expect(postSpy).toHaveBeenCalledWith('', expectedData) 72 | }) 73 | 74 | it('creates from device profile definition', async () => { 75 | const device = { new: 'device' } 76 | postSpy.mockResolvedValueOnce(device) 77 | 78 | const deviceCreate: VirtualDeviceCreateRequest = { 79 | owner: { 80 | ownerId: 'owner-id', 81 | ownerType: 'LOCATION', 82 | }, 83 | name: 'Living room light', 84 | roomId: 'room-id', 85 | deviceProfile: { 86 | 'components': [ 87 | { 88 | 'id': 'main', 89 | 'capabilities': [ 90 | { 91 | 'id': 'switch', 92 | 'version': 1, 93 | }, 94 | ], 95 | 'categories': [], 96 | }, 97 | ], 98 | }, 99 | } 100 | 101 | const expectedData = deviceCreate 102 | 103 | expect(await virtualDevicesEndpoint.create(deviceCreate)).toBe(device) 104 | 105 | expect(postSpy).toHaveBeenCalledTimes(1) 106 | expect(postSpy).toHaveBeenCalledWith('', expectedData) 107 | }) 108 | }) 109 | 110 | describe('createStandard', () => { 111 | it('creates from prototype', async () => { 112 | const device = { new: 'device' } 113 | postSpy.mockResolvedValueOnce(device) 114 | 115 | const deviceCreate: VirtualDeviceStandardCreateRequest = { 116 | owner: { 117 | ownerId: 'owner-id', 118 | ownerType: 'LOCATION', 119 | }, 120 | name: 'Living room light', 121 | roomId: 'room-id', 122 | prototype: 'SWITCH', 123 | } 124 | 125 | const expectedData = deviceCreate 126 | 127 | expect(await virtualDevicesEndpoint.createStandard(deviceCreate)).toBe(device) 128 | 129 | expect(postSpy).toHaveBeenCalledTimes(1) 130 | expect(postSpy).toHaveBeenCalledWith('prototypes', expectedData) 131 | }) 132 | }) 133 | 134 | test('createEvents', async () => { 135 | const events: DeviceEvent[] = [{ component: 'main' } as DeviceEvent] 136 | postSpy.mockResolvedValueOnce([true]) 137 | 138 | expect(await virtualDevicesEndpoint.createEvents('device-id', events)).toStrictEqual([true]) 139 | 140 | expect(postSpy).toHaveBeenCalledTimes(1) 141 | expect(postSpy).toHaveBeenCalledWith('device-id/events', { deviceEvents: events }) 142 | }) 143 | }) 144 | -------------------------------------------------------------------------------- /src/authenticator.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosResponse, AxiosRequestConfig } from 'axios' 2 | import { MutexInterface } from 'async-mutex' 3 | 4 | import { EndpointClientConfig, HttpClientHeaders } from './endpoint-client' 5 | 6 | 7 | /** 8 | * Implement this interface to implement a process for handling authentication. 9 | * 10 | * This is not meant to be a "service" in the traditional sense because 11 | * implementors are not expected to be stateless. 12 | */ 13 | export interface Authenticator { 14 | login?(): Promise 15 | logout?(): Promise 16 | refresh?(clientConfig: EndpointClientConfig): Promise 17 | acquireRefreshMutex?(): Promise 18 | 19 | /** 20 | * Performs required authentication steps and returns credentials as a set of HTTP headers which 21 | * must be included in authenticated requests. 22 | * Expected to perform any required steps (such as token refresh) needed to return valid credentials. 23 | * 24 | * @returns {string} valid auth token 25 | */ 26 | authenticate(): Promise 27 | } 28 | 29 | 30 | /** 31 | * For use in tests or on endpoints that don't need any authentication. 32 | */ 33 | export class NoOpAuthenticator implements Authenticator { 34 | authenticate(): Promise { 35 | return Promise.resolve({}) 36 | } 37 | } 38 | 39 | 40 | /** 41 | * A simple bearer token authenticator that knows nothing about refreshing 42 | * or logging in our out. If the token is expired, it simply won't work. 43 | */ 44 | export class BearerTokenAuthenticator implements Authenticator { 45 | constructor(public token: string) { 46 | // simple 47 | } 48 | 49 | authenticate(): Promise { 50 | /* 51 | return Promise.resolve({ 52 | ...requestConfig, 53 | headers: { 54 | ...requestConfig.headers, 55 | Authorization: `Bearer ${this.token}`, 56 | }, 57 | }) 58 | */ 59 | return Promise.resolve({ Authorization: `Bearer ${this.token}` }) 60 | } 61 | } 62 | 63 | export interface AuthData { 64 | authToken: string 65 | refreshToken: string 66 | } 67 | 68 | export interface RefreshData { 69 | refreshToken: string 70 | clientId: string 71 | clientSecret: string 72 | } 73 | 74 | export interface RefreshTokenStore { 75 | getRefreshData(): Promise 76 | putAuthData(data: AuthData): Promise 77 | } 78 | 79 | /** 80 | * An authenticator that supports refreshing of the access token using a refresh token by loading 81 | * the refresh token, client ID, and client secret from a token store, performing the refresh, and 82 | * storing the new tokens. 83 | * 84 | * Note that corruption of the refresh token is unlikely but possible if two of the same 85 | * authenticators refresh the same token at the same time. 86 | */ 87 | export class RefreshTokenAuthenticator implements Authenticator { 88 | constructor(public token: string, private tokenStore: RefreshTokenStore) { 89 | // simple 90 | } 91 | 92 | authenticate(): Promise { 93 | return Promise.resolve({ Authorization: `Bearer ${this.token}` }) 94 | } 95 | 96 | async refresh(clientConfig: EndpointClientConfig): Promise { 97 | const refreshData: RefreshData = await this.tokenStore.getRefreshData() 98 | const headers = { 99 | 'Content-Type': 'application/x-www-form-urlencoded', 100 | 'Authorization': 'Basic ' + Buffer.from(`${refreshData.clientId}:${refreshData.clientSecret}`, 'ascii').toString('base64'), 101 | 'Accept': 'application/json', 102 | } 103 | 104 | const axiosConfig: AxiosRequestConfig = { 105 | url: clientConfig.urlProvider?.authURL, 106 | method: 'POST', 107 | headers, 108 | data: `grant_type=refresh_token&client_id=${refreshData.clientId}&refresh_token=${refreshData.refreshToken}`, 109 | } 110 | 111 | const response: AxiosResponse = await axios.request(axiosConfig) 112 | if (response.status > 199 && response.status < 300) { 113 | const authData: AuthData = { 114 | authToken: response.data.access_token, 115 | refreshToken: response.data.refresh_token, 116 | } 117 | this.token = authData.authToken 118 | await this.tokenStore.putAuthData(authData) 119 | return { Authorization: `Bearer ${this.token}` } 120 | } 121 | 122 | throw Error(`error ${response.status} refreshing token, with message ${response.data}`) 123 | } 124 | } 125 | 126 | /** 127 | * A an authenticator that works like RefreshTokenAuthenticator but which can use a mutex to help 128 | * prevent corruption of the refresh token. 129 | * 130 | * Note that while `acquireRefreshMutex` is provided for you to use the mutex, the mutex is not 131 | * automatically used. 132 | */ 133 | export class SequentialRefreshTokenAuthenticator extends RefreshTokenAuthenticator { 134 | constructor(token: string, tokenStore: RefreshTokenStore, private refreshMutex: MutexInterface) { 135 | super(token, tokenStore) 136 | } 137 | 138 | acquireRefreshMutex(): Promise { 139 | return this.refreshMutex.acquire() 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /test/unit/rooms.test.ts: -------------------------------------------------------------------------------- 1 | import axios from '../../__mocks__/axios' 2 | import { 3 | BearerTokenAuthenticator, 4 | SmartThingsClient, 5 | Device, 6 | Room, 7 | SuccessStatusValue, Status, 8 | } from '../../src' 9 | import { expectedRequest } from './helpers/utils' 10 | import { 11 | get_locations_95efee9b_6073_4871_b5ba_de6642187293_rooms as list, 12 | get_locations_b4db3e54_14f3_4bf4_b217_b8583757d446_rooms as listExplicit, 13 | get_locations_95efee9b_6073_4871_b5ba_de6642187293_rooms_717ce958 as get, 14 | get_locations_b4db3e54_14f3_4bf4_b217_b8583757d446_rooms_717ce958 as explicitGet, 15 | get_locations_95efee9b_6073_4871_b5ba_de6642187293_rooms_717ce958_devices as listDevices, 16 | } from './data/rooms/get' 17 | import { 18 | post_locations_95efee9b_6073_4871_b5ba_de6642187293_rooms as create, 19 | } from './data/rooms/post' 20 | import { 21 | put_locations_95efee9b_6073_4871_b5ba_de6642187293_rooms_f32f1b48 as update, 22 | } from './data/rooms/put' 23 | import { 24 | delete_locations_95efee9b_6073_4871_b5ba_de6642187293_rooms as deleteRoom, 25 | delete_locations_b4db3e54_14f3_4bf4_b217_b8583757d446_rooms as deleteRoomExplicit, 26 | } from './data/rooms/delete' 27 | 28 | 29 | const authenticator = new BearerTokenAuthenticator('00000000-0000-0000-0000-000000000000') 30 | const client = new SmartThingsClient(authenticator, { locationId: '95efee9b-6073-4871-b5ba-de6642187293' }) 31 | 32 | describe('Rooms', () => { 33 | afterEach(() => { 34 | axios.request.mockReset() 35 | }) 36 | 37 | it('list', async () => { 38 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: list.response })) 39 | const response: Room[] = await client.rooms.list() 40 | expect(axios.request).toHaveBeenCalledTimes(1) 41 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(list.request)) 42 | expect(response).toBe(list.response.items) 43 | }) 44 | 45 | it('list explicit', async () => { 46 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: listExplicit.response })) 47 | const response: Room[] = await client.rooms.list('b4db3e54-14f3-4bf4-b217-b8583757d446') 48 | expect(axios.request).toHaveBeenCalledTimes(1) 49 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(listExplicit.request)) 50 | expect(response).toBe(listExplicit.response.items) 51 | }) 52 | 53 | it('get', async () => { 54 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: get.response })) 55 | const response: Room = await client.rooms.get('717ce958-49c6-4448-8544-fa2da2e7592b') 56 | expect(axios.request).toHaveBeenCalledTimes(1) 57 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(get.request)) 58 | expect(response).toBe(get.response) 59 | }) 60 | 61 | it('get explicit', async () => { 62 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: explicitGet.response })) 63 | const response: Room = await client.rooms.get( 64 | '717ce958-49c6-4448-8544-fa2da2e7592b', 65 | 'b4db3e54-14f3-4bf4-b217-b8583757d446') 66 | expect(axios.request).toHaveBeenCalledTimes(1) 67 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(explicitGet.request)) 68 | expect(response).toBe(explicitGet.response) 69 | }) 70 | 71 | it('list devices', async () => { 72 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: listDevices.response })) 73 | const response: Device[] = await client.rooms.listDevices('717ce958-49c6-4448-8544-fa2da2e7592b') 74 | expect(axios.request).toHaveBeenCalledTimes(1) 75 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(listDevices.request)) 76 | expect(response).toBe(listDevices.response.items) 77 | }) 78 | 79 | it('create', async () => { 80 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: create.response })) 81 | const response: Room = await client.rooms.create({ 82 | 'name': 'Test Room', 83 | }) 84 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(create.request)) 85 | expect(response).toBe(create.response) 86 | }) 87 | 88 | it('update', async () => { 89 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: update.response })) 90 | const response: Room = await client.rooms.update('f32f1b48-58ab-441b-8240-10860cc52618', { 91 | 'name': 'Test Room Renamed', 92 | }) 93 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(update.request)) 94 | expect(response).toBe(update.response) 95 | }) 96 | 97 | it('delete', async () => { 98 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: deleteRoom.response })) 99 | const response: Status = await client.rooms.delete('f32f1b48-58ab-441b-8240-10860cc52618') 100 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(deleteRoom.request)) 101 | expect(response).toEqual(SuccessStatusValue) 102 | }) 103 | 104 | it('delete explicit', async () => { 105 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: deleteRoomExplicit.response })) 106 | const response: Status = await client.rooms.delete( 107 | 'f32f1b48-58ab-441b-8240-10860cc52618', 108 | 'b4db3e54-14f3-4bf4-b217-b8583757d446') 109 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(deleteRoomExplicit.request)) 110 | expect(response).toEqual(SuccessStatusValue) 111 | }) 112 | }) 113 | -------------------------------------------------------------------------------- /src/endpoint/history.ts: -------------------------------------------------------------------------------- 1 | import { Endpoint } from '../endpoint' 2 | import { EndpointClient, EndpointClientConfig, HttpClientParams } from '../endpoint-client' 3 | import { PaginatedList } from '../pagination' 4 | 5 | 6 | export interface DeviceActivity { 7 | /** Device ID */ 8 | deviceId: string 9 | 10 | /** Device nick name */ 11 | deviceName: string 12 | 13 | /** Location ID */ 14 | locationId: string 15 | 16 | /** Location name */ 17 | locationName: string 18 | 19 | /** The IS0-8601 date time strings in UTC of the activity */ 20 | time: string 21 | 22 | /** Translated human readable string (localized) */ 23 | text: string 24 | 25 | /** device component ID. Not nullable. */ 26 | component: string 27 | 28 | /** device component label. Nullable. */ 29 | componentLabel?: string 30 | 31 | /** capability name */ 32 | capability: string 33 | 34 | /** attribute name */ 35 | attribute: string 36 | 37 | /** attribute value */ 38 | value: object 39 | unit?: string 40 | data?: Record 41 | 42 | /** translated attribute name based on 'Accept-Language' requested in header */ 43 | translatedAttributeName?: string 44 | 45 | /** translated attribute value based on 'Accept-Language' requested in header */ 46 | translatedAttributeValue?: string 47 | 48 | /** UNIX epoch in milliseconds of the activity time */ 49 | epoch: number 50 | 51 | /** Hash to differentiate two events with the same epoch value */ 52 | hash: number 53 | } 54 | 55 | export interface PaginationRequest { 56 | /** 57 | * Paging parameter for going to previous page. Before epoch time (millisecond). 58 | * Return the nearest records (the immediate previous page) with event time before the specified value exclusively. e.g. 1511913638679. Note: type is a long. 59 | * 60 | */ 61 | before?: number 62 | 63 | /** 64 | * Paging parameter for going to previous page. Before Hash (long). This needs to be specified when 'before' is specified. 65 | * Please put in associated hash value of the record specified by the 'before' parameter. 66 | * 67 | */ 68 | beforeHash?: number 69 | 70 | /** 71 | * Paging parameter for going to next page. After epoch time (millisecond). 72 | * Return the nearest records (the immediate next page) with event time after the specified value exclusively. e.g. 1511913638679. Note: type is a long. 73 | * 74 | */ 75 | after?: number 76 | 77 | /** 78 | * Paging parameter for going to next page. After Hash (long). this needs to be specified when 'after' is specified. 79 | * Please put in associated hash value of the record specified by the 'after' parameter. 80 | * 81 | */ 82 | afterHash?: number 83 | 84 | /** 85 | * Maximum number of events to return. Defaults to 20 86 | */ 87 | limit?: number 88 | } 89 | 90 | export type DeviceHistoryRequest = PaginationRequest & { 91 | locationId?: string | string[] 92 | deviceId?: string | string[] 93 | oldestFirst?: boolean 94 | } 95 | 96 | export class HistoryEndpoint extends Endpoint { 97 | constructor(config: EndpointClientConfig) { 98 | super(new EndpointClient('history', config)) 99 | } 100 | 101 | /** 102 | * Queries for device events. Returns an object that supports explicit paging with next() and previous() as well 103 | * as asynchronous iteration. 104 | * 105 | * Explicit paging: 106 | * ``` 107 | * const result = await client.history.devices({deviceId: 'c8fc80fc-6bbb-4b74-a9fa-97acc3d5fa01'}) 108 | * for (const item of result.items) { 109 | * console.log(`${item.name} = ${item.value}`) 110 | * } 111 | * while (await next()) { 112 | * for (const item of result.items) { 113 | * console.log(`${item.name} = ${item.value}`) 114 | * } 115 | * } 116 | * ``` 117 | * 118 | * Asynchronous iteration 119 | * ``` 120 | * for await (const item of client.history.devices({deviceId: 'c8fc80fc-6bbb-4b74-a9fa-97acc3d5fa01'}) { 121 | * console.log(`${item.name} = ${item.value}`) 122 | * } 123 | * ``` 124 | * 125 | * @param options query options -- deviceId, limit, before, beforeHash, after, afterHash, oldestFirst, and 126 | * locationId. 127 | */ 128 | public async devices(options: DeviceHistoryRequest = {}): Promise> { 129 | const params: HttpClientParams = {} 130 | if ('locationId' in options && options.locationId) { 131 | params.locationId = options.locationId 132 | } else if (this.client.config.locationId) { 133 | params.locationId = this.client.config.locationId 134 | } else { 135 | throw new Error('Location ID is undefined') 136 | } 137 | if ('deviceId' in options && options.deviceId) { 138 | params.deviceId = options.deviceId 139 | } 140 | if ('limit' in options && options.limit) { 141 | params.limit = options.limit 142 | } 143 | if ('before' in options && options.before) { 144 | params.pagingBeforeEpoch = options.before 145 | } 146 | if ('beforeHash' in options && options.beforeHash) { 147 | params.pagingBeforeHash = options.beforeHash 148 | } 149 | if ('after' in options && options.after) { 150 | params.pagingAfterEpoch = options.after 151 | } 152 | if ('afterHash' in options && options.afterHash) { 153 | params.pagingAfterHash = options.afterHash 154 | } 155 | if ('oldestFirst' in options) { 156 | params.oldestFirst = `${options.oldestFirst}` 157 | } 158 | 159 | return new PaginatedList( 160 | await this.client.get>('devices', params), 161 | this.client) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /test/unit/apps.test.ts: -------------------------------------------------------------------------------- 1 | import { NoOpAuthenticator } from '../../src/authenticator' 2 | import { EndpointClient } from '../../src/endpoint-client' 3 | import { AppClassification, AppCreateRequest, AppCreationResponse, AppOAuthRequest, AppOAuthResponse, AppResponse, AppsEndpoint, AppType, GenerateAppOAuthRequest, GenerateAppOAuthResponse, PagedApp, SignatureType } from '../../src/endpoint/apps' 4 | 5 | 6 | const MOCK_APP_LIST = [{ appId: 'appId' }] as PagedApp[] 7 | const MOCK_APP = { appId: 'appId', appType: AppType.WEBHOOK_SMART_APP } as AppResponse 8 | const MOCK_APP_CREATE = { app: {} } as AppCreationResponse 9 | const MOCK_APP_OAUTH = { clientName: 'clientName' } as AppOAuthResponse 10 | const MOCK_APP_OAUTH_GENERATE = { oauthClientId: 'oauthClientId' } as GenerateAppOAuthResponse 11 | 12 | describe('AppsEndpoint', () => { 13 | const authenticator = new NoOpAuthenticator() 14 | const apps = new AppsEndpoint({ authenticator }) 15 | 16 | const getSpy = jest.spyOn(EndpointClient.prototype, 'get') 17 | const getPagedItemsSpy = jest.spyOn(EndpointClient.prototype, 'getPagedItems') 18 | const postSpy = jest.spyOn(EndpointClient.prototype, 'post') 19 | const putSpy = jest.spyOn(EndpointClient.prototype, 'put') 20 | const deleteSpy = jest.spyOn(EndpointClient.prototype, 'delete') 21 | 22 | afterEach(() => { 23 | jest.clearAllMocks() 24 | }) 25 | 26 | test('List', async () => { 27 | getPagedItemsSpy.mockResolvedValueOnce(MOCK_APP_LIST) 28 | const response = await apps.list() 29 | 30 | expect(getPagedItemsSpy).toBeCalledWith(undefined, {}) 31 | expect(response).toStrictEqual(MOCK_APP_LIST) 32 | }) 33 | 34 | test('List Automations', async () => { 35 | getPagedItemsSpy.mockResolvedValueOnce(MOCK_APP_LIST) 36 | const response = await apps.list({ classification: AppClassification.AUTOMATION }) 37 | 38 | expect(getPagedItemsSpy).toBeCalledWith(undefined, { classification: 'AUTOMATION' }) 39 | expect(response).toStrictEqual(MOCK_APP_LIST) 40 | }) 41 | 42 | test('List Webhooks', async () => { 43 | getPagedItemsSpy.mockResolvedValueOnce(MOCK_APP_LIST) 44 | const response = await apps.list({ appType: AppType.WEBHOOK_SMART_APP }) 45 | 46 | expect(getPagedItemsSpy).toBeCalledWith(undefined, { appType: 'WEBHOOK_SMART_APP' }) 47 | expect(response).toStrictEqual(MOCK_APP_LIST) 48 | }) 49 | 50 | test('List Lambda Automations', async () => { 51 | getPagedItemsSpy.mockResolvedValueOnce(MOCK_APP_LIST) 52 | const response = await apps.list({ appType: AppType.LAMBDA_SMART_APP, classification: AppClassification.AUTOMATION }) 53 | 54 | expect(getPagedItemsSpy).toBeCalledWith(undefined, { appType: 'LAMBDA_SMART_APP', classification: 'AUTOMATION' }) 55 | expect(response).toStrictEqual(MOCK_APP_LIST) 56 | 57 | }) 58 | 59 | test('List Tags', async () => { 60 | getPagedItemsSpy.mockResolvedValueOnce(MOCK_APP_LIST) 61 | const response = await apps.list({ tag: { industry: 'energy', region: 'North America' } }) 62 | 63 | expect(getPagedItemsSpy).toBeCalledWith(undefined, { 'tag:industry': 'energy', 'tag:region': 'North America' }) 64 | expect(response).toStrictEqual(MOCK_APP_LIST) 65 | }) 66 | 67 | test('Get', async () => { 68 | getSpy.mockResolvedValueOnce(MOCK_APP) 69 | const response = await apps.get('appName') 70 | 71 | expect(getSpy).toBeCalledWith('appName') 72 | expect(response).toStrictEqual(MOCK_APP) 73 | }) 74 | 75 | test('Create', async () => { 76 | postSpy.mockResolvedValueOnce(MOCK_APP_CREATE) 77 | const createRequest = { appName: 'app' } as AppCreateRequest 78 | const response = await apps.create(createRequest) 79 | 80 | expect(postSpy).toBeCalledWith(undefined, createRequest, {}) 81 | expect(response).toStrictEqual(MOCK_APP_CREATE) 82 | }) 83 | 84 | test('Update signature type', async () => { 85 | putSpy.mockResolvedValueOnce({}) 86 | 87 | await expect(apps.updateSignatureType('appId', SignatureType.ST_PADLOCK)).resolves.toBeUndefined() 88 | expect(putSpy).toBeCalledWith('appId/signature-type', { signatureType: 'ST_PADLOCK' }) 89 | }) 90 | 91 | test('Register', async () => { 92 | putSpy.mockResolvedValueOnce({}) 93 | 94 | await expect(apps.register('appId')).resolves.toBeUndefined() 95 | expect(putSpy).toBeCalledWith('appId/register') 96 | }) 97 | 98 | test('Update OAuth', async () => { 99 | putSpy.mockResolvedValueOnce(MOCK_APP_OAUTH) 100 | const oauthRequest = { redirectUris: [] } as unknown as AppOAuthRequest 101 | 102 | const response = await apps.updateOauth('appId', oauthRequest) 103 | 104 | expect(putSpy).toBeCalledWith('appId/oauth', oauthRequest) 105 | expect(response).toStrictEqual(MOCK_APP_OAUTH) 106 | }) 107 | 108 | test('Regenerate OAuth', async () => { 109 | postSpy.mockResolvedValueOnce(MOCK_APP_OAUTH_GENERATE) 110 | const regenerateRequest = { clientName: 'clientName' } as GenerateAppOAuthRequest 111 | 112 | const response = await apps.regenerateOauth('appId', regenerateRequest) 113 | 114 | expect(postSpy).toBeCalledWith('appId/oauth/generate', regenerateRequest) 115 | expect(response).toStrictEqual(MOCK_APP_OAUTH_GENERATE) 116 | }) 117 | 118 | test('Delete', async () => { 119 | deleteSpy.mockResolvedValueOnce({}) 120 | 121 | await expect(apps.delete('appId')).resolves.toBeUndefined() 122 | expect(deleteSpy).toBeCalledWith('appId') 123 | }) 124 | 125 | test('Delete Error', async () => { 126 | const error = new Error('failed') 127 | deleteSpy.mockRejectedValueOnce(error) 128 | 129 | await expect(apps.delete('appId')).rejects.toThrow(error) 130 | }) 131 | }) 132 | -------------------------------------------------------------------------------- /test/unit/schema.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SmartThingsClient, 3 | SchemaApp, 4 | SchemaAppRequest, 5 | Status, 6 | SuccessStatusValue, 7 | InstalledSchemaApp, 8 | EndpointClient, 9 | SchemaCreateResponse, 10 | NoOpAuthenticator, 11 | } from '../../src' 12 | 13 | 14 | const client = new SmartThingsClient(new NoOpAuthenticator()) 15 | 16 | describe('Schema', () => { 17 | const getSpy = jest.spyOn(EndpointClient.prototype, 'get') 18 | const deleteSpy = jest.spyOn(EndpointClient.prototype, 'delete') 19 | const postSpy = jest.spyOn(EndpointClient.prototype, 'post') 20 | const putSpy = jest.spyOn(EndpointClient.prototype, 'put') 21 | 22 | afterEach(() => { 23 | jest.clearAllMocks() 24 | }) 25 | 26 | it('List', async () => { 27 | const list = [{ endpointAppId: 'endpoint_app_id' }] as SchemaApp[] 28 | getSpy.mockResolvedValueOnce({ endpointApps: list }) 29 | const response = await client.schema.list() 30 | expect(getSpy).toHaveBeenCalledWith('apps') 31 | expect(response).toStrictEqual(list) 32 | }) 33 | 34 | it('Get app', async () => { 35 | const app = { endpointAppId: 'endpoint_app_id' } 36 | getSpy.mockResolvedValueOnce(app) 37 | const response = await client.schema.get('endpoint_app_id') 38 | expect(getSpy).toHaveBeenCalledWith('apps/endpoint_app_id') 39 | expect(response).toBe(app) 40 | }) 41 | 42 | it('Create app', async () => { 43 | const app = { appName: 'Test app' } as SchemaAppRequest 44 | postSpy.mockResolvedValueOnce(app) 45 | 46 | const response = await client.schema.create(app) 47 | 48 | expect(postSpy).toHaveBeenCalledWith('apps', app, undefined, undefined) 49 | expect(response).toStrictEqual(app) 50 | }) 51 | 52 | it('Create app with organization', async () => { 53 | const app = { appName: 'Test app' } as SchemaAppRequest 54 | postSpy.mockResolvedValueOnce(app) 55 | 56 | const response = await client.schema.create(app, 'organization-id') 57 | 58 | expect(postSpy).toHaveBeenCalledWith( 59 | 'apps', 60 | app, 61 | undefined, 62 | { headerOverrides: { 'X-ST-Organization': 'organization-id' } }, 63 | ) 64 | expect(response).toStrictEqual(app) 65 | }) 66 | 67 | it('Update app', async () => { 68 | const app = { appName: 'Test app (modified)' } as SchemaAppRequest 69 | const id = 'schema-app-id' 70 | putSpy.mockResolvedValueOnce(app) 71 | 72 | const response: Status = await client.schema.update(id, app as SchemaAppRequest) 73 | 74 | expect(putSpy).toHaveBeenCalledWith(`apps/${id}`, app, undefined, undefined) 75 | expect(response).toEqual(SuccessStatusValue) 76 | }) 77 | 78 | it('Update app with organization', async () => { 79 | const app = { appName: 'Test app (modified)' } as SchemaAppRequest 80 | const id = 'schema-app-id' 81 | putSpy.mockResolvedValueOnce(app) 82 | 83 | const response: Status = await client.schema.update(id, app as SchemaAppRequest, 'organization-id') 84 | 85 | expect(putSpy).toHaveBeenCalledWith( 86 | `apps/${id}`, 87 | app, undefined, 88 | { headerOverrides: { 'X-ST-Organization': 'organization-id' } }, 89 | ) 90 | expect(response).toEqual(SuccessStatusValue) 91 | }) 92 | 93 | it('Regenerate OAuth', async () => { 94 | const app = { endpointAppId: 'schema-app-id', stClientId: 'xxx', stClientSecret: 'yyy' } as SchemaCreateResponse 95 | postSpy.mockResolvedValueOnce(app) 96 | const response = await client.schema.regenerateOauth('schema-app-id') 97 | expect(postSpy).toHaveBeenCalledWith('oauth/stclient/credentials', { endpointAppId: 'schema-app-id' }) 98 | expect(response).toStrictEqual(app) 99 | }) 100 | 101 | it('Get page', async () => { 102 | const page = { pageType: 'requiresLogin' } 103 | getSpy.mockResolvedValueOnce(page) 104 | const response = await client.schema.getPage('schema-app-id', 'location_id') 105 | expect(getSpy).toHaveBeenCalledWith('install/schema-app-id?locationId=location_id&type=oauthLink') 106 | expect(response).toStrictEqual(page) 107 | }) 108 | 109 | it('List installed apps', async () => { 110 | const list = [{ isaId: 'isa_id' }] 111 | getSpy.mockResolvedValueOnce({ installedSmartApps: list }) 112 | const response = await client.schema.installedApps('location_id') 113 | expect(getSpy).toHaveBeenCalledWith('installedapps/location/location_id') 114 | expect(response).toStrictEqual(list) 115 | }) 116 | 117 | it('List installed apps empty', async () => { 118 | const list: InstalledSchemaApp[] = [] 119 | getSpy.mockResolvedValueOnce(undefined) 120 | const response = await client.schema.installedApps('location_id') 121 | expect(getSpy).toHaveBeenCalledWith('installedapps/location/location_id') 122 | expect(response).toStrictEqual(list) 123 | }) 124 | 125 | 126 | it('Get installed app', async () => { 127 | const app = { isaId: 'isa_id' } 128 | getSpy.mockResolvedValueOnce(app) 129 | const response = await client.schema.getInstalledApp('isa_id') 130 | expect(getSpy).toHaveBeenCalledWith('installedapps/isa_id') 131 | expect(response).toStrictEqual(app) 132 | }) 133 | 134 | it('Delete installed app', async () => { 135 | deleteSpy.mockResolvedValueOnce(undefined) 136 | const response = await client.schema.deleteInstalledApp('isa_id') 137 | expect(deleteSpy).toHaveBeenCalledWith('installedapps/isa_id') 138 | expect(response).toEqual(SuccessStatusValue) 139 | }) 140 | 141 | it('Delete app', async () => { 142 | deleteSpy.mockResolvedValueOnce(undefined) 143 | const response = await client.schema.delete('endpoint_app_id') 144 | expect(deleteSpy).toHaveBeenCalledWith('apps/endpoint_app_id') 145 | expect(response).toEqual(SuccessStatusValue) 146 | }) 147 | 148 | }) 149 | -------------------------------------------------------------------------------- /test/unit/modes.test.ts: -------------------------------------------------------------------------------- 1 | import axios from '../../__mocks__/axios' 2 | import { 3 | BearerTokenAuthenticator, 4 | SmartThingsClient, 5 | Mode, 6 | SuccessStatusValue, Status, 7 | } from '../../src' 8 | import { expectedRequest } from './helpers/utils' 9 | import { 10 | get_locations_95efee9b_6073_4871_b5ba_de6642187293_modes as list, 11 | get_locations_b4db3e54_14f3_4bf4_b217_b8583757d446_modes as listExplicit, 12 | get_locations_95efee9b_6073_4871_b5ba_de6642187293_modes_current as getCurrent, 13 | get_locations_b4db3e54_14f3_4bf4_b217_b8583757d446_modes_current as explicitGet, 14 | } from './data/modes/get' 15 | import { 16 | post_locations_95efee9b_6073_4871_b5ba_de6642187293_modes as create, 17 | } from './data/modes/post' 18 | import { 19 | put_locations_95efee9b_6073_4871_b5ba_de6642187293_modes as update, 20 | put_locations_95efee9b_6073_4871_b5ba_de6642187293_modes_current as setCurrent, 21 | } from './data/modes/put' 22 | import { 23 | delete_locations_95efee9b_6073_4871_b5ba_de6642187293_modes as deleteMode, 24 | delete_locations_b4db3e54_14f3_4bf4_b217_b8583757d446_modes as deleteModeExplicit, 25 | } from './data/modes/delete' 26 | 27 | 28 | const authenticator = new BearerTokenAuthenticator('00000000-0000-0000-0000-000000000000') 29 | const client = new SmartThingsClient(authenticator, { locationId: '95efee9b-6073-4871-b5ba-de6642187293' }) 30 | 31 | describe('Modes', () => { 32 | afterEach(() => { 33 | axios.request.mockReset() 34 | }) 35 | 36 | it('list', async () => { 37 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: list.response })) 38 | const response: Mode[] = await client.modes.list() 39 | expect(axios.request).toHaveBeenCalledTimes(1) 40 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(list.request)) 41 | expect(response).toBe(list.response.items) 42 | }) 43 | 44 | it('list explicit', async () => { 45 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: listExplicit.response })) 46 | const response: Mode[] = await client.modes.list('b4db3e54-14f3-4bf4-b217-b8583757d446') 47 | expect(axios.request).toHaveBeenCalledTimes(1) 48 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(listExplicit.request)) 49 | expect(response).toBe(listExplicit.response.items) 50 | }) 51 | 52 | it('get', async () => { 53 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: list.response })) 54 | const response: Mode = await client.modes.get('ab7d4dc0-c0de-4276-a5dc-6b3a230f1bc7') 55 | expect(axios.request).toHaveBeenCalledTimes(1) 56 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(list.request)) 57 | expect(response).toEqual({ 58 | 'id': 'ab7d4dc0-c0de-4276-a5dc-6b3a230f1bc7', 59 | 'label': 'Home', 60 | 'name': 'Home', 61 | }) 62 | }) 63 | 64 | it('get current implicit', async () => { 65 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: getCurrent.response })) 66 | const response: Mode = await client.modes.getCurrent() 67 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(getCurrent.request)) 68 | expect(response).toBe(getCurrent.response) 69 | }) 70 | 71 | it('get current explicit', async () => { 72 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: explicitGet.response })) 73 | const response: Mode = await client.modes.getCurrent('b4db3e54-14f3-4bf4-b217-b8583757d446') 74 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(explicitGet.request)) 75 | expect(response).toBe(explicitGet.response) 76 | }) 77 | 78 | it('set current implicit', async () => { 79 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: setCurrent.response })) 80 | const response: Mode = await client.modes.setCurrent('7b7ca378-03ed-419d-93c1-76d3bb41c8b3') 81 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(setCurrent.request)) 82 | expect(response).toBe(setCurrent.response) 83 | }) 84 | 85 | it('create', async () => { 86 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: create.response })) 87 | const response: Mode = await client.modes.create({ 88 | 'label': 'Mode 4', 89 | }) 90 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(create.request)) 91 | expect(response).toBe(create.response) 92 | }) 93 | 94 | it('update', async () => { 95 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: update.response })) 96 | const response: Mode = await client.modes.update('7b7ca378-03ed-419d-93c1-76d3bb41c8b3', { 97 | 'label': 'Mode Four', 98 | }) 99 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(update.request)) 100 | expect(response).toBe(update.response) 101 | }) 102 | 103 | it('delete', async () => { 104 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: deleteMode.response })) 105 | const response: Status = await client.modes.delete('7b7ca378-03ed-419d-93c1-76d3bb41c8b3') 106 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(deleteMode.request)) 107 | expect(response).toEqual(SuccessStatusValue) 108 | }) 109 | 110 | it('delete explicit', async () => { 111 | axios.request.mockImplementationOnce(() => Promise.resolve({ status: 200, data: deleteModeExplicit.response })) 112 | const response: Status = await client.modes.delete( 113 | '7b7ca378-03ed-419d-93c1-76d3bb41c8b3', 114 | 'b4db3e54-14f3-4bf4-b217-b8583757d446') 115 | expect(axios.request).toHaveBeenCalledWith(expectedRequest(deleteModeExplicit.request)) 116 | expect(response).toEqual(SuccessStatusValue) 117 | }) 118 | }) 119 | -------------------------------------------------------------------------------- /src/endpoint/deviceprofiles.ts: -------------------------------------------------------------------------------- 1 | import { Endpoint } from '../endpoint' 2 | import { EndpointClient, EndpointClientConfig } from '../endpoint-client' 3 | import { LocaleReference, Status, SuccessStatusValue } from '../types' 4 | import { CapabilityReference, PreferenceType } from './devices' 5 | 6 | 7 | export interface DeviceComponentRequest { 8 | id?: string 9 | capabilities?: CapabilityReference[] 10 | categories?: string[] 11 | } 12 | 13 | export interface DeviceComponent extends DeviceComponentRequest { 14 | /** 15 | * UTF-8 label for the component. This value is generated and dependent on the locale of the request 16 | */ 17 | label?: string 18 | } 19 | 20 | export enum DeviceProfileStatus { 21 | DEVELOPMENT = 'DEVELOPMENT', 22 | PUBLISHED = 'PUBLISHED' 23 | } 24 | 25 | export interface DeviceProfilePreferenceDefinition { 26 | minimum?: number 27 | maximum?: number 28 | minLength?: number 29 | maxLength?: number 30 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 31 | default: any 32 | stringType?: 'text' | 'password' | 'paragraph' 33 | options?: { [key: string]: string } 34 | } 35 | 36 | export interface DeviceProfilePreferenceCore { 37 | title: string 38 | description?: string 39 | required?: boolean 40 | preferenceType: PreferenceType 41 | } 42 | export interface DeviceProfilePreferenceRequest extends DeviceProfilePreferenceCore { 43 | explicit?: boolean 44 | definition: DeviceProfilePreferenceDefinition 45 | preferenceId?: string 46 | } 47 | 48 | export interface DeviceProfileUpdateRequest { 49 | /** 50 | * must have between 1 and 20 components 51 | */ 52 | components?: DeviceComponentRequest[] 53 | metadata?: { [key: string]: string } 54 | preferences?: DeviceProfilePreferenceRequest[] 55 | } 56 | 57 | export interface DeviceProfileCreateRequest extends DeviceProfileUpdateRequest { 58 | name?: string 59 | } 60 | export type DeviceProfileRequest = DeviceProfileCreateRequest 61 | 62 | export interface DeviceProfilePreference extends DeviceProfilePreferenceCore { 63 | id?: string 64 | } 65 | 66 | export interface DeviceProfile extends DeviceProfileCreateRequest { 67 | id: string 68 | name: string 69 | components: DeviceComponent[] 70 | metadata?: { [key: string]: string } 71 | status: DeviceProfileStatus 72 | } 73 | 74 | export interface ComponentTranslations { 75 | /** 76 | * Short UTF-8 text used when displaying the component. 77 | */ 78 | label: string 79 | /** 80 | * UTF-8 text describing the component. 81 | */ 82 | description?: string 83 | } 84 | 85 | export interface DeviceProfileTranslations { 86 | tag: string 87 | /** 88 | * A map of component ID to it's translations. 89 | */ 90 | components?: { [key: string]: ComponentTranslations } 91 | } 92 | 93 | export class DeviceProfilesEndpoint extends Endpoint { 94 | constructor(config: EndpointClientConfig) { 95 | super(new EndpointClient('deviceprofiles', config)) 96 | } 97 | 98 | /** 99 | * List all the device profiles belonging to the principal (i.e. user) 100 | */ 101 | public list(): Promise { 102 | return this.client.getPagedItems() 103 | } 104 | 105 | /** 106 | * Get the definition of a specific device profile 107 | * @param id UUID of the device profile 108 | */ 109 | public get(id: string): Promise { 110 | return this.client.get(id) 111 | } 112 | 113 | /** 114 | * Delete a device profile 115 | * @param id UUID of the device profile 116 | */ 117 | public async delete(id: string): Promise { 118 | await this.client.delete(id) 119 | return SuccessStatusValue 120 | } 121 | 122 | /** 123 | * Create a device profile 124 | * @param data device profile definition 125 | */ 126 | public create(data: DeviceProfileCreateRequest): Promise { 127 | return this.client.post(undefined, data) 128 | } 129 | 130 | /** 131 | * Update a device profile 132 | * @param id UUID of the device profile 133 | * @param data the new device profile definition 134 | */ 135 | public update(id: string, data: DeviceProfileUpdateRequest): Promise { 136 | return this.client.put(id, data) 137 | } 138 | 139 | /** 140 | * Update the status of a device profile 141 | * @param id UUID of the device profile 142 | * @param deviceProfileStatus new device profile status 143 | */ 144 | public updateStatus(id: string, deviceProfileStatus: DeviceProfileStatus): Promise { 145 | return this.client.post(`${id}/status`, { deviceProfileStatus }) 146 | } 147 | 148 | /** 149 | * Returns a list of the locales supported by the device profile 150 | * @param id UUID of the device profile 151 | */ 152 | public listLocales(id: string): Promise { 153 | return this.client.getPagedItems(`${id}/i18n`) 154 | } 155 | 156 | /** 157 | * Retrieve the translations for the specified locale 158 | * @param id UUID of the device profile 159 | * @param tag locale tag, e.g. 'en', 'es', or 'ko' 160 | */ 161 | public getTranslations(id: string, tag: string): Promise { 162 | return this.client.get(`${id}/i18n/${tag}`) 163 | } 164 | 165 | /** 166 | * Create or update the translations for a device profile 167 | * @param id UUID of the device profile 168 | * @param data translations 169 | */ 170 | public upsertTranslations(id: string, data: DeviceProfileTranslations): Promise { 171 | return this.client.put(`${id}/i18n/${data.tag}`, data) 172 | } 173 | 174 | /** 175 | * Retrieve the translations for the specified locale 176 | * @param id UUID of the device profile 177 | * @param tag locale tag, e.g. 'en', 'es', or 'ko' 178 | */ 179 | public async deleteTranslations(id: string, tag: string): Promise { 180 | await this.client.delete(`${id}/i18n/${tag}`) 181 | return SuccessStatusValue 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /test/unit/data/installedapps/put.ts: -------------------------------------------------------------------------------- 1 | import { ConfigValueType } from '../../../../src' 2 | 3 | 4 | export const put_installedapps_e09af197_4a51_42d9_8fd9_a39a67049d4a = { 5 | 'request': { 6 | 'url': 'https://api.smartthings.com/installedapps/e09af197-4a51-42d9-8fd9-a39a67049d4a', 7 | 'method': 'put', 8 | 'headers': { 9 | 'Content-Type': 'application/json;charset=utf-8', 10 | 'Accept': 'application/json', 11 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 12 | }, 13 | 'data': { 14 | 'displayName': 'Updated Functional Test Switch Reflector', 15 | }, 16 | }, 17 | 'response': { 18 | 'installedAppId': 'e09af197-4a51-42d9-8fd9-a39a67049d4a', 19 | 'installedAppType': 'WEBHOOK_SMART_APP', 20 | 'installedAppStatus': 'PENDING', 21 | 'displayName': 'Updated Functional Test Switch Reflector', 22 | 'appId': '1c593873-ef7d-4665-8f0d-e1da25861e02', 23 | 'referenceId': null, 24 | 'locationId': '95efee9b-6073-4871-b5ba-de6642187293', 25 | 'owner': { 26 | 'ownerType': 'USER', 27 | 'ownerId': 'c257d2c7-332b-d60d-808d-550bfbd54556', 28 | }, 29 | 'notices': [], 30 | 'createdDate': '2020-02-29T15:51:17Z', 31 | 'lastUpdatedDate': '2020-02-29T15:51:18Z', 32 | 'ui': { 33 | 'pluginId': null, 34 | 'pluginUri': null, 35 | 'dashboardCardsEnabled': false, 36 | 'preInstallDashboardCardsEnabled': false, 37 | }, 38 | 'iconImage': { 39 | 'url': null, 40 | }, 41 | 'classifications': [ 42 | 'AUTOMATION', 43 | ], 44 | 'principalType': 'LOCATION', 45 | 'singleInstance': false, 46 | }, 47 | } 48 | 49 | export const put_installedapps_e09af197_4a51_42d9_8fd9_a39a67049d4a_configs = { 50 | 'request': { 51 | 'url': 'https://api.smartthings.com/installedapps/e09af197-4a51-42d9-8fd9-a39a67049d4a/configs', 52 | 'method': 'put', 53 | 'headers': { 54 | 'Content-Type': 'application/json;charset=utf-8', 55 | 'Accept': 'application/json', 56 | 'Authorization': 'Bearer 00000000-0000-0000-0000-000000000000', 57 | }, 58 | 'data': { 59 | config: { 60 | 'triggerSwitch': [ 61 | { 62 | 'valueType': ConfigValueType.DEVICE, 63 | 'deviceConfig': { 64 | 'deviceId': '385931b6-0121-4848-bcc8-54cb76436de1', 65 | 'componentId': 'main', 66 | 'permissions': [ 67 | 'r:devices:385931b6-0121-4848-bcc8-54cb76436de1', 68 | ], 69 | }, 70 | }, 71 | ], 72 | 'targetSwitch': [ 73 | { 74 | 'valueType': ConfigValueType.DEVICE, 75 | 'deviceConfig': { 76 | 'deviceId': 'b97058f4-c642-4162-8c2d-15009fdf5bfc', 77 | 'componentId': 'main', 78 | 'permissions': [ 79 | 'r:devices:b97058f4-c642-4162-8c2d-15009fdf5bfc', 80 | 'x:devices:b97058f4-c642-4162-8c2d-15009fdf5bfc', 81 | ], 82 | }, 83 | }, 84 | { 85 | 'valueType': ConfigValueType.DEVICE, 86 | 'deviceConfig': { 87 | 'deviceId': '8cfb5b5f-1683-4459-932c-9493c63da626', 88 | 'componentId': 'main', 89 | 'permissions': [ 90 | 'r:devices:8cfb5b5f-1683-4459-932c-9493c63da626', 91 | 'x:devices:8cfb5b5f-1683-4459-932c-9493c63da626', 92 | ], 93 | }, 94 | }, 95 | { 96 | 'valueType': ConfigValueType.DEVICE, 97 | 'deviceConfig': { 98 | 'deviceId': '46c38b7c-81bc-4e65-80be-dddf1fdd45b8', 99 | 'componentId': 'main', 100 | 'permissions': [ 101 | 'r:devices:46c38b7c-81bc-4e65-80be-dddf1fdd45b8', 102 | 'x:devices:46c38b7c-81bc-4e65-80be-dddf1fdd45b8', 103 | ], 104 | }, 105 | }, 106 | ], 107 | }, 108 | }, 109 | }, 110 | 'response': { 111 | 'installedAppId': 'e09af197-4a51-42d9-8fd9-a39a67049d4a', 112 | 'configurationId': '95758b7b-6a37-45fc-9702-c6d5609c7241', 113 | 'configurationStatus': 'STAGED', 114 | 'config': { 115 | 'triggerSwitch': [ 116 | { 117 | 'valueType': 'DEVICE', 118 | 'stringConfig': null, 119 | 'deviceConfig': { 120 | 'deviceId': '385931b6-0121-4848-bcc8-54cb76436de1', 121 | 'componentId': 'main', 122 | 'permissions': [ 123 | 'r:devices:385931b6-0121-4848-bcc8-54cb76436de1', 124 | ], 125 | }, 126 | 'permissionConfig': null, 127 | 'modeConfig': null, 128 | 'sceneConfig': null, 129 | 'messageConfig': null, 130 | }, 131 | ], 132 | 'targetSwitch': [ 133 | { 134 | 'valueType': 'DEVICE', 135 | 'stringConfig': null, 136 | 'deviceConfig': { 137 | 'deviceId': 'b97058f4-c642-4162-8c2d-15009fdf5bfc', 138 | 'componentId': 'main', 139 | 'permissions': [ 140 | 'r:devices:b97058f4-c642-4162-8c2d-15009fdf5bfc', 141 | 'x:devices:b97058f4-c642-4162-8c2d-15009fdf5bfc', 142 | ], 143 | }, 144 | 'permissionConfig': null, 145 | 'modeConfig': null, 146 | 'sceneConfig': null, 147 | 'messageConfig': null, 148 | }, 149 | { 150 | 'valueType': 'DEVICE', 151 | 'stringConfig': null, 152 | 'deviceConfig': { 153 | 'deviceId': '8cfb5b5f-1683-4459-932c-9493c63da626', 154 | 'componentId': 'main', 155 | 'permissions': [ 156 | 'r:devices:8cfb5b5f-1683-4459-932c-9493c63da626', 157 | 'x:devices:8cfb5b5f-1683-4459-932c-9493c63da626', 158 | ], 159 | }, 160 | 'permissionConfig': null, 161 | 'modeConfig': null, 162 | 'sceneConfig': null, 163 | 'messageConfig': null, 164 | }, 165 | { 166 | 'valueType': 'DEVICE', 167 | 'stringConfig': null, 168 | 'deviceConfig': { 169 | 'deviceId': '46c38b7c-81bc-4e65-80be-dddf1fdd45b8', 170 | 'componentId': 'main', 171 | 'permissions': [ 172 | 'r:devices:46c38b7c-81bc-4e65-80be-dddf1fdd45b8', 173 | 'x:devices:46c38b7c-81bc-4e65-80be-dddf1fdd45b8', 174 | ], 175 | }, 176 | 'permissionConfig': null, 177 | 'modeConfig': null, 178 | 'sceneConfig': null, 179 | 'messageConfig': null, 180 | }, 181 | ], 182 | }, 183 | 'createdDate': '2020-02-29T15:51:18Z', 184 | 'lastUpdatedDate': '2020-02-29T15:51:18Z', 185 | }, 186 | } 187 | -------------------------------------------------------------------------------- /test/unit/data/presentation/models.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CapabilityPresentationOperator, 3 | CapabilityVisibleCondition, 4 | PatchItemOpEnum, 5 | PresentationDeviceConfig, 6 | PresentationDeviceConfigEntry, 7 | PresentationDPInfo, 8 | } from '../../../../src' 9 | 10 | 11 | export const deviceConfig = { 12 | 'manufacturerName': 'Test Manufacturer', 13 | 'presentationId': 'd8469d5c-3ca2-4601-9f21-2b7a0ccd44a5', 14 | 'type': 'profile', 15 | 'dpInfo': [ 16 | 17 | ] as PresentationDPInfo[], 18 | 'iconUrl': 'www.randomplace.com/icon.png', 19 | 'dashboard': { 20 | 'states': [ 21 | { 22 | 'component': 'component', 23 | 'capability': 'testCapability', 24 | 'version': 1, 25 | 'values': [ 26 | { 'key': 'keyName' }, 27 | ], 28 | 'patch': [ 29 | { 'op': PatchItemOpEnum.REPLACE, 'path': '/0/main/1/value', 'value': 'New Value' }, 30 | ], 31 | 'visibleCondition': { 32 | 'component': 'component', 33 | 'version': 1, 34 | 'value': 'valueName', 35 | 'operator': CapabilityPresentationOperator.GREATER_THAN_OR_EQUALS, 36 | 'operand': 'keyName', 37 | } as CapabilityVisibleCondition, 38 | }, 39 | ], 40 | 'actions': [ 41 | { 42 | 'component': 'component', 43 | 'capability': 'testCapability', 44 | 'version': 1, 45 | 'values': [ 46 | { 'key': 'keyName' }, 47 | ], 48 | 'patch': [ 49 | { 'op': PatchItemOpEnum.REPLACE, 'path': '/0/main/1/value', 'value': 'New Value' }, 50 | ], 51 | 'visibleCondition': { 52 | 'component': 'component', 53 | 'version': 1, 54 | 'value': 'valueName', 55 | 'operator': CapabilityPresentationOperator.GREATER_THAN_OR_EQUALS, 56 | 'operand': 'keyName', 57 | } as CapabilityVisibleCondition, 58 | }, 59 | ] as PresentationDeviceConfigEntry[], 60 | }, 61 | 'detailView': [ 62 | { 63 | 'component': 'component', 64 | 'capability': 'testCapability', 65 | 'version': 1, 66 | 'values': [ 67 | { 'key': 'keyName' }, 68 | ], 69 | 'patch': [ 70 | { 'op': PatchItemOpEnum.REPLACE, 'path': '/0/main/1/value', 'value': 'New Value' }, 71 | ], 72 | 'visibleCondition': { 73 | 'component': 'component', 74 | 'version': 1, 75 | 'value': 'valueName', 76 | 'operator': CapabilityPresentationOperator.GREATER_THAN_OR_EQUALS, 77 | 'operand': 'keyName', 78 | } as CapabilityVisibleCondition, 79 | }, 80 | ] as PresentationDeviceConfigEntry[], 81 | 'automation': { 82 | 'conditions': [ 83 | { 84 | 'component': 'component', 85 | 'capability': 'testCapability', 86 | 'version': 1, 87 | 'values': [ 88 | { 'key': 'keyName' }, 89 | ], 90 | 'patch': [ 91 | { 'op': PatchItemOpEnum.REPLACE, 'path': '/0/main/1/value', 'value': 'New Value' }, 92 | ], 93 | 'visibleCondition': { 94 | 'component': 'component', 95 | 'version': 1, 96 | 'value': 'valueName', 97 | 'operator': CapabilityPresentationOperator.GREATER_THAN_OR_EQUALS, 98 | 'operand': 'keyName', 99 | } as CapabilityVisibleCondition, 100 | }, 101 | ] as PresentationDeviceConfigEntry[], 102 | 'actions': [ 103 | { 104 | 'component': 'component', 105 | 'capability': 'testCapability', 106 | 'version': 1, 107 | 'values': [ 108 | { 'key': 'keyName' }, 109 | ], 110 | 'patch': [ 111 | { 'op': PatchItemOpEnum.REPLACE, 'path': '/0/main/1/value', 'value': 'New Value' }, 112 | ], 113 | 'visibleCondition': { 114 | 'component': 'component', 115 | 'version': 1, 116 | 'value': 'valueName', 117 | 'operator': CapabilityPresentationOperator.GREATER_THAN_OR_EQUALS, 118 | 'operand': 'keyName', 119 | } as CapabilityVisibleCondition, 120 | }, 121 | ] as PresentationDeviceConfigEntry[], 122 | }, 123 | } as PresentationDeviceConfig 124 | 125 | export const presentation = { 126 | 'manufacturerName': 'SmartThingsCommunity', 127 | 'presentationId': 'dd8fee94-0896-327c-bcb7-330955289c6d', 128 | 'mnmn': 'SmartThingsCommunity', 129 | 'vid': 'dd8fee94-0896-327c-bcb7-330955289c6d', 130 | 'dashboard': { 131 | 'states': [ 132 | { 133 | 'label': '{{outputVoltage.value}} V', 134 | 'capability': 'detailmedia27985.outputVoltage', 135 | 'version': 1, 136 | 'component': 'main', 137 | }, 138 | ], 139 | 'actions': [], 140 | 'basicPlus': [], 141 | }, 142 | 'detailView': [ 143 | { 144 | 'capability': 'detailmedia27985.outputVoltage', 145 | 'version': 1, 146 | 'label': 'Output Voltage (RMS)', 147 | 'displayType': 'slider', 148 | 'slider': { 149 | 'range': [ 150 | 0, 151 | 240, 152 | ], 153 | 'step': 1, 154 | 'unit': null, 155 | 'command': 'setOutputVoltage', 156 | 'value': 'outputVoltage.value', 157 | }, 158 | 'state': null, 159 | 'component': 'main', 160 | }, 161 | ], 162 | 'automation': { 163 | 'conditions': [ 164 | { 165 | 'capability': 'detailmedia27985.outputVoltage', 166 | 'version': 1, 167 | 'label': 'Output Voltage (RMS)', 168 | 'displayType': 'slider', 169 | 'slider': { 170 | 'range': [ 171 | 0, 172 | 240, 173 | ], 174 | 'step': 1, 175 | 'unit': null, 176 | 'value': 'outputVoltage.value', 177 | }, 178 | 'exclusion': [], 179 | 'component': 'main', 180 | }, 181 | ], 182 | 'actions': [ 183 | { 184 | 'capability': 'detailmedia27985.outputVoltage', 185 | 'version': 1, 186 | 'label': 'Output Voltage (RMS)', 187 | 'displayType': 'slider', 188 | 'slider': { 189 | 'range': [ 190 | 0, 191 | 240, 192 | ], 193 | 'step': 1, 194 | 'unit': null, 195 | 'command': 'setOutputVoltage', 196 | }, 197 | 'component': 'main', 198 | 'exclusion': [], 199 | }, 200 | ], 201 | }, 202 | 'dpInfo': [ 203 | { 204 | 'os': 'ios', 205 | 'dpUri': 'plugin://com.samsung.ios.plugin.stplugin/assets/files/index.html', 206 | }, 207 | { 208 | 'os': 'android', 209 | 'dpUri': 'plugin://com.samsung.android.plugin.stplugin', 210 | }, 211 | { 212 | 'os': 'web', 213 | 'dpUri': 'wwst://com.samsung.one.plugin.stplugin', 214 | }, 215 | ], 216 | } 217 | -------------------------------------------------------------------------------- /src/endpoint/locations.ts: -------------------------------------------------------------------------------- 1 | import { Endpoint } from '../endpoint' 2 | import { EndpointClient, EndpointClientConfig } from '../endpoint-client' 3 | import { Status, SuccessStatusValue } from '../types' 4 | 5 | 6 | export interface LocationParent { 7 | type?: LocationParentType 8 | 9 | /** 10 | * The ID of the parent 11 | * @format ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ 12 | */ 13 | id?: string 14 | } 15 | 16 | export type LocationParentType = 'LOCATIONGROUP' | 'ACCOUNT' 17 | 18 | /** 19 | * Client-facing action identifiers that may be permitted for the user. 20 | * 21 | * w:locations - the user can change the name of the location 22 | * d:locations - the user can delete the location 23 | * w:devices - the user can create devices on the location 24 | * w:locations:geo - the user can edit the geo-coordinates of the location 25 | * w:rooms - the user can create rooms on the location 26 | */ 27 | export type LocationClientAction = 'w:locations' | 'd:locations' | 'w:devices' | 'w:locations:geo' | 'w:rooms' 28 | 29 | /** 30 | * A slimmed down representation of the Location model. 31 | */ 32 | export interface LocationItem { 33 | /** 34 | * The ID of the location. 35 | * @format uuid 36 | */ 37 | locationId: string 38 | name: string 39 | 40 | /** 41 | * List of client-facing action identifiers that are currently permitted for the user. 42 | * If the value of this property is not null, then any action not included in the list 43 | * value of the property is currently prohibited for the user. 44 | */ 45 | allowed?: LocationClientAction[] | null 46 | parent?: LocationParent 47 | } 48 | 49 | export interface LocationUpdate { 50 | name: string 51 | 52 | /** 53 | * A geographical latitude. 54 | * @min -90 55 | * @max 90 56 | */ 57 | latitude?: number 58 | 59 | /** 60 | * A geographical longitude. 61 | * @min -180 62 | * @max 180 63 | */ 64 | longitude?: number 65 | 66 | /** 67 | * The radius in meters (integer) around latitude and longitude which defines this location. 68 | * @min 20 69 | * @max 500000 70 | */ 71 | regionRadius?: number 72 | 73 | /** The desired temperature scale used for the Location. Values include F and C. */ 74 | temperatureScale: 'F' | 'C' 75 | 76 | /** 77 | * We expect a POSIX locale but we also accept an IETF BCP 47 language tag. 78 | * @example en_US 79 | */ 80 | locale?: string 81 | 82 | /** Additional information about the Location that allows SmartThings to further define your Location. */ 83 | additionalProperties?: { [name: string]: string } // TODO: type should be { [name: string]: string | undefined } in future major version 84 | } 85 | 86 | export interface LocationCreate extends LocationUpdate { 87 | /** 88 | * An ISO Alpha-3 country code (e.g. GBR, USA) 89 | * @pattern ^[A-Z]{3}$ 90 | */ 91 | countryCode: string 92 | } 93 | 94 | export interface Location extends LocationCreate { 95 | /** 96 | * The ID of the location. 97 | * @format uuid 98 | */ 99 | locationId: string 100 | 101 | /** 102 | * An ID matching the Java Time Zone ID of the location. Automatically generated if latitude and longitude have been 103 | * provided. 104 | */ 105 | timeZoneId: string 106 | 107 | /** 108 | * Not currently in use. 109 | * @example null 110 | */ 111 | backgroundImage: string 112 | 113 | /** 114 | * List of client-facing action identifiers that are currently permitted for the user. 115 | * If the value of this property is not null, then any action not included in the list 116 | * value of the property is currently prohibited for the user. 117 | */ 118 | allowed?: LocationClientAction[] | null 119 | parent?: LocationParent 120 | 121 | /** 122 | * The timestamp of when a location was created in UTC. 123 | * @format date-time 124 | */ 125 | created?: string 126 | 127 | /** 128 | * The timestamp of when a location was last updated in UTC. 129 | * @format date-time 130 | */ 131 | lastModified?: string 132 | } 133 | 134 | export interface LocationGetOptions { 135 | /** 136 | * If set to true, the items in the response may contain the allowed list property. 137 | */ 138 | allowed: boolean 139 | } 140 | 141 | export class LocationsEndpoint extends Endpoint { 142 | constructor(config: EndpointClientConfig) { 143 | super(new EndpointClient('locations', config)) 144 | } 145 | 146 | /** 147 | * List all Locations currently available in a user account. 148 | */ 149 | public async list(): Promise { 150 | return this.client.getPagedItems() 151 | } 152 | 153 | /** 154 | * Get a specific Location from a user's account. 155 | * @param id UUID of the location 156 | * @param options optional query parameters passed wth request 157 | */ 158 | public get(id?: string, options: LocationGetOptions = { allowed: false }): Promise { 159 | const params = { allowed: options.allowed.toString() } 160 | return this.client.get(this.locationId(id), params) 161 | } 162 | 163 | /** 164 | * Create a Location for a user. 165 | * The Location will be created geographically close to the country code provided in the request body. 166 | * If Location creation is not supported in the requested country code, the API will return a 422 error 167 | * response with an error code of UnsupportedGeoRegionError. 168 | * 169 | * @param location definition of the location 170 | */ 171 | public create(location: LocationCreate): Promise { 172 | return this.client.post(undefined, location) 173 | } 174 | 175 | /** 176 | * Update one or more fields of a specified Location. 177 | * @param id UUID of the location 178 | * @param location new location definition 179 | */ 180 | public update(id: string, location: LocationUpdate): Promise { 181 | return this.client.put(id, location) 182 | } 183 | 184 | /** 185 | * Delete a Location from a user's account. 186 | * @param id UUID of the location 187 | */ 188 | public async delete(id: string): Promise { 189 | await this.client.delete(id) 190 | return SuccessStatusValue 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /test/unit/channels.test.ts: -------------------------------------------------------------------------------- 1 | import { NoOpAuthenticator } from '../../src/authenticator' 2 | import { Channel, ChannelCreate, ChannelsEndpoint, ChannelUpdate, DriverChannelDetails } 3 | from '../../src/endpoint/channels' 4 | import { EndpointClient } from '../../src/endpoint-client' 5 | 6 | 7 | describe('ChannelsEndpoint', () => { 8 | afterEach(() => { 9 | jest.clearAllMocks() 10 | }) 11 | 12 | const getSpy = jest.spyOn(EndpointClient.prototype, 'get').mockImplementation() 13 | const postSpy = jest.spyOn(EndpointClient.prototype, 'post').mockImplementation() 14 | const putSpy = jest.spyOn(EndpointClient.prototype, 'put').mockImplementation() 15 | const deleteSpy = jest.spyOn(EndpointClient.prototype, 'delete').mockImplementation() 16 | const getPagedItemsSpy = jest.spyOn(EndpointClient.prototype, 'getPagedItems').mockImplementation() 17 | 18 | const authenticator = new NoOpAuthenticator() 19 | const channelsEndpoint = new ChannelsEndpoint({ authenticator }) 20 | 21 | test('create', async () => { 22 | const createRequest = { name: 'channel-to-create' } as ChannelCreate 23 | const created = { channelId: 'created-channel' } as Channel 24 | postSpy.mockResolvedValueOnce(created) 25 | 26 | expect(await channelsEndpoint.create(createRequest)).toBe(created) 27 | 28 | expect(postSpy).toHaveBeenCalledTimes(1) 29 | expect(postSpy).toHaveBeenCalledWith('', createRequest) 30 | }) 31 | 32 | test('delete', async () => { 33 | await expect(channelsEndpoint.delete('id-to-delete')).resolves.not.toThrow() 34 | 35 | expect(deleteSpy).toHaveBeenCalledTimes(1) 36 | expect(deleteSpy).toHaveBeenCalledWith('id-to-delete') 37 | }) 38 | 39 | test('update', async () => { 40 | const updateRequest = { name: 'channel-to-update' } as ChannelUpdate 41 | const updated = { channelId: 'updated-channel' } as Channel 42 | putSpy.mockResolvedValueOnce(updated) 43 | 44 | expect(await channelsEndpoint.update('input-channel-id', updateRequest)).toBe(updated) 45 | 46 | expect(putSpy).toHaveBeenCalledTimes(1) 47 | expect(putSpy).toHaveBeenCalledWith('input-channel-id', updateRequest) 48 | }) 49 | 50 | test('get', async () => { 51 | const channel = { channelId: 'channel-id' } 52 | getSpy.mockResolvedValueOnce(channel) 53 | 54 | expect(await channelsEndpoint.get('requested-channel-id')).toBe(channel) 55 | 56 | expect(getSpy).toHaveBeenCalledWith('requested-channel-id') 57 | }) 58 | 59 | test('getDriverChannelMetaInfo', async () => { 60 | const driver = { driverId: 'driver-id' } 61 | getSpy.mockResolvedValueOnce(driver) 62 | 63 | expect(await channelsEndpoint.getDriverChannelMetaInfo('requested-channel-id', 'requested-driver-id')) 64 | .toBe(driver) 65 | 66 | expect(getSpy).toHaveBeenCalledWith('requested-channel-id/drivers/requested-driver-id/meta') 67 | }) 68 | 69 | describe('list', () => { 70 | const channelList = [{ channelId: 'listed-channel' }] as Channel[] 71 | getPagedItemsSpy.mockResolvedValue(channelList) 72 | 73 | it('works without options', async () => { 74 | expect(await channelsEndpoint.list()).toBe(channelList) 75 | 76 | expect(getPagedItemsSpy).toHaveBeenCalledTimes(1) 77 | expect(getPagedItemsSpy).toHaveBeenCalledWith('', {}) 78 | }) 79 | 80 | it.each([ 81 | ['subscriberType', 'HUB', { type: 'HUB' }], 82 | ['includeReadOnly', true, { includeReadOnly: 'true' }], 83 | ])('handles %s', async (searchKey, searchValue, expectedParams) => { 84 | expect(await channelsEndpoint.list({ [searchKey]: searchValue })).toBe(channelList) 85 | 86 | expect(getPagedItemsSpy).toHaveBeenCalledTimes(1) 87 | expect(getPagedItemsSpy).toHaveBeenCalledWith('', expectedParams) 88 | }) 89 | 90 | it('works with both subscriber id and type', async () => { 91 | expect(await channelsEndpoint.list({ subscriberType: 'HUB', subscriberId: 'subscriber-id' })).toBe(channelList) 92 | 93 | expect(getPagedItemsSpy).toHaveBeenCalledTimes(1) 94 | expect(getPagedItemsSpy).toHaveBeenCalledWith('', { type: 'HUB', subscriberId: 'subscriber-id' }) 95 | }) 96 | 97 | it('throws exception when subscriber id included without type', async () => { 98 | await expect(channelsEndpoint.list({ subscriberId: 'subscriber-id' })).rejects 99 | .toThrow('specifying a subscriberId requires also specifying a subscriberType') 100 | 101 | expect(getPagedItemsSpy).toHaveBeenCalledTimes(0) 102 | }) 103 | }) 104 | 105 | test('listAssignedDrivers', async () => { 106 | const driverChannelDetailList = [{ driverId: 'listed-in-channel-id' }] as DriverChannelDetails[] 107 | getPagedItemsSpy.mockResolvedValueOnce(driverChannelDetailList) 108 | 109 | expect(await channelsEndpoint.listAssignedDrivers('channel-id')).toBe(driverChannelDetailList) 110 | 111 | expect(getPagedItemsSpy).toHaveBeenCalledWith('channel-id/drivers') 112 | }) 113 | 114 | test('assignDriver', async () => { 115 | const details = { driverId: 'assigned-driver' } as DriverChannelDetails 116 | postSpy.mockResolvedValueOnce(details) 117 | 118 | expect(await channelsEndpoint.assignDriver('channel-id', 'driver-id', 'version')).toBe(details) 119 | 120 | expect(postSpy).toHaveBeenCalledTimes(1) 121 | expect(postSpy).toHaveBeenCalledWith('channel-id/drivers', { driverId: 'driver-id', version: 'version' }) 122 | }) 123 | 124 | test('unassignDriver', async () => { 125 | await expect(channelsEndpoint.unassignDriver('channel-id', 'driver-id')).resolves.not.toThrow() 126 | 127 | expect(deleteSpy).toHaveBeenCalledTimes(1) 128 | expect(deleteSpy).toHaveBeenCalledWith('channel-id/drivers/driver-id') 129 | }) 130 | 131 | test('enrollHub', async () => { 132 | postSpy.mockResolvedValueOnce({ unused: 'value' }) 133 | 134 | await expect(channelsEndpoint.enrollHub('channel-id', 'hub-id')).resolves.not.toThrow() 135 | 136 | expect(postSpy).toHaveBeenCalledTimes(1) 137 | expect(postSpy).toHaveBeenCalledWith('channel-id/hubs/hub-id') 138 | }) 139 | 140 | test('unenrollHub', async () => { 141 | await expect(channelsEndpoint.unenrollHub('channel-id', 'hub-id')).resolves.not.toThrow() 142 | 143 | expect(deleteSpy).toHaveBeenCalledTimes(1) 144 | expect(deleteSpy).toHaveBeenCalledWith('channel-id/hubs/hub-id') 145 | }) 146 | }) 147 | -------------------------------------------------------------------------------- /src/endpoint/drivers.ts: -------------------------------------------------------------------------------- 1 | import { Endpoint } from '../endpoint' 2 | import { EndpointClient, EndpointClientConfig } from '../endpoint-client' 3 | 4 | 5 | export interface EdgeClientClusters { 6 | /** 7 | * List of 16-bit cluster identifiers for client clusters. 8 | * 9 | * values are integers. 10 | */ 11 | client?: number[] 12 | 13 | /** 14 | * List of 16-bit cluster identifiers for server clusters. 15 | * 16 | * values are integers. 17 | */ 18 | server?: number[] 19 | } 20 | 21 | export interface EdgeDeviceIntegrationProfileKey { 22 | /** 23 | * This is not yet marked as required in the API docs but it is. 24 | */ 25 | id: string 26 | 27 | /** 28 | * Major Version of the integration profile 29 | * 30 | * Value is 64 bit integer. 31 | * This is not yet marked as required in the API docs but it is. 32 | */ 33 | majorVersion: number 34 | } 35 | 36 | export interface EdgeZigbeeGenericFingerprint { 37 | clusters?: EdgeClientClusters 38 | 39 | /** 40 | * Device Identifiers associated with a generic zigbee fingerprint 41 | * 42 | * values are integers 43 | */ 44 | deviceIdentifiers?: number[] 45 | 46 | /** 47 | * Device profiles associated with a generic zigbee fingerprint 48 | * 49 | * values are integers 50 | */ 51 | zigbeeProfiles?: number[] 52 | 53 | deviceIntegrationProfileKey?: EdgeDeviceIntegrationProfileKey 54 | } 55 | 56 | export interface EdgeZigbeeManufacturerFingerprint { 57 | /** 58 | * Reported manufacturer of the device 59 | * 60 | * 0-32 characters 61 | */ 62 | manufacturer?: string 63 | 64 | /** 65 | * Reported model of the device 66 | * 67 | * 0-32 characters 68 | */ 69 | model?: string 70 | 71 | deviceIntegrationProfileKey?: EdgeDeviceIntegrationProfileKey 72 | } 73 | 74 | export interface EdgeCommandClasses { 75 | /** 76 | * List of 8-bit command class identifiers to match regardless of controlled or supported 77 | */ 78 | either?: number[] 79 | 80 | /** 81 | * List of 8-bit command class identifiers that are controlled 82 | */ 83 | controlled?: number[] 84 | 85 | /** 86 | * List of 8-bit command class identifiers that are supported 87 | */ 88 | supported?: number[] 89 | } 90 | 91 | export interface EdgeZWaveGenericFingerprint { 92 | /** 93 | * 8-bit indicator for the generic type of the device 94 | * 95 | * integer 0 - 255 96 | */ 97 | genericType?: number 98 | 99 | /** 100 | * List of reported command classes 101 | * 102 | * values are integers 103 | */ 104 | specificType?: number[] 105 | 106 | commandClasses?: EdgeCommandClasses 107 | 108 | deviceIntegrationProfileKey?: EdgeDeviceIntegrationProfileKey 109 | } 110 | 111 | export interface EdgeZWaveManufacturerFingerprint { 112 | /** 113 | * 16-bit manufacturer defined product type 114 | * 115 | * integer 0 - 65535 116 | */ 117 | productType: number 118 | 119 | /** 120 | * 16-bit manufacturer identifier assigned by the Z-Wave Specification 121 | * 122 | * integer 0 - 65535 123 | */ 124 | manufacturerId?: number 125 | 126 | /** 127 | * 16-bit manufacturer defined product identifier 128 | * 129 | * integer 0 - 65535 130 | */ 131 | productId?: number 132 | 133 | deviceIntegrationProfileKey?: EdgeDeviceIntegrationProfileKey 134 | } 135 | 136 | export interface EdgeDriverFingerprint { 137 | id: string // string <^[a-zA-Z0-9 _/\\\-()\\[\\]{}\.]{1,36}$> (FingerprintId) 138 | type: 'ZIGBEE_MANUFACTURER' | 'DTH' | 'ZWAVE_MANUFACTURER' 139 | 140 | /** 141 | * Label assigned to device at join time. If this is not set the driver name is used. 142 | * 143 | * <^[a-zA-Z0-9 _\/\\-()\[\]{}\.]{1,50}$> 144 | */ 145 | deviceLabel?: string 146 | 147 | zigbeeGeneric?: EdgeZigbeeGenericFingerprint 148 | zigbeeManufacturer?: EdgeZigbeeManufacturerFingerprint 149 | zwaveGeneric?: EdgeZWaveGenericFingerprint 150 | zwaveManufacturer?: EdgeZWaveManufacturerFingerprint 151 | } 152 | 153 | export interface EdgePermissionAttributes { 154 | [name: string]: unknown 155 | } 156 | 157 | export interface EdgeDriverPermissions { 158 | name: string 159 | attributes: EdgePermissionAttributes 160 | } 161 | 162 | export interface EdgeDriverSummary { 163 | driverId: string 164 | 165 | /** 166 | * The name of the driver 167 | * 168 | * ^[a-zA-Z0-9 _\/\\-()\[\]{}\.]{1,50}$ 169 | */ 170 | name: string 171 | 172 | description?: string 173 | 174 | /** 175 | * A user-scoped package key used to look up the respective driver record. 176 | * 177 | * ^[a-zA-Z0-9 _/\\\-()\\[\\]{}\.]{1,36}$ 178 | */ 179 | packageKey: string 180 | 181 | deviceIntegrationProfiles: EdgeDeviceIntegrationProfileKey[] 182 | 183 | permissions?: EdgeDriverPermissions[] 184 | 185 | /** 186 | * The version of the driver revision being returned. 187 | * 188 | * This is not yet marked as required in the API docs but it is. 189 | */ 190 | version: string 191 | } 192 | 193 | export interface EdgeDriver extends EdgeDriverSummary { 194 | fingerprints?: EdgeDriverFingerprint[] 195 | } 196 | 197 | export class DriversEndpoint extends Endpoint { 198 | constructor(config: EndpointClientConfig) { 199 | super(new EndpointClient('drivers', config)) 200 | } 201 | 202 | public async get(id: string): Promise { 203 | return this.client.get(id) 204 | } 205 | 206 | public async getRevision(id: string, version: string): Promise { 207 | return this.client.get(`${id}/versions/${version}`) 208 | } 209 | 210 | public async delete(id: string): Promise { 211 | await this.client.delete(id) 212 | } 213 | 214 | public async list(): Promise { 215 | return this.client.getPagedItems('') 216 | } 217 | 218 | /** 219 | * List drivers in the default channel. (The default channel in this context is a channel 220 | * that users do not need to subscribe to.) 221 | */ 222 | public async listDefault(): Promise { 223 | return this.client.getPagedItems('default') 224 | } 225 | 226 | /** 227 | * Uploads the zipped package represented by archiveData. 228 | */ 229 | public async upload(archiveData: Uint8Array): Promise { 230 | return this.client.request('post', 'package', archiveData, undefined, 231 | { headerOverrides: { 'Content-Type': 'application/zip' } }) 232 | } 233 | } 234 | --------------------------------------------------------------------------------