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