diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts index 18b80f86a5..79af9bee18 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts @@ -129,6 +129,9 @@ import { import { RadarChartBasicConfigComponent } from '@home/components/widget/config/basic/chart/radar-chart-basic-config.component'; +import { + ScadaTestBasicConfigComponent +} from '@home/components/widget/config/basic/scada/scada-test-basic-config.component'; @NgModule({ declarations: [ @@ -171,7 +174,8 @@ import { PieChartBasicConfigComponent, BarChartBasicConfigComponent, PolarAreaChartBasicConfigComponent, - RadarChartBasicConfigComponent + RadarChartBasicConfigComponent, + ScadaTestBasicConfigComponent ], imports: [ CommonModule, @@ -216,7 +220,8 @@ import { PieChartBasicConfigComponent, BarChartBasicConfigComponent, PolarAreaChartBasicConfigComponent, - RadarChartBasicConfigComponent + RadarChartBasicConfigComponent, + ScadaTestBasicConfigComponent ] }) export class BasicWidgetConfigModule { @@ -255,5 +260,6 @@ export const basicWidgetConfigComponentsMap: {[key: string]: Type + + + + + diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/scada/scada-test-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/scada/scada-test-basic-config.component.ts new file mode 100644 index 0000000000..3d6a4a6a45 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/scada/scada-test-basic-config.component.ts @@ -0,0 +1,64 @@ +/// +/// Copyright © 2016-2024 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 } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { BasicWidgetConfigComponent } from '@home/components/widget/config/widget-config.component.models'; +import { WidgetConfigComponentData } from '@home/models/widget-component.models'; +import { TargetDevice, } from '@shared/models/widget.models'; +import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; +import { ScadaTestWidgetSettings } from '@home/components/widget/lib/scada/scada-test-widget.models'; + +@Component({ + selector: 'tb-scada-test-basic-config', + templateUrl: './scada-test-basic-config.component.html', + styleUrls: ['../basic-config.scss'] +}) +export class ScadaTestBasicConfigComponent extends BasicWidgetConfigComponent { + + get targetDevice(): TargetDevice { + return this.scadaTestWidgetConfigForm.get('targetDevice').value; + } + + scadaTestWidgetConfigForm: UntypedFormGroup; + + constructor(protected store: Store, + protected widgetConfigComponent: WidgetConfigComponent, + private fb: UntypedFormBuilder) { + super(store, widgetConfigComponent); + } + + protected configForm(): UntypedFormGroup { + return this.scadaTestWidgetConfigForm; + } + + protected onConfigSet(configData: WidgetConfigComponentData) { + const settings: ScadaTestWidgetSettings = {...(configData.config.settings as ScadaTestWidgetSettings || { scadaObject: {}})}; + this.scadaTestWidgetConfigForm = this.fb.group({ + targetDevice: [configData.config.targetDevice, []], + scadaObject: [settings.scadaObject, []] + }); + } + + protected prepareOutputConfig(config: any): WidgetConfigComponentData { + this.widgetConfig.config.targetDevice = config.targetDevice; + this.widgetConfig.config.settings = this.widgetConfig.config.settings || {}; + this.widgetConfig.config.settings.scadaObject = config.scadaObject; + return this.widgetConfig; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.component.html new file mode 100644 index 0000000000..f811e7f889 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.component.html @@ -0,0 +1,19 @@ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.component.scss new file mode 100644 index 0000000000..5012fe3a7c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.component.scss @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2024 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-scada-shape { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.component.ts new file mode 100644 index 0000000000..25451dcf15 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.component.ts @@ -0,0 +1,102 @@ +/// +/// Copyright © 2016-2024 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, + ChangeDetectorRef, + Component, + ElementRef, + OnDestroy, + OnInit, + Renderer2, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { BasicActionWidgetComponent } from '@home/components/widget/lib/action/action-widget.models'; +import { ImagePipe } from '@shared/pipe/image.pipe'; +import { DomSanitizer } from '@angular/platform-browser'; +import { SVG, Svg } from '@svgdotjs/svg.js'; +import { HttpClient } from '@angular/common/http'; +import { ScadaObject, ScadaObjectSettings } from '@home/components/widget/lib/scada/scada.models'; +import { ResizeObserver } from '@juggle/resize-observer'; +import { ScadaTestWidgetSettings } from '@home/components/widget/lib/scada/scada-test-widget.models'; + +@Component({ + selector: 'tb-scada-test-widget', + templateUrl: './scada-test-widget.component.html', + styleUrls: ['../action/action-widget.scss', './scada-test-widget.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class ScadaTestWidgetComponent extends + BasicActionWidgetComponent implements OnInit, AfterViewInit, OnDestroy { + + @ViewChild('scadaShape', {static: false}) + scadaShape: ElementRef; + + private settings: ScadaTestWidgetSettings; + + private autoScale = true; + private scadaObject: ScadaObject; + + private shapeResize$: ResizeObserver; + + constructor(protected imagePipe: ImagePipe, + protected sanitizer: DomSanitizer, + private renderer: Renderer2, + protected cd: ChangeDetectorRef, + private http: HttpClient) { + super(cd); + } + + ngOnInit(): void { + super.ngOnInit(); + this.settings = {...this.ctx.settings}; + this.scadaObject = new ScadaObject(this.ctx, '/assets/widget/scada/drawing.svg', this.settings.scadaObject); + this.scadaObject.init().subscribe(); + } + + ngAfterViewInit(): void { + this.scadaObject.addTo(this.scadaShape.nativeElement); + if (this.autoScale) { + this.shapeResize$ = new ResizeObserver(() => { + this.onResize(); + }); + this.shapeResize$.observe(this.scadaShape.nativeElement); + this.onResize(); + } + super.ngAfterViewInit(); + } + + ngOnDestroy() { + if (this.shapeResize$) { + this.shapeResize$.disconnect(); + } + if (this.scadaObject) { + this.scadaObject.destroy(); + } + super.ngOnDestroy(); + } + + public onInit() { + super.onInit(); + } + + private onResize() { + const shapeWidth = this.scadaShape.nativeElement.getBoundingClientRect().width; + const shapeHeight = this.scadaShape.nativeElement.getBoundingClientRect().height; + this.scadaObject.setSize(shapeWidth, shapeHeight); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.models.ts new file mode 100644 index 0000000000..a74d81e3d5 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-test-widget.models.ts @@ -0,0 +1,21 @@ +/// +/// Copyright © 2016-2024 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 { ScadaObjectSettings } from '@home/components/widget/lib/scada/scada.models'; + +export interface ScadaTestWidgetSettings { + scadaObject: ScadaObjectSettings; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada.models.ts new file mode 100644 index 0000000000..7088fe6131 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada.models.ts @@ -0,0 +1,398 @@ +/// +/// Copyright © 2016-2024 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 { ValueType } from '@shared/models/constants'; +import { Box, Element, Runner, Svg, SVG, Timeline } from '@svgdotjs/svg.js'; +import { DataToValueType, GetValueAction, GetValueSettings } from '@shared/models/action-widget-settings.models'; +import { insertVariable, isDefinedAndNotNull, isNumber, isNumeric, isUndefinedOrNull, mergeDeep } from '@core/utils'; +import { BehaviorSubject, forkJoin, Observable } from 'rxjs'; +import { map, share } from 'rxjs/operators'; +import { ValueAction, ValueGetter } from '@home/components/widget/lib/action/action-widget.models'; +import { WidgetContext } from '@home/models/widget-component.models'; + +export type ValueMatcherType = 'any' | 'constant' | 'range'; + +export interface ValueMatcher { + type: ValueMatcherType; + value?: any; + range?: {from: number; to: number}; +} + +export type ScadaObjectAttributeValueType = 'input' | 'property'; + +export interface ScadaObjectAttributeValue { + type: ScadaObjectAttributeValueType; + propertyId?: string; +} + +export interface ScadaObjectAttributeState { + name: string; + value: ScadaObjectAttributeValue; +} + +export interface ScadaObjectState { + tag: string; + attributes: ScadaObjectAttributeState[]; + animate?: number; +} + +export interface ScadaObjectUpdateState { + matcher: ValueMatcher; + state: ScadaObjectState[]; +} + +export enum ScadaObjectBehaviorType { + setValue = 'setValue', + getValue = 'getValue' +} + +export interface ScadaObjectBehaviorBase { + id: string; + name: string; + type: ScadaObjectBehaviorType; +} + +export interface ScadaObjectBehaviorGet extends ScadaObjectBehaviorBase { + valueType: ValueType; + defaultValue: any; + onUpdate: ScadaObjectUpdateState[]; +} + +export interface ScadaObjectBehaviorSet extends ScadaObjectBehaviorBase { + todo: any; +} + +export type ScadaObjectBehavior = ScadaObjectBehaviorGet | ScadaObjectBehaviorSet; + +export type ScadaObjectPropertyType = 'string' | 'number' | 'color'; + +export interface ScadaObjectPropertyBase { + id: string; + name: string; + type: ScadaObjectPropertyType; + default: any; +} + +export interface ScadaObjectNumberProperty extends ScadaObjectPropertyBase { + min?: number; + max?: number; +} + +export type ScadaObjectProperty = ScadaObjectPropertyBase & ScadaObjectNumberProperty; + +export interface ScadaObjectMetadata { + title: string; + initial: ScadaObjectState[]; + behavior: ScadaObjectBehavior[]; + properties: ScadaObjectProperty[]; +} + +export const emptyMetadata: ScadaObjectMetadata = { + title: '', + initial: [], + behavior: [], + properties: [] +}; + + +export const parseScadaObjectMetadataFromContent = (svgContent: string): ScadaObjectMetadata => { + try { + const svgDoc = new DOMParser().parseFromString(svgContent, 'image/svg+xml'); + return parseScadaObjectMetadataFromDom(svgDoc); + } catch (_e) { + return emptyMetadata; + } +}; + +const parseScadaObjectMetadataFromDom = (svgDoc: Document): ScadaObjectMetadata => { + try { + const elements = svgDoc.getElementsByTagName('tb:metadata'); + if (elements.length) { + return JSON.parse(elements[0].innerHTML); + } else { + return emptyMetadata; + } + } catch (_e) { + return emptyMetadata; + } +}; + +const defaultGetValueSettings = (get: ScadaObjectBehaviorGet): GetValueSettings => ({ + action: GetValueAction.DO_NOTHING, + defaultValue: get.defaultValue, + executeRpc: { + method: 'getState', + requestTimeout: 5000, + requestPersistent: false, + persistentPollingInterval: 1000 + }, + getAttribute: { + key: 'state', + scope: null + }, + getTimeSeries: { + key: 'state' + }, + dataToValue: { + type: DataToValueType.NONE, + compareToValue: true, + dataToValueFunction: '/* Should return boolean value */\nreturn data;' + } + }); + +export const defaultScadaObjectSettings = (metadata: ScadaObjectMetadata): ScadaObjectSettings => { + const settings: ScadaObjectSettings = {}; + for (const behaviour of metadata.behavior) { + //behaviour.id + if (behaviour.type === ScadaObjectBehaviorType.getValue) { + settings[behaviour.id] = defaultGetValueSettings(behaviour as ScadaObjectBehaviorGet); + } else if (behaviour.type === ScadaObjectBehaviorType.setValue) { + // TODO: + } + } + for (const property of metadata.properties) { + settings[property.id] = property.default; + } + return settings; +}; + +export type ScadaObjectSettings = {[id: string]: any}; + +export class ScadaObject { + + private metadata: ScadaObjectMetadata; + private settings: ScadaObjectSettings; + + private rootElement: HTMLElement; + private svgShape: Svg; + private box: Box; + private targetWidth: number; + private targetHeight: number; + + private loadingSubject = new BehaviorSubject(false); + private valueGetters: ValueGetter[] = []; + private valueActions: ValueAction[] = []; + + private animationTimeline: Timeline; + + loading$ = this.loadingSubject.asObservable().pipe(share()); + + constructor(private ctx: WidgetContext, + private svgPath: string, + private inputSettings: ScadaObjectSettings) {} + + public init(): Observable { + return this.ctx.http.get(this.svgPath, {responseType: 'text'}).pipe( + map((inputSvgContent) => { + const doc: XMLDocument = new DOMParser().parseFromString(inputSvgContent, 'image/svg+xml'); + this.metadata = parseScadaObjectMetadataFromDom(doc); + const defaults = defaultScadaObjectSettings(this.metadata); + this.settings = mergeDeep({}, defaults, this.inputSettings || {}); + this.prepareSvgShape(doc); + this.prepareStates(); + }) + ); + } + + public addTo(element: HTMLElement) { + this.rootElement = element; + if (this.svgShape) { + this.svgShape.addTo(element); + } + } + + public destroy() { + this.valueActions.forEach(v => v.destroy()); + this.loadingSubject.complete(); + this.loadingSubject.unsubscribe(); + } + + public setSize(targetWidth: number, targetHeight: number) { + this.targetWidth = targetWidth; + this.targetHeight = targetHeight; + if (this.svgShape) { + this.resize(); + } + } + + private prepareSvgShape(doc: XMLDocument) { + const elements = doc.getElementsByTagName('tb:metadata'); + for (let i=0;i = this.settings[getBehavior.id]; + getValueSettings = {...getValueSettings, actionLabel: getBehavior.name}; + const valueGetter = + ValueGetter.fromSettings(this.ctx, getValueSettings, getBehavior.valueType, { + next: (val) => {this.onValue(getBehavior.id, val);}, + error: (e) => {} + }); + this.valueGetters.push(valueGetter); + this.valueActions.push(valueGetter); + } + } + if (this.metadata.initial) { + this.updateState(this.metadata.initial); + } + if (this.valueGetters.length) { + const getValueObservables: Array> = []; + this.valueGetters.forEach(valueGetter => { + getValueObservables.push(valueGetter.getValue()); + }); + this.loadingSubject.next(true); + forkJoin(getValueObservables).subscribe( + { + next: () => { + this.loadingSubject.next(false); + }, + error: () => { + this.loadingSubject.next(false); + } + } + ); + } + } + + private resize() { + let scale: number; + if (this.targetWidth < this.targetHeight) { + scale = this.targetWidth / this.box.width; + } else { + scale = this.targetHeight / this.box.height; + } + this.svgShape.node.style.transform = `scale(${scale})`; + } + + private onValue(id: string, value: any) { + const getBehavior = this.metadata.behavior.find(b => b.id === id) as ScadaObjectBehaviorGet; + value = this.normalizeValue(value, getBehavior.valueType); + const updateStates = this.filterUpdateStates(getBehavior.onUpdate, value); + if (this.animationTimeline) { + this.animationTimeline.finish(); + } + for (const updateState of updateStates) { + this.updateState(updateState.state, value); + } + } + + private updateState(state: ScadaObjectState[], value?: any) { + for (const stateEntry of state) { + const tag = stateEntry.tag; + const elements = this.svgShape.find(`[tb\\:tag="${tag}"]`); + const attrs = this.computeAttributes(stateEntry.attributes, value); + elements.forEach(e => { + this.setElementAttributes(e, attrs, stateEntry.animate); + }); + } + } + + private normalizeValue(value: any, type: ValueType): any { + if (isUndefinedOrNull(value)) { + switch (type) { + case ValueType.STRING: + return ''; + case ValueType.INTEGER: + case ValueType.DOUBLE: + return 0; + case ValueType.BOOLEAN: + return false; + case ValueType.JSON: + return {}; + } + } else { + return value; + } + } + + private computeAttributes(attributes: ScadaObjectAttributeState[], value: any): {[attr: string]: any} { + const res: {[attr: string]: any} = {}; + for (const attribute of attributes) { + const attr = attribute.name; + res[attr] = this.getAttributeValue(attribute, value); + } + return res; + } + + private setElementAttributes(element: Element, attrs: {[attr: string]: any}, animate?: number) { + if (isDefinedAndNotNull(animate)) { + this.animation(element, animate).attr(attrs); + } else { + element.attr(attrs); + } + } + + private animation(element: Element, duration: number): Runner { + if (!this.animationTimeline) { + this.animationTimeline = new Timeline(); + } + element.timeline(this.animationTimeline); + return element.animate(duration, 0, 'now'); + } + + private getAttributeValue(attribute: ScadaObjectAttributeState, value: any): any { + if (attribute.value.type === 'input') { + return value; + } else if (attribute.value.type === 'property') { + const id = attribute.value.propertyId; + return this.settings[id] || ''; + } else { + return ''; + } + } + + private filterUpdateStates(states: ScadaObjectUpdateState[], val: any): ScadaObjectUpdateState[] { + return states.filter(s => this.valueMatches(s.matcher, val)); + } + + private valueMatches(matcher: ValueMatcher, val: any): boolean { + switch (matcher.type) { + case 'any': + return true; + case 'constant': + return matcher.value === val; + case 'range': + if (isDefinedAndNotNull(val) && isNumeric(val)) { + const num = Number(val); + const range = matcher.range; + return ((!isNumber(range.from) || num >= range.from) && (!isNumber(range.to) || num < range.to)); + } else { + return false; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.component.html new file mode 100644 index 0000000000..d5a8cba6df --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.component.html @@ -0,0 +1,48 @@ + + +
{{ metadata?.title }}
+
+
widgets.slider.behavior
+
+
{{ behaviour.name }}
+ + +
+
+
+
+
{{ property.name }}
+ + + + + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.component.ts new file mode 100644 index 0000000000..ad10d681a7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-object-settings.component.ts @@ -0,0 +1,168 @@ +/// +/// Copyright © 2016-2024 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, forwardRef, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormGroup, + ValidatorFn, + Validators +} from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { + defaultScadaObjectSettings, + parseScadaObjectMetadataFromContent, + ScadaObjectBehaviorType, + ScadaObjectMetadata, + ScadaObjectSettings +} from '@home/components/widget/lib/scada/scada.models'; +import { HttpClient } from '@angular/common/http'; +import { ValueType } from '@shared/models/constants'; +import { IAliasController } from '@core/api/widget-api.models'; +import { TargetDevice, widgetType } from '@shared/models/widget.models'; +import { isDefinedAndNotNull } from '@core/utils'; + +@Component({ + selector: 'tb-scada-object-settings', + templateUrl: './scada-object-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ScadaObjectSettingsComponent), + multi: true + } + ] +}) +export class ScadaObjectSettingsComponent implements OnInit, OnChanges, ControlValueAccessor { + + ScadaObjectBehaviorType = ScadaObjectBehaviorType; + + @Input() + disabled: boolean; + + @Input() + svgPath = '/assets/widget/scada/drawing.svg'; + + @Input() + aliasController: IAliasController; + + @Input() + targetDevice: TargetDevice; + + @Input() + widgetType: widgetType; + + private modelValue: ScadaObjectSettings; + + private propagateChange = null; + + public scadaObjectSettingsFormGroup: UntypedFormGroup; + + metadata: ScadaObjectMetadata; + + constructor(protected store: Store, + private fb: UntypedFormBuilder, + private http: HttpClient) { + } + + ngOnInit(): void { + this.scadaObjectSettingsFormGroup = this.fb.group({}); + this.scadaObjectSettingsFormGroup.valueChanges.subscribe(() => { + this.updateModel(); + }); + this.loadMetadata(); + } + + ngOnChanges(changes: SimpleChanges): void { + for (const propName of Object.keys(changes)) { + const change = changes[propName]; + if (!change.firstChange && change.currentValue !== change.previousValue) { + if (['svgPath'].includes(propName)) { + this.loadMetadata(); + } + } + } + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.scadaObjectSettingsFormGroup.disable({emitEvent: false}); + } else { + this.scadaObjectSettingsFormGroup.enable({emitEvent: false}); + } + } + + writeValue(value: ScadaObjectSettings): void { + this.modelValue = value || {}; + this.setupValue(); + } + + private loadMetadata() { + this.http.get(this.svgPath, {responseType: 'text'}).subscribe( + (svgContent) => { + this.metadata = parseScadaObjectMetadataFromContent(svgContent); + for (const control of Object.keys(this.scadaObjectSettingsFormGroup.controls)) { + this.scadaObjectSettingsFormGroup.removeControl(control, {emitEvent: false}); + } + for (const behaviour of this.metadata.behavior) { + this.scadaObjectSettingsFormGroup.addControl(behaviour.id, this.fb.control(null, []), {emitEvent: false}); + } + for (const property of this.metadata.properties) { + const validators: ValidatorFn[] = []; + if (property.type === 'number') { + if (isDefinedAndNotNull(property.min)) { + validators.push(Validators.min(property.min)); + } + if (isDefinedAndNotNull(property.max)) { + validators.push(Validators.max(property.max)); + } + } + this.scadaObjectSettingsFormGroup.addControl(property.id, this.fb.control(null, validators), {emitEvent: false}); + } + this.setupValue(); + } + ); + } + + private setupValue() { + if (this.metadata) { + const defaults = defaultScadaObjectSettings(this.metadata); + this.modelValue = {...defaults, ...this.modelValue}; + this.scadaObjectSettingsFormGroup.patchValue( + this.modelValue, {emitEvent: false} + ); + } + } + + private updateModel() { + this.modelValue = this.scadaObjectSettingsFormGroup.getRawValue(); + this.propagateChange(this.modelValue); + } + + protected readonly ValueType = ValueType; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts index bbe3dfb20c..152cf25b35 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts @@ -149,6 +149,9 @@ import { StatusWidgetStateSettingsComponent } from '@home/components/widget/lib/settings/common/indicator/status-widget-state-settings.component'; import { ChartBarSettingsComponent } from '@home/components/widget/lib/settings/common/chart/chart-bar-settings.component'; +import { + ScadaObjectSettingsComponent +} from '@home/components/widget/lib/settings/common/scada/scada-object-settings.component'; @NgModule({ declarations: [ @@ -204,6 +207,7 @@ import { ChartBarSettingsComponent } from '@home/components/widget/lib/settings/ TimeSeriesChartStateRowComponent, TimeSeriesChartGridSettingsComponent, StatusWidgetStateSettingsComponent, + ScadaObjectSettingsComponent, DataKeyInputComponent, EntityAliasInputComponent ], @@ -265,6 +269,7 @@ import { ChartBarSettingsComponent } from '@home/components/widget/lib/settings/ TimeSeriesChartStateRowComponent, TimeSeriesChartGridSettingsComponent, StatusWidgetStateSettingsComponent, + ScadaObjectSettingsComponent, DataKeyInputComponent, EntityAliasInputComponent ], 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 69185b6dd2..f062494763 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 @@ -92,6 +92,7 @@ import { PieChartWidgetComponent } from '@home/components/widget/lib/chart/pie-c import { BarChartWidgetComponent } from '@home/components/widget/lib/chart/bar-chart-widget.component'; import { PolarAreaWidgetComponent } from '@home/components/widget/lib/chart/polar-area-widget.component'; import { RadarChartWidgetComponent } from '@home/components/widget/lib/chart/radar-chart-widget.component'; +import { ScadaTestWidgetComponent } from '@home/components/widget/lib/scada/scada-test-widget.component'; @NgModule({ declarations: @@ -150,7 +151,8 @@ import { RadarChartWidgetComponent } from '@home/components/widget/lib/chart/rad PieChartWidgetComponent, BarChartWidgetComponent, PolarAreaWidgetComponent, - RadarChartWidgetComponent + RadarChartWidgetComponent, + ScadaTestWidgetComponent ], imports: [ CommonModule, @@ -212,7 +214,8 @@ import { RadarChartWidgetComponent } from '@home/components/widget/lib/chart/rad PieChartWidgetComponent, BarChartWidgetComponent, PolarAreaWidgetComponent, - RadarChartWidgetComponent + RadarChartWidgetComponent, + ScadaTestWidgetComponent ], providers: [ {provide: WIDGET_COMPONENTS_MODULE_TOKEN, useValue: WidgetComponentsModule } diff --git a/ui-ngx/src/assets/widget/scada/drawing.svg b/ui-ngx/src/assets/widget/scada/drawing.svg new file mode 100644 index 0000000000..972ca550cd --- /dev/null +++ b/ui-ngx/src/assets/widget/scada/drawing.svg @@ -0,0 +1,149 @@ + + + { + "title": "My first SCADA Object", + "initial": [ + { + "tag": "RECT", + "attributes": [ + { + "name": "fill", + "value": { + "type": "property", + "propertyId": "background" + } + }, + { + "name": "stroke", + "value": { + "type": "property", + "propertyId": "strokeColor" + } + }, + { + "name": "stroke-width", + "value": { + "type": "property", + "propertyId": "strokeWidth" + } + } + ] + } + ], + "behavior": [ + { + "id": "initialState", + "name": "Initial state", + "type": "getValue", + "valueType": "DOUBLE", + "defaultValue": 0, + "onUpdate": [ + { + "matcher": { + "type": "any" + }, + "state": [ + { + "tag": "RECT", + "animate": 200, + "attributes": [ + { + "name": "height", + "value": { + "type": "input" + } + }, + { + "name": "width", + "value": { + "type": "input" + } + } + ] + } + ] + } + ] + }, + { + "id": "disabledState", + "name": "Disabled state", + "type": "getValue", + "valueType": "BOOLEAN", + "defaultValue": false, + "onUpdate": [ + { + "matcher": { + "type": "constant", + "value": true + }, + "state": [ + { + "tag": "RECT", + "attributes": [ + { + "name": "fill", + "value": { + "type": "property", + "propertyId": "disabledBackground" + } + } + ] + } + ] + }, + { + "matcher": { + "type": "constant", + "value": false + }, + "state": [ + { + "tag": "RECT", + "attributes": [ + { + "name": "fill", + "value": { + "type": "property", + "propertyId": "background" + } + } + ] + } + ] + } + ] + } + ], + "properties": [ + { + "id": "strokeColor", + "name": "Stroke color", + "type": "color", + "default": "#aaa" + }, + { + "id": "strokeWidth", + "name": "Stroke width", + "type": "number", + "default": 5, + "min": 0, + "max": 20 + }, + { + "id": "background", + "name": "Background", + "type": "color", + "default": "green" + }, + { + "id": "disabledBackground", + "name": "Disabled background", + "type": "color", + "default": "#ccc" + } + ] + } + + +