diff --git a/src/common/entity/group_entities.ts b/src/common/entity/group_entities.ts new file mode 100644 index 0000000000..32f9634af5 --- /dev/null +++ b/src/common/entity/group_entities.ts @@ -0,0 +1,68 @@ +import { callService, type HassEntity } from "home-assistant-js-websocket"; +import { computeStateDomain } from "./compute_state_domain"; +import { isUnavailableState, UNAVAILABLE } from "../../data/entity"; +import type { HomeAssistant } from "../../types"; + +export const computeGroupEntitiesState = (states: HassEntity[]): string => { + if (!states.length) { + return UNAVAILABLE; + } + + const validState = states.filter((stateObj) => isUnavailableState(stateObj)); + + if (!validState) { + return UNAVAILABLE; + } + + // Use the first state to determine the domain + // This assumes all states in the group have the same domain + const domain = computeStateDomain(states[0]); + + if (domain === "cover") { + for (const s of ["opening", "closing", "open"]) { + if (states.some((stateObj) => stateObj.state === s)) { + return s; + } + } + return "closed"; + } + + if (states.some((stateObj) => stateObj.state === "on")) { + return "on"; + } + return "off"; +}; + +export const toggleGroupEntities = ( + hass: HomeAssistant, + states: HassEntity[] +) => { + if (!states.length) { + return; + } + + // Use the first state to determine the domain + // This assumes all states in the group have the same domain + const domain = computeStateDomain(states[0]); + + const state = computeGroupEntitiesState(states); + + const isOn = state === "on" || state === "open"; + + let service = isOn ? "turn_off" : "turn_on"; + if (domain === "cover") { + if (state === "opening" || state === "closing") { + // If the cover is opening or closing, we toggle it to stop it + service = "stop_cover"; + } else { + // For covers, we use the open/close service + service = isOn ? "close_cover" : "open_cover"; + } + } + + const entitiesIds = states.map((stateObj) => stateObj.entity_id); + + callService(hass.connection, domain, service, { + entity_id: entitiesIds, + }); +}; diff --git a/src/common/entity/state_color.ts b/src/common/entity/state_color.ts index 474fee0e59..976f56ab11 100644 --- a/src/common/entity/state_color.ts +++ b/src/common/entity/state_color.ts @@ -64,15 +64,27 @@ export const domainStateColorProperties = ( const compareState = state !== undefined ? state : stateObj.state; const active = stateActive(stateObj, state); + return domainColorProperties( + domain, + stateObj.attributes.device_class, + compareState, + active + ); +}; + +export const domainColorProperties = ( + domain: string, + deviceClass: string | undefined, + state: string, + active: boolean +) => { const properties: string[] = []; - const stateKey = slugify(compareState, "_"); + const stateKey = slugify(state, "_"); const activeKey = active ? "active" : "inactive"; - const dc = stateObj.attributes.device_class; - - if (dc) { - properties.push(`--state-${domain}-${dc}-${stateKey}-color`); + if (deviceClass) { + properties.push(`--state-${domain}-${deviceClass}-${stateKey}-color`); } properties.push( diff --git a/src/components/ha-control-button-group.ts b/src/components/ha-control-button-group.ts index 5c59d0801f..fa9d0717c1 100644 --- a/src/components/ha-control-button-group.ts +++ b/src/components/ha-control-button-group.ts @@ -26,6 +26,7 @@ export class HaControlButtonGroup extends LitElement { .container { display: flex; flex-direction: row; + justify-content: var(--control-button-group-alignment, start); width: 100%; height: 100%; } diff --git a/src/components/ha-domain-icon.ts b/src/components/ha-domain-icon.ts index 6994c32c99..7d30d2f4a9 100644 --- a/src/components/ha-domain-icon.ts +++ b/src/components/ha-domain-icon.ts @@ -18,6 +18,8 @@ export class HaDomainIcon extends LitElement { @property({ attribute: false }) public deviceClass?: string; + @property({ attribute: false }) public state?: string; + @property() public icon?: string; @property({ attribute: "brand-fallback", type: Boolean }) @@ -36,14 +38,17 @@ export class HaDomainIcon extends LitElement { return this._renderFallback(); } - const icon = domainIcon(this.hass, this.domain, this.deviceClass).then( - (icn) => { - if (icn) { - return html``; - } - return this._renderFallback(); + const icon = domainIcon( + this.hass, + this.domain, + this.deviceClass, + this.state + ).then((icn) => { + if (icn) { + return html``; } - ); + return this._renderFallback(); + }); return html`${until(icon)}`; } diff --git a/src/data/icons.ts b/src/data/icons.ts index 5d870aac2e..4c6699c038 100644 --- a/src/data/icons.ts +++ b/src/data/icons.ts @@ -504,14 +504,25 @@ export const serviceSectionIcon = async ( export const domainIcon = async ( hass: HomeAssistant, domain: string, - deviceClass?: string + deviceClass?: string, + state?: string ): Promise => { const entityComponentIcons = await getComponentIcons(hass, domain); if (entityComponentIcons) { const translations = (deviceClass && entityComponentIcons[deviceClass]) || entityComponentIcons._; - return translations?.default; + // First check for exact state match + if (state && translations.state?.[state]) { + return translations.state[state]; + } + // Then check for range-based icons if we have a numeric state + if (state !== undefined && translations.range && !isNaN(Number(state))) { + return getIconFromRange(Number(state), translations.range); + } + // Fallback to default icon + return translations.default; } + return undefined; }; diff --git a/src/panels/lovelace/card-features/common/card-feature-styles.ts b/src/panels/lovelace/card-features/common/card-feature-styles.ts index 799570cc99..15a6cebcd1 100644 --- a/src/panels/lovelace/card-features/common/card-feature-styles.ts +++ b/src/panels/lovelace/card-features/common/card-feature-styles.ts @@ -25,6 +25,9 @@ export const cardFeatureStyles = css` flex-basis: 20px; --control-button-padding: 0px; } + ha-control-button-group[no-stretch] > ha-control-button { + max-width: 48px; + } ha-control-button { --control-button-focus-color: var(--feature-color); } 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 index 353be677b9..ceb789d02c 100644 --- a/src/panels/lovelace/card-features/hui-area-controls-card-feature.ts +++ b/src/panels/lovelace/card-features/hui-area-controls-card-feature.ts @@ -1,17 +1,22 @@ -import { mdiFan, mdiLightbulb, mdiToggleSwitch } from "@mdi/js"; -import { callService, type HassEntity } from "home-assistant-js-websocket"; -import { LitElement, css, html, nothing } from "lit"; +import type { HassEntity } from "home-assistant-js-websocket"; +import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; +import { styleMap } from "lit/directives/style-map"; import memoizeOne from "memoize-one"; +import { ensureArray } from "../../../common/array/ensure-array"; +import { generateEntityFilter } from "../../../common/entity/entity_filter"; import { - generateEntityFilter, - type EntityFilter, -} from "../../../common/entity/entity_filter"; + computeGroupEntitiesState, + toggleGroupEntities, +} from "../../../common/entity/group_entities"; import { stateActive } from "../../../common/entity/state_active"; +import { domainColorProperties } from "../../../common/entity/state_color"; import "../../../components/ha-control-button"; import "../../../components/ha-control-button-group"; import "../../../components/ha-svg-icon"; import type { AreaRegistryEntry } from "../../../data/area_registry"; +import { forwardHaptic } from "../../../data/haptics"; +import { computeCssVariable } from "../../../resources/css-variables"; import type { HomeAssistant } from "../../../types"; import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types"; import { cardFeatureStyles } from "./common/card-feature-styles"; @@ -19,41 +24,55 @@ import type { AreaControl, AreaControlsCardFeatureConfig, LovelaceCardFeatureContext, + LovelaceCardFeaturePosition, } from "./types"; import { AREA_CONTROLS } from "./types"; interface AreaControlsButton { - iconPath: string; - onService: string; - offService: string; - filter: EntityFilter; + offIcon?: string; + onIcon?: string; + filter: { + domain: string; + device_class?: string; + }; } +const coverButton = (deviceClass: string) => ({ + filter: { + domain: "cover", + device_class: deviceClass, + }, +}); + export const AREA_CONTROLS_BUTTONS: Record = { light: { - iconPath: mdiLightbulb, + // Overrides the icons for lights + offIcon: "mdi:lightbulb-off", + onIcon: "mdi:lightbulb", filter: { domain: "light", }, - onService: "light.turn_on", - offService: "light.turn_off", }, fan: { - iconPath: mdiFan, filter: { domain: "fan", }, - onService: "fan.turn_on", - offService: "fan.turn_off", }, switch: { - iconPath: mdiToggleSwitch, filter: { domain: "switch", }, - onService: "switch.turn_on", - offService: "switch.turn_off", }, + "cover-blind": coverButton("blind"), + "cover-curtain": coverButton("curtain"), + "cover-damper": coverButton("damper"), + "cover-awning": coverButton("awning"), + "cover-door": coverButton("door"), + "cover-garage": coverButton("garage"), + "cover-gate": coverButton("gate"), + "cover-shade": coverButton("shade"), + "cover-shutter": coverButton("shutter"), + "cover-window": coverButton("window"), }; export const supportsAreaControlsCardFeature = ( @@ -87,6 +106,8 @@ export const getAreaControlEntities = ( {} as Record ); +export const MAX_DEFAULT_AREA_CONTROLS = 4; + @customElement("hui-area-controls-card-feature") class HuiAreaControlsCardFeature extends LitElement @@ -96,6 +117,9 @@ class HuiAreaControlsCardFeature @property({ attribute: false }) public context?: LovelaceCardFeatureContext; + @property({ attribute: false }) + public position?: LovelaceCardFeaturePosition; + @state() private _config?: AreaControlsCardFeatureConfig; private get _area() { @@ -151,17 +175,12 @@ class HuiAreaControlsCardFeature ); const entitiesIds = controlEntities[control]; - const { onService, offService } = AREA_CONTROLS_BUTTONS[control]; + const entities = entitiesIds + .map((entityId) => this.hass!.states[entityId] as HassEntity | undefined) + .filter((v): v is HassEntity => Boolean(v)); - 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, - }); + forwardHaptic("light"); + toggleGroupEntities(this.hass, entities); } private _controlEntities = memoizeOne( @@ -200,33 +219,67 @@ class HuiAreaControlsCardFeature (control) => controlEntities[control].length > 0 ); - if (!supportedControls.length) { + const displayControls = this._config.controls + ? supportedControls + : supportedControls.slice(0, MAX_DEFAULT_AREA_CONTROLS); // Limit to max if using default controls + + if (!displayControls.length) { return nothing; } return html` - - ${supportedControls.map((control) => { + + ${displayControls.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); - }); + const entityIds = controlEntities[control]; + + const entities = entityIds + .map( + (entityId) => + this.hass!.states[entityId] as HassEntity | undefined + ) + .filter((v): v is HassEntity => Boolean(v)); + + const groupState = computeGroupEntitiesState(entities); + + const active = entities[0] + ? stateActive(entities[0], groupState) + : false; + + const label = this.hass!.localize( + `ui.card_features.area_controls.${control}.${active ? "off" : "on"}` + ); + + const icon = active ? button.onIcon : button.offIcon; + + const domain = button.filter.domain; + const deviceClass = button.filter.device_class + ? ensureArray(button.filter.device_class)[0] + : undefined; + + const activeColor = computeCssVariable( + domainColorProperties(domain, deviceClass, groupState, true) + ); return html` - + `; })} @@ -238,6 +291,9 @@ class HuiAreaControlsCardFeature return [ cardFeatureStyles, css` + ha-control-button-group { + --control-button-group-alignment: flex-end; + } ha-control-button { --active-color: var(--state-active-color); --control-button-focus-color: var(--state-active-color); diff --git a/src/panels/lovelace/card-features/hui-card-feature.ts b/src/panels/lovelace/card-features/hui-card-feature.ts index 12592b4fba..fa92cc89ae 100644 --- a/src/panels/lovelace/card-features/hui-card-feature.ts +++ b/src/panels/lovelace/card-features/hui-card-feature.ts @@ -7,6 +7,7 @@ import type { LovelaceCardFeature } from "../types"; import type { LovelaceCardFeatureConfig, LovelaceCardFeatureContext, + LovelaceCardFeaturePosition, } from "./types"; @customElement("hui-card-feature") @@ -19,6 +20,9 @@ export class HuiCardFeature extends LitElement { @property({ attribute: false }) public color?: string; + @property({ attribute: false }) + public position?: LovelaceCardFeaturePosition; + private _element?: LovelaceCardFeature | HuiErrorCard; private _getFeatureElement(feature: LovelaceCardFeatureConfig) { @@ -41,6 +45,7 @@ export class HuiCardFeature extends LitElement { element.hass = this.hass; element.context = this.context; element.color = this.color; + element.position = this.position; // Backwards compatibility from custom card features if (this.context.entity_id) { const stateObj = this.hass.states[this.context.entity_id]; diff --git a/src/panels/lovelace/card-features/hui-card-features.ts b/src/panels/lovelace/card-features/hui-card-features.ts index b723c3f2a0..138e639657 100644 --- a/src/panels/lovelace/card-features/hui-card-features.ts +++ b/src/panels/lovelace/card-features/hui-card-features.ts @@ -5,6 +5,7 @@ import "./hui-card-feature"; import type { LovelaceCardFeatureConfig, LovelaceCardFeatureContext, + LovelaceCardFeaturePosition, } from "./types"; @customElement("hui-card-features") @@ -17,6 +18,9 @@ export class HuiCardFeatures extends LitElement { @property({ attribute: false }) public color?: string; + @property({ attribute: false }) + public position?: LovelaceCardFeaturePosition; + protected render() { if (!this.features) { return nothing; @@ -29,6 +33,7 @@ export class HuiCardFeatures extends LitElement { .context=${this.context} .color=${this.color} .feature=${feature} + .position=${this.position} > ` )} diff --git a/src/panels/lovelace/card-features/types.ts b/src/panels/lovelace/card-features/types.ts index b486fa1c4d..4b74481431 100644 --- a/src/panels/lovelace/card-features/types.ts +++ b/src/panels/lovelace/card-features/types.ts @@ -158,7 +158,21 @@ export interface UpdateActionsCardFeatureConfig { backup?: "yes" | "no" | "ask"; } -export const AREA_CONTROLS = ["light", "fan", "switch"] as const; +export const AREA_CONTROLS = [ + "light", + "fan", + "cover-shutter", + "cover-blind", + "cover-curtain", + "cover-shade", + "cover-awning", + "cover-garage", + "cover-gate", + "cover-door", + "cover-window", + "cover-damper", + "switch", +] as const; export type AreaControl = (typeof AREA_CONTROLS)[number]; @@ -168,6 +182,8 @@ export interface AreaControlsCardFeatureConfig { exclude_entities?: string[]; } +export type LovelaceCardFeaturePosition = "bottom" | "inline"; + export type LovelaceCardFeatureConfig = | AlarmModesCardFeatureConfig | ClimateFanModesCardFeatureConfig diff --git a/src/panels/lovelace/cards/hui-area-card.ts b/src/panels/lovelace/cards/hui-area-card.ts index 3f87f13f65..dc2844e731 100644 --- a/src/panels/lovelace/cards/hui-area-card.ts +++ b/src/panels/lovelace/cards/hui-area-card.ts @@ -514,6 +514,7 @@ export class HuiAreaCard extends LitElement implements LovelaceCard { .context=${this._featureContext} .color=${this._config.color} .features=${features} + .position=${featurePosition} > ` : nothing} diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index 5e04bfb1f0..38c58a6bed 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -9,7 +9,10 @@ import type { ThemeMode, TranslationDict, } from "../../../types"; -import type { LovelaceCardFeatureConfig } from "../card-features/types"; +import type { + LovelaceCardFeatureConfig, + LovelaceCardFeaturePosition, +} from "../card-features/types"; import type { LegacyStateFilter } from "../common/evaluate-filter"; import type { Condition, LegacyCondition } from "../common/validate-condition"; import type { HuiImage } from "../components/hui-image"; @@ -113,7 +116,7 @@ export interface AreaCardConfig extends LovelaceCardConfig { sensor_classes?: string[]; alert_classes?: string[]; features?: LovelaceCardFeatureConfig[]; - features_position?: "bottom" | "inline"; + features_position?: LovelaceCardFeaturePosition; } export interface ButtonCardConfig extends LovelaceCardConfig { @@ -564,7 +567,7 @@ export interface TileCardConfig extends LovelaceCardConfig { icon_hold_action?: ActionConfig; icon_double_tap_action?: ActionConfig; features?: LovelaceCardFeatureConfig[]; - features_position?: "bottom" | "inline"; + features_position?: LovelaceCardFeaturePosition; } export interface HeadingCardConfig extends LovelaceCardConfig { 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 index ac79793497..819ce72dfb 100644 --- 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 @@ -9,7 +9,10 @@ import type { SchemaUnion, } from "../../../../components/ha-form/types"; import type { HomeAssistant } from "../../../../types"; -import { getAreaControlEntities } from "../../card-features/hui-area-controls-card-feature"; +import { + getAreaControlEntities, + MAX_DEFAULT_AREA_CONTROLS, +} from "../../card-features/hui-area-controls-card-feature"; import { AREA_CONTROLS, type AreaControl, @@ -72,7 +75,7 @@ export class HuiAreaControlsCardFeatureEditor ] as const satisfies readonly HaFormSchema[] ); - private _compatibleControls = memoizeOne( + private _supportedControls = memoizeOne( ( areaId: string, // needed to update memoized function when entities, devices or areas change @@ -99,14 +102,14 @@ export class HuiAreaControlsCardFeatureEditor return nothing; } - const compatibleControls = this._compatibleControls( + const supportedControls = this._supportedControls( this.context.area_id, this.hass.entities, this.hass.devices, this.hass.areas ); - if (compatibleControls.length === 0) { + if (supportedControls.length === 0) { return html` ${this.hass.localize( @@ -124,7 +127,7 @@ export class HuiAreaControlsCardFeatureEditor const schema = this._schema( this.hass.localize, data.customize_controls, - compatibleControls + supportedControls ); return html` @@ -143,12 +146,12 @@ export class HuiAreaControlsCardFeatureEditor .value as AreaControlsCardFeatureData; if (customize_controls && !config.controls) { - config.controls = this._compatibleControls( + config.controls = this._supportedControls( this.context!.area_id!, this.hass!.entities, this.hass!.devices, this.hass!.areas - ).concat(); + ).slice(0, MAX_DEFAULT_AREA_CONTROLS); // Limit to max default controls } if (!customize_controls && config.controls) { diff --git a/src/panels/lovelace/strategies/areas/areas-overview-view-strategy.ts b/src/panels/lovelace/strategies/areas/areas-overview-view-strategy.ts index 4fdd45c075..3ff37a0b7c 100644 --- a/src/panels/lovelace/strategies/areas/areas-overview-view-strategy.ts +++ b/src/panels/lovelace/strategies/areas/areas-overview-view-strategy.ts @@ -6,7 +6,7 @@ import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/sec import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view"; import type { HomeAssistant } from "../../../../types"; import { getAreaControlEntities } from "../../card-features/hui-area-controls-card-feature"; -import type { AreaControl } from "../../card-features/types"; +import { AREA_CONTROLS, type AreaControl } from "../../card-features/types"; import type { AreaCardConfig, HeadingCardConfig } from "../../cards/types"; import type { EntitiesDisplay } from "./area-view-strategy"; import { computeAreaPath, getAreas } from "./helpers/areas-strategy-helper"; @@ -77,7 +77,9 @@ export class AreasOverviewViewStrategy extends ReactiveElement { .map((display) => display.hidden || []) .flat(); - const controls: AreaControl[] = ["light", "fan"]; + const controls: AreaControl[] = AREA_CONTROLS.filter( + (a) => a !== "switch" // Exclude switches control for areas as we don't know what the switches control + ); const controlEntities = getAreaControlEntities( controls, area.area_id, @@ -112,6 +114,11 @@ export class AreasOverviewViewStrategy extends ReactiveElement { }, ] : [], + grid_options: { + rows: 1, + columns: 12, + }, + features_position: "inline", navigation_path: path, }; }); diff --git a/src/panels/lovelace/types.ts b/src/panels/lovelace/types.ts index 40534349bf..ea580f59d8 100644 --- a/src/panels/lovelace/types.ts +++ b/src/panels/lovelace/types.ts @@ -13,6 +13,7 @@ import type { Constructor, HomeAssistant } from "../../types"; import type { LovelaceCardFeatureConfig, LovelaceCardFeatureContext, + LovelaceCardFeaturePosition, } from "./card-features/types"; import type { LovelaceElement, LovelaceElementConfig } from "./elements/types"; import type { LovelaceRow, LovelaceRowConfig } from "./entity-rows/types"; @@ -179,6 +180,7 @@ export interface LovelaceCardFeature extends HTMLElement { context?: LovelaceCardFeatureContext; setConfig(config: LovelaceCardFeatureConfig); color?: string; + position?: LovelaceCardFeaturePosition; } export interface LovelaceCardFeatureConstructor diff --git a/src/translations/en.json b/src/translations/en.json index 5bb35b2aab..ae3efff279 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -325,6 +325,62 @@ "low": "Low" } }, + "card_features": { + "area_controls": { + "light": { + "on": "Turn on area lights", + "off": "Turn off area lights" + }, + "fan": { + "on": "Turn on area fans", + "off": "Turn off area fans" + }, + "switch": { + "on": "Turn on area switches", + "off": "Turn off area switches" + }, + "cover-awning": { + "on": "Open area awnings", + "off": "Close area awnings" + }, + "cover-blind": { + "on": "Open area blinds", + "off": "Close area blinds" + }, + "cover-curtain": { + "on": "Open area curtains", + "off": "Close area curtains" + }, + "cover-damper": { + "on": "Open area dampers", + "off": "Close area dampers" + }, + "cover-door": { + "on": "Open area doors", + "off": "Close area doors" + }, + "cover-garage": { + "on": "Open garage door", + "off": "Close garage door" + }, + "cover-gate": { + "on": "Open area gates", + "off": "Close area gates" + }, + "cover-shade": { + "on": "Open area shades", + "off": "Close area shades" + }, + "cover-shutter": { + "on": "Open area shutters", + "off": "Close area shutters" + }, + "cover-window": { + "on": "Open area windows", + "off": "Close area windows" + } + } + }, "common": { "and": "and", "continue": "Continue", @@ -383,6 +439,7 @@ "markdown": "Markdown", "suggest_ai": "Suggest with AI" }, + "components": { "selectors": { "media": { @@ -7857,7 +7914,17 @@ "controls_options": { "light": "Lights", "fan": "Fans", - "switch": "Switches" + "switch": "Switches", + "cover-awning": "Awnings", + "cover-blind": "Blinds", + "cover-curtain": "Curtains", + "cover-damper": "Dampers", + "cover-door": "Doors", + "cover-garage": "Garage doors", + "cover-gate": "Gates", + "cover-shade": "Shades", + "cover-shutter": "Shutters", + "cover-window": "Windows" }, "no_compatible_controls": "No compatible controls available for this area" }