* 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:
codetunez 2021-04-21 15:45:31 -07:00 коммит произвёл GitHub
Родитель 74b9c0fe32
Коммит e7dece7650
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
24 изменённых файлов: 2122 добавлений и 1504 удалений

4
.gitignore поставляемый
Просмотреть файл

@ -22,3 +22,7 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*
.eslintcache
# deployment changes
.deployment
.vscode/settings.json

Просмотреть файл

@ -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

2306
package-lock.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"
}
}

Двоичные данные
public/favicon.ico Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 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

16
src/components/monaco.css Normal file
Просмотреть файл

@ -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;
}

45
src/components/monaco.tsx Normal file
Просмотреть файл

@ -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;
}

Просмотреть файл

@ -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')
);

20
src/index.tsx Normal file
Просмотреть файл

@ -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')
);

101
src/pages/twin.css Normal file
Просмотреть файл

@ -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;
}

271
src/pages/twin.tsx Normal file
Просмотреть файл

@ -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;

1
src/react-app-env.d.ts поставляемый Normal file
Просмотреть файл

@ -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>
}
}

Просмотреть файл

@ -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);
}

Просмотреть файл

@ -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;

26
tsconfig.json Normal file
Просмотреть файл

@ -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"
]
}