Add cover controls to area card and improve areas dashboard (#25892)

This commit is contained in:
Paul Bottein 2025-06-25 15:14:41 +02:00 committed by GitHub
parent 5c1a8029bf
commit 174d54396f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 336 additions and 71 deletions

View File

@ -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,
});
};

View File

@ -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(

View File

@ -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%;
}

View File

@ -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) => {
const icon = domainIcon(
this.hass,
this.domain,
this.deviceClass,
this.state
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
}
);
});
return html`${until(icon)}`;
}

View File

@ -504,14 +504,25 @@ export const serviceSectionIcon = async (
export const domainIcon = async (
hass: HomeAssistant,
domain: string,
deviceClass?: string
deviceClass?: string,
state?: string
): Promise<string | undefined> => {
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;
};

View File

@ -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);
}

View File

@ -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<AreaControl, AreaControlsButton> = {
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<AreaControl, string[]>
);
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`
<ha-control-button-group>
${supportedControls.map((control) => {
<ha-control-button-group ?no-stretch=${this.position === "inline"}>
${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`
<ha-control-button
style=${styleMap({
"--active-color": activeColor,
})}
.title=${label}
aria-label=${label}
class=${active ? "active" : ""}
.control=${control}
@click=${this._handleButtonTap}
>
<ha-svg-icon .path=${button.iconPath}></ha-svg-icon>
<ha-domain-icon
.hass=${this.hass}
.icon=${icon}
.domain=${domain}
.deviceClass=${deviceClass}
.state=${groupState}
></ha-domain-icon>
</ha-control-button>
`;
})}
@ -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);

View File

@ -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];

View File

@ -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}
></hui-card-feature>
`
)}

View File

@ -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

View File

@ -514,6 +514,7 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
.context=${this._featureContext}
.color=${this._config.color}
.features=${features}
.position=${featurePosition}
></hui-card-features>
`
: nothing}

View File

@ -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 {

View File

@ -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`
<ha-alert alert-type="warning">
${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) {

View File

@ -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,
};
});

View File

@ -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

View File

@ -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"
}