Compare commits

..
Author SHA1 Message Date
Aidan Timson 8895c7f567 Show only the state on the alert badge 2026-10-19 09:22:47 +02:00
Aidan Timson 7c70626dbd Keep the alert card and badge focused on alert options 2026-10-19 09:22:47 +02:00
Aidan Timson 18a986dff6 Drop hass from the alert card and badge 2026-10-19 09:22:47 +02:00
Aidan Timson e3aaa3975e Match the alert card and badge content options to the tile card and entity badge 2026-10-19 09:22:47 +02:00
Aidan Timson c198007563 Use contexts for alert card and badge actions and pictures 2026-10-19 09:22:47 +02:00
Aidan Timson 667f6c1040 Add tile card options to the alert card and badge 2026-10-19 09:22:47 +02:00
Aidan Timson 169ef855ca Share tile action and picture helpers with the entity badge 2026-10-19 09:22:47 +02:00
Aidan Timson c2955a8745 Add alert badge (#54881)
* Add alert badge

* Use shared alert domains in the alert badge

* Only alert while the alert badge entity is in an alert state

* Default the alert badge color to red and show its colors side by side

* Use consumeLocalize in the alert badge

* Rename the alert badge colors to alert color and clear color
2026-10-19 09:22:42 +02:00
28 changed files with 1146 additions and 770 deletions

No files matched your search

+9 -54
View File
@@ -1,5 +1,9 @@
import { mdiHelpCircleOutline } from "@mdi/js";
import type { HassService, HassServices } from "home-assistant-js-websocket";
import type {
HassService,
HassServices,
HassServiceTarget,
} from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -26,11 +30,9 @@ import {
expandDeviceTarget,
expandFloorTarget,
expandLabelTarget,
resolveEntityIDs,
type Selector,
type TargetSelector,
} from "../data/selector";
import type { HassServiceTargetWithExclude } from "../data/target";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import { documentationUrl } from "../util/documentation-url";
import { getSelectorFallbackValue } from "./ha-form/get-selector-fallback-value";
@@ -103,7 +105,7 @@ export class HaServiceControl extends LitElement {
@property({ attribute: false }) public value?: {
action: string;
target?: HassServiceTargetWithExclude;
target?: HassServiceTarget;
data?: Record<string, any>;
};
@@ -341,7 +343,7 @@ export class HaServiceControl extends LitElement {
);
targetDevices.push(...expanded.devices);
const primaryEntities = expanded.entities.filter(isPrimaryEntity);
targetEntities.push(...primaryEntities);
targetEntities.push(primaryEntities);
targetAreas.push(...expanded.areas);
});
}
@@ -382,19 +384,6 @@ export class HaServiceControl extends LitElement {
targetEntities.push(...primaryEntities);
});
}
const exclude = value?.target?.exclude;
if (exclude) {
const excluded = new Set(
resolveEntityIDs(
this.hass,
exclude,
this.hass.entities,
this.hass.devices,
this.hass.areas
)
);
return targetEntities.filter((entityId) => !excluded.has(entityId));
}
return targetEntities;
});
@@ -449,9 +438,6 @@ export class HaServiceControl extends LitElement {
} else {
this._stickySelector.target = { object: null };
}
} else if (typeof value === "object" && "exclude" in value) {
// The target picker can't show an exclude yet, so edit it as YAML.
this._stickySelector.target = { object: null };
}
return (
this._stickySelector.target ??
@@ -857,7 +843,7 @@ export class HaServiceControl extends LitElement {
}
const newService = ev.detail.value || "";
let target: HassServiceTargetWithExclude | undefined;
let target: HassServiceTarget | undefined;
if (newService) {
const serviceData = this._getServiceInfo(newService, this.hass.services);
@@ -908,42 +894,11 @@ export class HaServiceControl extends LitElement {
)
);
}
const targetFloors = ensureArray(
currentTarget.floor_id || this._value!.data?.floor_id
)?.filter(
(floor) =>
expandFloorTarget(this.hass, floor, this.hass.areas, targetSelector)
.areas.length > 0
);
const targetLabels = ensureArray(
currentTarget.label_id || this._value!.data?.label_id
)?.filter((label) => {
const expanded = expandLabelTarget(
this.hass,
label,
this.hass.areas,
this.hass.devices,
this.hass.entities,
targetSelector
);
return (
expanded.areas.length > 0 ||
expanded.devices.length > 0 ||
expanded.entities.length > 0
);
});
target = {
...(targetEntities.length ? { entity_id: targetEntities } : {}),
...(targetDevices.length ? { device_id: targetDevices } : {}),
...(targetAreas.length ? { area_id: targetAreas } : {}),
...(targetFloors?.length ? { floor_id: targetFloors } : {}),
...(targetLabels?.length ? { label_id: targetLabels } : {}),
};
// An exclude on its own is not a target, so it only stays while
// something is still included.
if (currentTarget.exclude && Object.keys(target).length) {
target.exclude = currentTarget.exclude;
}
}
}
@@ -980,7 +935,7 @@ export class HaServiceControl extends LitElement {
private _targetChanged(
ev: HASSDomEvent<{
value: HassServiceTargetWithExclude | undefined;
value: HassServiceTarget | undefined;
isValid?: boolean;
}>
) {
+5 -14
View File
@@ -496,9 +496,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
currentTarget.id
);
return this._withoutOrphanExclude(
this._addTargetToValue(valueWithoutCurrent, newTarget)
);
return this._addTargetToValue(valueWithoutCurrent, newTarget);
}
private _addTargetToValue(
@@ -543,7 +541,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
private _handleRemove(ev: HASSDomEvent<HASSDomEvents["remove-target-item"]>) {
const { type, id } = ev.detail;
fireEvent(this, "value-changed", {
value: this._withoutOrphanExclude(this._removeItem(this.value, type, id)),
value: this._removeItem(this.value, type, id),
});
}
@@ -604,13 +602,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
const val = { ...value }!;
delete val[typeId];
return val;
}
private _withoutOrphanExclude(value: this["value"]): this["value"] {
// An exclude on its own is not a target.
if (value && Object.keys(value).some((key) => key !== "exclude")) {
return value;
if (Object.keys(val).length) {
return val;
}
return undefined;
}
@@ -964,9 +957,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
id: replacement,
});
}
fireEvent(this, "value-changed", {
value: this._withoutOrphanExclude(value),
});
fireEvent(this, "value-changed", { value });
}
private _renderRow = (
+3 -3
View File
@@ -2,6 +2,7 @@ import type {
Connection,
HassEntityAttributeBase,
HassEntityBase,
HassServiceTarget,
} from "home-assistant-js-websocket";
import { ensureArray } from "../common/array/ensure-array";
import type { WeekdayShort } from "../common/datetime/weekday";
@@ -17,7 +18,6 @@ import type {
DeviceTrigger,
} from "./device/device_automation";
import type { Action, Field, MODES, Option } from "./script";
import type { HassServiceTargetWithExclude } from "./target";
import { migrateAutomationAction } from "./script";
import type { TriggerDescription } from "./trigger";
@@ -106,7 +106,7 @@ export interface BaseTrigger {
export interface PlatformTrigger extends BaseTrigger {
trigger: Exclude<string, LegacyTrigger["trigger"]>;
target?: HassServiceTargetWithExclude;
target?: HassServiceTarget;
}
export interface StateTrigger extends BaseTrigger {
@@ -251,7 +251,7 @@ interface BaseCondition {
export interface PlatformCondition extends BaseCondition {
condition: Exclude<string, LegacyCondition["condition"]>;
target?: HassServiceTargetWithExclude;
target?: HassServiceTarget;
}
export interface LogicalCondition extends BaseCondition {
+4 -13
View File
@@ -2,6 +2,7 @@ import type {
HassEntityAttributeBase,
HassEntityBase,
HassServices,
HassServiceTarget,
} from "home-assistant-js-websocket";
import type { Describe } from "superstruct";
import {
@@ -30,7 +31,6 @@ import type {
} from "./automation";
import { migrateAutomationTrigger } from "./automation";
import type { BlueprintInput } from "./blueprint";
import type { HassServiceTargetWithExclude } from "./target";
export const MODES = ["single", "restart", "queued", "parallel"] as const;
export const MODES_MAX = ["queued", "parallel"] as const;
@@ -43,21 +43,12 @@ export const baseActionStruct = object({
enabled: optional(boolean()),
});
const targetIncludeFields = {
export const targetStruct = object({
entity_id: optional(union([string(), array(string())])),
device_id: optional(union([string(), array(string())])),
area_id: optional(union([string(), array(string())])),
floor_id: optional(union([string(), array(string())])),
label_id: optional(union([string(), array(string())])),
};
// A target without an exclude.
export const targetIncludeStruct = object(targetIncludeFields);
export const targetStruct = object({
...targetIncludeFields,
// Same shape as the target, without nesting another exclude.
exclude: optional(targetIncludeStruct),
});
export const serviceActionStruct: Describe<ServiceActionWithTemplate> = assign(
@@ -135,14 +126,14 @@ export interface ServiceAction extends BaseAction {
action?: string;
service_template?: string;
entity_id?: string;
target?: HassServiceTargetWithExclude;
target?: HassServiceTarget;
data?: Record<string, unknown>;
response_variable?: string;
metadata?: Record<string, unknown>;
}
type ServiceActionWithTemplate = ServiceAction & {
target?: HassServiceTargetWithExclude | string;
target?: HassServiceTarget | string;
};
export type { ServiceActionWithTemplate };
+1 -39
View File
@@ -23,7 +23,6 @@ import type {
EntityRegistryEntry,
} from "./entity/entity_registry";
import type { EntitySources } from "./entity/entity_sources";
import type { HassServiceTargetWithExclude } from "./target";
export type ThresholdMode = "crossed" | "changed" | "is";
@@ -1171,7 +1170,7 @@ export const computeCreateDomains = (
export const resolveEntityIDs = (
hass: HomeAssistant,
targetPickerValue: HassServiceTargetWithExclude,
targetPickerValue: HassServiceTarget,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
@@ -1181,43 +1180,6 @@ export const resolveEntityIDs = (
return [];
}
const included = resolveIncludedEntityIDs(
hass,
targetPickerValue,
entities,
devices,
areas,
targetSelector
);
if (!targetPickerValue.exclude) {
return included;
}
// Excludes are resolved like a target and taken out of the whole result,
// matching core. They are not narrowed by the selector: leaving out more
// than the selector allows can't add anything back.
const excluded = new Set(
resolveIncludedEntityIDs(
hass,
targetPickerValue.exclude,
entities,
devices,
areas,
{ target: {} }
)
);
return included.filter((entityId) => !excluded.has(entityId));
};
const resolveIncludedEntityIDs = (
hass: HomeAssistant,
targetPickerValue: HassServiceTarget,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
targetSelector: TargetSelector
): string[] => {
const targetEntities = new Set(ensureArray(targetPickerValue.entity_id));
const targetDevices = new Set(ensureArray(targetPickerValue.device_id));
const targetAreas = new Set(ensureArray(targetPickerValue.area_id));
-8
View File
@@ -26,14 +26,6 @@ export interface TargetItem {
id: string;
}
/**
* A target that can leave things out again. `exclude` has the shape of a
* target and is subtracted, as a whole, from everything the target resolves to.
*/
export type HassServiceTargetWithExclude = HassServiceTarget & {
exclude?: HassServiceTarget;
};
export interface SingleHassServiceTarget {
entity_id?: string;
device_id?: string;
@@ -9,6 +9,7 @@ import {
mdiShape,
mdiSwapHorizontal,
} from "@mdi/js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import {
css,
html,
@@ -24,10 +25,7 @@ import { consume } from "../../../../common/decorators/consume";
import { ensureArray } from "../../../../common/array/ensure-array";
import { transform } from "../../../../common/decorators/transform";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import {
hasTemplate,
isTemplate,
} from "../../../../common/string/has-template";
import { isTemplate } from "../../../../common/string/has-template";
import "../../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
@@ -53,11 +51,7 @@ import {
entityMeetsTargetSelector,
type TargetSelector,
} from "../../../../data/selector";
import {
extractFromTarget,
type HassServiceTargetWithExclude,
type TargetType,
} from "../../../../data/target";
import { extractFromTarget, type TargetType } from "../../../../data/target";
import { showMoreInfoDialog } from "../../../../dialogs/more-info/show-ha-more-info-dialog";
import { isConfigObject } from "../ui-support";
import { getTargetIcon } from "./get_target_icon";
@@ -66,7 +60,7 @@ import { getTargetText } from "./get_target_text";
@customElement("ha-automation-row-targets")
export class HaAutomationRowTargets extends LitElement {
@property({ attribute: false })
public target?: HassServiceTargetWithExclude;
public target?: HassServiceTarget;
@property({ attribute: false })
public targetRequired = false;
@@ -119,12 +113,9 @@ export class HaAutomationRowTargets extends LitElement {
private _countCache = new Map<
string,
Promise<TargetCount | undefined> | TargetCount | undefined
Promise<number | undefined> | number | undefined
>();
// What the target's `exclude` resolves to, shared by every count.
private _excludedEntities?: Promise<Set<string>>;
private _rerenderCount = true;
protected willUpdate(changedProps: PropertyValues) {
@@ -135,7 +126,6 @@ export class HaAutomationRowTargets extends LitElement {
changedProps.has("_registries")
) {
this._rerenderCount = true;
this._excludedEntities = undefined;
}
if (
@@ -222,47 +212,6 @@ export class HaAutomationRowTargets extends LitElement {
}).length;
}
// A templated exclude can only be resolved when the automation runs.
private _getExclude() {
const exclude = isConfigObject(this.target)
? this.target.exclude
: undefined;
return isConfigObject(exclude) && !hasTemplate(exclude)
? exclude
: undefined;
}
private _getExcludedEntities(): Promise<Set<string>> {
const exclude = this._getExclude();
if (!exclude) {
return Promise.resolve(new Set());
}
if (!this._excludedEntities) {
this._excludedEntities = extractFromTarget(
this._api.callWS,
exclude,
false,
this.selector?.target?.primary_entities_only
)
.then((result) => new Set(result.referenced_entities))
// Counts then leave the exclude out rather than failing altogether.
.catch(() => new Set<string>());
}
return this._excludedEntities;
}
private _formatCount(count: TargetCount | undefined) {
if (!count) {
return " (-)";
}
return count.excluded
? ` ${this._i18n.localize(
"ui.panel.config.automation.editor.target_summary.count_excluded",
{ count: count.count, excluded: count.excluded }
)}`
: ` (${count.count})`;
}
private _renderCount(
targetType: "floor" | "area" | "device" | "label",
targetId: string
@@ -270,36 +219,22 @@ export class HaAutomationRowTargets extends LitElement {
const key = `${targetType}:${targetId}`;
let fallback = " (-)";
if (!this._countCache.has(key) || this._rerenderCount) {
const cached = this._countCache.get(key);
if (cached && !(cached instanceof Promise)) {
fallback = this._formatCount(cached);
if (typeof this._countCache.get(key) === "number") {
fallback = ` (${this._countCache.get(key)})`;
}
this._countCache.set(
key,
Promise.all([
extractFromTarget(
this._api.callWS,
{
[`${targetType}_id`]: [targetId],
},
false,
this.selector?.target?.primary_entities_only
),
this._getExcludedEntities(),
])
.then(([result, excluded]): TargetCount => {
// Excludes are global, so anything they resolve to is taken out
// of this target too.
const remaining = result.referenced_entities.filter(
(entityId) => !excluded.has(entityId)
);
const count = this._countMatchingEntities(remaining);
return {
count,
excluded:
this._countMatchingEntities(result.referenced_entities) - count,
};
})
extractFromTarget(
this._api.callWS,
{
[`${targetType}_id`]: [targetId],
},
false,
this.selector?.target?.primary_entities_only
)
.then((result) =>
this._countMatchingEntities(result.referenced_entities)
)
.catch((err) => {
// eslint-disable-next-line no-console
console.error("Error counting target entities", err);
@@ -308,21 +243,20 @@ export class HaAutomationRowTargets extends LitElement {
);
}
const cached = this._countCache.get(key);
if (cached instanceof Promise) {
if (this._countCache.get(key) instanceof Promise) {
return until(
cached.then((count) => {
if (this._countCache.get(key) === cached) {
(this._countCache.get(key) as Promise<number | undefined>)!.then(
(count) => {
this._countCache.set(key, count);
return count === undefined ? nothing : html` (${count})`;
}
return count === undefined ? nothing : this._formatCount(count);
}),
),
fallback
);
}
if (cached) {
return this._formatCount(cached);
if (typeof this._countCache.get(key) === "number") {
return ` (${this._countCache.get(key)})`;
}
return nothing;
}
@@ -337,12 +271,8 @@ export class HaAutomationRowTargets extends LitElement {
// Any other value that isn't a mapping is invalid. Without this, the
// characters of a string or the items of a list would count as targets.
// `exclude` takes entities away from the other keys; it is not a target
// of its own, so it gets no chip. Its effect shows in each count.
const { exclude: _exclude, ...target } = isConfigObject(this.target)
? this.target
: {};
const length = Object.keys(target).length;
const target = isConfigObject(this.target) ? this.target : undefined;
const length = Object.keys(target || {}).length;
if (!length) {
return this._renderTargetBadge(
this.targetRequired
@@ -355,13 +285,13 @@ export class HaAutomationRowTargets extends LitElement {
this.targetRequired
);
}
const totalLength = Object.values(target).reduce(
const totalLength = Object.values(target || {}).reduce(
(acc, val) => acc + ensureArray(val).length,
0
);
if (totalLength <= 5) {
const targets = Object.entries(target).reduce<
const targets = Object.entries(target!).reduce<
["floor" | "area" | "device" | "entity" | "label", string][]
>((acc, [targetType, targetId]) => {
const type = targetType.replace("_id", "") as
@@ -383,7 +313,7 @@ export class HaAutomationRowTargets extends LitElement {
);
}
const rows = Object.entries(target)
const rows = Object.entries(target!)
.reduce<["floor" | "area" | "device" | "entity" | "label", string][]>(
(acc, [targetType, targetId]) => {
const type = targetType.replace("_id", "") as
@@ -797,11 +727,6 @@ export class HaAutomationRowTargets extends LitElement {
`;
}
interface TargetCount {
count: number;
excluded: number;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-row-targets": HaAutomationRowTargets;
@@ -0,0 +1,15 @@
import { ALERT_DOMAINS } from "../../../common/entity/alert_active";
import { computeDomain } from "../../../common/entity/compute_domain";
import type { AlertBadgeConfig } from "../badges/types";
import type { BadgeSuggestionProvider } from "./types";
export const alertBadgeSuggestions: BadgeSuggestionProvider<AlertBadgeConfig> =
{
getEntitySuggestion(_hass, entityId) {
if (!ALERT_DOMAINS.includes(computeDomain(entityId))) {
return [];
}
return [{ config: { type: "alert", entity: entityId } }];
},
};
@@ -1,3 +1,4 @@
import { alertBadgeSuggestions } from "./hui-alert-badge-suggestions";
import { entityBadgeSuggestions } from "./hui-entity-badge-suggestions";
import type { BadgeSuggestionProvider } from "./types";
@@ -6,4 +7,5 @@ export const BADGE_SUGGESTION_PROVIDERS: Record<
BadgeSuggestionProvider
> = {
entity: entityBadgeSuggestions,
alert: alertBadgeSuggestions,
};
@@ -0,0 +1,239 @@
import type { ContextType } from "@lit/context";
import { mdiAlertCircle } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { computeCssColor } from "../../../common/color/compute-color";
import { consume } from "../../../common/decorators/consume";
import {
consumeEntityState,
consumeLocalize,
} from "../../../common/decorators/consume-context-entry";
import { fireEvent } from "../../../common/dom/fire_event";
import {
ALERT_DOMAINS,
DEFAULT_ALERT_COLOR,
isAlertActive,
} from "../../../common/entity/alert_active";
import { computeDomain } from "../../../common/entity/compute_domain";
import { isValidEntityId } from "../../../common/entity/valid_entity_id";
import "../../../components/ha-badge";
import "../../../components/ha-state-icon";
import "../../../components/ha-svg-icon";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { formattersContext } from "../../../data/context";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import type { HomeAssistant } from "../../../types";
import { hasTileCardAction } from "../cards/tile/tile-card-helpers";
import { actionHandler } from "../common/directives/action-handler-directive";
import { findEntities } from "../common/find-entities";
import { hasAction } from "../common/has-action";
import type { LovelaceBadge, LovelaceBadgeEditor } from "../types";
import type { AlertBadgeConfig } from "./types";
export const ALERT_BADGE_DEFAULT_CONFIG: Partial<AlertBadgeConfig> = {
type: "alert",
show_name: true,
show_state: true,
show_icon: true,
};
@customElement("hui-alert-badge")
export class HuiAlertBadge extends LitElement implements LovelaceBadge {
public static async getConfigElement(): Promise<LovelaceBadgeEditor> {
await import("../editor/config-elements/hui-alert-badge-editor");
return document.createElement("hui-alert-badge-editor");
}
public static getStubConfig(
hass: HomeAssistant,
entities: string[],
entitiesFallback: string[]
): AlertBadgeConfig {
const [entity] = findEntities(
hass,
1,
entities,
entitiesFallback,
ALERT_DOMAINS
);
return { type: "alert", entity: entity || "" };
}
public static getDefaultConfig(): Partial<AlertBadgeConfig> {
return ALERT_BADGE_DEFAULT_CONFIG;
}
@state() private _config?: AlertBadgeConfig;
@state()
@consumeEntityState({ entityIdPath: ["_config", "entity"] })
private _stateObj?: HassEntity;
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters!: ContextType<typeof formattersContext>;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
public setConfig(config: AlertBadgeConfig): void {
if (
!isValidEntityId(config.entity) ||
(config.alert_color !== undefined &&
typeof config.alert_color !== "string") ||
(config.clear_color !== undefined &&
typeof config.clear_color !== "string") ||
(config.pulse !== undefined && typeof config.pulse !== "boolean")
) {
throw new Error("Invalid configuration");
}
if (!ALERT_DOMAINS.includes(computeDomain(config.entity))) {
throw new Error(
`Specify an entity from within the ${ALERT_DOMAINS.join(", ")} domains`
);
}
this._config = { ...ALERT_BADGE_DEFAULT_CONFIG, ...config };
}
protected render() {
if (!this._config || !this._formatters || !this._localize) {
return nothing;
}
const stateObj = this._stateObj;
if (!stateObj) {
return html`
<ha-badge .label=${this._config.entity} class="error">
<ha-svg-icon slot="icon" .path=${mdiAlertCircle}></ha-svg-icon>
${this._localize("ui.badge.entity.not_found")}
</ha-badge>
`;
}
const active = isAlertActive(stateObj);
const color = active
? (this._config.alert_color ?? DEFAULT_ALERT_COLOR)
: this._config.clear_color;
const pulse = active && this._config.pulse === true;
const hasColor = color !== undefined && color !== "none";
const name = this._formatters.formatEntityName(stateObj, this._config.name);
const { show_name: showName, show_state: showState } = this._config;
const content = showState
? this._formatters.formatEntityState(stateObj)
: showName
? name
: undefined;
return html`
<ha-badge
.type=${hasTileCardAction(this._config) ? "button" : "badge"}
.label=${showState && showName ? name : undefined}
.iconOnly=${!content}
class=${classMap({ alert: true, pulse, "no-color": !hasColor })}
style=${styleMap({
"--ha-alert-color":
color && hasColor ? computeCssColor(color) : undefined,
})}
@action=${this._handleAction}
.actionHandler=${actionHandler({
hasHold: hasAction(this._config.hold_action),
hasDoubleClick: hasAction(this._config.double_tap_action),
})}
>
${
this._config.show_icon
? html`<ha-state-icon
slot="icon"
.stateObj=${stateObj}
.icon=${this._config.icon}
></ha-state-icon>`
: nothing
}
${content}
</ha-badge>
`;
}
private _handleAction(ev: ActionHandlerEvent): void {
fireEvent(this, "hass-action", {
config: this._config!,
action: ev.detail.action!,
});
}
static styles = css`
@keyframes pulse-opacity {
from {
opacity: 0;
}
to {
opacity: var(--ha-alert-pulse-opacity);
}
}
:host {
--ha-alert-pulse-duration: 1s;
--ha-alert-pulse-opacity: 0.3;
--ha-alert-badge-background: var(
--ha-card-background,
var(--card-background-color, white)
);
}
ha-badge.error {
--badge-color: var(--red-color);
}
ha-badge.alert {
display: block;
position: relative;
isolation: isolate;
border-radius: var(
--ha-badge-border-radius,
calc(var(--ha-badge-size, 36px) / 2)
);
background: var(--ha-alert-badge-background);
--ha-card-background: transparent;
--badge-color: var(--ha-alert-color);
}
ha-badge.alert.no-color {
--badge-color: var(--secondary-text-color);
}
ha-badge.alert::before {
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
background-color: var(--ha-alert-color);
content: "";
opacity: var(--ha-alert-pulse-opacity);
pointer-events: none;
}
ha-badge.alert.pulse::before {
animation: pulse-opacity var(--ha-alert-pulse-duration) ease-in-out
infinite alternate;
}
@media (prefers-reduced-motion: reduce) {
ha-badge.alert.pulse::before {
animation: none;
}
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-alert-badge": HuiAlertBadge;
}
}
+6 -24
View File
@@ -8,15 +8,17 @@ import memoizeOne from "memoize-one";
import { computeCssColor } from "../../../common/color/compute-color";
import { hsv2rgb, rgb2hex, rgb2hsv } from "../../../common/color/convert-color";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { stateActive } from "../../../common/entity/state_active";
import { stateColorCss } from "../../../common/entity/state_color";
import "../../../components/ha-badge";
import "../../../components/ha-state-icon";
import "../../../components/ha-svg-icon";
import { cameraUrlWithWidthHeight } from "../../../data/camera";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import type { HomeAssistant } from "../../../types";
import {
getTileImageUrl,
hasTileCardAction,
} from "../cards/tile/tile-card-helpers";
import { actionHandler } from "../common/directives/action-handler-directive";
import { findEntities } from "../common/find-entities";
import { handleAction } from "../common/handle-action";
@@ -99,12 +101,7 @@ export class HuiEntityBadge extends LitElement implements LovelaceBadge {
}
get hasAction() {
return (
!this._config?.tap_action ||
hasAction(this._config?.tap_action) ||
hasAction(this._config?.hold_action) ||
hasAction(this._config?.double_tap_action)
);
return hasTileCardAction(this._config);
}
private _computeStateColor = memoizeOne(
@@ -138,21 +135,6 @@ export class HuiEntityBadge extends LitElement implements LovelaceBadge {
}
);
private _getImageUrl(stateObj: HassEntity): string | undefined {
const entityPicture =
stateObj.attributes.entity_picture_local ||
stateObj.attributes.entity_picture;
if (!entityPicture) return undefined;
let imageUrl = this.hass!.hassUrl(entityPicture);
if (computeStateDomain(stateObj) === "camera") {
imageUrl = cameraUrlWithWidthHeight(imageUrl, 32, 32);
}
return imageUrl;
}
protected render() {
if (!this._config || !this.hass) {
return nothing;
@@ -195,7 +177,7 @@ export class HuiEntityBadge extends LitElement implements LovelaceBadge {
const showEntityPicture = this._config.show_entity_picture;
const imageUrl = showEntityPicture
? this._getImageUrl(stateObj)
? getTileImageUrl(this.hass, stateObj, 32)
: undefined;
const label = showState && showName ? name : undefined;
+16
View File
@@ -31,6 +31,22 @@ export interface StateLabelBadgeConfig extends LovelaceBadgeConfig {
double_tap_action?: ActionConfig;
}
export interface AlertBadgeConfig extends LovelaceBadgeConfig {
type: "alert";
entity: string;
name?: string | EntityNameItem | EntityNameItem[];
icon?: string;
alert_color?: string;
clear_color?: string;
pulse?: boolean;
show_name?: boolean;
show_state?: boolean;
show_icon?: boolean;
tap_action?: ActionConfig;
hold_action?: ActionConfig;
double_tap_action?: ActionConfig;
}
export interface ShortcutBadgeConfig extends LovelaceBadgeConfig {
text?: string;
icon?: string;
+48 -25
View File
@@ -25,11 +25,13 @@ import { formattersContext } from "../../../data/context";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import type { HomeAssistant } from "../../../types";
import { findEntities } from "../common/find-entities";
import { hasAction } from "../common/has-action";
import type {
LovelaceCard,
LovelaceCardEditor,
LovelaceGridOptions,
} from "../types";
import { hasTileCardAction } from "./tile/tile-card-helpers";
import { tileCardStyle } from "./tile/tile-card-style";
import type { AlertCardConfig } from "./types";
@@ -84,19 +86,25 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
`Specify an entity from within the ${ALERT_DOMAINS.join(", ")} domains`
);
}
this._config = config;
this._config = {
tap_action: { action: "more-info" },
...config,
};
}
public getCardSize(): number {
return 1;
return this._config?.vertical ? 2 : 1;
}
public getGridOptions(): LovelaceGridOptions {
const vertical = Boolean(this._config?.vertical);
return {
columns: 6,
rows: 1,
min_columns: 6,
min_rows: 1,
rows: vertical ? 2 : 1,
min_columns: vertical ? 3 : 6,
min_rows: vertical ? 2 : 1,
};
}
@@ -110,11 +118,6 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
return nothing;
}
const stateDisplay = this._formatters.formatEntityState(stateObj);
const areaName = this._formatters.formatEntityName(stateObj, {
type: "area",
});
const active = isAlertActive(stateObj);
const color = active
@@ -124,6 +127,10 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
const pulse = active && this._config.pulse === true;
const hasColor = color !== undefined && color !== "none";
const areaName = this._formatters.formatEntityName(stateObj, {
type: "area",
});
return html`
<ha-card
class=${classMap({ pulse, "no-color": !hasColor })}
@@ -136,25 +143,40 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
})}
>
<ha-tile-container
.interactive=${true}
.actionHandlerOptions=${{ hasHold: false, hasDoubleClick: false }}
.vertical=${Boolean(this._config.vertical)}
.interactive=${hasTileCardAction(this._config)}
.actionHandlerOptions=${{
hasHold: hasAction(this._config.hold_action),
hasDoubleClick: hasAction(this._config.double_tap_action),
}}
@action=${this._handleAction}
>
<ha-tile-icon slot="icon">
<ha-state-icon slot="icon" .stateObj=${stateObj}></ha-state-icon>
<ha-state-icon
slot="icon"
.icon=${this._config.icon}
.stateObj=${stateObj}
></ha-state-icon>
</ha-tile-icon>
<ha-tile-info slot="info">
<span slot="primary"
>${this._formatters.formatEntityName(stateObj)}</span
>${this._formatters.formatEntityName(
stateObj,
this._config.name
)}</span
>
<span slot="secondary">
${stateDisplay} ·
<ha-relative-time
.datetime=${stateObj.last_changed}
.format=${"short"}
></ha-relative-time>
${areaName ? html` · ${areaName}` : nothing}
</span>
${
this._config.hide_state
? nothing
: html`<span slot="secondary">
${this._formatters.formatEntityState(stateObj)} ·
<ha-relative-time
.datetime=${stateObj.last_changed}
.format=${"short"}
></ha-relative-time>
${areaName ? html` · ${areaName}` : nothing}
</span>`
}
</ha-tile-info>
</ha-tile-container>
</ha-card>
@@ -162,9 +184,10 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
}
private _handleAction(ev: ActionHandlerEvent): void {
if (ev.detail.action === "tap" && this._config) {
fireEvent(this, "hass-more-info", { entityId: this._config.entity });
}
fireEvent(this, "hass-action", {
config: this._config!,
action: ev.detail.action!,
});
}
static styles = [
+17 -60
View File
@@ -8,7 +8,6 @@ import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../../../common/color/compute-color";
import { hsv2rgb, rgb2hex, rgb2hsv } from "../../../common/color/convert-color";
import { DOMAINS_TOGGLE } from "../../../common/const";
import { computeDomain } from "../../../common/entity/compute_domain";
import { stateActive } from "../../../common/entity/state_active";
import { stateColorCss } from "../../../common/entity/state_color";
@@ -18,7 +17,6 @@ import "../../../components/tile/ha-tile-badge";
import "../../../components/tile/ha-tile-container";
import "../../../components/tile/ha-tile-icon";
import "../../../components/tile/ha-tile-info";
import { cameraUrlWithWidthHeight } from "../../../data/camera";
import { timerJustFinished } from "../../../data/timer";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import "../../../state-display/state-display";
@@ -39,18 +37,16 @@ import type {
LovelaceGridOptions,
} from "../types";
import { renderTileBadge } from "./tile/badges/tile-badge";
import {
getTileActionDefaults,
getTileIconActionConfig,
getTileImageUrl,
hasTileCardAction,
hasTileIconAction,
} from "./tile/tile-card-helpers";
import { tileCardStyle } from "./tile/tile-card-style";
import type { TileCardConfig } from "./types";
export const getEntityDefaultTileIconAction = (entityId: string) => {
const domain = computeDomain(entityId);
const supportsIconAction =
DOMAINS_TOGGLE.has(domain) ||
["button", "input_button", "scene"].includes(domain);
return supportsIconAction ? "toggle" : "none";
};
@customElement("hui-tile-card")
export class HuiTileCard extends LitElement implements LovelaceCard {
public static async getConfigElement(): Promise<LovelaceCardEditor> {
@@ -124,12 +120,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
}
this._config = {
tap_action: {
action: "more-info",
},
icon_tap_action: {
action: getEntityDefaultTileIconAction(config.entity),
},
...getTileActionDefaults(config.entity),
...config,
};
this._featureContext = {
@@ -184,28 +175,12 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
return;
}
const config = {
entity: this._config!.entity,
tap_action: this._config!.icon_tap_action,
hold_action: this._config!.icon_hold_action,
double_tap_action: this._config!.icon_double_tap_action,
};
handleAction(this, this.hass!, config, ev.detail.action!);
}
private _getImageUrl(entity: HassEntity): string | undefined {
const entityPicture =
entity.attributes.entity_picture_local ||
entity.attributes.entity_picture;
if (!entityPicture) return undefined;
let imageUrl = this.hass!.hassUrl(entityPicture);
if (computeDomain(entity.entity_id) === "camera") {
imageUrl = cameraUrlWithWidthHeight(imageUrl, 80, 80);
}
return imageUrl;
handleAction(
this,
this.hass!,
getTileIconActionConfig(this._config!.entity, this._config!),
ev.detail.action!
);
}
private _computeStateColor = memoizeOne(
@@ -247,24 +222,6 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
}
);
private get _hasCardAction() {
return (
!this._config?.tap_action ||
hasAction(this._config?.tap_action) ||
hasAction(this._config?.hold_action) ||
hasAction(this._config?.double_tap_action)
);
}
private get _hasIconAction() {
return (
!this._config?.icon_tap_action ||
hasAction(this._config?.icon_tap_action) ||
hasAction(this._config?.icon_hold_action) ||
hasAction(this._config?.icon_double_tap_action)
);
}
private _featurePosition = memoizeOne((config: TileCardConfig) => {
if (config.vertical) {
return "bottom";
@@ -317,7 +274,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
};
const imageUrl = this._config.show_entity_picture
? this._getImageUrl(stateObj)
? getTileImageUrl(this.hass, stateObj)
: undefined;
const featurePosition = this._featurePosition(this._config);
@@ -334,7 +291,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
.featurePosition=${featurePosition}
.vertical=${Boolean(this._config.vertical)}
.fixedInfoHeight=${fixedInfoHeight}
.interactive=${this._hasCardAction}
.interactive=${hasTileCardAction(this._config)}
.actionHandlerOptions=${{
hasHold: hasAction(this._config!.hold_action),
hasDoubleClick: hasAction(this._config!.double_tap_action),
@@ -348,7 +305,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
hasHold: hasAction(this._config!.icon_hold_action),
hasDoubleClick: hasAction(this._config!.icon_double_tap_action),
}}
.interactive=${this._hasIconAction}
.interactive=${hasTileIconAction(this._config)}
.accessibleLabel=${name}
.imageUrl=${imageUrl}
data-domain=${ifDefined(domain)}
@@ -0,0 +1,67 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { DOMAINS_TOGGLE } from "../../../../common/const";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { cameraUrlWithWidthHeight } from "../../../../data/camera";
import type { HomeAssistantConnection } from "../../../../types";
import type { ActionConfigParams } from "../../common/handle-action";
import { hasAction } from "../../common/has-action";
import type { TileActionsConfig } from "../types";
export const getEntityDefaultTileIconAction = (entityId: string) => {
const domain = computeDomain(entityId);
const supportsIconAction =
DOMAINS_TOGGLE.has(domain) ||
["button", "input_button", "scene"].includes(domain);
return supportsIconAction ? "toggle" : "none";
};
export const getTileActionDefaults = (
entityId: string
): Pick<TileActionsConfig, "tap_action" | "icon_tap_action"> => ({
tap_action: { action: "more-info" },
icon_tap_action: { action: getEntityDefaultTileIconAction(entityId) },
});
export const hasTileCardAction = (config?: TileActionsConfig) =>
!config?.tap_action ||
hasAction(config.tap_action) ||
hasAction(config.hold_action) ||
hasAction(config.double_tap_action);
export const hasTileIconAction = (config?: TileActionsConfig) =>
!config?.icon_tap_action ||
hasAction(config.icon_tap_action) ||
hasAction(config.icon_hold_action) ||
hasAction(config.icon_double_tap_action);
export const getTileIconActionConfig = (
entity: string,
config: TileActionsConfig
): ActionConfigParams => ({
entity,
tap_action: config.icon_tap_action,
hold_action: config.icon_hold_action,
double_tap_action: config.icon_double_tap_action,
});
export const getTileImageUrl = (
connection: Pick<HomeAssistantConnection, "hassUrl">,
stateObj: HassEntity,
size = 80
): string | undefined => {
const entityPicture =
stateObj.attributes.entity_picture_local ||
stateObj.attributes.entity_picture;
if (!entityPicture) {
return undefined;
}
const imageUrl = connection.hassUrl(entityPicture);
return computeDomain(stateObj.entity_id) === "camera"
? cameraUrlWithWidthHeight(imageUrl, size, size)
: imageUrl;
};
+17 -7
View File
@@ -686,7 +686,16 @@ export interface WeatherForecastCardConfig extends LovelaceCardConfig {
double_tap_action?: ActionConfig;
}
export interface TileCardConfig extends LovelaceCardConfig {
export interface TileActionsConfig {
tap_action?: ActionConfig;
hold_action?: ActionConfig;
double_tap_action?: ActionConfig;
icon_tap_action?: ActionConfig;
icon_hold_action?: ActionConfig;
icon_double_tap_action?: ActionConfig;
}
export interface TileCardConfig extends LovelaceCardConfig, TileActionsConfig {
entity: string;
name?: string | EntityNameItem | EntityNameItem[];
hide_state?: boolean;
@@ -695,12 +704,6 @@ export interface TileCardConfig extends LovelaceCardConfig {
color?: string;
show_entity_picture?: boolean;
vertical?: boolean;
tap_action?: ActionConfig;
hold_action?: ActionConfig;
double_tap_action?: ActionConfig;
icon_tap_action?: ActionConfig;
icon_hold_action?: ActionConfig;
icon_double_tap_action?: ActionConfig;
features?: LovelaceCardFeatureConfig[];
features_position?: LovelaceCardFeaturePosition;
time_format?: TimestampRenderingFormat;
@@ -739,9 +742,16 @@ export interface ShortcutCardConfig extends LovelaceCardConfig {
export interface AlertCardConfig extends LovelaceCardConfig {
type: "alert";
entity: string;
name?: string | EntityNameItem | EntityNameItem[];
icon?: string;
alert_color?: string;
clear_color?: string;
pulse?: boolean;
hide_state?: boolean;
vertical?: boolean;
tap_action?: ActionConfig;
hold_action?: ActionConfig;
double_tap_action?: ActionConfig;
}
export interface ToggleGroupCardConfig extends LovelaceCardConfig {
@@ -9,6 +9,7 @@ import {
const ALWAYS_LOADED_TYPES = new Set(["error", "entity"]);
const LAZY_LOAD_TYPES = {
alert: () => import("../badges/hui-alert-badge"),
"entity-filter": () => import("../badges/hui-entity-filter-badge"),
shortcut: () => import("../badges/hui-shortcut-badge"),
"state-label": () => import("../badges/hui-state-label-badge"),
@@ -24,7 +24,7 @@ import "../../../../../components/ha-alert";
import "../../../../../components/ha-form/ha-form";
import type { HaFormSchema } from "../../../../../components/ha-form/types";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import { targetIncludeStruct } from "../../../../../data/script";
import { targetStruct } from "../../../../../data/script";
import type { HomeAssistant } from "../../../../../types";
import {
LOCATION_MATCH_MODES,
@@ -35,7 +35,7 @@ import {
const locationConditionStruct = object({
condition: literal("location"),
locations: optional(array(string())),
target: optional(targetIncludeStruct),
target: optional(targetStruct),
match_mode: optional(enums(LOCATION_MATCH_MODES)),
away: optional(boolean()),
});
@@ -0,0 +1,75 @@
import { mdiAlertOutline, mdiTextShort } from "@mdi/js";
import { DEFAULT_ALERT_COLOR } from "../../../../common/entity/alert_active";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import type { HaFormSchema } from "../../../../components/ha-form/types";
import { contentLayoutSchema, displayedElementsSchema } from "./content-schema";
export const alertSchema = () =>
({
name: "alert",
type: "expandable",
flatten: true,
iconPath: mdiAlertOutline,
schema: [
{
name: "",
type: "grid",
schema: [
{
name: "clear_color",
selector: {
ui_color: { include_none: true, default_color: "none" },
},
},
{
name: "alert_color",
selector: {
ui_color: {
include_none: true,
default_color: DEFAULT_ALERT_COLOR,
},
},
},
],
},
{ name: "pulse", selector: { boolean: {} } },
],
}) as const satisfies HaFormSchema;
const nameSchema = {
name: "name",
selector: { entity_name: {} },
context: { entity: "entity" },
} as const satisfies HaFormSchema;
const iconSchema = {
name: "icon",
selector: { icon: {} },
context: { icon_entity: "entity" },
} as const satisfies HaFormSchema;
export const alertCardContentSchema = (localize: LocalizeFunc) =>
({
name: "content",
type: "expandable",
flatten: true,
iconPath: mdiTextShort,
schema: [
nameSchema,
{
name: "",
type: "grid",
schema: [iconSchema, { name: "hide_state", selector: { boolean: {} } }],
},
contentLayoutSchema(localize),
],
}) as const satisfies HaFormSchema;
export const alertBadgeContentSchema = (localize: LocalizeFunc) =>
({
name: "content",
type: "expandable",
flatten: true,
iconPath: mdiTextShort,
schema: [nameSchema, iconSchema, displayedElementsSchema(localize)],
}) as const satisfies HaFormSchema;
@@ -0,0 +1,246 @@
import { mdiTextShort } from "@mdi/js";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import type { HaFormSchema } from "../../../../components/ha-form/types";
export interface TileContentFormConfig {
hide_state?: boolean;
state_content?: string | string[];
vertical?: boolean;
content_layout?: string;
}
export interface BadgeContentFormConfig {
show_name?: boolean;
show_state?: boolean;
show_icon?: boolean;
state_content?: string | string[];
displayed_elements?: string[];
}
const colorSchema = {
name: "color",
selector: {
ui_color: {
default_color: "state",
include_state: true,
},
},
} as const satisfies HaFormSchema;
export const contentLayoutSchema = (localize: LocalizeFunc) =>
({
name: "content_layout",
required: true,
selector: {
select: {
mode: "box",
options: ["horizontal", "vertical"].map((value) => ({
label: localize(
`ui.panel.lovelace.editor.card.tile.content_layout_options.${value}`
),
value,
image: {
src: `/static/images/form/tile_content_layout_${value}.svg`,
src_dark: `/static/images/form/tile_content_layout_${value}_dark.svg`,
flip_rtl: true,
},
})),
},
},
}) as const satisfies HaFormSchema;
export const displayedElementsSchema = (localize: LocalizeFunc) =>
({
name: "displayed_elements",
selector: {
select: {
mode: "list",
multiple: true,
options: (["name", "state", "icon"] as const).map((value) => ({
value,
label: localize(
`ui.panel.lovelace.editor.badge.entity.displayed_elements_options.${value}`
),
})),
},
},
}) as const satisfies HaFormSchema;
export const tileContentSchema = (
localize: LocalizeFunc,
showTimeFormat: boolean
) =>
({
name: "content",
flatten: true,
type: "expandable",
iconPath: mdiTextShort,
schema: [
{
name: "name",
selector: {
entity_name: {},
},
context: { entity: "entity" },
},
{
name: "",
type: "grid",
schema: [
{
name: "icon",
selector: {
icon: {},
},
context: { icon_entity: "entity" },
},
colorSchema,
{
name: "show_entity_picture",
selector: {
boolean: {},
},
},
{
name: "hide_state",
selector: {
boolean: {},
},
},
],
},
{
name: "state_content",
visible: { field: "hide_state", operator: "not_eq", value: true },
selector: {
ui_state_content: {
allow_context: true,
},
},
context: {
filter_entity: "entity",
},
},
{
name: "time_format",
visible: showTimeFormat,
selector: {
ui_time_format: {},
},
},
contentLayoutSchema(localize),
],
}) as const satisfies HaFormSchema;
export const badgeContentSchema = (
localize: LocalizeFunc,
showTimeFormat: boolean
) =>
({
name: "content",
type: "expandable",
flatten: true,
iconPath: mdiTextShort,
schema: [
{
name: "name",
selector: {
entity_name: {},
},
context: { entity: "entity" },
},
{
name: "",
type: "grid",
schema: [
colorSchema,
{
name: "icon",
selector: {
icon: {},
},
context: { icon_entity: "entity" },
},
{
name: "show_entity_picture",
selector: {
boolean: {},
},
},
],
},
displayedElementsSchema(localize),
{
name: "state_content",
selector: {
ui_state_content: {
allow_name: true,
},
},
context: {
filter_entity: "entity",
},
},
{
name: "time_format",
visible: showTimeFormat,
selector: {
ui_time_format: {},
},
},
],
}) as const satisfies HaFormSchema;
export const tileContentFormData = <T extends TileContentFormConfig>(
config: T
): T => ({
...config,
content_layout: config.vertical ? "vertical" : "horizontal",
});
export const tileContentConfigFromForm = <T extends TileContentFormConfig>(
value: T
): T => {
const config = { ...value };
if (config.hide_state || !config.state_content) {
delete config.state_content;
}
if (config.content_layout) {
config.vertical = config.content_layout === "vertical";
delete config.content_layout;
}
return config;
};
export const badgeContentFormData = <T extends BadgeContentFormConfig>(
config: T
): T => ({
...config,
displayed_elements: [
...(config.show_name ? ["name"] : []),
...(config.show_state ? ["state"] : []),
...(config.show_icon ? ["icon"] : []),
],
});
export const badgeContentConfigFromForm = <T extends BadgeContentFormConfig>(
value: T
): T => {
const config = { ...value };
if (!config.state_content) {
delete config.state_content;
}
if (config.displayed_elements) {
config.show_name = config.displayed_elements.includes("name");
config.show_state = config.displayed_elements.includes("state");
config.show_icon = config.displayed_elements.includes("icon");
delete config.displayed_elements;
}
return config;
};
@@ -0,0 +1,146 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { assert, assign, boolean, object, optional, string } from "superstruct";
import { fireEvent } from "../../../../common/dom/fire_event";
import { ALERT_DOMAINS } from "../../../../common/entity/alert_active";
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, ValueChangedEvent } from "../../../../types";
import type { AlertBadgeConfig } from "../../badges/types";
import { ALERT_BADGE_DEFAULT_CONFIG } from "../../badges/hui-alert-badge";
import type { LovelaceBadgeEditor } from "../../types";
import { actionConfigStruct } from "../structs/action-struct";
import { baseLovelaceBadgeConfig } from "../structs/base-badge-struct";
import { entityNameStruct } from "../structs/entity-name-struct";
import { alertBadgeContentSchema, alertSchema } from "./alert-schema";
import {
badgeContentConfigFromForm,
badgeContentFormData,
} from "./content-schema";
import { entityInteractionsSchema } from "./interactions-schema";
const badgeConfigStruct = assign(
baseLovelaceBadgeConfig,
object({
entity: optional(string()),
name: optional(entityNameStruct),
icon: optional(string()),
alert_color: optional(string()),
clear_color: optional(string()),
pulse: optional(boolean()),
show_name: optional(boolean()),
show_state: optional(boolean()),
show_icon: optional(boolean()),
tap_action: optional(actionConfigStruct),
hold_action: optional(actionConfigStruct),
double_tap_action: optional(actionConfigStruct),
})
);
@customElement("hui-alert-badge-editor")
export class HuiAlertBadgeEditor
extends LitElement
implements LovelaceBadgeEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _config?: AlertBadgeConfig;
public setConfig(config: AlertBadgeConfig): void {
assert(config, badgeConfigStruct);
this._config = { ...ALERT_BADGE_DEFAULT_CONFIG, ...config };
}
private _schema = memoizeOne(
(localize: LocalizeFunc) =>
[
{
name: "entity",
required: true,
selector: { entity: { filter: { domain: ALERT_DOMAINS } } },
},
alertSchema(),
alertBadgeContentSchema(localize),
entityInteractionsSchema(),
] as const satisfies readonly HaFormSchema[]
);
private _formData = memoizeOne(badgeContentFormData<AlertBadgeConfig>);
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
return html`
<ha-form
.hass=${this.hass}
.data=${this._formData(this._config)}
.schema=${this._schema(this.hass.localize)}
.computeLabel=${this._computeLabelCallback}
.computeHelper=${this._computeHelperCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _valueChanged(ev: ValueChangedEvent<AlertBadgeConfig>): void {
ev.stopPropagation();
const config = badgeContentConfigFromForm(ev.detail.value);
fireEvent(this, "config-changed", { config });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "pulse":
case "alert_color":
case "clear_color":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.alert.${schema.name}`
);
case "alert":
return this.hass!.localize(
"ui.panel.lovelace.editor.card.alert.alert_section"
);
case "displayed_elements":
return this.hass!.localize(
`ui.panel.lovelace.editor.badge.entity.${schema.name}`
);
default:
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
}
};
private _computeHelperCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
if (
schema.name === "pulse" ||
schema.name === "alert_color" ||
schema.name === "clear_color"
) {
return this.hass?.localize(
`ui.panel.lovelace.editor.card.alert.${schema.name}_helper`
);
}
return undefined;
};
}
declare global {
interface HTMLElementTagNameMap {
"hui-alert-badge-editor": HuiAlertBadgeEditor;
}
}
@@ -1,53 +1,45 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { assert, assign, boolean, object, optional, string } from "superstruct";
import { fireEvent } from "../../../../common/dom/fire_event";
import {
ALERT_DOMAINS,
DEFAULT_ALERT_COLOR,
} from "../../../../common/entity/alert_active";
import { ALERT_DOMAINS } from "../../../../common/entity/alert_active";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import type { AlertCardConfig } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types";
import { actionConfigStruct } from "../structs/action-struct";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import { entityNameStruct } from "../structs/entity-name-struct";
import { alertCardContentSchema, alertSchema } from "./alert-schema";
import {
tileContentConfigFromForm,
tileContentFormData,
} from "./content-schema";
import { entityInteractionsSchema } from "./interactions-schema";
const cardConfigStruct = assign(
baseLovelaceCardConfig,
object({
entity: optional(string()),
name: optional(entityNameStruct),
icon: optional(string()),
alert_color: optional(string()),
clear_color: optional(string()),
pulse: optional(boolean()),
hide_state: optional(boolean()),
vertical: optional(boolean()),
tap_action: optional(actionConfigStruct),
hold_action: optional(actionConfigStruct),
double_tap_action: optional(actionConfigStruct),
})
);
const SCHEMA = [
{
name: "entity",
required: true,
selector: { entity: { filter: { domain: ALERT_DOMAINS } } },
},
{
name: "",
type: "grid",
schema: [
{
name: "clear_color",
selector: { ui_color: { include_none: true, default_color: "none" } },
},
{
name: "alert_color",
selector: {
ui_color: { include_none: true, default_color: DEFAULT_ALERT_COLOR },
},
},
],
},
{ name: "pulse", selector: { boolean: {} } },
] as const;
@customElement("hui-alert-card-editor")
export class HuiAlertCardEditor
extends LitElement
@@ -62,6 +54,20 @@ export class HuiAlertCardEditor
this._config = config;
}
private _schema = memoizeOne(
(localize: LocalizeFunc) =>
[
{
name: "entity",
required: true,
selector: { entity: { filter: { domain: ALERT_DOMAINS } } },
},
alertSchema(),
alertCardContentSchema(localize),
entityInteractionsSchema(),
] as const satisfies readonly HaFormSchema[]
);
protected render() {
if (!this.hass || !this._config) {
return nothing;
@@ -70,8 +76,8 @@ export class HuiAlertCardEditor
return html`
<ha-form
.hass=${this.hass}
.data=${this._config}
.schema=${SCHEMA}
.data=${tileContentFormData(this._config)}
.schema=${this._schema(this.hass.localize)}
.computeLabel=${this._computeLabelCallback}
.computeHelper=${this._computeHelperCallback}
@value-changed=${this._valueChanged}
@@ -81,22 +87,41 @@ export class HuiAlertCardEditor
private _valueChanged(ev: ValueChangedEvent<AlertCardConfig>): void {
ev.stopPropagation();
fireEvent(this, "config-changed", { config: ev.detail.value });
const config = tileContentConfigFromForm(ev.detail.value);
fireEvent(this, "config-changed", { config });
}
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
if (schema.name === "entity") {
return this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.entity"
);
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "pulse":
case "alert_color":
case "clear_color":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.alert.${schema.name}`
);
case "alert":
return this.hass!.localize(
"ui.panel.lovelace.editor.card.alert.alert_section"
);
case "hide_state":
case "content_layout":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.tile.${schema.name}`
);
default:
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
}
return this.hass!.localize(
`ui.panel.lovelace.editor.card.alert.${schema.name}`
);
};
private _computeHelperCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
private _computeHelperCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
if (
schema.name === "pulse" ||
schema.name === "alert_color" ||
@@ -1,4 +1,3 @@
import { mdiGestureTap, mdiTextShort } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -20,7 +19,7 @@ import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import {
DEFAULT_CONFIG,
DISPLAY_TYPES,
@@ -28,11 +27,16 @@ import {
} from "../../badges/hui-entity-badge";
import type { EntityBadgeConfig } from "../../badges/types";
import type { LovelaceBadgeEditor } from "../../types";
import { ACTION_RELATED_CONTEXT } from "../../components/hui-action-editor";
import { actionConfigStruct } from "../structs/action-struct";
import { baseLovelaceBadgeConfig } from "../structs/base-badge-struct";
import { entityNameStruct } from "../structs/entity-name-struct";
import { configElementStyle } from "./config-elements-style";
import {
badgeContentConfigFromForm,
badgeContentFormData,
badgeContentSchema,
} from "./content-schema";
import { entityInteractionsSchema } from "./interactions-schema";
import { stateContentHasTimestamp } from "../../../../state-display/state-display";
import { timeFormatConfigStruct } from "../../components/types";
@@ -78,148 +82,12 @@ export class HuiEntityBadgeEditor
(localize: LocalizeFunc, showTimeFormat: boolean) =>
[
{ name: "entity", selector: { entity: {} } },
{
name: "content",
type: "expandable",
flatten: true,
iconPath: mdiTextShort,
schema: [
{
name: "name",
selector: {
entity_name: {},
},
context: { entity: "entity" },
},
{
name: "",
type: "grid",
schema: [
{
name: "color",
selector: {
ui_color: {
default_color: "state",
include_state: true,
},
},
},
{
name: "icon",
selector: {
icon: {},
},
context: { icon_entity: "entity" },
},
{
name: "show_entity_picture",
selector: {
boolean: {},
},
},
],
},
{
name: "displayed_elements",
selector: {
select: {
mode: "list",
multiple: true,
options: [
{
value: "name",
label: localize(
`ui.panel.lovelace.editor.badge.entity.displayed_elements_options.name`
),
},
{
value: "state",
label: localize(
`ui.panel.lovelace.editor.badge.entity.displayed_elements_options.state`
),
},
{
value: "icon",
label: localize(
`ui.panel.lovelace.editor.badge.entity.displayed_elements_options.icon`
),
},
],
},
},
},
{
name: "state_content",
selector: {
ui_state_content: {
allow_name: true,
},
},
context: {
filter_entity: "entity",
},
},
...(showTimeFormat
? ([
{
name: "time_format",
selector: {
ui_time_format: {},
},
},
] as const satisfies readonly HaFormSchema[])
: []),
],
},
{
name: "interactions",
type: "expandable",
flatten: true,
iconPath: mdiGestureTap,
schema: [
{
name: "tap_action",
selector: {
ui_action: {
default_action: "more-info",
},
},
context: ACTION_RELATED_CONTEXT,
},
{
name: "",
type: "optional_actions",
flatten: true,
schema: (["hold_action", "double_tap_action"] as const).map(
(action) => ({
name: action,
selector: {
ui_action: {
default_action: "none" as const,
},
},
context: ACTION_RELATED_CONTEXT,
})
),
},
],
},
badgeContentSchema(localize, showTimeFormat),
entityInteractionsSchema(),
] as const satisfies readonly HaFormSchema[]
);
_displayedElements = memoizeOne((config: EntityBadgeConfig) => {
const elements: string[] = [];
if (config.show_name) {
elements.push("name");
}
if (config.show_state) {
elements.push("state");
}
if (config.show_icon) {
elements.push("icon");
}
return elements;
});
private _formData = memoizeOne(badgeContentFormData<EntityBadgeConfig>);
protected render() {
if (!this.hass || !this._config) {
@@ -238,10 +106,7 @@ export class HuiEntityBadgeEditor
const schema = this._schema(this.hass!.localize, showTimeFormat);
const data = {
...this._config,
displayed_elements: this._displayedElements(this._config),
};
const data = this._formData(this._config);
return html`
<ha-form
@@ -255,24 +120,13 @@ export class HuiEntityBadgeEditor
`;
}
private _valueChanged(ev: CustomEvent): void {
private _valueChanged(ev: ValueChangedEvent<EntityBadgeConfig>): void {
ev.stopPropagation();
if (!this._config || !this.hass) {
return;
}
const config = { ...ev.detail.value } as EntityBadgeConfig;
if (!config.state_content) {
delete config.state_content;
}
if (config.displayed_elements) {
config.show_name = config.displayed_elements.includes("name");
config.show_state = config.displayed_elements.includes("state");
config.show_icon = config.displayed_elements.includes("icon");
delete config.displayed_elements;
}
const config = badgeContentConfigFromForm(ev.detail.value);
fireEvent(this, "config-changed", { config });
}
@@ -1,4 +1,4 @@
import { mdiGestureTap, mdiListBox, mdiTextShort } from "@mdi/js";
import { mdiListBox } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -30,8 +30,6 @@ import type {
LovelaceCardFeatureConfig,
LovelaceCardFeatureContext,
} from "../../card-features/types";
import { ACTION_RELATED_CONTEXT } from "../../components/hui-action-editor";
import { getEntityDefaultTileIconAction } from "../../cards/hui-tile-card";
import type { TileCardConfig } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types";
import { actionConfigStruct } from "../structs/action-struct";
@@ -39,7 +37,13 @@ import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import { entityNameStruct } from "../structs/entity-name-struct";
import type { EditDetailElementEvent, EditSubElementEvent } from "../types";
import { configElementStyle } from "./config-elements-style";
import {
tileContentConfigFromForm,
tileContentFormData,
tileContentSchema,
} from "./content-schema";
import { getSupportedFeaturesType } from "./hui-card-features-editor";
import { tileInteractionsSchema } from "./interactions-schema";
import { stateContentHasTimestamp } from "../../../../state-display/state-display";
import { timeFormatConfigStruct } from "../../components/types";
@@ -96,147 +100,8 @@ export class HuiTileCardEditor
) =>
[
{ name: "entity", selector: { entity: {} } },
{
name: "content",
flatten: true,
type: "expandable",
iconPath: mdiTextShort,
schema: [
{
name: "name",
selector: {
entity_name: {},
},
context: { entity: "entity" },
},
{
name: "",
type: "grid",
schema: [
{
name: "icon",
selector: {
icon: {},
},
context: { icon_entity: "entity" },
},
{
name: "color",
selector: {
ui_color: {
default_color: "state",
include_state: true,
},
},
},
{
name: "show_entity_picture",
selector: {
boolean: {},
},
},
{
name: "hide_state",
selector: {
boolean: {},
},
},
],
},
{
name: "state_content",
visible: { field: "hide_state", operator: "not_eq", value: true },
selector: {
ui_state_content: {
allow_context: true,
},
},
context: {
filter_entity: "entity",
},
},
{
name: "time_format",
visible: showTimeFormat,
selector: {
ui_time_format: {},
},
},
{
name: "content_layout",
required: true,
selector: {
select: {
mode: "box",
options: ["horizontal", "vertical"].map((value) => ({
label: localize(
`ui.panel.lovelace.editor.card.tile.content_layout_options.${value}`
),
value,
image: {
src: `/static/images/form/tile_content_layout_${value}.svg`,
src_dark: `/static/images/form/tile_content_layout_${value}_dark.svg`,
flip_rtl: true,
},
})),
},
},
},
],
},
{
name: "interactions",
type: "expandable",
flatten: true,
iconPath: mdiGestureTap,
schema: [
{
name: "tap_action",
selector: {
ui_action: {
default_action: "more-info",
},
},
context: ACTION_RELATED_CONTEXT,
},
{
name: "",
type: "divider",
},
{
name: "icon_tap_action",
selector: {
ui_action: {
default_action: entityId
? getEntityDefaultTileIconAction(entityId)
: "more-info",
},
},
context: ACTION_RELATED_CONTEXT,
},
{
name: "",
type: "optional_actions",
flatten: true,
schema: (
[
"hold_action",
"icon_hold_action",
"double_tap_action",
"icon_double_tap_action",
] as const
).map((action) => ({
name: action,
selector: {
ui_action: {
default_action: "none" as const,
},
},
context: ACTION_RELATED_CONTEXT,
})),
},
],
},
tileContentSchema(localize, showTimeFormat),
tileInteractionsSchema(entityId),
] as const satisfies readonly HaFormSchema[]
);
@@ -296,10 +161,7 @@ export class HuiTileCardEditor
const featuresSchema = this._featuresSchema(this.hass.localize, vertical);
const data = {
...this._config,
content_layout: vertical ? "vertical" : "horizontal",
};
const data = tileContentFormData(this._config);
// Default features position to bottom and force it to bottom in vertical mode
if (!data.features_position || vertical) {
@@ -362,24 +224,10 @@ export class HuiTileCardEditor
const newConfig = ev.detail.value as TileCardConfig;
let config: TileCardConfig = {
let config: TileCardConfig = tileContentConfigFromForm({
features: this._config.features,
...newConfig,
};
if (config.hide_state) {
delete config.state_content;
}
if (!config.state_content) {
delete config.state_content;
}
// Convert content_layout to vertical
if (config.content_layout) {
config.vertical = config.content_layout === "vertical";
delete config.content_layout;
}
});
config = orderProperties(config, fieldOrder);
@@ -0,0 +1,94 @@
import { mdiGestureTap } from "@mdi/js";
import type { HaFormSchema } from "../../../../components/ha-form/types";
import { getEntityDefaultTileIconAction } from "../../cards/tile/tile-card-helpers";
import { ACTION_RELATED_CONTEXT } from "../../components/hui-action-editor";
export const tileInteractionsSchema = (entityId: string | undefined) =>
({
name: "interactions",
type: "expandable",
flatten: true,
iconPath: mdiGestureTap,
schema: [
{
name: "tap_action",
selector: {
ui_action: {
default_action: "more-info",
},
},
context: ACTION_RELATED_CONTEXT,
},
{
name: "",
type: "divider",
},
{
name: "icon_tap_action",
selector: {
ui_action: {
default_action: entityId
? getEntityDefaultTileIconAction(entityId)
: "more-info",
},
},
context: ACTION_RELATED_CONTEXT,
},
{
name: "",
type: "optional_actions",
flatten: true,
schema: (
[
"hold_action",
"icon_hold_action",
"double_tap_action",
"icon_double_tap_action",
] as const
).map((action) => ({
name: action,
selector: {
ui_action: {
default_action: "none" as const,
},
},
context: ACTION_RELATED_CONTEXT,
})),
},
],
}) as const satisfies HaFormSchema;
export const entityInteractionsSchema = () =>
({
name: "interactions",
type: "expandable",
flatten: true,
iconPath: mdiGestureTap,
schema: [
{
name: "tap_action",
selector: {
ui_action: {
default_action: "more-info",
},
},
context: ACTION_RELATED_CONTEXT,
},
{
name: "",
type: "optional_actions",
flatten: true,
schema: (["hold_action", "double_tap_action"] as const).map(
(action) => ({
name: action,
selector: {
ui_action: {
default_action: "none" as const,
},
},
context: ACTION_RELATED_CONTEXT,
})
),
},
],
}) as const satisfies HaFormSchema;
@@ -9,4 +9,8 @@ export const coreBadges: Badge[] = [
type: "shortcut",
showElement: true,
},
{
type: "alert",
showElement: true,
},
];
+6 -2
View File
@@ -5599,7 +5599,6 @@
"template": "Template",
"device_replaced": "Replaced device",
"device_replaced_description": "This device was replaced by one or more new devices. Update this automation to use them.",
"count_excluded": "({count}, {excluded} excluded)",
"types": {
"entity": "[%key:ui::components::target-picker::type::entities%]",
"device": "[%key:ui::components::target-picker::type::devices%]",
@@ -10494,7 +10493,8 @@
"clear_color": "Clear color",
"clear_color_helper": "Used when the entity isn't in an alert state. The background doesn't pulse.",
"pulse": "Pulse",
"pulse_helper": "Animates the colored background while the entity is in an alert state. When reduced motion is on, the background stays tinted instead."
"pulse_helper": "Animates the colored background while the entity is in an alert state. When reduced motion is on, the background stays tinted instead.",
"alert_section": "Alert"
},
"area": {
"name": "Area",
@@ -11290,6 +11290,10 @@
"state_filter": "[%key:ui::panel::lovelace::editor::card::picture-elements::state_filter%]"
},
"badge": {
"alert": {
"name": "Alert",
"description": "A badge that highlights an entity that needs attention, with an optional color and pulsing background."
},
"entity": {
"name": "Entity",
"description": "The Entity badge gives you a quick overview of an entity.",
-48
View File
@@ -193,52 +193,4 @@ describe("resolveEntityIDs", () => {
"sensor.kitchen_hub_temp",
]);
});
// Excludes are global: resolved like a target and taken out of everything
// the target includes, so logbook, history and state selectors only show
// what the action or trigger actually acts on.
describe("exclude", () => {
it("takes an excluded entity out of an area", () => {
expect(
resolve({
area_id: "kitchen",
exclude: { entity_id: "light.kitchen" },
})
).toEqual(["sensor.kitchen_hub_temp"]);
});
it("takes an excluded area out of a device", () => {
expect(
resolve({ device_id: "hub", exclude: { area_id: "hallway" } })
).toEqual(["light.kitchen", "sensor.kitchen_hub_temp"]);
});
it("takes an excluded device out of every included target", () => {
expect(
resolve({
area_id: ["kitchen", "hallway"],
exclude: { device_id: ["hub"] },
})
).toEqual([]);
});
it("wins over an explicit include of the same entity", () => {
expect(
resolve({
entity_id: ["light.kitchen", "sensor.hallway_probe"],
exclude: { entity_id: "light.kitchen" },
})
).toEqual(["sensor.hallway_probe"]);
});
it("leaves the target alone when the exclude matches nothing", () => {
expect(
resolve({ area_id: "kitchen", exclude: { area_id: "deleted_area" } })
).toEqual(["light.kitchen", "sensor.kitchen_hub_temp"]);
});
it("resolves nothing for an exclude on its own", () => {
expect(resolve({ exclude: { area_id: "kitchen" } })).toEqual([]);
});
});
});