diff --git a/src/components/ha-control-select.ts b/src/components/ha-control-select.ts index 0bc3ba2687..6330ef4e9e 100644 --- a/src/components/ha-control-select.ts +++ b/src/components/ha-control-select.ts @@ -177,6 +177,7 @@ export class HaControlSelect extends LitElement { .value=${option.value} aria-selected=${this.value === option.value} aria-label=${ifDefined(option.label)} + title=${ifDefined(option.label)} @click=${this._handleOptionClick} @mousedown=${this._handleOptionMouseDown} @mouseup=${this._handleOptionMouseUp} diff --git a/src/data/alarm_control_panel.ts b/src/data/alarm_control_panel.ts index 112c993424..d5f59d595d 100644 --- a/src/data/alarm_control_panel.ts +++ b/src/data/alarm_control_panel.ts @@ -1,3 +1,11 @@ +import { + mdiAirplane, + mdiHome, + mdiLock, + mdiMoonWaningCrescent, + mdiShield, + mdiShieldOff, +} from "@mdi/js"; import { HassEntityAttributeBase, HassEntityBase, @@ -43,3 +51,55 @@ export const callAlarmAction = ( code, }); }; + +export type AlarmMode = + | "away" + | "home" + | "night" + | "vacation" + | "custom_bypass" + | "disarmed"; + +type AlarmConfig = { + service: string; + feature?: AlarmControlPanelEntityFeature; + state: string; + path: string; +}; +export 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, + }, +}; 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 index 457e9f072e..2a95653f04 100644 --- 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 @@ -1,11 +1,3 @@ -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"; @@ -19,63 +11,12 @@ import type { ControlSelectOption } from "../../../../components/ha-control-sele import "../../../../components/ha-control-slider"; import { AlarmControlPanelEntity, - AlarmControlPanelEntityFeature, + AlarmMode, + ALARM_MODES, } 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; @@ -112,12 +53,15 @@ export class HaMoreInfoAlarmControlPanelModes extends LitElement { 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; + // Force ha-control-select to previous mode because we don't known if the service call will succeed due to code check + this._currentMode = mode; + await this.requestUpdate("_currentMode"); + this._currentMode = this._getCurrentMode(this.stateObj!); + let code: string | undefined; if ( @@ -142,20 +86,15 @@ export class HaMoreInfoAlarmControlPanelModes extends LitElement { ), }); 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); - } + await this.hass.callService("alarm_control_panel", service, { + entity_id: this.stateObj!.entity_id, + code, + }); } protected render() { @@ -177,8 +116,7 @@ export class HaMoreInfoAlarmControlPanelModes extends LitElement { .options=${options} .value=${this._currentMode} @value-changed=${this._valueChanged} - no-optimistic-update - .ariaLabel=${computeAttributeNameDisplay( + .label=${computeAttributeNameDisplay( this.hass.localize, this.stateObj, this.hass.entities, diff --git a/src/panels/lovelace/cards/hui-tile-card.ts b/src/panels/lovelace/cards/hui-tile-card.ts index 95339dcac5..6993c7698e 100644 --- a/src/panels/lovelace/cards/hui-tile-card.ts +++ b/src/panels/lovelace/cards/hui-tile-card.ts @@ -18,6 +18,7 @@ import { state, } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; +import { ifDefined } from "lit/directives/if-defined"; import { styleMap } from "lit/directives/style-map"; import memoizeOne from "memoize-one"; import { computeCssColor } from "../../../common/color/compute-color"; @@ -308,6 +309,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard { const active = stateActive(stateObj); const color = this._computeStateColor(stateObj, this._config.color); + const domain = computeDomain(stateObj.entity_id); const style = { "--tile-color": color, @@ -353,6 +355,8 @@ export class HuiTileCard extends LitElement implements LovelaceCard { ` : html` = new Set([ "cover-open-close", @@ -15,6 +16,7 @@ const TYPES: Set = new Set([ "light-brightness", "vacuum-commands", "fan-speed", + "alarm-modes", ]); export const createTileFeatureElement = (config: LovelaceTileFeatureConfig) => diff --git a/src/panels/lovelace/editor/config-elements/hui-alarm-modes-tile-feature-editor.ts b/src/panels/lovelace/editor/config-elements/hui-alarm-modes-tile-feature-editor.ts new file mode 100644 index 0000000000..9793135ecd --- /dev/null +++ b/src/panels/lovelace/editor/config-elements/hui-alarm-modes-tile-feature-editor.ts @@ -0,0 +1,107 @@ +import { HassEntity } from "home-assistant-js-websocket"; +import { html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { supportsFeature } from "../../../../common/entity/supports-feature"; +import type { LocalizeFunc } from "../../../../common/translations/localize"; +import type { SchemaUnion } from "../../../../components/ha-form/types"; +import { AlarmMode, ALARM_MODES } from "../../../../data/alarm_control_panel"; +import type { HomeAssistant } from "../../../../types"; +import { + LovelaceTileFeatureContext, + AlarmModesFileFeatureConfig, +} from "../../tile-features/types"; +import type { LovelaceTileFeatureEditor } from "../../types"; +import "../../../../components/ha-form/ha-form"; + +@customElement("hui-alarm-modes-tile-feature-editor") +export class HuiAlarmModesTileFeatureEditor + extends LitElement + implements LovelaceTileFeatureEditor +{ + @property({ attribute: false }) public hass?: HomeAssistant; + + @property({ attribute: false }) public context?: LovelaceTileFeatureContext; + + @state() private _config?: AlarmModesFileFeatureConfig; + + public setConfig(config: AlarmModesFileFeatureConfig): void { + this._config = config; + } + + private _schema = memoizeOne( + (localize: LocalizeFunc, stateObj?: HassEntity) => + [ + { + name: "modes", + selector: { + select: { + multiple: true, + mode: "list", + options: Object.keys(ALARM_MODES) + .filter((mode) => { + const feature = ALARM_MODES[mode as AlarmMode].feature; + return ( + stateObj && (!feature || supportsFeature(stateObj, feature)) + ); + }) + .map((mode) => ({ + value: mode, + label: `${localize( + `ui.panel.lovelace.editor.card.tile.features.types.alarm-modes.modes_list.${mode}` + )}`, + })), + }, + }, + }, + ] as const + ); + + protected render() { + if (!this.hass || !this._config) { + return nothing; + } + + const stateObj = this.context?.entity_id + ? this.hass.states[this.context?.entity_id] + : undefined; + + const schema = this._schema(this.hass.localize, stateObj); + + return html` + + `; + } + + private _valueChanged(ev: CustomEvent): void { + fireEvent(this, "config-changed", { config: ev.detail.value }); + } + + private _computeLabelCallback = ( + schema: SchemaUnion> + ) => { + switch (schema.name) { + case "modes": + return this.hass!.localize( + `ui.panel.lovelace.editor.card.tile.features.types.alarm-modes.${schema.name}` + ); + default: + return this.hass!.localize( + `ui.panel.lovelace.editor.card.generic.${schema.name}` + ); + } + }; +} + +declare global { + interface HTMLElementTagNameMap { + "hui-alarm-modes-tile-feature-editor": HuiAlarmModesTileFeatureEditor; + } +} diff --git a/src/panels/lovelace/editor/config-elements/hui-tile-card-features-editor.ts b/src/panels/lovelace/editor/config-elements/hui-tile-card-features-editor.ts index c162d8a1b7..712c9e1d26 100644 --- a/src/panels/lovelace/editor/config-elements/hui-tile-card-features-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-tile-card-features-editor.ts @@ -25,6 +25,7 @@ import { } from "../../../../resources/sortable.ondemand"; import { HomeAssistant } from "../../../../types"; import { getTileFeatureElementClass } from "../../create-element/create-tile-feature-element"; +import { supportsAlarmModesTileFeature } from "../../tile-features/hui-alarm-modes-tile-feature"; import { supportsCoverOpenCloseTileFeature } from "../../tile-features/hui-cover-open-close-tile-feature"; import { supportsCoverTiltTileFeature } from "../../tile-features/hui-cover-tilt-tile-feature"; import { supportsFanSpeedTileFeature } from "../../tile-features/hui-fan-speed-tile-feature"; @@ -41,9 +42,13 @@ const FEATURE_TYPES: FeatureType[] = [ "light-brightness", "vacuum-commands", "fan-speed", + "alarm-modes", ]; -const EDITABLES_FEATURE_TYPES = new Set(["vacuum-commands"]); +const EDITABLES_FEATURE_TYPES = new Set([ + "vacuum-commands", + "alarm-modes", +]); const SUPPORTS_FEATURE_TYPES: Record = { @@ -52,6 +57,7 @@ const SUPPORTS_FEATURE_TYPES: Record = "light-brightness": supportsLightBrightnessTileFeature, "vacuum-commands": supportsVacuumCommandTileFeature, "fan-speed": supportsFanSpeedTileFeature, + "alarm-modes": supportsAlarmModesTileFeature, }; const CUSTOM_FEATURE_ENTRIES: Record< diff --git a/src/panels/lovelace/editor/config-elements/hui-vacuum-commands-tile-feature-editor.ts b/src/panels/lovelace/editor/config-elements/hui-vacuum-commands-tile-feature-editor.ts index aeed477351..e9679933ab 100644 --- a/src/panels/lovelace/editor/config-elements/hui-vacuum-commands-tile-feature-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-vacuum-commands-tile-feature-editor.ts @@ -37,6 +37,7 @@ export class HuiVacuumCommandsTileFeatureEditor selector: { select: { multiple: true, + mode: "list", options: VACUUM_COMMANDS.filter( (command) => stateObj && supportsVacuumCommand(stateObj, command) diff --git a/src/panels/lovelace/tile-features/hui-alarm-modes-tile-feature.ts b/src/panels/lovelace/tile-features/hui-alarm-modes-tile-feature.ts new file mode 100644 index 0000000000..350af66ff1 --- /dev/null +++ b/src/panels/lovelace/tile-features/hui-alarm-modes-tile-feature.ts @@ -0,0 +1,245 @@ +import { mdiShieldOff } from "@mdi/js"; +import { HassEntity } from "home-assistant-js-websocket"; +import { css, html, LitElement, TemplateResult } 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 { computeDomain } from "../../../common/entity/compute_domain"; +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 "../../../components/ha-control-button"; +import "../../../components/ha-control-button-group"; +import { + AlarmControlPanelEntity, + AlarmMode, + ALARM_MODES, +} from "../../../data/alarm_control_panel"; +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"; + +export const supportsAlarmModesTileFeature = (stateObj: HassEntity) => { + const domain = computeDomain(stateObj.entity_id); + return domain === "alarm_control_panel"; +}; + +@customElement("hui-alarm-modes-tile-feature") +class HuiAlarmModeTileFeature + extends LitElement + implements LovelaceTileFeature +{ + @property({ attribute: false }) public hass?: HomeAssistant; + + @property({ attribute: false }) public stateObj?: AlarmControlPanelEntity; + + @state() private _config?: AlarmModesFileFeatureConfig; + + @state() _currentMode?: AlarmMode; + + static getStubConfig(_, stateObj?: HassEntity): AlarmModesFileFeatureConfig { + return { + type: "alarm-modes", + modes: stateObj + ? (Object.keys(ALARM_MODES) as AlarmMode[]).filter((mode) => { + const feature = ALARM_MODES[mode as AlarmMode].feature; + return !feature || supportsFeature(stateObj, feature); + }) + : [], + }; + } + + public static async getConfigElement(): Promise { + await import( + "../editor/config-elements/hui-alarm-modes-tile-feature-editor" + ); + return document.createElement("hui-alarm-modes-tile-feature-editor"); + } + + public setConfig(config: AlarmModesFileFeatureConfig): void { + if (!config) { + throw new Error("Invalid configuration"); + } + this._config = config; + } + + 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 _modes = memoizeOne( + ( + stateObj: AlarmControlPanelEntity, + selectedModes: AlarmMode[] | undefined + ) => { + if (!selectedModes) { + return []; + } + + return (Object.keys(ALARM_MODES) as AlarmMode[]).filter((mode) => { + const feature = ALARM_MODES[mode].feature; + return ( + (!feature || supportsFeature(stateObj, feature)) && + selectedModes.includes(mode) + ); + }); + } + ); + + private _getCurrentMode(stateObj: AlarmControlPanelEntity) { + return this._modes(stateObj, this._config?.modes).find( + (mode) => ALARM_MODES[mode].state === stateObj.state + ); + } + + private async _valueChanged(ev: CustomEvent) { + const mode = (ev.detail as any).value as AlarmMode; + + if (ALARM_MODES[mode].state === this.stateObj!.state) return; + + // Force ha-control-select to previous mode because we don't known if the service call will succeed due to code check + this._currentMode = mode; + await this.requestUpdate("_currentMode"); + this._currentMode = this._getCurrentMode(this.stateObj!); + + this._setMode(mode); + } + + private async _disarm() { + this._setMode("disarmed"); + } + + private async _setMode(mode: AlarmMode) { + const { service } = ALARM_MODES[mode]; + + 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) { + return; + } + code = response; + } + + this.hass!.callService("alarm_control_panel", service, { + entity_id: this.stateObj!.entity_id, + code, + }); + } + + protected render(): TemplateResult | null { + if ( + !this._config || + !this.hass || + !this.stateObj || + !supportsAlarmModesTileFeature(this.stateObj) + ) { + return null; + } + + const color = stateColorCss(this.stateObj); + + const modes = this._modes(this.stateObj, this._config.modes); + + 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, + })); + + if (["triggered", "arming", "pending"].includes(this.stateObj.state)) { + return html` + + + + + + `; + } + return html` +
+ + +
+ `; + } + + static get styles() { + return css` + ha-control-select { + --control-select-color: var(--tile-color); + --control-select-padding: 0; + --control-select-thickness: 40px; + --control-select-border-radius: 10px; + --control-select-button-border-radius: 10px; + } + ha-control-button-group { + margin: 0 12px 12px 12px; + --control-button-group-spacing: 12px; + } + .container { + padding: 0 12px 12px 12px; + width: auto; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-alarm-modes-tile-feature": HuiAlarmModeTileFeature; + } +} diff --git a/src/panels/lovelace/tile-features/types.ts b/src/panels/lovelace/tile-features/types.ts index e6ddbb6c18..03128a5efb 100644 --- a/src/panels/lovelace/tile-features/types.ts +++ b/src/panels/lovelace/tile-features/types.ts @@ -1,3 +1,5 @@ +import { AlarmMode } from "../../../data/alarm_control_panel"; + export interface CoverOpenCloseTileFeatureConfig { type: "cover-open-close"; } @@ -14,6 +16,11 @@ export interface FanSpeedTileFeatureConfig { type: "fan-speed"; } +export interface AlarmModesFileFeatureConfig { + type: "alarm-modes"; + modes?: AlarmMode[]; +} + export const VACUUM_COMMANDS = [ "start_pause", "stop", @@ -34,7 +41,8 @@ export type LovelaceTileFeatureConfig = | CoverTiltTileFeatureConfig | LightBrightnessTileFeatureConfig | VacuumCommandsTileFeatureConfig - | FanSpeedTileFeatureConfig; + | FanSpeedTileFeatureConfig + | AlarmModesFileFeatureConfig; export type LovelaceTileFeatureContext = { entity_id?: string; diff --git a/src/translations/en.json b/src/translations/en.json index 9ead70fea5..f72f6330e7 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -4461,6 +4461,18 @@ "fan-speed": { "label": "Fan speed" }, + "alarm-modes": { + "label": "Alarm modes", + "modes": "Modes", + "modes_list": { + "away": "[%key:ui::dialogs::more_info_control::alarm_control_panel::modes::away%]", + "home": "[%key:ui::dialogs::more_info_control::alarm_control_panel::modes::home%]", + "night": "[%key:ui::dialogs::more_info_control::alarm_control_panel::modes::night%]", + "vacation": "[%key:ui::dialogs::more_info_control::alarm_control_panel::modes::vacation%]", + "custom_bypass": "[%key:ui::dialogs::more_info_control::alarm_control_panel::modes::custom_bypass%]", + "disarmed": "[%key:ui::dialogs::more_info_control::alarm_control_panel::modes::disarmed%]" + } + }, "light-brightness": { "label": "Light brightness" },