RC Final
* V2 - Initial refactor * V2 - Folder refactor * V2 - Baseline refactor * V2 - Stable * V2 - Beta live * V2 - Text updates * V2 - Final * V2 - README.md update
This commit is contained in:
Родитель
74b9c0fe32
Коммит
e7dece7650
|
@ -22,3 +22,7 @@ npm-debug.log*
|
|||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.eslintcache
|
||||
|
||||
# deployment changes
|
||||
.deployment
|
||||
.vscode/settings.json
|
||||
|
|
38
README.md
38
README.md
|
@ -1,17 +1,18 @@
|
|||
# Twin Viewer
|
||||
An application to to view and interact with the Device Twin from IoT Central, IoT Hub and the device
|
||||
|
||||
## Code AAD configuration updates
|
||||
After cloning this repo, you will need to update part of the configuration to use the AAD application that you set up to authenticate and authorize to use Central APIs. You do not need access to MS Graph or ARM to use this tool. To get help setting up an AAD application to use IoT Central resources visit [this](https://github.com/iot-for-all/iotc-aad-setup) repo.
|
||||
## AAD configuration updates
|
||||
After cloning this repo, you will need to update part of the configuration to use the AAD application that you set up to authenticate and authorize to use Central APIs. You will also need to access to MS Graph but **not** ARM resources/scopes to use this tool. To get help setting up an AAD application to use IoT Central resources visit [this](https://github.com/iot-for-all/iotc-aad-setup) repo.
|
||||
|
||||
In the src/shared/authContext.js file, find the block of code near the top of the file that has the following object and update the guid in the configuration to use the values of your AAD application
|
||||
In the [config.tsx](/src/config.tsx) file, find the block of code near the top of the file that has the following object and update the guid in the configuration to use the values of your AAD application
|
||||
|
||||
Example
|
||||
```
|
||||
const AAD = {
|
||||
clientId: '532607b1-ce64-4fd3-b48f-5d7d803e0774',
|
||||
tenantId: 'c26906ba-01b0-48ec-afd2-ad8a55b1b0fb',
|
||||
redirect: 'http://localhost:4002'
|
||||
export const Config = {
|
||||
AADLoginServer: 'https://login.microsoftonline.com',
|
||||
AADClientID: '532607b1-ce64-4fd3-b48f-5d7d803e0774',
|
||||
AADDirectoryID: 'c26906ba-01b0-48ec-afd2-ad8a55b1b0fb',
|
||||
AADRedirectURI: 'http://localhost:4002',
|
||||
}
|
||||
```
|
||||
Once this step is completed, build the app and run. This is a one time update and you will only need to redo this if you change or create a new AAD application.
|
||||
|
@ -42,30 +43,25 @@ http://localhost:4002
|
|||
````
|
||||
|
||||
### __Instructions__
|
||||
The concept of the tool is pretty simple. With the correct context provided on the URL, the tool will ask you to sign in using the same account you use to access your IoT Central application (as given by the app id). If you are already signed in, you will be silently auth'd.
|
||||
|
||||
Once auth'd you will be presented with a UX with the following possible actions. The order you execute the actions is irrelevant but options will be disabled unless the correct context is provided. For example, to see the Device Twin you need the Scope ID and the SaS key of the device. This should be provided on the URL *or* by getting the Central Twin first (which will in the background get the same scope/sas key information for the device). By turning the header on, you will see all this information (see below)
|
||||
When launched, the tool will ask you to sign in using the same account you use to access your IoT Central application (as given by the app id). If you are already signed in, you will be silently auth'd. Once auth'd you will be prompted you will be asked to setup the device. Following the instructions in the tool
|
||||
|
||||
This a summary of the UX features
|
||||
|
||||
| Action | Description
|
||||
|---------------------------|------------------------------------------------------------
|
||||
| Get Central Twin | Use the Azure IoT Central API to get the device properties
|
||||
| Get Cloud Twin | Use the Azure IoT Service SDK to get the Twin from the Cloud
|
||||
| Connect/Re-Connect | Start a browser based device using the same device credentials as the real device
|
||||
| Get Device Twin | Use the Azire IoT Device SDK to get the LKV of the Twin on the Cloud
|
||||
| Help | Use the help panel to set up the device identity (overrides the URL)
|
||||
| Get Central Twin | Use the Azure IoT Central API to get the device twin properties
|
||||
| Get Cloud Twin | Use the Azure IoT Service SDK to get the device twin from the Cloud
|
||||
| Get Device Twin | Use the Azure IoT Device SDK to get the LKV of the Twin on the Cloud
|
||||
| Send Reported Device Twin | (For convenience) Send a reported twin value to the cloud as if it was coming from the device
|
||||
| Send Telemetry | (For convenience) Send a telemetry value to the cloud as if it was coming from the device
|
||||
|
||||
<br />
|
||||
|
||||
### __Incoming Desired Twin feature__
|
||||
When you connect the device using the option in the tool, you will be able to receive desired twin requests. The incoming payloads will appear in a section called "Incoming Desired Twin". Use a cloud based tool of your choice i.e. IoT Central to send desired twin values to the device and observe the changes in the tool.
|
||||
When you connect the device, you will be able to receive desired twin requests. The incoming payloads will appear in a section called "Incoming Desired Twin". Use a cloud based tool of your choice i.e. IoT Central to send desired twin values to the device and observe the changes in the tool.
|
||||
|
||||
<br />
|
||||
|
||||
### __URL Options__
|
||||
You will need to provide parameters on the URL so that the tool has the correct context. Use the standard URL pattern to delimit parameters i.e.
|
||||
### __Setting up the device__
|
||||
You can use the Help panel inside the tool to set up the application and device identity. You can also provide parameters on the URL so that the tool will start with the correct application and device context. Use the standard URL query string pattern to delimit parameters i.e.
|
||||
|
||||
```
|
||||
http://localhost:4002?deviceId=<myDeviceId>&appId=<myAppId>
|
||||
|
@ -76,6 +72,6 @@ http://localhost:4002?deviceId=<myDeviceId>&appId=<myAppId>
|
|||
| appId | The ID of the IoT Central application | Y | string | n/a
|
||||
| deviceId | The ID of the device in IoT Central | Y | string | n/a
|
||||
| scopeId | The Scop ID of the IoT Central application | N | string | n/a
|
||||
| sasKey | A URL encoded string for the device's SaS Key | N | string | n/a
|
||||
| sasKey | A URI encoded string for the device's SaS Key | N | string | n/a
|
||||
| header | Show the header bar (UX) | N | bool | true
|
||||
| cloud | Show the Cloud Twin option (UX) | N | bool | true
|
||||
|
|
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
20
package.json
20
package.json
|
@ -1,20 +1,22 @@
|
|||
{
|
||||
"name": "baseline",
|
||||
"version": "0.1.0",
|
||||
"name": "iotc-twinviewer",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "./server.js",
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^2.10.0",
|
||||
"@azure/msal-react": "^1.0.0-alpha.3",
|
||||
"@fluentui/react": "^8.11.1",
|
||||
"@monaco-editor/react": "^4.1.1",
|
||||
"@testing-library/jest-dom": "^5.11.6",
|
||||
"@testing-library/react": "^11.2.2",
|
||||
"@testing-library/user-event": "^12.6.0",
|
||||
"axios": "^0.21.0",
|
||||
"axios": "^0.21.1",
|
||||
"azure-iothub": "^1.13.1",
|
||||
"body-parser": "^1.19.0",
|
||||
"cra-build-watch": "^3.4.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"express": "^4.17.1",
|
||||
"jsoneditor": "^9.1.9",
|
||||
"react": "^17.0.1",
|
||||
"react-dom": "^17.0.1",
|
||||
"react-scripts": "4.0.1",
|
||||
|
@ -22,8 +24,8 @@
|
|||
"web-vitals": "^0.2.4"
|
||||
},
|
||||
"scripts": {
|
||||
"app": "node server.js",
|
||||
"start": "cross-env PORT=4002 react-scripts start",
|
||||
"start": "node server.js",
|
||||
"start-cra": "cross-env PORT=4002 react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject",
|
||||
|
@ -46,5 +48,11 @@
|
|||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/axios": "^0.14.0",
|
||||
"@types/react": "^17.0.3",
|
||||
"@types/react-dom": "^17.0.3",
|
||||
"typescript": "^4.2.0"
|
||||
}
|
||||
}
|
||||
|
|
Двоичный файл не отображается.
После Ширина: | Высота: | Размер: 15 KiB |
|
@ -3,6 +3,7 @@
|
|||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="A tool app to view the Twin" />
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
.device-form-simulation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.device-form-simulation i {
|
||||
margin-right: 0.25rem;
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
import './deviceForm.css';
|
||||
import React from 'react';
|
||||
|
||||
import { AuthContext } from '../context/authContext';
|
||||
import { DeviceContext } from '../context/deviceContext';
|
||||
import { TextField } from '@fluentui/react/lib/TextField';
|
||||
import { PrimaryButton, DefaultButton } from '@fluentui/react';
|
||||
import { ProgressIndicator } from '@fluentui/react/lib/ProgressIndicator';
|
||||
import { FontIcon } from '@fluentui/react/lib/Icon';
|
||||
import { Link } from '@fluentui/react';
|
||||
import { usePromise } from '../hooks/usePromise';
|
||||
|
||||
function DeviceForm() {
|
||||
const authContext = React.useContext<any>(AuthContext);
|
||||
const deviceContext = React.useContext<any>(DeviceContext);
|
||||
|
||||
// set up the simulated device initialization call
|
||||
const [connectProgress, , , connectDevice] = usePromise({ promiseFn: () => deviceContext.setDevice(form, authContext) });
|
||||
|
||||
// display the logged in user
|
||||
const [user, setUser] = React.useState<any>(authContext.loginAccount?.idTokenClaims?.preferred_username || '')
|
||||
|
||||
// set up the form to capture the fields input
|
||||
const [form, setForm] = React.useState<any>({
|
||||
appId: deviceContext.appId,
|
||||
deviceId: deviceContext.deviceId,
|
||||
scopeId: deviceContext.scopeId,
|
||||
sasKey: deviceContext.sasKey,
|
||||
})
|
||||
|
||||
// once connected, show the connect state
|
||||
const [connected, setConnected] = React.useState<boolean>(deviceContext.connected);
|
||||
|
||||
// handler to update the form fields (onChange)
|
||||
const updateField = (e: any) => {
|
||||
setForm({ ...form, [e.target.name]: e.target.value });
|
||||
}
|
||||
|
||||
// set up the form from device context (if changed)
|
||||
React.useEffect(() => {
|
||||
setForm({
|
||||
appId: deviceContext.appId,
|
||||
deviceId: deviceContext.deviceId,
|
||||
scopeId: deviceContext.scopeId,
|
||||
sasKey: deviceContext.sasKey,
|
||||
})
|
||||
}, [deviceContext])
|
||||
|
||||
// set up the logged in user's display name (if changed)
|
||||
React.useEffect(() => {
|
||||
setUser(authContext.loginAccount?.idTokenClaims?.preferred_username || '')
|
||||
}, [authContext])
|
||||
|
||||
// set up the connected state (if changed)
|
||||
React.useEffect(() => {
|
||||
setConnected(deviceContext.connected);
|
||||
}, [deviceContext.connected])
|
||||
|
||||
// render the UX
|
||||
return <div className='device-form'>
|
||||
<h3>Setup the user</h3>
|
||||
<p>Ensure the following user has been added to the IoT Central application before setting up the device.</p>
|
||||
<TextField value={user} label='Signed-in user' readOnly={true} />
|
||||
<p>If this is not the correct user, <Link onClick={() => authContext.signOut()} underline={true}>sign-out</Link> and sign back in with a different account.</p>
|
||||
<br />
|
||||
<h3>Setup the device to debug</h3>
|
||||
{!connectProgress && connected ?
|
||||
<>
|
||||
<p>Currently connected to device <b>{form.deviceId}</b></p>
|
||||
<DefaultButton onClick={() => { deviceContext.disconnectDevice() }}>Disconnect the device</DefaultButton>
|
||||
</> :
|
||||
<>
|
||||
<p>Please provide details for the device you would like to connect to and debug.</p>
|
||||
<TextField disabled={false} autoComplete='off' label='Application ID' required={true} name='appId' value={form.appId} onChange={updateField} />
|
||||
<TextField disabled={false} autoComplete='off' label='Device ID' required={true} name='deviceId' value={form.deviceId} onChange={updateField} />
|
||||
<TextField disabled={false} autoComplete='off' label='Scope ID' required={false} name='scopeId' value={form.scopeId} onChange={updateField} placeholder='Can be left blank' />
|
||||
<TextField disabled={false} autoComplete='off' label='SaS Key' required={false} name='sasKey' value={form.sasKey} onChange={updateField} placeholder='Can be left blank' />
|
||||
<br />
|
||||
<PrimaryButton onClick={() => { connectDevice() }}>Connect device</PrimaryButton>
|
||||
<br /><br />
|
||||
<div className='device-form-simulation'><FontIcon iconName='Warning' /><span>Simulated devices are not supported.</span></div>
|
||||
<br />
|
||||
{connectProgress ? <ProgressIndicator label='Connecting' /> : null}
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
export default DeviceForm
|
|
@ -0,0 +1,16 @@
|
|||
.monaco {
|
||||
width: 100%;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.12), 0 0 1px rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
|
||||
.monaco-full {
|
||||
height: calc(100vh - 14rem);
|
||||
}
|
||||
|
||||
.monaco-medium {
|
||||
height: calc(100vh - 30rem);
|
||||
}
|
||||
|
||||
.monaco-small {
|
||||
height: 12rem;
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
import './monaco.css';
|
||||
import Editor from '@monaco-editor/react';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
function Monaco({ data, size, onChange }: { data: any, size: 'full' | 'medium' | 'small', onChange?: any }) {
|
||||
|
||||
// set up the data that the editor uses
|
||||
const [jsonData, setJsonData] = React.useState<any>();
|
||||
|
||||
// if new data arrives, set the data variable for the editor
|
||||
React.useEffect(() => {
|
||||
if (typeof data === 'object' && data !== null) {
|
||||
setJsonData(JSON.stringify(data, null, 2));
|
||||
} else {
|
||||
setJsonData(data);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// only add an onChange handler if one is supplied
|
||||
const options = onChange ? { onChange: (value: any) => { onChange(value) } } : {};
|
||||
|
||||
// render the UX
|
||||
return <div className={'monaco monaco-' + size}>
|
||||
<Editor options={{
|
||||
renderLineHighlight: 'none',
|
||||
wordWrap: 'on',
|
||||
formatOnType: true,
|
||||
lineNumbers: 'off',
|
||||
minimap: { enabled: false },
|
||||
glyphMargin: false,
|
||||
disableLayerHinting: true,
|
||||
highlightActiveIndentGuide: false,
|
||||
matchBrackets: 'never',
|
||||
renderIndentGuides: false
|
||||
}}
|
||||
{...options}
|
||||
language='json'
|
||||
defaultValue={jsonData}
|
||||
value={jsonData}
|
||||
/>
|
||||
</div >
|
||||
}
|
||||
|
||||
export default Monaco
|
|
@ -2,8 +2,40 @@ import { Config } from '../config';
|
|||
import * as msal from '@azure/msal-browser';
|
||||
import * as React from 'react';
|
||||
|
||||
const Scopes = {
|
||||
Central: 'https://apps.azureiotcentral.com/user_impersonation',
|
||||
// generic to handle getting an access token for a given scope
|
||||
function getAccessTokenForScope(msalInstance: any, scope: any, account: any) {
|
||||
const tokenRequest: any = {
|
||||
scopes: Array.isArray(scope) ? scope : [scope],
|
||||
forceRefresh: false,
|
||||
redirectUri: Config.AADRedirectURI
|
||||
};
|
||||
|
||||
if (account) { tokenRequest.account = account };
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
msalInstance.acquireTokenSilent(tokenRequest)
|
||||
.then((res: any) => {
|
||||
resolve(res)
|
||||
})
|
||||
.catch((err: any) => {
|
||||
if (err.name === 'BrowserAuthError') {
|
||||
msalInstance.acquireTokenPopup(tokenRequest)
|
||||
.then((res: any) => {
|
||||
resolve(res)
|
||||
})
|
||||
.catch((err: any) => {
|
||||
reject(err);
|
||||
})
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const Scopes = {
|
||||
Graph: 'User.Read',
|
||||
Central: 'https://apps.azureiotcentral.com/user_impersonation'
|
||||
}
|
||||
|
||||
export const MsalConfig = {
|
||||
|
@ -17,13 +49,21 @@ export const MsalConfig = {
|
|||
}
|
||||
}
|
||||
|
||||
export interface AuthContextSate {
|
||||
authenticated: boolean,
|
||||
loginAccount: any,
|
||||
signIn: any,
|
||||
signOut: any,
|
||||
getAccessToken: any
|
||||
}
|
||||
|
||||
export const AuthContext = React.createContext({});
|
||||
|
||||
export class AuthProvider extends React.PureComponent {
|
||||
|
||||
msalInstance = null;
|
||||
private msalInstance: any = null;
|
||||
|
||||
constructor(props) {
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
this.msalInstance = new msal.PublicClientApplication(MsalConfig);
|
||||
}
|
||||
|
@ -31,11 +71,15 @@ export class AuthProvider extends React.PureComponent {
|
|||
signIn = () => {
|
||||
if (this.state.authenticated) { return; }
|
||||
|
||||
let loginAccount = {};
|
||||
let loginAccount: any = {};
|
||||
this.msalInstance.handleRedirectPromise()
|
||||
.then((res) => {
|
||||
.then((res: any) => {
|
||||
loginAccount = res ? res.data.value[0] : this.msalInstance.getAllAccounts()[0];
|
||||
return getAccessTokenForScope(this.msalInstance, Scopes.Central, loginAccount, Config.AADRedirectURI);
|
||||
return getAccessTokenForScope(this.msalInstance, Scopes.Graph, loginAccount);
|
||||
})
|
||||
.then((res: any) => {
|
||||
loginAccount = res;
|
||||
return getAccessTokenForScope(this.msalInstance, Scopes.Central, loginAccount);
|
||||
})
|
||||
.then(() => {
|
||||
this.setState({ loginAccount, authenticated: true })
|
||||
|
@ -46,15 +90,15 @@ export class AuthProvider extends React.PureComponent {
|
|||
}
|
||||
|
||||
signOut = () => {
|
||||
this.msalInstance.logout({ account: this.state.loginAccount });
|
||||
this.msalInstance.logout();
|
||||
}
|
||||
|
||||
getAccessToken = async () => {
|
||||
const res = await getAccessTokenForScope(this.msalInstance, Scopes.Central, this.state.loginAccount);
|
||||
const res: any = await getAccessTokenForScope(this.msalInstance, Scopes.Central, this.state.loginAccount);
|
||||
return res.accessToken;
|
||||
}
|
||||
|
||||
state = {
|
||||
state: AuthContextSate = {
|
||||
authenticated: false,
|
||||
loginAccount: {},
|
||||
signIn: this.signIn,
|
||||
|
@ -69,35 +113,4 @@ export class AuthProvider extends React.PureComponent {
|
|||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function getAccessTokenForScope(msalInstance, scope, account, redirect) {
|
||||
const tokenRequest = {
|
||||
scopes: Array.isArray(scope) ? scope : [scope],
|
||||
forceRefresh: false,
|
||||
redirectUri: redirect
|
||||
};
|
||||
|
||||
if (account) { tokenRequest.account = account };
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
msalInstance.acquireTokenSilent(tokenRequest)
|
||||
.then((res) => {
|
||||
resolve(res)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name === 'BrowserAuthError') {
|
||||
msalInstance.acquireTokenPopup(tokenRequest)
|
||||
.then((res) => {
|
||||
resolve(res)
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err);
|
||||
})
|
||||
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
|
@ -0,0 +1,123 @@
|
|||
import { AzDpsClient } from '../lib/AzDpsClient.js'
|
||||
import { AzIoTHubClient } from '../lib/AzIoTHubClient.js'
|
||||
|
||||
import * as React from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
// this creates a real device client in the browser
|
||||
async function connectBrowserDevice(deviceId: any, scopeId: any, sasKey: any, setDesired: any) {
|
||||
const dpsClient = new AzDpsClient(scopeId, deviceId, sasKey);
|
||||
const result = await dpsClient.registerDevice();
|
||||
if (result.status === 'assigned') {
|
||||
const host = result.registrationState.assignedHub;
|
||||
|
||||
const client = new AzIoTHubClient(host, deviceId, sasKey);
|
||||
client.setDirectMehodCallback((method: any, payload: any, rid: any) => {
|
||||
// not exposed in the UX
|
||||
});
|
||||
|
||||
client.setDesiredPropertyCallback((desired: any) => {
|
||||
setDesired(JSON.parse(desired || ''));
|
||||
});
|
||||
|
||||
client.disconnectCallback = ((err: any) => {
|
||||
console.log(err)
|
||||
console.log('Disconnected');
|
||||
});
|
||||
|
||||
await client.connect();
|
||||
return client;
|
||||
} else {
|
||||
return { 'error': 'NOTASSIGNED: ' + result.status };
|
||||
}
|
||||
}
|
||||
|
||||
async function getCentralDeviceCreds(deviceId: any, appId: any, authContext: any) {
|
||||
try {
|
||||
const at = await authContext.getAccessToken();
|
||||
const credentials = await axios(`https://${appId}.azureiotcentral.com/api/preview/devices/${deviceId}/credentials`, { headers: { 'Authorization': 'Bearer ' + at } });
|
||||
return credentials.data;
|
||||
} catch (err) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export interface DeviceContextState {
|
||||
connected: boolean,
|
||||
client: any,
|
||||
appId: string,
|
||||
deviceId: string,
|
||||
scopeId: string,
|
||||
sasKey: string,
|
||||
twinDesired: any,
|
||||
setDevice: any,
|
||||
connectDevice: any
|
||||
disconnectDevice: any
|
||||
}
|
||||
|
||||
export const DeviceContext = React.createContext({});
|
||||
|
||||
export class DeviceProvider extends React.PureComponent {
|
||||
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
this.state.appId = urlParams && urlParams.get('appId') ? urlParams.get('appId') || '' : '';
|
||||
this.state.deviceId = urlParams && urlParams.get('deviceId') ? urlParams.get('deviceId') || '' : '';
|
||||
this.state.scopeId = urlParams && urlParams.get('scopeId') ? urlParams.get('scopeId') || '' : '';
|
||||
this.state.sasKey = urlParams && urlParams.get('sasKey') ? decodeURI(urlParams.get('sasKey') || '') : '';
|
||||
}
|
||||
|
||||
async connectDevice() {
|
||||
const res: any = await connectBrowserDevice(this.state.deviceId, this.state.scopeId, this.state.sasKey, this.setDesired.bind(this))
|
||||
this.setState({ client: res, connected: res ? true : false });
|
||||
}
|
||||
|
||||
async disconnectDevice() {
|
||||
this.setState({
|
||||
connected: false,
|
||||
client: null,
|
||||
appId: '',
|
||||
deviceId: '',
|
||||
scopeId: '',
|
||||
sasKey: '',
|
||||
twinDesired: {}
|
||||
})
|
||||
}
|
||||
|
||||
async setDevice({ appId, deviceId, scopeId, sasKey }: { appId: string, deviceId: string, scopeId: string, sasKey: string }, authContext: any) {
|
||||
if (scopeId === '' || sasKey === '') {
|
||||
const creds: any = await getCentralDeviceCreds(deviceId, appId, authContext);
|
||||
scopeId = creds.idScope
|
||||
sasKey = creds.symmetricKey.primaryKey
|
||||
}
|
||||
const client: any = await connectBrowserDevice(deviceId, scopeId, sasKey, this.setDesired.bind(this))
|
||||
this.setState({ client, appId, deviceId, scopeId, sasKey, connected: client ? true : false });
|
||||
}
|
||||
|
||||
setDesired(data: any) {
|
||||
this.setState({ twinDesired: data });
|
||||
}
|
||||
|
||||
state: DeviceContextState = {
|
||||
connected: false,
|
||||
client: null,
|
||||
appId: '',
|
||||
deviceId: '',
|
||||
scopeId: '',
|
||||
sasKey: '',
|
||||
twinDesired: {},
|
||||
setDevice: this.setDevice.bind(this),
|
||||
connectDevice: this.connectDevice.bind(this),
|
||||
disconnectDevice: this.disconnectDevice.bind(this)
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<DeviceContext.Provider value={this.state}>
|
||||
{ this.props.children}
|
||||
</DeviceContext.Provider>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,9 +1,9 @@
|
|||
import * as React from 'react';
|
||||
|
||||
export const usePromise = ({ promiseFn }) => {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [data, setData] = React.useState(null);
|
||||
const [error, setError] = React.useState(null);
|
||||
export const usePromise = ({ promiseFn }: { promiseFn: any }) => {
|
||||
const [loading, setLoading] = React.useState<boolean>(false);
|
||||
const [data, setData] = React.useState<any>(null);
|
||||
const [error, setError] = React.useState<any>(null);
|
||||
|
||||
const callPromise = async () => {
|
||||
setLoading(true);
|
||||
|
@ -12,8 +12,8 @@ export const usePromise = ({ promiseFn }) => {
|
|||
try {
|
||||
const res = await promiseFn();
|
||||
setData(res);
|
||||
} catch (error) {
|
||||
setError({ error: error });
|
||||
} catch (error: any) {
|
||||
setError(error);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
|
@ -7,4 +7,14 @@ body {
|
|||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
margin: 0 1rem 0 0;
|
||||
}
|
||||
|
||||
h5 {
|
||||
margin: 0;
|
||||
font-weight: 200;
|
||||
font-size: 0.75rem;
|
||||
}
|
14
src/index.js
14
src/index.js
|
@ -1,14 +0,0 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import './index.css';
|
||||
import Twin from './twin';
|
||||
import { AuthProvider } from './shared/authContext';
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<AuthProvider >
|
||||
<Twin />
|
||||
</AuthProvider>
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root')
|
||||
);
|
|
@ -0,0 +1,20 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import './index.css';
|
||||
import Twin from './pages/twin';
|
||||
import { AuthProvider } from './context/authContext';
|
||||
import { DeviceProvider } from './context/deviceContext';
|
||||
|
||||
import { initializeIcons } from '@fluentui/react/lib/Icons';
|
||||
initializeIcons();
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<AuthProvider >
|
||||
<DeviceProvider>
|
||||
<Twin />
|
||||
</DeviceProvider>
|
||||
</AuthProvider>
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root')
|
||||
);
|
|
@ -0,0 +1,101 @@
|
|||
body {
|
||||
background-color: #f7f7f7;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#root, .page {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-initial {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.btn-bar {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layout {
|
||||
flex-grow: 1;
|
||||
padding: 0 1rem 0 1.5rem;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.layout>div {
|
||||
min-width: 40rem;
|
||||
}
|
||||
|
||||
.layout>div {
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.option {
|
||||
height: 8rem;
|
||||
}
|
||||
|
||||
.option label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
height: 2rem;
|
||||
line-height: 2rem;
|
||||
padding: 0.5rem 1rem 0.5rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header>div:nth-child(2) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.help-panel-header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.help-panel-header .help>button {
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.help i {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.help button {
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cliploader-spacer {
|
||||
margin-left: 0.25rem;
|
||||
}
|
|
@ -0,0 +1,271 @@
|
|||
import './twin.css';
|
||||
|
||||
import React from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
import { AuthContext } from '../context/authContext';
|
||||
import { DeviceContext } from '../context/deviceContext';
|
||||
import { usePromise } from '../hooks/usePromise';
|
||||
|
||||
import { FontIcon } from '@fluentui/react/lib/Icon';
|
||||
import { Panel, PanelType, IPanelProps } from '@fluentui/react/lib/Panel';
|
||||
import { IRenderFunction } from '@fluentui/react/lib/Utilities';
|
||||
import { PrimaryButton } from '@fluentui/react';
|
||||
import { ProgressIndicator } from '@fluentui/react/lib/ProgressIndicator';
|
||||
import { TeachingBubble } from '@fluentui/react/lib/TeachingBubble';
|
||||
import { IButtonProps } from '@fluentui/react/lib/Button';
|
||||
import { DirectionalHint } from '@fluentui/react/lib/Callout';
|
||||
|
||||
import Monaco from '../components/monaco';
|
||||
import DeviceForm from '../components/deviceForm';
|
||||
import ClipLoader from 'react-spinners/ClipLoader';
|
||||
|
||||
async function getCentralTwin(deviceId: any, appId: any, authContext: any) {
|
||||
try {
|
||||
const at = await authContext.getAccessToken();
|
||||
const properties = await axios(`https://${appId}.azureiotcentral.com/api/preview/devices/${deviceId}/properties`, { headers: { 'Authorization': 'Bearer ' + at } });
|
||||
const credentials = await axios(`https://${appId}.azureiotcentral.com/api/preview/devices/${deviceId}/credentials`, { headers: { 'Authorization': 'Bearer ' + at } });
|
||||
return { twin: properties.data, credentials: credentials.data };
|
||||
} catch (err) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function getCloudTwin(deviceId: any, appId: any, authContext: any) {
|
||||
let hubs: any = {};
|
||||
try {
|
||||
const at = await authContext.getAccessToken();
|
||||
hubs = await axios.post(`https://${appId}.azureiotcentral.com/system/iothubs/generateSasTokens`, {}, { headers: { 'Authorization': 'Bearer ' + at } });
|
||||
} catch {
|
||||
throw new Error('Device not found');
|
||||
}
|
||||
|
||||
for (const key in hubs.data) {
|
||||
try {
|
||||
const res = await axios.get('/api/twin/' + deviceId, { headers: { 'Authorization': hubs.data[key].iothubTenantSasToken.sasToken } })
|
||||
return res.data;
|
||||
} catch {
|
||||
// an error means the twin is not found or cannot be fetched. Just move to the next hub
|
||||
}
|
||||
}
|
||||
throw new Error('Device not found');
|
||||
}
|
||||
|
||||
async function getDeviceTwin(client: any) {
|
||||
try {
|
||||
const twin = await client.getTwin();
|
||||
return { reported: twin.reported, desired: twin.desired }
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeDeviceTwin(client: any, payload: any) {
|
||||
try {
|
||||
const updateResult = await client.updateTwin(payload);
|
||||
if (updateResult === 204) {
|
||||
const twin = await client.getTwin()
|
||||
return { reported: twin.reported, desired: twin.desired }
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeDeviceTelemetry(client: any, payload: any) {
|
||||
try {
|
||||
await client.sendTelemetry(payload);
|
||||
return payload; // just return something
|
||||
}
|
||||
catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
function App() {
|
||||
|
||||
// provide access to user and device authentication/authorization
|
||||
const authContext = React.useContext<any>(AuthContext);
|
||||
const deviceContext = React.useContext<any>(DeviceContext);
|
||||
|
||||
// device level state
|
||||
const [connected, setConnected] = React.useState<any>(deviceContext.connected);
|
||||
const [desired, setDesired] = React.useState<any>({});
|
||||
const [deviceTwinClient, setDeviceTwinClient] = React.useState<any>(null);
|
||||
|
||||
// ux level state
|
||||
const [helpPanel, showHelpPanel] = React.useState<boolean>(false);
|
||||
const [teaching, showTeaching] = React.useState<boolean>(true);
|
||||
|
||||
// the reported twin as typed in by the user
|
||||
const [reportedTwin, setReportedTwin] = React.useState<any>({});
|
||||
|
||||
// the reported telemetry as typed in by the user
|
||||
const [reportedTelemetry, setReportedTelemetry] = React.useState<any>({});
|
||||
|
||||
// // use url params to provide the deviceId and application Id (mandatory)
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const headerUx = urlParams && urlParams.get('header') && urlParams.get('header') === 'false' ? false : true;
|
||||
const cloudUx = urlParams && urlParams.get('cloud') && urlParams.get('cloud') === 'false' ? false : true;
|
||||
|
||||
// all the async data loading methods
|
||||
const [progressFetchCentralTwin, centralTwin, , fetchCentralTwin] = usePromise({ promiseFn: () => getCentralTwin(deviceContext.deviceId, deviceContext.appId, authContext) });
|
||||
const [progressFetchCloudTwin, cloudTwin, , fetchCloudTwin] = usePromise({ promiseFn: () => getCloudTwin(deviceContext.deviceId, deviceContext.appId, authContext) });
|
||||
const [progressFetchDeviceTwin, deviceTwin, , fetchDeviceTwin] = usePromise({ promiseFn: () => getDeviceTwin(deviceTwinClient) });
|
||||
const [progressSendDeviceTwin, , , sendDeviceTwin] = usePromise({ promiseFn: () => writeDeviceTwin(deviceTwinClient, reportedTwin) });
|
||||
const [progressSendDeviceTelemetry, , , sendDeviceTelemetry] = usePromise({ promiseFn: () => writeDeviceTelemetry(deviceTwinClient, reportedTelemetry) });
|
||||
|
||||
// first render cycle. do silent authentication
|
||||
React.useEffect(() => {
|
||||
authContext.signIn();
|
||||
}, [authContext]);
|
||||
|
||||
// when connected status changes, update the UX
|
||||
React.useEffect(() => {
|
||||
setConnected(deviceContext.connected);
|
||||
}, [deviceContext.connected])
|
||||
|
||||
// when device client is created, update the internal state
|
||||
React.useEffect(() => {
|
||||
setDeviceTwinClient(deviceContext.client);
|
||||
}, [deviceContext.client])
|
||||
|
||||
// when a twin desired is received, update the UX
|
||||
React.useEffect(() => {
|
||||
setDesired(deviceContext.twinDesired);
|
||||
}, [deviceContext.twinDesired])
|
||||
|
||||
// when the internal state device client is changed, fetch all the device data
|
||||
React.useEffect(() => {
|
||||
if (deviceTwinClient) {
|
||||
fetchDeviceTwin();
|
||||
fetchCentralTwin();
|
||||
fetchCloudTwin();
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
}, [deviceTwinClient])
|
||||
|
||||
// this is the header UX for the help panel
|
||||
const onRenderNavigationContent: IRenderFunction<IPanelProps> = React.useCallback(
|
||||
(props, defaultRender) => (
|
||||
<div className='help-panel-header'>
|
||||
<div className='help'>
|
||||
<button onClick={() => showHelpPanel(false)}><FontIcon iconName='Unknown' />Help</button>
|
||||
</div>
|
||||
{defaultRender!(props)}
|
||||
</div>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
// this is the button UX for the teaching bubble
|
||||
const primaryButtonProps: IButtonProps = {
|
||||
children: 'OK',
|
||||
onClick: () => showTeaching(false)
|
||||
};
|
||||
|
||||
// main render
|
||||
return !authContext.authenticated ?
|
||||
<div className='page-initial'>
|
||||
<h2>Please wait</h2>
|
||||
<ProgressIndicator label='Waiting for authentication' />
|
||||
</div>
|
||||
:
|
||||
<div className='page'>
|
||||
<Panel
|
||||
headerText=''
|
||||
hasCloseButton={false}
|
||||
isLightDismiss={true}
|
||||
type={PanelType.customNear}
|
||||
isOpen={helpPanel}
|
||||
customWidth={'320px'}
|
||||
onDismiss={() => { showHelpPanel(false) }}
|
||||
onRenderNavigationContent={onRenderNavigationContent}>
|
||||
<DeviceForm />
|
||||
</Panel>
|
||||
{!headerUx ? null : <>
|
||||
<div className='header'>
|
||||
<div className='help'>
|
||||
<button id='help' onClick={() => showHelpPanel(true)}><FontIcon iconName='Unknown' />Help</button>
|
||||
</div>
|
||||
<div>{connected ? 'DEVICE CONNECTED' : 'DEVICE NOT CONNECTED'}</div>
|
||||
</div>
|
||||
|
||||
{!teaching ? null :
|
||||
<TeachingBubble target='#help' primaryButtonProps={primaryButtonProps} headline='Setup Twin Viewer' calloutProps={{ directionalHint: DirectionalHint.bottomCenter }}>
|
||||
Use Help to setup the application and device for the twin you would like to debug.
|
||||
</TeachingBubble>
|
||||
}
|
||||
</>
|
||||
}
|
||||
<div className='layout'>
|
||||
<div className='column'>
|
||||
<div className='option'>
|
||||
<h5>Platform: Azure IoT Central</h5>
|
||||
<h2>Central Twin</h2>
|
||||
<label>View the device's twin using the IoT Central Public REST API</label>
|
||||
<PrimaryButton disabled={!deviceTwinClient} onClick={() => { fetchCentralTwin() }}>Get Central Twin</PrimaryButton>
|
||||
</div>
|
||||
{centralTwin && deviceContext.connected ? <Monaco data={centralTwin.twin} size='full' /> : progressFetchCentralTwin ? <ProgressIndicator label='Fetching' /> : null}
|
||||
</div>
|
||||
{!cloudUx ? null :
|
||||
<div className='column'>
|
||||
<div className='option'>
|
||||
<h5>Platform: Azure IoT Hub</h5>
|
||||
<h2>Cloud Twin</h2>
|
||||
<label>View the device's twin in the Cloud/IoT Hub using the Service SDK</label>
|
||||
<PrimaryButton disabled={!deviceTwinClient} onClick={() => { fetchCloudTwin() }}>Get Cloud Twin</PrimaryButton>
|
||||
</div>
|
||||
{cloudTwin && deviceContext.connected ? <Monaco data={cloudTwin} size='full' /> : progressFetchCloudTwin ? <ProgressIndicator label='Fetching' /> : null}
|
||||
</div>
|
||||
}
|
||||
<div className='column'>
|
||||
<div className='option'>
|
||||
<h5>Platform: Device</h5>
|
||||
<h2>Device Twin</h2>
|
||||
<label>A simulated version of the device using the Device SDK</label>
|
||||
<div className='btn-bar'>
|
||||
<PrimaryButton disabled={!deviceTwinClient} onClick={() => { fetchDeviceTwin() }}>
|
||||
<span>Get Device Twin</span>
|
||||
<span className='cliploader-spacer'>{progressFetchDeviceTwin ? <ClipLoader size={8} color='#fffff' /> : null}</span>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
</div>
|
||||
{deviceTwinClient ?
|
||||
<>
|
||||
{deviceTwin && !progressFetchDeviceTwin ? <Monaco data={deviceTwin} size='medium' /> : progressFetchDeviceTwin ? 'Fetching' : 'Click to view the latest version of the Cloud\'s full Twin from the device'}
|
||||
<h4>Incoming Desired Twin</h4>
|
||||
{desired ? <Monaco data={desired} size='small' /> : 'Waiting... Send a desired property to this device from your cloud based application i.e. IoT Central'}
|
||||
</>
|
||||
: ''}
|
||||
</div>
|
||||
<div className='column'>
|
||||
<div className='option'>
|
||||
<h5>Platform: Device</h5>
|
||||
<h2>Report a Device Twin</h2>
|
||||
<label>Send a reported device twin to the hub using the Device SDK</label>
|
||||
<PrimaryButton disabled={!deviceTwinClient} onClick={() => { sendDeviceTwin() }}>
|
||||
<span>Send Reported Device Twin</span>
|
||||
<span className='cliploader-spacer'>{progressSendDeviceTwin ? <ClipLoader size={8} color='#ffffff' /> : null}</span>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
{deviceTwinClient ? <Monaco data={reportedTwin} onChange={setReportedTwin} size='full' /> : ''}
|
||||
</div>
|
||||
<div className='column'>
|
||||
<div className='option'>
|
||||
<h5>Platform: Device</h5>
|
||||
<h2>Report Telemetry</h2>
|
||||
<label>Send telemetry to the hub using the Device SDK</label>
|
||||
<PrimaryButton disabled={!deviceTwinClient} onClick={() => { sendDeviceTelemetry() }}>
|
||||
<span>Send Telemetry</span>
|
||||
<span className='cliploader-spacer'>{progressSendDeviceTelemetry ? <ClipLoader size={8} color='#ffffff' /> : null}</span>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
{deviceTwinClient ? <Monaco data={reportedTelemetry} onChange={setReportedTelemetry} size='full' /> : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
}
|
||||
|
||||
export default App;
|
|
@ -0,0 +1 @@
|
|||
/// <reference types='react-scripts' />
|
|
@ -1,64 +0,0 @@
|
|||
import * as React from 'react';
|
||||
import * as JS from 'jsoneditor';
|
||||
import 'jsoneditor/dist/jsoneditor.css';
|
||||
|
||||
export class Json extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props)
|
||||
this.myRef = React.createRef();
|
||||
this.state = {
|
||||
editor: {},
|
||||
options: {
|
||||
mode: 'code',
|
||||
mainMenuBar: false,
|
||||
navigationBar: false,
|
||||
statusBar: true,
|
||||
onChange: this.callback
|
||||
}
|
||||
}
|
||||
|
||||
if (this.props.liveUpdate) {
|
||||
this.state.options['onEditable'] = (node) => {
|
||||
if (!node.path) { return false; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const container = this.myRef.current;
|
||||
// eslint-disable-next-line
|
||||
this.state.editor = new JS(container, this.state.options);
|
||||
// eslint-disable-next-line
|
||||
this.state.editor.set(this.props.json || {});
|
||||
}
|
||||
|
||||
// this is set to false as to not trigger updates whilst authoring JSON
|
||||
shouldComponentUpdate(nextProps) {
|
||||
if (this.props.liveUpdate) {
|
||||
this.state.editor.set(nextProps.json || {});
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.state.editor) {
|
||||
this.state.editor.destroy();
|
||||
// eslint-disable-next-line
|
||||
this.state.editor = null;
|
||||
}
|
||||
}
|
||||
|
||||
callback = () => {
|
||||
let text = '';
|
||||
try {
|
||||
text = this.state.editor.get()
|
||||
if (this.props.onChange) { this.props.onChange(text); }
|
||||
} catch { }
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div ref={this.myRef} className={this.props.className}></div>
|
||||
}
|
||||
}
|
122
src/twin.css
122
src/twin.css
|
@ -1,122 +0,0 @@
|
|||
body {
|
||||
background-color: #cfcfcf;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#root, .page {
|
||||
height: 100%;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
b {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
margin: 0 1rem 1rem 0;
|
||||
}
|
||||
|
||||
h5 {
|
||||
margin: 0;
|
||||
font-weight: 200;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
padding: 1rem;
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: lightgray;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-bar {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.btn-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.btn-inline>span:first-child {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex-shrink: 0;
|
||||
height: 2rem;
|
||||
line-height: 2rem;
|
||||
padding: 0.5rem;
|
||||
background-color: black;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.layout {
|
||||
flex-grow: 1;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.layout>div {
|
||||
min-width: 40rem;
|
||||
}
|
||||
|
||||
.layout>div {
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.layout>div .editor {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.option {
|
||||
height: 9rem;
|
||||
}
|
||||
|
||||
.option label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.option button:not(:last-child) {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.tall-editor {
|
||||
height: calc(100vh - 17rem);
|
||||
}
|
||||
|
||||
.small-editor {
|
||||
height: 40rem;
|
||||
}
|
||||
|
||||
.last-editor {
|
||||
height: calc(100vh - 61rem);
|
||||
}
|
240
src/twin.js
240
src/twin.js
|
@ -1,240 +0,0 @@
|
|||
import React from 'react';
|
||||
import * as axios from 'axios';
|
||||
import './twin.css';
|
||||
import { AuthContext } from './shared/authContext';
|
||||
import { Json } from './shared/json';
|
||||
import { usePromise } from './shared/usePromise';
|
||||
import { AzDpsClient } from './lib/AzDpsClient.js'
|
||||
import { AzIoTHubClient } from './lib/AzIoTHubClient.js'
|
||||
import ClipLoader from "react-spinners/ClipLoader";
|
||||
|
||||
// this creates a real device client in the browser
|
||||
async function connectBrowserDevice(deviceId, scopeId, sasKey, setDesired) {
|
||||
const dpsClient = new AzDpsClient(scopeId, deviceId, sasKey);
|
||||
const result = await dpsClient.registerDevice();
|
||||
if (result.status === 'assigned') {
|
||||
const host = result.registrationState.assignedHub;
|
||||
|
||||
const client = new AzIoTHubClient(host, deviceId, sasKey);
|
||||
client.setDirectMehodCallback((method, payload, rid) => {
|
||||
// not exposed in the UX
|
||||
})
|
||||
client.setDesiredPropertyCallback(desired => {
|
||||
setDesired(JSON.parse(desired || ''));
|
||||
})
|
||||
client.disconnectCallback = (err) => {
|
||||
console.log(err)
|
||||
console.log('Disconnected');
|
||||
}
|
||||
await client.connect();
|
||||
return client;
|
||||
} else {
|
||||
return { 'error': 'NOTASSIGNED: ' + result.status };
|
||||
}
|
||||
}
|
||||
|
||||
async function getCentralTwin(deviceId, appId, authContext) {
|
||||
try {
|
||||
const at = await authContext.getAccessToken();
|
||||
const properties = await axios(`https://${appId}.azureiotcentral.com/api/preview/devices/${deviceId}/properties`, { headers: { "Authorization": "Bearer " + at } });
|
||||
const credentials = await axios(`https://${appId}.azureiotcentral.com/api/preview/devices/${deviceId}/credentials`, { headers: { "Authorization": "Bearer " + at } });
|
||||
return { twin: properties.data, credentials: credentials.data };
|
||||
} catch (err) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function getCloudTwin(deviceId, appId, authContext) {
|
||||
let hubs = {};
|
||||
try {
|
||||
const at = await authContext.getAccessToken();
|
||||
hubs = await axios.post(`https://${appId}.azureiotcentral.com/system/iothubs/generateSasTokens`, {}, { headers: { "Authorization": "Bearer " + at } });
|
||||
} catch {
|
||||
throw new Error('Device not found');
|
||||
}
|
||||
|
||||
for (const key in hubs.data) {
|
||||
try {
|
||||
const res = await axios.get('/api/twin/' + deviceId, { headers: { "Authorization": hubs.data[key].iothubTenantSasToken.sasToken } })
|
||||
return res.data;
|
||||
} catch {
|
||||
// an error means the twin is not found or cannot be fetched. Just move to the next hub
|
||||
}
|
||||
}
|
||||
throw new Error('Device not found');
|
||||
}
|
||||
|
||||
async function getDeviceTwin(client) {
|
||||
try {
|
||||
const twin = await client.getTwin();
|
||||
return { reported: twin.reported, desired: twin.desired }
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeDeviceTwin(client, payload) {
|
||||
try {
|
||||
const updateResult = await client.updateTwin(JSON.stringify(payload));
|
||||
if (updateResult === 204) {
|
||||
const twin = await client.getTwin()
|
||||
return { reported: twin.reported, desired: twin.desired }
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeDeviceTelemetry(client, payload) {
|
||||
try {
|
||||
await client.sendTelemetry(JSON.stringify(payload));
|
||||
return payload; // just return something
|
||||
}
|
||||
catch (err) {
|
||||
console.log(err);
|
||||
}
|
||||
}
|
||||
|
||||
function App() {
|
||||
|
||||
// provide access to authentication and authorization results
|
||||
const authContext = React.useContext(AuthContext);
|
||||
|
||||
// the desired twin payload received when the device is connected
|
||||
const [desired, setDesired] = React.useState({});
|
||||
|
||||
// the reported twin as typed in by the user
|
||||
const [reportedTwin, setReportedTwin] = React.useState({});
|
||||
|
||||
// the reported telemetry as typed in by the user
|
||||
const [reportedTelemetry, setReportedTelemetry] = React.useState({});
|
||||
|
||||
// use url params to provide the deviceId and application Id (mandatory)
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const appId = urlParams && urlParams.get('appId') ? urlParams.get('appId') : '';
|
||||
const deviceId = urlParams && urlParams.get('deviceId') ? urlParams.get('deviceId') : '';
|
||||
const headerUx = urlParams && urlParams.get('header') && urlParams.get('header') === "false" ? false : true;
|
||||
const cloudUx = urlParams && urlParams.get('cloud') && urlParams.get('cloud') === "false" ? false : true;
|
||||
|
||||
// all the async data loading methods
|
||||
// eslint-disable-next-line
|
||||
const [progressFetchCentralTwin, centralTwin, errorFetchCentralTwin, fetchCentralTwin] = usePromise({ promiseFn: () => getCentralTwin(deviceId, appId, authContext) });
|
||||
// eslint-disable-next-line
|
||||
const [progressFetchCloudTwin, cloudTwin, errorFetchCloudTwin, fetchCloudTwin] = usePromise({ promiseFn: () => getCloudTwin(deviceId, appId, authContext) });
|
||||
// eslint-disable-next-line
|
||||
const [progressFetchDeviceClient, deviceTwinClient, errorFetchDeviceTwinClient, fetchDeviceTwinClient] = usePromise({ promiseFn: () => connectBrowserDevice(deviceId, scopeId, sasKey, setDesired) });
|
||||
// eslint-disable-next-line
|
||||
const [progressFetchDeviceTwin, deviceTwin, errorFetchDeviceTwin, fetchDeviceTwin] = usePromise({ promiseFn: () => getDeviceTwin(deviceTwinClient) });
|
||||
// eslint-disable-next-line
|
||||
const [progressSendDeviceTwin, deviceTwinSend, errorSendDeviceTwin, sendDeviceTwin] = usePromise({ promiseFn: () => writeDeviceTwin(deviceTwinClient, reportedTwin) });
|
||||
// eslint-disable-next-line
|
||||
const [progressSendDeviceTelemetry, deviceTelemetrySend, errorSendDeviceTelemetry, sendDeviceTelemetry] = usePromise({ promiseFn: () => writeDeviceTelemetry(deviceTwinClient, reportedTelemetry) });
|
||||
|
||||
// use the url to override (or shortcut) getting the scopeId and sasKey
|
||||
const scopeId = urlParams && urlParams.get('scopeId') ? urlParams.get('scopeId') : centralTwin ? centralTwin.credentials.idScope : '';
|
||||
const sasKey = urlParams && urlParams.get('sasKey') ? decodeURI(urlParams.get('sasKey')) : centralTwin ? centralTwin.credentials.symmetricKey.primaryKey : '';
|
||||
|
||||
// first render cycle. do silent authentication
|
||||
React.useEffect(() => {
|
||||
authContext.signIn();
|
||||
}, [authContext]);
|
||||
|
||||
// main render
|
||||
return !authContext.authenticated ? <span>Authenticating</span> :
|
||||
<div className="page">
|
||||
{!headerUx ? null :
|
||||
<div className="header">Device ID: {deviceId === '' ? '(Need Device ID)' : deviceId} / Application ID: {appId === '' ? '(Need Application ID)' : appId} / Scope ID: {scopeId === '' ? '(Get From Central Twin call)' : scopeId} / Sas Key ID: {sasKey === '' ? '(Get From Central Twin call)' : sasKey}</div>
|
||||
}
|
||||
<div className='layout'>
|
||||
<div className="column">
|
||||
<div className="option">
|
||||
<h5>Platform: Azure IoT Central</h5>
|
||||
<h2>Central Twin</h2>
|
||||
<label>Get the Central version of Twin using the REST API</label>
|
||||
<button className="btn-inline" onClick={() => { fetchCentralTwin() }}>
|
||||
<span>Get Central Twin</span>
|
||||
<span>{progressFetchCentralTwin ? <ClipLoader size={8} /> : null}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="editor">
|
||||
{centralTwin ? <Json json={centralTwin.twin} liveUpdate={true} className='tall-editor' /> : progressFetchCentralTwin ? "Fetching..." : "Click to get Twin data"}
|
||||
</div>
|
||||
</div>
|
||||
{!cloudUx ? null :
|
||||
<div className="column">
|
||||
<div className="option">
|
||||
<h5>Platform: Azure IoT Hub</h5>
|
||||
<h2>Cloud Twin</h2>
|
||||
<label>Get the Cloud/Hub version of the Twin using the Service SDK</label>
|
||||
<button className="btn-inline" onClick={() => { fetchCloudTwin() }}>
|
||||
<span>Get Cloud Twin</span>
|
||||
<span>{progressFetchCloudTwin ? <ClipLoader size={8} /> : null}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="editor">
|
||||
{cloudTwin ? <Json json={cloudTwin} liveUpdate={true} className='tall-editor' /> : progressFetchCloudTwin ? "Fetching" : "Click to get Twin data"}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div className="column">
|
||||
<div className="option">
|
||||
<h5>Platform: Device</h5>
|
||||
<h2>Device Twin</h2>
|
||||
<label>Connect a simulated version of this device and see the Twin using the Device SDK</label>
|
||||
<div className="btn-bar">
|
||||
<button className="btn-inline" disabled={scopeId === '' || sasKey === ''} onClick={() => { fetchDeviceTwinClient() }}>
|
||||
<span>{deviceTwinClient ? "Re-Connect" : "Connect"}</span>
|
||||
<span>{progressFetchDeviceClient ? <ClipLoader size={8} /> : null}</span>
|
||||
</button>
|
||||
<button className="btn-inline" disabled={!deviceTwinClient} onClick={() => { fetchDeviceTwin() }}>
|
||||
<span>Get Device Twin</span>
|
||||
<span>{progressFetchDeviceTwin ? <ClipLoader size={8} /> : null}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="editor">
|
||||
{deviceTwinClient ?
|
||||
<>
|
||||
<div className='small-editor'>
|
||||
{deviceTwin && !progressFetchDeviceTwin ? <Json json={deviceTwin} liveUpdate={true} className='small-editor' /> : progressFetchDeviceTwin ? 'Fetching' : 'Click to view the latest version of the Cloud\'s full Twin from the device'}
|
||||
</div>
|
||||
<h4>Incoming Desired Twin</h4>
|
||||
{desired ? <Json json={desired} liveUpdate={true} className='last-editor' /> : "Waiting... Send a desired property to this device from your cloud based application i.e. IoT Central"}
|
||||
</>
|
||||
: progressFetchDeviceClient ? "Connecting" : "Click to connect connect to hub"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="column">
|
||||
<div className="option">
|
||||
<h5>Platform: Device</h5>
|
||||
<h2>Report a Device Twin</h2>
|
||||
<label>Send a reported device twin back to the hub using the Device SDK</label>
|
||||
<button className="btn-inline" disabled={!deviceTwinClient} onClick={() => { sendDeviceTwin() }}>
|
||||
<span>Send Reported Device Twin</span>
|
||||
<span>{progressSendDeviceTwin ? <ClipLoader size={8} /> : null}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="editor">
|
||||
{deviceTwinClient ? <Json json={reportedTwin} onChange={setReportedTwin} liveUpdate={false} className='tall-editor' /> : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="column">
|
||||
<div className="option">
|
||||
<h5>Platform: Device</h5>
|
||||
<h2>Report Telemetry</h2>
|
||||
<label>Send telemetry back to the hub using the Device SDK</label>
|
||||
<button className="btn-inline" disabled={!deviceTwinClient} onClick={() => { sendDeviceTelemetry() }}>
|
||||
<span>Send Telemetry</span>
|
||||
<span>{progressSendDeviceTelemetry ? <ClipLoader size={8} /> : null}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="editor">
|
||||
{deviceTwinClient ? <Json json={reportedTelemetry} onChange={setReportedTelemetry} liveUpdate={false} className='tall-editor' /> : ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default App;
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
Загрузка…
Ссылка в новой задаче