diff --git a/application/src/main/data/json/system/widget_bundles/control_widgets.json b/application/src/main/data/json/system/widget_bundles/control_widgets.json
index f8395c656f..f440feb385 100644
--- a/application/src/main/data/json/system/widget_bundles/control_widgets.json
+++ b/application/src/main/data/json/system/widget_bundles/control_widgets.json
@@ -45,9 +45,9 @@
"sizeX": 5,
"sizeY": 4.5,
"resources": [],
- "templateHtml": "",
+ "templateHtml": "",
"templateCss": "",
- "controllerScript": "self.onInit = function() {\n var scope = self.ctx.$scope;\n scope.ctx = self.ctx;\n}\n\nself.onResize = function() {\n if (self.ctx.resize) {\n self.ctx.resize();\n }\n}\n\nself.onDestroy = function() {\n}\n",
+ "controllerScript": "self.onInit = function() {\n}\n\nself.onResize = function() {\n}\n\nself.onDestroy = function() {\n}\n",
"settingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"properties\": {\n \"minValue\": {\n \"title\": \"Minimum value\",\n \"type\": \"number\",\n \"default\": 0\n },\n \"maxValue\": {\n \"title\": \"Maximum value\",\n \"type\": \"number\",\n \"default\": 100\n },\n \"initialValue\": {\n \"title\": \"Initial value\",\n \"type\": \"number\",\n \"default\": 50\n },\n \"title\": {\n \"title\": \"Knob title\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"getValueMethod\": {\n \"title\": \"Get value method\",\n \"type\": \"string\",\n \"default\": \"getValue\"\n },\n \"setValueMethod\": {\n \"title\": \"Set value method\",\n \"type\": \"string\",\n \"default\": \"setValue\"\n },\n \"requestTimeout\": {\n \"title\": \"RPC request timeout\",\n \"type\": \"number\",\n \"default\": 500\n }\n },\n \"required\": [\"minValue\", \"maxValue\", \"getValueMethod\", \"setValueMethod\", \"requestTimeout\"]\n },\n \"form\": [\n \"minValue\",\n \"maxValue\",\n \"initialValue\",\n \"title\",\n \"getValueMethod\",\n \"setValueMethod\",\n \"requestTimeout\"\n ]\n}",
"dataKeySettingsSchema": "{}\n",
"defaultConfig": "{\"targetDeviceAliases\":[],\"showTitle\":false,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"requestTimeout\":500,\"maxValue\":100,\"initialValue\":50,\"minValue\":0,\"title\":\"Knob control\",\"getValueMethod\":\"getValue\",\"setValueMethod\":\"setValue\"},\"title\":\"Knob Control\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{},\"decimals\":2}"
@@ -61,9 +61,9 @@
"sizeX": 4,
"sizeY": 2.5,
"resources": [],
- "templateHtml": "",
+ "templateHtml": "",
"templateCss": "",
- "controllerScript": "self.onInit = function() {\n var scope = self.ctx.$scope;\n scope.ctx = self.ctx;\n}\n\nself.onResize = function() {\n if (self.ctx.resize) {\n self.ctx.resize();\n }\n}\n\nself.onDestroy = function() {\n}\n",
+ "controllerScript": "self.onInit = function() {\n}\n\nself.onResize = function() {\n}\n\nself.onDestroy = function() {\n}\n",
"settingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"properties\": {\n \"initialValue\": {\n \"title\": \"Initial value\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"title\": {\n \"title\": \"Switch title\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"showOnOffLabels\": {\n \"title\": \"Show on/off labels\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"retrieveValueMethod\": {\n \"title\": \"Retrieve on/off value using method\",\n \"type\": \"string\",\n \"default\": \"rpc\"\n },\n \"valueKey\": {\n \"title\": \"Attribute/Timeseries value key (only when subscribe for attribute/timeseries method)\",\n \"type\": \"string\",\n \"default\": \"value\"\n },\n \"getValueMethod\": {\n \"title\": \"RPC get value method\",\n \"type\": \"string\",\n \"default\": \"getValue\"\n },\n \"setValueMethod\": {\n \"title\": \"RPC set value method\",\n \"type\": \"string\",\n \"default\": \"setValue\"\n },\n \"parseValueFunction\": {\n \"title\": \"Parse value function, f(data), returns boolean\",\n \"type\": \"string\",\n \"default\": \"return data ? true : false;\"\n },\n \"convertValueFunction\": {\n \"title\": \"Convert value function, f(value), returns payload used by RPC set value method\",\n \"type\": \"string\",\n \"default\": \"return value;\"\n },\n \"requestTimeout\": {\n \"title\": \"RPC request timeout\",\n \"type\": \"number\",\n \"default\": 500\n }\n },\n \"required\": [\"requestTimeout\"]\n },\n \"form\": [\n \"initialValue\",\n \"title\",\n \"showOnOffLabels\",\n {\n \"key\": \"retrieveValueMethod\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"items\": [\n {\n \"value\": \"none\",\n \"label\": \"Don't retrieve\"\n },\n {\n \"value\": \"rpc\",\n \"label\": \"Call RPC get value method\"\n },\n {\n \"value\": \"attribute\",\n \"label\": \"Subscribe for attribute\"\n },\n {\n \"value\": \"timeseries\",\n \"label\": \"Subscribe for timeseries\"\n }\n ]\n },\n \"valueKey\",\n \"getValueMethod\",\n \"setValueMethod\",\n {\n \"key\": \"parseValueFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"convertValueFunction\",\n \"type\": \"javascript\"\n },\n \"requestTimeout\"\n ]\n}",
"dataKeySettingsSchema": "{}\n",
"defaultConfig": "{\"targetDeviceAliases\":[],\"showTitle\":false,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"requestTimeout\":500,\"initialValue\":false,\"getValueMethod\":\"getValue\",\"setValueMethod\":\"setValue\",\"showOnOffLabels\":true,\"title\":\"Switch control\"},\"title\":\"Switch Control\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{},\"decimals\":2}"
@@ -77,9 +77,9 @@
"sizeX": 2.5,
"sizeY": 2,
"resources": [],
- "templateHtml": "",
+ "templateHtml": "",
"templateCss": "",
- "controllerScript": "self.onInit = function() {\n var scope = self.ctx.$scope;\n scope.ctx = self.ctx;\n}\n\nself.onResize = function() {\n if (self.ctx.resize) {\n self.ctx.resize();\n }\n}\n\nself.onDestroy = function() {\n}\n",
+ "controllerScript": "self.onInit = function() {\n}\n\nself.onResize = function() {\n}\n\nself.onDestroy = function() {\n}\n",
"settingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"properties\": {\n \"initialValue\": {\n \"title\": \"Initial value\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"title\": {\n \"title\": \"Switch title\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"retrieveValueMethod\": {\n \"title\": \"Retrieve on/off value using method\",\n \"type\": \"string\",\n \"default\": \"rpc\"\n },\n \"valueKey\": {\n \"title\": \"Attribute/Timeseries value key (only when subscribe for attribute/timeseries method)\",\n \"type\": \"string\",\n \"default\": \"value\"\n },\n \"getValueMethod\": {\n \"title\": \"RPC get value method\",\n \"type\": \"string\",\n \"default\": \"getValue\"\n },\n \"setValueMethod\": {\n \"title\": \"RPC set value method\",\n \"type\": \"string\",\n \"default\": \"setValue\"\n },\n \"parseValueFunction\": {\n \"title\": \"Parse value function, f(data), returns boolean\",\n \"type\": \"string\",\n \"default\": \"return data ? true : false;\"\n },\n \"convertValueFunction\": {\n \"title\": \"Convert value function, f(value), returns payload used by RPC set value method\",\n \"type\": \"string\",\n \"default\": \"return value;\"\n },\n \"requestTimeout\": {\n \"title\": \"RPC request timeout\",\n \"type\": \"number\",\n \"default\": 500\n }\n },\n \"required\": [\"requestTimeout\"]\n },\n \"form\": [\n \"initialValue\",\n \"title\",\n {\n \"key\": \"retrieveValueMethod\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"items\": [\n {\n \"value\": \"none\",\n \"label\": \"Don't retrieve\"\n },\n {\n \"value\": \"rpc\",\n \"label\": \"Call RPC get value method\"\n },\n {\n \"value\": \"attribute\",\n \"label\": \"Subscribe for attribute\"\n },\n {\n \"value\": \"timeseries\",\n \"label\": \"Subscribe for timeseries\"\n }\n ]\n },\n \"valueKey\",\n \"getValueMethod\",\n \"setValueMethod\",\n {\n \"key\": \"parseValueFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"convertValueFunction\",\n \"type\": \"javascript\"\n },\n \"requestTimeout\"\n ]\n}",
"dataKeySettingsSchema": "{}\n",
"defaultConfig": "{\"targetDeviceAliases\":[],\"showTitle\":false,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"requestTimeout\":500,\"initialValue\":false,\"getValueMethod\":\"getValue\",\"setValueMethod\":\"setValue\",\"title\":\"Round switch\",\"retrieveValueMethod\":\"rpc\",\"valueKey\":\"value\",\"parseValueFunction\":\"return data ? true : false;\",\"convertValueFunction\":\"return value;\"},\"title\":\"Round switch\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{},\"decimals\":2}"
@@ -93,9 +93,9 @@
"sizeX": 2.5,
"sizeY": 2.5,
"resources": [],
- "templateHtml": "",
+ "templateHtml": "",
"templateCss": "",
- "controllerScript": "self.onInit = function() {\n var scope = self.ctx.$scope;\n scope.ctx = self.ctx;\n}\n\nself.onResize = function() {\n if (self.ctx.resize) {\n self.ctx.resize();\n }\n}\n\nself.onDestroy = function() {\n}\n",
+ "controllerScript": "self.onInit = function() {\n}\n\nself.onResize = function() {\n}\n\nself.onDestroy = function() {\n}\n",
"settingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"properties\": {\n \"initialValue\": {\n \"title\": \"Initial value\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"title\": {\n \"title\": \"LED title\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"ledColor\": {\n \"title\": \"LED Color\",\n \"type\": \"string\",\n \"default\": \"green\"\n },\n \"performCheckStatus\": {\n \"title\": \"Perform RPC device status check\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"checkStatusMethod\": {\n \"title\": \"RPC check device status method\",\n \"type\": \"string\",\n \"default\": \"checkStatus\"\n },\n \"retrieveValueMethod\": {\n \"title\": \"Retrieve led status value using method\",\n \"type\": \"string\",\n \"default\": \"attribute\"\n },\n \"valueAttribute\": {\n \"title\": \"Device attribute/timeseries containing led status value\",\n \"type\": \"string\",\n \"default\": \"value\"\n },\n \"parseValueFunction\": {\n \"title\": \"Parse led status value function, f(data), returns boolean\",\n \"type\": \"string\",\n \"default\": \"return data ? true : false;\"\n },\n \"requestTimeout\": {\n \"title\": \"RPC request timeout (ms)\",\n \"type\": \"number\",\n \"default\": 500\n }\n },\n \"required\": [\"valueAttribute\", \"requestTimeout\"]\n },\n \"form\": [\n \"initialValue\",\n \"title\",\n {\n \"key\": \"ledColor\",\n \"type\": \"color\"\n },\n \"performCheckStatus\",\n \"checkStatusMethod\",\n {\n \"key\": \"retrieveValueMethod\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"items\": [\n {\n \"value\": \"attribute\",\n \"label\": \"Subscribe for attribute\"\n },\n {\n \"value\": \"timeseries\",\n \"label\": \"Subscribe for timeseries\"\n }\n ]\n },\n \"valueAttribute\",\n {\n \"key\": \"parseValueFunction\",\n \"type\": \"javascript\"\n },\n \"requestTimeout\"\n ]\n}",
"dataKeySettingsSchema": "{}\n",
"defaultConfig": "{\"targetDeviceAliases\":[],\"showTitle\":false,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"requestTimeout\":500,\"initialValue\":true,\"title\":\"Led indicator\",\"ledColor\":\"#4caf50\",\"valueAttribute\":\"value\",\"retrieveValueMethod\":\"attribute\",\"parseValueFunction\":\"return data ? true : false;\",\"performCheckStatus\":true,\"checkStatusMethod\":\"checkStatus\"},\"title\":\"Led indicator\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{},\"decimals\":2}"
diff --git a/ui-ngx/src/app/core/api/widget-api.models.ts b/ui-ngx/src/app/core/api/widget-api.models.ts
index 00d33cdde1..88f5139e1e 100644
--- a/ui-ngx/src/app/core/api/widget-api.models.ts
+++ b/ui-ngx/src/app/core/api/widget-api.models.ts
@@ -185,7 +185,7 @@ export interface WidgetSubscriptionCallbacks {
}
export interface WidgetSubscriptionOptions {
- type: widgetType;
+ type?: widgetType;
stateData?: boolean;
alarmSource?: Datasource;
alarmSearchStatus?: AlarmSearchStatus;
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/canvas-digital-gauge.ts b/ui-ngx/src/app/modules/home/components/widget/lib/canvas-digital-gauge.ts
index b1fdd7b859..336dc7de2f 100644
--- a/ui-ngx/src/app/modules/home/components/widget/lib/canvas-digital-gauge.ts
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/canvas-digital-gauge.ts
@@ -395,10 +395,12 @@ export class CanvasDigitalGauge extends BaseGauge {
let color = this.contextProgressClone.currentColor;
const options = this.options as CanvasDigitalGaugeOptions;
if (!color) {
+ const progress = (CanvasGauges.drawings.normalizedValue(options).normal - options.minValue) /
+ (options.maxValue - options.minValue);
if (options.neonGlowBrightness) {
- color = getProgressColor(0, options.neonColorsRange);
+ color = getProgressColor(progress, options.neonColorsRange);
} else {
- color = getProgressColor(0, options.colorsRange);
+ color = getProgressColor(progress, options.colorsRange);
}
}
return color;
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.html
new file mode 100644
index 0000000000..5b70d8a958
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.html
@@ -0,0 +1,45 @@
+
+
+
+
+
+ {{ value }}
+
+
+
+
+
+
+ {{ error }}
+
+
+ {{ title }}
+
+
+ min
+
+ max
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.scss
new file mode 100644
index 0000000000..ca37ac4c69
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.scss
@@ -0,0 +1,168 @@
+/**
+ * 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.
+ */
+$knob-img: url("./svg/knob.svg") !default;
+
+$bars-margin-pct: percentage(.033) !default;
+$background-margin-pct: percentage(.05) !default;
+$value-container-margin-pct: percentage(.35) !default;
+$error-height: percentage(.05) !default;
+$title-height: percentage(.066) !default;
+$title-container-margin-pct: percentage(.2) !default;
+$title-container-margin-bottom-pct: percentage(.05) !default;
+$minmax-height: percentage(.04) !default;
+$minmax-container-margin-pct: percentage(.18) !default;
+$minmax-container-margin-bottom-pct: percentage(.12) !default;
+
+$background-color: #e6e7e8 !default;
+
+:host {
+ .tb-knob {
+ width: 100%;
+ height: 100%;
+ background: $background-color;
+
+ .knob {
+ position: relative;
+
+ &[draggable] {
+ user-select: none;
+ }
+
+ #canvasBar {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 2;
+ }
+
+ .canvas-background {
+ position: absolute;
+ top: $background-margin-pct;
+ right: $background-margin-pct;
+ bottom: $background-margin-pct;
+ left: $background-margin-pct;
+ z-index: 2;
+ background: #3f4346;
+ border-radius: 50%;
+ }
+
+ .value-container {
+ position: absolute;
+ top: $value-container-margin-pct;
+ right: $value-container-margin-pct;
+ bottom: $value-container-margin-pct;
+ left: $value-container-margin-pct;
+ z-index: 4;
+
+ .knob-value {
+ font-weight: 500;
+ color: #fff;
+ white-space: nowrap;
+ }
+ }
+
+ .error-container {
+ position: absolute;
+ top: 1%;
+ right: 0;
+ left: 0;
+ z-index: 4;
+ height: $error-height;
+
+ .knob-error {
+ color: #ff3315;
+ white-space: nowrap;
+ }
+ }
+
+ .title-container {
+ position: absolute;
+ right: $title-container-margin-pct;
+ bottom: $title-container-margin-bottom-pct;
+ left: $title-container-margin-pct;
+ z-index: 4;
+ height: $title-height;
+
+ .knob-title {
+ font-weight: 500;
+ color: #757575;
+ white-space: nowrap;
+ }
+ }
+
+ .minmax-container {
+ position: absolute;
+ right: $minmax-container-margin-pct;
+ bottom: $minmax-container-margin-bottom-pct;
+ left: $minmax-container-margin-pct;
+ z-index: 4;
+ height: $minmax-height;
+
+ .minmax-label {
+ font-weight: 500;
+ color: #757575;
+ white-space: nowrap;
+ }
+ }
+
+ .top-pointer-container {
+ position: absolute;
+ top: $bars-margin-pct;
+ right: $bars-margin-pct;
+ bottom: $bars-margin-pct;
+ left: $bars-margin-pct;
+ z-index: 3;
+ cursor: pointer !important;
+
+ .top-pointer {
+ position: absolute;
+ top: 50%;
+ left: 22%;
+ width: 5%;
+ height: 5%;
+ margin-top: -2.5%;
+ cursor: pointer !important;
+ content: "";
+ background-color: #b5b5b5;
+ border-radius: 50%;
+ box-shadow: 1px 0 2px #040404;
+ }
+ }
+
+ .top {
+ position: absolute;
+ top: $bars-margin-pct;
+ right: $bars-margin-pct;
+ bottom: $bars-margin-pct;
+ left: $bars-margin-pct;
+ z-index: 2;
+ cursor: pointer !important;
+ background: $knob-img no-repeat;
+ background-size: contain;
+ }
+
+ #text-measure {
+ position: absolute;
+ width: auto;
+ height: auto;
+ white-space: nowrap;
+ visibility: hidden;
+ }
+ }
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.ts
new file mode 100644
index 0000000000..4c21f188b0
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.ts
@@ -0,0 +1,435 @@
+///
+/// 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 { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core';
+import { PageComponent } from '@shared/components/page.component';
+import { WidgetContext } from '@home/models/widget-component.models';
+import { UtilsService } from '@core/services/utils.service';
+import { Store } from '@ngrx/store';
+import { AppState } from '@core/core.state';
+import { isDefined, isNumber } from '@core/utils';
+import { CanvasDigitalGauge, CanvasDigitalGaugeOptions } from '@home/components/widget/lib/canvas-digital-gauge';
+import GenericOptions = CanvasGauges.GenericOptions;
+import * as tinycolor_ from 'tinycolor2';
+
+const tinycolor = tinycolor_;
+
+interface KnobSettings {
+ minValue: number;
+ maxValue: number;
+ initialValue: number;
+ title: string;
+ getValueMethod: string;
+ setValueMethod: string;
+ requestTimeout: number;
+}
+
+@Component({
+ selector: 'tb-knob',
+ templateUrl: './knob.component.html',
+ styleUrls: ['./knob.component.scss']
+})
+export class KnobComponent extends PageComponent implements OnInit, OnDestroy {
+
+ @ViewChild('knob', {static: true}) knobRef: ElementRef;
+ @ViewChild('knobContainer', {static: true}) knobContainerRef: ElementRef;
+ @ViewChild('knobTopPointerContainer', {static: true}) knobTopPointerContainerRef: ElementRef;
+ @ViewChild('knobTopPointer', {static: true}) knobTopPointerRef: ElementRef;
+ @ViewChild('knobValueContainer', {static: true}) knobValueContainerRef: ElementRef;
+ @ViewChild('knobValue', {static: true}) knobValueRef: ElementRef;
+ @ViewChild('knobErrorContainer', {static: true}) knobErrorContainerRef: ElementRef;
+ @ViewChild('knobError', {static: true}) knobErrorRef: ElementRef;
+ @ViewChild('knobTitleContainer', {static: true}) knobTitleContainerRef: ElementRef;
+ @ViewChild('knobTitle', {static: true}) knobTitleRef: ElementRef;
+ @ViewChild('knobMinmaxContainer', {static: true}) knobMinmaxContainerRef: ElementRef;
+ @ViewChild('textMeasure', {static: true}) textMeasureRef: ElementRef;
+ @ViewChild('canvasBar', {static: true}) canvasBarElementRef: ElementRef;
+
+ @Input()
+ ctx: WidgetContext;
+
+ value = '0';
+ error = '';
+ title = '';
+ minValue: number;
+ maxValue: number;
+
+ private startDeg = -1;
+ private currentDeg = 0;
+ private rotation = 0;
+ private lastDeg = 0;
+ private moving = false;
+
+ private minDeg = -45;
+ private maxDeg = 225;
+
+ private isSimulated: boolean;
+ private requestTimeout: number;
+ private getValueMethod: string;
+ private setValueMethod: string;
+
+ private executingUpdateValue: boolean;
+ private scheduledValue: number;
+ private rpcValue: number;
+
+ private knob: JQuery;
+ private knobContainer: JQuery;
+ private knobTopPointerContainer: JQuery;
+ private knobTopPointer: JQuery;
+ private knobValueContainer: JQuery;
+ private knobValue: JQuery;
+ private knobTitleContainer: JQuery;
+ private knobTitle: JQuery;
+ private knobErrorContainer: JQuery;
+ private knobError: JQuery;
+ private knobMinmaxContainer: JQuery;
+ private minmaxLabel: JQuery;
+ private textMeasure: JQuery;
+ private canvasBarElement: HTMLElement;
+
+ private canvasBar: CanvasDigitalGauge;
+
+ private knobResizeListener: any;
+
+ constructor(private utils: UtilsService,
+ protected store: Store) {
+ super(store);
+ }
+
+ ngOnInit(): void {
+ this.knob = $(this.knobRef.nativeElement);
+ this.knobContainer = $(this.knobContainerRef.nativeElement);
+ this.knobTopPointerContainer = $(this.knobTopPointerContainerRef.nativeElement);
+ this.knobTopPointer = $(this.knobTopPointerRef.nativeElement);
+ this.knobValueContainer = $(this.knobValueContainerRef.nativeElement);
+ this.knobValue = $(this.knobValueRef.nativeElement);
+ this.knobTitleContainer = $(this.knobTitleContainerRef.nativeElement);
+ this.knobTitle = $(this.knobTitleRef.nativeElement);
+ this.knobErrorContainer = $(this.knobErrorContainerRef.nativeElement);
+ this.knobError = $(this.knobErrorRef.nativeElement);
+ this.knobMinmaxContainer = $(this.knobMinmaxContainerRef.nativeElement);
+ this.minmaxLabel = this.knobMinmaxContainer.find('.minmax-label');
+ this.textMeasure = $(this.textMeasureRef.nativeElement);
+ this.canvasBarElement = this.canvasBarElementRef.nativeElement;
+
+ this.knobResizeListener = this.resize.bind(this);
+ // @ts-ignore
+ addResizeListener(this.knobContainerRef.nativeElement, this.knobResizeListener);
+ this.init();
+ }
+
+ ngOnDestroy(): void {
+ if (this.knobResizeListener) {
+ // @ts-ignore
+ removeResizeListener(this.knobContainerRef.nativeElement, this.knobResizeListener);
+ }
+ }
+
+ private init() {
+ const settings: KnobSettings = this.ctx.settings;
+
+ this.minValue = isDefined(settings.minValue) ? settings.minValue : 0;
+ this.maxValue = isDefined(settings.maxValue) ? settings.maxValue : 100;
+ this.title = isDefined(settings.title) ? settings.title : '';
+
+ const levelColors = ['#19ff4b', '#ffff19', '#ff3232'];
+
+ const canvasBarData: CanvasDigitalGaugeOptions = {
+ renderTo: this.canvasBarElement,
+ hideValue: true,
+ neonGlowBrightness: 0,
+ gaugeWidthScale: 0.4,
+ gaugeColor: 'rgba(0, 0, 0, 0)',
+ levelColors,
+ minValue: this.minValue,
+ maxValue: this.maxValue,
+ gaugeType: 'donut',
+ dashThickness: 2,
+ donutStartAngle: 3/4*Math.PI,
+ donutEndAngle: 9/4*Math.PI,
+ animation: false
+ };
+
+ this.canvasBar = new CanvasDigitalGauge(canvasBarData).draw();
+
+ this.knob.on('click', (e) => {
+ if (this.moving) {
+ this.moving = false;
+ return false;
+ }
+ e.preventDefault();
+
+ const offset = this.knob.offset();
+ const center = {
+ y : offset.top + this.knob.height()/2,
+ x: offset.left + this.knob.width()/2
+ };
+ const rad2deg = 180/Math.PI;
+ const t: Touch = ((e.originalEvent as any).touches) ? (e.originalEvent as any).touches[0] : e;
+
+ const a = center.y - t.pageY;
+ const b = center.x - t.pageX;
+ let deg = Math.atan2(a,b)*rad2deg;
+ if(deg < 0){
+ deg = 360 + deg;
+ }
+ if (deg > this.maxDeg) {
+ if (deg - 360 > this.minDeg) {
+ deg = deg - 360;
+ } else {
+ return false;
+ }
+ }
+ this.currentDeg = deg;
+ this.lastDeg = deg;
+ this.knobTopPointerContainer.css('transform','rotate('+(this.currentDeg)+'deg)');
+ this.turn(this.degreeToRatio(this.currentDeg));
+ this.rotation = this.currentDeg;
+ this.startDeg = -1;
+ });
+
+ this.knob.on('mousedown touchstart', (e) => {
+ e.preventDefault();
+ const offset = this.knob.offset();
+ const center = {
+ y : offset.top + this.knob.height()/2,
+ x: offset.left + this.knob.width()/2
+ };
+ const rad2deg = 180/Math.PI;
+
+ this.knob.on('mousemove.rem touchmove.rem', (ev) => {
+ this.moving = true;
+ const t: Touch = ((ev.originalEvent as any).touches) ? (ev.originalEvent as any).touches[0] : ev;
+
+ const a = center.y - t.pageY;
+ const b = center.x - t.pageX;
+ let deg = Math.atan2(a,b)*rad2deg;
+ if(deg < 0){
+ deg = 360 + deg;
+ }
+
+ if(this.startDeg === -1){
+ this.startDeg = deg;
+ }
+
+ let tmp = Math.floor((deg-this.startDeg) + this.rotation);
+
+ if(tmp < 0){
+ tmp = 360 + tmp;
+ }
+ else if(tmp > 359){
+ tmp = tmp % 360;
+ }
+
+ if (tmp > this.maxDeg) {
+ if (tmp - 360 > this.minDeg) {
+ tmp = tmp - 360;
+ } else {
+ const deltaMax = Math.abs(this.maxDeg - this.lastDeg);
+ const deltaMin = Math.abs(this.minDeg - this.lastDeg);
+ if (deltaMax < deltaMin) {
+ tmp = this.maxDeg;
+ } else {
+ tmp = this.minDeg;
+ }
+ }
+ }
+ if(Math.abs(tmp - this.lastDeg) > 180){
+ this.startDeg = deg;
+ this.rotation = this.currentDeg;
+ return false;
+ }
+
+ this.currentDeg = tmp;
+ this.lastDeg = tmp;
+
+ this.knobTopPointerContainer.css('transform','rotate('+(this.currentDeg)+'deg)');
+ this.turn(this.degreeToRatio(this.currentDeg));
+ });
+
+ $(document).on('mouseup.rem touchend.rem',() => {
+ this.knob.off('.rem');
+ $(document).off('.rem');
+ this.rotation = this.currentDeg;
+ this.startDeg = -1;
+ });
+
+ });
+
+ const initialValue = isDefined(settings.initialValue) ? settings.initialValue : this.minValue;
+ this.setValue(initialValue);
+
+ const subscription = this.ctx.defaultSubscription;
+ const rpcEnabled = subscription.rpcEnabled;
+
+ this.isSimulated = this.utils.widgetEditMode;
+
+ this.requestTimeout = 500;
+ if (settings.requestTimeout) {
+ this.requestTimeout = settings.requestTimeout;
+ }
+ this.getValueMethod = 'getValue';
+ if (settings.getValueMethod && settings.getValueMethod.length) {
+ this.getValueMethod = settings.getValueMethod;
+ }
+ this.setValueMethod = 'setValue';
+ if (settings.setValueMethod && settings.setValueMethod.length) {
+ this.setValueMethod = settings.setValueMethod;
+ }
+ if (!rpcEnabled) {
+ this.onError('Target device is not set!');
+ } else {
+ if (!this.isSimulated) {
+ this.rpcRequestValue();
+ }
+ }
+ }
+
+ private degreeToRatio(degree: number): number {
+ return (degree-this.minDeg)/(this.maxDeg-this.minDeg);
+ }
+
+ private ratioToDegree(ratio: number): number {
+ return this.minDeg + ratio*(this.maxDeg-this.minDeg);
+ }
+
+ private turn(ratio: number) {
+ const value = Number((this.minValue + (this.maxValue - this.minValue)*ratio).toFixed(this.ctx.decimals));
+ if (this.canvasBar.value !== value) {
+ this.canvasBar.value = value;
+ }
+ this.updateColor(this.canvasBar.getValueColor());
+ this.onValue(value);
+ }
+
+ private resize() {
+ const width = this.knobContainer.width();
+ const height = this.knobContainer.height();
+ const size = Math.min(width, height);
+ this.knob.css({width: size, height: size});
+ this.canvasBar.update({width: size, height: size} as GenericOptions);
+ this.setFontSize(this.knobTitle, this.title, this.knobTitleContainer.height(), this.knobTitleContainer.width());
+ this.setFontSize(this.knobError, this.error, this.knobErrorContainer.height(), this.knobErrorContainer.width());
+ const minmaxHeight = this.knobMinmaxContainer.height();
+ this.minmaxLabel.css({fontSize: minmaxHeight+'px', lineHeight: minmaxHeight+'px'});
+ this.checkValueSize();
+ }
+
+ private checkValueSize() {
+ const fontSize = this.knobValueContainer.height()/3.3;
+ const containerWidth = this.knobValueContainer.width();
+ this.setFontSize(this.knobValue, this.value+'', fontSize, containerWidth);
+ }
+
+ private setFontSize(element: JQuery, text: string, fontSize: number, maxWidth: number) {
+ let textWidth = this.measureTextWidth(text, fontSize);
+ while (textWidth > maxWidth) {
+ fontSize--;
+ textWidth = this.measureTextWidth(text, fontSize);
+ }
+ element.css({fontSize: fontSize+'px', lineHeight: fontSize+'px'});
+ }
+
+ private measureTextWidth(text: string, fontSize: number): number {
+ this.textMeasure.css({fontSize: fontSize+'px', lineHeight: fontSize+'px'});
+ this.textMeasure.text(text);
+ return this.textMeasure.width();
+ }
+
+ private setValue(value: number) {
+ const ratio = (value-this.minValue) / (this.maxValue - this.minValue);
+ this.rotation = this.lastDeg = this.currentDeg = this.ratioToDegree(ratio);
+ this.knobTopPointerContainer.css('transform','rotate('+(this.currentDeg)+'deg)');
+ if (this.canvasBar.value !== value) {
+ this.canvasBar.value = value;
+ }
+ this.updateColor(this.canvasBar.getValueColor());
+ this.value = this.formatValue(value);
+ this.checkValueSize();
+ }
+
+ private updateColor(color: string) {
+ const glowColor = tinycolor(color).brighten(30).toHexString();
+ this.knobValue.css({color: glowColor});
+ const textShadow = `${color} 1px 1px 10px, ${glowColor} 1px 1px 10px`;
+ this.knobValue.css({textShadow});
+ this.knobTopPointer.css({backgroundColor: glowColor});
+ const boxShadow = `inset 1px 0 2px #040404, 1px 1px 8px 2px ${glowColor}`;
+ this.knobTopPointer.css({boxShadow});
+ }
+
+ private onValue(value: number) {
+ this.value = this.formatValue(value);
+ this.checkValueSize();
+ this.rpcUpdateValue(value);
+ this.ctx.detectChanges();
+ }
+
+ private formatValue(value: any): string {
+ return this.ctx.utils.formatValue(value, this.ctx.decimals, this.ctx.units, true);
+ }
+
+ private rpcRequestValue() {
+ this.error = '';
+ this.ctx.controlApi.sendTwoWayCommand(this.getValueMethod, null, this.requestTimeout).subscribe(
+ (responseBody) => {
+ if (isNumber(responseBody)) {
+ const numValue = Number(Number(responseBody).toFixed(this.ctx.decimals));
+ this.setValue(numValue);
+ } else {
+ const errorText = `Unable to parse response: ${responseBody}`;
+ this.onError(errorText);
+ }
+ },
+ () => {
+ const errorText = this.ctx.defaultSubscription.rpcErrorText;
+ this.onError(errorText);
+ }
+ );
+ }
+
+ private rpcUpdateValue(value: number) {
+ if (this.executingUpdateValue) {
+ this.scheduledValue = value;
+ return;
+ } else {
+ this.scheduledValue = null;
+ this.rpcValue = value;
+ this.executingUpdateValue = true;
+ }
+ this.error = '';
+ this.ctx.controlApi.sendOneWayCommand(this.setValueMethod, value, this.requestTimeout).subscribe(
+ () => {
+ this.executingUpdateValue = false;
+ if (this.scheduledValue != null && this.scheduledValue !== this.rpcValue) {
+ this.rpcUpdateValue(this.scheduledValue);
+ }
+ },
+ () => {
+ this.executingUpdateValue = false;
+ const errorText = this.ctx.defaultSubscription.rpcErrorText;
+ this.onError(errorText);
+ }
+ );
+ }
+
+ private onError(error: string) {
+ this.error = error;
+ this.setFontSize(this.knobError, this.error, this.knobErrorContainer.height(), this.knobErrorContainer.width());
+ this.ctx.detectChanges();
+ }
+
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.html
new file mode 100644
index 0000000000..5d15434b74
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.html
@@ -0,0 +1,32 @@
+
+
+
+ {{title}}
+
+
+
+ {{ error }}
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.scss
new file mode 100644
index 0000000000..5dc4e28814
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.scss
@@ -0,0 +1,74 @@
+/**
+ * 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 "~compass-sass-mixins/lib/compass";
+
+$error-height: 14px !default;
+
+$background-color: #e6e7e8 !default;
+
+:host {
+ .tb-led-indicator {
+ width: 100%;
+ height: 100%;
+ background: $background-color;
+
+ .title-container {
+ .led-title {
+ font-weight: 500;
+ color: #757575;
+ white-space: nowrap;
+ }
+ }
+
+ .error-container {
+ position: absolute;
+ top: 1%;
+ right: 0;
+ left: 0;
+ z-index: 4;
+ height: $error-height;
+
+ .led-error {
+ color: #ff3315;
+ white-space: nowrap;
+ }
+ }
+
+ #text-measure {
+ position: absolute;
+ width: auto;
+ height: auto;
+ white-space: nowrap;
+ visibility: hidden;
+ }
+
+ #led-container {
+ padding: 10px;
+
+ .led {
+ position: relative;
+ cursor: pointer;
+ background-image: radial-gradient(circle closest-corner at 50% 50%, transparent, rgba(0, 0, 0, .25));
+ border-radius: 50%;
+ transition: background-color .5s, box-shadow .5s;
+
+ &.disabled {
+ background-image: radial-gradient(circle closest-corner at 50% 50%, rgba(255, 255, 255, .5), rgba(0, 0, 0, .1));
+ }
+ }
+ }
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.ts
new file mode 100644
index 0000000000..c307018976
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.ts
@@ -0,0 +1,342 @@
+///
+/// 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 { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core';
+import { PageComponent } from '@shared/components/page.component';
+import { WidgetContext } from '@home/models/widget-component.models';
+import { Store } from '@ngrx/store';
+import { AppState } from '@core/core.state';
+import { isDefined } from '@core/utils';
+import * as tinycolor_ from 'tinycolor2';
+import { UtilsService } from '@core/services/utils.service';
+import { IWidgetSubscription, SubscriptionInfo, WidgetSubscriptionOptions } from '@core/api/widget-api.models';
+import { DatasourceType, widgetType } from '@shared/models/widget.models';
+import { EntityType } from '@shared/models/entity-type.models';
+import Timeout = NodeJS.Timeout;
+
+const tinycolor = tinycolor_;
+
+const checkStatusPollingInterval = 10000;
+
+type RetrieveValueMethod = 'attribute' | 'timeseries';
+
+interface LedIndicatorSettings {
+ initialValue: boolean;
+ title: string;
+ ledColor: string;
+ performCheckStatus: boolean;
+ checkStatusMethod: string;
+ retrieveValueMethod: RetrieveValueMethod;
+ valueAttribute: string;
+ parseValueFunction: string;
+ requestTimeout: number;
+}
+
+@Component({
+ selector: 'tb-led-indicator',
+ templateUrl: './led-indicator.component.html',
+ styleUrls: ['./led-indicator.component.scss']
+})
+export class LedIndicatorComponent extends PageComponent implements OnInit, OnDestroy {
+
+ @ViewChild('led', {static: true}) ledRef: ElementRef;
+ @ViewChild('ledContainer', {static: true}) ledContainerRef: ElementRef;
+ @ViewChild('textMeasure', {static: true}) textMeasureRef: ElementRef;
+ @ViewChild('ledTitleContainer', {static: true}) ledTitleContainerRef: ElementRef;
+ @ViewChild('ledTitle', {static: true}) ledTitleRef: ElementRef;
+ @ViewChild('ledErrorContainer', {static: true}) ledErrorContainerRef: ElementRef;
+ @ViewChild('ledError', {static: true}) ledErrorRef: ElementRef;
+
+ @Input()
+ ctx: WidgetContext;
+
+ showTitle = false;
+ value = false;
+ error = '';
+ title = '';
+
+ private valueAttribute: string;
+ private ledColor: string;
+ private ledMiddleColor: string;
+ private disabledColor: string;
+ private disabledMiddleColor: string;
+
+ private isSimulated: boolean;
+ private requestTimeout: number;
+ private retrieveValueMethod: RetrieveValueMethod;
+ private parseValueFunction: (data: any) => boolean;
+ private performCheckStatus: boolean;
+ private checkStatusMethod: string;
+
+ private destroyed = false;
+ private checkStatusTimeoutHandle: Timeout;
+ private subscription: IWidgetSubscription;
+
+ private led: JQuery;
+ private ledContainer: JQuery;
+ private textMeasure: JQuery;
+ private ledTitleContainer: JQuery;
+ private ledTitle: JQuery;
+ private ledErrorContainer: JQuery;
+ private ledError: JQuery;
+
+ private ledResizeListener: any;
+
+ private subscriptionOptions: WidgetSubscriptionOptions = {
+ callbacks: {
+ onDataUpdated: this.onDataUpdated.bind(this),
+ onDataUpdateError: this.onDataUpdateError.bind(this),
+ dataLoading: () => {}
+ }
+ };
+
+
+ constructor(private utils: UtilsService,
+ protected store: Store) {
+ super(store);
+ }
+
+ ngOnInit(): void {
+ this.led = $(this.ledRef.nativeElement);
+ this.ledContainer = $(this.ledContainerRef.nativeElement);
+ this.textMeasure = $(this.textMeasureRef.nativeElement);
+ this.ledTitleContainer = $(this.ledTitleContainerRef.nativeElement);
+ this.ledTitle = $(this.ledTitleRef.nativeElement);
+ this.ledErrorContainer = $(this.ledErrorContainerRef.nativeElement);
+ this.ledError = $(this.ledErrorRef.nativeElement);
+
+ this.ledResizeListener = this.resize.bind(this);
+ // @ts-ignore
+ addResizeListener(this.ledContainerRef.nativeElement, this.ledResizeListener);
+ this.init();
+ }
+
+ ngOnDestroy(): void {
+ this.destroyed = true;
+ if (this.checkStatusTimeoutHandle) {
+ clearTimeout(this.checkStatusTimeoutHandle);
+ }
+ if (this.subscription) {
+ this.ctx.subscriptionApi.removeSubscription(this.subscription.id);
+ }
+ if (this.ledResizeListener) {
+ // @ts-ignore
+ removeResizeListener(this.ledContainerRef.nativeElement, this.ledResizeListener);
+ }
+ }
+
+ private init() {
+ const settings: LedIndicatorSettings = this.ctx.settings;
+ this.title = isDefined(settings.title) ? settings.title : '';
+ this.showTitle = !!(this.title && this.title.length);
+
+ const origColor = isDefined(settings.ledColor) ? settings.ledColor : 'green';
+ this.valueAttribute = isDefined(settings.valueAttribute) ? settings.valueAttribute : 'value';
+
+ this.ledColor = tinycolor(origColor).brighten(30).toHexString();
+ this.ledMiddleColor = tinycolor(origColor).toHexString();
+ this.disabledColor = tinycolor(origColor).darken(40).toHexString();
+ this.disabledMiddleColor = tinycolor(origColor).darken(60).toHexString();
+
+ const initialValue = isDefined(settings.initialValue) ? settings.initialValue : false;
+ this.setValue(initialValue, true);
+
+ const subscription = this.ctx.defaultSubscription;
+ const rpcEnabled = subscription.rpcEnabled;
+
+ this.isSimulated = this.utils.widgetEditMode;
+
+ this.requestTimeout = 500;
+ if (settings.requestTimeout) {
+ this.requestTimeout = settings.requestTimeout;
+ }
+ this.retrieveValueMethod = 'attribute';
+ if (settings.retrieveValueMethod && settings.retrieveValueMethod.length) {
+ this.retrieveValueMethod = settings.retrieveValueMethod;
+ }
+ this.parseValueFunction = (data) => !!data;
+ if (settings.parseValueFunction && settings.parseValueFunction.length) {
+ try {
+ this.parseValueFunction = new Function('data', settings.parseValueFunction) as (data: any) => boolean;
+ } catch (e) {
+ this.parseValueFunction = (data) => !!data;
+ }
+ }
+ this.performCheckStatus = settings.performCheckStatus !== false;
+ if (this.performCheckStatus) {
+ this.checkStatusMethod = 'checkStatus';
+ if (settings.checkStatusMethod && settings.checkStatusMethod.length) {
+ this.checkStatusMethod = settings.checkStatusMethod;
+ }
+ }
+ if (!rpcEnabled) {
+ this.onError('Target device is not set!');
+ } else {
+ if (!this.isSimulated) {
+ if (this.performCheckStatus) {
+ this.rpcCheckStatus();
+ } else {
+ this.subscribeForValue();
+ }
+ }
+ }
+ }
+
+ private resize() {
+ const width = this.ledContainer.width();
+ const height = this.ledContainer.height();
+ const size = Math.min(width, height);
+
+ this.led.css({width: size, height: size});
+
+ if (this.showTitle) {
+ this.setFontSize(this.ledTitle, this.title, this.ledTitleContainer.height() * 2 / 3, this.ledTitleContainer.width());
+ }
+ this.setFontSize(this.ledError, this.error, this.ledErrorContainer.height(), this.ledErrorContainer.width());
+ }
+
+ private setFontSize(element: JQuery, text: string, fontSize: number, maxWidth: number) {
+ let textWidth = this.measureTextWidth(text, fontSize);
+ while (textWidth > maxWidth) {
+ fontSize--;
+ textWidth = this.measureTextWidth(text, fontSize);
+ }
+ element.css({fontSize: fontSize+'px', lineHeight: fontSize+'px'});
+ }
+
+ private measureTextWidth(text: string, fontSize: number): number {
+ this.textMeasure.css({fontSize: fontSize+'px', lineHeight: fontSize+'px'});
+ this.textMeasure.text(text);
+ return this.textMeasure.width();
+ }
+
+ private onError(error: string) {
+ this.error = error;
+ this.setFontSize(this.ledError, this.error, this.ledErrorContainer.height(), this.ledErrorContainer.width());
+ this.ctx.detectChanges();
+ }
+
+ private setValue(value: boolean, forceUpdate?: boolean) {
+ if (this.value !== value || forceUpdate) {
+ this.value = value;
+ this.updateColor();
+ }
+ }
+
+ private updateColor() {
+ const color = this.value ? this.ledColor : this.disabledColor;
+ const middleColor = this.value ? this.ledMiddleColor : this.disabledMiddleColor;
+ const boxShadow = `#000 0 -1px 6px 1px, inset ${middleColor} 0 -1px 8px, ${color} 0 3px 11px`;
+ this.led.css({backgroundColor: color});
+ this.led.css({boxShadow});
+ if (this.value) {
+ this.led.removeClass( 'disabled' );
+ } else {
+ this.led.addClass( 'disabled' );
+ }
+ }
+
+ private rpcCheckStatus() {
+ if (this.destroyed) {
+ return;
+ }
+ this.error = '';
+ this.ctx.controlApi.sendTwoWayCommand(this.checkStatusMethod, null, this.requestTimeout).subscribe(
+ (responseBody) => {
+ const status = !!responseBody;
+ if (status) {
+ if (this.checkStatusTimeoutHandle) {
+ clearTimeout(this.checkStatusTimeoutHandle);
+ this.checkStatusTimeoutHandle = null;
+ }
+ this.subscribeForValue();
+ } else {
+ const errorText = 'Unknown device status!';
+ this.onError(errorText);
+ if (this.checkStatusTimeoutHandle) {
+ clearTimeout(this.checkStatusTimeoutHandle);
+ }
+ this.checkStatusTimeoutHandle = setTimeout(this.rpcCheckStatus.bind(this), checkStatusPollingInterval);
+ }
+ },
+ () => {
+ const errorText = this.ctx.defaultSubscription.rpcErrorText;
+ this.onError(errorText);
+ if (this.checkStatusTimeoutHandle) {
+ clearTimeout(this.checkStatusTimeoutHandle);
+ }
+ this.checkStatusTimeoutHandle = setTimeout(this.rpcCheckStatus.bind(this), checkStatusPollingInterval);
+ }
+ );
+ }
+
+ private subscribeForValue() {
+ const subscriptionsInfo: SubscriptionInfo[] = [{
+ type: DatasourceType.entity,
+ entityType: EntityType.DEVICE,
+ entityId: this.ctx.defaultSubscription.targetDeviceId
+ }];
+
+ if (this.retrieveValueMethod === 'attribute') {
+ subscriptionsInfo[0].attributes = [
+ {name: this.valueAttribute}
+ ];
+ } else {
+ subscriptionsInfo[0].timeseries = [
+ {name: this.valueAttribute}
+ ];
+ }
+
+ this.ctx.subscriptionApi.createSubscriptionFromInfo (
+ widgetType.latest, subscriptionsInfo, this.subscriptionOptions, false, true).subscribe(
+ (subscription) => {
+ this.subscription = subscription;
+ }
+ );
+ }
+
+ private onDataUpdated (subscription: IWidgetSubscription, detectChanges: boolean) {
+ let value = false;
+ const data = subscription.data;
+ if (data.length) {
+ const keyData = data[0];
+ if (keyData && keyData.data && keyData.data[0]) {
+ const attrValue = keyData.data[0][1];
+ if (attrValue) {
+ let parsed = null;
+ try {
+ parsed = this.parseValueFunction(JSON.parse(attrValue));
+ } catch (e){/**/}
+ value = !!parsed;
+ }
+ }
+ }
+ this.setValue(value);
+ if (detectChanges) {
+ this.ctx.detectChanges();
+ }
+ }
+
+ private onDataUpdateError(subscription: IWidgetSubscription, e: any) {
+ const exceptionData = this.utils.parseException(e);
+ let errorText = exceptionData.name;
+ if (exceptionData.message) {
+ errorText += ': ' + exceptionData.message;
+ }
+ this.onError(errorText);
+ }
+
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.html
new file mode 100644
index 0000000000..9ffe802151
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.html
@@ -0,0 +1,38 @@
+
+
+
+ {{title}}
+
+
+
+ {{ error }}
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.scss
new file mode 100644
index 0000000000..99cbe764eb
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.scss
@@ -0,0 +1,198 @@
+/**
+ * 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 "~compass-sass-mixins/lib/compass";
+
+$error-height: 14px !default;
+
+$background-color: #e6e7e8 !default;
+
+:host {
+ .tb-round-switch {
+ width: 100%;
+ height: 100%;
+ background: $background-color;
+
+ .title-container {
+ .switch-title {
+ font-weight: 500;
+ color: #757575;
+ white-space: nowrap;
+ }
+ }
+
+ .error-container {
+ position: absolute;
+ top: 1%;
+ right: 0;
+ left: 0;
+ z-index: 4;
+ height: $error-height;
+
+ .switch-error {
+ color: #ff3315;
+ white-space: nowrap;
+ }
+ }
+
+ #text-measure {
+ position: absolute;
+ width: auto;
+ height: auto;
+ white-space: nowrap;
+ visibility: hidden;
+ }
+
+ #switch-container {
+ padding: 10px;
+
+ .switch {
+ position: relative;
+
+ box-sizing: border-box;
+ width: 260px;
+ min-width: 260px;
+ height: 260px;
+ min-height: 260px;
+ padding: 25px;
+ font-family: sans-serif;
+ font-size: 48px;
+
+ color: #424242;
+ cursor: pointer;
+ background: #ddd;
+ background: linear-gradient(180deg, #bbb, #ddd);
+ border-radius: 130px;
+
+ box-shadow: 0 0 0 8px rgba(0, 0, 0, .1),
+ 0 0 3px 1px rgba(0, 0, 0, .1),
+ inset 0 8px 3px -8px rgba(255, 255, 255, .4);
+
+ input {
+ display: none;
+ }
+
+ .on,
+ .off {
+ position: absolute;
+ width: 100%;
+ text-align: center;
+
+ text-shadow: 1px 1px 4px #4a4a4a;
+ }
+
+ .on {
+ top: 10px;
+ font-family: sans-serif;
+ color: #444;
+
+ transition: all .1s;
+ }
+
+ .off {
+ bottom: 5px;
+
+ transition: all .1s;
+
+ transform: scaleY(.85);
+ }
+
+ .but {
+ position: relative;
+ display: block;
+ width: 200px;
+ height: 178px;
+ font-size: 48px;
+ cursor: pointer;
+ background-color: #d8d8d8;
+ border-bottom-width: 0;
+ border-radius: 400px 400px 400px 400px / 400px 400px 300px 300px;
+
+ box-shadow: inset 8px 6px 5px -7px #a2a2a2,
+ inset -8px 6px 5px -7px #a2a2a2,
+ inset 0 -3px 2px -2px rgba(200, 200, 200, .5),
+ 0 3px 3px -2px #fff,
+ inset 0 -230px 60px -200px rgba(255, 255, 255, .2),
+ inset 0 220px 40px -200px rgba(0, 0, 0, .3);
+
+ transition: all .2s;
+ }
+
+ .back {
+
+ box-sizing: border-box;
+ width: 210px;
+ height: 210px;
+ padding: 4px 4px;
+ cursor: pointer;
+ background-color: #888787;
+ background-image: linear-gradient(-90deg, transparent 30%, transparent 70%), linear-gradient(0deg, rgba(150, 150, 150, 0) 30%, rgba(150, 150, 150, .2) 50%, rgba(150, 150, 150, 0) 70%);
+ border-radius: 105px;
+
+ box-shadow: 30px 30px 30px -20px rgba(58, 58, 58, .3),
+ -30px 30px 30px -20px rgba(58, 58, 58, .3),
+ 0 30px 30px 0 rgba(16, 16, 16, .3),
+ inset 0 -1px 0 0 #484848;
+
+ transition: all .2s;
+ }
+
+
+ input:checked + .back .on,
+ input:checked + .back .off {
+ text-shadow: 1px 1px 4px #4a4a4a;
+ }
+
+ input:checked + .back .on {
+ top: 10px;
+ color: #4c4c4c;
+
+ transform: scaleY(.85);
+ }
+
+ input:checked + .back .off {
+ bottom: 5px;
+ color: #444;
+
+ transform: scaleY(1);
+ }
+
+ input:checked + .back .but {
+ margin-top: 20px;
+ background: #dcdcdc radial-gradient(circle closest-corner at 50% 15%, rgba(0, 0, 0, .3), transparent);
+ border-radius: 400px 400px 400px 400px / 300px 300px 400px 400px;
+
+ box-shadow: inset 8px -4px 5px -7px #a9a9a9,
+ inset -8px -4px 5px -7px #808080,
+ 0 -3px 8px -4px rgba(50, 50, 50, .4),
+ inset 0 3px 4px -2px #9c9c9c,
+ inset 0 280px 40px -200px rgba(0, 0, 0, .2),
+ inset 0 -200px 40px -200px rgba(180, 180, 180, .2);
+ }
+
+ input:checked + .back {
+ padding: 2px 4px;
+
+ background-image: linear-gradient(0deg, #868686 30%, transparent 70%), linear-gradient(90deg, rgba(115, 115, 115, 0) 0%, rgba(255, 255, 255, .74) 50%, rgba(105, 105, 105, 0) 100%);
+
+ box-shadow: 30px 30px 30px -20px rgba(49, 49, 49, .1),
+ -30px 30px 30px -20px rgba(111, 111, 111, .1),
+ 0 30px 30px 0 rgba(0, 0, 0, .2),
+ inset 0 1px 2px 0 rgba(167, 167, 167, .6);
+ }
+ }
+ }
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.ts
new file mode 100644
index 0000000000..3be714fd2b
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.ts
@@ -0,0 +1,338 @@
+///
+/// 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 { AfterViewInit, Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core';
+import { PageComponent } from '@shared/components/page.component';
+import { WidgetContext } from '@home/models/widget-component.models';
+import { UtilsService } from '@core/services/utils.service';
+import { Store } from '@ngrx/store';
+import { AppState } from '@core/core.state';
+import { isDefined } from '@core/utils';
+import { IWidgetSubscription, SubscriptionInfo, WidgetSubscriptionOptions } from '@core/api/widget-api.models';
+import { DatasourceType, widgetType } from '@shared/models/widget.models';
+import { EntityType } from '@shared/models/entity-type.models';
+
+type RetrieveValueMethod = 'rpc' | 'attribute' | 'timeseries';
+
+interface RoundSwitchSettings {
+ initialValue: boolean;
+ title: string;
+ retrieveValueMethod: RetrieveValueMethod;
+ valueKey: string;
+ getValueMethod: string;
+ setValueMethod: string;
+ parseValueFunction: string;
+ convertValueFunction: string;
+ requestTimeout: number;
+}
+
+@Component({
+ selector: 'tb-round-switch',
+ templateUrl: './round-switch.component.html',
+ styleUrls: ['./round-switch.component.scss']
+})
+export class RoundSwitchComponent extends PageComponent implements OnInit, OnDestroy {
+
+ @ViewChild('switch', {static: true}) switchElementRef: ElementRef;
+ @ViewChild('switchContainer', {static: true}) switchContainerRef: ElementRef;
+ @ViewChild('onoff', {static: true}) onoffRef: ElementRef;
+ @ViewChild('textMeasure', {static: true}) textMeasureRef: ElementRef;
+ @ViewChild('switchTitleContainer', {static: true}) switchTitleContainerRef: ElementRef;
+ @ViewChild('switchTitle', {static: true}) switchTitleRef: ElementRef;
+ @ViewChild('switchErrorContainer', {static: true}) switchErrorContainerRef: ElementRef;
+ @ViewChild('switchError', {static: true}) switchErrorRef: ElementRef;
+
+ @Input()
+ ctx: WidgetContext;
+
+ showTitle = false;
+ value = false;
+ error = '';
+ title = '';
+ checkboxId = 'onoff-' + this.utils.guid();
+
+ private isSimulated: boolean;
+ private requestTimeout: number;
+ private retrieveValueMethod: RetrieveValueMethod;
+ private valueKey: string;
+ private parseValueFunction: (data: any) => boolean;
+ private convertValueFunction: (value: any) => any;
+ private getValueMethod: string;
+ private setValueMethod: string;
+
+ private valueSubscription: IWidgetSubscription;
+
+ private executingUpdateValue: boolean;
+ private scheduledValue: boolean;
+ private rpcValue: boolean;
+
+ private switchElement: JQuery;
+ private switchContainer: JQuery;
+ private onoff: JQuery;
+ private textMeasure: JQuery;
+ private switchTitleContainer: JQuery;
+ private switchTitle: JQuery;
+ private switchErrorContainer: JQuery;
+ private switchError: JQuery;
+
+ private switchResizeListener: any;
+
+ constructor(private utils: UtilsService,
+ protected store: Store) {
+ super(store);
+ }
+
+ ngOnInit(): void {
+ this.switchElement = $(this.switchElementRef.nativeElement);
+ this.switchContainer = $(this.switchContainerRef.nativeElement);
+ this.onoff = $(this.onoffRef.nativeElement);
+ this.textMeasure = $(this.textMeasureRef.nativeElement);
+ this.switchTitleContainer = $(this.switchTitleContainerRef.nativeElement);
+ this.switchTitle = $(this.switchTitleRef.nativeElement);
+ this.switchErrorContainer = $(this.switchErrorContainerRef.nativeElement);
+ this.switchError = $(this.switchErrorRef.nativeElement);
+
+ this.onoff.on('change', () => {
+ this.value = this.onoff.prop('checked') === false;
+ this.onValue();
+ });
+
+ this.switchResizeListener = this.resize.bind(this);
+ // @ts-ignore
+ addResizeListener(this.switchContainerRef.nativeElement, this.switchResizeListener);
+ this.init();
+ // this.ctx.resize = this.resize.bind(this);
+ }
+
+ ngOnDestroy(): void {
+ if (this.valueSubscription) {
+ this.ctx.subscriptionApi.removeSubscription(this.valueSubscription.id);
+ }
+ if (this.switchResizeListener) {
+ // @ts-ignore
+ removeResizeListener(this.switchContainerRef.nativeElement, this.switchResizeListener);
+ }
+ }
+
+ private init() {
+ const settings: RoundSwitchSettings = this.ctx.settings;
+ this.title = isDefined(settings.title) ? settings.title : '';
+ this.showTitle = !!(this.title && this.title.length);
+ const initialValue = isDefined(settings.initialValue) ? settings.initialValue : false;
+ this.setValue(initialValue);
+
+ const subscription = this.ctx.defaultSubscription;
+ const rpcEnabled = subscription.rpcEnabled;
+
+ this.isSimulated = this.utils.widgetEditMode;
+
+ this.requestTimeout = 500;
+ if (settings.requestTimeout) {
+ this.requestTimeout = settings.requestTimeout;
+ }
+ this.retrieveValueMethod = 'rpc';
+ if (settings.retrieveValueMethod && settings.retrieveValueMethod.length) {
+ this.retrieveValueMethod = settings.retrieveValueMethod;
+ }
+ this.valueKey = 'value';
+ if (settings.valueKey && settings.valueKey.length) {
+ this.valueKey = settings.valueKey;
+ }
+ this.parseValueFunction = (data) => !!data;
+ if (settings.parseValueFunction && settings.parseValueFunction.length) {
+ try {
+ this.parseValueFunction = new Function('data', settings.parseValueFunction) as (data: any) => boolean;
+ } catch (e) {
+ this.parseValueFunction = (data) => !!data;
+ }
+ }
+ this.convertValueFunction = (value) => value;
+ if (settings.convertValueFunction && settings.convertValueFunction.length) {
+ try {
+ this.convertValueFunction = new Function('value', settings.convertValueFunction) as (value: any) => any;
+ } catch (e) {
+ this.convertValueFunction = (value) => value;
+ }
+ }
+ this.getValueMethod = 'getValue';
+ if (settings.getValueMethod && settings.getValueMethod.length) {
+ this.getValueMethod = settings.getValueMethod;
+ }
+ this.setValueMethod = 'setValue';
+ if (settings.setValueMethod && settings.setValueMethod.length) {
+ this.setValueMethod = settings.setValueMethod;
+ }
+ if (!rpcEnabled) {
+ this.onError('Target device is not set!');
+ } else {
+ if (!this.isSimulated) {
+ if (this.retrieveValueMethod === 'rpc') {
+ this.rpcRequestValue();
+ } else if (this.retrieveValueMethod === 'attribute' || this.retrieveValueMethod === 'timeseries') {
+ this.subscribeForValue();
+ }
+ }
+ }
+
+ }
+
+ private resize() {
+ const width = this.switchContainer.width();
+ const height = this.switchContainer.height();
+ const size = Math.min(width, height);
+ const scale = size/260;
+ this.switchElement.css({
+ '-webkit-transform': `scale(${scale})`,
+ '-moz-transform': `scale(${scale})`,
+ '-ms-transform': `scale(${scale})`,
+ '-o-transform': `scale(${scale})`,
+ transform: `scale(${scale})`
+ });
+ if (this.showTitle) {
+ this.setFontSize(this.switchTitle, this.title, this.switchTitleContainer.height() * 2 / 3, this.switchTitleContainer.width());
+ }
+ this.setFontSize(this.switchError, this.error, this.switchErrorContainer.height(), this.switchErrorContainer.width());
+ }
+
+ private setFontSize(element: JQuery, text: string, fontSize: number, maxWidth: number) {
+ let textWidth = this.measureTextWidth(text, fontSize);
+ while (textWidth > maxWidth) {
+ fontSize--;
+ textWidth = this.measureTextWidth(text, fontSize);
+ }
+ element.css({fontSize: fontSize+'px', lineHeight: fontSize+'px'});
+ }
+
+ private measureTextWidth(text: string, fontSize: number): number {
+ this.textMeasure.css({fontSize: fontSize+'px', lineHeight: fontSize+'px'});
+ this.textMeasure.text(text);
+ return this.textMeasure.width();
+ }
+
+ private onError(error: string) {
+ this.error = error;
+ this.setFontSize(this.switchError, this.error, this.switchErrorContainer.height(), this.switchErrorContainer.width());
+ this.ctx.detectChanges();
+ }
+
+ private setValue(value: boolean) {
+ this.value = value ? true : false;
+ this.onoff.prop('checked', !this.value);
+ }
+
+ private onValue() {
+ this.rpcUpdateValue(this.value);
+ }
+
+ private rpcRequestValue() {
+ this.error = '';
+ this.ctx.controlApi.sendTwoWayCommand(this.getValueMethod, null, this.requestTimeout).subscribe(
+ (responseBody) => {
+ this.setValue(this.parseValueFunction(responseBody));
+ },
+ () => {
+ const errorText = this.ctx.defaultSubscription.rpcErrorText;
+ this.onError(errorText);
+ }
+ );
+ }
+
+ private rpcUpdateValue(value) {
+ if (this.executingUpdateValue) {
+ this.scheduledValue = value;
+ return;
+ } else {
+ this.scheduledValue = null;
+ this.rpcValue = value;
+ this.executingUpdateValue = true;
+ }
+ this.error = '';
+ this.ctx.controlApi.sendOneWayCommand(this.setValueMethod, this.convertValueFunction(value), this.requestTimeout).subscribe(
+ () => {
+ this.executingUpdateValue = false;
+ if (this.scheduledValue != null && this.scheduledValue !== this.rpcValue) {
+ this.rpcUpdateValue(this.scheduledValue);
+ }
+ },
+ () => {
+ this.executingUpdateValue = false;
+ const errorText = this.ctx.defaultSubscription.rpcErrorText;
+ this.onError(errorText);
+ }
+ );
+ }
+
+ private subscribeForValue() {
+ const valueSubscriptionInfo: SubscriptionInfo[] = [{
+ type: DatasourceType.entity,
+ entityType: EntityType.DEVICE,
+ entityId: this.ctx.defaultSubscription.targetDeviceId
+ }];
+ if (this.retrieveValueMethod === 'attribute') {
+ valueSubscriptionInfo[0].attributes = [
+ {name: this.valueKey}
+ ];
+ } else {
+ valueSubscriptionInfo[0].timeseries = [
+ {name: this.valueKey}
+ ];
+ }
+ const subscriptionOptions: WidgetSubscriptionOptions = {
+ callbacks: {
+ onDataUpdated: this.onDataUpdated.bind(this),
+ onDataUpdateError: this.onDataUpdateError.bind(this)
+ }
+ };
+ this.ctx.subscriptionApi.createSubscriptionFromInfo (
+ widgetType.latest, valueSubscriptionInfo, subscriptionOptions, false, true).subscribe(
+ (subscription) => {
+ this.valueSubscription = subscription;
+ }
+ );
+ }
+
+ private onDataUpdated(subscription: IWidgetSubscription, detectChanges: boolean) {
+ let value = false;
+ const data = subscription.data;
+ if (data.length) {
+ const keyData = data[0];
+ if (keyData && keyData.data && keyData.data[0]) {
+ const attrValue = keyData.data[0][1];
+ if (attrValue) {
+ let parsed = null;
+ try {
+ parsed = this.parseValueFunction(JSON.parse(attrValue));
+ } catch (e){/**/}
+ value = !!parsed;
+ }
+ }
+ }
+ this.setValue(value);
+ if (detectChanges) {
+ this.ctx.detectChanges();
+ }
+ }
+
+ private onDataUpdateError(subscription: IWidgetSubscription, e: any) {
+ const exceptionData = this.utils.parseException(e);
+ let errorText = exceptionData.name;
+ if (exceptionData.message) {
+ errorText += ': ' + exceptionData.message;
+ }
+ this.onError(errorText);
+ }
+
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/rpc-widgets.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/rpc-widgets.module.ts
new file mode 100644
index 0000000000..3d50ef8b81
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/rpc-widgets.module.ts
@@ -0,0 +1,44 @@
+///
+/// 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 { NgModule } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { SharedModule } from '@app/shared/shared.module';
+import { LedIndicatorComponent } from '@home/components/widget/lib/rpc/led-indicator.component';
+import { RoundSwitchComponent } from '@home/components/widget/lib/rpc/round-switch.component';
+import { SwitchComponent } from '@home/components/widget/lib/rpc/switch.component';
+import { KnobComponent } from '@home/components/widget/lib/rpc/knob.component';
+
+@NgModule({
+ declarations:
+ [
+ LedIndicatorComponent,
+ RoundSwitchComponent,
+ SwitchComponent,
+ KnobComponent
+ ],
+ imports: [
+ CommonModule,
+ SharedModule
+ ],
+ exports: [
+ LedIndicatorComponent,
+ RoundSwitchComponent,
+ SwitchComponent,
+ KnobComponent
+ ]
+})
+export class RpcWidgetsModule { }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/knob.svg b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/knob.svg
new file mode 100644
index 0000000000..81fe9949b7
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/knob.svg
@@ -0,0 +1,36 @@
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/thumb-bar-checked.svg b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/thumb-bar-checked.svg
new file mode 100644
index 0000000000..57fa2b423e
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/thumb-bar-checked.svg
@@ -0,0 +1,43 @@
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/thumb-bar.svg b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/thumb-bar.svg
new file mode 100644
index 0000000000..0b952933a4
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/thumb-bar.svg
@@ -0,0 +1,42 @@
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/thumb-checked.svg b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/thumb-checked.svg
new file mode 100644
index 0000000000..6d59b94736
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/thumb-checked.svg
@@ -0,0 +1,40 @@
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/thumb.svg b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/thumb.svg
new file mode 100644
index 0000000000..03e9ea0f77
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/svg/thumb.svg
@@ -0,0 +1,37 @@
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.html
new file mode 100644
index 0000000000..29747503d9
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.html
@@ -0,0 +1,39 @@
+
+
+
+ {{ error }}
+
+
+ {{title}}
+
+
+
+
+
+
+
+ OFF
+
+ ON
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.scss
new file mode 100644
index 0000000000..72edb278e8
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.scss
@@ -0,0 +1,147 @@
+/**
+ * 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.
+ */
+
+
+$thumb-img: url("./svg/thumb.svg") !default;
+$thumb-checked-img: url("./svg/thumb-checked.svg") !default;
+$thumb-bar-img: url("./svg/thumb-bar.svg") !default;
+$thumb-bar-checked-img: url("./svg/thumb-bar-checked.svg") !default;
+
+$background-color: #e6e7e8 !default;
+
+$error-height: 14px !default;
+
+:host {
+
+ .tb-switch {
+ width: 100%;
+ height: 100%;
+ background: $background-color;
+
+ .error-container {
+ position: absolute;
+ top: 1%;
+ right: 0;
+ left: 0;
+ z-index: 4;
+ height: $error-height;
+
+ .switch-error {
+ color: #ff3315;
+ white-space: nowrap;
+ }
+ }
+
+ .onoff-container {
+ height: 100%;
+ font-weight: 500;
+ color: #757575;
+ white-space: nowrap;
+
+ .off-label {
+ color: #b7b5b5;
+ }
+
+ .on-label {
+ color: #ff7e57;
+ text-shadow: #ff6e4a 1px 1px 10px, #ffd1c3 1px 1px 10px;
+ }
+ }
+
+ .title-container {
+ .switch-title {
+ font-weight: 500;
+ color: #757575;
+ white-space: nowrap;
+ }
+ }
+
+ #switch-container {
+ padding-right: 10px;
+ padding-left: 10px;
+ }
+
+ .switch {
+ position: relative;
+ }
+
+ #text-measure {
+ position: absolute;
+ width: auto;
+ height: auto;
+ white-space: nowrap;
+ visibility: hidden;
+ }
+ }
+}
+
+:host ::ng-deep {
+ .tb-switch {
+ .switch {
+ mat-slide-toggle {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ margin: 0;
+
+ .mat-slide-toggle-bar {
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: $thumb-bar-img no-repeat;
+ background-size: contain;
+ border-radius: 0;
+ }
+
+ .mat-slide-toggle-thumb-container {
+ top: 5%;
+ left: .25%;
+ width: 50%;
+ height: 90%;
+ }
+
+ .mat-slide-toggle-thumb {
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: $thumb-img no-repeat;
+ background-size: contain;
+ border-radius: 0;
+ box-shadow: none;
+ }
+
+ &.mat-checked {
+ .mat-slide-toggle-thumb-container {
+ transform: translate3d(100%,0,0);
+ }
+ .mat-slide-toggle-bar {
+ background: $thumb-bar-checked-img no-repeat;
+ background-size: contain;
+ }
+
+ .mat-slide-toggle-thumb {
+ background: $thumb-checked-img no-repeat;
+ background-size: contain;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.ts
new file mode 100644
index 0000000000..b91e32bf4d
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.ts
@@ -0,0 +1,354 @@
+///
+/// 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 { Component, ElementRef, Input, OnDestroy, OnInit, ViewChild } from '@angular/core';
+import { PageComponent } from '@shared/components/page.component';
+import { WidgetContext } from '@home/models/widget-component.models';
+import { UtilsService } from '@core/services/utils.service';
+import { Store } from '@ngrx/store';
+import { AppState } from '@core/core.state';
+import { isDefined } from '@core/utils';
+import { IWidgetSubscription, SubscriptionInfo, WidgetSubscriptionOptions } from '@core/api/widget-api.models';
+import { DatasourceType, widgetType } from '@shared/models/widget.models';
+import { EntityType } from '@shared/models/entity-type.models';
+import { MatSlideToggle } from '@angular/material/slide-toggle';
+
+const switchAspectRation = 2.7893;
+
+type RetrieveValueMethod = 'rpc' | 'attribute' | 'timeseries';
+
+interface SwitchSettings {
+ initialValue: boolean;
+ title: string;
+ showOnOffLabels: boolean;
+ retrieveValueMethod: RetrieveValueMethod;
+ valueKey: string;
+ getValueMethod: string;
+ setValueMethod: string;
+ parseValueFunction: string;
+ convertValueFunction: string;
+ requestTimeout: number;
+}
+
+@Component({
+ selector: 'tb-switch',
+ templateUrl: './switch.component.html',
+ styleUrls: ['./switch.component.scss']
+})
+export class SwitchComponent extends PageComponent implements OnInit, OnDestroy {
+
+ @ViewChild('switch', {static: true}) switchElementRef: ElementRef;
+ @ViewChild('switchContainer', {static: true}) switchContainerRef: ElementRef;
+ @ViewChild('matSlideToggle', {static: true}) matSlideToggleRef: MatSlideToggle;
+ @ViewChild('onoffContainer', {static: true}) onoffContainerRef: ElementRef;
+ @ViewChild('onLabel', {static: true}) onLabelRef: ElementRef;
+ @ViewChild('offLabel', {static: true}) offLabelRef: ElementRef;
+ @ViewChild('switchTitleContainer', {static: true}) switchTitleContainerRef: ElementRef;
+ @ViewChild('switchTitle', {static: true}) switchTitleRef: ElementRef;
+ @ViewChild('textMeasure', {static: true}) textMeasureRef: ElementRef;
+ @ViewChild('switchErrorContainer', {static: true}) switchErrorContainerRef: ElementRef;
+ @ViewChild('switchError', {static: true}) switchErrorRef: ElementRef;
+
+ @Input()
+ ctx: WidgetContext;
+
+ showTitle = false;
+ value = false;
+ error = '';
+ title = '';
+ showOnOffLabels = false;
+
+ private isSimulated: boolean;
+ private requestTimeout: number;
+ private retrieveValueMethod: RetrieveValueMethod;
+ private valueKey: string;
+ private parseValueFunction: (data: any) => boolean;
+ private convertValueFunction: (value: any) => any;
+ private getValueMethod: string;
+ private setValueMethod: string;
+
+ private valueSubscription: IWidgetSubscription;
+
+ private executingUpdateValue: boolean;
+ private scheduledValue: boolean;
+ private rpcValue: boolean;
+
+ private switchElement: JQuery;
+ private switchContainer: JQuery;
+ private matSlideToggle: JQuery;
+ private onoffContainer: JQuery;
+ private onLabel: JQuery;
+ private offLabel: JQuery;
+ private switchTitleContainer: JQuery;
+ private switchTitle: JQuery;
+ private textMeasure: JQuery;
+ private switchErrorContainer: JQuery;
+ private switchError: JQuery;
+
+ private switchResizeListener: any;
+
+ constructor(private utils: UtilsService,
+ protected store: Store) {
+ super(store);
+ }
+
+ ngOnInit(): void {
+ this.switchElement = $(this.switchElementRef.nativeElement);
+ this.switchContainer = $(this.switchContainerRef.nativeElement);
+ this.matSlideToggle = $(this.matSlideToggleRef._elementRef.nativeElement);
+ this.onoffContainer = $(this.onoffContainerRef.nativeElement);
+ this.onLabel = $(this.onLabelRef.nativeElement);
+ this.offLabel = $(this.offLabelRef.nativeElement);
+ this.switchTitleContainer = $(this.switchTitleContainerRef.nativeElement);
+ this.switchTitle = $(this.switchTitleRef.nativeElement);
+ this.textMeasure = $(this.textMeasureRef.nativeElement);
+ this.switchErrorContainer = $(this.switchErrorContainerRef.nativeElement);
+ this.switchError = $(this.switchErrorRef.nativeElement);
+
+ this.switchResizeListener = this.resize.bind(this);
+ // @ts-ignore
+ addResizeListener(this.switchContainerRef.nativeElement, this.switchResizeListener);
+ this.init();
+ }
+
+ ngOnDestroy(): void {
+ if (this.valueSubscription) {
+ this.ctx.subscriptionApi.removeSubscription(this.valueSubscription.id);
+ }
+ if (this.switchResizeListener) {
+ // @ts-ignore
+ removeResizeListener(this.switchContainerRef.nativeElement, this.switchResizeListener);
+ }
+ }
+
+ private init() {
+ const settings: SwitchSettings = this.ctx.settings;
+ this.title = isDefined(settings.title) ? settings.title : '';
+ this.showTitle = !!(this.title && this.title.length);
+ this.showOnOffLabels = isDefined(settings.showOnOffLabels) ? settings.showOnOffLabels : true;
+ const initialValue = isDefined(settings.initialValue) ? settings.initialValue : false;
+ this.setValue(initialValue);
+
+ const subscription = this.ctx.defaultSubscription;
+ const rpcEnabled = subscription.rpcEnabled;
+
+ this.isSimulated = this.utils.widgetEditMode;
+
+ this.requestTimeout = 500;
+ if (settings.requestTimeout) {
+ this.requestTimeout = settings.requestTimeout;
+ }
+ this.retrieveValueMethod = 'rpc';
+ if (settings.retrieveValueMethod && settings.retrieveValueMethod.length) {
+ this.retrieveValueMethod = settings.retrieveValueMethod;
+ }
+ this.valueKey = 'value';
+ if (settings.valueKey && settings.valueKey.length) {
+ this.valueKey = settings.valueKey;
+ }
+ this.parseValueFunction = (data) => !!data;
+ if (settings.parseValueFunction && settings.parseValueFunction.length) {
+ try {
+ this.parseValueFunction = new Function('data', settings.parseValueFunction) as (data: any) => boolean;
+ } catch (e) {
+ this.parseValueFunction = (data) => !!data;
+ }
+ }
+ this.convertValueFunction = (value) => value;
+ if (settings.convertValueFunction && settings.convertValueFunction.length) {
+ try {
+ this.convertValueFunction = new Function('value', settings.convertValueFunction) as (value: any) => any;
+ } catch (e) {
+ this.convertValueFunction = (value) => value;
+ }
+ }
+ this.getValueMethod = 'getValue';
+ if (settings.getValueMethod && settings.getValueMethod.length) {
+ this.getValueMethod = settings.getValueMethod;
+ }
+ this.setValueMethod = 'setValue';
+ if (settings.setValueMethod && settings.setValueMethod.length) {
+ this.setValueMethod = settings.setValueMethod;
+ }
+ if (!rpcEnabled) {
+ this.onError('Target device is not set!');
+ } else {
+ if (!this.isSimulated) {
+ if (this.retrieveValueMethod === 'rpc') {
+ this.rpcRequestValue();
+ } else if (this.retrieveValueMethod === 'attribute' || this.retrieveValueMethod === 'timeseries') {
+ this.subscribeForValue();
+ }
+ }
+ }
+
+ }
+
+ private resize() {
+ let width = this.switchContainer.width();
+ let height = this.switchContainer.height();
+ if (this.showOnOffLabels) {
+ height = height*2/3;
+ }
+ const ratio = width/height;
+ if (ratio > switchAspectRation) {
+ width = height*switchAspectRation;
+ } else {
+ height = width/switchAspectRation;
+ }
+ this.switchElement.css({width, height});
+ this.matSlideToggle.css({width, height, minWidth: width});
+
+ if (this.showTitle) {
+ this.setFontSize(this.switchTitle, this.title, this.switchTitleContainer.height() * 2 / 3, this.switchTitleContainer.width());
+ }
+
+ if (this.showOnOffLabels) {
+ this.onoffContainer.css({width, height: this.switchContainer.height() / 3});
+ this.setFontSize(this.onLabel, 'OFF', this.onoffContainer.height(), this.onoffContainer.width() / 2);
+ this.setFontSize(this.offLabel, 'OFF', this.onoffContainer.height(), this.onoffContainer.width() / 2);
+ }
+
+ this.setFontSize(this.switchError, this.error, this.switchErrorContainer.height(), this.switchErrorContainer.width());
+ }
+
+ private setFontSize(element: JQuery, text: string, fontSize: number, maxWidth: number) {
+ let textWidth = this.measureTextWidth(text, fontSize);
+ while (textWidth > maxWidth) {
+ fontSize--;
+ textWidth = this.measureTextWidth(text, fontSize);
+ }
+ element.css({fontSize: fontSize+'px', lineHeight: fontSize+'px'});
+ }
+
+ private measureTextWidth(text: string, fontSize: number): number {
+ this.textMeasure.css({fontSize: fontSize+'px', lineHeight: fontSize+'px'});
+ this.textMeasure.text(text);
+ return this.textMeasure.width();
+ }
+
+ private onError(error: string) {
+ this.error = error;
+ this.setFontSize(this.switchError, this.error, this.switchErrorContainer.height(), this.switchErrorContainer.width());
+ this.ctx.detectChanges();
+ }
+
+ private setValue(value: boolean) {
+ this.value = value ? true : false;
+ }
+
+ public onValue() {
+ this.rpcUpdateValue(this.value);
+ }
+
+ private rpcRequestValue() {
+ this.error = '';
+ this.ctx.controlApi.sendTwoWayCommand(this.getValueMethod, null, this.requestTimeout).subscribe(
+ (responseBody) => {
+ this.setValue(this.parseValueFunction(responseBody));
+ },
+ () => {
+ const errorText = this.ctx.defaultSubscription.rpcErrorText;
+ this.onError(errorText);
+ }
+ );
+ }
+
+ private rpcUpdateValue(value) {
+ if (this.executingUpdateValue) {
+ this.scheduledValue = value;
+ return;
+ } else {
+ this.scheduledValue = null;
+ this.rpcValue = value;
+ this.executingUpdateValue = true;
+ }
+ this.error = '';
+ this.ctx.controlApi.sendOneWayCommand(this.setValueMethod, this.convertValueFunction(value), this.requestTimeout).subscribe(
+ () => {
+ this.executingUpdateValue = false;
+ if (this.scheduledValue != null && this.scheduledValue !== this.rpcValue) {
+ this.rpcUpdateValue(this.scheduledValue);
+ }
+ },
+ () => {
+ this.executingUpdateValue = false;
+ const errorText = this.ctx.defaultSubscription.rpcErrorText;
+ this.onError(errorText);
+ }
+ );
+ }
+
+ private subscribeForValue() {
+ const valueSubscriptionInfo: SubscriptionInfo[] = [{
+ type: DatasourceType.entity,
+ entityType: EntityType.DEVICE,
+ entityId: this.ctx.defaultSubscription.targetDeviceId
+ }];
+ if (this.retrieveValueMethod === 'attribute') {
+ valueSubscriptionInfo[0].attributes = [
+ {name: this.valueKey}
+ ];
+ } else {
+ valueSubscriptionInfo[0].timeseries = [
+ {name: this.valueKey}
+ ];
+ }
+ const subscriptionOptions: WidgetSubscriptionOptions = {
+ callbacks: {
+ onDataUpdated: this.onDataUpdated.bind(this),
+ onDataUpdateError: this.onDataUpdateError.bind(this)
+ }
+ };
+ this.ctx.subscriptionApi.createSubscriptionFromInfo (
+ widgetType.latest, valueSubscriptionInfo, subscriptionOptions, false, true).subscribe(
+ (subscription) => {
+ this.valueSubscription = subscription;
+ }
+ );
+ }
+
+ private onDataUpdated(subscription: IWidgetSubscription, detectChanges: boolean) {
+ let value = false;
+ const data = subscription.data;
+ if (data.length) {
+ const keyData = data[0];
+ if (keyData && keyData.data && keyData.data[0]) {
+ const attrValue = keyData.data[0][1];
+ if (attrValue) {
+ let parsed = null;
+ try {
+ parsed = this.parseValueFunction(JSON.parse(attrValue));
+ } catch (e){/**/}
+ value = !!parsed;
+ }
+ }
+ }
+ this.setValue(value);
+ if (detectChanges) {
+ this.ctx.detectChanges();
+ }
+ }
+
+ private onDataUpdateError(subscription: IWidgetSubscription, e: any) {
+ const exceptionData = this.utils.parseException(e);
+ let errorText = exceptionData.name;
+ if (exceptionData.message) {
+ errorText += ': ' + exceptionData.message;
+ }
+ this.onError(errorText);
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts
index 3817481d85..248c2b48b5 100644
--- a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts
+++ b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts
@@ -25,6 +25,7 @@ import { SharedHomeComponentsModule } from '@home/components/shared-home-compone
import { TimeseriesTableWidgetComponent } from '@home/components/widget/lib/timeseries-table-widget.component';
import { EntitiesHierarchyWidgetComponent } from '@home/components/widget/lib/entities-hierarchy-widget.component';
import { CustomDialogService } from '@home/components/widget/dialog/custom-dialog.service';
+import { RpcWidgetsModule } from '@home/components/widget/lib/rpc/rpc-widgets.module';
@NgModule({
declarations:
@@ -39,13 +40,15 @@ import { CustomDialogService } from '@home/components/widget/dialog/custom-dialo
imports: [
CommonModule,
SharedModule,
+ RpcWidgetsModule,
SharedHomeComponentsModule
],
exports: [
EntitiesTableWidgetComponent,
AlarmsTableWidgetComponent,
TimeseriesTableWidgetComponent,
- EntitiesHierarchyWidgetComponent
+ EntitiesHierarchyWidgetComponent,
+ RpcWidgetsModule
],
providers: [
CustomDialogService