diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/power-button-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/power-button-basic-config.component.ts
index 2c92936141..3d3ee57f0e 100644
--- a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/power-button-basic-config.component.ts
+++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/power-button-basic-config.component.ts
@@ -1,5 +1,5 @@
///
-/// Copyright © 2016-2024 The Thingsboard Authors
+/// Copyright © 2016-2025 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.
@@ -87,6 +87,19 @@ export class PowerButtonBasicConfigComponent extends BasicWidgetConfigComponent
icon: [configData.config.titleIcon, []],
iconColor: [configData.config.iconColor, []],
+ onButtonIcon: this.fb.group({
+ showIcon: [settings.onButtonIcon.showIcon, []],
+ iconSize: [settings.onButtonIcon.iconSize, [Validators.min(0)]],
+ iconSizeUnit: [settings.onButtonIcon.iconSizeUnit, []],
+ icon: [settings.onButtonIcon.icon, []],
+ }),
+ offButtonIcon: this.fb.group({
+ showIcon: [settings.offButtonIcon.showIcon, []],
+ iconSize: [settings.offButtonIcon.iconSize, [Validators.min(0)]],
+ iconSizeUnit: [settings.offButtonIcon.iconSizeUnit, []],
+ icon: [settings.offButtonIcon.icon, []],
+ }),
+
mainColorOn: [settings.mainColorOn, []],
backgroundColorOn: [settings.backgroundColorOn, []],
@@ -128,6 +141,9 @@ export class PowerButtonBasicConfigComponent extends BasicWidgetConfigComponent
this.widgetConfig.config.settings.layout = config.layout;
+ this.widgetConfig.config.settings.onButtonIcon = config.onButtonIcon;
+ this.widgetConfig.config.settings.offButtonIcon = config.offButtonIcon;
+
this.widgetConfig.config.settings.mainColorOn = config.mainColorOn;
this.widgetConfig.config.settings.backgroundColorOn = config.backgroundColorOn;
@@ -148,12 +164,14 @@ export class PowerButtonBasicConfigComponent extends BasicWidgetConfigComponent
}
protected validatorTriggers(): string[] {
- return ['showTitle', 'showIcon'];
+ return ['showTitle', 'showIcon', 'onButtonIcon.showIcon', 'offButtonIcon.showIcon'];
}
protected updateValidators(emitEvent: boolean, trigger?: string) {
const showTitle: boolean = this.powerButtonWidgetConfigForm.get('showTitle').value;
const showIcon: boolean = this.powerButtonWidgetConfigForm.get('showIcon').value;
+ const onButtonIcon: boolean = this.powerButtonWidgetConfigForm.get('onButtonIcon').get('showIcon').value;
+ const offButtonIcon: boolean = this.powerButtonWidgetConfigForm.get('offButtonIcon').get('showIcon').value;
if (showTitle) {
this.powerButtonWidgetConfigForm.get('title').enable();
this.powerButtonWidgetConfigForm.get('titleFont').enable();
@@ -180,6 +198,24 @@ export class PowerButtonBasicConfigComponent extends BasicWidgetConfigComponent
this.powerButtonWidgetConfigForm.get('icon').disable();
this.powerButtonWidgetConfigForm.get('iconColor').disable();
}
+ if (onButtonIcon) {
+ this.powerButtonWidgetConfigForm.get('onButtonIcon').get('iconSize').enable();
+ this.powerButtonWidgetConfigForm.get('onButtonIcon').get('iconSizeUnit').enable();
+ this.powerButtonWidgetConfigForm.get('onButtonIcon').get('icon').enable();
+ } else {
+ this.powerButtonWidgetConfigForm.get('onButtonIcon').get('iconSize').disable();
+ this.powerButtonWidgetConfigForm.get('onButtonIcon').get('iconSizeUnit').disable();
+ this.powerButtonWidgetConfigForm.get('onButtonIcon').get('icon').disable();
+ }
+ if (offButtonIcon) {
+ this.powerButtonWidgetConfigForm.get('offButtonIcon').get('iconSize').enable();
+ this.powerButtonWidgetConfigForm.get('offButtonIcon').get('iconSizeUnit').enable();
+ this.powerButtonWidgetConfigForm.get('offButtonIcon').get('icon').enable();
+ } else {
+ this.powerButtonWidgetConfigForm.get('offButtonIcon').get('iconSize').disable();
+ this.powerButtonWidgetConfigForm.get('offButtonIcon').get('iconSizeUnit').disable();
+ this.powerButtonWidgetConfigForm.get('offButtonIcon').get('icon').disable();
+ }
}
private getCardButtons(config: WidgetConfig): string[] {
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/segmented-button-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/segmented-button-basic-config.component.html
index 54f9cd6625..abb1621966 100644
--- a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/segmented-button-basic-config.component.html
+++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/segmented-button-basic-config.component.html
@@ -1,6 +1,6 @@
+
+
+
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/value-stepper-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/value-stepper-basic-config.component.ts
new file mode 100644
index 0000000000..763816b0f9
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/value-stepper-basic-config.component.ts
@@ -0,0 +1,225 @@
+///
+/// Copyright © 2016-2025 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, Validators } 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, WidgetConfig, } from '@shared/models/widget.models';
+import { WidgetConfigComponent } from '@home/components/widget/widget-config.component';
+import { formatValue, isUndefined } from '@core/utils';
+import { ValueType } from '@shared/models/constants';
+import {
+ valueStepperDefaultSettings,
+ valueStepperTypeImages,
+ valueStepperTypes,
+ valueStepperTypeTranslations,
+ ValueStepperWidgetSettings
+} from '@home/components/widget/lib/rpc/value-stepper-widget.models';
+
+type ButtonAppearanceType = 'left' | 'right';
+
+@Component({
+ selector: 'tb-value-stepper-basic-config',
+ templateUrl: './value-stepper-basic-config.component.html',
+ styleUrls: ['../basic-config.scss']
+})
+export class ValueStepperBasicConfigComponent extends BasicWidgetConfigComponent {
+
+ get targetDevice(): TargetDevice {
+ return this.valueStepperWidgetConfigForm.get('targetDevice').value;
+ }
+
+ valueStepperTypeTranslationMap = valueStepperTypeTranslations;
+ valueStepperTypes = valueStepperTypes;
+ valueStepperTypeImageMap = valueStepperTypeImages;
+
+ buttonAppearanceType: ButtonAppearanceType = 'left';
+
+ valueType = ValueType;
+
+ valueStepperWidgetConfigForm: UntypedFormGroup;
+
+ valuePreviewFn = this._valuePreviewFn.bind(this);
+
+ constructor(protected store: Store
,
+ protected widgetConfigComponent: WidgetConfigComponent,
+ private fb: UntypedFormBuilder) {
+ super(store, widgetConfigComponent);
+ }
+
+ protected configForm(): UntypedFormGroup {
+ return this.valueStepperWidgetConfigForm;
+ }
+
+ protected onConfigSet(configData: WidgetConfigComponentData) {
+ const settings: ValueStepperWidgetSettings = {...valueStepperDefaultSettings, ...(configData.config.settings || {})};
+ this.valueStepperWidgetConfigForm = this.fb.group({
+ targetDevice: [configData.config.targetDevice, []],
+
+ initialState: [settings.initialState, []],
+ leftButtonClick: [settings.leftButtonClick, []],
+ rightButtonClick: [settings.rightButtonClick, []],
+ disabledState: [settings.disabledState, []],
+
+ appearance: this.fb.group({
+ type: [settings.appearance.type, []],
+ autoScale: [settings.appearance.autoScale, []],
+ minValueRange: [settings.appearance.minValueRange, []],
+ maxValueRange: [settings.appearance.maxValueRange, []],
+ valueStep: [settings.appearance.valueStep, [Validators.min(0)]],
+ showValueBox: [settings.appearance.showValueBox, []],
+ valueUnits: [settings.appearance.valueUnits, []],
+ valueDecimals: [settings.appearance.valueDecimals, []],
+ valueFont: [settings.appearance.valueFont, []],
+ valueColor: [settings.appearance.valueColor],
+ valueBoxBackground: [settings.appearance.valueBoxBackground, []],
+ showBorder: [settings.appearance.showBorder, []],
+ borderWidth: [settings.appearance.borderWidth, []],
+ borderColor: [settings.appearance.borderColor, []]
+ }),
+
+ buttonAppearance: this.fb.group({
+ leftButton: this.fb.group({
+ showButton: [settings.buttonAppearance.leftButton.showButton],
+ icon: [settings.buttonAppearance.leftButton.icon],
+ iconSize: [settings.buttonAppearance.leftButton.iconSize],
+ iconSizeUnit: [settings.buttonAppearance.leftButton.iconSizeUnit],
+ mainColorOn: [settings.buttonAppearance.leftButton.mainColorOn, []],
+ backgroundColorOn: [settings.buttonAppearance.leftButton.backgroundColorOn, []],
+ mainColorDisabled: [settings.buttonAppearance.leftButton.mainColorDisabled, []],
+ backgroundColorDisabled: [settings.buttonAppearance.leftButton.backgroundColorDisabled, []]
+ }),
+ rightButton: this.fb.group({
+ showButton: [settings.buttonAppearance.rightButton.showButton],
+ icon: [settings.buttonAppearance.rightButton.icon],
+ iconSize: [settings.buttonAppearance.rightButton.iconSize],
+ iconSizeUnit: [settings.buttonAppearance.rightButton.iconSizeUnit],
+ mainColorOn: [settings.buttonAppearance.rightButton.mainColorOn, []],
+ backgroundColorOn: [settings.buttonAppearance.rightButton.backgroundColorOn, []],
+ mainColorDisabled: [settings.buttonAppearance.rightButton.mainColorDisabled, []],
+ backgroundColorDisabled: [settings.buttonAppearance.rightButton.backgroundColorDisabled, []]
+ })
+ }),
+
+ background: [settings.background, []],
+ cardButtons: [this.getCardButtons(configData.config), []],
+ borderRadius: [configData.config.borderRadius, []],
+ padding: [settings.padding, []],
+
+ actions: [configData.config.actions || {}, []]
+ });
+ }
+
+
+ protected prepareOutputConfig(config: any): WidgetConfigComponentData {
+ this.widgetConfig.config.targetDevice = config.targetDevice;
+ this.widgetConfig.config.settings = this.widgetConfig.config.settings || {};
+ this.widgetConfig.config.settings.initialState = config.initialState;
+ this.widgetConfig.config.settings.disabledState = config.disabledState;
+ this.widgetConfig.config.settings.leftButtonClick = config.leftButtonClick;
+ this.widgetConfig.config.settings.rightButtonClick = config.rightButtonClick;
+ this.widgetConfig.config.settings.appearance = config.appearance;
+ this.widgetConfig.config.settings.buttonAppearance = config.buttonAppearance;
+
+ this.widgetConfig.config.settings.background = config.background;
+ this.setCardButtons(config.cardButtons, this.widgetConfig.config);
+ this.widgetConfig.config.borderRadius = config.borderRadius;
+ this.widgetConfig.config.settings.padding = config.padding;
+ this.widgetConfig.config.actions = config.actions;
+
+ return this.widgetConfig;
+ }
+
+
+ protected validatorTriggers(): string[] {
+ return ['appearance.showValueBox', 'appearance.showBorder',
+ 'buttonAppearance.leftButton.showButton', 'buttonAppearance.rightButton.showButton'];
+ }
+
+ protected updateValidators(emitEvent: boolean, trigger?: string) {
+ const showValueBox: boolean = this.valueStepperWidgetConfigForm.get('appearance').get('showValueBox').value;
+ const showBorder: boolean = this.valueStepperWidgetConfigForm.get('appearance').get('showBorder').value;
+ const showLeftButton: boolean = this.valueStepperWidgetConfigForm.get('buttonAppearance').get('leftButton').get('showButton').value;
+ const showRightButton: boolean = this.valueStepperWidgetConfigForm.get('buttonAppearance').get('rightButton').get('showButton').value;
+ if (showValueBox) {
+ this.valueStepperWidgetConfigForm.get('appearance').get('valueUnits').enable();
+ this.valueStepperWidgetConfigForm.get('appearance').get('valueDecimals').enable();
+ this.valueStepperWidgetConfigForm.get('appearance').get('valueFont').enable();
+ this.valueStepperWidgetConfigForm.get('appearance').get('valueColor').enable();
+ this.valueStepperWidgetConfigForm.get('appearance').get('valueBoxBackground').enable();
+ this.valueStepperWidgetConfigForm.get('appearance').get('showBorder').enable({emitEvent: false});
+ if (showBorder) {
+ this.valueStepperWidgetConfigForm.get('appearance').get('borderWidth').enable();
+ this.valueStepperWidgetConfigForm.get('appearance').get('borderColor').enable();
+ } else {
+ this.valueStepperWidgetConfigForm.get('appearance').get('borderWidth').disable();
+ this.valueStepperWidgetConfigForm.get('appearance').get('borderColor').disable();
+ }
+ } else {
+ this.valueStepperWidgetConfigForm.get('appearance').get('valueUnits').disable();
+ this.valueStepperWidgetConfigForm.get('appearance').get('valueDecimals').disable();
+ this.valueStepperWidgetConfigForm.get('appearance').get('valueFont').disable();
+ this.valueStepperWidgetConfigForm.get('appearance').get('valueColor').disable();
+ this.valueStepperWidgetConfigForm.get('appearance').get('valueBoxBackground').disable();
+ this.valueStepperWidgetConfigForm.get('appearance').get('showBorder').disable({emitEvent: false});
+ this.valueStepperWidgetConfigForm.get('appearance').get('borderWidth').disable();
+ this.valueStepperWidgetConfigForm.get('appearance').get('borderColor').disable();
+ }
+ this.buttonValidators(showLeftButton, 'leftButton');
+ this.buttonValidators(showRightButton, 'rightButton');
+ }
+
+ private buttonValidators(showButtonValue: boolean, button: string) {
+ if (showButtonValue) {
+ this.valueStepperWidgetConfigForm.get('buttonAppearance').get(button).get('icon').enable()
+ this.valueStepperWidgetConfigForm.get('buttonAppearance').get(button).get('iconSize').enable()
+ this.valueStepperWidgetConfigForm.get('buttonAppearance').get(button).get('iconSizeUnit').enable()
+ this.valueStepperWidgetConfigForm.get('buttonAppearance').get(button).get('mainColorOn').enable()
+ this.valueStepperWidgetConfigForm.get('buttonAppearance').get(button).get('backgroundColorOn').enable()
+ this.valueStepperWidgetConfigForm.get('buttonAppearance').get(button).get('mainColorDisabled').enable()
+ this.valueStepperWidgetConfigForm.get('buttonAppearance').get(button).get('backgroundColorDisabled').enable()
+ } else {
+ this.valueStepperWidgetConfigForm.get('buttonAppearance').get(button).get('icon').disable()
+ this.valueStepperWidgetConfigForm.get('buttonAppearance').get(button).get('iconSize').disable()
+ this.valueStepperWidgetConfigForm.get('buttonAppearance').get(button).get('iconSizeUnit').disable()
+ this.valueStepperWidgetConfigForm.get('buttonAppearance').get(button).get('mainColorOn').disable()
+ this.valueStepperWidgetConfigForm.get('buttonAppearance').get(button).get('backgroundColorOn').disable()
+ this.valueStepperWidgetConfigForm.get('buttonAppearance').get(button).get('mainColorDisabled').disable()
+ this.valueStepperWidgetConfigForm.get('buttonAppearance').get(button).get('backgroundColorDisabled').disable()
+ }
+ }
+
+ private getCardButtons(config: WidgetConfig): string[] {
+ const buttons: string[] = [];
+ if (isUndefined(config.enableFullscreen) || config.enableFullscreen) {
+ buttons.push('fullscreen');
+ }
+ return buttons;
+ }
+
+ private setCardButtons(buttons: string[], config: WidgetConfig) {
+ config.enableFullscreen = buttons.includes('fullscreen');
+ }
+
+ private _valuePreviewFn(): string {
+ const units: string = this.valueStepperWidgetConfigForm.get('appearance').get('valueUnits').value;
+ const decimals: number = this.valueStepperWidgetConfigForm.get('appearance').get('valueDecimals').value;
+ return formatValue(48, decimals, units, false);
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/scada/scada-symbol-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/scada/scada-symbol-basic-config.component.html
index d3e31fe189..4b5cb39d81 100644
--- a/ui-ngx/src/app/modules/home/components/widget/config/basic/scada/scada-symbol-basic-config.component.html
+++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/scada/scada-symbol-basic-config.component.html
@@ -1,6 +1,6 @@
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/value-stepper-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/value-stepper-widget.component.scss
new file mode 100644
index 0000000000..687f969f3b
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/value-stepper-widget.component.scss
@@ -0,0 +1,86 @@
+/**
+ * Copyright © 2016-2025 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-value-stepper-panel {
+ width: 100%;
+ height: 100%;
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 20px 24px 24px 24px;
+ > div:not(.tb-value-stepper-overlay) {
+ z-index: 1;
+ }
+ .tb-value-stepper-overlay {
+ position: absolute;
+ top: 12px;
+ left: 12px;
+ bottom: 12px;
+ right: 12px;
+ }
+ div.tb-widget-title {
+ padding: 0;
+ }
+ .tb-value-stepper-content {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ min-width: 0;
+ min-height: 0;
+ height: 100%;
+ .tb-value-stepper-value-box {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex: 1;
+ height: 32px;
+ padding: 0 12px;
+ border-radius: 4px;
+ &-disabled {
+ border-color: rgba(0, 0, 0, 0.38);
+ }
+ }
+ .tb-button-shape {
+ width: 32px;
+ height: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ &-left {
+ margin-right: 12px;
+ }
+ &-right {
+ margin-left: 12px;
+ }
+ svg {
+ .tb-small-shadow {
+ filter: drop-shadow(0px 0px 4px rgba(0, 0, 0, 0.2));
+ }
+ .tb-shadow {
+ filter: drop-shadow(0px 0px 10px rgba(0, 0, 0, 0.15));
+ }
+ }
+ &.tb-button-pointer {
+ svg {
+ .tb-hover-circle {
+ cursor: pointer;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/value-stepper-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/value-stepper-widget.component.ts
new file mode 100644
index 0000000000..92f6ea6907
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/value-stepper-widget.component.ts
@@ -0,0 +1,391 @@
+///
+/// Copyright © 2016-2025 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,
+ DestroyRef,
+ ElementRef,
+ inject,
+ NgZone,
+ OnDestroy,
+ OnInit,
+ Renderer2,
+ ViewChild,
+ ViewEncapsulation
+} from '@angular/core';
+import { BasicActionWidgetComponent, ValueSetter } from '@home/components/widget/lib/action/action-widget.models';
+import { backgroundStyle, ComponentStyle, overlayStyle, textStyle } from '@shared/models/widget-settings.models';
+import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
+import { ImagePipe } from '@shared/pipe/image.pipe';
+import { DomSanitizer } from '@angular/platform-browser';
+import { ValueType } from '@shared/models/constants';
+import {
+ PowerButtonLayout,
+ PowerButtonShape,
+ powerButtonShapeSize,
+ PowerButtonWidgetSettings
+} from '@home/components/widget/lib/rpc/power-button-widget.models';
+import { SVG, Svg } from '@svgdotjs/svg.js';
+import { MatIconRegistry } from '@angular/material/icon';
+import { formatValue, isDefinedAndNotNull, isNumeric } from '@core/utils';
+import {
+ valueStepperDefaultSettings,
+ ValueStepperWidgetSettings
+} from '@home/components/widget/lib/rpc/value-stepper-widget.models';
+import { UtilsService } from '@core/services/utils.service';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+
+@Component({
+ selector: 'tb-value-stepper-widget',
+ templateUrl: './value-stepper-widget.component.html',
+ styleUrls: ['../action/action-widget.scss', './value-stepper-widget.component.scss'],
+ encapsulation: ViewEncapsulation.None
+})
+export class ValueStepperWidgetComponent extends
+ BasicActionWidgetComponent implements OnInit, AfterViewInit, OnDestroy {
+
+ @ViewChild('leftButton', {static: true})
+ leftButton: ElementRef;
+
+ @ViewChild('rightButton', {static: true})
+ rightButton: ElementRef;
+
+ @ViewChild('stepperContent', {static: true})
+ stepperContent: ElementRef;
+
+ @ViewChild('valueBoxContainer', {static: true})
+ valueBox: ElementRef;
+
+ @ViewChild('value', {static: true})
+ valueElement: ElementRef;
+
+ settings: ValueStepperWidgetSettings;
+
+ backgroundStyle$: Observable;
+ overlayStyle: ComponentStyle = {};
+ padding: string;
+
+ valueStyle: ComponentStyle = {};
+ valueStyleColor = '';
+ disabledColor = 'rgba(0, 0, 0, 0.38)';
+ value: number = null;
+
+ autoScale = false;
+
+ showValueBox = true;
+ showLeftButton = true;
+ showRightButton = true;
+
+ valueText = 'N/A';
+
+ disabledState$ = new BehaviorSubject(false);
+
+ private prevValue: number = null;
+ private shapeResize$: ResizeObserver;
+ private drawSvgShapePending = false;
+ private svgShapeLeft: Svg;
+ private svgShapeRight: Svg;
+ private powerButtonSvgShapeLeft: PowerButtonShape;
+ private powerButtonSvgShapeRight: PowerButtonShape;
+
+ private disabledState = false;
+ public leftDisabledState = false;
+ public rightDisabledState = false;
+
+ private valueSetterLeft: ValueSetter;
+ private valueSetterRight: ValueSetter;
+
+ private leftDisabledState$ = new BehaviorSubject(false);
+ private rightDisabledState$ = new BehaviorSubject(false);
+
+ protected destroyRef = inject(DestroyRef);
+
+ constructor(protected imagePipe: ImagePipe,
+ protected sanitizer: DomSanitizer,
+ private renderer: Renderer2,
+ private iconRegistry: MatIconRegistry,
+ private utils: UtilsService,
+ private elementRef: ElementRef,
+ protected cd: ChangeDetectorRef,
+ protected zone: NgZone) {
+ super(cd);
+ }
+
+ ngOnInit(): void {
+ super.ngOnInit();
+ this.settings = {...valueStepperDefaultSettings, ...this.ctx.settings};
+
+ this.autoScale = this.settings.appearance.autoScale;
+
+ this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer);
+ this.overlayStyle = overlayStyle(this.settings.background.overlay);
+ this.padding = this.settings.background.overlay.enabled ? undefined : this.settings.padding;
+
+ this.showValueBox = this.settings.appearance.showValueBox;
+ this.showLeftButton = this.settings.buttonAppearance.leftButton.showButton;
+ this.showRightButton = this.settings.buttonAppearance.rightButton.showButton;
+ this.valueStyle = textStyle(this.settings.appearance.valueFont);
+ this.valueStyleColor = this.settings.appearance.valueColor;
+
+ if (this.showValueBox) {
+ const valueBoxCss = `.tb-value-stepper-value-box {\n`+
+ `border: ${this.settings.appearance.showBorder ?
+ `${this.settings.appearance.borderWidth}px solid ${this.settings.appearance.borderColor}` :
+ 'none'};\n`+
+ `background-color: ${this.settings.appearance.valueBoxBackground}` +
+ `}`;
+ this.utils.applyCssToElement(this.renderer, this.elementRef.nativeElement, 'tb-value-stepper-value-box', valueBoxCss);
+ }
+
+ const getInitialStateSettings =
+ {...this.settings.initialState, actionLabel: this.ctx.translate.instant('widgets.slider.initial-value')};
+ this.createValueGetter(getInitialStateSettings, ValueType.INTEGER, {
+ next: (value) => this.onValue(value)
+ });
+ const disabledStateSettings =
+ {...this.settings.disabledState, actionLabel: this.ctx.translate.instant('widgets.rpc-state.disabled-state')};
+ this.createValueGetter(disabledStateSettings, ValueType.BOOLEAN, {
+ next: (value) => this.disabledState$.next(value)
+ });
+
+ const leftButtonClick = {...this.settings.leftButtonClick,
+ actionLabel: this.ctx.translate.instant('widgets.slider.on-value-change')};
+ this.valueSetterLeft = this.createValueSetter(leftButtonClick);
+
+ const rightButtonClick = {...this.settings.rightButtonClick,
+ actionLabel: this.ctx.translate.instant('widgets.slider.on-value-change')};
+ this.valueSetterRight = this.createValueSetter(rightButtonClick);
+
+ combineLatest([
+ this.loading$,
+ this.disabledState$.asObservable(),
+ this.leftDisabledState$.asObservable()
+ ]).pipe(
+ takeUntilDestroyed(this.destroyRef)
+ ).subscribe(value => {
+ const state = value.includes(true);
+ this.updateLeftDisabledState(state)
+ });
+
+ combineLatest([
+ this.loading$,
+ this.disabledState$.asObservable(),
+ this.rightDisabledState$.asObservable()
+ ]).pipe(
+ takeUntilDestroyed(this.destroyRef)
+ ).subscribe(value => {
+ const state = value.includes(true);
+ this.updateRightDisabledState(state)
+ });
+ }
+
+ ngAfterViewInit(): void {
+ if (this.drawSvgShapePending) {
+ this.drawSvg();
+ }
+ super.ngAfterViewInit();
+ }
+
+ ngOnDestroy() {
+ if (this.shapeResize$) {
+ this.shapeResize$.disconnect();
+ }
+ super.ngOnDestroy();
+ }
+
+ public onInit() {
+ super.onInit();
+ const borderRadius = this.ctx.$widgetElement.css('borderRadius');
+ this.overlayStyle = {...this.overlayStyle, ...{borderRadius}};
+ if (this.leftButton || this.rightButton) {
+ this.drawSvg();
+ } else {
+ this.drawSvgShapePending = true;
+ }
+ this.cd.detectChanges();
+ }
+
+ private onValue(value: number): void {
+ this.value = value;
+ this.prevValue = value;
+ if ((this.value + this.settings.appearance.valueStep) > this.settings.appearance.maxValueRange) {
+ this.rightDisabledState$.next(true);
+ } else {
+ this.rightDisabledState$.next(false);
+ }
+ if ((this.value - this.settings.appearance.valueStep) < this.settings.appearance.minValueRange) {
+ this.leftDisabledState$.next(true);
+ } else {
+ this.leftDisabledState$.next(false);
+ }
+ this.updateValueText();
+ this.cd.markForCheck();
+ }
+
+ private updateValueText() {
+ if (isDefinedAndNotNull(this.value) && isNumeric(this.value)) {
+ this.valueText = formatValue(this.value, this.settings.appearance.valueDecimals, this.settings.appearance.valueUnits, false);
+ } else {
+ this.valueText = 'N/A';
+ }
+ }
+
+ private onClick(rightButtonClick: boolean = false) {
+ this.updateValueText();
+ if (!this.ctx.isEdit && !this.ctx.isPreview && !this.disabledState) {
+ const prevValue = this.prevValue;
+ const targetValue = rightButtonClick ?
+ (this.value + this.settings.appearance.valueStep) :
+ (this.value - this.settings.appearance.valueStep);
+ this.updateValue(rightButtonClick ? this.valueSetterRight : this.valueSetterLeft, targetValue, {
+ next: () => this.onValue(targetValue),
+ error: () => this.onValue(prevValue)
+ });
+ }
+ }
+
+ private drawSvg() {
+ let leftButtonSetting: PowerButtonWidgetSettings;
+ let rightButtonSetting: PowerButtonWidgetSettings;
+ if (this.showLeftButton) {
+ this.svgShapeLeft = SVG().addTo(this.leftButton.nativeElement).size(powerButtonShapeSize, powerButtonShapeSize);
+ this.renderer.setStyle(this.svgShapeLeft.node, 'overflow', 'visible');
+ this.renderer.setStyle(this.svgShapeLeft.node, 'user-select', 'none');
+ leftButtonSetting = {
+ layout: PowerButtonLayout[this.settings.appearance.type],
+ onButtonIcon: {
+ showIcon: true,
+ icon: this.settings.buttonAppearance.leftButton.icon,
+ iconSize: this.settings.buttonAppearance.leftButton.iconSize * 1.7,
+ iconSizeUnit: this.settings.buttonAppearance.leftButton.iconSizeUnit
+ },
+ offButtonIcon: {
+ showIcon: true,
+ icon: this.settings.buttonAppearance.leftButton.icon,
+ iconSize: this.settings.buttonAppearance.leftButton.iconSize * 1.7,
+ iconSizeUnit: this.settings.buttonAppearance.leftButton.iconSizeUnit
+ },
+ mainColorOn: this.settings.buttonAppearance.leftButton.mainColorOn,
+ backgroundColorOn: this.settings.buttonAppearance.leftButton.backgroundColorOn,
+ mainColorOff: this.settings.buttonAppearance.leftButton.mainColorOff,
+ backgroundColorOff: this.settings.buttonAppearance.leftButton.backgroundColorOff,
+ mainColorDisabled: this.settings.buttonAppearance.leftButton.mainColorDisabled,
+ backgroundColorDisabled: this.settings.buttonAppearance.leftButton.backgroundColorDisabled
+ };
+ }
+ if (this.showRightButton) {
+ this.svgShapeRight = SVG().addTo(this.rightButton.nativeElement).size(powerButtonShapeSize, powerButtonShapeSize);
+ this.renderer.setStyle(this.svgShapeRight.node, 'overflow', 'visible');
+ this.renderer.setStyle(this.svgShapeRight.node, 'user-select', 'none');
+
+ rightButtonSetting = {
+ layout: PowerButtonLayout[this.settings.appearance.type],
+ onButtonIcon: {
+ showIcon: true,
+ icon: this.settings.buttonAppearance.rightButton.icon,
+ iconSize: this.settings.buttonAppearance.rightButton.iconSize * 1.7,
+ iconSizeUnit: this.settings.buttonAppearance.rightButton.iconSizeUnit
+ },
+ offButtonIcon: {
+ showIcon: true,
+ icon: this.settings.buttonAppearance.rightButton.icon,
+ iconSize: this.settings.buttonAppearance.rightButton.iconSize * 1.7,
+ iconSizeUnit: this.settings.buttonAppearance.rightButton.iconSizeUnit
+ },
+ mainColorOn: this.settings.buttonAppearance.rightButton.mainColorOn,
+ backgroundColorOn: this.settings.buttonAppearance.rightButton.backgroundColorOn,
+ mainColorOff: this.settings.buttonAppearance.rightButton.mainColorOff,
+ backgroundColorOff: this.settings.buttonAppearance.rightButton.backgroundColorOff,
+ mainColorDisabled: this.settings.buttonAppearance.rightButton.mainColorDisabled,
+ backgroundColorDisabled: this.settings.buttonAppearance.rightButton.backgroundColorDisabled
+ };
+ }
+
+ this.zone.run(() => {
+ if (this.showLeftButton) {
+ this.powerButtonSvgShapeLeft = PowerButtonShape.fromSettings(this.ctx, this.svgShapeLeft, this.iconRegistry,
+ leftButtonSetting , true, this.leftDisabledState, () => this.onClick());
+ }
+ if (this.showRightButton) {
+ this.powerButtonSvgShapeRight = PowerButtonShape.fromSettings(this.ctx, this.svgShapeRight, this.iconRegistry,
+ rightButtonSetting, true, this.rightDisabledState, () => this.onClick(true));
+ }
+ });
+
+ this.shapeResize$ = new ResizeObserver(() => {
+ this.onResize();
+ });
+ if (this.autoScale) {
+ this.shapeResize$.observe(this.stepperContent.nativeElement);
+ }
+ if (this.showLeftButton) {
+ this.shapeResize$.observe(this.leftButton.nativeElement);
+ }
+ if (this.showRightButton) {
+ this.shapeResize$.observe(this.rightButton.nativeElement);
+ }
+ this.onResize();
+ }
+
+ private updateLeftDisabledState(disabled: boolean) {
+ this.leftDisabledState = disabled;
+ this.powerButtonSvgShapeLeft?.setDisabled(this.leftDisabledState);
+ this.cd.markForCheck();
+ }
+
+
+ private updateRightDisabledState(disabled: boolean) {
+ this.rightDisabledState = disabled;
+ this.powerButtonSvgShapeRight?.setDisabled(this.rightDisabledState);
+ this.cd.markForCheck();
+ }
+
+ private onResize() {
+ const panelWidth = this.stepperContent.nativeElement.getBoundingClientRect().width;
+ const panelHeight = this.stepperContent.nativeElement.getBoundingClientRect().height;
+
+ const minAspect = 0.2;
+ const avgContentHeight = 32;
+ const targetHeight = panelWidth * Math.min(panelHeight / panelWidth, minAspect);
+ const multiplier = targetHeight / avgContentHeight;
+ const size = avgContentHeight * multiplier;
+
+ if (this.showValueBox) {
+ this.renderer.setStyle(this.valueBox?.nativeElement, 'height', `${size}px`);
+ this.renderer.setStyle(this.valueElement?.nativeElement, 'font-size', `${this.settings.appearance.valueFont.size * multiplier}px`);
+ }
+ if (this.showLeftButton) {
+ this.renderer.setStyle(this.leftButton?.nativeElement, 'width', `${size}px`);
+ this.renderer.setStyle(this.leftButton?.nativeElement, 'height', `${size}px`);
+ }
+ if (this.showRightButton) {
+ this.renderer.setStyle(this.rightButton?.nativeElement, 'width', `${size}px`);
+ this.renderer.setStyle(this.rightButton?.nativeElement, 'height', `${size}px`);
+ }
+ if (size) {
+ const scale = size / powerButtonShapeSize;
+ if (this.showLeftButton) {
+ this.renderer.setStyle(this.svgShapeLeft?.node, 'transform', `scale(${scale})`);
+ }
+ if (this.showRightButton) {
+ this.renderer.setStyle(this.svgShapeRight?.node, 'transform', `scale(${scale})`);
+ }
+ }
+ }
+
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/value-stepper-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/value-stepper-widget.models.ts
new file mode 100644
index 0000000000..e0603de4a5
--- /dev/null
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/value-stepper-widget.models.ts
@@ -0,0 +1,252 @@
+///
+/// Copyright © 2016-2025 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 {
+ DataToValueType,
+ GetValueAction,
+ GetValueSettings,
+ SetValueAction,
+ SetValueSettings,
+ ValueToDataType
+} from '@shared/models/action-widget-settings.models';
+import { WidgetButtonCustomStyles } from '@shared/components/button/widget-button.models';
+import { BackgroundSettings, BackgroundType, cssUnit, Font } from '@shared/models/widget-settings.models';
+import { AttributeScope } from '@shared/models/telemetry/telemetry.models';
+
+const defaultMainColor = '#305680';
+
+export enum ValueStepperType {
+ simplified = 'simplified',
+ default = 'default',
+ default_volume = 'default_volume'
+}
+
+export const valueStepperTypes = Object.keys(ValueStepperType) as ValueStepperType[];
+
+export const valueStepperTypeTranslations = new Map(
+ [
+ [ValueStepperType.simplified, 'widgets.value-stepper.simplified'],
+ [ValueStepperType.default, 'widgets.value-stepper.filled'],
+ [ValueStepperType.default_volume, 'widgets.value-stepper.volume']
+ ]
+);
+
+export const valueStepperTypeImages = new Map(
+ [
+ [ValueStepperType.simplified, 'assets/widget/value-stepper/simplified.svg'],
+ [ValueStepperType.default, 'assets/widget/value-stepper/filled.svg'],
+ [ValueStepperType.default_volume, 'assets/widget/value-stepper/volume.svg']
+ ]
+);
+
+export interface ValueStepperWidgetSettings {
+ initialState: GetValueSettings;
+ leftButtonClick: SetValueSettings;
+ rightButtonClick: SetValueSettings;
+ disabledState: GetValueSettings;
+
+ appearance: ValueStepperAppearance;
+ buttonAppearance: {
+ leftButton: ValueStepperButtonAppearance;
+ rightButton: ValueStepperButtonAppearance;
+ }
+
+ background: BackgroundSettings;
+ padding: string;
+}
+
+export interface ValueStepperAppearance {
+ type: ValueStepperType;
+ autoScale: boolean;
+ minValueRange: number;
+ maxValueRange: number;
+ valueStep: number;
+ showValueBox: boolean;
+ valueUnits: string;
+ valueDecimals: number;
+ valueFont: Font;
+ valueColor: string;
+ valueBoxBackground: string;
+ showBorder: boolean;
+ borderWidth: number;
+ borderColor: string;
+}
+
+export interface ValueStepperButtonAppearance {
+ showButton: boolean;
+ icon: string;
+ iconSize: number;
+ iconSizeUnit: cssUnit;
+ mainColorOn: string;
+ backgroundColorOn: string;
+ mainColorOff: string;
+ backgroundColorOff: string;
+ mainColorDisabled: string;
+ backgroundColorDisabled: string;
+ customStyle: WidgetButtonCustomStyles;
+}
+
+export const valueStepperDefaultAppearance: ValueStepperAppearance = {
+ type: ValueStepperType.simplified,
+ autoScale: true,
+ minValueRange: -100,
+ maxValueRange: 100,
+ valueStep: 0.5,
+ showValueBox: true,
+ valueUnits: '',
+ valueDecimals: 1,
+ valueFont: {
+ family: 'Roboto',
+ weight: '500',
+ style: 'normal',
+ size: 16,
+ sizeUnit: 'px',
+ lineHeight: '24px'
+ },
+ valueColor: '#000',
+ valueBoxBackground: 'rgba(0, 0, 0, 0.12)',
+ showBorder: true,
+ borderWidth: 1,
+ borderColor: defaultMainColor
+}
+
+export const valueStepperButtonDefaultAppearance: ValueStepperButtonAppearance = {
+ showButton: true,
+ icon: '',
+ iconSize: 24,
+ iconSizeUnit: 'px',
+
+ mainColorOn: '#3F52DD',
+ backgroundColorOn: '#FFFFFF',
+ mainColorOff: '#A2A2A2',
+ backgroundColorOff: '#FFFFFF',
+ mainColorDisabled: 'rgba(0,0,0,0.12)',
+ backgroundColorDisabled: '#FFFFFF',
+ customStyle: {
+ enabled: null,
+ hovered: null,
+ pressed: null,
+ activated: null,
+ disabled: null
+ }
+}
+
+export const valueStepperDefaultSettings: ValueStepperWidgetSettings = {
+ initialState: {
+ action: GetValueAction.EXECUTE_RPC,
+ defaultValue: 0,
+ executeRpc: {
+ method: 'getState',
+ requestTimeout: 5000,
+ requestPersistent: false,
+ persistentPollingInterval: 1000
+ },
+ getAttribute: {
+ key: 'state',
+ scope: null
+ },
+ getTimeSeries: {
+ key: 'state'
+ },
+ getAlarmStatus: {
+ severityList: null,
+ typeList: null
+ },
+ dataToValue: {
+ type: DataToValueType.NONE,
+ compareToValue: true,
+ dataToValueFunction: '/* Should return integer value */\nreturn data;'
+ }
+ },
+ disabledState: {
+ action: GetValueAction.DO_NOTHING,
+ defaultValue: false,
+ getAttribute: {
+ key: 'state',
+ scope: null
+ },
+ getTimeSeries: {
+ key: 'state'
+ },
+ getAlarmStatus: {
+ severityList: null,
+ typeList: null
+ },
+ dataToValue: {
+ type: DataToValueType.NONE,
+ compareToValue: true,
+ dataToValueFunction: '/* Should return boolean value */\nreturn data;'
+ }
+ },
+ leftButtonClick: {
+ action: SetValueAction.EXECUTE_RPC,
+ executeRpc: {
+ method: 'setState',
+ requestTimeout: 5000,
+ requestPersistent: false,
+ persistentPollingInterval: 1000
+ },
+ setAttribute: {
+ key: 'state',
+ scope: AttributeScope.SERVER_SCOPE
+ },
+ putTimeSeries: {
+ key: 'state'
+ },
+ valueToData: {
+ type: ValueToDataType.VALUE,
+ constantValue: 0,
+ valueToDataFunction: '/* Convert input integer value to RPC parameters or attribute/time-series value */\nreturn value;'
+ }
+ },
+ rightButtonClick: {
+ action: SetValueAction.EXECUTE_RPC,
+ executeRpc: {
+ method: 'setState',
+ requestTimeout: 5000,
+ requestPersistent: false,
+ persistentPollingInterval: 1000
+ },
+ setAttribute: {
+ key: 'state',
+ scope: AttributeScope.SERVER_SCOPE
+ },
+ putTimeSeries: {
+ key: 'state'
+ },
+ valueToData: {
+ type: ValueToDataType.VALUE,
+ constantValue: 0,
+ valueToDataFunction: '/* Convert input integer value to RPC parameters or attribute/time-series value */\nreturn value;'
+ }
+ },
+ appearance: valueStepperDefaultAppearance,
+ buttonAppearance: {
+ leftButton: {...valueStepperButtonDefaultAppearance, icon: 'arrow_back_ios_new'},
+ rightButton: {...valueStepperButtonDefaultAppearance, icon: 'arrow_forward_ios'}
+ },
+ background: {
+ type: BackgroundType.color,
+ color: '#fff',
+ overlay: {
+ enabled: false,
+ color: 'rgba(255,255,255,0.72)',
+ blur: 3
+ }
+ },
+ padding: '12px'
+};
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol-widget.component.html
index da2b80c7ce..efea328e04 100644
--- a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol-widget.component.html
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol-widget.component.html
@@ -1,6 +1,6 @@
-