From b6a3e7121568a3d31b112fdc0a3a81edf2e95ff4 Mon Sep 17 00:00:00 2001 From: Vladyslav Date: Mon, 9 Sep 2019 12:00:32 +0300 Subject: [PATCH] Feature/input widget webcamera (#1990) * create widget and work to video * style widget stream photo and create preview photo * Add translate, save and style from widget --- .../system/widget_bundles/input_widgets.json | 16 ++ ui/src/app/api/widget.service.js | 4 +- ui/src/app/locale/locale.constant-en_US.json | 5 + .../app/widget/lib/web-camera-input-widget.js | 196 ++++++++++++++++++ .../widget/lib/web-camera-input-widget.scss | 67 ++++++ .../lib/web-camera-input-widget.tpl.html | 73 +++++++ 6 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 ui/src/app/widget/lib/web-camera-input-widget.js create mode 100644 ui/src/app/widget/lib/web-camera-input-widget.scss create mode 100644 ui/src/app/widget/lib/web-camera-input-widget.tpl.html diff --git a/application/src/main/data/json/system/widget_bundles/input_widgets.json b/application/src/main/data/json/system/widget_bundles/input_widgets.json index 66484001d1..5129ea19a7 100644 --- a/application/src/main/data/json/system/widget_bundles/input_widgets.json +++ b/application/src/main/data/json/system/widget_bundles/input_widgets.json @@ -276,6 +276,22 @@ "dataKeySettingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"DataKeySettings\",\n \"properties\": {\n \"readOnly\": {\n \"title\": \"Value is read only\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"inputTypeNumber\": {\n \"title\": \"Datakey is a number\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"step\": {\n \"title\": \"Step interval between valid values (only for numbers)\",\n \"type\": \"number\",\n \"default\": \"1\"\n },\n \"icon\": {\n \"title\": \"Icon to show before input cell\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"useCellStyleFunction\": {\n \"title\": \"Use cell style function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"cellStyleFunction\": {\n \"title\": \"Cell style function: f(value)\",\n \"type\": \"string\",\n \"default\": \"\"\n }\n },\n \"required\": []\n },\n \"form\": [\n \"readOnly\",\n \"inputTypeNumber\",\n \"step\",\n\t\t{\n \t\t\"key\": \"icon\",\n\t\t\t\"type\": \"icon\"\n\t\t},\n \"useCellStyleFunction\",\n {\n \"key\": \"cellStyleFunction\",\n \"type\": \"javascript\"\n }\n ]\n}\n", "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Sin\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.23592248334107624,\"funcBody\":\"return Math.round(1000*Math.sin(time/5000));\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Update Multiple Attributes\",\"dropShadow\":true,\"enableFullscreen\":false,\"enableDataExport\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" } + }, + { + "alias": "web_camera_input", + "name": "Web Camera Input", + "descriptor": { + "type": "latest", + "sizeX": 9.5, + "sizeY": 6.5, + "resources": [], + "templateHtml": "\n", + "templateCss": "#container {\n overflow: auto;\n}\n\n.tbDatasource-container {\n margin: 5px;\n padding: 8px;\n}\n\n.tbDatasource-title {\n font-size: 1.200rem;\n font-weight: 500;\n padding-bottom: 10px;\n}\n\n.tbDatasource-table {\n width: 100%;\n box-shadow: 0 0 10px #ccc;\n border-collapse: collapse;\n white-space: nowrap;\n font-size: 1.000rem;\n color: #757575;\n}\n\n.tbDatasource-table td {\n position: relative;\n border-top: 1px solid rgba(0, 0, 0, 0.12);\n border-bottom: 1px solid rgba(0, 0, 0, 0.12);\n padding: 0px 18px;\n box-sizing: border-box;\n}", + "controllerScript": "self.onInit = function() {\n var scope = self.ctx.$scope;\n scope.ctx = self.ctx;\n}\n\nself.onDataUpdated = function() {\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1\n }\n}\n\nself.onDestroy = function() {\n}\n", + "settingsSchema": "{}", + "dataKeySettingsSchema": "{}\n", + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Web Camera Input\",\"showTitleIcon\":false,\"titleIcon\":\"more_horiz\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"displayTimewindow\":true,\"showLegend\":false,\"actions\":{}}" + } } ] } diff --git a/ui/src/app/api/widget.service.js b/ui/src/app/api/widget.service.js index ea59130484..8cb5f884b3 100644 --- a/ui/src/app/api/widget.service.js +++ b/ui/src/app/api/widget.service.js @@ -25,6 +25,7 @@ import thingsboardEntitiesHierarchyWidget from '../widget/lib/entities-hierarchy import thingsboardExtensionsTableWidget from '../widget/lib/extensions-table-widget'; import thingsboardDateRangeNavigatorWidget from '../widget/lib/date-range-navigator/date-range-navigator'; import thingsboardMultipleInputWidget from '../widget/lib/multiple-input-widget'; +import thingsboardWebCameraInputWidget from '../widget/lib/web-camera-input-widget'; import thingsboardRpcWidgets from '../widget/lib/rpc'; @@ -50,7 +51,8 @@ import thingsboardUtils from '../common/utils.service'; export default angular.module('thingsboard.api.widget', ['oc.lazyLoad', thingsboardLedLight, thingsboardTimeseriesTableWidget, thingsboardAlarmsTableWidget, thingsboardEntitiesTableWidget, thingsboardEntitiesHierarchyWidget, thingsboardExtensionsTableWidget, thingsboardDateRangeNavigatorWidget, - thingsboardMultipleInputWidget, thingsboardRpcWidgets, thingsboardTypes, thingsboardUtils, TripAnimationWidget]) + thingsboardMultipleInputWidget, thingsboardWebCameraInputWidget, thingsboardRpcWidgets, thingsboardTypes, + thingsboardUtils, TripAnimationWidget]) .factory('widgetService', WidgetService) .name; diff --git a/ui/src/app/locale/locale.constant-en_US.json b/ui/src/app/locale/locale.constant-en_US.json index 54a7ad38c1..0292b94de9 100644 --- a/ui/src/app/locale/locale.constant-en_US.json +++ b/ui/src/app/locale/locale.constant-en_US.json @@ -1693,10 +1693,15 @@ "entity-timeseries-required": "Entity timeseries is required", "not-allowed-entity": "Selected entity cannot have shared attributes", "no-attribute-selected": "No attribute is selected", + "no-datakey-selected": "No datakey is selected", "no-entity-selected": "No entity selected", + "no-image": "No image", + "no-support-web-camera": "No supported web camera", "no-timeseries-selected": "No timeseries selected", "switch-attribute-value": "Switch entity attribute value", + "switch-camera": "Switch camera", "switch-timeseries-value": "Switch entity timeseries value", + "take-photo": "Take photo", "time": "Time", "timeseries-not-allowed": "Timeseries parameter cannot be used in this widget", "update-failed": "Update failed", diff --git a/ui/src/app/widget/lib/web-camera-input-widget.js b/ui/src/app/widget/lib/web-camera-input-widget.js new file mode 100644 index 0000000000..344d648c40 --- /dev/null +++ b/ui/src/app/widget/lib/web-camera-input-widget.js @@ -0,0 +1,196 @@ +/* + * Copyright © 2016-2019 The Thingsboard Authors + * + * 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. + */ +import './web-camera-input-widget.scss'; + +/* eslint-disable import/no-unresolved, import/default */ +import webCameraWidgetTemplate from './web-camera-input-widget.tpl.html'; +/* eslint-enable import/no-unresolved, import/default */ + +export default angular.module('thingsboard.widgets.webCameraWidget', []) + .directive('tbWebCameraWidget', webCameraWidget) + .name; + +/*@ngInject*/ +function webCameraWidget() { + return { + restrict: "E", + scope: true, + bindToController: { + ctx: '=' + }, + controller: WebCameraWidgetController, + controllerAs: 'vm', + templateUrl: webCameraWidgetTemplate + }; +} + +function WebCameraWidgetController($element, $scope, $window, types, utils, attributeService, Fullscreen) { + let vm = this; + + vm.videoInput = []; + vm.videoDevice = ""; + vm.previewPhoto = ""; + vm.isShowCamera = false; + vm.isPreviewPhoto = false; + + let streamDevice = null; + let indexWebCamera = 0; + let videoElement = null; + let canvas = null; + let photoCamera = null; + let dataKeyType = ""; + + vm.getStream = getStream; + vm.createPhoto = createPhoto; + vm.takePhoto = takePhoto; + vm.switchWebCamera = switchWebCamera; + vm.cancelPhoto = cancelPhoto; + vm.closeCamera = closeCamera; + vm.savePhoto = savePhoto; + + vm.isEntityDetected = false; + vm.dataKeyDetected = false; + vm.isCameraSupport = false; + vm.isDeviceDetect = false; + + $scope.$watch('vm.ctx', function () { + if (vm.ctx && vm.ctx.datasources && vm.ctx.datasources.length) { + let datasource = vm.ctx.datasources[0]; + if (datasource.type === types.datasourceType.entity) { + if (datasource.entityType && datasource.entityId) { + if (vm.ctx.settings.widgetTitle && vm.ctx.settings.widgetTitle.length) { + $scope.titleTemplate = utils.customTranslation(vm.ctx.settings.widgetTitle, vm.ctx.settings.widgetTitle); + } else { + $scope.titleTemplate = vm.ctx.widgetConfig.title; + } + vm.isEntityDetected = true; + } + } + if (datasource.dataKeys.length) { + $scope.currentKey = datasource.dataKeys[0].name; + dataKeyType = datasource.dataKeys[0].type; + vm.dataKeyDetected = true; + } + if (hasGetUserMedia()) { + vm.isCameraSupport = true; + getDevices().then(gotDevices).then(() => { + vm.isDeviceDetect = !!vm.videoInput.length; + }); + } + } + }); + + function hasGetUserMedia() { + return !!($window.navigator.mediaDevices && $window.navigator.mediaDevices.getUserMedia); + } + + function takePhoto(){ + vm.isShowCamera = true; + videoElement = $element[0].querySelector('#videoStream'); + photoCamera = $element[0].querySelector('#photoCamera'); + canvas = $element[0].querySelector('canvas'); + Fullscreen.enable(photoCamera); + getStream(); + } + + function cancelPhoto() { + vm.isPreviewPhoto = false; + vm.previewPhoto = ""; + } + + function switchWebCamera() { + indexWebCamera = (indexWebCamera+1)%vm.videoInput.length; + vm.videoDevice = vm.videoInput[indexWebCamera].deviceId; + getStream(); + } + + function getDevices() { + return $window.navigator.mediaDevices.enumerateDevices(); + } + + function gotDevices(deviceInfos) { + for (const deviceInfo of deviceInfos) { + let device = { + deviceId: deviceInfo.deviceId, + label: "" + }; + if (deviceInfo.kind === 'videoinput') { + device.label = deviceInfo.label || `Camera ${vm.videoInput.length + 1}`; + vm.videoInput.push(device); + } + } + } + + function getStream() { + if (streamDevice !== null) { + streamDevice.getTracks().forEach(track => { + track.stop(); + }); + } + const constraints = { + video: {deviceId: vm.videoDevice !== "" ? {exact: vm.videoDevice} : undefined} + }; + return $window.navigator.mediaDevices.getUserMedia(constraints).then(gotStream); + } + + function gotStream(stream) { + streamDevice = stream; + if(vm.videoDevice === ""){ + indexWebCamera = vm.videoInput.findIndex(option => option.label === stream.getVideoTracks()[0].label); + indexWebCamera = indexWebCamera === -1 ? 0 : indexWebCamera; + vm.videoDevice = vm.videoInput[indexWebCamera].deviceId; + } + videoElement.srcObject = stream; + } + + function createPhoto() { + canvas.width = videoElement.videoWidth; + canvas.height = videoElement.videoHeight; + canvas.getContext('2d').drawImage(videoElement, 0, 0); + vm.previewPhoto = canvas.toDataURL('image/png'); + vm.isPreviewPhoto = true; + } + + function closeCamera(){ + Fullscreen.cancel(photoCamera); + vm.isShowCamera = false; + if (streamDevice !== null) { + streamDevice.getTracks().forEach(track => { + track.stop(); + }); + } + streamDevice = null; + videoElement.srcObject = null; + } + + function savePhoto(){ + let promiseData = null; + let datasource = vm.ctx.datasources[0]; + let saveData = [{ + key: datasource.dataKeys[0].name, + value: vm.previewPhoto + }]; + if(dataKeyType === types.dataKeyType.attribute){ + promiseData = attributeService.saveEntityAttributes(datasource.entityType, datasource.entityId, types.attributesScope.server.value, saveData); + } else if(dataKeyType === types.dataKeyType.timeseries){ + promiseData = attributeService.saveEntityTimeseries(datasource.entityType, datasource.entityId, "scope", saveData); + } + promiseData.then(()=>{ + vm.isPreviewPhoto = false; + closeCamera(); + }) + } +} diff --git a/ui/src/app/widget/lib/web-camera-input-widget.scss b/ui/src/app/widget/lib/web-camera-input-widget.scss new file mode 100644 index 0000000000..3ef958cff3 --- /dev/null +++ b/ui/src/app/widget/lib/web-camera-input-widget.scss @@ -0,0 +1,67 @@ +/** + * Copyright © 2016-2019 The Thingsboard Authors + * + * 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. + */ + +.tb-web-camera { + height: 100%; + + &__last-photo{ + width: 100%; + margin: 5px 0; + text-align: center; + border: solid 1px; + + &_text{ + position: absolute; + top: 50%; + left: 50%; + margin-top: -.625em; + transform: translate(-50%, -50%); + } + + &_img{ + width: 100%; + height: 100%; + object-fit: contain; + } + } + + .camera { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + + .camera-stream{ + display: block; + width: 100%; + height: 100%; + object-fit: contain; + } + + .camera-controls { + position: absolute; + bottom: 0; + width: 100%; + padding: 0 5px 5px; + } + } + + .message-text{ + font-size: 18px; + color: #a0a0a0; + text-align: center; + } +} diff --git a/ui/src/app/widget/lib/web-camera-input-widget.tpl.html b/ui/src/app/widget/lib/web-camera-input-widget.tpl.html new file mode 100644 index 0000000000..0fc8e16da2 --- /dev/null +++ b/ui/src/app/widget/lib/web-camera-input-widget.tpl.html @@ -0,0 +1,73 @@ + +
+
+
+
+ widgets.input-widgets.no-image + +
+ {{ "widgets.input-widgets.take-photo" | translate }} +
+
+
+ +
+
+ + switch_camera + + + photo_camera + + + close + +
+
+
+
+ + +
+
+ + close + + + check + +
+
+
+
+
+
+ {{ 'widgets.input-widgets.no-entity-selected' | translate }} +
+
+ {{ 'widgets.input-widgets.no-datakey-selected' | translate }} +
+
+ {{ 'widgets.input-widgets.no-support-web-camera' | translate }} +
+
+ {{ 'widgets.input-widgets.no-support-web-camera' | translate }} +
+