├── .eslintrc.js
├── .github
└── workflows
│ ├── main.yml
│ └── test.yml
├── .gitignore
├── .npmrc
├── .prettierrc.js
├── .vscode
└── settings.json
├── LICENSE
├── README.md
├── index.html
├── jest.config.js
├── package-lock.json
├── package.json
├── public
├── favicon.ico
├── logo192.png
├── logo512.png
├── manifest.json
└── robots.txt
├── src
├── App.tsx
├── TestD2CMessageSubscriber.tsx
├── TestInvokeDirectMethod.tsx
├── TestMultiConnectionState.tsx
├── TestMultiTelemetry.tsx
├── TestMultiTelemetryObject.tsx
├── TestPatchDesiredProperties.tsx
├── TestSingleSubscriber.tsx
├── TestSubscriber.tsx
├── ToggleableBox.tsx
├── __fixtures__
│ ├── grantFixtures.ts
│ └── subscriptionFixtures.ts
├── __tests__
│ ├── Ux4iot.test.ts
│ └── ux4iotState.test.ts
├── env.d.ts
├── index.css
├── index.tsx
├── library
│ ├── Ux4iotContext.tsx
│ ├── base
│ │ ├── Ux4iot.ts
│ │ ├── Ux4iotApi.ts
│ │ ├── types.ts
│ │ ├── utils.ts
│ │ ├── ux4iot-shared.ts
│ │ └── ux4iotState.ts
│ ├── index.ts
│ ├── telemetryState.ts
│ ├── useConnectionState.ts
│ ├── useD2CMessages.ts
│ ├── useDeviceTwin.ts
│ ├── useDirectMethod.ts
│ ├── useMultiConnectionState.ts
│ ├── useMultiTelemetry.ts
│ ├── usePatchDesiredProperties.ts
│ ├── useSubscription.ts
│ ├── useTelemetry.ts
│ └── ux4iot-shared.ts
├── reportWebVitals.ts
└── setupTests.ts
├── tsconfig-lib.json
├── tsconfig.json
└── vite.config.ts
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | es6: true,
4 | node: true,
5 | },
6 | extends: [
7 | 'eslint:recommended',
8 | 'plugin:@typescript-eslint/eslint-recommended',
9 | 'plugin:@typescript-eslint/recommended',
10 | 'plugin:prettier/recommended',
11 | 'react-app',
12 | 'react-app/jest',
13 | ],
14 | parser: '@typescript-eslint/parser',
15 | parserOptions: {
16 | ecmaVersion: 11,
17 | sourceType: 'module',
18 | },
19 | plugins: ['@typescript-eslint', 'prettier', 'react-hooks'],
20 | ignorePatterns: ['dist/**/*.js'],
21 | rules: {
22 | indent: 'off',
23 | 'linebreak-style': ['error', 'unix'],
24 | semi: ['error', 'always'],
25 | '@typescript-eslint/no-explicit-any': 'off',
26 | '@typescript-eslint/ban-ts-comment': 'off',
27 | 'no-debugger': 'warn',
28 | 'react-hooks/rules-of-hooks': 'error', // Checks effect dependencies
29 | 'react-hooks/exhaustive-deps': 'warn', // Checks effect dependencies
30 | },
31 | };
32 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 |
2 | # This workflow will do a clean install of node dependencies, cache/restore them, build the source code and run tests across different versions of node
3 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
4 |
5 | name: Node.js CI
6 |
7 | on:
8 | push:
9 | tags:
10 | - '*'
11 |
12 | jobs:
13 | build:
14 | runs-on: ubuntu-latest
15 | steps:
16 | - uses: actions/checkout@v2
17 | - uses: actions/setup-node@v2
18 | with:
19 | node-version: '14.x'
20 | registry-url: 'https://registry.npmjs.org'
21 | cache: 'npm'
22 | always-auth: true
23 | - run: npm ci
24 | - run: npm run build --if-present
25 | - run: npm publish
26 | env:
27 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
28 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Node.js CI
2 |
3 | on: [push]
4 |
5 | jobs:
6 | test:
7 | runs-on: ubuntu-latest
8 |
9 | steps:
10 | - uses: actions/checkout@v2
11 | - name: Use Node.js
12 | uses: actions/setup-node@v1
13 | with:
14 | node-version: 14.x
15 | - name: Install dependencies
16 | run: npm install
17 | - name: Run tests
18 | run: npm test
19 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /lib
6 |
7 | # production
8 | /build
9 |
10 | # misc
11 | .DS_Store
12 | .env.local
13 | .env.development.local
14 | .env.test.local
15 | .env.production.local
16 |
17 | npm-debug.log*
18 | yarn-debug.log*
19 | yarn-error.log*
20 |
21 | *.tgz
22 | *.swp
23 |
24 | .env
25 |
26 | .idea
27 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | registry=https://registry.npmjs.org/
2 |
--------------------------------------------------------------------------------
/.prettierrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | printWidth: 80,
3 | tabWidth: 2,
4 | bracketSpacing: true,
5 | singleQuote: true,
6 | arrowParens: 'avoid',
7 | useTabs: true,
8 | };
9 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "node_modules/typescript/lib"
3 | }
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Device Insight GmbH
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ux4iot-react
2 |
3 | ux4iot is a tool for directly communicating with your IoT devices from your web frontend. Your React frontend gets access to Azure IoT Hub's
4 | communication primitives without having a custom-built backend middleware translating between IoT Hub and your user interface.
5 | No need to design a REST API so that your UI can offer IoT functionality.
6 |
7 | Use the hooks in this library to implement your use cases for live data and for controlling devices.
8 |
9 | As an example: Using live data in your React application is as easy as writing
10 |
11 | ```js
12 | const temperature = useTelemetry('myDevice', 'temperature');
13 | ```
14 |
15 | in your React components.
16 |
17 | This library provides hooks for:
18 |
19 | - `useTelemetry` - Subscribe to a single telemetry key of a device
20 | - `useMultiTelemetry` - Subscribe to telemetry of multiple devices
21 | - `useDeviceTwin` - Subscribe to device twin changes
22 | - `useConnectionState` - Subscribe to connection state updates of a device
23 | - `useMultiConnectionState` - Subscribe to connection states of multiple devices
24 | - `useDirectMethod` - Execute a direct method on a device
25 | - `usePatchDesiredProperties` - Update the desired properties of the device twin
26 | - `useD2CMessages` - Use the raw messages sent by your devices
27 |
28 | ## Prerequisites
29 |
30 | In order to use this library you need to have an ux4iot instance deployed in your Azure subscription. [Here](https://docs.ux4iot.com/quickstart)
31 | is a link to a quickstart that explains how to deploy one. [Here](https://azuremarketplace.microsoft.com/en-us/marketplace/apps/deviceinsightgmbh-4961725.ux4iot)
32 | is the link to the Azure Marketplace offering.
33 |
34 | ## Installation
35 |
36 | Install `ux4iot-react` in your frontend application:
37 |
38 | ```
39 | npm install ux4iot-react
40 | ```
41 |
42 | ## Documentation
43 |
44 | Check out the [Documentation](https://docs.ux4iot.com/using-react/introduction) for
45 |
46 | - Additional options
47 | - Hook API
48 | - ux4iot Admin SDKs
49 | - ux4iot Admin API
50 | - Reference to other related libraries of the ux4iot service
51 |
52 | ## Tests
53 | - As ux4iot-react does not provide a lot of tests, the main features in this library are tested via integration tests in an internal repository.
54 |
55 | ## Releasing
56 |
57 | If you want to release a new version
58 | - `git checkout master`
59 | - Increase the version based on your changed in package.json (usually minor)
60 | - `git commit -m 'Release VERSION'`
61 | - `git tag VERSION -m 'Release VERSION'`
62 | - `git push`
63 | - `git push --tags`
64 |
65 | The tag pipeline of github actions will build the package and publish it to npm.
66 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 | React App
19 |
20 |
21 |
22 |
23 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | /*
2 | * For a detailed explanation regarding each configuration property, visit:
3 | * https://jestjs.io/docs/configuration
4 | */
5 |
6 | module.exports = {
7 | // All imported modules in your tests should be mocked automatically
8 | // automock: false,
9 |
10 | // Stop running tests after `n` failures
11 | // bail: 0,
12 |
13 | // The directory where Jest should store its cached dependency information
14 | // cacheDirectory: "/tmp/jest_rs",
15 |
16 | // Automatically clear mock calls, instances and results before every test
17 | // clearMocks: false,
18 |
19 | // Indicates whether the coverage information should be collected while executing the test
20 | // collectCoverage: false,
21 |
22 | // An array of glob patterns indicating a set of files for which coverage information should be collected
23 | // collectCoverageFrom: undefined,
24 |
25 | // The directory where Jest should output its coverage files
26 | // coverageDirectory: undefined,
27 |
28 | // An array of regexp pattern strings used to skip coverage collection
29 | // coveragePathIgnorePatterns: [
30 | // "/node_modules/"
31 | // ],
32 |
33 | // Indicates which provider should be used to instrument code for coverage
34 | coverageProvider: 'v8',
35 |
36 | // A list of reporter names that Jest uses when writing coverage reports
37 | // coverageReporters: [
38 | // "json",
39 | // "text",
40 | // "lcov",
41 | // "clover"
42 | // ],
43 |
44 | // An object that configures minimum threshold enforcement for coverage results
45 | // coverageThreshold: undefined,
46 |
47 | // A path to a custom dependency extractor
48 | // dependencyExtractor: undefined,
49 |
50 | // Make calling deprecated APIs throw helpful error messages
51 | // errorOnDeprecated: false,
52 |
53 | // Force coverage collection from ignored files using an array of glob patterns
54 | // forceCoverageMatch: [],
55 |
56 | // A path to a module which exports an async function that is triggered once before all test suites
57 | // globalSetup: undefined,
58 |
59 | // A path to a module which exports an async function that is triggered once after all test suites
60 | // globalTeardown: undefined,
61 |
62 | // A set of global variables that need to be available in all test environments
63 | // globals: {},
64 |
65 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
66 | // maxWorkers: "50%",
67 |
68 | // An array of directory names to be searched recursively up from the requiring module's location
69 | // moduleDirectories: [
70 | // "node_modules"
71 | // ],
72 |
73 | // An array of file extensions your modules use
74 | moduleFileExtensions: ['js', 'ts', 'tsx'],
75 |
76 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
77 | // moduleNameMapper: {},
78 |
79 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
80 | // modulePathIgnorePatterns: [],
81 |
82 | // Activates notifications for test results
83 | // notify: false,
84 |
85 | // An enum that specifies notification mode. Requires { notify: true }
86 | // notifyMode: "failure-change",
87 |
88 | // A preset that is used as a base for Jest's configuration
89 | preset: 'ts-jest',
90 |
91 | // Run tests from one or more projects
92 | // projects: undefined,
93 |
94 | // Use this configuration option to add custom reporters to Jest
95 | // reporters: undefined,
96 |
97 | // Automatically reset mock state before every test
98 | // resetMocks: false,
99 |
100 | // Reset the module registry before running each individual test
101 | // resetModules: false,
102 |
103 | // A path to a custom resolver
104 | // resolver: undefined,
105 |
106 | // Automatically restore mock state and implementation before every test
107 | // restoreMocks: false,
108 |
109 | // The root directory that Jest should scan for tests and modules within
110 | // rootDir: undefined,
111 |
112 | // A list of paths to directories that Jest should use to search for files in
113 | // roots: [
114 | // ""
115 | // ],
116 |
117 | // Allows you to use a custom runner instead of Jest's default test runner
118 | // runner: "jest-runner",
119 |
120 | // The paths to modules that run some code to configure or set up the testing environment before each test
121 | setupFiles: [],
122 |
123 | // A list of paths to modules that run some code to configure or set up the testing framework before each test
124 | // setupFilesAfterEnv: ['./jest.setup.js'],
125 |
126 | // The number of seconds after which a test is considered as slow and reported as such in the results.
127 | // slowTestThreshold: 5,
128 |
129 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing
130 | // snapshotSerializers: [],
131 |
132 | // The test environment that will be used for testing
133 | testEnvironment: 'jest-environment-jsdom',
134 |
135 | // Options that will be passed to the testEnvironment
136 | // testEnvironmentOptions: {},
137 |
138 | // Adds a location field to test results
139 | // testLocationInResults: false,
140 |
141 | // The glob patterns Jest uses to detect test files
142 | // testMatch: [
143 | // "**/__tests__/**/*.[jt]s?(x)",
144 | // "**/?(*.)+(spec|test).[tj]s?(x)"
145 | // ],
146 |
147 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
148 | // testPathIgnorePatterns: [
149 | // "/node_modules/"
150 | // ],
151 |
152 | // The regexp pattern or array of patterns that Jest uses to detect test files
153 | // testRegex: [],
154 |
155 | // This option allows the use of a custom results processor
156 | // testResultsProcessor: undefined,
157 |
158 | // This option allows use of a custom test runner
159 | // testRunner: "jest-circus/runner",
160 |
161 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
162 | // testURL: "http://localhost",
163 |
164 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
165 | // timers: "real",
166 |
167 | // A map from regular expressions to paths to transformers
168 | // transform: undefined,
169 |
170 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
171 | // transformIgnorePatterns: [
172 | // "/node_modules/",
173 | // "\\.pnp\\.[^\\/]+$"
174 | // ],
175 |
176 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
177 | // unmockedModulePathPatterns: undefined,
178 |
179 | // Indicates whether each individual test should be reported during the run
180 | // verbose: undefined,
181 |
182 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
183 | // watchPathIgnorePatterns: [],
184 |
185 | // Whether to use watchman for file crawling
186 | // watchman: true,
187 | };
188 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ux4iot-react",
3 | "version": "4.1.5",
4 | "description": "React Hooks for ux4iot",
5 | "main": "lib/index.js",
6 | "types": "lib/index.d.ts",
7 | "files": [
8 | "lib"
9 | ],
10 | "repository": {
11 | "url": "https://github.com/deviceinsight/ux4iot-react",
12 | "type": "git"
13 | },
14 | "scripts": {
15 | "start": "vite",
16 | "preview": "vite preview",
17 | "build-react-app": "tsc && vite build",
18 | "build": "tsc --project tsconfig-lib.json",
19 | "test": "jest",
20 | "lint:fix": "eslint --ignore-path='./.gitignore' '*/**/*.{js,ts}' --quiet --fix",
21 | "lint": "eslint --ignore-path='./.gitignore' '*/**/*.{js,ts}'",
22 | "prettier": "prettier --ignore-path=./.gitignore --write \"{src/**/*.ts, *.ts,src/**/*.tsx, *.tsx}\""
23 | },
24 | "dependencies": {
25 | "axios": "^1.7.4",
26 | "azure-iothub": "1.14.1",
27 | "socket.io-client": "^4.2.0",
28 | "uuid": "^8.3.2"
29 | },
30 | "devDependencies": {
31 | "@testing-library/jest-dom": "^6.1.4",
32 | "@testing-library/react": "^14.1.2",
33 | "@testing-library/user-event": "^14.5.1",
34 | "@types/jest": "^29.5.10",
35 | "@types/react": "^18.2.39",
36 | "@types/react-dom": "^18.2.17",
37 | "@types/uuid": "^9.0.7",
38 | "@typescript-eslint/eslint-plugin": "^5.62.0",
39 | "@vitejs/plugin-react": "^4.2.0",
40 | "eslint-config-prettier": "^9.0.0",
41 | "eslint-config-react-app": "^7.0.1",
42 | "eslint-plugin-prettier": "^5.0.1",
43 | "jest": "^29.7.0",
44 | "jest-dom": "^4.0.0",
45 | "jest-environment-jsdom": "^29.7.0",
46 | "prettier": "^3.1.0",
47 | "react": "^18.2.0",
48 | "react-dom": "^18.2.0",
49 | "ts-jest": "^29.1.1",
50 | "typescript": "^5.3.2",
51 | "vite": "^5.0.2",
52 | "web-vitals": "^3.5.0"
53 | },
54 | "peerDependencies": {
55 | "react": ">=16.8 || ^17.x || ^18.x",
56 | "react-dom": ">=16.8 || ^17.x || ^18.x"
57 | },
58 | "browserslist": {
59 | "production": [
60 | ">0.2%",
61 | "not dead",
62 | "not op_mini all"
63 | ],
64 | "development": [
65 | "last 1 chrome version",
66 | "last 1 firefox version",
67 | "last 1 safari version"
68 | ]
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/deviceinsight/ux4iot-react/94b7a4afbf399ce058d638830a8c43a1463a9d67/public/favicon.ico
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/deviceinsight/ux4iot-react/94b7a4afbf399ce058d638830a8c43a1463a9d67/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/deviceinsight/ux4iot-react/94b7a4afbf399ce058d638830a8c43a1463a9d67/public/logo512.png
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Vite React App",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { Ux4iotContextProvider } from './library';
3 | import { TestInvokeDirectMethod } from './TestInvokeDirectMethod';
4 | import { TestPatchDesiredProperties } from './TestPatchDesiredProperties';
5 | import { TestSingleSubscriber } from './TestSingleSubscriber';
6 | import { TestSubscriber } from './TestSubscriber';
7 | import { TestMultiTelemetry } from './TestMultiTelemetry';
8 | import { TestMultiConnectionState } from './TestMultiConnectionState';
9 | import { TestD2CMessageSubscriber } from './TestD2CMessageSubscriber';
10 | import { ToggleableBox } from './ToggleableBox';
11 | import { TestMultiTelemetryObject } from './TestMultiTelemetryObject';
12 |
13 | const { VITE_UX4IOT_CONNECTION_STRING } = import.meta.env;
14 |
15 | function App(): JSX.Element | null {
16 | const [reload, setReload] = useState(0);
17 | const [kill, setKill] = useState(false);
18 | const [connectionReason, setConnectionReason] = useState();
19 | const [connectionDesc, setConnectionDesc] = useState();
20 | if (!VITE_UX4IOT_CONNECTION_STRING) {
21 | console.error('VITE_UX4IOT_CONNECTION_STRING is missing.');
22 | return null;
23 | }
24 |
25 | return (
26 |
27 |
34 |
35 | {!kill && (
36 |
{
40 | setConnectionReason(reason);
41 | setConnectionDesc(description);
42 | },
43 | reconnectTimeout: 4000,
44 | maxReconnectTimeout: 10000,
45 | }}
46 | >
47 |
48 |
Connection Reason: {connectionReason}
49 |
Connection Desc: {connectionDesc}
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
65 |
66 |
67 |
71 |
72 |
73 |
77 |
78 |
79 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 | )}
95 |
96 | );
97 | }
98 |
99 | export default App;
100 |
--------------------------------------------------------------------------------
/src/TestD2CMessageSubscriber.tsx:
--------------------------------------------------------------------------------
1 | import { FC, useState } from 'react';
2 | import { useD2CMessages } from './library';
3 |
4 | type Props = {
5 | deviceId: string;
6 | };
7 |
8 | export const TestD2CMessageSubscriber: FC = ({ deviceId }) => {
9 | const [ts, setTs] = useState('');
10 | const lastMessage = useD2CMessages(deviceId, {
11 | onData: (deviceId, message, timestamp) => {
12 | setTs(timestamp);
13 | console.log(
14 | `Received message from ${deviceId} at ${timestamp}: ${JSON.stringify(
15 | message
16 | )}`
17 | );
18 | },
19 | });
20 |
21 | return (
22 |
23 |
UseD2CMessages
24 |
Subscribed to deviceId {deviceId}
25 |
26 | Raw Message:
{JSON.stringify(lastMessage, null, 2)}
27 |
28 |
29 | Received at
{ts}
30 |
31 |
32 | );
33 | };
34 |
--------------------------------------------------------------------------------
/src/TestInvokeDirectMethod.tsx:
--------------------------------------------------------------------------------
1 | import { FC, useCallback, useState } from 'react';
2 | import { useDirectMethod } from './library';
3 |
4 | type Props = {
5 | deviceId: string;
6 | };
7 |
8 | export const TestInvokeDirectMethod: FC = ({ deviceId }) => {
9 | const reboot = useDirectMethod(deviceId, 'setSendInterval');
10 | const [error, setError] = useState('');
11 | const [loading, setLoading] = useState(false);
12 | const [delay, setDelay] = useState(1);
13 | const [rebootResult, setRebootResult] = useState();
14 |
15 | const handleClick = useCallback(() => {
16 | setLoading(true);
17 | setError('');
18 | reboot({ delay: delay.toString() })
19 | .then(response => {
20 | console.log('success', response);
21 | setRebootResult(response);
22 | })
23 | .catch(error => {
24 | console.log('oops');
25 | setError(error.toString());
26 | })
27 | .finally(() => {
28 | setLoading(false);
29 | });
30 | }, [reboot, delay]);
31 |
32 | return (
33 |
34 |
Invoke Direct Method "set send interval"
35 |
36 |
39 |
40 |
41 |
42 | setDelay(parseInt(value))}
46 | />
47 |
48 |
49 |
50 | Result:
51 |
{JSON.stringify(rebootResult, null, 2)}
52 |
53 |
Loading: {loading}
54 |
Error: {error}
55 |
56 |
57 | );
58 | };
59 |
--------------------------------------------------------------------------------
/src/TestMultiConnectionState.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from 'react';
2 | import { useMultiConnectionState } from './library';
3 |
4 | type Props = {
5 | deviceIds: string[];
6 | };
7 |
8 | export const TestMultiConnectionState: FC = ({ deviceIds }) => {
9 | const { connectionStates, toggleConnectionState, isSubscribed } =
10 | useMultiConnectionState({
11 | initialSubscribers: deviceIds,
12 | onData: (deviceId, connectionState) => {
13 | console.log('useMultiConnectionState', deviceId, connectionState);
14 | },
15 | onGrantError: error => console.log(error),
16 | });
17 |
18 | return (
19 |
20 |
21 |
UseMultiConnectionState
22 | {[
23 | 'simulated-device',
24 | 'simulated-device-2',
25 | 'device-that-doesnt-exist',
26 | ].map(id => {
27 | return (
28 |
29 |
{id}
30 |
31 |
32 | toggleConnectionState(id)}
36 | />
37 |
38 |
39 | );
40 | })}
41 |
42 | data:
{JSON.stringify(connectionStates, null, 2)}
43 |
44 |
45 |
46 | );
47 | };
48 |
--------------------------------------------------------------------------------
/src/TestMultiTelemetry.tsx:
--------------------------------------------------------------------------------
1 | import { FC, useState } from 'react';
2 | import { useMultiTelemetry } from './library';
3 |
4 | type Props = {
5 | deviceId: string;
6 | datapoints: string[];
7 | };
8 |
9 | export const TestMultiTelemetry: FC = ({ deviceId }) => {
10 | const [ts, setTs] = useState('');
11 | const [grantError, setGrantError] = useState('');
12 | const [subError, setSubError] = useState('');
13 | const { telemetry, toggleTelemetry, isSubscribed } = useMultiTelemetry({
14 | initialSubscribers: { [deviceId]: ['temperature', 'pressure', 'state'] },
15 | onData: (deviceId, message, timestamp) => {
16 | setTs(timestamp);
17 | console.log('useMultiTelemetry', deviceId, message, timestamp);
18 | },
19 | onGrantError: error => setGrantError(error),
20 | onSubscriptionError: error => setSubError(error),
21 | });
22 |
23 | return (
24 |
25 |
26 |
UseMultiTelemetry
27 | {[
28 | 'simulated-device',
29 | 'simulated-device-2',
30 | 'device-that-doesnt-exist',
31 | ].map(id => {
32 | return (
33 |
34 |
{id}
35 | {['temperature', 'pressure', 'state'].map(dp => {
36 | return (
37 |
38 |
39 | toggleTelemetry(id, dp)}
43 | />
44 |
45 | );
46 | })}
47 |
48 | );
49 | })}
50 |
51 | data:
{JSON.stringify(telemetry, null, 2)}
52 |
53 |
54 | Received at
{ts}
55 |
56 |
grant error: {grantError.toString()}
57 |
sub error: {subError.toString()}
58 |
59 |
60 | );
61 | };
62 |
--------------------------------------------------------------------------------
/src/TestMultiTelemetryObject.tsx:
--------------------------------------------------------------------------------
1 | import { FC, useState } from 'react';
2 | import { useMultiTelemetry } from './library';
3 |
4 | type Props = {
5 | deviceId: string;
6 | datapoints: string[];
7 | };
8 |
9 | export const TestMultiTelemetryObject: FC = ({ deviceId }) => {
10 | const [ts, setTs] = useState('');
11 | const [grantError, setGrantError] = useState('');
12 | const [subError, setSubError] = useState('');
13 | const { telemetry, toggleTelemetry, isSubscribed } = useMultiTelemetry({
14 | initialSubscribers: { [deviceId]: ['nestedobject', 'geoposition'] },
15 | onData: (deviceId, message, timestamp) => {
16 | setTs(timestamp);
17 | console.log('useMultiTelemetry', deviceId, message, timestamp);
18 | },
19 | onGrantError: error => setGrantError(error),
20 | onSubscriptionError: error => setSubError(error),
21 | });
22 |
23 | return (
24 |
25 |
26 |
UseMultiTelemetry
27 | {['simulated-device'].map(id => {
28 | return (
29 |
30 |
{id}
31 | {['nestedobject', 'geoposition'].map(dp => {
32 | return (
33 |
34 |
35 | toggleTelemetry(id, dp)}
39 | />
40 |
41 | );
42 | })}
43 |
44 | );
45 | })}
46 |
47 | data:
{JSON.stringify(telemetry, null, 2)}
48 |
49 |
50 | Received at
{ts}
51 |
52 |
grant error: {grantError.toString()}
53 |
sub error: {subError.toString()}
54 |
55 |
56 | );
57 | };
58 |
--------------------------------------------------------------------------------
/src/TestPatchDesiredProperties.tsx:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 | import { FC, useCallback, useState } from 'react';
3 | import { IoTHubResponse } from './library';
4 | import { usePatchDesiredProperties } from './library';
5 |
6 | type Props = {
7 | deviceId: string;
8 | };
9 |
10 | export const TestPatchDesiredProperties: FC = ({ deviceId }) => {
11 | const [desired, setDesired] = useState>({
12 | desiredProperty1: 'value1',
13 | desiredProperty2: 'value2',
14 | });
15 | const [result, setResult] = useState(null);
16 | const [error, setError] = useState('');
17 | const [loading, setLoading] = useState(false);
18 |
19 | const patch = usePatchDesiredProperties(deviceId, {
20 | onGrantError: error => {
21 | console.log('usepatchdesiredproperties', error);
22 | setError(error);
23 | },
24 | });
25 |
26 | const onClick = useCallback(async () => {
27 | try {
28 | setLoading(false);
29 | const response = await patch(desired);
30 | if (response) {
31 | setResult(response);
32 | }
33 | } catch (error) {
34 | if (axios.isAxiosError(error)) {
35 | console.log(error.response);
36 | }
37 | } finally {
38 | setLoading(false);
39 | }
40 | }, [desired, patch]);
41 |
42 | return (
43 |
44 |
Set Desired Properties
45 |
46 |
47 |
51 | setDesired({ ...desired, desiredProperty1: value })
52 | }
53 | />
54 |
55 |
56 |
57 |
61 | setDesired({ ...desired, desiredProperty2: value })
62 | }
63 | />
64 |
65 |
66 |
67 |
68 |
69 | Result:
70 |
{JSON.stringify(result, null, 2)}
71 |
72 |
Loading: {loading}
73 |
Error: {error}
74 |
75 | );
76 | };
77 |
--------------------------------------------------------------------------------
/src/TestSingleSubscriber.tsx:
--------------------------------------------------------------------------------
1 | import { FC, useState } from 'react';
2 | import { useTelemetry } from './library';
3 |
4 | type Props = {
5 | deviceId: string;
6 | datapointName: string;
7 | };
8 | export const TestSingleSubscriber: FC = ({
9 | deviceId,
10 | datapointName,
11 | }) => {
12 | const [ts, setTs] = useState('');
13 | const value = useTelemetry(deviceId, datapointName, {
14 | onData: (deviceId, data, timestamp) => {
15 | setTs(timestamp);
16 | console.log(
17 | `received telemetry from useTelemetry from ${deviceId}: ${data} at ${timestamp}`
18 | );
19 | },
20 | onGrantError: error => console.log(error),
21 | });
22 |
23 | return (
24 |
25 |
UseTelemetry
26 |
{JSON.stringify(value)}
27 |
28 | Subscribed to deviceId {deviceId} and telemetryKey {datapointName}
29 |
30 |
31 | Value:
{JSON.stringify(value)}
32 |
33 |
34 | Received at
{ts}
35 |
36 |
37 | );
38 | };
39 |
--------------------------------------------------------------------------------
/src/TestSubscriber.tsx:
--------------------------------------------------------------------------------
1 | import { FC, useState } from 'react';
2 | import { useMultiTelemetry, useConnectionState, useDeviceTwin } from './library';
3 |
4 | type Props = {
5 | deviceId: string;
6 | datapoints: string[];
7 | };
8 |
9 | export const TestSubscriber: FC = ({ deviceId }) => {
10 | const [ts, setTs] = useState('');
11 | const { telemetry, toggleTelemetry, isSubscribed } = useMultiTelemetry({
12 | initialSubscribers: { [deviceId]: ['temperature', 'pressure'] },
13 | onData: (deviceId, message, timestamp) => {
14 | setTs(timestamp);
15 | console.log('useMultiTelemetry', deviceId, message, timestamp);
16 | },
17 | onGrantError: error => console.log(error),
18 | });
19 | const [myState, setMyState] = useState(0);
20 | const twin = useDeviceTwin(deviceId, {
21 | onData: twin => {
22 | setMyState(myState + 1);
23 | },
24 | onGrantError: error => console.log(error),
25 | });
26 | const connectionState = useConnectionState(deviceId);
27 |
28 | return (
29 |
30 |
31 |
UseMultiTelemetry
32 | {[
33 | 'simulated-device',
34 | 'simulated-device-2',
35 | 'device-that-doesnt-exist',
36 | ].map(id => {
37 | return (
38 |
39 |
{id}
40 | {['temperature', 'pressure', 'state'].map(dp => {
41 | return (
42 |
43 |
44 | toggleTelemetry(id, dp)}
48 | />
49 |
50 | );
51 | })}
52 |
53 | );
54 | })}
55 |
56 | data:
{JSON.stringify(telemetry, null, 2)}
57 |
58 |
59 | Received at
{ts}
60 |
61 |
62 |
63 |
UseDeviceTwin
64 |
65 | data:
{JSON.stringify(twin, null, 2)}
66 |
67 |
68 |
69 |
UseConnectionState
70 |
71 | data:
{JSON.stringify(connectionState, null, 2)}
72 |
73 |
74 |
75 | );
76 | };
77 |
--------------------------------------------------------------------------------
/src/ToggleableBox.tsx:
--------------------------------------------------------------------------------
1 | import { FC, ReactNode, useState } from 'react';
2 |
3 | type Props = {
4 | label: string;
5 | initialShow?: boolean;
6 | children: ReactNode;
7 | };
8 |
9 | export const ToggleableBox: FC = ({ label, children, initialShow }) => {
10 | const [show, setShow] = useState(!!initialShow);
11 | return (
12 |
13 |
14 | setShow(!show)} />
15 | {show && children}
16 |
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/src/__fixtures__/grantFixtures.ts:
--------------------------------------------------------------------------------
1 | import {
2 | TelemetryGrantRequest,
3 | DeviceTwinGrantRequest,
4 | DirectMethodGrantRequest,
5 | ConnectionStateGrantRequest,
6 | DesiredPropertyGrantRequest,
7 | D2CMessageGrantRequest,
8 | } from '../library';
9 |
10 | export const mockedTelemetryGrant = (
11 | sessionId: string
12 | ): TelemetryGrantRequest => ({
13 | type: 'telemetry',
14 | deviceId: 'mockedDeviceId',
15 | telemetryKey: 'mockedTelemetryKey',
16 | sessionId,
17 | });
18 | export const mockedTelemetryGrant2 = (
19 | sessionId: string
20 | ): TelemetryGrantRequest => ({
21 | type: 'telemetry',
22 | deviceId: 'mockedDeviceId2',
23 | telemetryKey: 'mockedTelemetryKey2',
24 | sessionId,
25 | });
26 | export const mockedDeviceTwinGrant = (
27 | sessionId: string
28 | ): DeviceTwinGrantRequest => ({
29 | type: 'deviceTwin',
30 | deviceId: 'mockedDeviceId',
31 | sessionId,
32 | });
33 | export const mockedDirectMethodGrant = (
34 | sessionId: string
35 | ): DirectMethodGrantRequest => ({
36 | type: 'directMethod',
37 | deviceId: 'mockedDeviceId',
38 | directMethodName: 'mockedDirectMethod',
39 | sessionId,
40 | });
41 | export const mockedConnectionStateGrant = (
42 | sessionId: string
43 | ): ConnectionStateGrantRequest => ({
44 | type: 'connectionState',
45 | deviceId: 'mockedDeviceId',
46 | sessionId,
47 | });
48 | export const mockedDesiredPropertiesGrant = (
49 | sessionId: string
50 | ): DesiredPropertyGrantRequest => ({
51 | type: 'desiredProperties',
52 | deviceId: 'mockedDeviceId',
53 | sessionId,
54 | });
55 | export const mockedD2CMessagesGrant = (
56 | sessionId: string
57 | ): D2CMessageGrantRequest => ({
58 | type: 'd2cMessages',
59 | deviceId: 'mockedDeviceId',
60 | sessionId,
61 | });
62 |
--------------------------------------------------------------------------------
/src/__fixtures__/subscriptionFixtures.ts:
--------------------------------------------------------------------------------
1 | import {
2 | TelemetrySubscriptionRequest,
3 | DeviceTwinSubscriptionRequest,
4 | ConnectionStateSubscriptionRequest,
5 | D2CMessageSubscriptionRequest,
6 | } from '../library';
7 |
8 | export const mockedTelemetrySubscription = (
9 | sessionId: string
10 | ): TelemetrySubscriptionRequest => ({
11 | type: 'telemetry',
12 | deviceId: 'mockedDeviceId',
13 | telemetryKey: 'mockedTelemetryKey',
14 | sessionId,
15 | });
16 | export const mockedTelemetrySubscription2 = (
17 | sessionId: string
18 | ): TelemetrySubscriptionRequest => ({
19 | type: 'telemetry',
20 | deviceId: 'mockedDeviceId2',
21 | telemetryKey: 'mockedTelemetryKey2',
22 | sessionId,
23 | });
24 | export const mockedTelemetrySubscription3 = (
25 | sessionId: string
26 | ): TelemetrySubscriptionRequest => ({
27 | type: 'telemetry',
28 | deviceId: 'mockedDeviceId',
29 | telemetryKey: 'mockedTelemetryKey2',
30 | sessionId,
31 | });
32 | export const mockedDeviceTwinSubscription = (
33 | sessionId: string
34 | ): DeviceTwinSubscriptionRequest => ({
35 | type: 'deviceTwin',
36 | deviceId: 'mockedDeviceId',
37 | sessionId,
38 | });
39 | export const mockedConnectionStateSubscription = (
40 | sessionId: string
41 | ): ConnectionStateSubscriptionRequest => ({
42 | type: 'connectionState',
43 | deviceId: 'mockedDeviceId',
44 | sessionId,
45 | });
46 | export const mockedD2CMessagesSubscription = (
47 | sessionId: string
48 | ): D2CMessageSubscriptionRequest => ({
49 | type: 'd2cMessages',
50 | deviceId: 'mockedDeviceId',
51 | sessionId,
52 | });
53 |
--------------------------------------------------------------------------------
/src/__tests__/Ux4iot.test.ts:
--------------------------------------------------------------------------------
1 | import { Ux4iot } from '../library/base/Ux4iot';
2 | jest.mock('socket.io-client', () => {
3 | return {
4 | ...jest.requireActual('socket.io-client'),
5 | io: jest.fn().mockImplementation(() => ({
6 | on: () => undefined,
7 | })),
8 | };
9 | });
10 | const getSessionIdMock = jest.fn(() => Promise.resolve());
11 | jest.mock('../library/base/Ux4iotApi', () => {
12 | return {
13 | Ux4iotApi: jest.fn().mockImplementation(() => ({
14 | ...jest.requireActual('../library/base/Ux4iotApi'),
15 | getSessionId: getSessionIdMock,
16 | setSessionId: () => undefined,
17 | getSocketURL: () => `fake`,
18 | })),
19 | };
20 | });
21 |
22 | describe('Ux4iot', () => {
23 | it('should make exactly one call to getSessionId on initialization', async () => {
24 | await Ux4iot.create({ adminConnectionString: '' });
25 |
26 | expect(getSessionIdMock).toBeCalledTimes(1);
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/src/__tests__/ux4iotState.test.ts:
--------------------------------------------------------------------------------
1 | import { TelemetrySubscriptionRequest } from '../library';
2 | import * as ux4iotState from '../library/base/ux4iotState';
3 | import {
4 | mockedConnectionStateGrant,
5 | mockedDesiredPropertiesGrant,
6 | mockedDeviceTwinGrant,
7 | mockedDirectMethodGrant,
8 | mockedD2CMessagesGrant,
9 | mockedTelemetryGrant,
10 | mockedTelemetryGrant2,
11 | } from '../__fixtures__/grantFixtures';
12 | import {
13 | mockedConnectionStateSubscription,
14 | mockedDeviceTwinSubscription,
15 | mockedD2CMessagesSubscription,
16 | mockedTelemetrySubscription,
17 | mockedTelemetrySubscription2,
18 | mockedTelemetrySubscription3,
19 | } from '../__fixtures__/subscriptionFixtures';
20 |
21 | const sessionId = 'sessionId';
22 |
23 | describe('ux4iotState', () => {
24 | it('has initial structure', () => {
25 | const state = ux4iotState.state;
26 | expect(state).toEqual({
27 | grants: {
28 | deviceTwin: [],
29 | connectionState: [],
30 | d2cMessages: [],
31 | telemetry: [],
32 | desiredProperties: [],
33 | directMethod: [],
34 | },
35 | subscriptions: {},
36 | });
37 | });
38 |
39 | it('adds grants', () => {
40 | ux4iotState.addGrant(mockedTelemetryGrant(sessionId));
41 | ux4iotState.addGrant(mockedTelemetryGrant2(sessionId));
42 | ux4iotState.addGrant(mockedConnectionStateGrant(sessionId));
43 | ux4iotState.addGrant(mockedDeviceTwinGrant(sessionId));
44 | ux4iotState.addGrant(mockedD2CMessagesGrant(sessionId));
45 | ux4iotState.addGrant(mockedDesiredPropertiesGrant(sessionId));
46 | ux4iotState.addGrant(mockedDirectMethodGrant(sessionId));
47 |
48 | expect(ux4iotState.state.grants).toEqual({
49 | connectionState: [
50 | {
51 | deviceId: 'mockedDeviceId',
52 | type: 'connectionState',
53 | sessionId: 'sessionId',
54 | },
55 | ],
56 | d2cMessages: [
57 | {
58 | deviceId: 'mockedDeviceId',
59 | type: 'd2cMessages',
60 | sessionId: 'sessionId',
61 | },
62 | ],
63 | desiredProperties: [
64 | {
65 | deviceId: 'mockedDeviceId',
66 | type: 'desiredProperties',
67 | sessionId: 'sessionId',
68 | },
69 | ],
70 | deviceTwin: [
71 | {
72 | deviceId: 'mockedDeviceId',
73 | type: 'deviceTwin',
74 | sessionId: 'sessionId',
75 | },
76 | ],
77 | directMethod: [
78 | {
79 | deviceId: 'mockedDeviceId',
80 | directMethodName: 'mockedDirectMethod',
81 | type: 'directMethod',
82 | sessionId: 'sessionId',
83 | },
84 | ],
85 | telemetry: [
86 | {
87 | deviceId: 'mockedDeviceId',
88 | type: 'telemetry',
89 | sessionId: 'sessionId',
90 | telemetryKey: 'mockedTelemetryKey',
91 | },
92 | {
93 | deviceId: 'mockedDeviceId2',
94 | type: 'telemetry',
95 | sessionId: 'sessionId',
96 | telemetryKey: 'mockedTelemetryKey2',
97 | },
98 | ],
99 | });
100 | });
101 |
102 | it('correctly determines hasGrantForSubscription', () => {
103 | const telemetrySub = mockedTelemetrySubscription(sessionId);
104 | const telemetrySub2 = mockedTelemetrySubscription2(sessionId);
105 | const telemetrySub3 = mockedTelemetrySubscription3(sessionId);
106 | const deviceTwinSub = mockedDeviceTwinSubscription(sessionId);
107 | const d2cMessageSub = mockedD2CMessagesSubscription(sessionId);
108 | const connectionStateSub = mockedConnectionStateSubscription(sessionId);
109 |
110 | expect(ux4iotState.hasGrantForSubscription(telemetrySub)).toBe(true);
111 | expect(ux4iotState.hasGrantForSubscription(telemetrySub2)).toBe(true);
112 | expect(ux4iotState.hasGrantForSubscription(telemetrySub3)).toBe(false);
113 | expect(ux4iotState.hasGrantForSubscription(deviceTwinSub)).toBe(true);
114 | expect(ux4iotState.hasGrantForSubscription(d2cMessageSub)).toBe(true);
115 | expect(ux4iotState.hasGrantForSubscription(connectionStateSub)).toBe(true);
116 | });
117 | it('removes grants', () => {
118 | ux4iotState.removeGrant(mockedTelemetryGrant2(sessionId));
119 | ux4iotState.removeGrant(mockedConnectionStateGrant(sessionId));
120 | ux4iotState.removeGrant(mockedDeviceTwinGrant(sessionId));
121 | ux4iotState.removeGrant(mockedD2CMessagesGrant(sessionId));
122 | ux4iotState.removeGrant(mockedDesiredPropertiesGrant(sessionId));
123 | ux4iotState.removeGrant(mockedDirectMethodGrant(sessionId));
124 |
125 | expect(ux4iotState.state.grants).toEqual({
126 | deviceTwin: [],
127 | connectionState: [],
128 | d2cMessages: [],
129 | telemetry: [
130 | {
131 | deviceId: 'mockedDeviceId',
132 | type: 'telemetry',
133 | sessionId: 'sessionId',
134 | telemetryKey: 'mockedTelemetryKey',
135 | },
136 | ],
137 | desiredProperties: [],
138 | directMethod: [],
139 | });
140 | });
141 |
142 | it('adds subscriptions', () => {
143 | const m = jest.fn();
144 | const telemetrySub = mockedTelemetrySubscription(sessionId);
145 | const deviceTwinSub = mockedDeviceTwinSubscription(sessionId);
146 | const d2cMessageSub = mockedD2CMessagesSubscription(sessionId);
147 | const connectionStateSub = mockedConnectionStateSubscription(sessionId);
148 |
149 | ux4iotState.addSubscription('sub1', telemetrySub, m);
150 | ux4iotState.addSubscription('sub2', deviceTwinSub, m);
151 | ux4iotState.addSubscription('sub3', d2cMessageSub, m);
152 | ux4iotState.addSubscription('sub4', connectionStateSub, m);
153 |
154 | expect(ux4iotState.state.subscriptions).toEqual({
155 | sub1: [
156 | {
157 | deviceId: 'mockedDeviceId',
158 | onData: m,
159 | telemetryKeys: ['mockedTelemetryKey'],
160 | type: 'telemetry',
161 | sessionId,
162 | },
163 | ],
164 | sub2: [
165 | {
166 | deviceId: 'mockedDeviceId',
167 | onData: m,
168 | type: 'deviceTwin',
169 | sessionId,
170 | },
171 | ],
172 | sub3: [
173 | {
174 | deviceId: 'mockedDeviceId',
175 | onData: m,
176 | type: 'd2cMessages',
177 | sessionId,
178 | },
179 | ],
180 | sub4: [
181 | {
182 | deviceId: 'mockedDeviceId',
183 | onData: m,
184 | type: 'connectionState',
185 | sessionId,
186 | },
187 | ],
188 | });
189 | });
190 |
191 | it('adds new telemetry subscriptions correctly', () => {
192 | const m = jest.fn();
193 | const telemetrySub2 = mockedTelemetrySubscription2(sessionId);
194 | const telemetrySub3 = mockedTelemetrySubscription3(sessionId);
195 |
196 | ux4iotState.addSubscription('sub1', telemetrySub2, m);
197 | ux4iotState.addSubscription('sub1', telemetrySub3, m);
198 |
199 | /*
200 | using JSON.stringify here since otherwise the jest compiler says
201 | Received: serializes to the same string
202 | which I couldnt fix
203 | */
204 | expect(JSON.stringify(ux4iotState.state.subscriptions)).toEqual(
205 | JSON.stringify({
206 | sub1: [
207 | {
208 | type: 'telemetry',
209 | deviceId: 'mockedDeviceId',
210 | telemetryKeys: ['mockedTelemetryKey', 'mockedTelemetryKey2'],
211 | onData: m,
212 | sessionId,
213 | },
214 | {
215 | type: 'telemetry',
216 | deviceId: 'mockedDeviceId2',
217 | telemetryKeys: ['mockedTelemetryKey2'],
218 | onData: m,
219 | sessionId,
220 | },
221 | ],
222 | sub2: [
223 | {
224 | type: 'deviceTwin',
225 | deviceId: 'mockedDeviceId',
226 | onData: m,
227 | sessionId,
228 | },
229 | ],
230 | sub3: [
231 | {
232 | type: 'd2cMessages',
233 | deviceId: 'mockedDeviceId',
234 | onData: m,
235 | sessionId,
236 | },
237 | ],
238 | sub4: [
239 | {
240 | type: 'connectionState',
241 | deviceId: 'mockedDeviceId',
242 | onData: m,
243 | sessionId,
244 | },
245 | ],
246 | })
247 | );
248 | });
249 |
250 | it('checks for subscriptions correctly', () => {
251 | const telemetrySub = mockedTelemetrySubscription(sessionId);
252 | const telemetrySub2 = mockedTelemetrySubscription2(sessionId);
253 | const telemetrySub3 = mockedTelemetrySubscription3(sessionId);
254 | const deviceTwinSub = mockedDeviceTwinSubscription(sessionId);
255 | const d2cMessageSub = mockedD2CMessagesSubscription(sessionId);
256 | const connectionStateSub = mockedConnectionStateSubscription(sessionId);
257 | const unknownSub: TelemetrySubscriptionRequest = {
258 | type: 'telemetry',
259 | deviceId: 'mockedDeviceId',
260 | telemetryKey: 'a',
261 | sessionId,
262 | };
263 |
264 | expect(ux4iotState.hasSubscription('sub1', telemetrySub)).toBe(true);
265 | expect(ux4iotState.hasSubscription('sub1', telemetrySub2)).toBe(true);
266 | expect(ux4iotState.hasSubscription('sub1', telemetrySub3)).toBe(true);
267 | expect(ux4iotState.hasSubscription('sub1', unknownSub)).toBe(false);
268 | expect(ux4iotState.hasSubscription('sub2', deviceTwinSub)).toBe(true);
269 | expect(ux4iotState.hasSubscription('sub3', d2cMessageSub)).toBe(true);
270 | expect(ux4iotState.hasSubscription('sub4', connectionStateSub)).toBe(true);
271 | expect(ux4iotState.hasSubscription('sub4', d2cMessageSub)).toBe(false);
272 | expect(ux4iotState.hasSubscription('sub5', connectionStateSub)).toBe(false);
273 | });
274 |
275 | it('removes telemetry subscriptions', () => {
276 | const telemetrySub = mockedTelemetrySubscription(sessionId);
277 | const deviceTwinSub = mockedDeviceTwinSubscription(sessionId);
278 | const d2cMessageSub = mockedD2CMessagesSubscription(sessionId);
279 | const connectionStateSub = mockedConnectionStateSubscription(sessionId);
280 |
281 | ux4iotState.removeSubscription('sub1', telemetrySub);
282 | ux4iotState.removeSubscription('sub2', deviceTwinSub);
283 | ux4iotState.removeSubscription('sub3', d2cMessageSub);
284 | ux4iotState.removeSubscription('sub4', connectionStateSub);
285 | const m = jest.fn();
286 |
287 | expect(JSON.stringify(ux4iotState.state.subscriptions)).toEqual(
288 | JSON.stringify({
289 | sub1: [
290 | {
291 | type: 'telemetry',
292 | deviceId: 'mockedDeviceId',
293 | telemetryKeys: ['mockedTelemetryKey2'],
294 | onData: m,
295 | sessionId,
296 | },
297 | {
298 | type: 'telemetry',
299 | deviceId: 'mockedDeviceId2',
300 | telemetryKeys: ['mockedTelemetryKey2'],
301 | onData: m,
302 | sessionId,
303 | },
304 | ],
305 | })
306 | );
307 | });
308 |
309 | it('cleans up subscriberId', () => {
310 | ux4iotState.cleanSubId('sub1');
311 |
312 | expect(ux4iotState.state.subscriptions).toEqual({});
313 | });
314 | });
315 |
--------------------------------------------------------------------------------
/src/env.d.ts:
--------------------------------------------------------------------------------
1 | interface ImportMetaEnv {
2 | readonly VITE_UX4IOT_CONNECTION_STRING: string;
3 | }
4 |
5 | interface ImportMeta {
6 | readonly env: ImportMetaEnv;
7 | }
8 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | }
4 |
5 | html,
6 | body {
7 | margin: 0;
8 | padding: 0;
9 | }
10 |
11 | body {
12 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
13 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
14 | sans-serif;
15 | -webkit-font-smoothing: antialiased;
16 | -moz-osx-font-smoothing: grayscale;
17 | }
18 |
19 | .App {
20 | width: 100vw;
21 | display: flex;
22 | flex-direction: column;
23 | }
24 |
25 | .App > div {
26 | margin: 20px;
27 | padding: 10px;
28 | background-color: #eee;
29 | }
30 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import './index.css';
4 | import App from './App';
5 | import reportWebVitals from './reportWebVitals';
6 |
7 | ReactDOM.render(
8 |
9 |
10 | ,
11 | document.getElementById('root')
12 | );
13 |
14 | // If you want to start measuring performance in your app, pass a function
15 | // to log results (for example: reportWebVitals(console.log))
16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
17 | reportWebVitals();
18 |
--------------------------------------------------------------------------------
/src/library/Ux4iotContext.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | ComponentType,
3 | createContext,
4 | ReactNode,
5 | useCallback,
6 | useContext,
7 | useEffect,
8 | useRef,
9 | useState,
10 | } from 'react';
11 | import { InitializationOptions } from './base/types';
12 | import { Ux4iot } from './base/Ux4iot';
13 |
14 | type Ux4iotProviderProps = {
15 | options: InitializationOptions;
16 | children?: ReactNode;
17 | };
18 |
19 | type Ux4iotContextProps = { ux4iot: Ux4iot | undefined; sessionId: string };
20 |
21 | export const Ux4iotContext = createContext({
22 | ux4iot: undefined,
23 | sessionId: '',
24 | });
25 | export const Ux4iotContextProvider: ComponentType = ({
26 | options,
27 | children,
28 | }) => {
29 | const optionsRef = useRef(options);
30 | const [sessionId, setSessionId] = useState('');
31 | const [ux4iot, setUx4iot] = useState();
32 |
33 | function onSessionId(sessionId: string) {
34 | setSessionId(sessionId);
35 | }
36 |
37 | const initialize = useCallback(async () => {
38 | if (!ux4iot) {
39 | try {
40 | const ux4iot = await Ux4iot.create(optionsRef.current, onSessionId);
41 | setUx4iot(ux4iot);
42 | } catch (error) {
43 | console.error(error);
44 | }
45 | }
46 | }, [ux4iot]);
47 |
48 | useEffect(() => {
49 | optionsRef.current = options;
50 | }, [options]);
51 |
52 | useEffect(() => {
53 | initialize();
54 |
55 | return () => {
56 | Ux4iot.destroyInitialization();
57 | if (ux4iot) {
58 | ux4iot.destroy();
59 | setUx4iot(undefined);
60 | setSessionId('');
61 | }
62 | };
63 | }, [ux4iot, initialize]);
64 |
65 | return (
66 |
67 | {children}
68 |
69 | );
70 | };
71 |
72 | export const useUx4iot = () => {
73 | return useContext(Ux4iotContext);
74 | };
75 |
--------------------------------------------------------------------------------
/src/library/base/Ux4iot.ts:
--------------------------------------------------------------------------------
1 | import { io, Socket } from 'socket.io-client';
2 | import {
3 | InitializationOptions,
4 | isDevOptions,
5 | ConnectionUpdateFunction,
6 | MessageCallback,
7 | SubscriptionErrorCallback,
8 | GrantErrorCallback,
9 | GRANT_RESPONSES,
10 | TelemetryCallback,
11 | } from './types';
12 |
13 | import {
14 | CachedValueType,
15 | DesiredPropertyGrantRequest,
16 | DirectMethodGrantRequest,
17 | GrantRequest,
18 | IoTHubResponse,
19 | LastValueConnectionStateResponse,
20 | LastValueDeviceTwinResponse,
21 | LastValueObj,
22 | LastValueResponse,
23 | LastValueTelemetryResponse,
24 | Message,
25 | SubscriptionRequest,
26 | TelemetrySubscriptionRequest,
27 | } from './ux4iot-shared';
28 |
29 | import { Ux4iotApi } from './Ux4iotApi';
30 | import * as ux4iotState from './ux4iotState';
31 | import {
32 | getGrantFromSubscriptionRequest,
33 | isConnectionStateMessage,
34 | isD2CMessage,
35 | isDeviceTwinMessage,
36 | isTelemetryMessage,
37 | } from './utils';
38 | import { DeviceMethodParams } from 'azure-iothub';
39 | import { AxiosError } from 'axios';
40 | import { ConnectionUpdateReason } from './types';
41 |
42 | const RECONNECT_TIMEOUT = 5000;
43 | const MAX_RECONNECT_TIMEOUT = 30000;
44 | const NETWORK_STATES: Record = {
45 | UX4IOT_OFFLINE: [
46 | 'ux4iot_unreachable',
47 | 'Failed to fetch sessionId of ux4iot.',
48 | ],
49 | SERVER_UNAVAILABLE: [
50 | 'socket_connect_error',
51 | 'Could not establish connection to ux4iot websocket',
52 | ],
53 | CLIENT_DISCONNECTED: ['socket_disconnect', 'Client manually disconnected'],
54 | SERVER_DISCONNECTED: [
55 | 'socket_disconnect',
56 | 'Disconnected / Error Connecting.',
57 | ],
58 | CONNECTED: ['socket_connect', 'Connected to ux4iot websocket'],
59 | };
60 |
61 | function nextTimeout(
62 | timeout: number = RECONNECT_TIMEOUT,
63 | maxTimeout: number = MAX_RECONNECT_TIMEOUT
64 | ) {
65 | return timeout + timeout > maxTimeout ? maxTimeout : timeout + timeout;
66 | }
67 |
68 | export class Ux4iot {
69 | sessionId = '';
70 | socket: Socket | undefined;
71 | devMode: boolean;
72 | api: Ux4iotApi;
73 | retryTimeout: number;
74 | maxRetryTimeout: number;
75 | retryTimeoutAfterError?: NodeJS.Timeout;
76 | static initializationTimeout?: NodeJS.Timeout;
77 | onSocketConnectionUpdate?: ConnectionUpdateFunction;
78 | onSessionId?: (sessionId: string) => void;
79 |
80 | private constructor(
81 | options: InitializationOptions,
82 | sessionId: string,
83 | api: Ux4iotApi,
84 | onSessionId?: (sessionId: string) => void
85 | ) {
86 | this.sessionId = sessionId;
87 | this.api = api;
88 | this.devMode = isDevOptions(options);
89 | this.retryTimeout = options.reconnectTimeout ?? RECONNECT_TIMEOUT;
90 | this.maxRetryTimeout = options.maxReconnectTimeout ?? MAX_RECONNECT_TIMEOUT;
91 | this.onSessionId = onSessionId;
92 | this.onSocketConnectionUpdate = options.onSocketConnectionUpdate;
93 | this.initializeSocket();
94 | }
95 |
96 | public static async create(
97 | options: InitializationOptions,
98 | onSessionId?: (sessionId: string) => void
99 | ): Promise {
100 | const { onSocketConnectionUpdate, reconnectTimeout, maxReconnectTimeout } =
101 | options;
102 | const timeout = reconnectTimeout ?? RECONNECT_TIMEOUT;
103 | const maxTimeout = maxReconnectTimeout ?? MAX_RECONNECT_TIMEOUT;
104 | const api = new Ux4iotApi(options);
105 | try {
106 | const sessionId = await api.getSessionId();
107 | api.setSessionId(sessionId);
108 | clearTimeout(Ux4iot.initializationTimeout);
109 | return new Ux4iot(options, sessionId, api, onSessionId);
110 | } catch (error) {
111 | const [reason, description] = NETWORK_STATES.UX4IOT_OFFLINE;
112 | onSocketConnectionUpdate?.(reason, description);
113 |
114 | console.warn(
115 | `Trying to initialize again in ${timeout / 1000} seconds...`
116 | );
117 |
118 | const nextOptions = {
119 | ...options,
120 | reconnectTimeout: nextTimeout(timeout, maxTimeout),
121 | maxReconnectTimeout: maxTimeout,
122 | };
123 |
124 | return new Promise((resolve, reject) => {
125 | Ux4iot.initializationTimeout = setTimeout(() => {
126 | return resolve(Ux4iot.create(nextOptions, onSessionId));
127 | }, timeout);
128 | });
129 | }
130 | }
131 |
132 | public static destroyInitialization() {
133 | if (Ux4iot.initializationTimeout) {
134 | console.warn('Initialization interval canceled');
135 | clearTimeout(Ux4iot.initializationTimeout);
136 | }
137 | }
138 |
139 | private initializeSocket() {
140 | const socketURI = this.api.getSocketURL(this.sessionId);
141 | this.socket = io(socketURI, { transports: ['websocket', 'polling'] });
142 | this.socket.on('connect', this.onConnect.bind(this));
143 | this.socket.on('connect_error', this.onConnectError.bind(this));
144 | this.socket.on('disconnect', this.onDisconnect.bind(this));
145 | this.socket.on('data', this.onData.bind(this));
146 | }
147 |
148 | private tryReconnect(timeout: number = this.retryTimeout) {
149 | clearTimeout(this.retryTimeoutAfterError);
150 |
151 | this.log(`Trying to reconnect in ${timeout / 1000} seconds...`);
152 |
153 | this.retryTimeoutAfterError = setTimeout(async () => {
154 | if (!this.socket) {
155 | try {
156 | const sessionId = await this.api.getSessionId();
157 | this.api.setSessionId(sessionId);
158 | this.sessionId = sessionId;
159 | } catch (error) {
160 | const [reason, description] = NETWORK_STATES.UX4IOT_OFFLINE;
161 | this.log(reason, description, error);
162 | this.onSocketConnectionUpdate?.(reason, description);
163 | this.tryReconnect(
164 | nextTimeout(timeout ?? this.retryTimeout, this.maxRetryTimeout)
165 | );
166 | return;
167 | }
168 | this.initializeSocket();
169 | }
170 | }, timeout);
171 | }
172 |
173 | private async onConnect() {
174 | this.log(`Connected to ${this.api.getSocketURL(this.sessionId)}`);
175 | this.log('Successfully reconnected. Resubscribing to old state...');
176 | ux4iotState.resetState();
177 | console.log('onSessionId called with', this.sessionId);
178 | this.onSessionId?.(this.sessionId); // this callback should be used to reestablish all subscriptions
179 | this.onSocketConnectionUpdate?.(...NETWORK_STATES.CONNECTED);
180 | clearTimeout(this.retryTimeoutAfterError);
181 | }
182 |
183 | private onConnectError() {
184 | this.log(`on connect error called`);
185 | const socketURL = this.api.getSocketURL(this.sessionId);
186 | this.log(`Failed to establish websocket to ${socketURL}`);
187 | const [reason, description] = NETWORK_STATES.SERVER_UNAVAILABLE;
188 | this.onSocketConnectionUpdate?.(reason, description);
189 | this.tryReconnect();
190 | }
191 |
192 | private onDisconnect(error: unknown) {
193 | this.log(`on disconnect called`);
194 | if (error === 'io client disconnect') {
195 | // https://socket.io/docs/v4/client-api/#event-disconnect
196 | const [reason, description] = NETWORK_STATES.CLIENT_DISCONNECTED;
197 | this.log(reason, description, error);
198 | this.onSocketConnectionUpdate?.(reason, description);
199 | } else {
200 | const [reason, description] = NETWORK_STATES.SERVER_DISCONNECTED;
201 | this.log(reason, description, error);
202 | this.onSocketConnectionUpdate?.(reason, description);
203 | this.socket = undefined;
204 | this.tryReconnect();
205 | }
206 | }
207 |
208 | public async destroy(): Promise {
209 | this.socket?.disconnect();
210 | this.socket = undefined;
211 | clearTimeout(this.retryTimeoutAfterError);
212 | this.log('socket with id', this.sessionId, 'destroyed');
213 | }
214 |
215 | private async onData(m: Message) {
216 | for (const subscriptions of Object.values(
217 | ux4iotState.state.subscriptions
218 | )) {
219 | for (const sub of subscriptions) {
220 | const { type, deviceId } = sub;
221 | if (deviceId === m.deviceId) {
222 | switch (type) {
223 | case 'telemetry': {
224 | if (isTelemetryMessage(m)) {
225 | const telemetry: Record = {};
226 | for (const telemetryKey of sub.telemetryKeys) {
227 | if (m.telemetry[telemetryKey] !== undefined) {
228 | telemetry[telemetryKey] = m.telemetry[telemetryKey];
229 | }
230 | }
231 | sub.onData(m.deviceId, telemetry, m.timestamp);
232 | }
233 | break;
234 | }
235 | case 'connectionState':
236 | isConnectionStateMessage(m) &&
237 | sub.onData(m.deviceId, m.connectionState, m.timestamp);
238 | break;
239 | case 'd2cMessages':
240 | isD2CMessage(m) && sub.onData(m.deviceId, m.message, m.timestamp);
241 | break;
242 | case 'deviceTwin':
243 | isDeviceTwinMessage(m) &&
244 | sub.onData(m.deviceId, m.deviceTwin, m.timestamp);
245 | break;
246 | }
247 | }
248 | }
249 | }
250 | }
251 |
252 | async patchDesiredProperties(
253 | grantRequest: DesiredPropertyGrantRequest,
254 | patch: Record,
255 | onGrantError?: GrantErrorCallback
256 | ): Promise {
257 | await this.grant(grantRequest, onGrantError);
258 | if (ux4iotState.hasGrant(grantRequest)) {
259 | await this.api.patchDesiredProperties(grantRequest.deviceId, patch);
260 | }
261 | }
262 |
263 | async invokeDirectMethod(
264 | grantRequest: DirectMethodGrantRequest,
265 | options: DeviceMethodParams,
266 | onGrantError?: GrantErrorCallback
267 | ): Promise {
268 | await this.grant(grantRequest, onGrantError);
269 | if (ux4iotState.hasGrant(grantRequest)) {
270 | return await this.api.invokeDirectMethod(grantRequest.deviceId, options);
271 | }
272 | }
273 |
274 | async grant(grantRequest: GrantRequest, onGrantError?: GrantErrorCallback) {
275 | if (ux4iotState.hasGrant(grantRequest)) {
276 | return;
277 | }
278 | const grantResponse = await this.api.requestGrant(grantRequest);
279 | if (grantResponse === GRANT_RESPONSES.GRANTED) {
280 | ux4iotState.addGrant(grantRequest);
281 | } else {
282 | onGrantError?.(grantResponse);
283 | }
284 | }
285 |
286 | async subscribe(
287 | subscriberId: string,
288 | subscriptionRequest: SubscriptionRequest,
289 | onData: MessageCallback,
290 | onSubscriptionError?: SubscriptionErrorCallback,
291 | onGrantError?: GrantErrorCallback
292 | ) {
293 | const grantRequest = getGrantFromSubscriptionRequest(subscriptionRequest);
294 |
295 | await this.grant(grantRequest, onGrantError);
296 | if (ux4iotState.hasGrant(grantRequest)) {
297 | const response = await this.getLastValueForSubscriptionRequest(
298 | subscriptionRequest
299 | );
300 | onData(response.deviceId, response.data as any, response.timestamp);
301 | try {
302 | // this if block is used as an optimization.
303 | // When the number of subscribers is bigger than 0 then we do not need to fire a subscription request
304 | // If the request fails, then we do not need to remove the subscription, since it will only be added after
305 | // the subscribe request is successful
306 | // If the number of subscribers isn't 0 then we know that the request succeeded in the past
307 | if (ux4iotState.getNumberOfSubscribers(subscriptionRequest) === 0) {
308 | await this.api.subscribe(subscriptionRequest);
309 | }
310 | ux4iotState.addSubscription(subscriberId, subscriptionRequest, onData);
311 | } catch (error) {
312 | onSubscriptionError?.((error as AxiosError).response?.data);
313 | }
314 | } else {
315 | onSubscriptionError?.('No grant for subscription');
316 | ux4iotState.removeSubscription(subscriberId, subscriptionRequest);
317 | }
318 | }
319 |
320 | async unsubscribe(
321 | subscriberId: string,
322 | subscriptionRequest: SubscriptionRequest,
323 | onSubscriptionError?: SubscriptionErrorCallback,
324 | onGrantError?: GrantErrorCallback
325 | ) {
326 | const grantRequest = getGrantFromSubscriptionRequest(subscriptionRequest);
327 | await this.grant(grantRequest, onGrantError);
328 | if (ux4iotState.hasGrant(grantRequest)) {
329 | try {
330 | if (ux4iotState.getNumberOfSubscribers(subscriptionRequest) === 1) {
331 | await this.api.unsubscribe(subscriptionRequest);
332 | }
333 | ux4iotState.removeSubscription(subscriberId, subscriptionRequest);
334 | } catch (error) {
335 | onSubscriptionError?.(error);
336 | }
337 | } else {
338 | onSubscriptionError?.('No grant for subscription');
339 | }
340 | }
341 |
342 | async subscribeAllTelemetry(
343 | sessionId: string,
344 | deviceId: string,
345 | telemetryKeys: string[],
346 | subscriberId: string,
347 | onData: TelemetryCallback,
348 | onSubscriptionError?: SubscriptionErrorCallback,
349 | onGrantError?: GrantErrorCallback
350 | ) {
351 | const subscriptionRequests: TelemetrySubscriptionRequest[] =
352 | telemetryKeys.map(telemetryKey => ({
353 | sessionId,
354 | deviceId,
355 | telemetryKey,
356 | type: 'telemetry',
357 | }));
358 | const grantRequest: GrantRequest = {
359 | sessionId,
360 | deviceId,
361 | type: 'telemetry',
362 | telemetryKey: null,
363 | };
364 | await this.grant(grantRequest, onGrantError);
365 | if (ux4iotState.hasGrant(grantRequest)) {
366 | const response = await this.getLastTelemetryValues(
367 | deviceId,
368 | subscriptionRequests
369 | );
370 | // response.data = { [telemetryKey]: { value: v, timestamp: t }, ...}
371 | onData(response.deviceId, response.data, response.timestamp);
372 | try {
373 | const filteredSubscriptions = subscriptionRequests.filter(
374 | s => ux4iotState.getNumberOfSubscribers(s) === 0
375 | );
376 | if (filteredSubscriptions.length > 0) {
377 | await this.api.subscribeAll(filteredSubscriptions);
378 | }
379 | // we have to iterate over all subscriptionRequests because the state needs to save all subscribers with subscriberId
380 | for (const sr of subscriptionRequests) {
381 | ux4iotState.addSubscription(subscriberId, sr, onData);
382 | }
383 | } catch (error) {
384 | onSubscriptionError?.((error as AxiosError).response?.data);
385 | }
386 | } else {
387 | onSubscriptionError?.('No grant for subscription');
388 | for (const sr of subscriptionRequests) {
389 | ux4iotState.removeSubscription(subscriberId, sr);
390 | }
391 | }
392 | }
393 |
394 | async unsubscribeAllTelemetry(
395 | sessionId: string,
396 | deviceId: string,
397 | telemetryKeys: string[],
398 | subscriberId: string,
399 | onSubscriptionError?: SubscriptionErrorCallback,
400 | onGrantError?: GrantErrorCallback
401 | ) {
402 | const subscriptionRequests: TelemetrySubscriptionRequest[] =
403 | telemetryKeys.map(telemetryKey => ({
404 | sessionId,
405 | deviceId,
406 | telemetryKey,
407 | type: 'telemetry',
408 | }));
409 | const grantRequest: GrantRequest = {
410 | sessionId,
411 | deviceId,
412 | type: 'telemetry',
413 | telemetryKey: null,
414 | };
415 | await this.grant(grantRequest, onGrantError);
416 | if (ux4iotState.hasGrant(grantRequest)) {
417 | try {
418 | const filteredSubscriptions = subscriptionRequests.filter(
419 | s => ux4iotState.getNumberOfSubscribers(s) === 1
420 | );
421 | if (filteredSubscriptions.length > 0) {
422 | await this.api.unsubscribeAll(filteredSubscriptions);
423 | }
424 | // we have to iterate over all subscriptionRequests because the state needs to save all subscribers with subscriberId
425 | for (const sr of subscriptionRequests) {
426 | ux4iotState.removeSubscription(subscriberId, sr);
427 | }
428 | } catch (error) {
429 | onSubscriptionError?.((error as AxiosError).response?.data);
430 | }
431 | } else {
432 | onSubscriptionError?.('No grant for subscription');
433 | }
434 | }
435 |
436 | hasSubscription(
437 | subscriberId: string,
438 | subscriptionRequest: SubscriptionRequest
439 | ) {
440 | return ux4iotState.hasSubscription(subscriberId, subscriptionRequest);
441 | }
442 |
443 | getSubscriberIdSubscriptions(subscriberId: string): Record {
444 | const registered = ux4iotState.state.subscriptions[subscriberId];
445 | const subscriptions: Record = {};
446 |
447 | if (registered) {
448 | for (const sub of registered) {
449 | if (sub.type === 'telemetry') {
450 | subscriptions[sub.deviceId] = sub.telemetryKeys;
451 | } else {
452 | subscriptions[sub.deviceId] = [];
453 | }
454 | }
455 | }
456 | return subscriptions;
457 | }
458 |
459 | async removeSubscriberId(subscriberId: string, sessionId: string) {
460 | const subscriptions = ux4iotState.state.subscriptions[subscriberId];
461 | if (subscriptions) {
462 | const byDeviceId = subscriptions.reduce<
463 | Record
464 | >((acc, sub) => {
465 | (acc[sub.deviceId] = acc[sub.deviceId] || []).push(sub);
466 | return acc;
467 | }, {});
468 |
469 | for (const [deviceId, subscriptions] of Object.entries(byDeviceId)) {
470 | for (const sub of subscriptions) {
471 | try {
472 | if (sub.type === 'telemetry') {
473 | await this.unsubscribeAllTelemetry(
474 | sessionId,
475 | deviceId,
476 | sub.telemetryKeys,
477 | subscriberId
478 | );
479 | } else {
480 | await this.unsubscribe(subscriberId, sub);
481 | }
482 | } catch (error) {
483 | console.warn(
484 | 'could not unsubscribe subscriberId',
485 | subscriberId,
486 | error
487 | );
488 | }
489 | }
490 | }
491 | }
492 | ux4iotState.cleanSubId(subscriberId);
493 | }
494 |
495 | async getLastTelemetryValues(
496 | deviceId: string,
497 | subscriptionRequest: TelemetrySubscriptionRequest[]
498 | ): Promise>>> {
499 | const telemetryKeys = subscriptionRequest.map(
500 | sr => sr.telemetryKey as string
501 | );
502 | return this.api.getLastTelemetryValues(deviceId, telemetryKeys);
503 | }
504 |
505 | async getLastValueForSubscriptionRequest(
506 | subscriptionRequest: SubscriptionRequest
507 | ): Promise<
508 | | LastValueTelemetryResponse
509 | | LastValueConnectionStateResponse
510 | | LastValueDeviceTwinResponse
511 | | LastValueResponse
512 | > {
513 | const { type, deviceId } = subscriptionRequest;
514 | try {
515 | switch (type) {
516 | case 'connectionState':
517 | return await this.api.getLastConnectionState(deviceId);
518 | case 'deviceTwin':
519 | return await this.api.getLastDeviceTwin(deviceId);
520 | case 'telemetry': {
521 | const { telemetryKey } = subscriptionRequest;
522 | return await this.api.getLastTelemetryValue(
523 | deviceId,
524 | telemetryKey as string
525 | );
526 | }
527 | case 'd2cMessages':
528 | return Promise.resolve({ deviceId, data: undefined, timestamp: '' });
529 | default:
530 | return Promise.resolve({ deviceId, data: undefined, timestamp: '' });
531 | }
532 | } catch (error) {
533 | this.log((error as AxiosError).response?.data);
534 | return Promise.resolve({ deviceId, data: undefined, timestamp: '' });
535 | }
536 | }
537 |
538 | private log(...args: any[]) {
539 | if (this.devMode) {
540 | console.warn('ux4iot:', ...args);
541 | }
542 | }
543 | }
544 |
--------------------------------------------------------------------------------
/src/library/base/Ux4iotApi.ts:
--------------------------------------------------------------------------------
1 | import axios, { AxiosInstance } from 'axios';
2 | import { DeviceMethodParams } from 'azure-iothub';
3 | import {
4 | GrantRequestFunctionType,
5 | GRANT_RESPONSES,
6 | InitializationOptions,
7 | isDevOptions,
8 | } from './types';
9 | import { printDevModeWarning } from './utils';
10 | import {
11 | CachedValueType,
12 | GrantRequest,
13 | IoTHubResponse,
14 | LastValueConnectionStateResponse,
15 | LastValueDeviceTwinResponse,
16 | LastValueObj,
17 | LastValueTelemetryResponse,
18 | parseConnectionString,
19 | SubscriptionRequest,
20 | } from './ux4iot-shared';
21 |
22 | export class Ux4iotApi {
23 | private axiosInstance: AxiosInstance;
24 | private grantRequestFunction: GrantRequestFunctionType;
25 | private ux4iotUrl: string;
26 | private sessionId?: string;
27 |
28 | constructor(initializationOptions: InitializationOptions) {
29 | if (isDevOptions(initializationOptions)) {
30 | printDevModeWarning();
31 | const { Endpoint, SharedAccessKey } = parseConnectionString(
32 | initializationOptions.adminConnectionString
33 | );
34 | this.axiosInstance = axios.create({
35 | baseURL: Endpoint,
36 | headers: { 'Shared-Access-Key': SharedAccessKey },
37 | });
38 | this.ux4iotUrl = Endpoint;
39 | this.grantRequestFunction = this.defaultGrantRequestFunction;
40 | } else {
41 | const { ux4iotURL, grantRequestFunction } = initializationOptions;
42 | this.axiosInstance = axios.create({
43 | baseURL: ux4iotURL,
44 | });
45 | this.ux4iotUrl = ux4iotURL;
46 | this.grantRequestFunction = grantRequestFunction;
47 | }
48 | }
49 |
50 | private async defaultGrantRequestFunction(grant: GrantRequest) {
51 | try {
52 | await this.axiosInstance.put('/grants', grant);
53 | } catch (error) {
54 | if (axios.isAxiosError(error)) {
55 | if (error.response) {
56 | if (error.response.status === 401) {
57 | return GRANT_RESPONSES.UNAUTHORIZED;
58 | } else if (error.response.status === 403) {
59 | return GRANT_RESPONSES.FORBIDDEN;
60 | }
61 | }
62 | }
63 | return GRANT_RESPONSES.ERROR;
64 | }
65 | return GRANT_RESPONSES.GRANTED;
66 | }
67 |
68 | public setSessionId(sessionId: string) {
69 | this.sessionId = sessionId;
70 | }
71 |
72 | public async getSessionId(): Promise {
73 | const response = await this.axiosInstance.post('/session');
74 | return response.data.sessionId;
75 | }
76 |
77 | public getSocketURL(sessionId: string) {
78 | return `${this.ux4iotUrl}?sessionId=${sessionId}`;
79 | }
80 |
81 | public async requestGrant(
82 | grantBase: Omit
83 | ): Promise {
84 | if (!this.sessionId) return Promise.reject('There is no ux4iot session');
85 |
86 | const grant = { ...grantBase, sessionId: this.sessionId } as GrantRequest;
87 | return this.grantRequestFunction(grant);
88 | }
89 |
90 | public async subscribe(
91 | subscriptionRequestBase: Omit
92 | ): Promise {
93 | if (!this.sessionId) return Promise.reject('There is no ux4iot session');
94 |
95 | await this.axiosInstance.put('/subscription', {
96 | ...subscriptionRequestBase,
97 | sessionId: this.sessionId,
98 | });
99 | }
100 |
101 | public async unsubscribe(
102 | subscriptionRequestBase: Omit
103 | ): Promise {
104 | if (!this.sessionId) return Promise.reject('There is no ux4iot session');
105 |
106 | await this.axiosInstance.delete('/subscription', {
107 | data: { ...subscriptionRequestBase, sessionId: this.sessionId },
108 | });
109 | }
110 |
111 | public async subscribeAll(
112 | subscriptionRequests: SubscriptionRequest[]
113 | ): Promise {
114 | if (!this.sessionId) return Promise.reject('There is no ux4iot session');
115 |
116 | await this.axiosInstance.put(
117 | '/subscriptions',
118 | subscriptionRequests.map(sr => ({ ...sr, sessionId: this.sessionId }))
119 | );
120 | }
121 |
122 | public async unsubscribeAll(
123 | subscriptionRequests: SubscriptionRequest[]
124 | ): Promise {
125 | if (!this.sessionId) return Promise.reject('There is no ux4iot session');
126 |
127 | await this.axiosInstance.delete('/subscriptions', {
128 | data: subscriptionRequests.map(sr => ({
129 | ...sr,
130 | sessionId: this.sessionId,
131 | })),
132 | });
133 | }
134 |
135 | public async getLastTelemetryValue(
136 | deviceId: string,
137 | telemetryKey: string
138 | ): Promise {
139 | if (!this.sessionId) return Promise.reject('There is no ux4iot session');
140 |
141 | const response = await this.axiosInstance.get(
142 | `/lastValue/${deviceId}/${telemetryKey}`,
143 | { headers: { sessionId: this.sessionId } }
144 | );
145 | return response.data;
146 | }
147 |
148 | public async getLastTelemetryValues(
149 | deviceId: string,
150 | telemetryKeys: string[]
151 | ): Promise {
152 | const response = await this.axiosInstance.get(`/lastValue/${deviceId}`, {
153 | headers: { sessionId: this.sessionId },
154 | });
155 | const telemetryValues: Record> = {};
156 | for (const [key, value] of Object.entries(response.data.data)) {
157 | if (telemetryKeys.includes(key)) {
158 | telemetryValues[key as string] = value as LastValueObj;
159 | }
160 | }
161 | return {
162 | deviceId: response.data.deviceId,
163 | data: telemetryValues,
164 | timestamp: response.data.timestamp,
165 | };
166 | }
167 |
168 | public async getLastDeviceTwin(
169 | deviceId: string
170 | ): Promise {
171 | if (!this.sessionId) return Promise.reject('There is no ux4iot session');
172 |
173 | const response = await this.axiosInstance.get(`/deviceTwin/${deviceId}`, {
174 | headers: { sessionId: this.sessionId },
175 | });
176 | return response.data;
177 | }
178 |
179 | public async getLastConnectionState(
180 | deviceId: string
181 | ): Promise {
182 | if (!this.sessionId) return Promise.reject('There is no ux4iot session');
183 |
184 | const response = await this.axiosInstance.get(
185 | `/connectionState/${deviceId}`,
186 | { headers: { sessionId: this.sessionId } }
187 | );
188 |
189 | return response.data;
190 | }
191 |
192 | public async invokeDirectMethod(
193 | deviceId: string,
194 | options: DeviceMethodParams
195 | ): Promise {
196 | if (!this.sessionId) return Promise.reject('There is no ux4iot session');
197 |
198 | const response = await this.axiosInstance.post(
199 | '/directMethod',
200 | { deviceId, methodParams: options },
201 | { headers: { sessionId: this.sessionId } }
202 | );
203 |
204 | return response.data;
205 | }
206 |
207 | public async patchDesiredProperties(
208 | deviceId: string,
209 | desiredPropertyPatch: Record
210 | ): Promise {
211 | if (!this.sessionId) return Promise.reject('There is no ux4iot session');
212 |
213 | const response = await this.axiosInstance.patch(
214 | '/deviceTwinDesiredProperties',
215 | { deviceId, desiredPropertyPatch },
216 | { headers: { sessionId: this.sessionId } }
217 | );
218 |
219 | return response.data;
220 | }
221 | }
222 |
--------------------------------------------------------------------------------
/src/library/base/types.ts:
--------------------------------------------------------------------------------
1 | import { GrantRequest, TwinUpdate } from './ux4iot-shared';
2 |
3 | export type Subscribers = Record;
4 |
5 | export enum GRANT_RESPONSES {
6 | FORBIDDEN = 'FORBIDDEN',
7 | UNAUTHORIZED = 'UNAUTHORIZED',
8 | GRANTED = 'GRANTED',
9 | ERROR = 'ERROR',
10 | }
11 |
12 | export type GrantRequestFunctionType = (
13 | grant: GrantRequest
14 | ) => Promise;
15 |
16 | export type ConnectionUpdateReason =
17 | | 'socket_connect'
18 | | 'socket_connect_error'
19 | | 'socket_disconnect'
20 | | 'ux4iot_unreachable';
21 |
22 | export type ConnectionUpdateFunction = (
23 | reason: ConnectionUpdateReason,
24 | description?: string
25 | ) => void;
26 |
27 | export type InitializationOptions = {
28 | onSocketConnectionUpdate?: ConnectionUpdateFunction;
29 | reconnectTimeout?: number;
30 | maxReconnectTimeout?: number;
31 | } & (InitializeDevOptions | InitializeProdOptions);
32 | export type InitializeDevOptions = {
33 | adminConnectionString: string;
34 | };
35 | export type InitializeProdOptions = {
36 | ux4iotURL: string;
37 | grantRequestFunction: GrantRequestFunctionType;
38 | };
39 | export function isDevOptions(
40 | options: Record
41 | ): options is InitializeDevOptions {
42 | return !!options.adminConnectionString;
43 | }
44 | export function isProdOptions(
45 | options: Record
46 | ): options is InitializeProdOptions {
47 | return !!options.grantRequestFunction && !!options.ux4iotURL;
48 | }
49 |
50 | export type MessageCallbackBase = (
51 | deviceId: string,
52 | data: T | undefined,
53 | timestamp: string
54 | ) => void;
55 |
56 | export type TelemetryCallback = MessageCallbackBase>;
57 | export type DeviceTwinCallback = MessageCallbackBase;
58 | export type ConnectionStateCallback = MessageCallbackBase;
59 | export type D2CMessageCallback = MessageCallbackBase>;
60 |
61 | export type MessageCallback =
62 | | TelemetryCallback
63 | | DeviceTwinCallback
64 | | ConnectionStateCallback
65 | | D2CMessageCallback;
66 |
67 | export type PatchDesiredPropertiesOptions = Record;
68 |
69 | export type GrantErrorCallback = (error: GRANT_RESPONSES) => void;
70 | export type SubscriptionErrorCallback = (error: any) => void;
71 |
--------------------------------------------------------------------------------
/src/library/base/utils.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ConnectionStateMessage,
3 | DeviceTwinMessage,
4 | TelemetryMessage,
5 | D2CMessage,
6 | GrantRequest,
7 | SubscriptionRequest,
8 | TelemetryGrantRequest,
9 | } from './ux4iot-shared';
10 |
11 | const DELIMITER = '___';
12 |
13 | export function makeSubKey(...args: unknown[]): string {
14 | return args.filter(arg => !!arg).join(DELIMITER);
15 | }
16 |
17 | export function parseSubKey(subscriberKey: string): Record {
18 | const [subscriberId, deviceId, telemetryKey] = subscriberKey.split(DELIMITER);
19 |
20 | return { subscriberId, deviceId, telemetryKey };
21 | }
22 |
23 | export function deserializeTelemetrySubscriberState(
24 | subscriberId: string,
25 | state: Record
26 | ): Record {
27 | return Object.keys(state).reduce>((obj, key) => {
28 | const { subscriberId: id, deviceId, telemetryKey } = parseSubKey(key);
29 | if (subscriberId === id) {
30 | if (obj[deviceId]) {
31 | obj[deviceId].push(telemetryKey);
32 | } else {
33 | obj[deviceId] = [telemetryKey];
34 | }
35 | }
36 | return obj;
37 | }, {});
38 | }
39 |
40 | export function deserializeSubscriberState(
41 | subscriberId: string,
42 | state: Record
43 | ): string[] {
44 | return Object.keys(state).reduce((deviceIds, key) => {
45 | const { subscriberId: id, deviceId } = parseSubKey(key);
46 | if (subscriberId === id) {
47 | deviceIds.push(deviceId);
48 | }
49 | return deviceIds;
50 | }, []);
51 | }
52 |
53 | export function cleanSubId>(
54 | id: string,
55 | subscribers: T
56 | ): T {
57 | return Object.keys(subscribers).reduce((nextSubs, subKey) => {
58 | const { subscriberId } = parseSubKey(subKey);
59 | if (subscriberId !== id) {
60 | nextSubs[subKey as keyof T] = subscribers[subKey];
61 | }
62 | return nextSubs;
63 | }, {} as T);
64 | }
65 |
66 | export function printDevModeWarning(): void {
67 | console.log(
68 | `%c
69 | _____________________________________________________
70 | | |
71 | | Warning |
72 | | You are using ux4iot-react in Development mode. |
73 | | |
74 | | Don't use this in production, follow the link for |
75 | | more information: https://bit.ly/3igAntF |
76 | |_____________________________________________________|
77 | `,
78 | 'color: red; font-weight: bold; font-size: 14px;'
79 | );
80 | }
81 |
82 | export function isConnectionStateMessage(
83 | message: Record
84 | ): message is ConnectionStateMessage {
85 | return message.connectionState !== undefined;
86 | }
87 |
88 | export function isD2CMessage(
89 | message: Record
90 | ): message is D2CMessage {
91 | return !!message.message;
92 | }
93 |
94 | export function isDeviceTwinMessage(
95 | message: Record
96 | ): message is DeviceTwinMessage {
97 | return !!message.deviceTwin;
98 | }
99 |
100 | export function isTelemetryMessage(
101 | message: Record
102 | ): message is TelemetryMessage {
103 | return !!message.telemetry;
104 | }
105 |
106 | export function grantRequestsEqual(g1: GrantRequest, g2: GrantRequest) {
107 | if (
108 | g1.type === g2.type &&
109 | g1.sessionId === g2.sessionId &&
110 | g1.deviceId === g2.deviceId
111 | ) {
112 | switch (g1.type) {
113 | case 'desiredProperties':
114 | case 'deviceTwin':
115 | case 'connectionState':
116 | case 'd2cMessages':
117 | return true;
118 | case 'directMethod':
119 | return g1.directMethodName === (g2 as typeof g1).directMethodName;
120 | case 'telemetry':
121 | return g1.telemetryKey === (g2 as typeof g1).telemetryKey;
122 | default:
123 | return false;
124 | }
125 | }
126 | return false;
127 | }
128 |
129 | export function getGrantFromSubscriptionRequest(
130 | subscriptionRequest: SubscriptionRequest
131 | ): GrantRequest {
132 | const { type, sessionId, deviceId } = subscriptionRequest;
133 | const grantRequest: GrantRequest = { sessionId, deviceId, type };
134 | switch (type) {
135 | case 'connectionState':
136 | case 'deviceTwin':
137 | case 'd2cMessages':
138 | return grantRequest;
139 | case 'telemetry': {
140 | const { telemetryKey } = subscriptionRequest;
141 | return { ...grantRequest, telemetryKey } as TelemetryGrantRequest;
142 | }
143 | default:
144 | throw Error('No such grantType for subscriptionType');
145 | }
146 | }
147 |
--------------------------------------------------------------------------------
/src/library/base/ux4iot-shared.ts:
--------------------------------------------------------------------------------
1 | // twins won't change in the forseeable future so instead of using the "Twin['properties] type"
2 |
3 | // of the azure-iothub library a manual typing is used. azure-iothub isn't really getting updated..
4 | export type TwinUpdate = {
5 | version: number;
6 | properties: {
7 | reported: {
8 | [key: string]: any;
9 | };
10 | desired: {
11 | [key: string]: any;
12 | };
13 | };
14 | };
15 |
16 | export type DeviceId = string;
17 |
18 | // Requests
19 | type GrantRequestBase = {
20 | deviceId: string;
21 | sessionId: string;
22 | } & T;
23 | export type TelemetryGrantRequest = GrantRequestBase<{
24 | type: 'telemetry';
25 | telemetryKey?: string | null; // null means: Access to all telemetry keys
26 | }>;
27 | export type DeviceTwinGrantRequest = GrantRequestBase<{
28 | type: 'deviceTwin';
29 | }>;
30 | export type ConnectionStateGrantRequest = GrantRequestBase<{
31 | type: 'connectionState';
32 | }>;
33 | export type DesiredPropertyGrantRequest = GrantRequestBase<{
34 | type: 'desiredProperties';
35 | }>;
36 | export type DirectMethodGrantRequest = GrantRequestBase<{
37 | type: 'directMethod';
38 | directMethodName: string | null; // null means: Access to all direct methods
39 | }>;
40 | export type D2CMessageGrantRequest = GrantRequestBase<{
41 | type: 'd2cMessages';
42 | }>;
43 |
44 | export type GrantRequest =
45 | | TelemetryGrantRequest
46 | | DeviceTwinGrantRequest
47 | | ConnectionStateGrantRequest
48 | | DesiredPropertyGrantRequest
49 | | DirectMethodGrantRequest
50 | | D2CMessageGrantRequest;
51 |
52 | type SubscriptionRequestBase = {
53 | deviceId: string;
54 | sessionId: string;
55 | } & T;
56 | export type TelemetrySubscriptionRequest = SubscriptionRequestBase<{
57 | type: 'telemetry';
58 | telemetryKey: string | null; // null means: Access to all telemetry keys
59 | }>;
60 | export type DeviceTwinSubscriptionRequest = SubscriptionRequestBase<{
61 | type: 'deviceTwin';
62 | }>;
63 | export type ConnectionStateSubscriptionRequest = SubscriptionRequestBase<{
64 | type: 'connectionState';
65 | }>;
66 | export type D2CMessageSubscriptionRequest = SubscriptionRequestBase<{
67 | type: 'd2cMessages';
68 | }>;
69 |
70 | export type SubscriptionRequest =
71 | | TelemetrySubscriptionRequest // null means: Access to all telemetry keys
72 | | DeviceTwinSubscriptionRequest
73 | | ConnectionStateSubscriptionRequest
74 | | D2CMessageSubscriptionRequest;
75 |
76 | export type ConnectionState = {
77 | connected: boolean;
78 | };
79 |
80 | export type MessageBase = {
81 | deviceId: DeviceId;
82 | timestamp: string;
83 | } & T;
84 |
85 | export type TelemetryMessage = MessageBase<{
86 | telemetry: Record;
87 | }>;
88 |
89 | export type ConnectionStateMessage = MessageBase<{
90 | connectionState: boolean;
91 | }>;
92 |
93 | export type DeviceTwinMessage = MessageBase<{
94 | deviceTwin: TwinUpdate;
95 | }>;
96 |
97 | export type D2CMessage = MessageBase<{
98 | message: Record;
99 | }>;
100 |
101 | export type Message =
102 | | TelemetryMessage
103 | | ConnectionStateMessage
104 | | DeviceTwinMessage
105 | | D2CMessage;
106 |
107 | export type IoTHubResponse = {
108 | status: number;
109 | payload: unknown;
110 | };
111 |
112 | export type LogLevel = 'error' | 'warn' | 'info' | 'verbose' | 'debug';
113 |
114 | export type ParsedConnectionString = {
115 | Endpoint: string;
116 | SharedAccessKey: string;
117 | };
118 |
119 | export function parseConnectionString(
120 | connectionString: string
121 | ): ParsedConnectionString {
122 | const parsed = connectionString.split(';').reduce((acc, part) => {
123 | const i = part.indexOf('=');
124 | if (i < 0) return acc;
125 |
126 | const key = part.substring(0, i);
127 | const value = part.substring(i + 1);
128 |
129 | acc[key as keyof ParsedConnectionString] = value;
130 | return acc;
131 | }, {} as ParsedConnectionString);
132 |
133 | if (!parsed.Endpoint || !parsed.SharedAccessKey) {
134 | throw new Error(
135 | `Invalid Connection String: make sure "Endpoint=..." and "SharedAccessKey=..." is present in your connection string.`
136 | );
137 | }
138 | return parsed;
139 | }
140 |
141 | // Typeguards
142 |
143 | export const isGrantRequest = (request: unknown): request is GrantRequest => {
144 | return (
145 | !!request &&
146 | typeof (request as GrantRequest).deviceId === 'string' &&
147 | typeof (request as GrantRequest).sessionId === 'string'
148 | );
149 | };
150 |
151 | export const isTelemetryGrantRequest = (
152 | request: unknown
153 | ): request is TelemetryGrantRequest => {
154 | return (
155 | !!request &&
156 | isGrantRequest(request) &&
157 | (request as TelemetryGrantRequest).type === 'telemetry' &&
158 | typeof (request as TelemetryGrantRequest).telemetryKey === 'string'
159 | );
160 | };
161 | export const isDeviceTwinGrantRequest = (
162 | request: unknown
163 | ): request is DeviceTwinGrantRequest => {
164 | return (
165 | !!request &&
166 | isGrantRequest(request) &&
167 | (request as GrantRequest).type === 'deviceTwin'
168 | );
169 | };
170 | export const isConnectionStateGrantRequest = (
171 | request: unknown
172 | ): request is ConnectionStateGrantRequest => {
173 | return (
174 | !!request &&
175 | isGrantRequest(request) &&
176 | (request as GrantRequest).type === 'connectionState'
177 | );
178 | };
179 | export const isDesiredPropertyGrantRequest = (
180 | request: unknown
181 | ): request is DesiredPropertyGrantRequest => {
182 | return (
183 | !!request &&
184 | isGrantRequest(request) &&
185 | (request as GrantRequest).type === 'desiredProperties'
186 | );
187 | };
188 | export const isDirectMethodGrantRequest = (
189 | request: unknown
190 | ): request is DirectMethodGrantRequest => {
191 | return (
192 | !!request &&
193 | isGrantRequest(request) &&
194 | (request as GrantRequest).type === 'directMethod' &&
195 | typeof (request as DirectMethodGrantRequest).directMethodName === 'string'
196 | );
197 | };
198 | export const isD2CMessageGrantRequest = (
199 | request: unknown
200 | ): request is D2CMessageGrantRequest => {
201 | return (
202 | !!request &&
203 | isGrantRequest(request) &&
204 | (request as GrantRequest).type === 'd2cMessages'
205 | );
206 | };
207 |
208 | export type LastValueResponse = {
209 | deviceId: string;
210 | data: T;
211 | timestamp: string;
212 | };
213 |
214 | export type CachedValueType =
215 | | string
216 | | boolean
217 | | number
218 | | Record;
219 |
220 | export type LastValueObj = {
221 | value: T;
222 | timestamp: string; // iso date
223 | };
224 | export type LastValueTelemetryResponse = LastValueResponse<
225 | Record>
226 | >;
227 | export type LastValueDeviceTwinResponse = LastValueResponse;
228 | export type LastValueConnectionStateResponse = LastValueResponse;
229 |
230 | export type LastValuesBodyConfig = {
231 | connectionState?: boolean;
232 | deviceTwin?: boolean;
233 | telemetry?: string[];
234 | };
235 | export type LastValuesRequestBody = Record;
236 |
--------------------------------------------------------------------------------
/src/library/base/ux4iotState.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ConnectionStateCallback,
3 | D2CMessageCallback,
4 | DeviceTwinCallback,
5 | MessageCallback,
6 | TelemetryCallback,
7 | } from './types';
8 | import {
9 | ConnectionStateGrantRequest,
10 | ConnectionStateSubscriptionRequest,
11 | D2CMessageGrantRequest,
12 | D2CMessageSubscriptionRequest,
13 | DesiredPropertyGrantRequest,
14 | DeviceTwinGrantRequest,
15 | DeviceTwinSubscriptionRequest,
16 | DirectMethodGrantRequest,
17 | GrantRequest,
18 | SubscriptionRequest,
19 | TelemetryGrantRequest,
20 | TelemetrySubscriptionRequest,
21 | } from './ux4iot-shared';
22 | import { grantRequestsEqual } from './utils';
23 |
24 | export type Subscription =
25 | | TelemetrySubscription
26 | | DeviceTwinSubscription
27 | | D2CMessageSubscription
28 | | ConnectionStateSubscription;
29 | export type TelemetrySubscription = Omit<
30 | TelemetrySubscriptionRequest,
31 | 'telemetryKey'
32 | > & {
33 | onData: TelemetryCallback;
34 | telemetryKeys: string[];
35 | };
36 | export type DeviceTwinSubscription = DeviceTwinSubscriptionRequest & {
37 | onData: DeviceTwinCallback;
38 | };
39 | export type D2CMessageSubscription = D2CMessageSubscriptionRequest & {
40 | onData: D2CMessageCallback;
41 | };
42 | export type ConnectionStateSubscription = ConnectionStateSubscriptionRequest & {
43 | onData: ConnectionStateCallback;
44 | };
45 |
46 | type Ux4iotState = {
47 | grants: Ux4iotGrants;
48 | subscriptions: Ux4iotSubscriptions;
49 | };
50 |
51 | type Ux4iotGrants = {
52 | deviceTwin: DeviceTwinGrantRequest[];
53 | connectionState: ConnectionStateGrantRequest[];
54 | d2cMessages: D2CMessageGrantRequest[];
55 | telemetry: TelemetryGrantRequest[];
56 | desiredProperties: DesiredPropertyGrantRequest[];
57 | directMethod: DirectMethodGrantRequest[];
58 | };
59 | type Ux4iotSubscriptions = Record;
60 |
61 | export const state: Ux4iotState = {
62 | grants: {
63 | deviceTwin: [],
64 | connectionState: [],
65 | d2cMessages: [],
66 | telemetry: [],
67 | desiredProperties: [],
68 | directMethod: [],
69 | },
70 | subscriptions: {},
71 | };
72 |
73 | export function resetState() {
74 | state.grants = {
75 | deviceTwin: [],
76 | connectionState: [],
77 | d2cMessages: [],
78 | telemetry: [],
79 | desiredProperties: [],
80 | directMethod: [],
81 | };
82 | state.subscriptions = {};
83 | }
84 |
85 | export function hasSubscription(
86 | subscriberId: string,
87 | subscriptionRequest: SubscriptionRequest
88 | ): boolean {
89 | if (!state.subscriptions[subscriberId]) return false;
90 | for (const sub of state.subscriptions[subscriberId]) {
91 | const { type, deviceId, sessionId } = subscriptionRequest;
92 | const { type: sType, deviceId: sDeviceId, sessionId: sSessionId } = sub;
93 | if (sSessionId === sessionId && sDeviceId === deviceId && sType === type) {
94 | switch (type) {
95 | case 'connectionState':
96 | case 'd2cMessages':
97 | case 'deviceTwin':
98 | return true;
99 | case 'telemetry': {
100 | const { telemetryKeys } = sub as TelemetrySubscription;
101 | return telemetryKeys.includes(
102 | subscriptionRequest.telemetryKey as string
103 | );
104 | }
105 | }
106 | }
107 | }
108 | return false;
109 | }
110 |
111 | export function hasGrant(grantRequest: GrantRequest) {
112 | for (const grants of Object.values(state.grants))
113 | for (const grant of grants)
114 | if (grantRequestsEqual(grantRequest, grant)) return true;
115 | return false;
116 | }
117 |
118 | export function getNumberOfSubscribers(
119 | subscriptionRequest: SubscriptionRequest
120 | ) {
121 | let subscriptionCount = 0;
122 | for (const subscriptions of Object.values(state.subscriptions)) {
123 | for (const sub of subscriptions) {
124 | const { type, deviceId, sessionId } = subscriptionRequest;
125 | const { type: sType, deviceId: sDeviceId, sessionId: sSessionId } = sub;
126 | if (
127 | sessionId === sSessionId &&
128 | sDeviceId === deviceId &&
129 | sType === type
130 | ) {
131 | switch (type) {
132 | case 'connectionState':
133 | case 'd2cMessages':
134 | case 'deviceTwin':
135 | subscriptionCount++;
136 | break;
137 | case 'telemetry': {
138 | const { telemetryKeys } = sub as TelemetrySubscription;
139 | if (
140 | telemetryKeys.includes(subscriptionRequest.telemetryKey as string)
141 | ) {
142 | subscriptionCount++;
143 | }
144 | break;
145 | }
146 | }
147 | }
148 | }
149 | }
150 | return subscriptionCount;
151 | }
152 |
153 | export function hasGrantForSubscription(
154 | subscriptionRequest: SubscriptionRequest
155 | ) {
156 | const { deviceId, type, sessionId } = subscriptionRequest;
157 | for (const grants of Object.values(state.grants)) {
158 | for (const grant of grants) {
159 | if (grant.deviceId !== deviceId || grant.sessionId !== sessionId) {
160 | continue;
161 | }
162 | switch (type) {
163 | case 'connectionState':
164 | case 'deviceTwin':
165 | case 'd2cMessages':
166 | if (grant.type === type) return true;
167 | break;
168 | case 'telemetry': {
169 | const { telemetryKey } = subscriptionRequest;
170 | if (grant.type === type && grant.telemetryKey === telemetryKey)
171 | return true;
172 | break;
173 | }
174 | }
175 | }
176 | }
177 | return false;
178 | }
179 |
180 | export function addSubscription(
181 | subscriberId: string,
182 | subscriptionRequest: SubscriptionRequest,
183 | onData: MessageCallback
184 | ) {
185 | const { subscriptions } = state;
186 | const { type, deviceId, sessionId } = subscriptionRequest;
187 | if (hasSubscription(subscriberId, subscriptionRequest)) {
188 | return;
189 | }
190 | switch (type) {
191 | case 'deviceTwin':
192 | case 'connectionState':
193 | case 'd2cMessages': {
194 | const subscription = { type, deviceId, onData, sessionId } as
195 | | DeviceTwinSubscription
196 | | ConnectionStateSubscription
197 | | D2CMessageSubscription;
198 | !subscriptions[subscriberId]
199 | ? (subscriptions[subscriberId] = [subscription])
200 | : subscriptions[subscriberId].push(subscription);
201 | break;
202 | }
203 | case 'telemetry': {
204 | const { telemetryKey } = subscriptionRequest;
205 | const subscription = {
206 | type,
207 | deviceId,
208 | onData,
209 | telemetryKeys: [telemetryKey],
210 | sessionId,
211 | } as TelemetrySubscription;
212 | if (subscriptions[subscriberId]) {
213 | const foundSubscription = subscriptions[subscriberId].find(
214 | s => s.deviceId === deviceId
215 | ) as TelemetrySubscription;
216 | if (foundSubscription) {
217 | foundSubscription.telemetryKeys.push(telemetryKey as string);
218 | } else {
219 | subscriptions[subscriberId].push(subscription);
220 | }
221 | } else {
222 | subscriptions[subscriberId] = [subscription];
223 | }
224 | break;
225 | }
226 | default:
227 | break;
228 | }
229 | }
230 |
231 | export function removeSubscription(
232 | subscriberId: string,
233 | subscriptionRequest: SubscriptionRequest
234 | ): Subscription | undefined {
235 | if (!state.subscriptions[subscriberId]) {
236 | return;
237 | }
238 | const { type, deviceId, sessionId } = subscriptionRequest;
239 | switch (type) {
240 | case 'deviceTwin':
241 | case 'connectionState':
242 | case 'd2cMessages': {
243 | const subscriptionRequest = state.subscriptions[subscriberId].find(
244 | s =>
245 | s.deviceId === deviceId &&
246 | s.type === type &&
247 | s.sessionId === sessionId
248 | );
249 |
250 | state.subscriptions[subscriberId] = state.subscriptions[
251 | subscriberId
252 | ].filter(s => s !== subscriptionRequest);
253 |
254 | if (state.subscriptions[subscriberId].length === 0) {
255 | delete state.subscriptions[subscriberId];
256 | }
257 | return subscriptionRequest;
258 | }
259 | case 'telemetry':
260 | if (state.subscriptions[subscriberId]) {
261 | const foundSubscription = state.subscriptions[subscriberId].find(s => {
262 | return s.deviceId === deviceId && s.sessionId === sessionId;
263 | }) as TelemetrySubscription | undefined;
264 | if (foundSubscription) {
265 | const keys = foundSubscription.telemetryKeys;
266 | const { telemetryKey } = subscriptionRequest;
267 | const nextTelemetryKeys = keys.filter(k => k !== telemetryKey);
268 |
269 | if (nextTelemetryKeys.length === 0) {
270 | state.subscriptions[subscriberId] = state.subscriptions[
271 | subscriberId
272 | ].filter(s => {
273 | return s !== foundSubscription;
274 | });
275 | } else {
276 | foundSubscription.telemetryKeys = nextTelemetryKeys;
277 | }
278 | }
279 | if (state.subscriptions[subscriberId].length === 0) {
280 | delete state.subscriptions[subscriberId];
281 | }
282 | return foundSubscription;
283 | }
284 | break;
285 | default:
286 | break;
287 | }
288 | }
289 |
290 | export function addGrant(grantRequest: GrantRequest) {
291 | const { grants } = state;
292 | const { type } = grantRequest;
293 | //@ts-ignore type of grant request is correctly map in Ux4iotState
294 | grants[type].push(grantRequest);
295 | }
296 |
297 | export function removeGrant(grantRequest: GrantRequest) {
298 | const { grants } = state;
299 | const { type } = grantRequest;
300 | //@ts-ignore type of grant request is correctly map in Ux4iotState
301 | grants[type] = grants[type].filter(g => !grantRequestsEqual(g, grantRequest));
302 | }
303 |
304 | export function cleanSubId(subscriptionId: string) {
305 | delete state.subscriptions[subscriptionId];
306 | }
307 |
--------------------------------------------------------------------------------
/src/library/index.ts:
--------------------------------------------------------------------------------
1 | export * from './base/ux4iot-shared';
2 | export * from './base/types';
3 | export * from './base/Ux4iot';
4 | export * from './Ux4iotContext';
5 | export * from './useMultiTelemetry';
6 | export * from './useMultiConnectionState';
7 | export * from './useTelemetry';
8 | export * from './useDeviceTwin';
9 | export * from './useConnectionState';
10 | export * from './usePatchDesiredProperties';
11 | export * from './useDirectMethod';
12 | export * from './useD2CMessages';
13 |
--------------------------------------------------------------------------------
/src/library/telemetryState.ts:
--------------------------------------------------------------------------------
1 | import { Reducer } from 'react';
2 | import { CachedValueType } from './base/ux4iot-shared';
3 |
4 | type DeviceId = string;
5 | type TelemetryKey = string;
6 | export type TelemetryValue = {
7 | value: CachedValueType;
8 | timestamp: string | undefined;
9 | };
10 | export type TelemetryState = Record<
11 | DeviceId,
12 | Record
13 | >;
14 |
15 | export type ADD_DATA_ACTION = {
16 | type: 'ADD_DATA';
17 | deviceId: string;
18 | message: Record;
19 | timestamp?: string;
20 | };
21 |
22 | // message1 = { deviceId: 'sim1', timestamp: 'isodate', message: { [telemetryKey]: { value: v, timestamp: t }, ...}
23 | // message2 = { deviceId: 'sim1', timestamp: 'isodate', message: { [telemetryKey]: 'abc', ... }
24 | // message3 = { deviceId: 'sim1', timestamp: 'isodate', message: { [telemetryKey]: { lat: 12, lng: 43 }, ... }
25 |
26 | export type TelemetryAction = ADD_DATA_ACTION;
27 |
28 | function isObject(value: unknown) {
29 | return typeof value === 'object' && value !== null && !Array.isArray(value);
30 | }
31 |
32 | export const telemetryReducer: Reducer = (
33 | state,
34 | action
35 | ) => {
36 | switch (action.type) {
37 | case 'ADD_DATA': {
38 | const { deviceId, message, timestamp } = action;
39 | const nextDeviceState = { ...state[deviceId] };
40 |
41 | for (const [telemetryKey, telemetryValue] of Object.entries(message)) {
42 | const isLastValueMessage =
43 | isObject(telemetryValue) &&
44 | telemetryValue.timestamp !== undefined &&
45 | Object.keys(telemetryValue).length === 2;
46 |
47 | if (isLastValueMessage) {
48 | nextDeviceState[telemetryKey] = {
49 | value: telemetryValue.value,
50 | timestamp: telemetryValue.timestamp,
51 | };
52 | } else {
53 | nextDeviceState[telemetryKey] = { value: telemetryValue, timestamp };
54 | }
55 | }
56 |
57 | return { ...state, [deviceId]: nextDeviceState };
58 | }
59 | default:
60 | return state;
61 | }
62 | };
63 |
--------------------------------------------------------------------------------
/src/library/useConnectionState.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useRef, useState } from 'react';
2 | import {
3 | ConnectionStateCallback,
4 | GrantErrorCallback,
5 | SubscriptionErrorCallback,
6 | } from './base/types';
7 | import { useSubscription } from './useSubscription';
8 | import { ConnectionStateSubscriptionRequest } from './base/ux4iot-shared';
9 |
10 | type HookOptions = {
11 | onData?: ConnectionStateCallback;
12 | onGrantError?: GrantErrorCallback;
13 | onSubscriptionError?: SubscriptionErrorCallback;
14 | };
15 |
16 | export const useConnectionState = (
17 | deviceId: string,
18 | options: HookOptions = {}
19 | ): boolean | undefined => {
20 | const { onData } = options;
21 | const onDataRef = useRef(onData);
22 | const [connectionState, setConnectionState] = useState();
23 |
24 | useEffect(() => {
25 | onDataRef.current = onData;
26 | }, [onData]);
27 |
28 | const onConnectionState: ConnectionStateCallback = useCallback(
29 | (deviceId, connectionState, timestamp) => {
30 | if (connectionState !== undefined) {
31 | setConnectionState(connectionState);
32 | onDataRef.current?.(deviceId, connectionState, timestamp);
33 | }
34 | },
35 | [setConnectionState]
36 | );
37 |
38 | const subscriptionRequest = useCallback(
39 | (sessionId: string): ConnectionStateSubscriptionRequest => {
40 | return { deviceId, type: 'connectionState', sessionId };
41 | },
42 | [deviceId]
43 | );
44 |
45 | useSubscription(options, onConnectionState, subscriptionRequest);
46 |
47 | return connectionState;
48 | };
49 |
--------------------------------------------------------------------------------
/src/library/useD2CMessages.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useRef, useState } from 'react';
2 | import {
3 | D2CMessageCallback,
4 | GrantErrorCallback,
5 | SubscriptionErrorCallback,
6 | } from './base/types';
7 | import { useSubscription } from './useSubscription';
8 | import { D2CMessageSubscriptionRequest } from './base/ux4iot-shared';
9 |
10 | type HookOptions = {
11 | onData?: D2CMessageCallback;
12 | onGrantError?: GrantErrorCallback;
13 | onSubscriptionError?: SubscriptionErrorCallback;
14 | };
15 |
16 | export const useD2CMessages = >(
17 | deviceId: string,
18 | options: HookOptions = {}
19 | ): T | undefined => {
20 | const { onData } = options;
21 | const onDataRef = useRef(onData);
22 | const [lastMessage, setLastMessage] = useState();
23 |
24 | useEffect(() => {
25 | onDataRef.current = onData;
26 | }, [onData]);
27 |
28 | const onMessage: D2CMessageCallback = useCallback(
29 | (deviceId, message, timestamp) => {
30 | if (message) {
31 | setLastMessage(message as T);
32 | onDataRef.current?.(deviceId, message, timestamp);
33 | }
34 | },
35 | [setLastMessage]
36 | );
37 |
38 | const subscriptionRequest = useCallback(
39 | (sessionId: string): D2CMessageSubscriptionRequest => {
40 | return { deviceId, type: 'd2cMessages', sessionId };
41 | },
42 | [deviceId]
43 | );
44 |
45 | useSubscription(options, onMessage, subscriptionRequest);
46 |
47 | return lastMessage;
48 | };
49 |
--------------------------------------------------------------------------------
/src/library/useDeviceTwin.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useRef, useState } from 'react';
2 | import {
3 | DeviceTwinCallback,
4 | GrantErrorCallback,
5 | SubscriptionErrorCallback,
6 | } from './base/types';
7 | import { useSubscription } from './useSubscription';
8 | import {
9 | DeviceTwinSubscriptionRequest,
10 | TwinUpdate,
11 | } from './base/ux4iot-shared';
12 |
13 | type HookOptions = {
14 | onData?: DeviceTwinCallback;
15 | onGrantError?: GrantErrorCallback;
16 | onSubscriptionError?: SubscriptionErrorCallback;
17 | };
18 |
19 | export const useDeviceTwin = (
20 | deviceId: string,
21 | options: HookOptions = {}
22 | ): TwinUpdate | undefined => {
23 | const { onData } = options;
24 | const onDataRef = useRef(onData);
25 | const [twin, setTwin] = useState();
26 |
27 | useEffect(() => {
28 | onDataRef.current = onData;
29 | }, [onData]);
30 |
31 | const onDeviceTwin: DeviceTwinCallback = useCallback(
32 | (deviceId, deviceTwin, timestamp) => {
33 | if (deviceTwin) {
34 | setTwin(deviceTwin);
35 | onDataRef.current?.(deviceId, deviceTwin, timestamp);
36 | }
37 | },
38 | [setTwin]
39 | );
40 |
41 | const subscriptionRequest = useCallback(
42 | (sessionId: string): DeviceTwinSubscriptionRequest => {
43 | return { deviceId, type: 'deviceTwin', sessionId };
44 | },
45 | [deviceId]
46 | );
47 |
48 | useSubscription(options, onDeviceTwin, subscriptionRequest);
49 |
50 | return twin;
51 | };
52 |
--------------------------------------------------------------------------------
/src/library/useDirectMethod.ts:
--------------------------------------------------------------------------------
1 | import {useCallback, useContext, useEffect, useRef} from 'react';
2 | import {GrantErrorCallback} from './base/types';
3 | import {DirectMethodGrantRequest, IoTHubResponse} from './base/ux4iot-shared';
4 | import {Ux4iotContext} from './Ux4iotContext';
5 |
6 | type UseDirectMethodOutput = (
7 | payload: Record,
8 | responseTimeoutInSeconds?: number,
9 | connectTimeoutInSeconds?: number
10 | ) => Promise;
11 |
12 | type HookOptions = {
13 | onGrantError?: GrantErrorCallback;
14 | };
15 |
16 | export const useDirectMethod = (
17 | deviceId: string,
18 | directMethodName: string,
19 | options: HookOptions = {}
20 | ): UseDirectMethodOutput => {
21 | const { onGrantError } = options;
22 | const { ux4iot, sessionId } = useContext(Ux4iotContext);
23 | const onGrantErrorRef = useRef(onGrantError);
24 |
25 | useEffect(() => {
26 | onGrantErrorRef.current = onGrantError;
27 | }, [onGrantError]);
28 |
29 | return useCallback(
30 | async (
31 | payload,
32 | responseTimeoutInSeconds,
33 | connectTimeoutInSeconds
34 | ): Promise => {
35 | if (ux4iot) {
36 | const req: DirectMethodGrantRequest = {
37 | sessionId,
38 | deviceId,
39 | type: 'directMethod',
40 | directMethodName,
41 | };
42 | return await ux4iot.invokeDirectMethod(
43 | req,
44 | {
45 | methodName: directMethodName,
46 | payload,
47 | responseTimeoutInSeconds,
48 | connectTimeoutInSeconds,
49 | },
50 | onGrantErrorRef.current
51 | );
52 | }
53 | },
54 | [ux4iot, sessionId, deviceId, directMethodName]
55 | );
56 | };
57 |
--------------------------------------------------------------------------------
/src/library/useMultiConnectionState.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useCallback, useRef, useState, useContext } from 'react';
2 | import { v4 as uuid } from 'uuid';
3 | import {
4 | GrantErrorCallback,
5 | SubscriptionErrorCallback,
6 | ConnectionStateCallback,
7 | } from './base/types';
8 | import { ConnectionStateSubscriptionRequest } from './base/ux4iot-shared';
9 | import { Ux4iotContext } from './Ux4iotContext';
10 |
11 | type UseMultiConnectionStateOutput = {
12 | addConnectionState: (deviceId: string) => Promise;
13 | removeConnectionState: (deviceId: string) => Promise;
14 | toggleConnectionState: (deviceId: string) => Promise;
15 | isSubscribed: (deviceId: string) => boolean;
16 | connectionStates: Record;
17 | currentSubscribers: string[];
18 | };
19 |
20 | type HookOptions = {
21 | initialSubscribers?: string[];
22 | onData?: ConnectionStateCallback;
23 | onGrantError?: GrantErrorCallback;
24 | onSubscriptionError?: SubscriptionErrorCallback;
25 | };
26 |
27 | function getSubscriptionRequest(
28 | deviceId: string,
29 | sessionId: string
30 | ): ConnectionStateSubscriptionRequest {
31 | return {
32 | sessionId,
33 | deviceId,
34 | type: 'connectionState',
35 | };
36 | }
37 |
38 | export const useMultiConnectionState = (
39 | options: HookOptions
40 | ): UseMultiConnectionStateOutput => {
41 | const { onData, onGrantError, onSubscriptionError, initialSubscribers } =
42 | options;
43 | const { ux4iot, sessionId } = useContext(Ux4iotContext);
44 | const [currentSubscribers, setCurrentSubscribers] = useState([]);
45 | const initialSubscribersRef = useRef(initialSubscribers);
46 | const onDataRef = useRef(onData);
47 | const onGrantErrorRef = useRef(onGrantError);
48 | const onSubscriptionErrorRef = useRef(onSubscriptionError);
49 | const subscriptionId = useRef(uuid());
50 | const [connectionStates, setConnectionStates] = useState<
51 | Record
52 | >({});
53 |
54 | useEffect(() => {
55 | initialSubscribersRef.current = initialSubscribers;
56 | }, [initialSubscribers]);
57 |
58 | useEffect(() => {
59 | onDataRef.current = onData;
60 | onGrantErrorRef.current = onGrantError;
61 | onSubscriptionErrorRef.current = onSubscriptionError;
62 | }, [onData, onGrantError, onSubscriptionError]);
63 |
64 | const onConnectionState: ConnectionStateCallback = useCallback(
65 | (deviceId, connectionState, timestamp) => {
66 | // can be undefined because last value requests that fail emit a undefined value.
67 | if (connectionState !== undefined) {
68 | setConnectionStates(prevState => ({
69 | ...prevState,
70 | [deviceId]: connectionState,
71 | }));
72 | onDataRef.current?.(deviceId, connectionState, timestamp);
73 | }
74 | },
75 | []
76 | );
77 |
78 | const addConnectionState = useCallback(
79 | async (deviceId: string) => {
80 | if (ux4iot) {
81 | await ux4iot.subscribe(
82 | subscriptionId.current,
83 | getSubscriptionRequest(deviceId, sessionId),
84 | onConnectionState,
85 | onSubscriptionErrorRef.current,
86 | onGrantErrorRef.current
87 | );
88 | setCurrentSubscribers(
89 | Object.keys(
90 | ux4iot.getSubscriberIdSubscriptions(subscriptionId.current)
91 | )
92 | );
93 | }
94 | },
95 | [ux4iot, onConnectionState, sessionId]
96 | );
97 |
98 | const removeConnectionState = useCallback(
99 | async (deviceId: string) => {
100 | if (ux4iot) {
101 | await ux4iot.unsubscribe(
102 | subscriptionId.current,
103 | getSubscriptionRequest(deviceId, sessionId),
104 | onSubscriptionErrorRef.current,
105 | onGrantErrorRef.current
106 | );
107 | setCurrentSubscribers(
108 | Object.keys(
109 | ux4iot.getSubscriberIdSubscriptions(subscriptionId.current)
110 | )
111 | );
112 | }
113 | },
114 | [ux4iot, sessionId]
115 | );
116 |
117 | const toggleConnectionState = useCallback(
118 | async (deviceId: string) => {
119 | if (ux4iot) {
120 | ux4iot.hasSubscription(
121 | subscriptionId.current,
122 | getSubscriptionRequest(deviceId, sessionId)
123 | )
124 | ? await removeConnectionState(deviceId)
125 | : await addConnectionState(deviceId);
126 | }
127 | },
128 | [ux4iot, addConnectionState, removeConnectionState, sessionId]
129 | );
130 |
131 | const isSubscribed = useCallback(
132 | (deviceId: string): boolean => {
133 | if (ux4iot) {
134 | return !!ux4iot.hasSubscription(
135 | subscriptionId.current,
136 | getSubscriptionRequest(deviceId, sessionId)
137 | );
138 | } else {
139 | return false;
140 | }
141 | },
142 | [ux4iot, sessionId]
143 | );
144 |
145 | useEffect(() => {
146 | async function initSubscribe() {
147 | if (ux4iot && sessionId && initialSubscribersRef.current) {
148 | for (const deviceId of initialSubscribersRef.current) {
149 | await addConnectionState(deviceId);
150 | }
151 | setCurrentSubscribers(
152 | Object.keys(
153 | ux4iot.getSubscriberIdSubscriptions(subscriptionId.current)
154 | )
155 | );
156 | }
157 | }
158 | initSubscribe();
159 | }, [ux4iot, addConnectionState, sessionId]);
160 |
161 | useEffect(() => {
162 | const subId = subscriptionId.current;
163 | return () => {
164 | ux4iot?.removeSubscriberId(subId, sessionId);
165 | };
166 | }, [ux4iot, sessionId]);
167 |
168 | return {
169 | connectionStates,
170 | addConnectionState,
171 | removeConnectionState,
172 | toggleConnectionState,
173 | isSubscribed,
174 | currentSubscribers,
175 | };
176 | };
177 |
--------------------------------------------------------------------------------
/src/library/useMultiTelemetry.ts:
--------------------------------------------------------------------------------
1 | import {
2 | useEffect,
3 | useReducer,
4 | Reducer,
5 | useCallback,
6 | useRef,
7 | useState,
8 | useContext,
9 | } from 'react';
10 | import { v4 as uuid } from 'uuid';
11 | import {
12 | TelemetryAction,
13 | telemetryReducer,
14 | TelemetryState,
15 | } from './telemetryState';
16 | import {
17 | Subscribers,
18 | TelemetryCallback,
19 | GrantErrorCallback,
20 | SubscriptionErrorCallback,
21 | } from './base/types';
22 | import { TelemetrySubscriptionRequest } from './base/ux4iot-shared';
23 | import { Ux4iotContext } from './Ux4iotContext';
24 |
25 | export type { TelemetryValue, TelemetryState } from './telemetryState';
26 |
27 | type UseMultiTelemetryOutput = {
28 | addTelemetry: (deviceId: string, telemetryKeys: string[]) => Promise;
29 | removeTelemetry: (deviceId: string, telemetryKeys: string[]) => Promise;
30 | toggleTelemetry: (deviceId: string, telemetryKey: string) => Promise;
31 | isSubscribed: (deviceId: string, telemetryKey: string) => boolean;
32 | telemetry: TelemetryState;
33 | currentSubscribers: Subscribers;
34 | };
35 |
36 | type HookOptions = {
37 | initialSubscribers?: Subscribers;
38 | onData?: TelemetryCallback;
39 | onGrantError?: GrantErrorCallback;
40 | onSubscriptionError?: SubscriptionErrorCallback;
41 | };
42 |
43 | function getSubscriptionRequest(
44 | deviceId: string,
45 | telemetryKey: string,
46 | sessionId: string
47 | ): TelemetrySubscriptionRequest {
48 | return {
49 | sessionId,
50 | deviceId,
51 | telemetryKey,
52 | type: 'telemetry',
53 | };
54 | }
55 |
56 | export const useMultiTelemetry = (
57 | options: HookOptions
58 | ): UseMultiTelemetryOutput => {
59 | const { onData, onGrantError, onSubscriptionError, initialSubscribers } =
60 | options;
61 |
62 | const { ux4iot, sessionId } = useContext(Ux4iotContext);
63 | const [currentSubscribers, setCurrentSubscribers] = useState({});
64 | const initialSubscribersRef = useRef(initialSubscribers);
65 | const onDataRef = useRef(onData);
66 | const onGrantErrorRef = useRef(onGrantError);
67 | const onSubscriptionErrorRef = useRef(onSubscriptionError);
68 | const subscriptionId = useRef(uuid());
69 |
70 | useEffect(() => {
71 | initialSubscribersRef.current = initialSubscribers;
72 | }, [initialSubscribers]);
73 |
74 | useEffect(() => {
75 | onDataRef.current = onData;
76 | onGrantErrorRef.current = onGrantError;
77 | onSubscriptionErrorRef.current = onSubscriptionError;
78 | }, [onData, onGrantError, onSubscriptionError]);
79 |
80 | const [telemetry, setTelemetry] = useReducer<
81 | Reducer
82 | >(telemetryReducer, {});
83 |
84 | const onTelemetry: TelemetryCallback = useCallback(
85 | (deviceId, message, timestamp) => {
86 | if (message) {
87 | setTelemetry({ type: 'ADD_DATA', deviceId, message, timestamp });
88 | onDataRef.current?.(deviceId, message, timestamp);
89 | }
90 | },
91 | [setTelemetry]
92 | );
93 |
94 | const addTelemetry = useCallback(
95 | async (deviceId: string, telemetryKeys: string[]) => {
96 | if (ux4iot) {
97 | await ux4iot.subscribeAllTelemetry(
98 | sessionId,
99 | deviceId,
100 | telemetryKeys,
101 | subscriptionId.current,
102 | onTelemetry,
103 | onSubscriptionErrorRef.current,
104 | onGrantErrorRef.current
105 | );
106 | setCurrentSubscribers(
107 | ux4iot.getSubscriberIdSubscriptions(subscriptionId.current)
108 | );
109 | }
110 | },
111 | [ux4iot, onTelemetry, sessionId]
112 | );
113 |
114 | const removeTelemetry = useCallback(
115 | async (deviceId: string, telemetryKeys: string[]) => {
116 | if (ux4iot) {
117 | await ux4iot.unsubscribeAllTelemetry(
118 | sessionId,
119 | deviceId,
120 | telemetryKeys,
121 | subscriptionId.current,
122 | onSubscriptionErrorRef.current,
123 | onGrantErrorRef.current
124 | );
125 | setCurrentSubscribers(
126 | ux4iot.getSubscriberIdSubscriptions(subscriptionId.current)
127 | );
128 | }
129 | },
130 | [ux4iot, sessionId]
131 | );
132 |
133 | const toggleTelemetry = useCallback(
134 | async (deviceId: string, telemetryKey: string) => {
135 | if (ux4iot) {
136 | ux4iot.hasSubscription(
137 | subscriptionId.current,
138 | getSubscriptionRequest(deviceId, telemetryKey, sessionId)
139 | )
140 | ? await removeTelemetry(deviceId, [telemetryKey])
141 | : await addTelemetry(deviceId, [telemetryKey]);
142 | }
143 | },
144 | [ux4iot, addTelemetry, removeTelemetry, sessionId]
145 | );
146 |
147 | const isSubscribed = useCallback(
148 | (deviceId: string, telemetryKey: string): boolean => {
149 | if (ux4iot) {
150 | return ux4iot.hasSubscription(
151 | subscriptionId.current,
152 | getSubscriptionRequest(deviceId, telemetryKey, sessionId)
153 | );
154 | } else {
155 | return false;
156 | }
157 | },
158 | [ux4iot, sessionId]
159 | );
160 |
161 | useEffect(() => {
162 | async function initSubscribe() {
163 | if (ux4iot && sessionId && initialSubscribersRef.current) {
164 | for (const [deviceId, telemetryKeys] of Object.entries(
165 | initialSubscribersRef.current
166 | )) {
167 | await addTelemetry(deviceId, telemetryKeys);
168 | }
169 | setCurrentSubscribers(
170 | ux4iot.getSubscriberIdSubscriptions(subscriptionId.current)
171 | );
172 | }
173 | }
174 | initSubscribe();
175 | }, [ux4iot, addTelemetry, sessionId]);
176 |
177 | useEffect(() => {
178 | const subId = subscriptionId.current;
179 | return () => {
180 | ux4iot?.removeSubscriberId(subId, sessionId);
181 | };
182 | }, [ux4iot, sessionId]);
183 |
184 | return {
185 | telemetry,
186 | addTelemetry,
187 | removeTelemetry,
188 | toggleTelemetry,
189 | isSubscribed,
190 | currentSubscribers,
191 | };
192 | };
193 |
--------------------------------------------------------------------------------
/src/library/usePatchDesiredProperties.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useContext, useEffect, useRef } from 'react';
2 | import { GrantErrorCallback } from './base/types';
3 | import {
4 | DesiredPropertyGrantRequest,
5 | IoTHubResponse,
6 | } from './base/ux4iot-shared';
7 | import { Ux4iotContext } from './Ux4iotContext';
8 |
9 | type UsePatchDesiredPropertiesOutput = (
10 | desiredProperties: Record
11 | ) => Promise;
12 |
13 | type HookOptions = {
14 | onGrantError?: GrantErrorCallback;
15 | };
16 |
17 | export const usePatchDesiredProperties = (
18 | deviceId: string,
19 | options: HookOptions = {}
20 | ): UsePatchDesiredPropertiesOutput => {
21 | const { onGrantError } = options;
22 | const { ux4iot, sessionId } = useContext(Ux4iotContext);
23 | const onGrantErrorRef = useRef(onGrantError);
24 |
25 | useEffect(() => {
26 | onGrantErrorRef.current = onGrantError;
27 | }, [onGrantError]);
28 |
29 | const patchDesiredProperties = useCallback(
30 | async (
31 | desiredProperties: Record
32 | ): Promise => {
33 | if (ux4iot) {
34 | const req: DesiredPropertyGrantRequest = {
35 | sessionId,
36 | deviceId,
37 | type: 'desiredProperties',
38 | };
39 | return await ux4iot.patchDesiredProperties(
40 | req,
41 | desiredProperties,
42 | onGrantErrorRef.current
43 | );
44 | }
45 | },
46 | [deviceId, ux4iot, sessionId]
47 | );
48 |
49 | return patchDesiredProperties;
50 | };
51 |
--------------------------------------------------------------------------------
/src/library/useSubscription.ts:
--------------------------------------------------------------------------------
1 | import { useContext, useEffect, useRef } from 'react';
2 | import { v4 as uuid } from 'uuid';
3 | import {
4 | GrantErrorCallback,
5 | MessageCallback,
6 | SubscriptionErrorCallback,
7 | } from './base/types';
8 | import { SubscriptionRequest } from './base/ux4iot-shared';
9 | import { Ux4iotContext } from './Ux4iotContext';
10 |
11 | export type HookOptions = {
12 | onGrantError?: GrantErrorCallback;
13 | onSubscriptionError?: SubscriptionErrorCallback;
14 | };
15 | export function useSubscription(
16 | options: HookOptions = {},
17 | onMessage: MessageCallback,
18 | getSubscriptionRequest: (sessionId: string) => SubscriptionRequest
19 | ) {
20 | const { ux4iot, sessionId } = useContext(Ux4iotContext);
21 | const { onGrantError, onSubscriptionError } = options;
22 | const subscriptionId = useRef(uuid());
23 |
24 | const onGrantErrorRef = useRef(onGrantError);
25 | const onSubscriptionErrorRef = useRef(onSubscriptionError);
26 |
27 | useEffect(() => {
28 | onGrantErrorRef.current = onGrantError;
29 | onSubscriptionErrorRef.current = onSubscriptionError;
30 | }, [onGrantError, onSubscriptionError]);
31 |
32 | useEffect(() => {
33 | async function sub() {
34 | if (sessionId) {
35 | await ux4iot?.subscribe(
36 | subscriptionId.current,
37 | getSubscriptionRequest(sessionId),
38 | onMessage,
39 | onSubscriptionErrorRef.current,
40 | onGrantErrorRef.current
41 | );
42 | }
43 | }
44 | sub();
45 | const subId = subscriptionId.current;
46 |
47 | return () => {
48 | async function unsub() {
49 | if (sessionId) {
50 | await ux4iot?.unsubscribe(
51 | subId,
52 | getSubscriptionRequest(sessionId),
53 | onSubscriptionErrorRef.current,
54 | onGrantErrorRef.current
55 | );
56 | }
57 | }
58 | unsub();
59 | };
60 | }, [ux4iot, sessionId, getSubscriptionRequest, onMessage]);
61 | }
62 |
--------------------------------------------------------------------------------
/src/library/useTelemetry.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useRef, useState } from 'react';
2 | import {
3 | TelemetryCallback,
4 | GrantErrorCallback,
5 | SubscriptionErrorCallback,
6 | MessageCallbackBase,
7 | } from './base/types';
8 | import { TelemetrySubscriptionRequest } from './base/ux4iot-shared';
9 | import { useSubscription } from './useSubscription';
10 |
11 | type HookOptions = {
12 | onData?: MessageCallbackBase;
13 | onGrantError?: GrantErrorCallback;
14 | onSubscriptionError?: SubscriptionErrorCallback;
15 | };
16 |
17 | export const useTelemetry = (
18 | deviceId: string,
19 | telemetryKey: string,
20 | options: HookOptions = {}
21 | ): T | undefined => {
22 | const onDataRef = useRef(options.onData);
23 | const [value, setValue] = useState();
24 |
25 | useEffect(() => {
26 | onDataRef.current = options.onData;
27 | }, [options.onData]);
28 |
29 | const onTelemetry: TelemetryCallback = useCallback(
30 | (deviceId, message, timestamp) => {
31 | const maybeValue = message?.[telemetryKey];
32 | if (maybeValue !== undefined) {
33 | setValue(maybeValue as T);
34 | onDataRef.current?.(deviceId, maybeValue as T, timestamp);
35 | }
36 | },
37 | [telemetryKey]
38 | );
39 |
40 | const subscriptionRequest = useCallback(
41 | (sessionId: string): TelemetrySubscriptionRequest => {
42 | return { deviceId, telemetryKey, type: 'telemetry', sessionId };
43 | },
44 | [deviceId, telemetryKey]
45 | );
46 |
47 | useSubscription(options, onTelemetry, subscriptionRequest);
48 |
49 | return value;
50 | };
51 |
--------------------------------------------------------------------------------
/src/library/ux4iot-shared.ts:
--------------------------------------------------------------------------------
1 | // twins won't change in the forseeable future so instead of using the "Twin['properties] type"
2 | // of the azure-iothub library a manual typing is used. azure-iothub isn't really getting updated..
3 | export type TwinUpdate = {
4 | version: number;
5 | properties: {
6 | reported: {
7 | [key: string]: any;
8 | };
9 | desired: {
10 | [key: string]: any;
11 | };
12 | };
13 | };
14 |
15 | export type DeviceId = string;
16 |
17 | // Requests
18 | type GrantRequestBase = {
19 | deviceId: string;
20 | sessionId: string;
21 | } & T;
22 | export type TelemetryGrantRequest = GrantRequestBase<{
23 | type: 'telemetry';
24 | telemetryKey?: string | null; // null means: Access to all telemetry keys
25 | }>;
26 | export type DeviceTwinGrantRequest = GrantRequestBase<{
27 | type: 'deviceTwin';
28 | }>;
29 | export type ConnectionStateGrantRequest = GrantRequestBase<{
30 | type: 'connectionState';
31 | }>;
32 | export type DesiredPropertyGrantRequest = GrantRequestBase<{
33 | type: 'desiredProperties';
34 | }>;
35 | export type DirectMethodGrantRequest = GrantRequestBase<{
36 | type: 'directMethod';
37 | directMethodName: string | null; // null means: Access to all direct methods
38 | }>;
39 | export type D2CMessageGrantRequest = GrantRequestBase<{
40 | type: 'd2cMessages';
41 | }>;
42 |
43 | export type GrantRequest =
44 | | TelemetryGrantRequest
45 | | DeviceTwinGrantRequest
46 | | ConnectionStateGrantRequest
47 | | DesiredPropertyGrantRequest
48 | | DirectMethodGrantRequest
49 | | D2CMessageGrantRequest;
50 |
51 | type SubscriptionRequestBase = {
52 | deviceId: string;
53 | sessionId: string;
54 | } & T;
55 | export type TelemetrySubscriptionRequest = SubscriptionRequestBase<{
56 | type: 'telemetry';
57 | telemetryKey: string | null; // null means: Access to all telemetry keys
58 | }>;
59 | export type DeviceTwinSubscriptionRequest = SubscriptionRequestBase<{
60 | type: 'deviceTwin';
61 | }>;
62 | export type ConnectionStateSubscriptionRequest = SubscriptionRequestBase<{
63 | type: 'connectionState';
64 | }>;
65 | export type D2CMessageSubscriptionRequest = SubscriptionRequestBase<{
66 | type: 'd2cMessages';
67 | }>;
68 |
69 | export type SubscriptionRequest =
70 | | TelemetrySubscriptionRequest // null means: Access to all telemetry keysS
71 | | DeviceTwinSubscriptionRequest
72 | | ConnectionStateSubscriptionRequest
73 | | D2CMessageSubscriptionRequest;
74 |
75 | export type ConnectionState = {
76 | connected: boolean;
77 | };
78 |
79 | export type MessageBase = {
80 | deviceId: DeviceId;
81 | timestamp: string;
82 | } & T;
83 |
84 | export type TelemetryMessage = MessageBase<{
85 | telemetry: Record;
86 | }>;
87 |
88 | export type ConnectionStateMessage = MessageBase<{
89 | connectionState: boolean;
90 | }>;
91 |
92 | export type DeviceTwinMessage = MessageBase<{
93 | deviceTwin: TwinUpdate;
94 | }>;
95 |
96 | export type D2CMessage = MessageBase<{
97 | message: Record;
98 | }>;
99 |
100 | export type Message =
101 | | TelemetryMessage
102 | | ConnectionStateMessage
103 | | DeviceTwinMessage
104 | | D2CMessage;
105 |
106 | export type IoTHubResponse = {
107 | status: number;
108 | payload: unknown;
109 | };
110 |
111 | export type LogLevel = 'error' | 'warn' | 'info' | 'verbose' | 'debug';
112 |
113 | export type ParsedConnectionString = {
114 | Endpoint: string;
115 | SharedAccessKey: string;
116 | };
117 |
118 | export function parseConnectionString(
119 | connectionString: string
120 | ): ParsedConnectionString {
121 | const parsed = connectionString.split(';').reduce((acc, part) => {
122 | const i = part.indexOf('=');
123 | if (i < 0) return acc;
124 |
125 | const key = part.substring(0, i);
126 | const value = part.substring(i + 1);
127 |
128 | acc[key as keyof ParsedConnectionString] = value;
129 | return acc;
130 | }, {} as ParsedConnectionString);
131 |
132 | if (!parsed.Endpoint || !parsed.SharedAccessKey) {
133 | throw new Error(
134 | `Invalid Connection String: make sure "Endpoint=..." and "SharedAccessKey=..." is present in your connection string.`
135 | );
136 | }
137 | return parsed;
138 | }
139 |
140 | // Typeguards
141 |
142 | export const isGrantRequest = (request: unknown): request is GrantRequest => {
143 | return (
144 | !!request &&
145 | typeof (request as GrantRequest).deviceId === 'string' &&
146 | typeof (request as GrantRequest).sessionId === 'string'
147 | );
148 | };
149 |
150 | export const isTelemetryGrantRequest = (
151 | request: unknown
152 | ): request is TelemetryGrantRequest => {
153 | return (
154 | !!request &&
155 | isGrantRequest(request) &&
156 | (request as TelemetryGrantRequest).type === 'telemetry' &&
157 | typeof (request as TelemetryGrantRequest).telemetryKey === 'string'
158 | );
159 | };
160 | export const isDeviceTwinGrantRequest = (
161 | request: unknown
162 | ): request is DeviceTwinGrantRequest => {
163 | return (
164 | !!request &&
165 | isGrantRequest(request) &&
166 | (request as GrantRequest).type === 'deviceTwin'
167 | );
168 | };
169 | export const isConnectionStateGrantRequest = (
170 | request: unknown
171 | ): request is ConnectionStateGrantRequest => {
172 | return (
173 | !!request &&
174 | isGrantRequest(request) &&
175 | (request as GrantRequest).type === 'connectionState'
176 | );
177 | };
178 | export const isDesiredPropertyGrantRequest = (
179 | request: unknown
180 | ): request is DesiredPropertyGrantRequest => {
181 | return (
182 | !!request &&
183 | isGrantRequest(request) &&
184 | (request as GrantRequest).type === 'desiredProperties'
185 | );
186 | };
187 | export const isDirectMethodGrantRequest = (
188 | request: unknown
189 | ): request is DirectMethodGrantRequest => {
190 | return (
191 | !!request &&
192 | isGrantRequest(request) &&
193 | (request as GrantRequest).type === 'directMethod' &&
194 | typeof (request as DirectMethodGrantRequest).directMethodName === 'string'
195 | );
196 | };
197 | export const isD2CMessageGrantRequest = (
198 | request: unknown
199 | ): request is D2CMessageGrantRequest => {
200 | return (
201 | !!request &&
202 | isGrantRequest(request) &&
203 | (request as GrantRequest).type === 'd2cMessages'
204 | );
205 | };
206 |
--------------------------------------------------------------------------------
/src/reportWebVitals.ts:
--------------------------------------------------------------------------------
1 | import { ReportHandler } from 'web-vitals';
2 |
3 | const reportWebVitals = (onPerfEntry?: ReportHandler): void => {
4 | if (onPerfEntry && onPerfEntry instanceof Function) {
5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
6 | getCLS(onPerfEntry);
7 | getFID(onPerfEntry);
8 | getFCP(onPerfEntry);
9 | getLCP(onPerfEntry);
10 | getTTFB(onPerfEntry);
11 | });
12 | }
13 | };
14 |
15 | export default reportWebVitals;
16 |
--------------------------------------------------------------------------------
/src/setupTests.ts:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------
/tsconfig-lib.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */
4 |
5 | /* Projects */
6 | // "incremental": true, /* Enable incremental compilation */
7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
8 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */
9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */
10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
12 |
13 | /* Language and Environment */
14 | "target": "es5" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
15 | "lib": [
16 | "es2019"
17 | ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
18 | "jsx": "react-jsx" /* Specify what JSX code is generated. */,
19 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
20 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
21 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
22 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
23 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
24 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
25 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
26 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
27 |
28 | /* Modules */
29 | "module": "commonjs" /* Specify what module code is generated. */,
30 | "rootDir": "./src/library" /* Specify the root folder within your source files. */,
31 | "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
32 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
33 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
34 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
35 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
36 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */
37 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
38 | // "resolveJsonModule": true, /* Enable importing .json files */
39 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */
40 |
41 | /* JavaScript Support */
42 | "allowJs": false /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */,
43 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
44 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
45 |
46 | /* Emit */
47 | "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */,
48 | // "declarationMap": true /* Create sourcemaps for d.ts files. */,
49 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
50 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */
51 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
52 | "outDir": "./lib" /* Specify an output folder for all emitted files. */,
53 | // "removeComments": true, /* Disable emitting comments. */
54 | // "noEmit": true, /* Disable emitting files from a compilation. */
55 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
56 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */
57 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
58 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
60 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
61 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
62 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
63 | // "newLine": "crlf", /* Set the newline character for emitting files. */
64 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
65 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
66 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
67 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
68 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */
69 |
70 | /* Interop Constraints */
71 | "isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */,
72 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
73 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */,
74 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
75 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
76 |
77 | /* Type Checking */
78 | "strict": true /* Enable all strict type-checking options. */,
79 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
80 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
81 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
82 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
83 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
84 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
85 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */
86 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
87 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */
88 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */
89 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
90 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
91 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
92 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
93 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
94 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */
95 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
96 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
97 |
98 | /* Completeness */
99 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
100 | "skipLibCheck": true /* Skip type checking all .d.ts files. */
101 | },
102 | "include": ["src/library/**/*"]
103 | }
104 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "esnext",
4 | "useDefineForClassFields": true,
5 | "lib": ["dom", "dom.iterable", "esnext"],
6 | "types": ["vite/client", "jest"],
7 | "allowJs": false,
8 | "skipLibCheck": false,
9 | "esModuleInterop": false,
10 | "inlineSourceMap": true,
11 | "allowSyntheticDefaultImports": true,
12 | "strict": true,
13 | "forceConsistentCasingInFileNames": true,
14 | "module": "esnext",
15 | "moduleResolution": "node",
16 | "resolveJsonModule": true,
17 | "isolatedModules": false,
18 | "noEmit": true,
19 | "jsx": "react-jsx"
20 | },
21 | "include": ["src"]
22 | }
23 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite';
2 | import react from '@vitejs/plugin-react';
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | esbuild: {
8 | sourcemap: 'external',
9 | },
10 | server: {
11 | port: 3000,
12 | },
13 | });
14 |
--------------------------------------------------------------------------------