Browse Source

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
pull/1992/head
Vladyslav 7 years ago
committed by Igor Kulikov
parent
commit
b6a3e71215
  1. 16
      application/src/main/data/json/system/widget_bundles/input_widgets.json
  2. 4
      ui/src/app/api/widget.service.js
  3. 5
      ui/src/app/locale/locale.constant-en_US.json
  4. 196
      ui/src/app/widget/lib/web-camera-input-widget.js
  5. 67
      ui/src/app/widget/lib/web-camera-input-widget.scss
  6. 73
      ui/src/app/widget/lib/web-camera-input-widget.tpl.html

16
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": "<tb-web-camera-widget ctx=\"ctx\">\n</tb-web-camera-widget>",
"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\":{}}"
}
}
]
}

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

5
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",

196
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();
})
}
}

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

73
ui/src/app/widget/lib/web-camera-input-widget.tpl.html

@ -0,0 +1,73 @@
<!--
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.
-->
<div layout="column" layout-align="center center" class="tb-web-camera">
<div ng-if="vm.isEntityDetected && vm.dataKeyDetected && vm.isCameraSupport && vm.isDeviceDetect" layout-fill>
<div ng-show="!vm.isShowCamera" layout="column" layout-align="space-between center" layout-fill>
<div class="tb-web-camera__last-photo" flex>
<span ng-show="!vm.ctx.data[0].data[0][1]" class="tb-web-camera__last-photo_text" translate>widgets.input-widgets.no-image</span>
<img ng-show="vm.ctx.data[0].data[0][1]" class="tb-web-camera__last-photo_img" ng-src="{{vm.ctx.data[0].data[0][1]}}" />
</div>
<md-button class="md-raised md-primary" ng-click="vm.takePhoto()"">{{ "widgets.input-widgets.take-photo" | translate }}</md-button>
</div>
<div ng-show="vm.isShowCamera" layout="column" layout-align="center center" id="photoCamera">
<div class="camera" ng-show="!vm.isPreviewPhoto">
<video autoplay muted playsinline id="videoStream" class="camera-stream"></video>
<div class="camera-controls" layout="row" layout-wrap="" layout-align="space-between end">
<div flex></div>
<md-button class="md-fab md-primary md-mini" aria-label="{{ 'widgets.input-widgets.switch-camera' | translate }}"
ng-click="vm.switchWebCamera()" ng-disabled="{{vm.videoInput.length < 2}}">
<md-icon md-font-icon="switch_camera" aaria-label="{{ 'widgets.input-widgets.switch-camera' | translate }}">switch_camera</md-icon>
</md-button>
<md-button class="md-fab md-hue-2" aria-label="{{ 'widgets.input-widgets.take-photo' | translate }}" ng-click="vm.createPhoto()">
<md-icon md-font-icon="photo_camera" aria-label="Take Photo">photo_camera</md-icon>
</md-button>
<md-button class="md-fab md-primary md-mini" aria-label="{{ 'action.cancel' | translate }}" ng-click="vm.closeCamera()">
<md-icon md-font-icon="close" aria-label="{{ 'action.cancel' | translate }}">close</md-icon>
</md-button>
<div flex></div>
</div>
</div>
<div class="camera" ng-show="vm.isPreviewPhoto">
<img class="camera-stream" ng-src="{{vm.previewPhoto}}">
<canvas style="display:none;"></canvas>
<div class="camera-controls" layout="row" layout-wrap="" layout-align="space-between end">
<div flex></div>
<md-button class="md-fab md-primary" aria-label="{{ 'action.cancel' | translate }}" ng-click="vm.cancelPhoto()">
<md-icon md-font-icon="close" aria-label="{{ 'action.cancel' | translate }}">close</md-icon>
</md-button>
<md-button class="md-fab md-hue-2" aria-label="{{ 'action.save' | translate }}" ng-click="vm.savePhoto()">
<md-icon md-font-icon="check" aria-label="{{ 'action.save' | translate }}">check</md-icon>
</md-button>
<div flex></div>
</div>
</div>
</div>
</div>
<div class="message-text" ng-hide="vm.isEntityDetected">
{{ 'widgets.input-widgets.no-entity-selected' | translate }}
</div>
<div class="message-text" ng-if="vm.isEntityDetected && !vm.dataKeyDetected">
{{ 'widgets.input-widgets.no-datakey-selected' | translate }}
</div>
<div class="message-text" ng-if="vm.isEntityDetected && vm.dataKeyDetected && !vm.isCameraSupport">
{{ 'widgets.input-widgets.no-support-web-camera' | translate }}
</div>
<div class="message-text" ng-if="vm.isEntityDetected && vm.dataKeyDetected && vm.isCameraSupport && !vm.isDeviceDetect">
{{ 'widgets.input-widgets.no-support-web-camera' | translate }}
</div>
</div>
Loading…
Cancel
Save