Add more info lock (#15995)

* Add more info lock

* Use same height for pending state

* Fix attributes

* Add unlocking/locking to switch

* Improve code support
This commit is contained in:
Paul Bottein 2023-06-20 15:25:26 +02:00 committed by GitHub
parent 922e95b895
commit 7bc2ca3b65
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 438 additions and 65 deletions

20
src/data/lock.ts Normal file
View File

@ -0,0 +1,20 @@
import {
HassEntityAttributeBase,
HassEntityBase,
} from "home-assistant-js-websocket";
export const FORMAT_TEXT = "text";
export const FORMAT_NUMBER = "number";
export const enum LockEntityFeature {
OPEN = 1,
}
interface LockEntityAttributes extends HassEntityAttributeBase {
code_format?: string;
changed_by?: string | null;
}
export interface LockEntity extends HassEntityBase {
attributes: LockEntityAttributes;
}

View File

@ -1,14 +1,15 @@
import { mdiCheck, mdiClose } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-button";
import "../../../../components/ha-control-button";
import { createCloseHeading } from "../../../../components/ha-dialog";
import "../../../../components/ha-textfield";
import type { HaTextField } from "../../../../components/ha-textfield";
import { HomeAssistant } from "../../../../types";
import { HassDialog } from "../../../make-dialog-manager";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-button";
import "../../components/ha-control-button";
import { createCloseHeading } from "../../components/ha-dialog";
import "../../components/ha-textfield";
import type { HaTextField } from "../../components/ha-textfield";
import { HomeAssistant } from "../../types";
import { HassDialog } from "../make-dialog-manager";
import { EnterCodeDialogParams } from "./show-enter-code-dialog";
const BUTTONS = [
@ -72,7 +73,8 @@ export class DialogEnterCode
}
private _inputValueChange(e) {
const val = (e.currentTarget! as any).value;
const field = e.currentTarget as HaTextField;
const val = field.value;
this._showClearButton = !!val;
}
@ -97,6 +99,7 @@ export class DialogEnterCode
id="code"
.label=${this.hass.localize("ui.dialogs.enter_code.input_label")}
type="password"
pattern=${ifDefined(this._dialogParams.codePattern)}
input-mode="text"
></ha-textfield>
<ha-button slot="secondaryAction" dialogAction="cancel">

View File

@ -1,7 +1,8 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
export interface EnterCodeDialogParams {
codeFormat: "text" | "number";
codePattern?: string;
submitText?: string;
cancelText?: string;
title?: string;

View File

@ -14,7 +14,7 @@ import {
} from "../../../../data/alarm_control_panel";
import { UNAVAILABLE } from "../../../../data/entity";
import { HomeAssistant } from "../../../../types";
import { showEnterCodeDialogDialog } from "./show-enter-code-dialog";
import { showEnterCodeDialogDialog } from "../../../enter-code/show-enter-code-dialog";
@customElement("ha-more-info-alarm_control_panel-modes")
export class HaMoreInfoAlarmControlPanelModes extends LitElement {

View File

@ -0,0 +1,199 @@
import {
css,
CSSResultGroup,
html,
LitElement,
PropertyValues,
TemplateResult,
} from "lit";
import { customElement, property, state } from "lit/decorators";
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 { LockEntity } from "../../../../data/lock";
import { HomeAssistant } from "../../../../types";
import { showEnterCodeDialogDialog } from "../../../enter-code/show-enter-code-dialog";
@customElement("ha-more-info-lock-toggle")
export class HaMoreInfoLockToggle extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj!: LockEntity;
@state() private _isOn = false;
public willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("stateObj")) {
this._isOn =
this.stateObj.state === "locked" || this.stateObj.state === "locking";
}
}
private _valueChanged(ev) {
const checked = ev.target.checked as boolean;
if (checked) {
this._turnOn();
} else {
this._turnOff();
}
}
private async _turnOn() {
this._isOn = true;
try {
await this._callService(true);
} catch (err) {
this._isOn = false;
}
}
private async _turnOff() {
this._isOn = false;
try {
await this._callService(false);
} catch (err) {
this._isOn = true;
}
}
private async _callService(turnOn: boolean): Promise<void> {
if (!this.hass || !this.stateObj) {
return;
}
forwardHaptic("light");
let code: string | undefined;
if (this.stateObj.attributes.code_format) {
const response = await showEnterCodeDialogDialog(this, {
codeFormat: "text",
codePattern: this.stateObj.attributes.code_format,
title: this.hass.localize(
`ui.dialogs.more_info_control.lock.${turnOn ? "lock" : "unlock"}`
),
submitText: this.hass.localize(
`ui.dialogs.more_info_control.lock.${turnOn ? "lock" : "unlock"}`
),
});
if (response == null) {
throw new Error("cancel");
}
code = response;
}
await this.hass.callService("lock", turnOn ? "lock" : "unlock", {
entity_id: this.stateObj.entity_id,
code,
});
}
protected render(): TemplateResult {
const locking = this.stateObj.state === "locking";
const unlocking = this.stateObj.state === "unlocking";
const color = stateColorCss(this.stateObj);
const onIcon = domainIcon(
"lock",
this.stateObj,
locking ? "locking" : "locked"
);
const offIcon = domainIcon(
"lock",
this.stateObj,
unlocking ? "unlocking" : "unlocked"
);
if (this.stateObj.state === UNKNOWN) {
return html`
<div class="buttons">
<ha-control-button
.label=${this.hass.localize(
"ui.dialogs.more_info_control.lock.lock"
)}
@click=${this._turnOn}
>
<ha-svg-icon .path=${onIcon}></ha-svg-icon>
</ha-control-button>
<ha-control-button
.label=${this.hass.localize(
"ui.dialogs.more_info_control.lock.unlock"
)}
@click=${this._turnOff}
>
<ha-svg-icon .path=${offIcon}></ha-svg-icon>
</ha-control-button>
</div>
`;
}
return html`
<ha-control-switch
.pathOn=${onIcon}
.pathOff=${offIcon}
vertical
reversed
.checked=${this._isOn}
@change=${this._valueChanged}
.ariaLabel=${this.hass.localize("ui.dialogs.more_info_control.toggle")}
style=${styleMap({
"--control-switch-on-color": color,
"--control-switch-off-color": color,
})}
.disabled=${this.stateObj.state === UNAVAILABLE}
>
</ha-control-switch>
`;
}
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-more-info-lock-toggle": HaMoreInfoLockToggle;
}
}

View File

@ -22,6 +22,7 @@ export const DOMAINS_WITH_NEW_MORE_INFO = [
"fan",
"input_boolean",
"light",
"lock",
"siren",
"switch",
];

View File

@ -7,8 +7,8 @@ import { stateColorCss } from "../../../common/entity/state_color";
import "../../../components/ha-outlined-button";
import { AlarmControlPanelEntity } from "../../../data/alarm_control_panel";
import type { HomeAssistant } from "../../../types";
import { showEnterCodeDialogDialog } from "../../enter-code/show-enter-code-dialog";
import "../components/alarm_control_panel/ha-more-info-alarm_control_panel-modes";
import { showEnterCodeDialogDialog } from "../components/alarm_control_panel/show-enter-code-dialog";
import { moreInfoControlStyle } from "../components/ha-more-info-control-style";
import "../components/ha-more-info-state-header";

View File

@ -1,43 +1,156 @@
import "@material/mwc-button";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import "@material/web/iconbutton/outlined-icon-button";
import { mdiDoorOpen, mdiLock, mdiLockOff } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { domainIcon } from "../../../common/entity/domain_icon";
import { stateColorCss } from "../../../common/entity/state_color";
import { supportsFeature } from "../../../common/entity/supports-feature";
import "../../../components/ha-attributes";
import "../../../components/ha-textfield";
import type { HaTextField } from "../../../components/ha-textfield";
import { UNAVAILABLE } from "../../../data/entity";
import { LockEntity, LockEntityFeature } from "../../../data/lock";
import type { HomeAssistant } from "../../../types";
import { showEnterCodeDialogDialog } from "../../enter-code/show-enter-code-dialog";
import { moreInfoControlStyle } from "../components/ha-more-info-control-style";
import "../components/ha-more-info-state-header";
import "../components/lock/ha-more-info-lock-toggle";
@customElement("more-info-lock")
class MoreInfoLock extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj?: HassEntity;
@property({ attribute: false }) public stateObj?: LockEntity;
@query("ha-textfield") private _textfield?: HaTextField;
private async _open() {
this._callService("open");
}
private async _lock() {
this._callService("lock");
}
private async _unlock() {
this._callService("unlock");
}
private async _callService(service: "open" | "lock" | "unlock") {
let code: string | undefined;
if (this.stateObj!.attributes.code_format) {
const response = await showEnterCodeDialogDialog(this, {
codeFormat: "text",
codePattern: this.stateObj!.attributes.code_format,
title: this.hass.localize(
`ui.dialogs.more_info_control.lock.${service}`
),
submitText: this.hass.localize(
`ui.dialogs.more_info_control.lock.${service}`
),
});
if (!response) {
return;
}
code = response;
}
this.hass.callService("lock", service, {
entity_id: this.stateObj!.entity_id,
code,
});
}
protected render() {
if (!this.hass || !this.stateObj) {
return nothing;
}
const supportsOpen = supportsFeature(this.stateObj, LockEntityFeature.OPEN);
const color = stateColorCss(this.stateObj);
const style = {
"--icon-color": color,
};
const isJammed = this.stateObj.state === "jammed";
return html`
${this.stateObj.attributes.code_format
? html`<div class="code">
<ha-textfield
.label=${this.hass.localize("ui.card.lock.code")}
.pattern=${this.stateObj.attributes.code_format}
type="password"
></ha-textfield>
${this.stateObj.state === "locked"
? html`<mwc-button
@click=${this._callService}
data-service="unlock"
>${this.hass.localize("ui.card.lock.unlock")}</mwc-button
>`
: html`<mwc-button @click=${this._callService} data-service="lock"
>${this.hass.localize("ui.card.lock.lock")}</mwc-button
>`}
</div>`
: ""}
<ha-more-info-state-header
.hass=${this.hass}
.stateObj=${this.stateObj}
></ha-more-info-state-header>
<div class="controls" style=${styleMap(style)}>
${
this.stateObj.state === "jammed"
? html`
<div class="status">
<span></span>
<div class="icon">
<ha-svg-icon
.path=${domainIcon("lock", this.stateObj)}
></ha-svg-icon>
</div>
</div>
`
: html`
<ha-more-info-lock-toggle
.stateObj=${this.stateObj}
.hass=${this.hass}
>
</ha-more-info-lock-toggle>
`
}
${
supportsOpen || isJammed
? html`
<div class="buttons">
${supportsOpen
? html`
<md-outlined-icon-button
.disabled=${this.stateObj.state === UNAVAILABLE}
.title=${this.hass.localize(
"ui.dialogs.more_info_control.lock.open"
)}
.ariaLabel=${this.hass.localize(
"ui.dialogs.more_info_control.lock.open"
)}
@click=${this._open}
>
<ha-svg-icon .path=${mdiDoorOpen}></ha-svg-icon>
</md-outlined-icon-button>
`
: nothing}
${isJammed
? html`
<md-outlined-icon-button
.title=${this.hass.localize(
"ui.dialogs.more_info_control.lock.lock"
)}
.ariaLabel=${this.hass.localize(
"ui.dialogs.more_info_control.lock.lock"
)}
@click=${this._lock}
>
<ha-svg-icon .path=${mdiLock}></ha-svg-icon>
</md-outlined-icon-button>
<md-outlined-icon-button
.title=${this.hass.localize(
"ui.dialogs.more_info_control.lock.unlock"
)}
.ariaLabel=${this.hass.localize(
"ui.dialogs.more_info_control.lock.unlock"
)}
@click=${this._unlock}
>
<ha-svg-icon .path=${mdiLockOff}></ha-svg-icon>
</md-outlined-icon-button>
`
: nothing}
</div>
`
: nothing
}
</div>
</div>
<ha-attributes
.hass=${this.hass}
.stateObj=${this.stateObj}
@ -46,33 +159,64 @@ class MoreInfoLock extends LitElement {
`;
}
private _callService(ev) {
const service = ev.target.getAttribute("data-service");
const data = {
entity_id: this.stateObj!.entity_id,
code: this._textfield?.value,
};
this.hass.callService("lock", service, data);
static get styles(): CSSResultGroup {
return [
moreInfoControlStyle,
css`
md-outlined-icon-button {
--ha-icon-display: block;
--md-sys-color-on-surface: var(--secondary-text-color);
--md-sys-color-on-surface-variant: var(--secondary-text-color);
--md-sys-color-on-surface-rgb: var(--rgb-secondary-text-color);
--md-sys-color-outline: var(--secondary-text-color);
}
@keyframes pulse {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.status {
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
height: 45vh;
max-height: 320px;
min-height: 200px;
}
.status .icon {
position: relative;
--mdc-icon-size: 80px;
animation: pulse 1s infinite;
color: var(--icon-color);
border-radius: 50%;
width: 144px;
height: 144px;
display: flex;
align-items: center;
justify-content: center;
}
.status .icon::before {
content: "";
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
border-radius: 50%;
background-color: var(--icon-color);
transition: background-color 180ms ease-in-out;
opacity: 0.2;
}
`,
];
}
static styles = css`
:host {
display: flex;
align-items: center;
flex-direction: column;
}
.code {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
margin-bottom: 8px;
width: 100%;
}
ha-attributes {
width: 100%;
}
`;
}
declare global {

View File

@ -18,10 +18,10 @@ import {
ALARM_MODES,
} from "../../../data/alarm_control_panel";
import { UNAVAILABLE } from "../../../data/entity";
import { showEnterCodeDialogDialog } from "../../../dialogs/more-info/components/alarm_control_panel/show-enter-code-dialog";
import { HomeAssistant } from "../../../types";
import { LovelaceTileFeature, LovelaceTileFeatureEditor } from "../types";
import { AlarmModesFileFeatureConfig } from "./types";
import { showEnterCodeDialogDialog } from "../../../dialogs/enter-code/show-enter-code-dialog";
export const supportsAlarmModesTileFeature = (stateObj: HassEntity) => {
const domain = computeDomain(stateObj.entity_id);

View File

@ -978,6 +978,11 @@
"disarm_action": "Disarm",
"arm_title": "Arm",
"arm_action": "Arm"
},
"lock": {
"open": "Open",
"lock": "Lock",
"unlock": "Unlock"
}
},
"entity_registry": {