diff --git a/src/components/ha-aspect-ratio.ts b/src/components/ha-aspect-ratio.ts new file mode 100644 index 0000000000..67bc73c1e5 --- /dev/null +++ b/src/components/ha-aspect-ratio.ts @@ -0,0 +1,61 @@ +import { css, html, LitElement, type PropertyValues } from "lit"; +import { customElement, property } from "lit/decorators"; +import { styleMap } from "lit/directives/style-map"; +import parseAspectRatio from "../common/util/parse-aspect-ratio"; + +const DEFAULT_ASPECT_RATIO = "16:9"; + +@customElement("ha-aspect-ratio") +export class HaAspectRatio extends LitElement { + @property({ type: String, attribute: "aspect-ratio" }) + public aspectRatio?: string; + + private _ratio: { + w: number; + h: number; + } | null = null; + + public willUpdate(changedProps: PropertyValues) { + if (changedProps.has("aspect_ratio") || this._ratio === null) { + this._ratio = this.aspectRatio + ? parseAspectRatio(this.aspectRatio) + : null; + + if (this._ratio === null || this._ratio.w <= 0 || this._ratio.h <= 0) { + this._ratio = parseAspectRatio(DEFAULT_ASPECT_RATIO); + } + } + } + + protected render(): unknown { + if (!this.aspectRatio) { + return html``; + } + return html` +
+ +
+ `; + } + + static styles = css` + .ratio ::slotted(*) { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-aspect-ratio": HaAspectRatio; + } +} diff --git a/src/panels/lovelace/card-features/hui-area-controls-card-feature.ts b/src/panels/lovelace/card-features/hui-area-controls-card-feature.ts new file mode 100644 index 0000000000..ff6a8366a7 --- /dev/null +++ b/src/panels/lovelace/card-features/hui-area-controls-card-feature.ts @@ -0,0 +1,258 @@ +import { mdiFan, mdiLightbulb, mdiToggleSwitch } from "@mdi/js"; +import { callService, type HassEntity } from "home-assistant-js-websocket"; +import { LitElement, css, html, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { styleMap } from "lit/directives/style-map"; +import memoizeOne from "memoize-one"; +import { + generateEntityFilter, + type EntityFilter, +} from "../../../common/entity/entity_filter"; +import { stateActive } from "../../../common/entity/state_active"; +import "../../../components/ha-control-button"; +import "../../../components/ha-control-button-group"; +import "../../../components/ha-svg-icon"; +import type { AreaRegistryEntry } from "../../../data/area_registry"; +import type { HomeAssistant } from "../../../types"; +import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types"; +import { cardFeatureStyles } from "./common/card-feature-styles"; +import type { + AreaControl, + AreaControlsCardFeatureConfig, + LovelaceCardFeatureContext, +} from "./types"; +import { AREA_CONTROLS } from "./types"; + +interface AreaControlsButton { + iconPath: string; + activeColor: string; + onService: string; + offService: string; + filter: EntityFilter; +} + +export const AREA_CONTROLS_BUTTONS: Record = { + light: { + iconPath: mdiLightbulb, + filter: { + domain: "light", + }, + activeColor: "var(--state-light-active-color)", + onService: "light.turn_on", + offService: "light.turn_off", + }, + fan: { + iconPath: mdiFan, + filter: { + domain: "fan", + }, + activeColor: "var(--state-fan-active-color)", + onService: "fan.turn_on", + offService: "fan.turn_off", + }, + switch: { + iconPath: mdiToggleSwitch, + filter: { + domain: "switch", + }, + activeColor: "var(--state-switch-active-color)", + onService: "switch.turn_on", + offService: "switch.turn_off", + }, +}; + +export const supportsAreaControlsCardFeature = ( + hass: HomeAssistant, + context: LovelaceCardFeatureContext +) => { + const area = context.area_id ? hass.areas[context.area_id] : undefined; + return !!area; +}; + +export const getAreaControlEntities = ( + controls: AreaControl[], + areaId: string, + hass: HomeAssistant +): Record => + controls.reduce( + (acc, control) => { + const controlButton = AREA_CONTROLS_BUTTONS[control]; + const filter = generateEntityFilter(hass, { + area: areaId, + ...controlButton.filter, + }); + + acc[control] = Object.keys(hass.entities).filter((entityId) => + filter(entityId) + ); + return acc; + }, + {} as Record + ); + +@customElement("hui-area-controls-card-feature") +class HuiAreaControlsCardFeature + extends LitElement + implements LovelaceCardFeature +{ + @property({ attribute: false }) public hass?: HomeAssistant; + + @property({ attribute: false }) public context?: LovelaceCardFeatureContext; + + @state() private _config?: AreaControlsCardFeatureConfig; + + private get _area() { + if (!this.hass || !this.context || !this.context.area_id) { + return undefined; + } + return this.hass.areas[this.context.area_id!] as + | AreaRegistryEntry + | undefined; + } + + private get _controls() { + return ( + this._config?.controls || (AREA_CONTROLS as unknown as AreaControl[]) + ); + } + + static getStubConfig(): AreaControlsCardFeatureConfig { + return { + type: "area-controls", + }; + } + + public static async getConfigElement(): Promise { + await import( + "../editor/config-elements/hui-area-controls-card-feature-editor" + ); + return document.createElement("hui-area-controls-card-feature-editor"); + } + + public setConfig(config: AreaControlsCardFeatureConfig): void { + if (!config) { + throw new Error("Invalid configuration"); + } + this._config = config; + } + + private _handleButtonTap(ev: MouseEvent) { + ev.stopPropagation(); + + if (!this.context?.area_id || !this.hass) { + return; + } + const control = (ev.currentTarget as any).control as AreaControl; + + const controlEntities = this._controlEntities( + this._controls, + this.context.area_id, + this.hass!.entities, + this.hass!.devices, + this.hass!.areas + ); + const entitiesIds = controlEntities[control]; + + const { onService, offService } = AREA_CONTROLS_BUTTONS[control]; + + const isOn = entitiesIds.some((entityId) => + stateActive(this.hass!.states[entityId] as HassEntity) + ); + + const [domain, service] = (isOn ? offService : onService).split("."); + + callService(this.hass!.connection, domain, service, { + entity_id: entitiesIds, + }); + } + + private _controlEntities = memoizeOne( + ( + controls: AreaControl[], + areaId: string, + // needed to update memoized function when entities, devices or areas change + _entities: HomeAssistant["entities"], + _devices: HomeAssistant["devices"], + _areas: HomeAssistant["areas"] + ) => getAreaControlEntities(controls, areaId, this.hass!) + ); + + protected render() { + if ( + !this._config || + !this.hass || + !this.context || + !this._area || + !supportsAreaControlsCardFeature(this.hass, this.context) + ) { + return nothing; + } + + const controlEntities = this._controlEntities( + this._controls, + this.context.area_id!, + this.hass!.entities, + this.hass!.devices, + this.hass!.areas + ); + + const supportedControls = this._controls.filter( + (control) => controlEntities[control].length > 0 + ); + + if (!supportedControls.length) { + return nothing; + } + + return html` + + ${supportedControls.map((control) => { + const button = AREA_CONTROLS_BUTTONS[control]; + + const entities = controlEntities[control]; + const active = entities.some((entityId) => { + const stateObj = this.hass!.states[entityId] as + | HassEntity + | undefined; + if (!stateObj) { + return false; + } + return stateActive(stateObj); + }); + + return html` + + + + `; + })} + + `; + } + + static get styles() { + return [ + cardFeatureStyles, + css` + ha-control-button { + --active-color: var(--primary-color); + } + ha-control-button.active { + --control-button-background-color: var(--active-color); + --control-button-icon-color: var(--active-color); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-area-controls-card-feature": HuiAreaControlsCardFeature; + } +} diff --git a/src/panels/lovelace/card-features/types.ts b/src/panels/lovelace/card-features/types.ts index 743f1a607f..bb0852955b 100644 --- a/src/panels/lovelace/card-features/types.ts +++ b/src/panels/lovelace/card-features/types.ts @@ -158,6 +158,15 @@ export interface UpdateActionsCardFeatureConfig { backup?: "yes" | "no" | "ask"; } +export const AREA_CONTROLS = ["light", "fan", "switch"] as const; + +export type AreaControl = (typeof AREA_CONTROLS)[number]; + +export interface AreaControlsCardFeatureConfig { + type: "area-controls"; + controls?: AreaControl[]; +} + export type LovelaceCardFeatureConfig = | AlarmModesCardFeatureConfig | ClimateFanModesCardFeatureConfig @@ -187,8 +196,10 @@ export type LovelaceCardFeatureConfig = | ToggleCardFeatureConfig | UpdateActionsCardFeatureConfig | VacuumCommandsCardFeatureConfig - | WaterHeaterOperationModesCardFeatureConfig; + | WaterHeaterOperationModesCardFeatureConfig + | AreaControlsCardFeatureConfig; export interface LovelaceCardFeatureContext { entity_id?: string; + area_id?: string; } diff --git a/src/panels/lovelace/cards/hui-area-card.ts b/src/panels/lovelace/cards/hui-area-card.ts index 0b390386b0..5c8eb2888e 100644 --- a/src/panels/lovelace/cards/hui-area-card.ts +++ b/src/panels/lovelace/cards/hui-area-card.ts @@ -1,23 +1,19 @@ +import { mdiTextureBox } from "@mdi/js"; +import type { HassEntity } from "home-assistant-js-websocket"; import { - mdiFan, - mdiFanOff, - mdiLightbulbMultiple, - mdiLightbulbMultipleOff, - mdiRun, - mdiToggleSwitch, - mdiToggleSwitchOff, - mdiWaterAlert, -} from "@mdi/js"; -import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; -import type { PropertyValues, TemplateResult } from "lit"; -import { LitElement, css, html, nothing } from "lit"; + css, + html, + LitElement, + nothing, + type PropertyValues, + type TemplateResult, +} from "lit"; import { customElement, property, state } from "lit/decorators"; -import { classMap } from "lit/directives/class-map"; -import { styleMap } from "lit/directives/style-map"; +import { ifDefined } from "lit/directives/if-defined"; import memoizeOne from "memoize-one"; -import { STATES_OFF } from "../../../common/const"; -import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; -import { computeDomain } from "../../../common/entity/compute_domain"; +import { BINARY_STATE_ON } from "../../../common/const"; +import { computeAreaName } from "../../../common/entity/compute_area_name"; +import { generateEntityFilter } from "../../../common/entity/entity_filter"; import { navigate } from "../../../common/navigate"; import { formatNumber, @@ -25,23 +21,22 @@ import { } from "../../../common/number/format_number"; import { blankBeforeUnit } from "../../../common/translations/blank_before_unit"; import parseAspectRatio from "../../../common/util/parse-aspect-ratio"; -import { subscribeOne } from "../../../common/util/subscribe-one"; +import "../../../components/ha-aspect-ratio"; import "../../../components/ha-card"; +import "../../../components/ha-control-button"; +import "../../../components/ha-control-button-group"; import "../../../components/ha-domain-icon"; -import "../../../components/ha-icon-button"; -import "../../../components/ha-state-icon"; -import type { AreaRegistryEntry } from "../../../data/area_registry"; -import { subscribeAreaRegistry } from "../../../data/area_registry"; -import type { DeviceRegistryEntry } from "../../../data/device_registry"; -import { subscribeDeviceRegistry } from "../../../data/device_registry"; +import "../../../components/ha-icon"; +import "../../../components/ha-ripple"; +import "../../../components/ha-svg-icon"; +import "../../../components/tile/ha-tile-badge"; +import "../../../components/tile/ha-tile-icon"; +import "../../../components/tile/ha-tile-info"; import { isUnavailableState } from "../../../data/entity"; -import type { EntityRegistryEntry } from "../../../data/entity_registry"; -import { subscribeEntityRegistry } from "../../../data/entity_registry"; -import { forwardHaptic } from "../../../data/haptics"; -import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import type { HomeAssistant } from "../../../types"; -import "../components/hui-image"; -import "../components/hui-warning"; +import "../card-features/hui-card-features"; +import type { LovelaceCardFeatureContext } from "../card-features/types"; +import { actionHandler } from "../common/directives/action-handler-directive"; import type { LovelaceCard, LovelaceCardEditor, @@ -51,284 +46,338 @@ import type { AreaCardConfig } from "./types"; export const DEFAULT_ASPECT_RATIO = "16:9"; -const SENSOR_DOMAINS = ["sensor"]; - -const ALERT_DOMAINS = ["binary_sensor"]; - -const TOGGLE_DOMAINS = ["light", "switch", "fan"]; - -const OTHER_DOMAINS = ["camera"]; - export const DEVICE_CLASSES = { sensor: ["temperature", "humidity"], binary_sensor: ["motion", "moisture"], }; -const DOMAIN_ICONS = { - light: { on: mdiLightbulbMultiple, off: mdiLightbulbMultipleOff }, - switch: { on: mdiToggleSwitch, off: mdiToggleSwitchOff }, - fan: { on: mdiFan, off: mdiFanOff }, - binary_sensor: { - motion: mdiRun, - moisture: mdiWaterAlert, - }, -}; - @customElement("hui-area-card") -export class HuiAreaCard - extends SubscribeMixin(LitElement) - implements LovelaceCard -{ - public static async getConfigElement(): Promise { - await import("../editor/config-elements/hui-area-card-editor"); - return document.createElement("hui-area-card-editor"); - } - - public static async getStubConfig( - hass: HomeAssistant - ): Promise { - const areas = await subscribeOne(hass.connection, subscribeAreaRegistry); - return { type: "area", area: areas[0]?.area_id || "" }; - } - +export class HuiAreaCard extends LitElement implements LovelaceCard { @property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public layout?: string; @state() private _config?: AreaCardConfig; - @state() private _entities?: EntityRegistryEntry[]; - - @state() private _devices?: DeviceRegistryEntry[]; - - @state() private _areas?: AreaRegistryEntry[]; - - private _deviceClasses: Record = DEVICE_CLASSES; + @state() private _featureContext: LovelaceCardFeatureContext = {}; private _ratio: { w: number; h: number; } | null = null; - private _entitiesByDomain = memoizeOne( - ( - areaId: string, - devicesInArea: Set, - registryEntities: EntityRegistryEntry[], - deviceClasses: Record, - states: HomeAssistant["states"] - ) => { - const entitiesInArea = registryEntities - .filter( - (entry) => - !entry.entity_category && - !entry.hidden_by && - (entry.area_id - ? entry.area_id === areaId - : entry.device_id && devicesInArea.has(entry.device_id)) - ) - .map((entry) => entry.entity_id); - - const entitiesByDomain: Record = {}; - - for (const entity of entitiesInArea) { - const domain = computeDomain(entity); - if ( - !TOGGLE_DOMAINS.includes(domain) && - !SENSOR_DOMAINS.includes(domain) && - !ALERT_DOMAINS.includes(domain) && - !OTHER_DOMAINS.includes(domain) - ) { - continue; - } - const stateObj: HassEntity | undefined = states[entity]; - - if (!stateObj) { - continue; - } - - if ( - (SENSOR_DOMAINS.includes(domain) || ALERT_DOMAINS.includes(domain)) && - !deviceClasses[domain].includes( - stateObj.attributes.device_class || "" - ) - ) { - continue; - } - - if (!(domain in entitiesByDomain)) { - entitiesByDomain[domain] = []; - } - entitiesByDomain[domain].push(stateObj); - } - - return entitiesByDomain; - } - ); - - private _isOn(domain: string, deviceClass?: string): HassEntity | undefined { - const entities = this._entitiesByDomain( - this._config!.area, - this._devicesInArea(this._config!.area, this._devices!), - this._entities!, - this._deviceClasses, - this.hass.states - )[domain]; - if (!entities) { - return undefined; - } - return ( - deviceClass - ? entities.filter( - (entity) => entity.attributes.device_class === deviceClass - ) - : entities - ).find( - (entity) => - !isUnavailableState(entity.state) && !STATES_OFF.includes(entity.state) - ); - } - - private _average(domain: string, deviceClass?: string): string | undefined { - const entities = this._entitiesByDomain( - this._config!.area, - this._devicesInArea(this._config!.area, this._devices!), - this._entities!, - this._deviceClasses, - this.hass.states - )[domain].filter((entity) => - deviceClass ? entity.attributes.device_class === deviceClass : true - ); - if (!entities) { - return undefined; - } - let uom; - const values = entities.filter((entity) => { - if (!isNumericState(entity) || isNaN(Number(entity.state))) { - return false; - } - if (!uom) { - uom = entity.attributes.unit_of_measurement; - return true; - } - return entity.attributes.unit_of_measurement === uom; - }); - if (!values.length) { - return undefined; - } - const sum = values.reduce( - (total, entity) => total + Number(entity.state), - 0 - ); - return `${formatNumber(sum / values.length, this.hass!.locale, { - maximumFractionDigits: 1, - })}${uom ? blankBeforeUnit(uom, this.hass!.locale) : ""}${uom || ""}`; - } - - private _area = memoizeOne( - (areaId: string | undefined, areas: AreaRegistryEntry[]) => - areas.find((area) => area.area_id === areaId) || null - ); - - private _devicesInArea = memoizeOne( - (areaId: string | undefined, devices: DeviceRegistryEntry[]) => - new Set( - areaId - ? devices - .filter((device) => device.area_id === areaId) - .map((device) => device.id) - : [] - ) - ); - - public hassSubscribe(): UnsubscribeFunc[] { - return [ - subscribeAreaRegistry(this.hass!.connection, (areas) => { - this._areas = areas; - }), - subscribeDeviceRegistry(this.hass!.connection, (devices) => { - this._devices = devices; - }), - subscribeEntityRegistry(this.hass!.connection, (entries) => { - this._entities = entries; - }), - ]; - } - - public getCardSize(): number { - return 3; + public static async getConfigElement(): Promise { + await import("../editor/config-elements/hui-area-card-editor"); + return document.createElement("hui-area-card-editor"); } public setConfig(config: AreaCardConfig): void { if (!config.area) { - throw new Error("Area Required"); + throw new Error("Specify an area"); } - this._config = config; + const displayType = + config.display_type || (config.show_camera ? "camera" : "picture"); + this._config = { + ...config, + display_type: displayType, + }; - this._deviceClasses = { ...DEVICE_CLASSES }; - if (config.sensor_classes) { - this._deviceClasses.sensor = config.sensor_classes; - } - if (config.alert_classes) { - this._deviceClasses.binary_sensor = config.alert_classes; - } + this._featureContext = { + area_id: config.area, + }; } - protected shouldUpdate(changedProps: PropertyValues): boolean { - if (changedProps.has("_config") || !this._config) { - return true; - } + public static async getStubConfig( + hass: HomeAssistant + ): Promise { + const areas = Object.values(hass.areas); + return { type: "area", area: areas[0]?.area_id || "" }; + } - if ( - changedProps.has("_devicesInArea") || - changedProps.has("_areas") || - changedProps.has("_entities") - ) { - return true; - } - - if (!changedProps.has("hass")) { - return false; - } - - const oldHass = changedProps.get("hass") as HomeAssistant | undefined; - - if ( - !oldHass || - oldHass.themes !== this.hass!.themes || - oldHass.locale !== this.hass!.locale - ) { - return true; - } - - if ( - !this._devices || - !this._devicesInArea(this._config.area, this._devices) || - !this._entities - ) { - return false; - } - - const entities = this._entitiesByDomain( - this._config.area, - this._devicesInArea(this._config.area, this._devices), - this._entities, - this._deviceClasses, - this.hass.states + public getCardSize(): number { + const featuresPosition = + this._config && this._featurePosition(this._config); + const displayType = this._config?.display_type || "picture"; + const featuresCount = this._config?.features?.length || 0; + return ( + 1 + + (displayType === "compact" ? 0 : 2) + + (featuresPosition === "inline" ? 0 : featuresCount) ); + } - for (const domainEntities of Object.values(entities)) { - for (const stateObj of domainEntities) { - if (oldHass!.states[stateObj.entity_id] !== stateObj) { - return true; - } + public getGridOptions(): LovelaceGridOptions { + const columns = 6; + let min_columns = 6; + let rows = 1; + const featurePosition = this._config + ? this._featurePosition(this._config) + : "bottom"; + const featuresCount = this._config?.features?.length || 0; + if (featuresCount) { + if (featurePosition === "inline") { + min_columns = 12; + } else { + rows += featuresCount; } } - return false; + const displayType = this._config?.display_type || "picture"; + + if (displayType !== "compact") { + rows += 2; + } + + return { + columns, + rows, + min_columns, + min_rows: rows, + }; } + private get _hasCardAction() { + return this._config?.navigation_path; + } + + private _handleAction() { + if (this._config?.navigation_path) { + navigate(this._config.navigation_path); + } + } + + private _groupEntitiesByDeviceClass = ( + entityIds: string[] + ): Map => + entityIds.reduce((acc, entityId) => { + const stateObj = this.hass.states[entityId]; + const deviceClass = stateObj.attributes.device_class!; + if (!acc.has(deviceClass)) { + acc.set(deviceClass, []); + } + acc.get(deviceClass)!.push(stateObj.entity_id); + return acc; + }, new Map()); + + private _groupedSensorEntityIds = memoizeOne( + ( + entities: HomeAssistant["entities"], + areaId: string, + sensorClasses: string[] + ): Map => { + const sensorFilter = generateEntityFilter(this.hass, { + area: areaId, + entity_category: "none", + domain: "sensor", + device_class: sensorClasses, + }); + const entityIds = Object.keys(entities).filter(sensorFilter); + return this._groupEntitiesByDeviceClass(entityIds); + } + ); + + private _groupedBinarySensorEntityIds = memoizeOne( + ( + entities: HomeAssistant["entities"], + areaId: string, + binarySensorClasses: string[] + ): Map => { + const binarySensorFilter = generateEntityFilter(this.hass, { + area: areaId, + entity_category: "none", + domain: "binary_sensor", + device_class: binarySensorClasses, + }); + const entityIds = Object.keys(entities).filter(binarySensorFilter); + return this._groupEntitiesByDeviceClass(entityIds); + } + ); + + private _getCameraEntity = memoizeOne( + ( + entities: HomeAssistant["entities"], + areaId: string + ): string | undefined => { + const cameraFilter = generateEntityFilter(this.hass, { + area: areaId, + entity_category: "none", + domain: "camera", + }); + const cameraEntities = Object.keys(entities).filter(cameraFilter); + return cameraEntities.length > 0 ? cameraEntities[0] : undefined; + } + ); + + private _computeActiveAlertStates(): HassEntity[] { + const areaId = this._config?.area; + const area = areaId ? this.hass.areas[areaId] : undefined; + const alertClasses = this._config?.alert_classes; + if (!area || !alertClasses) { + return []; + } + const groupedEntities = this._groupedBinarySensorEntityIds( + this.hass.entities, + area.area_id, + alertClasses + ); + + return ( + alertClasses + .map((alertClass) => { + const entityIds = groupedEntities.get(alertClass) || []; + if (!entityIds) { + return []; + } + return entityIds + .map( + (entityId) => this.hass.states[entityId] as HassEntity | undefined + ) + .filter((stateObj) => stateObj?.state === BINARY_STATE_ON); + }) + .filter((activeAlerts) => activeAlerts.length > 0) + // Only return the first active entity for each alert class + .map((activeAlerts) => activeAlerts[0]!) + ); + } + + private _renderAlertSensorBadge(): TemplateResult<1> | typeof nothing { + const states = this._computeActiveAlertStates(); + + if (states.length === 0) { + return nothing; + } + + // Only render the first one when using a badge + const stateObj = states[0] as HassEntity | undefined; + + return html` + + + + `; + } + + private _renderAlertSensors(): TemplateResult<1> | typeof nothing { + const states = this._computeActiveAlertStates(); + + if (states.length === 0) { + return nothing; + } + return html` +
+ ${states.map( + (stateObj) => html` +
+ +
+ ` + )} +
+ `; + } + + private _computeSensorsDisplay(): string | undefined { + const areaId = this._config?.area; + const area = areaId ? this.hass.areas[areaId] : undefined; + const sensorClasses = this._config?.sensor_classes; + if (!area || !sensorClasses) { + return undefined; + } + + const groupedEntities = this._groupedSensorEntityIds( + this.hass.entities, + area.area_id, + sensorClasses + ); + + const sensorStates = sensorClasses + .map((sensorClass) => { + if (sensorClass === "temperature" && area.temperature_entity_id) { + const stateObj = this.hass.states[area.temperature_entity_id] as + | HassEntity + | undefined; + return !stateObj || isUnavailableState(stateObj.state) + ? "" + : this.hass.formatEntityState(stateObj); + } + if (sensorClass === "humidity" && area.humidity_entity_id) { + const stateObj = this.hass.states[area.humidity_entity_id] as + | HassEntity + | undefined; + return !stateObj || isUnavailableState(stateObj.state) + ? "" + : this.hass.formatEntityState(stateObj); + } + + const entityIds = groupedEntities.get(sensorClass); + + if (!entityIds) { + return undefined; + } + + // Ensure all entities have state + const entities = entityIds + .map((entityId) => this.hass.states[entityId]) + .filter(Boolean); + + if (entities.length === 0) { + return undefined; + } + + // Use the first entity's unit_of_measurement for formatting + const uom = entities.find( + (entity) => entity.attributes.unit_of_measurement + )?.attributes.unit_of_measurement; + + // Ensure all entities have the same unit_of_measurement + const validEntities = entities.filter( + (entity) => + entity.attributes.unit_of_measurement === uom && + isNumericState(entity) && + !isNaN(Number(entity.state)) + ); + + if (validEntities.length === 0) { + return undefined; + } + + const value = + validEntities.reduce((acc, entity) => acc + Number(entity.state), 0) / + validEntities.length; + + const formattedAverage = formatNumber(value, this.hass!.locale, { + maximumFractionDigits: 1, + }); + const formattedUnit = uom + ? `${blankBeforeUnit(uom, this.hass!.locale)}${uom}` + : ""; + + return `${formattedAverage}${formattedUnit}`; + }) + .filter(Boolean) + .join(" · "); + + return sensorStates; + } + + private _featurePosition = memoizeOne( + (config: AreaCardConfig) => config.features_position || "bottom" + ); + + private _displayedFeatures = memoizeOne((config: AreaCardConfig) => { + const features = config.features || []; + const featurePosition = this._featurePosition(config); + + if (featurePosition === "inline") { + return features.slice(0, 1); + } + return features; + }); + public willUpdate(changedProps: PropertyValues) { if (changedProps.has("_config") || this._ratio === null) { this._ratio = this._config?.aspect_ratio @@ -342,26 +391,14 @@ export class HuiAreaCard } protected render() { - if ( - !this._config || - !this.hass || - !this._areas || - !this._devices || - !this._entities - ) { + if (!this._config || !this.hass) { return nothing; } - const entitiesByDomain = this._entitiesByDomain( - this._config.area, - this._devicesInArea(this._config.area, this._devices), - this._entities, - this._deviceClasses, - this.hass.states - ); - const area = this._area(this._config.area, this._areas); + const areaId = this._config?.area; + const area = areaId ? this.hass.areas[areaId] : undefined; - if (area === null) { + if (!area) { return html` ${this.hass.localize("ui.card.area.area_not_found")} @@ -369,315 +406,270 @@ export class HuiAreaCard `; } - const sensors: TemplateResult[] = []; - SENSOR_DOMAINS.forEach((domain) => { - if (!(domain in entitiesByDomain)) { - return; - } - this._deviceClasses[domain].forEach((deviceClass) => { - let areaSensorEntityId: string | null = null; - switch (deviceClass) { - case "temperature": - areaSensorEntityId = area.temperature_entity_id; - break; - case "humidity": - areaSensorEntityId = area.humidity_entity_id; - break; - } - const areaEntity = - areaSensorEntityId && - this.hass.states[areaSensorEntityId] && - !isUnavailableState(this.hass.states[areaSensorEntityId].state) - ? this.hass.states[areaSensorEntityId] - : undefined; - if ( - areaEntity || - entitiesByDomain[domain].some( - (entity) => entity.attributes.device_class === deviceClass - ) - ) { - let value = areaEntity - ? this.hass.formatEntityState(areaEntity) - : this._average(domain, deviceClass); - if (!value) value = "—"; - sensors.push(html` -
- - ${value} -
- `); - } - }); - }); + const icon = area.icon; - let cameraEntityId: string | undefined; - if (this._config.show_camera && "camera" in entitiesByDomain) { - cameraEntityId = entitiesByDomain.camera[0].entity_id; - } + const name = this._config.name || computeAreaName(area); - const imageClass = area.picture || cameraEntityId; + const primary = name; + const secondary = this._computeSensorsDisplay(); - const ignoreAspectRatio = this.layout === "grid"; + const featurePosition = this._featurePosition(this._config); + const features = this._displayedFeatures(this._config); + + const containerOrientationClass = + featurePosition === "inline" ? "horizontal" : ""; + + const displayType = this._config.display_type || "picture"; + + const cameraEntityId = + displayType === "camera" + ? this._getCameraEntity(this.hass.entities, area.area_id) + : undefined; + + const ignoreAspectRatio = this.layout === "grid" || this.layout === "panel"; return html` - - ${area.picture || cameraEntityId - ? html` - - ` - : area.icon - ? html` -
- + +
+ +
+ ${displayType === "compact" + ? nothing + : html` +
+
+ ${(displayType === "picture" || displayType === "camera") && + (cameraEntityId || area.picture) + ? html` + + ` + : html` + +
+ ${area.icon + ? html`` + : nothing} +
+
+ `}
+ ${this._renderAlertSensors()} +
+ `} +
+
+ + ${displayType === "compact" + ? this._renderAlertSensorBadge() + : nothing} + ${icon + ? html`` + : html` + + `} + + +
+ ${features.length > 0 + ? html` + ` : nothing} - - `; } - protected updated(changedProps: PropertyValues): void { - super.updated(changedProps); - if (!this._config || !this.hass) { - return; - } - const oldHass = changedProps.get("hass") as HomeAssistant | undefined; - const oldConfig = changedProps.get("_config") as AreaCardConfig | undefined; - - if ( - (changedProps.has("hass") && - (!oldHass || oldHass.themes !== this.hass.themes)) || - (changedProps.has("_config") && - (!oldConfig || oldConfig.theme !== this._config.theme)) - ) { - applyThemesOnElement(this, this.hass.themes, this._config.theme); - } - } - - private _handleNavigation() { - if (this._config!.navigation_path) { - navigate(this._config!.navigation_path); - } - } - - private _toggle(ev: Event) { - ev.stopPropagation(); - const domain = (ev.currentTarget as any).domain as string; - if (TOGGLE_DOMAINS.includes(domain)) { - this.hass.callService( - domain, - this._isOn(domain) ? "turn_off" : "turn_on", - undefined, - { - area_id: this._config!.area, - } - ); - } - forwardHaptic("light"); - } - - getGridOptions(): LovelaceGridOptions { - return { - columns: 12, - rows: 3, - min_columns: 3, - }; - } - static styles = css` - ha-card { - overflow: hidden; - position: relative; - background-size: cover; - height: 100%; + :host { + --tile-color: var(--state-icon-color); + -webkit-tap-highlight-color: transparent; } - - .container { + ha-card:has(.background:focus-visible) { + --shadow-default: var(--ha-card-box-shadow, 0 0 0 0 transparent); + --shadow-focus: 0 0 0 1px var(--tile-color); + border-color: var(--tile-color); + box-shadow: var(--shadow-default), var(--shadow-focus); + } + ha-card { + --ha-ripple-color: var(--tile-color); + --ha-ripple-hover-opacity: 0.04; + --ha-ripple-pressed-opacity: 0.12; + height: 100%; + transition: + box-shadow 180ms ease-in-out, + border-color 180ms ease-in-out; display: flex; flex-direction: column; justify-content: space-between; + } + [role="button"] { + cursor: pointer; + pointer-events: auto; + } + [role="button"]:focus { + outline: none; + } + .background { position: absolute; top: 0; - bottom: 0; left: 0; + bottom: 0; right: 0; - background: linear-gradient( - 0, - rgba(33, 33, 33, 0.9) 0%, - rgba(33, 33, 33, 0) 45% - ); + border-radius: var(--ha-card-border-radius, 12px); + margin: calc(-1 * var(--ha-card-border-width, 1px)); + overflow: hidden; } - - ha-card:not(.image) .container::before { + .header { + flex: 1; + overflow: hidden; + border-radius: var(--ha-card-border-radius, 12px); + border-end-end-radius: 0; + border-end-start-radius: 0; + pointer-events: none; + } + .picture { + height: 100%; + width: 100%; + background-size: cover; + background-position: center; + position: relative; + } + .picture hui-image { + height: 100%; + } + .picture .icon-container { + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + --mdc-icon-size: 48px; + color: var(--tile-color); + } + .picture .icon-container::before { position: absolute; content: ""; width: 100%; height: 100%; - background-color: var(--sidebar-selected-icon-color); + background-color: var(--tile-color); opacity: 0.12; } - - .image hui-image { - height: 100%; + .container { + margin: calc(-1 * var(--ha-card-border-width, 1px)); + display: flex; + flex-direction: column; + flex: 1; + } + .header + .container { + height: auto; + flex: none; + } + .container.horizontal { + flex-direction: row; } - .icon-container { + .content { + position: relative; + display: flex; + flex-direction: row; + align-items: center; + padding: 10px; + flex: 1; + min-width: 0; + box-sizing: border-box; + pointer-events: none; + gap: 10px; + } + + ha-tile-icon { + --tile-icon-color: var(--tile-color); + position: relative; + padding: 6px; + margin: -6px; + } + ha-tile-badge { + position: absolute; + top: 3px; + right: 3px; + inset-inline-end: 3px; + inset-inline-start: initial; + } + ha-tile-info { + position: relative; + min-width: 0; + transition: background-color 180ms ease-in-out; + box-sizing: border-box; + } + hui-card-features { + --feature-color: var(--tile-color); + padding: 0 12px 12px 12px; + } + .container.horizontal hui-card-features { + width: calc(50% - var(--column-gap, 0px) / 2 - 12px); + flex: none; + --feature-height: 36px; + padding: 0 12px; + padding-inline-start: 0; + } + .alert-badge { + --tile-badge-background-color: var(--orange-color); + } + .alerts { position: absolute; top: 0; left: 0; - right: 0; - bottom: 0; + display: flex; + flex-direction: row; + gap: 4px; + padding: 4px; + pointer-events: none; + z-index: 1; + } + .alert { + background-color: var(--orange-color); + border-radius: 12px; + width: 24px; + height: 24px; + padding: 2px; + box-sizing: border-box; + --mdc-icon-size: 16px; display: flex; align-items: center; justify-content: center; - } - - .icon-container ha-icon { - --mdc-icon-size: 60px; - color: var(--sidebar-selected-icon-color); - } - - .sensors { - color: #e3e3e3; - font-size: var(--ha-font-size-l); - --mdc-icon-size: 24px; - opacity: 0.6; - margin-top: 8px; - } - - .sensor { - white-space: nowrap; - float: left; - margin-right: 4px; - margin-inline-end: 4px; - margin-inline-start: initial; - } - - .alerts { - padding: 16px; - } - - ha-state-icon { - display: inline-flex; - align-items: center; - justify-content: center; - position: relative; - } - - .alerts ha-state-icon { - background: var(--accent-color); - color: var(--text-accent-color, var(--text-primary-color)); - padding: 8px; - margin-right: 8px; - margin-inline-end: 8px; - margin-inline-start: initial; - border-radius: 50%; - } - - .name { color: white; - font-size: var(--ha-font-size-2xl); - } - - .bottom { - display: flex; - justify-content: space-between; - align-items: center; - padding: 16px; - } - - .navigate { - cursor: pointer; - } - - ha-icon-button { - color: white; - background-color: var(--area-button-color, #727272b2); - border-radius: 50%; - margin-left: 8px; - margin-inline-start: 8px; - margin-inline-end: initial; - --mdc-icon-button-size: 44px; - } - .on { - color: var(--state-light-active-color); } `; } diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index 52466b2de8..0957c9289a 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -101,11 +101,18 @@ export interface EntitiesCardConfig extends LovelaceCardConfig { } export interface AreaCardConfig extends LovelaceCardConfig { - area: string; + area?: string; + name?: string; navigation_path?: string; + display_type?: "compact" | "icon" | "picture" | "camera"; + /** @deprecated Use `display_type` instead */ show_camera?: boolean; camera_view?: HuiImage["cameraView"]; aspect_ratio?: string; + sensor_classes?: string[]; + alert_classes?: string[]; + features?: LovelaceCardFeatureConfig[]; + features_position?: "bottom" | "inline"; } export interface ButtonCardConfig extends LovelaceCardConfig { diff --git a/src/panels/lovelace/components/hui-image.ts b/src/panels/lovelace/components/hui-image.ts index 08c54b61bf..35ba02cd77 100644 --- a/src/panels/lovelace/components/hui-image.ts +++ b/src/panels/lovelace/components/hui-image.ts @@ -54,7 +54,10 @@ export class HuiImage extends LitElement { @property({ attribute: false }) public darkModeFilter?: string; - @property({ attribute: false }) public fitMode?: "cover" | "contain" | "fill"; + @property({ attribute: "fit-mode", type: String }) public fitMode?: + | "cover" + | "contain" + | "fill"; @state() private _imageVisible? = false; 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 31ce2eccd5..fc003da1f9 100644 --- a/src/panels/lovelace/create-element/create-card-feature-element.ts +++ b/src/panels/lovelace/create-element/create-card-feature-element.ts @@ -1,9 +1,9 @@ import "../card-features/hui-alarm-modes-card-feature"; import "../card-features/hui-climate-fan-modes-card-feature"; -import "../card-features/hui-climate-swing-modes-card-feature"; -import "../card-features/hui-climate-swing-horizontal-modes-card-feature"; import "../card-features/hui-climate-hvac-modes-card-feature"; import "../card-features/hui-climate-preset-modes-card-feature"; +import "../card-features/hui-climate-swing-horizontal-modes-card-feature"; +import "../card-features/hui-climate-swing-modes-card-feature"; import "../card-features/hui-counter-actions-card-feature"; import "../card-features/hui-cover-open-close-card-feature"; import "../card-features/hui-cover-position-card-feature"; @@ -21,12 +21,13 @@ import "../card-features/hui-lock-open-door-card-feature"; import "../card-features/hui-media-player-volume-slider-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"; import "../card-features/hui-target-humidity-card-feature"; +import "../card-features/hui-target-temperature-card-feature"; import "../card-features/hui-toggle-card-feature"; import "../card-features/hui-update-actions-card-feature"; import "../card-features/hui-vacuum-commands-card-feature"; import "../card-features/hui-water-heater-operation-modes-card-feature"; +import "../card-features/hui-area-controls-card-feature"; import type { LovelaceCardFeatureConfig } from "../card-features/types"; import { @@ -36,6 +37,7 @@ import { const TYPES = new Set([ "alarm-modes", + "area-controls", "climate-fan-modes", "climate-swing-modes", "climate-swing-horizontal-modes", diff --git a/src/panels/lovelace/editor/config-elements/hui-area-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-area-card-editor.ts index 7248d9fdc3..e9c15e329c 100644 --- a/src/panels/lovelace/editor/config-elements/hui-area-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-area-card-editor.ts @@ -1,43 +1,59 @@ -import { html, LitElement, nothing } from "lit"; +import { mdiGestureTap, mdiListBox, mdiTextShort } from "@mdi/js"; +import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import { - assert, + any, array, + assert, assign, boolean, + enums, object, optional, string, } from "superstruct"; -import { fireEvent } from "../../../../common/dom/fire_event"; -import "../../../../components/ha-form/ha-form"; import { - DEFAULT_ASPECT_RATIO, - DEVICE_CLASSES, -} from "../../cards/hui-area-card"; -import type { SchemaUnion } from "../../../../components/ha-form/types"; + fireEvent, + type HASSDomEvent, +} from "../../../../common/dom/fire_event"; +import { generateEntityFilter } from "../../../../common/entity/entity_filter"; +import { caseInsensitiveStringCompare } from "../../../../common/string/compare"; +import type { LocalizeFunc } from "../../../../common/translations/localize"; +import "../../../../components/ha-form/ha-form"; +import type { + HaFormSchema, + SchemaUnion, +} from "../../../../components/ha-form/types"; +import type { SelectOption } from "../../../../data/selector"; +import { getSensorNumericDeviceClasses } from "../../../../data/sensor"; import type { HomeAssistant } from "../../../../types"; +import type { + LovelaceCardFeatureConfig, + LovelaceCardFeatureContext, +} from "../../card-features/types"; +import { DEVICE_CLASSES } from "../../cards/hui-area-card"; import type { AreaCardConfig } from "../../cards/types"; import type { LovelaceCardEditor } from "../../types"; import { baseLovelaceCardConfig } from "../structs/base-card-struct"; -import { computeDomain } from "../../../../common/entity/compute_domain"; -import { caseInsensitiveStringCompare } from "../../../../common/string/compare"; -import type { SelectOption } from "../../../../data/selector"; -import { getSensorNumericDeviceClasses } from "../../../../data/sensor"; -import type { LocalizeFunc } from "../../../../common/translations/localize"; +import type { EditDetailElementEvent, EditSubElementEvent } from "../types"; +import { configElementStyle } from "./config-elements-style"; +import { getSupportedFeaturesType } from "./hui-card-features-editor"; const cardConfigStruct = assign( baseLovelaceCardConfig, object({ area: optional(string()), + name: optional(string()), navigation_path: optional(string()), - theme: optional(string()), show_camera: optional(boolean()), + display_type: optional(enums(["compact", "icon", "picture", "camera"])), camera_view: optional(string()), - aspect_ratio: optional(string()), alert_classes: optional(array(string())), sensor_classes: optional(array(string())), + features: optional(array(any())), + features_position: optional(enums(["bottom", "inline"])), + aspect_ratio: optional(string()), }) ); @@ -52,6 +68,12 @@ export class HuiAreaCardEditor @state() private _numericDeviceClasses?: string[]; + private _featureContext = memoizeOne( + (areaId?: string): LovelaceCardFeatureContext => ({ + area_id: areaId, + }) + ); + private _schema = memoizeOne( ( localize: LocalizeFunc, @@ -61,103 +83,143 @@ export class HuiAreaCardEditor ) => [ { name: "area", selector: { area: {} } }, - { name: "show_camera", required: false, selector: { boolean: {} } }, - ...(showCamera - ? ([ - { - name: "camera_view", - selector: { - select: { - options: ["auto", "live"].map((value) => ({ - value, - label: localize( - `ui.panel.lovelace.editor.card.generic.camera_view_options.${value}` + { + name: "content", + flatten: true, + type: "expandable", + iconPath: mdiTextShort, + schema: [ + { + name: "", + type: "grid", + schema: [ + { name: "name", selector: { text: {} } }, + { + name: "display_type", + required: true, + selector: { + select: { + options: ["compact", "icon", "picture", "camera"].map( + (value) => ({ + value, + label: localize( + `ui.panel.lovelace.editor.card.area.display_type_options.${value}` + ), + }) ), - })), - mode: "dropdown", + mode: "dropdown", + }, }, }, + ], + }, + { + name: "", + type: "grid", + schema: [ + ...(showCamera + ? ([ + { + name: "camera_view", + selector: { + select: { + options: ["auto", "live"].map((value) => ({ + value, + label: localize( + `ui.panel.lovelace.editor.card.generic.camera_view_options.${value}` + ), + })), + mode: "dropdown", + }, + }, + }, + ] as const satisfies readonly HaFormSchema[]) + : []), + ], + }, + { + name: "alert_classes", + selector: { + select: { + reorder: true, + multiple: true, + custom_value: true, + options: binaryClasses, + }, }, - ] as const) - : []), + }, + { + name: "sensor_classes", + selector: { + select: { + reorder: true, + multiple: true, + custom_value: true, + options: sensorClasses, + }, + }, + }, + ], + }, { - name: "", - type: "grid", + name: "interactions", + type: "expandable", + flatten: true, + iconPath: mdiGestureTap, schema: [ { name: "navigation_path", required: false, selector: { navigation: {} }, }, - { name: "theme", required: false, selector: { theme: {} } }, - { - name: "aspect_ratio", - default: DEFAULT_ASPECT_RATIO, - selector: { text: {} }, - }, ], }, - { - name: "alert_classes", - selector: { - select: { - reorder: true, - multiple: true, - custom_value: true, - options: binaryClasses, - }, - }, - }, - { - name: "sensor_classes", - selector: { - select: { - reorder: true, - multiple: true, - custom_value: true, - options: sensorClasses, - }, - }, - }, - ] as const + ] as const satisfies readonly HaFormSchema[] ); - private _binaryClassesForArea = memoizeOne((area: string): string[] => - this._classesForArea(area, "binary_sensor") + private _binaryClassesForArea = memoizeOne( + (area: string | undefined): string[] => { + if (!area) { + return []; + } + + const binarySensorFilter = generateEntityFilter(this.hass!, { + domain: "binary_sensor", + area, + entity_category: "none", + }); + + const classes = Object.keys(this.hass!.entities) + .filter(binarySensorFilter) + .map((id) => this.hass!.states[id]?.attributes.device_class) + .filter((c): c is string => Boolean(c)); + + return [...new Set(classes)]; + } ); private _sensorClassesForArea = memoizeOne( - (area: string, numericDeviceClasses?: string[]): string[] => - this._classesForArea(area, "sensor", numericDeviceClasses) + (area: string | undefined, numericDeviceClasses?: string[]): string[] => { + if (!area) { + return []; + } + + const sensorFilter = generateEntityFilter(this.hass!, { + domain: "sensor", + area, + device_class: numericDeviceClasses, + entity_category: "none", + }); + + const classes = Object.keys(this.hass!.entities) + .filter(sensorFilter) + .map((id) => this.hass!.states[id]?.attributes.device_class) + .filter((c): c is string => Boolean(c)); + + return [...new Set(classes)]; + } ); - private _classesForArea( - area: string, - domain: "sensor" | "binary_sensor", - numericDeviceClasses?: string[] | undefined - ): string[] { - const entities = Object.values(this.hass!.entities).filter( - (e) => - computeDomain(e.entity_id) === domain && - !e.entity_category && - !e.hidden && - (e.area_id === area || - (e.device_id && this.hass!.devices[e.device_id]?.area_id === area)) - ); - - const classes = entities - .map((e) => this.hass!.states[e.entity_id]?.attributes.device_class || "") - .filter( - (c) => - c && - (domain !== "sensor" || - !numericDeviceClasses || - numericDeviceClasses.includes(c)) - ); - - return [...new Set(classes)]; - } - private _buildBinaryOptions = memoizeOne( (possibleClasses: string[], currentClasses: string[]): SelectOption[] => this._buildOptions("binary_sensor", possibleClasses, currentClasses) @@ -191,7 +253,14 @@ export class HuiAreaCardEditor public setConfig(config: AreaCardConfig): void { assert(config, cardConfigStruct); - this._config = config; + + const displayType = + config.display_type || (config.show_camera ? "camera" : "picture"); + this._config = { + ...config, + display_type: displayType, + }; + delete this._config.show_camera; } protected async updated() { @@ -202,16 +271,50 @@ export class HuiAreaCardEditor } } + private _featuresSchema = memoizeOne( + (localize: LocalizeFunc) => + [ + { + name: "features_position", + required: true, + selector: { + select: { + mode: "box", + options: ["bottom", "inline"].map((value) => ({ + label: localize( + `ui.panel.lovelace.editor.card.tile.features_position_options.${value}` + ), + description: localize( + `ui.panel.lovelace.editor.card.tile.features_position_options.${value}_description` + ), + value, + image: { + src: `/static/images/form/tile_features_position_${value}.svg`, + src_dark: `/static/images/form/tile_features_position_${value}_dark.svg`, + flip_rtl: true, + }, + })), + }, + }, + }, + ] as const satisfies readonly HaFormSchema[] + ); + + private _hasCompatibleFeatures = memoizeOne( + (context: LovelaceCardFeatureContext) => + getSupportedFeaturesType(this.hass!, context).length > 0 + ); + protected render() { if (!this.hass || !this._config) { return nothing; } - const possibleBinaryClasses = this._binaryClassesForArea( - this._config.area || "" - ); + const areaId = this._config!.area; + + const possibleBinaryClasses = this._binaryClassesForArea(this._config.area); const possibleSensorClasses = this._sensorClassesForArea( - this._config.area || "", + this._config.area, this._numericDeviceClasses ); const binarySelectOptions = this._buildBinaryOptions( @@ -223,68 +326,196 @@ export class HuiAreaCardEditor this._config.sensor_classes || DEVICE_CLASSES.sensor ); + const showCamera = this._config.display_type === "camera"; + + const displayType = + this._config.display_type || this._config.show_camera + ? "camera" + : "picture"; + const schema = this._schema( this.hass.localize, - this._config.show_camera || false, + showCamera, binarySelectOptions, sensorSelectOptions ); + const featuresSchema = this._featuresSchema(this.hass.localize); + const data = { camera_view: "auto", alert_classes: DEVICE_CLASSES.binary_sensor, sensor_classes: DEVICE_CLASSES.sensor, + features_position: "bottom", + display_type: displayType, ...this._config, }; + const featureContext = this._featureContext(areaId); + const hasCompatibleFeatures = this._hasCompatibleFeatures(featureContext); + return html` + + +

+ ${this.hass!.localize( + "ui.panel.lovelace.editor.card.generic.features" + )} +

+
+ ${hasCompatibleFeatures + ? html` + + ` + : nothing} + +
+
`; } private _valueChanged(ev: CustomEvent): void { - const config = ev.detail.value; - if (!config.show_camera) { + const newConfig = ev.detail.value as AreaCardConfig; + + const config: AreaCardConfig = { + features: this._config!.features, + ...newConfig, + }; + + if (config.display_type !== "camera") { delete config.camera_view; } + fireEvent(this, "config-changed", { config }); } + private _featuresChanged(ev: CustomEvent) { + ev.stopPropagation(); + if (!this._config || !this.hass) { + return; + } + + const features = ev.detail.features as LovelaceCardFeatureConfig[]; + const config: AreaCardConfig = { + ...this._config, + features, + }; + + if (features.length === 0) { + delete config.features; + } + + fireEvent(this, "config-changed", { config }); + } + + private _editDetailElement(ev: HASSDomEvent): void { + const index = ev.detail.subElementConfig.index; + const config = this._config!.features![index!]; + const featureContext = this._featureContext(this._config!.area); + + fireEvent(this, "edit-sub-element", { + config: config, + saveConfig: (newConfig) => this._updateFeature(index!, newConfig), + context: featureContext, + type: "feature", + } as EditSubElementEvent< + LovelaceCardFeatureConfig, + LovelaceCardFeatureContext + >); + } + + private _updateFeature(index: number, feature: LovelaceCardFeatureConfig) { + const features = this._config!.features!.concat(); + features[index] = feature; + const config = { ...this._config!, features }; + fireEvent(this, "config-changed", { + config: config, + }); + } + + private _computeHelperCallback = ( + schema: + | SchemaUnion> + | SchemaUnion> + ): string | undefined => { + switch (schema.name) { + case "alert_classes": + if (this._config?.display_type === "compact") { + return this.hass!.localize( + `ui.panel.lovelace.editor.card.area.alert_classes_helper` + ); + } + return undefined; + default: + return undefined; + } + }; + private _computeLabelCallback = ( - schema: SchemaUnion> + schema: + | SchemaUnion> + | SchemaUnion> ) => { switch (schema.name) { - case "theme": - return `${this.hass!.localize( - "ui.panel.lovelace.editor.card.generic.theme" - )} (${this.hass!.localize( - "ui.panel.lovelace.editor.card.config.optional" - )})`; case "area": return this.hass!.localize("ui.panel.lovelace.editor.card.area.name"); + + case "name": + case "camera_view": + case "content": + return this.hass!.localize( + `ui.panel.lovelace.editor.card.generic.${schema.name}` + ); case "navigation_path": return this.hass!.localize( "ui.panel.lovelace.editor.action-editor.navigation_path" ); - case "aspect_ratio": + case "interactions": + case "features_position": return this.hass!.localize( - "ui.panel.lovelace.editor.card.generic.aspect_ratio" - ); - case "camera_view": - return this.hass!.localize( - "ui.panel.lovelace.editor.card.generic.camera_view" + `ui.panel.lovelace.editor.card.tile.${schema.name}` ); } return this.hass!.localize( `ui.panel.lovelace.editor.card.area.${schema.name}` ); }; + + static get styles() { + return [ + configElementStyle, + css` + ha-form { + display: block; + margin-bottom: 24px; + } + .features-form { + margin-bottom: 8px; + } + `, + ]; + } } declare global { diff --git a/src/panels/lovelace/editor/config-elements/hui-area-controls-card-feature-editor.ts b/src/panels/lovelace/editor/config-elements/hui-area-controls-card-feature-editor.ts new file mode 100644 index 0000000000..ac79793497 --- /dev/null +++ b/src/panels/lovelace/editor/config-elements/hui-area-controls-card-feature-editor.ts @@ -0,0 +1,180 @@ +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 type { LocalizeFunc } from "../../../../common/translations/localize"; +import "../../../../components/ha-form/ha-form"; +import type { + HaFormSchema, + SchemaUnion, +} from "../../../../components/ha-form/types"; +import type { HomeAssistant } from "../../../../types"; +import { getAreaControlEntities } from "../../card-features/hui-area-controls-card-feature"; +import { + AREA_CONTROLS, + type AreaControl, + type AreaControlsCardFeatureConfig, + type LovelaceCardFeatureContext, +} from "../../card-features/types"; +import type { LovelaceCardFeatureEditor } from "../../types"; + +type AreaControlsCardFeatureData = AreaControlsCardFeatureConfig & { + customize_controls: boolean; +}; + +@customElement("hui-area-controls-card-feature-editor") +export class HuiAreaControlsCardFeatureEditor + extends LitElement + implements LovelaceCardFeatureEditor +{ + @property({ attribute: false }) public hass?: HomeAssistant; + + @property({ attribute: false }) public context?: LovelaceCardFeatureContext; + + @state() private _config?: AreaControlsCardFeatureConfig; + + public setConfig(config: AreaControlsCardFeatureConfig): void { + this._config = config; + } + + private _schema = memoizeOne( + ( + localize: LocalizeFunc, + customizeControls: boolean, + compatibleControls: AreaControl[] + ) => + [ + { + name: "customize_controls", + selector: { + boolean: {}, + }, + }, + ...(customizeControls + ? ([ + { + name: "controls", + selector: { + select: { + reorder: true, + multiple: true, + options: compatibleControls.map((control) => ({ + value: control, + label: localize( + `ui.panel.lovelace.editor.features.types.area-controls.controls_options.${control}` + ), + })), + }, + }, + }, + ] as const satisfies readonly HaFormSchema[]) + : []), + ] as const satisfies readonly HaFormSchema[] + ); + + private _compatibleControls = memoizeOne( + ( + areaId: string, + // needed to update memoized function when entities, devices or areas change + _entities: HomeAssistant["entities"], + _devices: HomeAssistant["devices"], + _areas: HomeAssistant["areas"] + ) => { + if (!this.hass) { + return []; + } + const controlEntities = getAreaControlEntities( + AREA_CONTROLS as unknown as AreaControl[], + areaId, + this.hass! + ); + return ( + Object.keys(controlEntities) as (keyof typeof controlEntities)[] + ).filter((control) => controlEntities[control].length > 0); + } + ); + + protected render() { + if (!this.hass || !this._config || !this.context?.area_id) { + return nothing; + } + + const compatibleControls = this._compatibleControls( + this.context.area_id, + this.hass.entities, + this.hass.devices, + this.hass.areas + ); + + if (compatibleControls.length === 0) { + return html` + + ${this.hass.localize( + "ui.panel.lovelace.editor.features.types.area-controls.no_compatible_controls" + )} + + `; + } + + const data: AreaControlsCardFeatureData = { + ...this._config, + customize_controls: this._config.controls !== undefined, + }; + + const schema = this._schema( + this.hass.localize, + data.customize_controls, + compatibleControls + ); + + return html` + + `; + } + + private _valueChanged(ev: CustomEvent): void { + const { customize_controls, ...config } = ev.detail + .value as AreaControlsCardFeatureData; + + if (customize_controls && !config.controls) { + config.controls = this._compatibleControls( + this.context!.area_id!, + this.hass!.entities, + this.hass!.devices, + this.hass!.areas + ).concat(); + } + + if (!customize_controls && config.controls) { + delete config.controls; + } + + fireEvent(this, "config-changed", { config: config }); + } + + private _computeLabelCallback = ( + schema: SchemaUnion> + ) => { + switch (schema.name) { + case "controls": + case "customize_controls": + return this.hass!.localize( + `ui.panel.lovelace.editor.features.types.area-controls.${schema.name}` + ); + default: + return ""; + } + }; +} + +declare global { + interface HTMLElementTagNameMap { + "hui-area-controls-card-feature-editor": HuiAreaControlsCardFeatureEditor; + } +} 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 b92723ecc2..befbb73c7b 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 @@ -18,6 +18,7 @@ import { } from "../../../../data/lovelace_custom_cards"; import type { HomeAssistant } from "../../../../types"; import { supportsAlarmModesCardFeature } from "../../card-features/hui-alarm-modes-card-feature"; +import { supportsAreaControlsCardFeature } from "../../card-features/hui-area-controls-card-feature"; import { supportsClimateFanModesCardFeature } from "../../card-features/hui-climate-fan-modes-card-feature"; import { supportsClimateHvacModesCardFeature } from "../../card-features/hui-climate-hvac-modes-card-feature"; import { supportsClimatePresetModesCardFeature } from "../../card-features/hui-climate-preset-modes-card-feature"; @@ -61,6 +62,7 @@ type SupportsFeature = ( const UI_FEATURE_TYPES = [ "alarm-modes", + "area-controls", "climate-fan-modes", "climate-hvac-modes", "climate-preset-modes", @@ -95,6 +97,7 @@ type UiFeatureTypes = (typeof UI_FEATURE_TYPES)[number]; const EDITABLES_FEATURE_TYPES = new Set([ "alarm-modes", + "area-controls", "climate-fan-modes", "climate-hvac-modes", "climate-preset-modes", @@ -116,6 +119,7 @@ const SUPPORTS_FEATURE_TYPES: Record< SupportsFeature | undefined > = { "alarm-modes": supportsAlarmModesCardFeature, + "area-controls": supportsAreaControlsCardFeature, "climate-fan-modes": supportsClimateFanModesCardFeature, "climate-swing-modes": supportsClimateSwingModesCardFeature, "climate-swing-horizontal-modes": diff --git a/src/translations/en.json b/src/translations/en.json index d64c5f2ce5..895ac9fbc8 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -7172,10 +7172,17 @@ }, "area": { "name": "Area", - "alert_classes": "Alert Classes", - "sensor_classes": "Sensor Classes", + "alert_classes": "Alert classes", + "alert_classes_helper": "In compact style, only the first one will be shown. Order alerts by priority.", + "sensor_classes": "Sensor classes", "description": "The Area card automatically displays entities of a specific area.", - "show_camera": "Show camera feed instead of area picture" + "display_type": "Display type", + "display_type_options": { + "compact": "Compact", + "icon": "Area icon", + "picture": "Area picture", + "camera": "Camera feed" + } }, "calendar": { "name": "Calendar", @@ -7832,6 +7839,17 @@ "ask": "Ask" }, "backup_not_supported": "Backup is not supported." + }, + "area-controls": { + "label": "Area controls", + "customize_controls": "Customize controls", + "controls": "Controls", + "controls_options": { + "light": "Lights", + "fan": "Fans", + "switch": "Switches" + }, + "no_compatible_controls": "No compatible controls available for this area" } } },