diff --git a/gallery/src/pages/lovelace/tile-card.ts b/gallery/src/pages/lovelace/tile-card.ts
index 62ab5abd23ed..58c5ad77bd2f 100644
--- a/gallery/src/pages/lovelace/tile-card.ts
+++ b/gallery/src/pages/lovelace/tile-card.ts
@@ -2,6 +2,7 @@ import { html, LitElement, PropertyValues, TemplateResult } from "lit";
import { customElement, query } from "lit/decorators";
import { CoverEntityFeature } from "../../../../src/data/cover";
import { LightColorMode } from "../../../../src/data/light";
+import { LockEntityFeature } from "../../../../src/data/lock";
import { VacuumEntityFeature } from "../../../../src/data/vacuum";
import { getEntity } from "../../../../src/fake_data/entity";
import { provideHass } from "../../../../src/fake_data/provide_hass";
@@ -20,6 +21,11 @@ const ENTITIES = [
getEntity("light", "unavailable", "unavailable", {
friendly_name: "Unavailable entity",
}),
+ getEntity("lock", "front_door", "locked", {
+ friendly_name: "Front Door Lock",
+ device_class: "lock",
+ supported_features: LockEntityFeature.OPEN,
+ }),
getEntity("climate", "thermostat", "heat", {
current_temperature: 73,
min_temp: 45,
@@ -138,6 +144,24 @@ const CONFIGS = [
- type: "color-temp"
`,
},
+ {
+ heading: "Lock commands feature",
+ config: `
+- type: tile
+ entity: lock.front_door
+ features:
+ - type: "lock-commands"
+ `,
+ },
+ {
+ heading: "Lock open door feature",
+ config: `
+- type: tile
+ entity: lock.front_door
+ features:
+ - type: "lock-open-door"
+ `,
+ },
{
heading: "Vacuum commands feature",
config: `
diff --git a/src/data/lock.ts b/src/data/lock.ts
index 7d155369f4fa..26d42178023f 100644
--- a/src/data/lock.ts
+++ b/src/data/lock.ts
@@ -5,9 +5,7 @@ import {
import { getExtendedEntityRegistryEntry } from "./entity_registry";
import { showEnterCodeDialog } from "../dialogs/enter-code/show-enter-code-dialog";
import { HomeAssistant } from "../types";
-
-export const FORMAT_TEXT = "text";
-export const FORMAT_NUMBER = "number";
+import { UNAVAILABLE } from "./entity";
export const enum LockEntityFeature {
OPEN = 1,
@@ -24,6 +22,33 @@ export interface LockEntity extends HassEntityBase {
type ProtectedLockService = "lock" | "unlock" | "open";
+export function isLocked(stateObj: LockEntity) {
+ return stateObj.state === "locked";
+}
+
+export function isUnlocking(stateObj: LockEntity) {
+ return stateObj.state === "unlocking";
+}
+
+export function isLocking(stateObj: LockEntity) {
+ return stateObj.state === "locking";
+}
+
+export function isJammed(stateObj: LockEntity) {
+ return stateObj.state === "jammed";
+}
+
+export function isAvailable(stateObj: LockEntity) {
+ if (stateObj.state === UNAVAILABLE) {
+ return false;
+ }
+ const assumedState = stateObj.attributes.assumed_state === true;
+ return (
+ assumedState ||
+ (!isLocking(stateObj) && !isUnlocking(stateObj) && !isJammed(stateObj))
+ );
+}
+
export const callProtectedLockService = async (
element: HTMLElement,
hass: HomeAssistant,
diff --git a/src/dialogs/more-info/controls/more-info-lock.ts b/src/dialogs/more-info/controls/more-info-lock.ts
index ac0f45e43ad9..2236a523518b 100644
--- a/src/dialogs/more-info/controls/more-info-lock.ts
+++ b/src/dialogs/more-info/controls/more-info-lock.ts
@@ -9,11 +9,12 @@ import "../../../components/ha-control-button";
import "../../../components/ha-control-button-group";
import "../../../components/ha-outlined-icon-button";
import "../../../components/ha-state-icon";
-import { UNAVAILABLE } from "../../../data/entity";
import {
LockEntity,
LockEntityFeature,
callProtectedLockService,
+ isAvailable,
+ isJammed,
} from "../../../data/lock";
import "../../../state-control/lock/ha-state-control-lock-toggle";
import type { HomeAssistant } from "../../../types";
@@ -85,15 +86,13 @@ class MoreInfoLock extends LitElement {
"--state-color": color,
};
- const isJammed = this.stateObj.state === "jammed";
-
return html`
- ${this.stateObj.state === "jammed"
+ ${isJammed(this.stateObj)
? html`
@@ -125,7 +124,7 @@ class MoreInfoLock extends LitElement {
`
: html`
@@ -139,7 +138,7 @@ class MoreInfoLock extends LitElement {
: nothing}
- ${isJammed
+ ${isJammed(this.stateObj)
? html`
diff --git a/src/panels/lovelace/card-features/hui-lock-commands-card-feature.ts b/src/panels/lovelace/card-features/hui-lock-commands-card-feature.ts
new file mode 100644
index 000000000000..b0763a207422
--- /dev/null
+++ b/src/panels/lovelace/card-features/hui-lock-commands-card-feature.ts
@@ -0,0 +1,127 @@
+import { mdiLock, mdiLockOpen } from "@mdi/js";
+import { HassEntity } from "home-assistant-js-websocket";
+import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
+import { customElement, property, state } from "lit/decorators";
+import { classMap } from "lit/directives/class-map";
+import { computeDomain } from "../../../common/entity/compute_domain";
+
+import "../../../components/ha-control-button";
+import "../../../components/ha-control-button-group";
+import {
+ callProtectedLockService,
+ isAvailable,
+ isLocking,
+ isUnlocking,
+ isLocked,
+} from "../../../data/lock";
+import { HomeAssistant } from "../../../types";
+import { LovelaceCardFeature } from "../types";
+import { LockCommandsCardFeatureConfig } from "./types";
+import { forwardHaptic } from "../../../data/haptics";
+
+export const supportsLockCommandsCardFeature = (stateObj: HassEntity) => {
+ const domain = computeDomain(stateObj.entity_id);
+ return domain === "lock";
+};
+
+@customElement("hui-lock-commands-card-feature")
+class HuiLockCommandsCardFeature
+ extends LitElement
+ implements LovelaceCardFeature
+{
+ @property({ attribute: false }) public hass?: HomeAssistant;
+
+ @property({ attribute: false }) public stateObj?: HassEntity;
+
+ @state() private _config?: LockCommandsCardFeatureConfig;
+
+ static getStubConfig(): LockCommandsCardFeatureConfig {
+ return {
+ type: "lock-commands",
+ };
+ }
+
+ public setConfig(config: LockCommandsCardFeatureConfig): void {
+ if (!config) {
+ throw new Error("Invalid configuration");
+ }
+ this._config = config;
+ }
+
+ private _onTap(ev): void {
+ ev.stopPropagation();
+ const service = ev.target.dataset.service;
+ if (!this.hass || !this.stateObj || !service) {
+ return;
+ }
+ forwardHaptic("light");
+ callProtectedLockService(this, this.hass, this.stateObj, service);
+ }
+
+ protected render() {
+ if (
+ !this._config ||
+ !this.hass ||
+ !this.stateObj ||
+ !supportsLockCommandsCardFeature(this.stateObj)
+ ) {
+ return nothing;
+ }
+
+ return html`
+
+
+
+
+
+
+
+
+ `;
+ }
+
+ static get styles(): CSSResultGroup {
+ return css`
+ @keyframes pulse {
+ 0% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0;
+ }
+ 100% {
+ opacity: 1;
+ }
+ }
+ .pulse {
+ animation: pulse 1s infinite;
+ }
+ ha-control-button-group {
+ margin: 0 12px 12px 12px;
+ --control-button-group-spacing: 12px;
+ }
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "hui-lock-commands-card-feature": HuiLockCommandsCardFeature;
+ }
+}
diff --git a/src/panels/lovelace/card-features/hui-lock-open-door-card-feature.ts b/src/panels/lovelace/card-features/hui-lock-open-door-card-feature.ts
new file mode 100644
index 000000000000..4b13e4432338
--- /dev/null
+++ b/src/panels/lovelace/card-features/hui-lock-open-door-card-feature.ts
@@ -0,0 +1,158 @@
+import { mdiCheck } from "@mdi/js";
+import { HassEntity } from "home-assistant-js-websocket";
+import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
+import { customElement, property, state } from "lit/decorators";
+import { computeDomain } from "../../../common/entity/compute_domain";
+
+import { supportsFeature } from "../../../common/entity/supports-feature";
+import "../../../components/ha-control-button";
+import "../../../components/ha-control-button-group";
+import {
+ LockEntityFeature,
+ callProtectedLockService,
+ isAvailable,
+} from "../../../data/lock";
+import { HomeAssistant } from "../../../types";
+import { LovelaceCardFeature } from "../types";
+import { LockOpenDoorCardFeatureConfig } from "./types";
+
+export const supportsLockOpenDoorCardFeature = (stateObj: HassEntity) => {
+ const domain = computeDomain(stateObj.entity_id);
+ return domain === "lock" && supportsFeature(stateObj, LockEntityFeature.OPEN);
+};
+
+const CONFIRM_TIMEOUT_SECOND = 5;
+const OPENED_TIMEOUT_SECOND = 3;
+
+type ButtonState = "normal" | "confirm" | "success";
+
+@customElement("hui-lock-open-door-card-feature")
+class HuiLockOpenDoorCardFeature
+ extends LitElement
+ implements LovelaceCardFeature
+{
+ @property({ attribute: false }) public hass?: HomeAssistant;
+
+ @property({ attribute: false }) public stateObj?: HassEntity;
+
+ @state() public _buttonState: ButtonState = "normal";
+
+ @state() private _config?: LockOpenDoorCardFeatureConfig;
+
+ private _buttonTimeout?: number;
+
+ static getStubConfig(): LockOpenDoorCardFeatureConfig {
+ return {
+ type: "lock-open-door",
+ };
+ }
+
+ public setConfig(config: LockOpenDoorCardFeatureConfig): void {
+ if (!config) {
+ throw new Error("Invalid configuration");
+ }
+ this._config = config;
+ }
+
+ private _setButtonState(buttonState: ButtonState, timeoutSecond?: number) {
+ clearTimeout(this._buttonTimeout);
+ this._buttonState = buttonState;
+ if (timeoutSecond) {
+ this._buttonTimeout = window.setTimeout(() => {
+ this._buttonState = "normal";
+ }, timeoutSecond * 1000);
+ }
+ }
+
+ private async _open() {
+ if (this._buttonState !== "confirm") {
+ this._setButtonState("confirm", CONFIRM_TIMEOUT_SECOND);
+ return;
+ }
+ if (!this.hass || !this.stateObj) {
+ return;
+ }
+ callProtectedLockService(this, this.hass, this.stateObj!, "open");
+
+ this._setButtonState("success", OPENED_TIMEOUT_SECOND);
+ }
+
+ protected render() {
+ if (
+ !this._config ||
+ !this.hass ||
+ !this.stateObj ||
+ !supportsLockOpenDoorCardFeature(this.stateObj)
+ ) {
+ return nothing;
+ }
+
+ return html`
+ ${this._buttonState === "success"
+ ? html`
+
+ `
+ : html`
+
+
+ ${this._buttonState === "confirm"
+ ? this.hass.localize("ui.card.lock.open_door_confirm")
+ : this.hass.localize("ui.card.lock.open_door")}
+
+
+ `}
+ `;
+ }
+
+ static get styles(): CSSResultGroup {
+ return css`
+ .buttons {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-top: 0;
+ }
+ ha-control-button {
+ font-size: 14px;
+ }
+ ha-control-button-group {
+ margin: 0 12px 12px 12px;
+ --control-button-group-spacing: 12px;
+ }
+ .open-button {
+ width: 130px;
+ }
+ .open-button.confirm {
+ --control-button-background-color: var(--warning-color);
+ }
+ .open-success {
+ font-size: 14px;
+ line-height: 14px;
+ display: flex;
+ align-items: center;
+ flex-direction: row;
+ gap: 8px;
+ font-weight: 500;
+ color: var(--success-color);
+ }
+ ha-control-button-group + ha-attributes:not([empty]) {
+ margin-top: 16px;
+ }
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "hui-lock-open-door-card-feature": HuiLockOpenDoorCardFeature;
+ }
+}
diff --git a/src/panels/lovelace/card-features/types.ts b/src/panels/lovelace/card-features/types.ts
index 7b3771a2d86f..82fa311876a5 100644
--- a/src/panels/lovelace/card-features/types.ts
+++ b/src/panels/lovelace/card-features/types.ts
@@ -26,6 +26,14 @@ export interface LightColorTempCardFeatureConfig {
type: "light-color-temp";
}
+export interface LockCommandsCardFeatureConfig {
+ type: "lock-commands";
+}
+
+export interface LockOpenDoorCardFeatureConfig {
+ type: "lock-open-door";
+}
+
export interface FanPresetModesCardFeatureConfig {
type: "fan-preset-modes";
style?: "dropdown" | "icons";
@@ -143,6 +151,8 @@ export type LovelaceCardFeatureConfig =
| LawnMowerCommandsCardFeatureConfig
| LightBrightnessCardFeatureConfig
| LightColorTempCardFeatureConfig
+ | LockCommandsCardFeatureConfig
+ | LockOpenDoorCardFeatureConfig
| NumericInputCardFeatureConfig
| SelectOptionsCardFeatureConfig
| TargetHumidityCardFeatureConfig
diff --git a/src/panels/lovelace/create-element/create-card-feature-element.ts b/src/panels/lovelace/create-element/create-card-feature-element.ts
index 4054f4610ecc..2710c69ebf9a 100644
--- a/src/panels/lovelace/create-element/create-card-feature-element.ts
+++ b/src/panels/lovelace/create-element/create-card-feature-element.ts
@@ -14,6 +14,8 @@ import "../card-features/hui-humidifier-toggle-card-feature";
import "../card-features/hui-lawn-mower-commands-card-feature";
import "../card-features/hui-light-brightness-card-feature";
import "../card-features/hui-light-color-temp-card-feature";
+import "../card-features/hui-lock-commands-card-feature";
+import "../card-features/hui-lock-open-door-card-feature";
import "../card-features/hui-numeric-input-card-feature";
import "../card-features/hui-select-options-card-feature";
import "../card-features/hui-target-temperature-card-feature";
@@ -45,6 +47,8 @@ const TYPES: Set = new Set([
"lawn-mower-commands",
"light-brightness",
"light-color-temp",
+ "lock-commands",
+ "lock-open-door",
"numeric-input",
"select-options",
"target-humidity",
diff --git a/src/panels/lovelace/editor/config-elements/hui-card-features-editor.ts b/src/panels/lovelace/editor/config-elements/hui-card-features-editor.ts
index 444144925fa9..c4b938230a4c 100644
--- a/src/panels/lovelace/editor/config-elements/hui-card-features-editor.ts
+++ b/src/panels/lovelace/editor/config-elements/hui-card-features-editor.ts
@@ -35,6 +35,8 @@ import { supportsHumidifierToggleCardFeature } from "../../card-features/hui-hum
import { supportsLawnMowerCommandCardFeature } from "../../card-features/hui-lawn-mower-commands-card-feature";
import { supportsLightBrightnessCardFeature } from "../../card-features/hui-light-brightness-card-feature";
import { supportsLightColorTempCardFeature } from "../../card-features/hui-light-color-temp-card-feature";
+import { supportsLockCommandsCardFeature } from "../../card-features/hui-lock-commands-card-feature";
+import { supportsLockOpenDoorCardFeature } from "../../card-features/hui-lock-open-door-card-feature";
import { supportsNumericInputCardFeature } from "../../card-features/hui-numeric-input-card-feature";
import { supportsSelectOptionsCardFeature } from "../../card-features/hui-select-options-card-feature";
import { supportsTargetHumidityCardFeature } from "../../card-features/hui-target-humidity-card-feature";
@@ -56,8 +58,8 @@ const UI_FEATURE_TYPES = [
"climate-preset-modes",
"cover-open-close",
"cover-position",
- "cover-tilt-position",
"cover-tilt",
+ "cover-tilt-position",
"fan-preset-modes",
"fan-speed",
"humidifier-modes",
@@ -65,6 +67,8 @@ const UI_FEATURE_TYPES = [
"lawn-mower-commands",
"light-brightness",
"light-color-temp",
+ "lock-commands",
+ "lock-open-door",
"numeric-input",
"select-options",
"target-humidity",
@@ -111,6 +115,8 @@ const SUPPORTS_FEATURE_TYPES: Record<
"lawn-mower-commands": supportsLawnMowerCommandCardFeature,
"light-brightness": supportsLightBrightnessCardFeature,
"light-color-temp": supportsLightColorTempCardFeature,
+ "lock-commands": supportsLockCommandsCardFeature,
+ "lock-open-door": supportsLockOpenDoorCardFeature,
"numeric-input": supportsNumericInputCardFeature,
"select-options": supportsSelectOptionsCardFeature,
"target-humidity": supportsTargetHumidityCardFeature,
diff --git a/src/translations/en.json b/src/translations/en.json
index c360ded24463..18b28941f5ee 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -5948,6 +5948,12 @@
"light-color-temp": {
"label": "Light color temperature"
},
+ "lock-commands": {
+ "label": "Lock commands"
+ },
+ "lock-open-door": {
+ "label": "Lock open door"
+ },
"vacuum-commands": {
"label": "Vacuum commands",
"commands": "Commands",