diff --git a/src/common/entity/domain_icon.ts b/src/common/entity/domain_icon.ts
index 565358e9e4..dfbbc69a5e 100644
--- a/src/common/entity/domain_icon.ts
+++ b/src/common/entity/domain_icon.ts
@@ -28,10 +28,12 @@ import {
mdiLockAlert,
mdiLockClock,
mdiLockOpen,
+ mdiMeterGas,
mdiMotionSensor,
mdiPackage,
mdiPackageDown,
mdiPackageUp,
+ mdiPipeValve,
mdiPowerPlug,
mdiPowerPlugOff,
mdiRestart,
@@ -274,6 +276,16 @@ export const domainIconWithoutDefault = (
: mdiPackageUp
: mdiPackage;
+ case "valve":
+ switch (stateObj?.attributes.device_class) {
+ case "water":
+ return mdiPipeValve;
+ case "gas":
+ return mdiMeterGas;
+ default:
+ return mdiPipeValve;
+ }
+
case "water_heater":
return compareState === "off" ? mdiWaterBoilerOff : mdiWaterBoiler;
diff --git a/src/common/entity/state_active.ts b/src/common/entity/state_active.ts
index 7c304f0389..16ca41c9c1 100644
--- a/src/common/entity/state_active.ts
+++ b/src/common/entity/state_active.ts
@@ -42,6 +42,8 @@ export function stateActive(stateObj: HassEntity, state?: string): boolean {
return compareState !== "standby";
case "vacuum":
return !["idle", "docked", "paused"].includes(compareState);
+ case "valve":
+ return compareState !== "closed";
case "plant":
return compareState === "problem";
case "group":
diff --git a/src/common/entity/state_color.ts b/src/common/entity/state_color.ts
index 0abaca4c17..09145eed83 100644
--- a/src/common/entity/state_color.ts
+++ b/src/common/entity/state_color.ts
@@ -37,6 +37,7 @@ const STATE_COLORED_DOMAIN = new Set([
"timer",
"update",
"vacuum",
+ "valve",
"water_heater",
]);
diff --git a/src/components/ha-valve-controls.ts b/src/components/ha-valve-controls.ts
new file mode 100644
index 0000000000..ae37795951
--- /dev/null
+++ b/src/components/ha-valve-controls.ts
@@ -0,0 +1,102 @@
+import { mdiStop, mdiValveClosed, mdiValveOpen } from "@mdi/js";
+import { CSSResultGroup, LitElement, html, css, nothing } from "lit";
+import { customElement, property } from "lit/decorators";
+import { classMap } from "lit/directives/class-map";
+import { supportsFeature } from "../common/entity/supports-feature";
+import {
+ ValveEntity,
+ ValveEntityFeature,
+ canClose,
+ canOpen,
+ canStop,
+} from "../data/valve";
+import type { HomeAssistant } from "../types";
+import "./ha-icon-button";
+
+@customElement("ha-valve-controls")
+class HaValveControls extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property({ attribute: false }) public stateObj!: ValveEntity;
+
+ protected render() {
+ if (!this.stateObj) {
+ return nothing;
+ }
+
+ return html`
+
+
+
+
+
+
+
+ `;
+ }
+
+ private _onOpenTap(ev): void {
+ ev.stopPropagation();
+ this.hass.callService("valve", "open_valve", {
+ entity_id: this.stateObj.entity_id,
+ });
+ }
+
+ private _onCloseTap(ev): void {
+ ev.stopPropagation();
+ this.hass.callService("valve", "close_valve", {
+ entity_id: this.stateObj.entity_id,
+ });
+ }
+
+ private _onStopTap(ev): void {
+ ev.stopPropagation();
+ this.hass.callService("valve", "stop_valve", {
+ entity_id: this.stateObj.entity_id,
+ });
+ }
+
+ static get styles(): CSSResultGroup {
+ return css`
+ :host {
+ display: block;
+ }
+ .state {
+ white-space: nowrap;
+ }
+ .hidden {
+ visibility: hidden !important;
+ }
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ha-valve-controls": HaValveControls;
+ }
+}
diff --git a/src/data/cover.ts b/src/data/cover.ts
index 28ab3ebe1d..046da65aa3 100644
--- a/src/data/cover.ts
+++ b/src/data/cover.ts
@@ -65,7 +65,7 @@ export function canOpen(stateObj: CoverEntity) {
return false;
}
const assumedState = stateObj.attributes.assumed_state === true;
- return (!isFullyOpen(stateObj) && !isOpening(stateObj)) || assumedState;
+ return assumedState || (!isFullyOpen(stateObj) && !isOpening(stateObj));
}
export function canClose(stateObj: CoverEntity): boolean {
@@ -73,7 +73,7 @@ export function canClose(stateObj: CoverEntity): boolean {
return false;
}
const assumedState = stateObj.attributes.assumed_state === true;
- return (!isFullyClosed(stateObj) && !isClosing(stateObj)) || assumedState;
+ return assumedState || (!isFullyClosed(stateObj) && !isClosing(stateObj));
}
export function canStop(stateObj: CoverEntity): boolean {
@@ -85,7 +85,7 @@ export function canOpenTilt(stateObj: CoverEntity): boolean {
return false;
}
const assumedState = stateObj.attributes.assumed_state === true;
- return !isFullyOpenTilt(stateObj) || assumedState;
+ return assumedState || !isFullyOpenTilt(stateObj);
}
export function canCloseTilt(stateObj: CoverEntity): boolean {
@@ -93,7 +93,7 @@ export function canCloseTilt(stateObj: CoverEntity): boolean {
return false;
}
const assumedState = stateObj.attributes.assumed_state === true;
- return !isFullyClosedTilt(stateObj) || assumedState;
+ return assumedState || !isFullyClosedTilt(stateObj);
}
export function canStopTilt(stateObj: CoverEntity): boolean {
diff --git a/src/data/entity_attributes.ts b/src/data/entity_attributes.ts
index 3a07ae022a..d7782e1dbf 100644
--- a/src/data/entity_attributes.ts
+++ b/src/data/entity_attributes.ts
@@ -75,6 +75,9 @@ export const DOMAIN_ATTRIBUTES_UNITS = {
vacuum: {
battery_level: "%",
},
+ valve: {
+ current_position: "%",
+ },
sensor: {
battery_level: "%",
},
diff --git a/src/data/valve.ts b/src/data/valve.ts
new file mode 100644
index 0000000000..73f5d428ae
--- /dev/null
+++ b/src/data/valve.ts
@@ -0,0 +1,85 @@
+import {
+ HassEntityAttributeBase,
+ HassEntityBase,
+} from "home-assistant-js-websocket";
+import { UNAVAILABLE } from "./entity";
+import { stateActive } from "../common/entity/state_active";
+import { HomeAssistant } from "../types";
+
+export const enum ValveEntityFeature {
+ OPEN = 1,
+ CLOSE = 2,
+ SET_POSITION = 4,
+ STOP = 8,
+}
+
+export function isFullyOpen(stateObj: ValveEntity) {
+ if (stateObj.attributes.current_position !== undefined) {
+ return stateObj.attributes.current_position === 100;
+ }
+ return stateObj.state === "open";
+}
+
+export function isFullyClosed(stateObj: ValveEntity) {
+ if (stateObj.attributes.current_position !== undefined) {
+ return stateObj.attributes.current_position === 0;
+ }
+ return stateObj.state === "closed";
+}
+
+export function isOpening(stateObj: ValveEntity) {
+ return stateObj.state === "opening";
+}
+
+export function isClosing(stateObj: ValveEntity) {
+ return stateObj.state === "closing";
+}
+
+export function canOpen(stateObj: ValveEntity) {
+ if (stateObj.state === UNAVAILABLE) {
+ return false;
+ }
+ const assumedState = stateObj.attributes.assumed_state === true;
+ return assumedState || (!isFullyOpen(stateObj) && !isOpening(stateObj));
+}
+
+export function canClose(stateObj: ValveEntity): boolean {
+ if (stateObj.state === UNAVAILABLE) {
+ return false;
+ }
+ const assumedState = stateObj.attributes.assumed_state === true;
+ return assumedState || (!isFullyClosed(stateObj) && !isClosing(stateObj));
+}
+
+export function canStop(stateObj: ValveEntity): boolean {
+ return stateObj.state !== UNAVAILABLE;
+}
+
+interface ValveEntityAttributes extends HassEntityAttributeBase {
+ current_position?: number;
+ position?: number;
+}
+
+export interface ValveEntity extends HassEntityBase {
+ attributes: ValveEntityAttributes;
+}
+
+export function computeValvePositionStateDisplay(
+ stateObj: ValveEntity,
+ hass: HomeAssistant,
+ position?: number
+) {
+ const statePosition = stateActive(stateObj)
+ ? stateObj.attributes.current_position
+ : undefined;
+
+ const currentPosition = position ?? statePosition;
+
+ return currentPosition && currentPosition !== 100
+ ? hass.formatEntityAttributeValue(
+ stateObj,
+ "current_position",
+ Math.round(currentPosition)
+ )
+ : "";
+}
diff --git a/src/dialogs/more-info/const.ts b/src/dialogs/more-info/const.ts
index 80c5d5994f..65375a6f6d 100644
--- a/src/dialogs/more-info/const.ts
+++ b/src/dialogs/more-info/const.ts
@@ -27,6 +27,7 @@ export const DOMAINS_WITH_NEW_MORE_INFO = [
"lock",
"siren",
"switch",
+ "valve",
"water_heater",
];
/** Domains with separate more info dialog. */
@@ -61,6 +62,7 @@ export const DOMAINS_WITH_MORE_INFO = [
"timer",
"update",
"vacuum",
+ "valve",
"water_heater",
"weather",
];
diff --git a/src/dialogs/more-info/controls/more-info-cover.ts b/src/dialogs/more-info/controls/more-info-cover.ts
index f1f5e06e38..a352002e4a 100644
--- a/src/dialogs/more-info/controls/more-info-cover.ts
+++ b/src/dialogs/more-info/controls/more-info-cover.ts
@@ -42,7 +42,9 @@ class MoreInfoCover extends LitElement {
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("stateObj") && this.stateObj) {
- if (!this._mode) {
+ const entityId = this.stateObj.entity_id;
+ const oldEntityId = changedProps.get("stateObj")?.entity_id;
+ if (!this._mode || entityId !== oldEntityId) {
this._mode =
supportsFeature(this.stateObj, CoverEntityFeature.SET_POSITION) ||
supportsFeature(this.stateObj, CoverEntityFeature.SET_TILT_POSITION)
diff --git a/src/dialogs/more-info/controls/more-info-valve.ts b/src/dialogs/more-info/controls/more-info-valve.ts
new file mode 100644
index 0000000000..f7bae2a9be
--- /dev/null
+++ b/src/dialogs/more-info/controls/more-info-valve.ts
@@ -0,0 +1,192 @@
+import { mdiMenu, mdiSwapVertical } from "@mdi/js";
+import {
+ CSSResultGroup,
+ LitElement,
+ PropertyValues,
+ css,
+ html,
+ nothing,
+} from "lit";
+import { customElement, property, state } from "lit/decorators";
+import { supportsFeature } from "../../../common/entity/supports-feature";
+import "../../../components/ha-attributes";
+import "../../../components/ha-icon-button-group";
+import "../../../components/ha-icon-button-toggle";
+import {
+ ValveEntity,
+ ValveEntityFeature,
+ computeValvePositionStateDisplay,
+} from "../../../data/valve";
+import "../../../state-control/valve/ha-state-control-valve-buttons";
+import "../../../state-control/valve/ha-state-control-valve-position";
+import "../../../state-control/valve/ha-state-control-valve-toggle";
+import type { HomeAssistant } from "../../../types";
+import "../components/ha-more-info-state-header";
+import { moreInfoControlStyle } from "../components/more-info-control-style";
+
+type Mode = "position" | "button";
+
+@customElement("more-info-valve")
+class MoreInfoValve extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property({ attribute: false }) public stateObj?: ValveEntity;
+
+ @state() private _mode?: Mode;
+
+ private _setMode(ev) {
+ this._mode = ev.currentTarget.mode;
+ }
+
+ protected willUpdate(changedProps: PropertyValues): void {
+ super.willUpdate(changedProps);
+ if (changedProps.has("stateObj") && this.stateObj) {
+ const entityId = this.stateObj.entity_id;
+ const oldEntityId = changedProps.get("stateObj")?.entity_id;
+ if (!this._mode || entityId !== oldEntityId) {
+ this._mode = supportsFeature(
+ this.stateObj,
+ ValveEntityFeature.SET_POSITION
+ )
+ ? "position"
+ : "button";
+ }
+ }
+ }
+
+ private get _stateOverride() {
+ const stateDisplay = this.hass.formatEntityState(this.stateObj!);
+
+ const positionStateDisplay = computeValvePositionStateDisplay(
+ this.stateObj!,
+ this.hass
+ );
+
+ if (positionStateDisplay) {
+ return `${stateDisplay} βΈ± ${positionStateDisplay}`;
+ }
+ return stateDisplay;
+ }
+
+ protected render() {
+ if (!this.hass || !this.stateObj) {
+ return nothing;
+ }
+
+ const supportsPosition = supportsFeature(
+ this.stateObj,
+ ValveEntityFeature.SET_POSITION
+ );
+
+ const supportsOpenClose =
+ supportsFeature(this.stateObj, ValveEntityFeature.OPEN) ||
+ supportsFeature(this.stateObj, ValveEntityFeature.CLOSE) ||
+ supportsFeature(this.stateObj, ValveEntityFeature.STOP);
+
+ const supportsOpenCloseWithoutStop =
+ supportsFeature(this.stateObj, ValveEntityFeature.OPEN) &&
+ supportsFeature(this.stateObj, ValveEntityFeature.CLOSE) &&
+ !supportsFeature(this.stateObj, ValveEntityFeature.STOP);
+
+ return html`
+
+
+
+ ${
+ this._mode === "position"
+ ? html`
+ ${supportsPosition
+ ? html`
+
+ `
+ : nothing}
+ `
+ : nothing
+ }
+ ${
+ this._mode === "button"
+ ? html`
+ ${supportsOpenCloseWithoutStop
+ ? html`
+
+ `
+ : supportsOpenClose
+ ? html`
+
+ `
+ : nothing}
+ `
+ : nothing
+ }
+
+ ${
+ supportsPosition && supportsOpenClose
+ ? html`
+
+
+
+
+ `
+ : nothing
+ }
+
+
+
+ `;
+ }
+
+ static get styles(): CSSResultGroup {
+ return [
+ moreInfoControlStyle,
+ css`
+ .main-control {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ }
+ .main-control > * {
+ margin: 0 8px;
+ }
+ `,
+ ];
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "more-info-valve": MoreInfoValve;
+ }
+}
diff --git a/src/dialogs/more-info/state_more_info_control.ts b/src/dialogs/more-info/state_more_info_control.ts
index 65a2e0a1a0..2be9b0877b 100644
--- a/src/dialogs/more-info/state_more_info_control.ts
+++ b/src/dialogs/more-info/state_more_info_control.ts
@@ -35,6 +35,7 @@ const LAZY_LOADED_MORE_INFO_CONTROL = {
timer: () => import("./controls/more-info-timer"),
update: () => import("./controls/more-info-update"),
vacuum: () => import("./controls/more-info-vacuum"),
+ valve: () => import("./controls/more-info-valve"),
water_heater: () => import("./controls/more-info-water_heater"),
weather: () => import("./controls/more-info-weather"),
};
diff --git a/src/panels/lovelace/cards/hui-tile-card.ts b/src/panels/lovelace/cards/hui-tile-card.ts
index f20eb2ecde..1c06426d1a 100644
--- a/src/panels/lovelace/cards/hui-tile-card.ts
+++ b/src/panels/lovelace/cards/hui-tile-card.ts
@@ -251,6 +251,10 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
return this._renderStateContent(stateObj, ["state", "current_position"]);
}
+ if (domain === "valve" && active) {
+ return this._renderStateContent(stateObj, ["state", "current_position"]);
+ }
+
if (domain === "humidifier") {
return this._renderStateContent(stateObj, ["state", "current_humidity"]);
}
diff --git a/src/panels/lovelace/create-element/create-row-element.ts b/src/panels/lovelace/create-element/create-row-element.ts
index ed39e06c93..091f9b779c 100644
--- a/src/panels/lovelace/create-element/create-row-element.ts
+++ b/src/panels/lovelace/create-element/create-row-element.ts
@@ -49,6 +49,7 @@ const LAZY_LOAD_TYPES = {
"time-entity": () => import("../entity-rows/hui-time-entity-row"),
"timer-entity": () => import("../entity-rows/hui-timer-entity-row"),
"update-entity": () => import("../entity-rows/hui-update-entity-row"),
+ "valve-entity": () => import("../entity-rows/hui-valve-entity-row"),
conditional: () => import("../special-rows/hui-conditional-row"),
"weather-entity": () => import("../entity-rows/hui-weather-entity-row"),
divider: () => import("../special-rows/hui-divider-row"),
@@ -94,6 +95,7 @@ const DOMAIN_TO_ELEMENT_TYPE = {
timer: "timer",
update: "update",
vacuum: "toggle",
+ valve: "valve",
// Temporary. Once climate is rewritten,
// water heater should get its own row.
water_heater: "climate",
diff --git a/src/panels/lovelace/entity-rows/hui-valve-entity-row.ts b/src/panels/lovelace/entity-rows/hui-valve-entity-row.ts
new file mode 100644
index 0000000000..1d836e2ddb
--- /dev/null
+++ b/src/panels/lovelace/entity-rows/hui-valve-entity-row.ts
@@ -0,0 +1,73 @@
+import {
+ css,
+ CSSResultGroup,
+ html,
+ LitElement,
+ PropertyValues,
+ nothing,
+} from "lit";
+import { customElement, property, state } from "lit/decorators";
+import "../../../components/ha-valve-controls";
+import { ValveEntity } from "../../../data/valve";
+import { HomeAssistant } from "../../../types";
+import { hasConfigOrEntityChanged } from "../common/has-changed";
+import "../components/hui-generic-entity-row";
+import { createEntityNotFoundWarning } from "../components/hui-warning";
+import { EntityConfig, LovelaceRow } from "./types";
+
+@customElement("hui-valve-entity-row")
+class HuiValveEntityRow extends LitElement implements LovelaceRow {
+ @property({ attribute: false }) public hass?: HomeAssistant;
+
+ @state() private _config?: EntityConfig;
+
+ public setConfig(config: EntityConfig): void {
+ if (!config) {
+ throw new Error("Invalid configuration");
+ }
+ this._config = config;
+ }
+
+ protected shouldUpdate(changedProps: PropertyValues): boolean {
+ return hasConfigOrEntityChanged(this, changedProps);
+ }
+
+ protected render() {
+ if (!this._config || !this.hass) {
+ return nothing;
+ }
+
+ const stateObj = this.hass.states[this._config.entity] as ValveEntity;
+
+ if (!stateObj) {
+ return html`
+
+ ${createEntityNotFoundWarning(this.hass, this._config.entity)}
+
+ `;
+ }
+
+ return html`
+
+
+
+ `;
+ }
+
+ static get styles(): CSSResultGroup {
+ return css`
+ ha-valve-controls {
+ margin-right: -0.57em;
+ }
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "hui-valve-entity-row": HuiValveEntityRow;
+ }
+}
diff --git a/src/resources/ha-style.ts b/src/resources/ha-style.ts
index fa4841c755..21d9b18214 100644
--- a/src/resources/ha-style.ts
+++ b/src/resources/ha-style.ts
@@ -174,6 +174,7 @@ const mainStyles = css`
--state-switch-active-color: var(--amber-color);
--state-update-active-color: var(--orange-color);
--state-vacuum-active-color: var(--teal-color);
+ --state-valve-active-color: var(--blue-color);
--state-sensor-battery-high-color: var(--green-color);
--state-sensor-battery-low-color: var(--red-color);
--state-sensor-battery-medium-color: var(--orange-color);
diff --git a/src/state-control/valve/ha-state-control-valve-buttons.ts b/src/state-control/valve/ha-state-control-valve-buttons.ts
new file mode 100644
index 0000000000..5421741087
--- /dev/null
+++ b/src/state-control/valve/ha-state-control-valve-buttons.ts
@@ -0,0 +1,145 @@
+import { mdiStop, mdiValveClosed, mdiValveOpen } from "@mdi/js";
+import {
+ CSSResultGroup,
+ LitElement,
+ TemplateResult,
+ css,
+ html,
+ nothing,
+} from "lit";
+import { customElement, property } from "lit/decorators";
+import { repeat } from "lit/directives/repeat";
+import memoizeOne from "memoize-one";
+import { supportsFeature } from "../../common/entity/supports-feature";
+import "../../components/ha-control-button";
+import "../../components/ha-control-button-group";
+import "../../components/ha-control-slider";
+import "../../components/ha-svg-icon";
+import {
+ ValveEntity,
+ ValveEntityFeature,
+ canClose,
+ canOpen,
+ canStop,
+} from "../../data/valve";
+import { HomeAssistant } from "../../types";
+
+type ValveButton = "open" | "close" | "stop" | "none";
+
+export const getValveButtons = memoizeOne(
+ (stateObj: ValveEntity): ValveButton[] => {
+ const supportsOpen = supportsFeature(stateObj, ValveEntityFeature.OPEN);
+ const supportsClose = supportsFeature(stateObj, ValveEntityFeature.CLOSE);
+ const supportsStop = supportsFeature(stateObj, ValveEntityFeature.STOP);
+
+ const buttons: ValveButton[] = [];
+ if (supportsOpen) buttons.push("open");
+ if (supportsStop) buttons.push("stop");
+ if (supportsClose) buttons.push("close");
+ return buttons;
+ }
+);
+
+@customElement("ha-state-control-valve-buttons")
+export class HaStateControlValveButtons extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property({ attribute: false }) public stateObj!: ValveEntity;
+
+ private _onOpenTap(ev): void {
+ ev.stopPropagation();
+ this.hass!.callService("valve", "open_valve", {
+ entity_id: this.stateObj!.entity_id,
+ });
+ }
+
+ private _onCloseTap(ev): void {
+ ev.stopPropagation();
+ this.hass!.callService("valve", "close_valve", {
+ entity_id: this.stateObj!.entity_id,
+ });
+ }
+
+ private _onStopTap(ev): void {
+ ev.stopPropagation();
+ this.hass!.callService("valve", "stop_valve", {
+ entity_id: this.stateObj!.entity_id,
+ });
+ }
+
+ protected renderButton(button: ValveButton | undefined) {
+ if (button === "open") {
+ return html`
+
+
+
+ `;
+ }
+ if (button === "close") {
+ return html`
+
+
+
+ `;
+ }
+ if (button === "stop") {
+ return html`
+
+
+
+ `;
+ }
+ return nothing;
+ }
+
+ protected render(): TemplateResult {
+ const buttons = getValveButtons(this.stateObj);
+
+ return html`
+
+ ${repeat(
+ buttons,
+ (button) => button,
+ (button) => this.renderButton(button)
+ )}
+
+ `;
+ }
+
+ static get styles(): CSSResultGroup {
+ return css`
+ ha-control-button-group {
+ height: 45vh;
+ max-height: 320px;
+ min-height: 200px;
+ --control-button-group-spacing: 6px;
+ --control-button-group-thickness: 100px;
+ }
+ ha-control-button {
+ --control-button-border-radius: 18px;
+ --mdc-icon-size: 24px;
+ }
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ha-state-control-valve-buttons": HaStateControlValveButtons;
+ }
+}
diff --git a/src/state-control/valve/ha-state-control-valve-position.ts b/src/state-control/valve/ha-state-control-valve-position.ts
new file mode 100644
index 0000000000..992c960561
--- /dev/null
+++ b/src/state-control/valve/ha-state-control-valve-position.ts
@@ -0,0 +1,88 @@
+import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
+import { customElement, property, state } from "lit/decorators";
+import { styleMap } from "lit/directives/style-map";
+import { computeAttributeNameDisplay } from "../../common/entity/compute_attribute_display";
+import { stateColorCss } from "../../common/entity/state_color";
+import "../../components/ha-control-slider";
+import { CoverEntity } from "../../data/cover";
+import { UNAVAILABLE } from "../../data/entity";
+import { DOMAIN_ATTRIBUTES_UNITS } from "../../data/entity_attributes";
+import { HomeAssistant } from "../../types";
+
+@customElement("ha-state-control-valve-position")
+export class HaStateControlValvePosition extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property({ attribute: false }) public stateObj!: CoverEntity;
+
+ @state() value?: number;
+
+ protected updated(changedProp: Map): void {
+ if (changedProp.has("stateObj")) {
+ const currentPosition = this.stateObj?.attributes.current_position;
+ this.value =
+ currentPosition != null ? Math.round(currentPosition) : undefined;
+ }
+ }
+
+ private _valueChanged(ev: CustomEvent) {
+ const value = (ev.detail as any).value;
+ if (isNaN(value)) return;
+
+ this.hass.callService("valve", "set_valve_position", {
+ entity_id: this.stateObj!.entity_id,
+ position: value,
+ });
+ }
+
+ protected render(): TemplateResult {
+ const color = stateColorCss(this.stateObj);
+
+ return html`
+
+
+ `;
+ }
+
+ static get styles(): CSSResultGroup {
+ return css`
+ ha-control-slider {
+ height: 45vh;
+ max-height: 320px;
+ min-height: 200px;
+ --control-slider-thickness: 100px;
+ --control-slider-border-radius: 24px;
+ --control-slider-color: var(--primary-color);
+ --control-slider-background: var(--disabled-color);
+ --control-slider-background-opacity: 0.2;
+ --control-slider-tooltip-font-size: 20px;
+ }
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ha-state-control-valve-position": HaStateControlValvePosition;
+ }
+}
diff --git a/src/state-control/valve/ha-state-control-valve-toggle.ts b/src/state-control/valve/ha-state-control-valve-toggle.ts
new file mode 100644
index 0000000000..2eb0dbd934
--- /dev/null
+++ b/src/state-control/valve/ha-state-control-valve-toggle.ts
@@ -0,0 +1,167 @@
+import { HassEntity } from "home-assistant-js-websocket";
+import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
+import { customElement, property } from "lit/decorators";
+import { classMap } from "lit/directives/class-map";
+import { styleMap } from "lit/directives/style-map";
+import { domainIcon } from "../../common/entity/domain_icon";
+import { stateColorCss } from "../../common/entity/state_color";
+import "../../components/ha-control-button";
+import "../../components/ha-control-switch";
+import { UNAVAILABLE, UNKNOWN } from "../../data/entity";
+import { forwardHaptic } from "../../data/haptics";
+import { HomeAssistant } from "../../types";
+
+@customElement("ha-state-control-valve-toggle")
+export class HaStateControlValveToggle extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property({ attribute: false }) public stateObj!: HassEntity;
+
+ private _valueChanged(ev) {
+ const checked = ev.target.checked as boolean;
+
+ if (checked) {
+ this._turnOn();
+ } else {
+ this._turnOff();
+ }
+ }
+
+ private _turnOn() {
+ this._callService(true);
+ }
+
+ private _turnOff() {
+ this._callService(false);
+ }
+
+ private async _callService(turnOn): Promise {
+ if (!this.hass || !this.stateObj) {
+ return;
+ }
+ forwardHaptic("light");
+
+ await this.hass.callService(
+ "valve",
+ turnOn ? "open_valve" : "close_valve",
+ {
+ entity_id: this.stateObj.entity_id,
+ }
+ );
+ }
+
+ protected render(): TemplateResult {
+ const onColor = stateColorCss(this.stateObj, "open");
+ const offColor = stateColorCss(this.stateObj, "closed");
+
+ const isOn =
+ this.stateObj.state === "open" ||
+ this.stateObj.state === "closing" ||
+ this.stateObj.state === "opening";
+ const isOff = this.stateObj.state === "closed";
+
+ if (
+ this.stateObj.attributes.assumed_state ||
+ this.stateObj.state === UNKNOWN
+ ) {
+ return html`
+
+
+
+
+
+
+
+
+ `;
+ }
+
+ return html`
+
+
+ `;
+ }
+
+ static get styles(): CSSResultGroup {
+ return css`
+ ha-control-switch {
+ height: 45vh;
+ max-height: 320px;
+ min-height: 200px;
+ --control-switch-thickness: 100px;
+ --control-switch-border-radius: 24px;
+ --control-switch-padding: 6px;
+ --mdc-icon-size: 24px;
+ }
+ .buttons {
+ display: flex;
+ flex-direction: column;
+ width: 100px;
+ height: 45vh;
+ max-height: 320px;
+ min-height: 200px;
+ padding: 6px;
+ box-sizing: border-box;
+ }
+ ha-control-button {
+ flex: 1;
+ width: 100%;
+ --control-button-border-radius: 18px;
+ --mdc-icon-size: 24px;
+ }
+ ha-control-button.active {
+ --control-button-icon-color: white;
+ --control-button-background-color: var(--color);
+ --control-button-background-opacity: 1;
+ }
+ ha-control-button:not(:last-child) {
+ margin-bottom: 6px;
+ }
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ha-state-control-valve-toggle": HaStateControlValveToggle;
+ }
+}
diff --git a/src/translations/en.json b/src/translations/en.json
index 793887c840..0fbc62fdb2 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -255,6 +255,11 @@
"turn_off": "Turn off"
}
},
+ "valve": {
+ "open_valve": "Open valve",
+ "close_valve": "Close valve",
+ "stop_valve": "Stop valve"
+ },
"water_heater": {
"currently": "[%key:ui::card::climate::currently%]",
"on_off": "On / off",
@@ -1094,6 +1099,12 @@
"start_mowing": "Start mowing",
"pause": "Pause",
"dock": "Return to dock"
+ },
+ "valve": {
+ "switch_mode": {
+ "button": "[%key:ui::dialogs::more_info_control::valve::switch_mode::button%]",
+ "position": "[%key:ui::dialogs::more_info_control::valve::switch_mode::position%]"
+ }
}
},
"entity_registry": {