diff --git a/.gitignore b/.gitignore index 31cf25f..884ba41 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,40 @@ +# Created by https://www.toptal.com/developers/gitignore/api/react,visualstudiocode,macos,windows,node +# Edit at https://www.toptal.com/developers/gitignore?templates=react,visualstudiocode,macos,windows,node + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Node ### # Logs logs *.log @@ -5,11 +42,204 @@ npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* +.pnpm-debug.log* -# Build files -dist/ +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories node_modules/ -.DS_Store +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files .env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +### Node Patch ### +# Serverless Webpack directories +.webpack/ + +# Optional stylelint cache + +# SvelteKit build / generate output +.svelte-kit + +### react ### +.DS_* +**/*.backup.* +**/*.back.* + +node_modules + +*.sublime* + +psd +thumb +sketch + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +# Support for Project snippet scope +.vscode/*.code-snippets + +# Ignore code-workspaces +*.code-workspace + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/react,visualstudiocode,macos,windows,node + +# Build files +dist/ package-lock.json \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..b178f41 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,23 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "pwa-node", + "request": "launch", + "name": "Launch Program", + "skipFiles": [ + "/**" + ], + //"program": "${workspaceFolder}/server/localserver.js", + "cwd": "${workspaceRoot}", + "envFile": "${workspaceRoot}/server/.env", + "runtimeExecutable": "npm", + "runtimeArgs": [ + "run-script", "dev" + ], + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 08a39f9..da5fefa 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,16 @@ # forge-dataviz-iot-reference-app +![Node.js](https://img.shields.io/badge/node-%3E%3D%2016.0.0-brightgreen.svg) +![Platforms](https://img.shields.io/badge/platform-windows%20%7C%20osx%20%7C%20linux-lightgray.svg) +![License](https://img.shields.io/badge/license-MIT-green.svg) + +[![Viewer](https://img.shields.io/badge/Viewer-v7-green.svg)](http://developer.autodesk.com/) +[![Data-Visualization](https://img.shields.io/badge/Data%20Visualization-v1-green.svg)](http://developer.autodesk.com/) +[![oAuth2](https://img.shields.io/badge/oAuth2-v1-green.svg)](http://developer.autodesk.com/) +[![Data-Management](https://img.shields.io/badge/Data%20Management-v1-green.svg)](http://developer.autodesk.com/) +[![OSS](https://img.shields.io/badge/OSS-v2-green.svg)](http://developer.autodesk.com/) +[![Model-Derivative](https://img.shields.io/badge/Model%20Derivative-v2-green.svg)](http://developer.autodesk.com/) + ![Application](docs/dataviz-intro.jpg) This sample application demonstrates the functionality of the Forge Data Visualization extension. To learn more about the extension and the features it offers, see the [Data Visualization Extension Developer's Guide](https://forge.autodesk.com/en/docs/dataviz/v1/developers_guide/introduction/overview/). diff --git a/client/pages/App.jsx b/client/pages/App.jsx index 7c23f18..127729b 100755 --- a/client/pages/App.jsx +++ b/client/pages/App.jsx @@ -13,6 +13,7 @@ import StructureInfo from "./StructureInfo.jsx"; import Navisworks from "./Navisworks.jsx"; import CustomPage from "./CustomPage.jsx"; import Playground from "./Playground.jsx"; +import SensorManage from "./SensorManage.jsx"; /** * @@ -62,6 +63,9 @@ function App(props) { + + + diff --git a/client/pages/SensorManage.jsx b/client/pages/SensorManage.jsx new file mode 100644 index 0000000..9c1219a --- /dev/null +++ b/client/pages/SensorManage.jsx @@ -0,0 +1,145 @@ +/** + * This is a sample code to show how to use the getSelectedPoints() API to get a list of JSON objects representing {@link RoomDevice}. + * + * The resulting list can be used to add sprite viewables to the model. + */ + +import React, { useState, useEffect, useRef } from "react"; +import { Viewer } from "forge-dataviz-iot-react-components"; +import DataHelper from "./DataHelper"; +import { EventTypes } from "forge-dataviz-iot-react-components"; +import { HyperionToolContainer } from "forge-dataviz-iot-react-components"; + +import "./extensions/SensorManagerExtension"; + +class EventBus {} + +THREE.EventDispatcher.prototype.apply(EventBus.prototype); + +/** + * An example illustrating how to use the {@link SelectionTool} to get a list of JSON objects representing {@link RoomDevice}. + * Can be viewed at: https://hyperion.autodesk.io/SensorManage + * + * @component + * @param {Object} props + * @param {Object} props.appData Data passed to application. + * @param {("AutodeskStaging"|"AutodeskProduction")} props.appData.env Forge API environment + * @param {string} props.appData.docUrn Document URN of model + * @param {Object} props.appContext Contains base urls used to query assets, LMV, data etc. + * + * @memberof Autodesk.DataVisualization.Examples + */ +function SensorManage(props) { + const { env, docUrn } = props.appData; + const eventBusRef = useRef(new EventBus()); + const [appState, setAppState] = useState({}); + const [selectedLevel, setSelectedLevel] = useState(""); + const selectedLevelRef = useRef(null); + const ApplicationContext = props.appContext; + + selectedLevelRef.current = selectedLevel; + + /** + * Handles `Autodesk.Viewing.GEOMETRY_LOADED_EVENT` event that is sent when a model has been completely loaded in the viewer. + * + * @param {Autodesk.Viewing.GuiViewer3D} viewer The viewer in which the model is loaded. + * @param {Object} data Event data that contains the loaded model. + */ + async function onModelLoaded(viewer, data) { + const dataVizExtn = viewer.getExtension("Autodesk.DataVisualization"); + + // Get Model level info + let viewerDocument = data.model.getDocumentNode().getDocument(); + const aecModelData = await viewerDocument.downloadAecModelData(); + let levelsExt; + if (aecModelData) { + levelsExt = await viewer.loadExtension("Autodesk.AEC.LevelsExtension", { + doNotCreateUI: true, + }); + } + + let sensorMgrExt = await viewer.loadExtension("SensorManagerExtension", { + adapterType: "json", + baseUrl: ApplicationContext.dataUrl, + assetUrlPrefix: ApplicationContext.assetUrlPrefix + }); + + if (levelsExt && levelsExt.floorSelector) { + const floorData = levelsExt.floorSelector.floorData; + if (floorData && floorData.length) { + const floor = floorData[0]; + levelsExt.floorSelector.selectFloor(floor.index, true); + } + } + + await sensorMgrExt.refresh(); + + // Model Structure Info + let dataHelper = new DataHelper(); + let devices = []; + let shadingData = await dataHelper.createShadingGroupByFloor(viewer, data.model, devices); + const levelInfo = dataHelper.createDeviceTree(shadingData, true); + + setSelectedLevel(levelInfo[0]); + + setAppState({ + viewer, + dataVizExtn, + levelsExt, + buildingInfo: shadingData, + levelInfo: levelInfo, + }); + } + + useEffect(() => { + eventBusRef.current.addEventListener(EventTypes.GROUP_SELECTION_MOUSE_CLICK, (event) => { + if (appState.levelsExt) { + let floorSelector = appState.levelsExt.floorSelector; + + if (selectedLevelRef.current && selectedLevelRef.current.id == event.data.id) { + floorSelector.selectFloor(); + setSelectedLevel(null); + selectedLevelRef.current = null; + } else { + if (floorSelector.floorData) { + let floor = floorSelector.floorData.find( + (item) => item.name == event.data.id + ); + if (floor) { + floorSelector.selectFloor(floor.index, true); + setSelectedLevel(event.data); + selectedLevelRef.current = event.data; + } + } + } + } + }); + }, [appState.levelsExt]); + + return ( + + + await fetch("/api/token") + .then((res) => res.json()) + .then((data) => data.access_token) + } + /> + {appState && appState.levelInfo && ( + + )} + + ); +} + +export default SensorManage; diff --git a/client/pages/extensions/Hyperion.Data.JsonDbRestApiDataAdapter.js b/client/pages/extensions/Hyperion.Data.JsonDbRestApiDataAdapter.js new file mode 100644 index 0000000..ad7bd30 --- /dev/null +++ b/client/pages/extensions/Hyperion.Data.JsonDbRestApiDataAdapter.js @@ -0,0 +1,259 @@ + +const { DataAdapter, DeviceModel, DeviceData, AggregatedValues } = require("forge-dataviz-iot-data-modules/client"); +const { getTimeInEpochSeconds, getPaddedRange } = require("forge-dataviz-iot-data-modules/shared/Utility"); + +/** + * Data adapter class dealing with sample data. + * @memberof Autodesk.DataVisualization.Data + * @alias Autodesk.DataVisualization.Data.JsonDbRestApiDataAdapter + * @augments DataAdapter + */ +export class JsonDbRestApiDataAdapter extends DataAdapter { + /** + * Constructs an instance of RestApiDataAdapter. + */ + constructor(provider = "json", baseName = "") { + super("JsonDbRestApiDataAdapter", baseName); + /* eslint-disable no-undef */ + this._provider = provider; + } + + async addDeviceByModel(device, deviceModel) { + const data = { + ...device, + deviceModelId: deviceModel.id + }; + + return fetch(this._getResourceUrl("api/devices"), { + method: "post", + body: JSON.stringify(data), + headers: new Headers({ "Content-Type": "application/json" }) + }) + .then((response) => response.json()) + .then((rawDevice) => { + + const device = {}; + device.name = rawDevice.name; + + const p = rawDevice.position; + device.position = new THREE.Vector3( + parseFloat(p.x), + parseFloat(p.y), + parseFloat(p.z) + ); + + device.lastActivityTime = rawDevice.lastActivityTime; + device.deviceModel = deviceModel; + device.sensorTypes = deviceModel.propertyIds; + + return device; + }); + } + + async deleteDevice(deviceId) { + return fetch(this._getResourceUrl(`api/devices/${deviceId}`), { + method: "delete" + }) + .then((response) => response.json()) + .then((rawDevice) => { + + const device = {}; + device.name = rawDevice.name; + + const p = rawDevice.position; + device.position = new THREE.Vector3( + parseFloat(p.x), + parseFloat(p.y), + parseFloat(p.z) + ); + + device.lastActivityTime = rawDevice.lastActivityTime; + + return device; + }); + } + + /** + * Loads all DeviceModel objects from the sample REST API. + * + * @returns {Promise} A promise that resolves to a single + *  dimensional array containing a list of loaded DeviceModel objects. If no + *  DeviceModel is available, the promise resolves to an empty array. + * @memberof Autodesk.DataVisualization.Data + * @alias Autodesk.DataVisualization.Data.RestApiDataAdapter#loadDeviceModels + */ + async loadDeviceModels() { + const adapterId = this.id; + return fetch(this._getResourceUrl("api/device-models")) + .then((response) => response.json()) + .then((rawDeviceModels) => { + /** @type {DeviceModel[]} */ + const normalizedDeviceModels = []; + + rawDeviceModels.forEach((rdm) => { + // Create a normalized device model representation. + const ndm = new DeviceModel(rdm.deviceModelId, adapterId); + ndm.name = rdm.deviceModelName; + ndm.description = rdm.deviceModelDesc; + + // Generate device property representation. + rdm.deviceProperties.forEach((rdp) => { + const propId = rdp.propertyId; + const propName = rdp.propertyName; + + const ndp = ndm.addProperty(propId, propName); + ndp.description = rdp.propertyDesc; + ndp.dataType = rdp.propertyType; + ndp.dataUnit = rdp.propertyUnit; + ndp.rangeMin = rdp.rangeMin ? rdp.rangeMin : undefined; + ndp.rangeMax = rdp.rangeMax ? rdp.rangeMax : undefined; + }); + + normalizedDeviceModels.push(ndm); + }); + + // Fetch actual devices for each of the device models. + return this.fetchDevicesForModels(normalizedDeviceModels); + }); + } + + /** + * Fetches actual device IDs and populate DeviceModel objects with them. + * + * @param {DeviceModel[]} deviceModels The DeviceModel objects for which + *  actual device IDs are to be populated. + * + * @returns {Promise} A promise that resolves to the + *  DeviceModel objects populated with actual device IDs. + * @memberof Autodesk.DataVisualization.Data + * @alias Autodesk.DataVisualization.Data.RestApiDataAdapter#fetchDevicesForModels + */ + async fetchDevicesForModels(deviceModels) { + const promises = deviceModels.map((deviceModel) => { + const model = deviceModel.id; + return fetch(this._getResourceUrl("api/devices", { model: model })) + .then((response) => response.json()) + .then((jsonData) => jsonData.deviceInfo); + }); + + return Promise.all(promises).then((deviceInfosList) => { + // Assign devices to each device model. + deviceModels.forEach((deviceModel, index) => { + // Turn data provider specific device data format into + // the unified data format stored in Device object. + // + const deviceInfos = deviceInfosList[index]; + deviceInfos.forEach((deviceInfo) => { + const device = deviceModel.addDevice(deviceInfo.id); + device.name = deviceInfo.name; + + const p = deviceInfo.position; + device.position = new THREE.Vector3( + parseFloat(p.x), + parseFloat(p.y), + parseFloat(p.z) + ); + + device.lastActivityTime = deviceInfo.lastActivityTime; + device.deviceModel = deviceModel; + device.sensorTypes = deviceModel.propertyIds; + }); + }); + + return deviceModels; + }); + } + + /** + * Fetches the property data based on the given device ID. + * + * @param {QueryParam} query Parameters of this query. + * + * @returns {Promise} A promise that resolves to an aggregated + *  property data for the queried device. + * @memberof Autodesk.DataVisualization.Data + * @alias Autodesk.DataVisualization.Data.RestApiDataAdapter#fetchDeviceData + */ + async fetchDeviceData(query) { + const pids = query.propertyIds; + const promises = pids.map((pid) => this._fetchPropertyData(query, pid)); + + return Promise.all(promises).then((deviceDataList) => { + const deviceData = new DeviceData(query.deviceId); + deviceDataList.forEach((devData) => deviceData.mergeFrom(devData)); + return deviceData; + }); + } + + /** + * Fetches data for a single property based on the given device ID. + * + * @param {QueryParam} query Parameters of this query. + * @param {string} propertyId The ID of the property. + * + * @returns {Promise} A promise that resolves to an aggregated + *  property data for the queried device. + */ + async _fetchPropertyData(query, propertyId) { + const url = this._getResourceUrl("api/aggregates", { + device: query.deviceId, + property: propertyId, + startTime: query.dateTimeSpan.startSecond, + endTime: query.dateTimeSpan.endSecond, + resolution: query.dateTimeSpan.resolution, + }); + + return fetch(url) + .then((response) => response.json()) + .then((rawAggregates) => { + // Convert "rawAggregates" which is in the following format, into "AggregatedValues" + // + // rawAggregates = { + // timestamps: number[], + // count: number[], + // min: number[], + // max: number[], + // avg: number[], + // sum: number[], + // stdDev: number[] + // } + // + const aggrValues = new AggregatedValues(query.dateTimeSpan); + aggrValues.tsValues = rawAggregates.timestamps; + aggrValues.countValues = rawAggregates.count; + aggrValues.maxValues = rawAggregates.max; + aggrValues.minValues = rawAggregates.min; + aggrValues.avgValues = rawAggregates.avg; + aggrValues.sumValues = rawAggregates.sum; + aggrValues.stdDevValues = rawAggregates.stdDev; + aggrValues.setDataRange("avgValues", getPaddedRange(aggrValues.avgValues)); + + const deviceData = new DeviceData(query.deviceId); + const propertyData = deviceData.getPropertyData(propertyId); + propertyData.setAggregatedValues(aggrValues); + + return deviceData; + }) + .catch((err) => { + console.error(err); + }); + } + + /** + * Gets the resource URL for a given endpoint with query parameters + * + * @param {string} endpoint The endpoint for the URL to generate + * @param {Object.} parameters Key-value pairs of query parameters + * + * @returns {string} The string that represents the complete resource URL + * @private + */ + _getResourceUrl(endpoint, parameters) { + parameters = parameters || {}; + + parameters["provider"] = this._provider; + parameters["project"] = "unused"; + const ps = Object.entries(parameters).map(([k, v]) => `${k}=${v}`); + return `${this._baseName}/${endpoint}?${ps.join("&")}`; + } +} \ No newline at end of file diff --git a/client/pages/extensions/SensorManagerExtension.js b/client/pages/extensions/SensorManagerExtension.js new file mode 100644 index 0000000..18b2fed --- /dev/null +++ b/client/pages/extensions/SensorManagerExtension.js @@ -0,0 +1,419 @@ +///////////////////////////////////////////////////////////////////// +// Copyright (c) Autodesk, Inc. All rights reserved +// Written by Forge Partner Development +// +// Permission to use, copy, modify, and distribute this software in +// object code form for any purpose and without fee is hereby granted, +// provided that the above copyright notice appears in all copies and +// that both that copyright notice and the limited warranty and +// restricted rights notice below appear in all supporting +// documentation. +// +// AUTODESK PROVIDES THIS PROGRAM "AS IS" AND WITH ALL FAULTS. +// AUTODESK SPECIFICALLY DISCLAIMS ANY IMPLIED WARRANTY OF +// MERCHANTABILITY OR FITNESS FOR A PARTICULAR USE. AUTODESK, INC. +// DOES NOT WARRANT THAT THE OPERATION OF THE PROGRAM WILL BE +// UNINTERRUPTED OR ERROR FREE. +///////////////////////////////////////////////////////////////////// + +import { JsonDbRestApiDataAdapter } from "./Hyperion.Data.JsonDbRestApiDataAdapter"; + +//ref: https://github.com/Autodesk-Forge/library-javascript-viewer-extensions/blob/0c0db2d6426f4ff4aea1042813ed10da17c63554/src/components/UIComponent/UIComponent.js#L34 +function guid(format = "xxxxxxxxxx") { + let d = new Date().getTime(); + + return format.replace( + /[xy]/g, + function (c) { + let r = (d + Math.random() * 16) % 16 | 0; + d = Math.floor(d / 16); + return (c == "x" ? r : (r & 0x7 | 0x8)).toString(16); + }); +} + +class AdnToolInterface { + constructor(viewer) { + this._viewer = viewer; + this._active = false; + this._names = ["unnamed"]; + } + + get viewer() { + return this._viewer; + } + + getPriority() { + return 0; + } + + isActive() { + return this._active; + } + + getNames() { + return this._names; + } + + getName() { + return this._names[0]; + } + + register() { + } + + deregister() { + } + + activate(name) { + this._active = true; + } + + deactivate(name) { + this._active = false; + } + + update(highResTimestamp) { + return false; + } + + handleSingleClick(event, button) { + return false; + } + + handleDoubleClick(event, button) { + return false; + } + + handleSingleTap(event, button) { + return false; + } + + handleDoubleTap(event, button) { + return false; + } + + handleKeyDown(event, button) { + return false; + } + + handleKeyUp(event, button) { + return false; + } + + handleWheelInput(event, button) { + return false; + } + + handleButtonDown(event, button) { + return false; + } + + handleButtonUp(event, button) { + return false; + } + + handleMouseMove(event, button) { + return false; + } + + handleGesture(event, button) { + return false; + } + + handleBlur(event, button) { + return false; + } + + handleResize(event, button) { + return false; + } +} + +class AdnDataVizToolContextMenu extends Autodesk.Viewing.Extensions.ViewerObjectContextMenu { + constructor(parent) { + super(parent.viewer); + + this.parent = parent; + } + + get dataAdapter() { + return this.parent.dataAdapter; + } + + get dataVizExt() { + return this.parent.dataVizExt; + } + + buildMenu(event, status) { + if (!this.viewer.model) + return; + + const that = this; + + let menu = []; + + if (this.dataVizExt.tool.selectedDbId != 0) { + menu.push({ + title: "Remove Sensor", + target: async () => { + let selectedDbId = this.dataVizExt.tool.selectedDbId; + const deviceId = this.parent.dbIdToDeviceId[selectedDbId]; + + this.dataVizExt.clearHighlightedViewables(); + await this.dataAdapter.deleteDevice(deviceId); + + delete this.parent.deviceIdToDbId[deviceId]; + delete this.parent.dbIdToDeviceId[selectedDbId]; + + await this.parent.refresh(); + } + }); + } else { + menu = menu.concat(super.buildMenu(event, status)); + } + + return menu; + } +} + +class AdnDataVizTool extends AdnToolInterface { + constructor(parent) { + super(parent); + + this.parent = parent; + this._names = ["adn-dataviz-tool"]; + this.editMode = false; + this.startId = 100; + this.deviceIdToDbId = {}; + this.dbIdToDeviceId = {}; + + this.styleMap = {}; + + const { assetUrlPrefix } = this.parent.options; + /** + * @type {SensorStyleDefinitions} + */ + const sensorStyleDefinitions = { + co2: { + url: `${assetUrlPrefix}/images/co2.svg`, + color: 0xffffff, + }, + temperature: { + url: `${assetUrlPrefix}/images/thermometer.svg`, + color: 0xffffff, + }, + default: { + url: `${assetUrlPrefix}/images/circle.svg`, + color: 0xffffff, + }, + }; + + // Create model-to-style map from style definitions. + Object.entries(sensorStyleDefinitions).forEach(([type, styleDef]) => { + this.styleMap[type] = new Autodesk.DataVisualization.Core.ViewableStyle( + Autodesk.DataVisualization.Core.ViewableType.SPRITE, + new THREE.Color(styleDef.color), + styleDef.url + ); + }); + } + + get viewer() { + return this.parent.viewer; + } + + get dataVizExt() { + return this.viewer.getExtension("Autodesk.DataVisualization"); + } + + get dataAdapter() { + return this.parent.dataAdapter; + } + + getPriority() { + return 1; + } + + isEditMode() { + return this.editMode; + } + + enterEditMode() { + this.editMode = true; + } + + leaveEditMode() { + this.editMode = false; + } + + attachContextMenu() { + this.viewer.setContextMenu(new AdnDataVizToolContextMenu(this)); + } + + detachContextMenu() { + this.viewer.setDefaultContextMenu(); + } + + async refresh() { + const deviceModelsList = await this.dataAdapter.loadDeviceModels(); + const deviceModels = deviceModelsList.flat(Infinity); + this.deviceModels = deviceModels; + + if (!deviceModels || deviceModels.length <= 0) return; + + const devices = deviceModels.map(deviceModel => deviceModel.devices).flat(Infinity); + + if (!devices || devices.length <= 0) return; + + const dataVizExt = this.dataVizExt; + dataVizExt.removeAllViewables(); + + this.deviceIdToDbId = {}; + this.dbIdToDeviceId = {}; + + const viewableData = new Autodesk.DataVisualization.Core.ViewableData(); + viewableData.spriteSize = 16; + + // Add viewables + devices.forEach((device, index) => { + const dbId = this.startId++; + const deviceId = device.id; + this.deviceIdToDbId[deviceId] = dbId; + this.dbIdToDeviceId[dbId] = deviceId; + + const style = this.styleMap[device.type] || this.styleMap["default"]; + const viewable = new Autodesk.DataVisualization.Core.SpriteViewable( + device.position, + style, + dbId + ); + + viewableData.addViewable(viewable); + }); + + await viewableData.finish(); + dataVizExt.addViewables(viewableData); + } + + async getPropertiesAsync(dbId, model) { + return new Promise((resolve, reject) => { + model.getProperties2( + dbId, + (result) => resolve(result), + (error) => reject(error) + ); + }); + }; + + async handleSingleClick(event) { + const viewer = this.viewer; + viewer.clearSelection(); + + const viewport = viewer.container.getBoundingClientRect(); + const canvasX = event.clientX - viewport.left; + const canvasY = event.clientY - viewport.top; + + //get the selected 3D position of the object + const result = viewer.impl.hitTest(canvasX, canvasY, true); + // console.log(result); + + if (!result) return true; + + if (this.editMode) { + const hitTargetProps = await this.getPropertiesAsync(result.dbId, this.viewer.model); + const hitTargetName = hitTargetProps.name; + const deviceName = `${hitTargetName}-${guid()}`; + + const device = { + id: deviceName, + name: deviceName, + position: result.intersectPoint, + lastActivityTime: new Date().toISOString() + }; + + const deviceModel = this.deviceModels[0]; + await this.dataAdapter.addDeviceByModel(device, deviceModel); + await this.refresh(); + } + } +} + +class SensorManagerExtension extends Autodesk.Viewing.Extension { + constructor(viewer, options) { + super(viewer, options); + + this.tool = null; + + const { adapterType, baseUrl } = this.options; + this.dataAdapter = new JsonDbRestApiDataAdapter(adapterType, baseUrl); + } + + onToolbarCreated(toolbar) { + const sensorAddButton = new Autodesk.Viewing.UI.Button("toolbar-sensor-add-tool"); + sensorAddButton.onClick = () => { + const state = sensorAddButton.getState(); + + if (state === Autodesk.Viewing.UI.Button.State.INACTIVE) { + sensorAddButton.setState(Autodesk.Viewing.UI.Button.State.ACTIVE); + + this.viewer.toolController.activateTool(this.tool.getName()); + this.tool.enterEditMode(); + } else if (state === Autodesk.Viewing.UI.Button.State.ACTIVE) { + sensorAddButton.setState(Autodesk.Viewing.UI.Button.State.INACTIVE); + + this.viewer.toolController.deactivateTool(this.tool.getName()); + this.tool.leaveEditMode(); + } + }; + + sensorAddButton.icon.classList.add("fas", "fa-plus"); + sensorAddButton.setToolTip("Add new sensor points"); + + this.group = new Autodesk.Viewing.UI.ControlGroup("toolbar-dataviz-tool"); + this.group.addControl(sensorAddButton); + toolbar.addControl(this.group); + } + + async refresh() { + await this.tool.refresh(); + } + + async load() { + const loadCSS = (href) => new Promise(function (resolve, reject) { + const el = document.createElement("link"); + el.rel = "stylesheet"; + el.href = href; + el.onload = resolve; + el.onerror = reject; + document.head.appendChild(el); + }); + + await Promise.all([ + loadCSS("https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.11.2/css/all.min.css"), + this.viewer.loadExtension("Autodesk.DataVisualization") + ]); + + const tool = new AdnDataVizTool(this); + this.viewer.toolController.registerTool(tool); + // this.viewer.toolController.activateTool(tool.getName()); + this.tool = tool; + + tool.attachContextMenu(); + + console.log("SensorManagerExtension has been loaded."); + return true; + } + + async unload() { + this.tool.detachContextMenu(); + this.viewer.toolController.deactivateTool(this.tool.getName()); + this.viewer.toolController.deregisterTool(this.tool); + delete this.tool; + this.tool = null; + + console.log("SensorManagerExtension has been unloaded."); + return true; + } +} + +Autodesk.Viewing.theExtensionManager.registerExtension("SensorManagerExtension", SensorManagerExtension); \ No newline at end of file diff --git a/package.json b/package.json index 5f8f268..7d643d0 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "local-web-server": "4.2.1", "mini-css-extract-plugin": "^1.4.0", "node-loader": "^1.0.2", - "node-sass": "^5.0.0", + "node-sass": "^6.0.0", "nodemon": "2.0.7", "react": "16.13.1", "react-dom": "16.13.1", @@ -47,9 +47,15 @@ "codemirror": "^5.61.1", "echarts": "^4.9.0", "echarts-for-react": "^2.0.16", - "lodash": "^4.17.15", + "forge-dataviz-iot-data-modules": "0.1.11", + "forge-dataviz-iot-react-components": "0.1.17", + "lodash": "^4.17.21", + "lodash-id": "^0.14.1", + "lowdb": "^1.0.0", "moment": "^2.27.0", + "nanoid": "^3.3.4", "pixi.js": "^5.3.7", + "pluralize": "^8.0.0", "q": "^1.5.1", "react-codemirror2": "^7.2.1", "react-copy-to-clipboard": "^5.0.3", @@ -58,8 +64,6 @@ "react-router-dom": "5.2.0", "socket.io": "^3.0.4", "socket.io-client": "^3.0.4", - "uuid": "^8.3.2", - "forge-dataviz-iot-data-modules": "0.1.11", - "forge-dataviz-iot-react-components": "0.1.17" + "uuid": "^8.3.2" } -} \ No newline at end of file +} diff --git a/server/gateways/json-db/FileUtility.js b/server/gateways/json-db/FileUtility.js new file mode 100644 index 0000000..5b1485d --- /dev/null +++ b/server/gateways/json-db/FileUtility.js @@ -0,0 +1,43 @@ +// +// Copyright 2021 Autodesk +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +const FS = require("fs"); + +var FileUtility = {}; + +FileUtility.loadFile = async function (file) { + return new Promise((resolve, reject) => { + FS.readFile(file, { encoding: "utf8" }, (error, data) => { + if (error) { + reject(error); + } else { + resolve(data); + } + }); + }); +}; + +FileUtility.loadJSONFile = async function (file) { + let data = await FileUtility.loadFile(file); + try { + return JSON.parse(data); + } catch (e) { + console.error("Unable to parse file data. " + e); + return {}; + } +}; + +module.exports = FileUtility; \ No newline at end of file diff --git a/server/gateways/json-db/Hyperion.Server.JsonDbSyntheticGateway.js b/server/gateways/json-db/Hyperion.Server.JsonDbSyntheticGateway.js new file mode 100644 index 0000000..ae97129 --- /dev/null +++ b/server/gateways/json-db/Hyperion.Server.JsonDbSyntheticGateway.js @@ -0,0 +1,392 @@ +// +// Copyright 2021 Autodesk +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +const DataGateway = require("forge-dataviz-iot-data-modules/server/gateways/Hyperion.Server.DataGateway"); +const tweenFunctions = require("tween-functions"); +const _ = require("lodash"); +const lodashId = require("lodash-id"); +const pluralize = require("pluralize"); +const low = require("lowdb"); +const FileSync = require("lowdb/adapters/FileSync"); + +const { loadJSONFile } = require("./FileUtility.js"); +const validateData = require("./validate-data"); +const mixins = require("./mixins"); + +const START_DATE = new Date("2020-01-01"); + +function randomSign() { + return Math.random() > 0.5 ? 1 : -1; +} + +function random(v) { + return randomSign() * Math.random() * v; +} + +function weekNum(time) { + var weekNo = Math.abs(Math.ceil(((time - START_DATE) / 86400000 + 1) / 7)); + return weekNo % 2; +} + +class Synthetic { + /** + * + * @param {string} configFile File path to config file defining the data stops and range values used to generate synthetic data. For an example, refer to https://github.com/Autodesk-Forge/forge-dataviz-iot-reference-app/blob/main/server/gateways/synthetic-data/config.json + */ + constructor(configFile) { + this.configFile = configFile; + } + + nextStop(stops, index, direction) { + let nextIndex = index + 1 * direction; + + while (nextIndex < 0 && stops.length > 0) { + nextIndex += stops.length; + } + nextIndex = nextIndex % stops.length; + + let stop = stops[nextIndex].slice(0); + if ((nextIndex > index && direction < 0) || (nextIndex < index && direction > 0)) { + stop[0] += 24 * direction; + } + + return stop; + } + + _getTweenFunction(start, end) { + return start > end ? "easeInSine" : "easeOutSine"; + } + + _getStops(sensorType, time) { + let week = weekNum(time); + let day = time.getDay() % 7; + let sensorConfig = + this.config["Strategy"][sensorType] || this.config["Strategy"]["Temperature"]; + return sensorConfig[day][week]; + } + + async value(sensorType, currentTime, interval) { + if (!this.config) this.config = await loadJSONFile(this.configFile); + let hour = this._timeToDecimal(currentTime); + let self = this; + let stops = this._getStops(sensorType, currentTime); + let { min, max } = this.config["Range"][sensorType] || this.config["Range"]["Temperature"]; + + function tweenValue(hour, start, end) { + let duration = end[0] - start[0]; + let current = hour - start[0]; + + let startV = min + (max - min) * start[1]; + let endV = min + (max - min) * end[1]; + + let variant = ((endV - startV) / (duration / interval)) * random(5); + let tween = self._getTweenFunction(startV, endV); + let v = tweenFunctions[tween](current, startV, endV, duration) + variant; + return v; + } + + for (let i = 0; i < stops.length; i++) { + let c = stops[i]; + let p = this.nextStop(stops, i, -1); + let n = this.nextStop(stops, i, 1); + + if (c[0] >= hour && p[0] <= hour) { + return tweenValue(hour, p, c); + } else if (c[0] <= hour && n[0] >= hour) { + return tweenValue(hour, c, n); + } + } + } + + _timeToDecimal(time) { + return time.getHours() + time.getMinutes() / 60 + time.getSeconds() / 60 / 60; + } +} + +/** + * @classdesc A data gateway that supplies synthetic data provided json-server + * @class + * @augments DataGateway + * @memberof Autodesk.DataVisualization.Data + * @alias Autodesk.DataVisualization.Data.JsonDbSyntheticGateway + */ +class JsonDbSyntheticGateway extends DataGateway { + constructor(jsonDbFile, configFile) { + super("JsonDbSyntheticGateway"); + + this.configFile = configFile; + this.options = { foreignKeySuffix: "Id", _isFake: false }; + + let db = low(new FileSync(jsonDbFile)); + + validateData(db.getState()) + + // Add lodash-id methods to db + db._.mixin(lodashId); + + // Add specific mixins + db._.mixin(mixins); + + // Expose database + this.db = db; + } + + // Embed function + embed(name, resource, e) { + e && + [].concat(e).forEach((externalResource) => { + if (this.db.get(externalResource).value) { + const query = {}; + const singularResource = pluralize.singular(name); + query[`${singularResource}${this.options.foreignKeySuffix}`] = resource.id; + resource[externalResource] = this.db + .get(externalResource) + .filter(query) + .value(); + } + }) + } + + // Expand function used + expand(name, resource, e) { + e && + [].concat(e).forEach((innerResource) => { + const plural = pluralize(innerResource); + if (this.db.get(plural).value()) { + const prop = `${innerResource}${this.options.foreignKeySuffix}`; + resource[innerResource] = this.db + .get(plural) + .getById(resource[prop]) + .value(); + } + }) + } + + async getDeviceModels(/*embed = false*/) { + // Resource chain + let chain = this.db.get("deviceModels"); + // embed and expand + chain = chain.cloneDeep().forEach((element) => { + this.embed("deviceModels", element, "properties"); + + // if (embed) { + // this.embed("deviceModels", element, "devices"); + // } + }); + + return chain.value().map((element) => { + return { + deviceModelId: element.code, + deviceModelName: element.name, + deviceModelDesc: element.description, + deviceProperties: element.properties.map(property => { + return { + propertyId: property.code, + propertyName: property.name, + propertyDesc: property.description, + propertyType: property.type, + propertyUnit: property.unit, + rangeMin: property.rangeMin, + rangeMax: property.rangeMax + }; + }) + }; + }); + } + + async getDevicesInModel(deviceModelId) { + // Resource chain + let chain = this.db.get("devices"); + + // embed and expand + chain = chain.cloneDeep().forEach((element) => { + this.expand("devices", element, "deviceModel"); + }); + + chain = chain.filter((element) => { + return element.deviceModel.code = deviceModelId; + }); + + return { + deviceModelId: deviceModelId, + deviceInfo: chain.value().map((element) => { + return { + id: element.code, + name: element.name, + position: Object.assign({}, element.position), + lastActivityTime: element.lastActivityTime + }; + }) + }; + } + + async getAggregates(deviceId, propertyId, startSecond, endSecond, resolution) { + deviceId; // Not used for synthetic data generation. + propertyId; // Not used for synthetic data generation. + + let synthetic = new Synthetic(this.configFile); + + // Just sample data, no need to validate existence of device/property IDs. + const totalSeconds = Math.abs(endSecond - startSecond); + + let dataPoints = 0; + if (resolution === "1d" || resolution == "PT1D") { + dataPoints = 1 + Math.floor(totalSeconds / (60 * 60 * 24)); + } else if (resolution === "1h" || resolution == "PT1H") { + dataPoints = 1 + Math.floor(totalSeconds / (60 * 60)); + } else if (resolution === "1m") { + dataPoints = 1 + Math.floor(totalSeconds / 60); + } else { + dataPoints = 1 + Math.floor(totalSeconds / 60 / 5); + } + + // Keep to a reasonable data points to return to client. + dataPoints = dataPoints > 1 ? dataPoints : 2; + const maxDataPoints = dataPoints < 100 ? dataPoints : 100; + + const gapSeconds = Math.floor(totalSeconds / (maxDataPoints - 1)); + + const timestamps = []; + const countValues = []; + const minValues = []; + const maxValues = []; + const avgValues = []; + const sumValues = []; + const stdDevValues = []; + + let currSecond = startSecond; + for (let i = 0; i < maxDataPoints; ++i, currSecond += gapSeconds) { + timestamps.push(currSecond); + + // Generate a series of random data points. + let values = []; + let step = gapSeconds / 32; + let intervalToHour = gapSeconds / 60 / 60; + for (let i = 0; i < 32; i++) { + let time = new Date(Math.round((currSecond + step * i) * 1000)); + let v = await synthetic.value(propertyId, time, intervalToHour); + values.push(v); + } + + // const values = [...new Array(32)].map((_) => Math.random() * gap + min); + + countValues.push(values.length); + + minValues.push(Math.min(...values)); + maxValues.push(Math.max(...values)); + + const sum = values.reduce((p, c) => p + c); + const avg = sum / values.length; + sumValues.push(sum); + avgValues.push(avg); + + const sd = values.reduce((p, c) => p + Math.pow(c - avg, 2)); + stdDevValues.push(Math.sqrt(sd / values.length)); + } + + return { + timestamps: timestamps, + count: countValues, + min: minValues.map((v) => parseFloat(v.toFixed(2))), + max: maxValues.map((v) => parseFloat(v.toFixed(2))), + avg: avgValues.map((v) => parseFloat(v.toFixed(2))), + sum: sumValues.map((v) => parseFloat(v.toFixed(2))), + stdDev: stdDevValues.map((v) => parseFloat(v.toFixed(2))), + }; + } + + async addDevice(device) { + let resource; + + let deviceModel = this.db.get('deviceModels').find({ code: device.deviceModelId }).value(); + + if (!deviceModel) + throw `Device Model with given id \`${device.deviceModelId}\` not found`; + + const data = { + code: device.id, + name: device.name, + position: Object.assign({}, device.position), + lastActivityTime: device.lastActivityTime, + deviceModelId: deviceModel.id + }; + + if (this.options._isFake) { + const id = this.db.get('devices').createId().value(); + resource = { ...data, id }; + } else { + resource = this.db.get('devices').insert(data).value(); + } + + this.db.write(); + + return resource; + } + + async updateDevice(code, data) { + let resource; + + delete data.id; + delete data.deviceModelId; + + if (this.options._isFake) { + resource = this.db.get('devices').find({ code }).value(); + resource = { ...resource, ...data }; + } else { + let resourceInDb = this.db.get('devices').find({ code }).value(); + + if (!resourceInDb) + throw `Device with given id \`${code}\` not found`; + + let chain = this.db.get('devices'); + chain = chain.updateById(resourceInDb.id, data); + resource = chain.value(); + } + + this.db.write(); + + return resource; + } + + async removeDevice(code) { + let resource; + + if (this.options._isFake) { + resource = this.db.get('devices').value(); + } else { + let resourceInDb = this.db.get('devices').findLast({ code }).value(); + + if (!resourceInDb) + throw `Device with given id \`${code}\` not found`; + + resource = this.db.get('devices').removeById(resourceInDb.id).value(); + + // Remove dependents documents + const removable = this.db._.getRemovable(this.db.getState(), this.options); + removable.forEach((item) => { + this.db.get(item.name).removeById(item.id).value(); + }); + } + + this.db.write(); + + return resource; + } +} + +module.exports = { + JsonDbSyntheticGateway +}; \ No newline at end of file diff --git a/server/gateways/json-db/db.json b/server/gateways/json-db/db.json new file mode 100644 index 0000000..25570e6 --- /dev/null +++ b/server/gateways/json-db/db.json @@ -0,0 +1,407 @@ +{ + "deviceModels": [ + { + "id": 1, + "code": "d370a293-4bd5-4bdb-a3df-376dc131d44c", + "name": "Human Comfort Sensor", + "description": "Monitors indoor air quality by measuring levels of Carbon Dioxide (CO2), temperature, and humidity." + } + ], + "properties": [ + { + "id": 1, + "code": "Temperature", + "name": "Temperature", + "description": "External temperature in Fahrenheit", + "type": "double", + "unit": "Celsius", + "rangeMin": "18.00", + "rangeMax": "28.00", + "deviceModelId": 1 + }, + { + "id": 2, + "code": "Humidity", + "name": "Humidity", + "description": "Relative humidity in percentage", + "type": "double", + "unit": "%RH", + "rangeMin": "23.09", + "rangeMax": "49.09", + "deviceModelId": 1 + }, + { + "id": 3, + "code": "CO₂", + "name": "CO₂", + "description": "Level of carbon dioxide (CO₂)", + "type": "double", + "unit": "ppm", + "rangeMin": "482.81", + "rangeMax": "640.00", + "deviceModelId": 1 + } + ], + "devices": [ + { + "id": 1, + "code": "Hyperion-24", + "name": "Instruction 313", + "position": { + "x": "-157", + "y": " -68", + "z": " 15" + }, + "lastActivityTime": "2020-10-15T02:43:14.8786418Z", + "deviceModelId": 1 + }, + { + "id": 2, + "code": "Hyperion-12", + "name": "Lobby 216 South", + "position": { + "x": "-130", + "y": " -9", + "z": " 2.5" + }, + "lastActivityTime": "2020-10-15T02:43:10.4780964Z", + "deviceModelId": 1 + }, + { + "id": 2, + "code": "Hyperion-5", + "name": "Instruction 108", + "position": { + "x": "-84", + "y": " -62", + "z": " -9.6" + }, + "lastActivityTime": "2020-10-15T02:43:07.8094150Z", + "deviceModelId": 1 + }, + { + "id": 3, + "code": "Hyperion-9", + "name": "Cafeteria 121 East", + "position": { + "x": "-130", + "y": " 92", + "z": " -9.6" + }, + "lastActivityTime": "2020-10-15T02:43:09.1226169Z", + "deviceModelId": 1 + }, + { + "id": 4, + "code": "Hyperion-6", + "name": "Lobby 102 North", + "position": { + "x": "-130", + "y": " 33", + "z": " -9.6" + }, + "lastActivityTime": "2020-10-15T02:43:08.1371392Z", + "deviceModelId": 1 + }, + { + "id": 5, + "code": "Hyperion-14", + "name": "Lounge 223", + "position": { + "x": "-156", + "y": " 84", + "z": " 2.5" + }, + "lastActivityTime": "2020-10-15T02:43:11.4059920Z", + "deviceModelId": 1 + }, + { + "id": 6, + "code": "Hyperion-19", + "name": "Corridor 234", + "position": { + "x": "-110", + "y": " 68", + "z": " 2.5" + }, + "lastActivityTime": "2020-10-15T02:43:13.2109273Z", + "deviceModelId": 1 + }, + { + "id": 7, + "code": "Hyperion-20", + "name": "Instruction 204", + "position": { + "x": "0", + "y": " -62", + "z": " 2.5" + }, + "lastActivityTime": "2020-10-15T02:43:13.5130686Z", + "deviceModelId": 1 + }, + { + "id": 8, + "code": "Hyperion-23", + "name": "Open Office 321", + "position": { + "x": "-156", + "y": " 83", + "z": " 15" + }, + "lastActivityTime": "2020-10-15T02:43:14.5351640Z", + "deviceModelId": 1 + }, + { + "id": 9, + "code": "Hyperion-8", + "name": "Cafeteria 121 West", + "position": { + "x": "-162", + "y": " 86", + "z": " -9.6" + }, + "lastActivityTime": "2020-10-15T02:43:08.7929662Z", + "deviceModelId": 1 + }, + { + "id": 10, + "code": "Hyperion-1", + "name": "Conference 103", + "position": { + "x": "26", + "y": " -63", + "z": " -9.6" + }, + "lastActivityTime": "2020-10-15T02:43:06.4432973Z", + "deviceModelId": 1 + }, + { + "id": 11, + "code": "Hyperion-10", + "name": "Conference 123", + "position": { + "x": "-90", + "y": " 88", + "z": " -9.6" + }, + "lastActivityTime": "2020-10-15T02:43:09.4820947Z", + "deviceModelId": 1 + }, + { + "id": 12, + "code": "Hyperion-15", + "name": "Computer Lab 209", + "position": { + "x": "-83", + "y": " -63", + "z": " 2.5" + }, + "lastActivityTime": "2020-10-15T02:43:11.7762512Z", + "deviceModelId": 1 + }, + { + "id": 13, + "code": "Hyperion-11", + "name": "Lobby 216 North", + "position": { + "x": "-130", + "y": " 33", + "z": " 2.5" + }, + "lastActivityTime": "2020-10-15T02:43:10.1324428Z", + "deviceModelId": 1 + }, + { + "id": 14, + "code": "Hyperion-7", + "name": "Lobby 102 South", + "position": { + "x": "-130", + "y": " -9", + "z": " -9.6" + }, + "lastActivityTime": "2020-10-15T02:43:08.4611324Z", + "deviceModelId": 1 + }, + { + "id": 15, + "code": "Hyperion-13", + "name": "Instruction 218", + "position": { + "x": "-156", + "y": " -58", + "z": " 2.5" + }, + "lastActivityTime": "2020-10-15T02:43:10.8555447Z", + "deviceModelId": 1 + }, + { + "id": 16, + "code": "Hyperion-25", + "name": "Instruction 314", + "position": { + "x": "-156", + "y": " -27", + "z": " 15" + }, + "lastActivityTime": "2020-10-15T02:43:15.1934965Z", + "deviceModelId": 1 + }, + { + "id": 17, + "code": "Hyperion-17", + "name": "Computer Lab 222", + "position": { + "x": "-158", + "y": " 46", + "z": " 2.5" + }, + "lastActivityTime": "2020-10-15T02:43:12.4906398Z", + "deviceModelId": 1 + }, + { + "id": 18, + "code": "Hyperion-27", + "name": "Instruction 302", + "position": { + "x": "20", + "y": " -62", + "z": " 15" + }, + "lastActivityTime": "2020-10-15T02:43:15.8973538Z", + "deviceModelId": 1 + }, + { + "id": 19, + "code": "Hyperion-22", + "name": "Lobby 318 South", + "position": { + "x": "-130", + "y": " -9", + "z": " 15" + }, + "lastActivityTime": "2020-10-15T02:43:14.2078317Z", + "deviceModelId": 1 + }, + { + "id": 20, + "code": "Hyperion-4", + "name": "Instruction 106", + "position": { + "x": "-54", + "y": " -63", + "z": " -9.6" + }, + "lastActivityTime": "2020-10-15T02:43:07.4392732Z", + "deviceModelId": 1 + }, + { + "id": 21, + "code": "Hyperion-28", + "name": "Conference 325", + "position": { + "x": "-73", + "y": " 88", + "z": " 15" + }, + "lastActivityTime": "2020-10-15T02:43:16.2335228Z", + "deviceModelId": 1 + }, + { + "id": 22, + "code": "Hyperion-18", + "name": "Corridor 225", + "position": { + "x": "-65", + "y": " -48", + "z": " 2.5" + }, + "lastActivityTime": "2020-10-15T02:43:12.8303034Z", + "deviceModelId": 1 + }, + { + "id": 23, + "code": "Hyperion-3", + "name": "Instruction 105", + "position": { + "x": "-25", + "y": " -63", + "z": " -9.6" + }, + "lastActivityTime": "2020-10-15T02:43:07.0922422Z", + "deviceModelId": 1 + }, + { + "id": 24, + "code": "Hyperion-30", + "name": "Instruction 304", + "position": { + "x": "-31", + "y": " -62", + "z": " 15" + }, + "lastActivityTime": "2020-10-15T02:43:16.9245294Z", + "deviceModelId": 1 + }, + { + "id": 25, + "code": "Hyperion-16", + "name": "Drafting 208", + "position": { + "x": "-57.5", + "y": " -62.5", + "z": " 2.5" + }, + "lastActivityTime": "2020-10-15T02:43:12.1570772Z", + "deviceModelId": 1 + }, + { + "id": 26, + "code": "Hyperion-29", + "name": "Media Review 319", + "position": { + "x": "-156", + "y": " 27", + "z": " 15" + }, + "lastActivityTime": "2020-10-15T02:43:16.5871795Z", + "deviceModelId": 1 + }, + { + "id": 27, + "code": "Hyperion-21", + "name": "Lobby 318 North", + "position": { + "x": "-130", + "y": " 33", + "z": " 15" + }, + "lastActivityTime": "2020-10-15T02:43:13.8539961Z", + "deviceModelId": 1 + }, + { + "id": 28, + "code": "Hyperion-26", + "name": "Instruction 306", + "position": { + "x": "-83", + "y": " -62", + "z": " 15" + }, + "lastActivityTime": "2020-10-15T02:43:15.5333298Z", + "deviceModelId": 1 + }, + { + "id": 29, + "code": "Hyperion-2", + "name": "Instruction 115", + "position": { + "x": "-158", + "y": " -56", + "z": " -9.6" + }, + "lastActivityTime": "2020-10-15T02:43:06.7621884Z", + "deviceModelId": 1 + } + ] +} \ No newline at end of file diff --git a/server/gateways/json-db/mixins.js b/server/gateways/json-db/mixins.js new file mode 100644 index 0000000..df8c648 --- /dev/null +++ b/server/gateways/json-db/mixins.js @@ -0,0 +1,98 @@ +// The MIT License (MIT) + +// Copyright (c) 2015 typicode + +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// +// json-server https://github.com/typicode/json-server +// + +const { nanoid } = require('nanoid'); +const pluralize = require('pluralize'); + +module.exports = { + getRemovable, + createId, + deepQuery, +} + +// Returns document ids that have unsatisfied relations +// Example: a comment that references a post that doesn't exist +function getRemovable(db, opts) { + const _ = this + const removable = [] + _.each(db, (coll, collName) => { + _.each(coll, (doc) => { + _.each(doc, (value, key) => { + if (new RegExp(`${opts.foreignKeySuffix}$`).test(key)) { + // Remove foreign key suffix and pluralize it + // Example postId -> posts + const refName = pluralize.plural( + key.replace(new RegExp(`${opts.foreignKeySuffix}$`), '') + ) + // Test if table exists + if (db[refName]) { + // Test if references is defined in table + const ref = _.getById(db[refName], value) + if (_.isUndefined(ref)) { + removable.push({ name: collName, id: doc.id }) + } + } + } + }) + }) + }) + + return removable +} + +// Return incremented id or uuid +// Used to override lodash-id's createId with utils.createId +function createId(coll) { + const _ = this + const idProperty = _.__id() + if (_.isEmpty(coll)) { + return 1 + } else { + let id = _(coll).maxBy(idProperty)[idProperty] + + // Increment integer id or generate string id + return _.isFinite(id) ? ++id : nanoid(7) + } +} + +function deepQuery(value, q) { + const _ = this + if (value && q) { + if (_.isArray(value)) { + for (let i = 0; i < value.length; i++) { + if (_.deepQuery(value[i], q)) { + return true + } + } + } else if (_.isObject(value) && !_.isArray(value)) { + for (const k in value) { + if (_.deepQuery(value[k], q)) { + return true + } + } + } else if (value.toString().toLowerCase().indexOf(q) !== -1) { + return true + } + } +} \ No newline at end of file diff --git a/server/gateways/json-db/validate-data.js b/server/gateways/json-db/validate-data.js new file mode 100644 index 0000000..c7e2e3d --- /dev/null +++ b/server/gateways/json-db/validate-data.js @@ -0,0 +1,48 @@ +// The MIT License (MIT) + +// Copyright (c) 2015 typicode + +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// +// json-server https://github.com/typicode/json-server +// + +const _ = require('lodash'); + +function validateKey(key) { + if (key.indexOf('/') !== -1) { + const msg = [ + `Oops, found / character in database property '${key}'.`, + '', + "/ aren't supported, if you want to tweak default routes, see", + 'https://github.com/typicode/json-server/#add-custom-routes', + ].join('\n') + throw new Error(msg) + } +} + +module.exports = (obj) => { + if (_.isPlainObject(obj)) { + Object.keys(obj).forEach(validateKey) + } else { + throw new Error( + `Data must be an object. Found ${typeof obj}.` + + 'See https://github.com/typicode/json-server for example.' + ) + } +} \ No newline at end of file diff --git a/server/router/DataAPI.js b/server/router/DataAPI.js index a41fce8..43a5041 100644 --- a/server/router/DataAPI.js +++ b/server/router/DataAPI.js @@ -1,6 +1,7 @@ const { CsvDataGateway } = require("forge-dataviz-iot-data-modules/server"); const { SyntheticGateway } = require("forge-dataviz-iot-data-modules/server"); const { AzureGateway } = require("forge-dataviz-iot-data-modules/server"); +const { JsonDbSyntheticGateway} = require("../gateways/json-db/Hyperion.Server.JsonDbSyntheticGateway"); module.exports = function (router) { function gatewayFactory(req, res, next) { @@ -46,6 +47,12 @@ module.exports = function (router) { ); break; } + case "json": + const jsonDbFile = process.env.JSON_DB || `${__dirname}/../gateways/json-db/db.json`; + const configFile = process.env.SYNTHETIC_CONFIG || syntheticConfig; + + req.dataGateway = new JsonDbSyntheticGateway(jsonDbFile, configFile); + break; default: { deviceModelFile = process.env.DEVICE_MODEL_JSON || syntheticModels; deviceFile = process.env.DEVICE_JSON || syntheticDevices; @@ -106,6 +113,56 @@ module.exports = function (router) { }); }); + router.post("/api/devices", gatewayFactory, setCORS, function (req, res) { + /** @type {DataGateway} */ + const dataGateway = req.dataGateway; + + dataGateway + .addDevice(req.body) + .then((device) => { + setCacheHeader(res, 60 * 60 * 4); + res.status(201).json(device); + }) + .catch((error) => { + console.error(error); + res.status(500).send(error); + }); + }); + + router.patch("/api/devices/:id", gatewayFactory, setCORS, function (req, res) { + /** @type {DataGateway} */ + const dataGateway = req.dataGateway; + const code = req.params.id; + + dataGateway + .updateDevice(code, req.body) + .then((device) => { + setCacheHeader(res, 60 * 60 * 4); + res.status(200).json(device); + }) + .catch((error) => { + console.error(error); + res.status(500).send(error); + }); + }); + + router.delete("/api/devices/:id", gatewayFactory, setCORS, function (req, res) { + /** @type {DataGateway} */ + const dataGateway = req.dataGateway; + const code = req.params.id; + + dataGateway + .removeDevice(code) + .then((device) => { + setCacheHeader(res, 60 * 60 * 4); + res.status(200).json(device); + }) + .catch((error) => { + console.error(error); + res.status(500).send(error); + }); + }); + /** * Temporary handler to return aggregated data for a given device, its property, * and time window. This handler has obviously omitted a ton of validation as it