Compare commits

..
13 changed files with 265 additions and 41 deletions

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { computeDomain } from "./compute_domain";
export const ALERT_DOMAINS = [
"alarm_control_panel",
"binary_sensor",
"cover",
"lock",
];
export const DEFAULT_ALERT_COLOR = "red";
export const computeAlertActiveStates = (entityId: string): string[] => {
switch (computeDomain(entityId)) {
case "alarm_control_panel":
return ["triggered"];
case "cover":
return ["open"];
case "lock":
return ["jammed", "unlocked", "open"];
default:
return ["on"];
}
};
export const isAlertActive = (stateObj: HassEntity): boolean =>
computeAlertActiveStates(stateObj.entity_id).includes(stateObj.state);
@@ -0,0 +1,14 @@
import { ALERT_DOMAINS } from "../../../common/entity/alert_active";
import { computeDomain } from "../../../common/entity/compute_domain";
import type { AlertCardConfig } from "../cards/types";
import type { CardSuggestionProvider } from "./types";
export const alertCardSuggestions: CardSuggestionProvider<AlertCardConfig> = {
getEntitySuggestion(_hass, entityId) {
if (!ALERT_DOMAINS.includes(computeDomain(entityId))) {
return null;
}
return { config: { type: "alert", entity: entityId } };
},
};
@@ -1,4 +1,5 @@
import { alarmPanelCardSuggestions } from "./hui-alarm-panel-card-suggestions";
import { alertCardSuggestions } from "./hui-alert-card-suggestions";
import { calendarCardSuggestions } from "./hui-calendar-card-suggestions";
import { gaugeCardSuggestions } from "./hui-gauge-card-suggestions";
import { historyGraphCardSuggestions } from "./hui-history-graph-card-suggestions";
@@ -19,6 +20,7 @@ export const CARD_SUGGESTION_PROVIDERS: Record<string, CardSuggestionProvider> =
{
tile: tileCardSuggestions,
"alarm-panel": alarmPanelCardSuggestions,
alert: alertCardSuggestions,
calendar: calendarCardSuggestions,
gauge: gaugeCardSuggestions,
humidifier: humidifierCardSuggestions,
+57 -9
View File
@@ -8,6 +8,12 @@ import { consume } from "../../../common/decorators/consume";
import { computeCssColor } from "../../../common/color/compute-color";
import { consumeEntityState } 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-card";
import "../../../components/ha-relative-time";
@@ -17,12 +23,40 @@ import "../../../components/tile/ha-tile-icon";
import "../../../components/tile/ha-tile-info";
import { formattersContext } from "../../../data/context";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import type { LovelaceCard, LovelaceGridOptions } from "../types";
import type { HomeAssistant } from "../../../types";
import { findEntities } from "../common/find-entities";
import type {
LovelaceCard,
LovelaceCardEditor,
LovelaceGridOptions,
} from "../types";
import { tileCardStyle } from "./tile/tile-card-style";
import type { AlertCardConfig } from "./types";
@customElement("hui-alert-card")
export class HuiAlertCard extends LitElement implements LovelaceCard {
public static async getConfigElement(): Promise<LovelaceCardEditor> {
await import("../editor/config-elements/hui-alert-card-editor");
return document.createElement("hui-alert-card-editor");
}
public static getStubConfig(
hass: HomeAssistant,
entities: string[],
entitiesFallback: string[]
): AlertCardConfig {
const [entity] = findEntities(
hass,
1,
entities,
entitiesFallback,
ALERT_DOMAINS
);
return { type: "alert", entity: entity || "" };
}
@state() private _config?: AlertCardConfig;
@state()
@@ -36,11 +70,20 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
public setConfig(config: AlertCardConfig): void {
if (
!isValidEntityId(config.entity) ||
(config.color !== undefined && typeof config.color !== "string") ||
(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 = config;
}
@@ -71,18 +114,22 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
const areaName = this._formatters.formatEntityName(stateObj, {
type: "area",
});
const pulse = this._config.pulse === true;
const hasColor =
this._config.color !== undefined && this._config.color !== "none";
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";
return html`
<ha-card
class=${classMap({ pulse, "no-color": !hasColor })}
style=${styleMap({
"--ha-alert-color":
this._config.color && hasColor
? computeCssColor(this._config.color)
: undefined,
color && hasColor ? computeCssColor(color) : undefined,
"--ha-alert-static-opacity": pulse
? undefined
: "var(--ha-alert-pulse-opacity)",
@@ -169,7 +216,8 @@ export class HuiAlertCard extends LitElement implements LovelaceCard {
position: relative;
}
@media (prefers-reduced-motion: reduce) {
ha-card::before {
ha-card::before,
ha-card.pulse::before {
animation: none;
opacity: var(--ha-alert-pulse-opacity);
}
+2 -1
View File
@@ -739,7 +739,8 @@ export interface ShortcutCardConfig extends LovelaceCardConfig {
export interface AlertCardConfig extends LovelaceCardConfig {
type: "alert";
entity: string;
color?: string;
alert_color?: string;
clear_color?: string;
pulse?: boolean;
}
@@ -0,0 +1,118 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
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 "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import type { AlertCardConfig } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
const cardConfigStruct = assign(
baseLovelaceCardConfig,
object({
entity: optional(string()),
alert_color: optional(string()),
clear_color: optional(string()),
pulse: optional(boolean()),
})
);
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
implements LovelaceCardEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _config?: AlertCardConfig;
public setConfig(config: AlertCardConfig): void {
assert(config, cardConfigStruct);
this._config = config;
}
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
return html`
<ha-form
.hass=${this.hass}
.data=${this._config}
.schema=${SCHEMA}
.computeLabel=${this._computeLabelCallback}
.computeHelper=${this._computeHelperCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _valueChanged(ev: ValueChangedEvent<AlertCardConfig>): void {
ev.stopPropagation();
fireEvent(this, "config-changed", { config: ev.detail.value });
}
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
if (schema.name === "entity") {
return this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.entity"
);
}
return this.hass!.localize(
`ui.panel.lovelace.editor.card.alert.${schema.name}`
);
};
private _computeHelperCallback = (schema: SchemaUnion<typeof 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-card-editor": HuiAlertCardEditor;
}
}
@@ -141,6 +141,10 @@ export const coreCards: Card[] = [
type: "shortcut",
showElement: true,
},
{
type: "alert",
showElement: true,
},
];
export const energyCards: Card[] = [
@@ -4,6 +4,7 @@ import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { consume } from "../../../common/decorators/consume";
import { ALERT_DOMAINS } from "../../../common/entity/alert_active";
import { computeEntityPickerDisplay } from "../../../common/entity/compute_entity_name_display";
import {
fireEvent,
@@ -94,6 +95,7 @@ export class SecurityAlertsEditor extends LitElement {
"ui.panel.security.editor.add_alert_entity"
)}
.excludeEntities=${this.alertEntities.map(({ entity }) => entity)}
.includeDomains=${ALERT_DOMAINS}
.entityFilter=${this._entityFilter}
@value-changed=${this._add}
></ha-entity-picker>
@@ -1,5 +1,8 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { ensureArray } from "../../../common/array/ensure-array";
import {
computeAlertActiveStates,
isAlertActive,
} from "../../../common/entity/alert_active";
import { computeDomain } from "../../../common/entity/compute_domain";
import type {
SecurityAlertEntityConfig,
@@ -55,26 +58,15 @@ export const computeDefaultSecurityAlertSeverity = (
export const computeDefaultSecurityAlertVisibility = (
entityId: string
): StateCondition[] => {
const condition: StateCondition = {
condition: "state",
entity: entityId,
};
const states = computeAlertActiveStates(entityId);
switch (computeDomain(entityId)) {
case "alarm_control_panel":
condition.state = "triggered";
break;
case "cover":
condition.state = "open";
break;
case "lock":
condition.state = ["jammed", "unlocked", "open"];
break;
default:
condition.state = "on";
}
return [condition];
return [
{
condition: "state",
entity: entityId,
state: states.length === 1 ? states[0] : states,
},
];
};
export const resolveSecurityAlertSeverity = (
@@ -88,8 +80,8 @@ export const isSecurityAlertActive = (
entityId: string
): boolean => {
const stateObj = states[entityId];
const [{ state }] = computeDefaultSecurityAlertVisibility(entityId);
return !!stateObj && !!state && ensureArray(state).includes(stateObj.state);
return !!stateObj && isAlertActive(stateObj);
};
export const filterSecurityAlertEntities = (
@@ -113,7 +105,7 @@ export const computeSecurityAlertCardConfig = (
return {
type: "alert",
entity: alertEntity.entity,
color: severity === "alert" ? "red" : "amber",
alert_color: severity === "alert" ? "red" : "amber",
visibility: computeDefaultSecurityAlertVisibility(alertEntity.entity),
};
};
@@ -3,6 +3,8 @@ import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { getAreasFloorHierarchy } from "../../../common/areas/areas-floor-hierarchy";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { ALERT_DOMAINS } from "../../../common/entity/alert_active";
import { computeDomain } from "../../../common/entity/compute_domain";
import {
findEntities,
generateEntityFilter,
@@ -313,12 +315,16 @@ export class SecurityViewStrategy extends ReactiveElement {
const sidebarSections: LovelaceSectionConfig[] = [];
const alertCards = config.alert_entities?.map((alertEntity) =>
computeSecurityAlertCardConfig(
hass.states[alertEntity.entity],
alertEntity
const alertCards = config.alert_entities
?.filter((alertEntity) =>
ALERT_DOMAINS.includes(computeDomain(alertEntity.entity))
)
);
.map((alertEntity) =>
computeSecurityAlertCardConfig(
hass.states[alertEntity.entity],
alertEntity
)
);
if (alertCards?.length) {
sidebarSections.push({
+10
View File
@@ -10320,6 +10320,16 @@
"available_states": "Available states",
"description": "This card allows you to arm and disarm your alarm control panel integrations."
},
"alert": {
"name": "Alert",
"description": "This card highlights an entity that needs attention, with an optional color and pulsing background.",
"alert_color": "Alert color",
"alert_color_helper": "Used while the entity is in an alert state, such as a binary sensor that is on or an unlocked lock.",
"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."
},
"area": {
"name": "Area",
"color": "[%key:ui::panel::lovelace::editor::card::tile::color%]",
@@ -60,7 +60,7 @@ describe("computeSecurityAlertCardConfig", () => {
).toEqual({
type: "alert",
entity: "binary_sensor.smoke",
color: "red",
alert_color: "red",
visibility: [
{
condition: "state",
@@ -85,7 +85,7 @@ describe("computeSecurityAlertCardConfig", () => {
expect(
computeSecurityAlertCardConfig(stateObj, {
entity: stateObj.entity_id,
}).color
}).alert_color
).toBe("red");
}
);
@@ -56,7 +56,7 @@ describe("security-view-strategy", () => {
{
type: "alert",
entity: "binary_sensor.window",
color: "amber",
alert_color: "amber",
visibility: [
{
condition: "state",