diff --git a/src/components/ha-control-button.ts b/src/components/ha-control-button.ts index 8b4437b743..8c8f467f76 100644 --- a/src/components/ha-control-button.ts +++ b/src/components/ha-control-button.ts @@ -85,6 +85,7 @@ export class HaControlButton extends LitElement { --control-button-background-opacity: 0.2; --control-button-border-radius: 10px; --mdc-icon-size: 20px; + color: var(--primary-text-color); width: 40px; height: 40px; -webkit-tap-highlight-color: transparent; @@ -110,6 +111,8 @@ export class HaControlButton extends LitElement { --mdc-ripple-color: var(--control-button-background-color); /* For safari border-radius overflow */ z-index: 0; + font-size: inherit; + color: inherit; } .button::before { content: ""; diff --git a/src/components/ha-control-select.ts b/src/components/ha-control-select.ts index 7689c148d1..8a2085cfa1 100644 --- a/src/components/ha-control-select.ts +++ b/src/components/ha-control-select.ts @@ -100,10 +100,11 @@ export class HaControlSelect extends LitElement { private _handleKeydown(ev: KeyboardEvent) { if (!this.options || this._activeIndex == null || this.disabled) return; + const value = this.options[this._activeIndex].value; switch (ev.key) { case " ": - this.value = this.options[this._activeIndex].value; - fireEvent(this, "value-changed", { value: this.value }); + this.value = value; + fireEvent(this, "value-changed", { value }); break; case "ArrowUp": case "ArrowLeft": @@ -132,7 +133,7 @@ export class HaControlSelect extends LitElement { if (this.disabled) return; const value = (ev.target as any).value; this.value = value; - fireEvent(this, "value-changed", { value: this.value }); + fireEvent(this, "value-changed", { value }); } private _handleOptionMouseDown(ev: MouseEvent) { diff --git a/src/data/alarm_control_panel.ts b/src/data/alarm_control_panel.ts index 20ecbaa8ce..112c993424 100644 --- a/src/data/alarm_control_panel.ts +++ b/src/data/alarm_control_panel.ts @@ -1,3 +1,7 @@ +import { + HassEntityAttributeBase, + HassEntityBase, +} from "home-assistant-js-websocket"; import { HomeAssistant } from "../types"; export const FORMAT_TEXT = "text"; @@ -12,6 +16,16 @@ export const enum AlarmControlPanelEntityFeature { ARM_VACATION = 32, } +interface AlarmControlPanelEntityAttributes extends HassEntityAttributeBase { + code_format?: "text" | "number"; + changed_by?: string | null; + code_arm_required?: boolean; +} + +export interface AlarmControlPanelEntity extends HassEntityBase { + attributes: AlarmControlPanelEntityAttributes; +} + export const callAlarmAction = ( hass: HomeAssistant, entity: string, diff --git a/src/dialogs/more-info/components/alarm_control_panel/dialog-enter-code.ts b/src/dialogs/more-info/components/alarm_control_panel/dialog-enter-code.ts new file mode 100644 index 0000000000..62a1b6cb32 --- /dev/null +++ b/src/dialogs/more-info/components/alarm_control_panel/dialog-enter-code.ts @@ -0,0 +1,248 @@ +import "@material/web/button/filled-button"; +import "@material/web/iconbutton/filled-icon-button"; +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 { EnterCodeDialogParams } from "./show-enter-code-dialog"; + +const BUTTONS = [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "0", + "clear", + "submit", +]; + +@customElement("dialog-enter-code") +export class DialogEnterCode + extends LitElement + implements HassDialog +{ + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() private _dialogParams?: EnterCodeDialogParams; + + @query("#code") private _input?: HaTextField; + + @state() private _showClearButton = false; + + public async showDialog(dialogParams: EnterCodeDialogParams): Promise { + this._dialogParams = dialogParams; + await this.updateComplete; + } + + public closeDialog(): void { + if (this._dialogParams?.cancel) { + this._dialogParams.cancel(); + } + this._dialogParams = undefined; + this._showClearButton = false; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + private _submit(): void { + this._dialogParams?.submit?.(this._input?.value ?? ""); + this._dialogParams = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + private _numberClick(e: MouseEvent): void { + const val = (e.currentTarget! as any).value; + this._input!.value = this._input!.value + val; + this._showClearButton = true; + } + + private _clear(): void { + this._input!.value = ""; + this._showClearButton = false; + } + + private _inputValueChange(e) { + const val = (e.currentTarget! as any).value; + this._showClearButton = !!val; + } + + protected render() { + if (!this._dialogParams || !this.hass) { + return nothing; + } + + const isText = this._dialogParams.codeFormat === "text"; + + if (isText) { + return html` + + + + ${this._dialogParams.cancelText ?? + this.hass.localize("ui.common.cancel")} + + + ${this._dialogParams.submitText ?? + this.hass.localize("ui.common.submit")} + + + `; + } + + return html` + +
+ +
+ ${BUTTONS.map((value) => + value === "" + ? html`` + : value === "clear" + ? html` + + + + ` + : value === "submit" + ? html` + + + + ` + : html` + + ${value} + + ` + )} +
+
+
+ `; + } + + static get styles(): CSSResultGroup { + return css` + ha-dialog { + --mdc-dialog-heading-ink-color: var(--primary-text-color); + --mdc-dialog-content-ink-color: var(--primary-text-color); + /* Place above other dialogs */ + --dialog-z-index: 104; + } + ha-textfield { + width: 100%; + max-width: 300px; + margin: auto; + } + .container { + display: flex; + align-items: center; + flex-direction: column; + } + .keypad { + --keypad-columns: 3; + margin-top: 12px; + padding: 12px; + display: grid; + grid-template-columns: repeat(var(--keypad-columns), auto); + grid-auto-rows: auto; + grid-gap: 24px; + justify-items: center; + align-items: center; + } + .clear { + grid-row-start: 4; + grid-column-start: 0; + } + @media all and (max-height: 450px) { + .keypad { + --keypad-columns: 6; + } + .clear { + grid-row-start: 1; + grid-column-start: 6; + } + } + + ha-control-button { + width: 56px; + height: 56px; + --control-button-border-radius: 28px; + --mdc-icon-size: 24px; + font-size: 24px; + } + .submit { + --control-button-background-color: var(--green-color); + --control-button-icon-color: var(--green-color); + } + .clear { + --control-button-background-color: var(--red-color); + --control-button-icon-color: var(--red-color); + } + .hidden { + display: none; + } + .buttons { + margin-top: 12px; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-enter-code": DialogEnterCode; + } +} diff --git a/src/dialogs/more-info/components/alarm_control_panel/ha-more-info-alarm_control_panel-modes.ts b/src/dialogs/more-info/components/alarm_control_panel/ha-more-info-alarm_control_panel-modes.ts new file mode 100644 index 0000000000..457e9f072e --- /dev/null +++ b/src/dialogs/more-info/components/alarm_control_panel/ha-more-info-alarm_control_panel-modes.ts @@ -0,0 +1,216 @@ +import { + mdiAirplane, + mdiHome, + mdiLock, + mdiMoonWaningCrescent, + mdiShield, + mdiShieldOff, +} from "@mdi/js"; +import { HassEntity } from "home-assistant-js-websocket"; +import { css, CSSResultGroup, html, LitElement } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { styleMap } from "lit/directives/style-map"; +import memoizeOne from "memoize-one"; +import { computeAttributeNameDisplay } from "../../../../common/entity/compute_attribute_display"; +import { stateColorCss } from "../../../../common/entity/state_color"; +import { supportsFeature } from "../../../../common/entity/supports-feature"; +import "../../../../components/ha-control-select"; +import type { ControlSelectOption } from "../../../../components/ha-control-select"; +import "../../../../components/ha-control-slider"; +import { + AlarmControlPanelEntity, + AlarmControlPanelEntityFeature, +} from "../../../../data/alarm_control_panel"; +import { HomeAssistant } from "../../../../types"; +import { showEnterCodeDialogDialog } from "./show-enter-code-dialog"; + +type AlarmMode = + | "away" + | "home" + | "night" + | "vacation" + | "custom_bypass" + | "disarmed"; + +type AlarmConfig = { + service: string; + feature?: AlarmControlPanelEntityFeature; + state: string; + path: string; +}; +const ALARM_MODES: Record = { + away: { + feature: AlarmControlPanelEntityFeature.ARM_AWAY, + service: "alarm_arm_away", + state: "armed_away", + path: mdiLock, + }, + home: { + feature: AlarmControlPanelEntityFeature.ARM_HOME, + service: "alarm_arm_home", + state: "armed_home", + path: mdiHome, + }, + custom_bypass: { + feature: AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS, + service: "alarm_arm_custom_bypass", + state: "armed_custom_bypass", + path: mdiShield, + }, + night: { + feature: AlarmControlPanelEntityFeature.ARM_NIGHT, + service: "alarm_arm_night", + state: "armed_night", + path: mdiMoonWaningCrescent, + }, + vacation: { + feature: AlarmControlPanelEntityFeature.ARM_VACATION, + service: "alarm_arm_vacation", + state: "armed_vacation", + path: mdiAirplane, + }, + disarmed: { + service: "alarm_disarm", + state: "disarmed", + path: mdiShieldOff, + }, +}; + +@customElement("ha-more-info-alarm_control_panel-modes") +export class HaMoreInfoAlarmControlPanelModes extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public stateObj!: AlarmControlPanelEntity; + + @state() _currentMode?: AlarmMode; + + private _modes = memoizeOne((stateObj: AlarmControlPanelEntity) => { + const modes = Object.keys(ALARM_MODES) as AlarmMode[]; + return modes.filter((mode) => { + const feature = ALARM_MODES[mode as AlarmMode].feature; + return !feature || supportsFeature(stateObj, feature); + }); + }); + + protected updated(changedProp: Map): void { + super.updated(changedProp); + if (changedProp.has("stateObj") && this.stateObj) { + const oldStateObj = changedProp.get("stateObj") as HassEntity | undefined; + + if (!oldStateObj || this.stateObj.state !== oldStateObj.state) { + this._currentMode = this._getCurrentMode(this.stateObj); + } + } + } + + private _getCurrentMode(stateObj: AlarmControlPanelEntity) { + return this._modes(stateObj).find( + (mode) => ALARM_MODES[mode].state === stateObj.state + ); + } + + private async _valueChanged(ev: CustomEvent) { + const mode = (ev.detail as any).value as AlarmMode; + + this._currentMode = mode; + + const { state: modeState, service } = ALARM_MODES[mode]; + + if (modeState === this.stateObj.state) return; + + let code: string | undefined; + + if ( + (mode !== "disarmed" && + this.stateObj.attributes.code_arm_required && + this.stateObj.attributes.code_format) || + (mode === "disarmed" && this.stateObj.attributes.code_format) + ) { + const disarm = mode === "disarmed"; + + const response = await showEnterCodeDialogDialog(this, { + codeFormat: this.stateObj.attributes.code_format, + title: this.hass.localize( + `ui.dialogs.more_info_control.alarm_control_panel.${ + disarm ? "disarm_title" : "arm_title" + }` + ), + submitText: this.hass.localize( + `ui.dialogs.more_info_control.alarm_control_panel.${ + disarm ? "disarm_action" : "arm_action" + }` + ), + }); + if (!response) { + this._currentMode = this._getCurrentMode(this.stateObj); + return; + } + code = response; + } + + try { + await this.hass.callService("alarm_control_panel", service, { + entity_id: this.stateObj!.entity_id, + code, + }); + } catch (_err) { + this._currentMode = this._getCurrentMode(this.stateObj); + } + } + + protected render() { + const color = stateColorCss(this.stateObj); + + const modes = this._modes(this.stateObj); + + const options = modes.map((mode) => ({ + value: mode, + label: this.hass.localize( + `ui.dialogs.more_info_control.alarm_control_panel.modes.${mode}` + ), + path: ALARM_MODES[mode].path, + })); + + return html` + + + `; + } + + static get styles(): CSSResultGroup { + return css` + ha-control-select { + height: 45vh; + max-height: max(320px, var(--modes-count, 1) * 80px); + min-height: max(200px, var(--modes-count, 1) * 80px); + --control-select-thickness: 100px; + --control-select-border-radius: 24px; + --control-select-color: var(--primary-color); + --control-select-background: var(--disabled-color); + --control-select-background-opacity: 0.2; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-more-info-alarm_control_panel-modes": HaMoreInfoAlarmControlPanelModes; + } +} diff --git a/src/dialogs/more-info/components/alarm_control_panel/show-enter-code-dialog.ts b/src/dialogs/more-info/components/alarm_control_panel/show-enter-code-dialog.ts new file mode 100644 index 0000000000..802ae23fb1 --- /dev/null +++ b/src/dialogs/more-info/components/alarm_control_panel/show-enter-code-dialog.ts @@ -0,0 +1,39 @@ +import { fireEvent } from "../../../../common/dom/fire_event"; + +export interface EnterCodeDialogParams { + codeFormat: "text" | "number"; + submitText?: string; + cancelText?: string; + title?: string; + submit?: (code?: string) => void; + cancel?: () => void; +} + +export const showEnterCodeDialogDialog = ( + element: HTMLElement, + dialogParams: EnterCodeDialogParams +) => + new Promise((resolve) => { + const origCancel = dialogParams.cancel; + const origSubmit = dialogParams.submit; + + fireEvent(element, "show-dialog", { + dialogTag: "dialog-enter-code", + dialogImport: () => import("./dialog-enter-code"), + dialogParams: { + ...dialogParams, + cancel: () => { + resolve(null); + if (origCancel) { + origCancel(); + } + }, + submit: (code: string) => { + resolve(code); + if (origSubmit) { + origSubmit(code); + } + }, + }, + }); + }); diff --git a/src/dialogs/more-info/const.ts b/src/dialogs/more-info/const.ts index e6f48400d2..e9304a55fc 100644 --- a/src/dialogs/more-info/const.ts +++ b/src/dialogs/more-info/const.ts @@ -17,6 +17,7 @@ export const EDITABLE_DOMAINS_WITH_ID = ["scene", "automation"]; export const EDITABLE_DOMAINS_WITH_UNIQUE_ID = ["script"]; /** Domains with with new more info design. */ export const DOMAINS_WITH_NEW_MORE_INFO = [ + "alarm_control_panel", "cover", "fan", "input_boolean", diff --git a/src/dialogs/more-info/controls/more-info-alarm_control_panel.ts b/src/dialogs/more-info/controls/more-info-alarm_control_panel.ts index 9410c20e33..02ed0296cf 100644 --- a/src/dialogs/more-info/controls/more-info-alarm_control_panel.ts +++ b/src/dialogs/more-info/controls/more-info-alarm_control_panel.ts @@ -1,70 +1,46 @@ -import "@material/mwc-button"; -import type { HassEntity } from "home-assistant-js-websocket"; -import { css, html, LitElement, PropertyValues, nothing } from "lit"; -import { customElement, property, query, state } from "lit/decorators"; -import { classMap } from "lit/directives/class-map"; -import { supportsFeature } from "../../../common/entity/supports-feature"; -import "../../../components/ha-textfield"; -import type { HaTextField } from "../../../components/ha-textfield"; -import { - AlarmControlPanelEntityFeature, - callAlarmAction, - FORMAT_NUMBER, -} from "../../../data/alarm_control_panel"; +import "@material/web/button/outlined-button"; +import { mdiShieldOff } 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 { AlarmControlPanelEntity } from "../../../data/alarm_control_panel"; import type { HomeAssistant } from "../../../types"; - -const BUTTONS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "", "0", "clear"]; -const DISARM_ACTIONS = ["disarm"]; +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"; @customElement("more-info-alarm_control_panel") -export class MoreInfoAlarmControlPanel extends LitElement { +class MoreInfoAlarmControlPanel extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; - @property({ attribute: false }) public stateObj?: HassEntity; + @property({ attribute: false }) public stateObj?: AlarmControlPanelEntity; - @state() private _armActions: string[] = []; + private async _disarm() { + let code: string | undefined; - @query("#alarmCode") private _input?: HaTextField; - - public willUpdate(changedProps: PropertyValues) { - super.willUpdate(changedProps); - - if (!this.stateObj || !changedProps.has("stateObj")) { - return; + if (this.stateObj!.attributes.code_format) { + const response = await showEnterCodeDialogDialog(this, { + codeFormat: this.stateObj!.attributes.code_format, + title: this.hass.localize( + "ui.dialogs.more_info_control.alarm_control_panel.disarm_title" + ), + submitText: this.hass.localize( + "ui.dialogs.more_info_control.alarm_control_panel.disarm_action" + ), + }); + if (!response) { + return; + } + code = response; } - this._armActions = []; - if ( - supportsFeature(this.stateObj, AlarmControlPanelEntityFeature.ARM_HOME) - ) { - this._armActions.push("arm_home"); - } - if ( - supportsFeature(this.stateObj, AlarmControlPanelEntityFeature.ARM_AWAY) - ) { - this._armActions.push("arm_away"); - } - if ( - supportsFeature(this.stateObj, AlarmControlPanelEntityFeature.ARM_NIGHT) - ) { - this._armActions.push("arm_night"); - } - if ( - supportsFeature( - this.stateObj, - AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS - ) - ) { - this._armActions.push("arm_custom_bypass"); - } - if ( - supportsFeature( - this.stateObj, - AlarmControlPanelEntityFeature.ARM_VACATION - ) - ) { - this._armActions.push("arm_vacation"); - } + this.hass.callService("alarm_control_panel", "alarm_disarm", { + entity_id: this.stateObj!.entity_id, + code, + }); } protected render() { @@ -72,134 +48,104 @@ export class MoreInfoAlarmControlPanel extends LitElement { return nothing; } + const color = stateColorCss(this.stateObj); + const style = { + "--icon-color": color, + }; return html` - ${!this.stateObj.attributes.code_format - ? "" - : html` -
- -
- `} - ${this.stateObj.attributes.code_format !== FORMAT_NUMBER - ? "" - : html` -
- ${BUTTONS.map((value) => - value === "" - ? html`` - : html` - - ${value === "clear" - ? this.hass!.localize( - `ui.card.alarm_control_panel.clear_code` - ) - : value} - - ` - )} -
- `} -
- ${(this.stateObj.state === "disarmed" - ? this._armActions - : DISARM_ACTIONS - ).map( - (stateAction) => html` - - ${this.hass!.localize( - `ui.card.alarm_control_panel.${stateAction}` - )} - - ` - )} + +
+ ${["triggered", "arming", "pending"].includes(this.stateObj.state) + ? html` +
+ +
+ +
+ + + +
+ ` + : html` + + + `}
+ `; } - private _handlePadClick(e: MouseEvent): void { - const val = (e.currentTarget! as any).value; - this._input!.value = val === "clear" ? "" : this._input!.value + val; + static get styles(): CSSResultGroup { + return [ + moreInfoControlStyle, + css` + :host { + --icon-color: var(--primary-color); + } + md-outlined-button { + --ha-icon-display: block; + --md-sys-color-primary: var(--primary-text-color); + } + @keyframes pulse { + 0% { + opacity: 1; + } + 50% { + opacity: 0; + } + 100% { + opacity: 1; + } + } + .status { + display: flex; + align-items: center; + flex-direction: column; + } + .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; + } + .status md-outlined-button { + margin-top: 32px; + } + `, + ]; } - - private _handleActionClick(e: MouseEvent): void { - const input = this._input; - callAlarmAction( - this.hass!, - this.stateObj!.entity_id, - (e.currentTarget! as any).action, - input?.value || undefined - ); - if (input) { - input.value = ""; - } - } - - static styles = css` - ha-textfield { - display: block; - margin: 8px; - max-width: 150px; - text-align: center; - } - - #keypad { - display: flex; - justify-content: center; - flex-wrap: wrap; - margin: auto; - width: 100%; - max-width: 300px; - } - - #keypad mwc-button { - padding: 8px; - width: 30%; - box-sizing: border-box; - } - - .actions { - margin: 0; - display: flex; - flex-wrap: wrap; - justify-content: center; - } - - .actions mwc-button { - margin: 0 4px 4px; - } - - mwc-button#disarm { - color: var(--error-color); - } - - mwc-button.numberkey { - --mdc-typography-button-font-size: var(--keypad-font-size, 0.875rem); - } - - .center { - display: flex; - justify-content: center; - } - `; } declare global { diff --git a/src/resources/styles.ts b/src/resources/styles.ts index 852cb41943..a67cd35753 100644 --- a/src/resources/styles.ts +++ b/src/resources/styles.ts @@ -342,7 +342,8 @@ export const haStyleDialog = css` --ha-dialog-border-radius: 0px; } } - mwc-button.warning { + mwc-button.warning, + ha-button.warning { --mdc-theme-primary: var(--error-color); } .error { diff --git a/src/translations/en.json b/src/translations/en.json index 1c70120ab5..198014cab4 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -924,6 +924,20 @@ "medium": "Medium", "high": "High" } + }, + "alarm_control_panel": { + "modes": { + "away": "Away", + "home": "Home", + "night": "Night", + "vacation": "Vacation", + "custom_bypass": "Custom", + "disarmed": "Disarmed" + }, + "disarm_title": "Disarm", + "disarm_action": "Disarm", + "arm_title": "Arm", + "arm_action": "Arm" } }, "entity_registry": { @@ -1283,6 +1297,10 @@ "release_items": "This includes beta releases for:", "view_documentation": "View documentation", "join": "Join" + }, + "enter_code": { + "title": "Enter code", + "input_label": "Code" } }, "duration": {