mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-15 05:16:34 +00:00
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:
parent
d58186fec9
commit
f87e20cae9
61
src/components/ha-aspect-ratio.ts
Normal file
61
src/components/ha-aspect-ratio.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -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 {
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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<LovelaceCardFeatureConfig["type"]>([
|
||||
"alarm-modes",
|
||||
"area-controls",
|
||||
"climate-fan-modes",
|
||||
"climate-swing-modes",
|
||||
"climate-swing-horizontal-modes",
|
||||
|
@ -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`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${data}
|
||||
.schema=${schema}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
.computeHelper=${this._computeHelperCallback}
|
||||
@value-changed=${this._valueChanged}
|
||||
></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 {
|
||||
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<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 = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
schema:
|
||||
| SchemaUnion<ReturnType<typeof this._schema>>
|
||||
| SchemaUnion<ReturnType<typeof this._featuresSchema>>
|
||||
) => {
|
||||
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 {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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<UiFeatureTypes>([
|
||||
"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":
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
Loading…
x
Reference in New Issue
Block a user