Redesign area card (#25802)

* Use entity filter to get device classes in editor

* Add name and sensor states to area card

* Fix area type

* Add basic controls

* Fix editor

* Add image

* Add image type

* Add translation key for area controls

* Improve editor

* Fix unknown entity id in area

* Fix default feature position

* Add alert badge

* Add helper

* Display all alerts when using big card

* Disable covers and re-enable switches

* Filter compatible controls

* Use state icon for alerts

* Rename to display type

* Delete deprecated show camera

* Fix aspect ratio

* Improve helper

* Undo domain icon changes

* Undo domain icon changes

* Update types

* Fix translation cases

* Fix card size

* Feedback

* Don't fallback to compact

* Use plural form

* Refactor active color
This commit is contained in:
Paul Bottein 2025-06-20 15:33:26 +02:00 committed by GitHub
parent d58186fec9
commit f87e20cae9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 1449 additions and 682 deletions

View File

@ -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`<slot></slot>`;
}
return html`
<div
class="ratio"
style=${styleMap({
paddingBottom: `${((100 * this._ratio!.h) / this._ratio!.w).toFixed(2)}%`,
})}
>
<slot></slot>
</div>
`;
}
static styles = css`
.ratio ::slotted(*) {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-aspect-ratio": HaAspectRatio;
}
}

View File

@ -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<AreaControl, AreaControlsButton> = {
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<AreaControl, string[]> =>
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<AreaControl, string[]>
);
@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<LovelaceCardFeatureEditor> {
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`
<ha-control-button-group>
${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`
<ha-control-button
class=${active ? "active" : ""}
style=${styleMap({ "--active-color": button.activeColor })}
.control=${control}
@click=${this._handleButtonTap}
>
<ha-svg-icon .path=${button.iconPath}></ha-svg-icon>
</ha-control-button>
`;
})}
</ha-control-button-group>
`;
}
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;
}
}

View File

@ -158,6 +158,15 @@ export interface UpdateActionsCardFeatureConfig {
backup?: "yes" | "no" | "ask"; 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 = export type LovelaceCardFeatureConfig =
| AlarmModesCardFeatureConfig | AlarmModesCardFeatureConfig
| ClimateFanModesCardFeatureConfig | ClimateFanModesCardFeatureConfig
@ -187,8 +196,10 @@ export type LovelaceCardFeatureConfig =
| ToggleCardFeatureConfig | ToggleCardFeatureConfig
| UpdateActionsCardFeatureConfig | UpdateActionsCardFeatureConfig
| VacuumCommandsCardFeatureConfig | VacuumCommandsCardFeatureConfig
| WaterHeaterOperationModesCardFeatureConfig; | WaterHeaterOperationModesCardFeatureConfig
| AreaControlsCardFeatureConfig;
export interface LovelaceCardFeatureContext { export interface LovelaceCardFeatureContext {
entity_id?: string; entity_id?: string;
area_id?: string;
} }

File diff suppressed because it is too large Load Diff

View File

@ -101,11 +101,18 @@ export interface EntitiesCardConfig extends LovelaceCardConfig {
} }
export interface AreaCardConfig extends LovelaceCardConfig { export interface AreaCardConfig extends LovelaceCardConfig {
area: string; area?: string;
name?: string;
navigation_path?: string; navigation_path?: string;
display_type?: "compact" | "icon" | "picture" | "camera";
/** @deprecated Use `display_type` instead */
show_camera?: boolean; show_camera?: boolean;
camera_view?: HuiImage["cameraView"]; camera_view?: HuiImage["cameraView"];
aspect_ratio?: string; aspect_ratio?: string;
sensor_classes?: string[];
alert_classes?: string[];
features?: LovelaceCardFeatureConfig[];
features_position?: "bottom" | "inline";
} }
export interface ButtonCardConfig extends LovelaceCardConfig { export interface ButtonCardConfig extends LovelaceCardConfig {

View File

@ -54,7 +54,10 @@ export class HuiImage extends LitElement {
@property({ attribute: false }) public darkModeFilter?: string; @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; @state() private _imageVisible? = false;

View File

@ -1,9 +1,9 @@
import "../card-features/hui-alarm-modes-card-feature"; import "../card-features/hui-alarm-modes-card-feature";
import "../card-features/hui-climate-fan-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-hvac-modes-card-feature";
import "../card-features/hui-climate-preset-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-counter-actions-card-feature";
import "../card-features/hui-cover-open-close-card-feature"; import "../card-features/hui-cover-open-close-card-feature";
import "../card-features/hui-cover-position-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-media-player-volume-slider-card-feature";
import "../card-features/hui-numeric-input-card-feature"; import "../card-features/hui-numeric-input-card-feature";
import "../card-features/hui-select-options-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-humidity-card-feature";
import "../card-features/hui-target-temperature-card-feature";
import "../card-features/hui-toggle-card-feature"; import "../card-features/hui-toggle-card-feature";
import "../card-features/hui-update-actions-card-feature"; import "../card-features/hui-update-actions-card-feature";
import "../card-features/hui-vacuum-commands-card-feature"; import "../card-features/hui-vacuum-commands-card-feature";
import "../card-features/hui-water-heater-operation-modes-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 type { LovelaceCardFeatureConfig } from "../card-features/types";
import { import {
@ -36,6 +37,7 @@ import {
const TYPES = new Set<LovelaceCardFeatureConfig["type"]>([ const TYPES = new Set<LovelaceCardFeatureConfig["type"]>([
"alarm-modes", "alarm-modes",
"area-controls",
"climate-fan-modes", "climate-fan-modes",
"climate-swing-modes", "climate-swing-modes",
"climate-swing-horizontal-modes", "climate-swing-horizontal-modes",

View File

@ -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 { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { import {
assert, any,
array, array,
assert,
assign, assign,
boolean, boolean,
enums,
object, object,
optional, optional,
string, string,
} from "superstruct"; } from "superstruct";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-form/ha-form";
import { import {
DEFAULT_ASPECT_RATIO, fireEvent,
DEVICE_CLASSES, type HASSDomEvent,
} from "../../cards/hui-area-card"; } from "../../../../common/dom/fire_event";
import type { SchemaUnion } from "../../../../components/ha-form/types"; 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 { 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 { AreaCardConfig } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types"; import type { LovelaceCardEditor } from "../../types";
import { baseLovelaceCardConfig } from "../structs/base-card-struct"; import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import { computeDomain } from "../../../../common/entity/compute_domain"; import type { EditDetailElementEvent, EditSubElementEvent } from "../types";
import { caseInsensitiveStringCompare } from "../../../../common/string/compare"; import { configElementStyle } from "./config-elements-style";
import type { SelectOption } from "../../../../data/selector"; import { getSupportedFeaturesType } from "./hui-card-features-editor";
import { getSensorNumericDeviceClasses } from "../../../../data/sensor";
import type { LocalizeFunc } from "../../../../common/translations/localize";
const cardConfigStruct = assign( const cardConfigStruct = assign(
baseLovelaceCardConfig, baseLovelaceCardConfig,
object({ object({
area: optional(string()), area: optional(string()),
name: optional(string()),
navigation_path: optional(string()), navigation_path: optional(string()),
theme: optional(string()),
show_camera: optional(boolean()), show_camera: optional(boolean()),
display_type: optional(enums(["compact", "icon", "picture", "camera"])),
camera_view: optional(string()), camera_view: optional(string()),
aspect_ratio: optional(string()),
alert_classes: optional(array(string())), alert_classes: optional(array(string())),
sensor_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[]; @state() private _numericDeviceClasses?: string[];
private _featureContext = memoizeOne(
(areaId?: string): LovelaceCardFeatureContext => ({
area_id: areaId,
})
);
private _schema = memoizeOne( private _schema = memoizeOne(
( (
localize: LocalizeFunc, localize: LocalizeFunc,
@ -61,103 +83,143 @@ export class HuiAreaCardEditor
) => ) =>
[ [
{ name: "area", selector: { area: {} } }, { name: "area", selector: { area: {} } },
{ name: "show_camera", required: false, selector: { boolean: {} } }, {
...(showCamera name: "content",
? ([ flatten: true,
{ type: "expandable",
name: "camera_view", iconPath: mdiTextShort,
selector: { schema: [
select: { {
options: ["auto", "live"].map((value) => ({ name: "",
value, type: "grid",
label: localize( schema: [
`ui.panel.lovelace.editor.card.generic.camera_view_options.${value}` { 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: "", name: "interactions",
type: "grid", type: "expandable",
flatten: true,
iconPath: mdiGestureTap,
schema: [ schema: [
{ {
name: "navigation_path", name: "navigation_path",
required: false, required: false,
selector: { navigation: {} }, selector: { navigation: {} },
}, },
{ name: "theme", required: false, selector: { theme: {} } },
{
name: "aspect_ratio",
default: DEFAULT_ASPECT_RATIO,
selector: { text: {} },
},
], ],
}, },
{ ] as const satisfies readonly HaFormSchema[]
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
); );
private _binaryClassesForArea = memoizeOne((area: string): string[] => private _binaryClassesForArea = memoizeOne(
this._classesForArea(area, "binary_sensor") (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( private _sensorClassesForArea = memoizeOne(
(area: string, numericDeviceClasses?: string[]): string[] => (area: string | undefined, numericDeviceClasses?: string[]): string[] => {
this._classesForArea(area, "sensor", numericDeviceClasses) 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( private _buildBinaryOptions = memoizeOne(
(possibleClasses: string[], currentClasses: string[]): SelectOption[] => (possibleClasses: string[], currentClasses: string[]): SelectOption[] =>
this._buildOptions("binary_sensor", possibleClasses, currentClasses) this._buildOptions("binary_sensor", possibleClasses, currentClasses)
@ -191,7 +253,14 @@ export class HuiAreaCardEditor
public setConfig(config: AreaCardConfig): void { public setConfig(config: AreaCardConfig): void {
assert(config, cardConfigStruct); 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() { 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() { protected render() {
if (!this.hass || !this._config) { if (!this.hass || !this._config) {
return nothing; return nothing;
} }
const possibleBinaryClasses = this._binaryClassesForArea( const areaId = this._config!.area;
this._config.area || ""
); const possibleBinaryClasses = this._binaryClassesForArea(this._config.area);
const possibleSensorClasses = this._sensorClassesForArea( const possibleSensorClasses = this._sensorClassesForArea(
this._config.area || "", this._config.area,
this._numericDeviceClasses this._numericDeviceClasses
); );
const binarySelectOptions = this._buildBinaryOptions( const binarySelectOptions = this._buildBinaryOptions(
@ -223,68 +326,196 @@ export class HuiAreaCardEditor
this._config.sensor_classes || DEVICE_CLASSES.sensor 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( const schema = this._schema(
this.hass.localize, this.hass.localize,
this._config.show_camera || false, showCamera,
binarySelectOptions, binarySelectOptions,
sensorSelectOptions sensorSelectOptions
); );
const featuresSchema = this._featuresSchema(this.hass.localize);
const data = { const data = {
camera_view: "auto", camera_view: "auto",
alert_classes: DEVICE_CLASSES.binary_sensor, alert_classes: DEVICE_CLASSES.binary_sensor,
sensor_classes: DEVICE_CLASSES.sensor, sensor_classes: DEVICE_CLASSES.sensor,
features_position: "bottom",
display_type: displayType,
...this._config, ...this._config,
}; };
const featureContext = this._featureContext(areaId);
const hasCompatibleFeatures = this._hasCompatibleFeatures(featureContext);
return html` return html`
<ha-form <ha-form
.hass=${this.hass} .hass=${this.hass}
.data=${data} .data=${data}
.schema=${schema} .schema=${schema}
.computeLabel=${this._computeLabelCallback} .computeLabel=${this._computeLabelCallback}
.computeHelper=${this._computeHelperCallback}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
></ha-form> ></ha-form>
<ha-expansion-panel outlined>
<ha-svg-icon slot="leading-icon" .path=${mdiListBox}></ha-svg-icon>
<h3 slot="header">
${this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.features"
)}
</h3>
<div class="content">
${hasCompatibleFeatures
? html`
<ha-form
class="features-form"
.hass=${this.hass}
.data=${data}
.schema=${featuresSchema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
`
: nothing}
<hui-card-features-editor
.hass=${this.hass}
.context=${featureContext}
.features=${this._config!.features ?? []}
@features-changed=${this._featuresChanged}
@edit-detail-element=${this._editDetailElement}
></hui-card-features-editor>
</div>
</ha-expansion-panel>
`; `;
} }
private _valueChanged(ev: CustomEvent): void { private _valueChanged(ev: CustomEvent): void {
const config = ev.detail.value; const newConfig = ev.detail.value as AreaCardConfig;
if (!config.show_camera) {
const config: AreaCardConfig = {
features: this._config!.features,
...newConfig,
};
if (config.display_type !== "camera") {
delete config.camera_view; delete config.camera_view;
} }
fireEvent(this, "config-changed", { config }); 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<EditDetailElementEvent>): 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<ReturnType<typeof this._schema>>
| SchemaUnion<ReturnType<typeof this._featuresSchema>>
): 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 = ( private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>> schema:
| SchemaUnion<ReturnType<typeof this._schema>>
| SchemaUnion<ReturnType<typeof this._featuresSchema>>
) => { ) => {
switch (schema.name) { 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": case "area":
return this.hass!.localize("ui.panel.lovelace.editor.card.area.name"); 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": case "navigation_path":
return this.hass!.localize( return this.hass!.localize(
"ui.panel.lovelace.editor.action-editor.navigation_path" "ui.panel.lovelace.editor.action-editor.navigation_path"
); );
case "aspect_ratio": case "interactions":
case "features_position":
return this.hass!.localize( return this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.aspect_ratio" `ui.panel.lovelace.editor.card.tile.${schema.name}`
);
case "camera_view":
return this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.camera_view"
); );
} }
return this.hass!.localize( return this.hass!.localize(
`ui.panel.lovelace.editor.card.area.${schema.name}` `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 { declare global {

View File

@ -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`
<ha-alert alert-type="warning">
${this.hass.localize(
"ui.panel.lovelace.editor.features.types.area-controls.no_compatible_controls"
)}
</ha-alert>
`;
}
const data: AreaControlsCardFeatureData = {
...this._config,
customize_controls: this._config.controls !== undefined,
};
const schema = this._schema(
this.hass.localize,
data.customize_controls,
compatibleControls
);
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
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<ReturnType<typeof this._schema>>
) => {
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;
}
}

View File

@ -18,6 +18,7 @@ import {
} from "../../../../data/lovelace_custom_cards"; } from "../../../../data/lovelace_custom_cards";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import { supportsAlarmModesCardFeature } from "../../card-features/hui-alarm-modes-card-feature"; 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 { supportsClimateFanModesCardFeature } from "../../card-features/hui-climate-fan-modes-card-feature";
import { supportsClimateHvacModesCardFeature } from "../../card-features/hui-climate-hvac-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"; import { supportsClimatePresetModesCardFeature } from "../../card-features/hui-climate-preset-modes-card-feature";
@ -61,6 +62,7 @@ type SupportsFeature = (
const UI_FEATURE_TYPES = [ const UI_FEATURE_TYPES = [
"alarm-modes", "alarm-modes",
"area-controls",
"climate-fan-modes", "climate-fan-modes",
"climate-hvac-modes", "climate-hvac-modes",
"climate-preset-modes", "climate-preset-modes",
@ -95,6 +97,7 @@ type UiFeatureTypes = (typeof UI_FEATURE_TYPES)[number];
const EDITABLES_FEATURE_TYPES = new Set<UiFeatureTypes>([ const EDITABLES_FEATURE_TYPES = new Set<UiFeatureTypes>([
"alarm-modes", "alarm-modes",
"area-controls",
"climate-fan-modes", "climate-fan-modes",
"climate-hvac-modes", "climate-hvac-modes",
"climate-preset-modes", "climate-preset-modes",
@ -116,6 +119,7 @@ const SUPPORTS_FEATURE_TYPES: Record<
SupportsFeature | undefined SupportsFeature | undefined
> = { > = {
"alarm-modes": supportsAlarmModesCardFeature, "alarm-modes": supportsAlarmModesCardFeature,
"area-controls": supportsAreaControlsCardFeature,
"climate-fan-modes": supportsClimateFanModesCardFeature, "climate-fan-modes": supportsClimateFanModesCardFeature,
"climate-swing-modes": supportsClimateSwingModesCardFeature, "climate-swing-modes": supportsClimateSwingModesCardFeature,
"climate-swing-horizontal-modes": "climate-swing-horizontal-modes":

View File

@ -7172,10 +7172,17 @@
}, },
"area": { "area": {
"name": "Area", "name": "Area",
"alert_classes": "Alert Classes", "alert_classes": "Alert classes",
"sensor_classes": "Sensor 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.", "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": { "calendar": {
"name": "Calendar", "name": "Calendar",
@ -7832,6 +7839,17 @@
"ask": "Ask" "ask": "Ask"
}, },
"backup_not_supported": "Backup is not supported." "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"
} }
} }
}, },