mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-24 23:59:18 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e4282ca7c | ||
|
|
f785e96e24 | ||
|
|
20b7414682 |
+2
-2
@@ -152,7 +152,7 @@
|
||||
"@octokit/plugin-retry": "8.1.1",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@rsdoctor/rspack-plugin": "1.6.3",
|
||||
"@rsdoctor/rspack-plugin": "1.6.2",
|
||||
"@rspack/core": "2.1.10",
|
||||
"@rspack/dev-server": "2.2.0",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
@@ -176,7 +176,7 @@
|
||||
"browserslist": "4.28.8",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"del": "8.0.1",
|
||||
"eslint": "10.9.0",
|
||||
"eslint": "10.8.1",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
|
||||
@@ -304,9 +304,6 @@ export const DOMAIN_OPTIONS_ATTRIBUTES: Record<
|
||||
swing_mode: "swing_modes",
|
||||
swing_horizontal_mode: "swing_horizontal_modes",
|
||||
},
|
||||
cover: {
|
||||
speed: "supported_speeds",
|
||||
},
|
||||
event: {
|
||||
event_type: "event_types",
|
||||
},
|
||||
|
||||
@@ -470,19 +470,15 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
? MediaClassBrowserSettings[currentItem.children_media_class]
|
||||
: MediaClassBrowserSettings.directory;
|
||||
|
||||
const canPickCurrent =
|
||||
currentItem?.can_play ||
|
||||
(currentItem && this.accept?.includes("directory"));
|
||||
|
||||
return html`
|
||||
${
|
||||
canPickCurrent || showSearch
|
||||
currentItem.can_play || showSearch
|
||||
? html`
|
||||
<div
|
||||
class="header ${classMap({
|
||||
"no-img": !currentItem.thumbnail,
|
||||
"no-dialog": !this.dialog,
|
||||
"search-only": !canPickCurrent,
|
||||
"search-only": !currentItem.can_play,
|
||||
})}"
|
||||
@transitionend=${this._setHeaderHeight}
|
||||
>
|
||||
@@ -495,7 +491,7 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
canPickCurrent
|
||||
currentItem.can_play
|
||||
? html`<div class="header-content">
|
||||
${
|
||||
currentItem.thumbnail
|
||||
@@ -507,7 +503,7 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
></ha-media-browser-thumbnail>
|
||||
${
|
||||
this.narrow &&
|
||||
canPickCurrent &&
|
||||
currentItem?.can_play &&
|
||||
(!this.accept ||
|
||||
canPlayChildren.has(
|
||||
currentItem.media_content_id
|
||||
@@ -550,7 +546,7 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
}
|
||||
</div>
|
||||
${
|
||||
canPickCurrent &&
|
||||
currentItem.can_play &&
|
||||
(!currentItem.thumbnail || !this.narrow)
|
||||
? html`
|
||||
<ha-button
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createContext } from "@lit/context";
|
||||
import type { SecurityAlertItem } from "./helpers";
|
||||
|
||||
export const securityAlertsContext =
|
||||
createContext<SecurityAlertItem[]>("security-alerts");
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { mdiCctvOff, mdiLockOpen, mdiShieldAlert, mdiWater } from "@mdi/js";
|
||||
import { computeDomain } from "../../../../common/entity/compute_domain";
|
||||
import { isValidEntityId } from "../../../../common/entity/valid_entity_id";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../../../data/entity/entity";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { StateCondition } from "../../common/validate-condition";
|
||||
import type { SecurityAlertsCardEntityConfig } from "../types";
|
||||
import {
|
||||
checkConditionsMet,
|
||||
extractConditionEntityIds,
|
||||
validateConditionalConfig,
|
||||
} from "../../common/validate-condition";
|
||||
|
||||
export interface SecurityAlertItem {
|
||||
entityId: string;
|
||||
stateObj: HassEntity;
|
||||
color?: string;
|
||||
pulse: boolean;
|
||||
icon?: string;
|
||||
iconPath?: string;
|
||||
}
|
||||
|
||||
type SecurityAlertIcon = Pick<SecurityAlertItem, "icon" | "iconPath">;
|
||||
|
||||
export type SecurityAlertHass = Pick<
|
||||
HomeAssistant,
|
||||
"config" | "locale" | "states" | "user"
|
||||
>;
|
||||
|
||||
const WARNING_BINARY_SENSOR_DEVICE_CLASSES = [
|
||||
"door",
|
||||
"garage_door",
|
||||
"lock",
|
||||
"opening",
|
||||
"tamper",
|
||||
"window",
|
||||
] as const;
|
||||
|
||||
type WarningBinarySensorDeviceClass =
|
||||
(typeof WARNING_BINARY_SENSOR_DEVICE_CLASSES)[number];
|
||||
const WARNING_BINARY_SENSOR_DEVICE_CLASS_SET =
|
||||
new Set<WarningBinarySensorDeviceClass>(WARNING_BINARY_SENSOR_DEVICE_CLASSES);
|
||||
|
||||
const isWarningBinarySensorDeviceClass = (
|
||||
deviceClass: string
|
||||
): deviceClass is WarningBinarySensorDeviceClass =>
|
||||
WARNING_BINARY_SENSOR_DEVICE_CLASS_SET.has(
|
||||
deviceClass as WarningBinarySensorDeviceClass
|
||||
);
|
||||
|
||||
export const computeSecurityAlertEntityDefaultColor = (
|
||||
stateObj?: HassEntity
|
||||
): string => {
|
||||
if (!stateObj) {
|
||||
return "red";
|
||||
}
|
||||
|
||||
const domain = computeDomain(stateObj.entity_id);
|
||||
if (domain === "camera") {
|
||||
return "blue";
|
||||
}
|
||||
if (domain === "binary_sensor") {
|
||||
const deviceClass = stateObj.attributes.device_class;
|
||||
return typeof deviceClass === "string" &&
|
||||
isWarningBinarySensorDeviceClass(deviceClass)
|
||||
? "amber"
|
||||
: "red";
|
||||
}
|
||||
if (domain === "cover" || domain === "lock") {
|
||||
return "amber";
|
||||
}
|
||||
return "red";
|
||||
};
|
||||
|
||||
export const computeDefaultSecurityAlertVisibility = (
|
||||
entityId: string
|
||||
): StateCondition[] => {
|
||||
const condition: StateCondition = {
|
||||
condition: "state",
|
||||
entity: entityId,
|
||||
};
|
||||
|
||||
switch (computeDomain(entityId)) {
|
||||
case "alarm_control_panel":
|
||||
condition.state = "triggered";
|
||||
break;
|
||||
case "camera":
|
||||
condition.state = [UNAVAILABLE, UNKNOWN];
|
||||
break;
|
||||
case "cover":
|
||||
condition.state_not = "closed";
|
||||
break;
|
||||
case "lock":
|
||||
condition.state_not = "locked";
|
||||
break;
|
||||
default:
|
||||
condition.state = "on";
|
||||
}
|
||||
|
||||
return [condition];
|
||||
};
|
||||
|
||||
export const isValidSecurityAlertEntityConfig = (
|
||||
alertEntity: unknown
|
||||
): alertEntity is SecurityAlertsCardEntityConfig =>
|
||||
Boolean(
|
||||
alertEntity &&
|
||||
typeof alertEntity === "object" &&
|
||||
"entity" in alertEntity &&
|
||||
typeof alertEntity.entity === "string" &&
|
||||
isValidEntityId(alertEntity.entity) &&
|
||||
(!("color" in alertEntity) ||
|
||||
alertEntity.color === undefined ||
|
||||
typeof alertEntity.color === "string") &&
|
||||
(!("pulse" in alertEntity) ||
|
||||
alertEntity.pulse === undefined ||
|
||||
typeof alertEntity.pulse === "boolean") &&
|
||||
(!("visibility" in alertEntity) ||
|
||||
alertEntity.visibility === undefined ||
|
||||
(Array.isArray(alertEntity.visibility) &&
|
||||
alertEntity.visibility.every(
|
||||
(condition) => condition?.condition === "state"
|
||||
) &&
|
||||
validateConditionalConfig(alertEntity.visibility)))
|
||||
);
|
||||
|
||||
export const extractSecurityAlertEntityIds = (
|
||||
alertEntities: SecurityAlertsCardEntityConfig[]
|
||||
): string[] => [
|
||||
...new Set(
|
||||
alertEntities.flatMap((alertEntity) => [
|
||||
alertEntity.entity,
|
||||
...extractConditionEntityIds(
|
||||
alertEntity.visibility ??
|
||||
computeDefaultSecurityAlertVisibility(alertEntity.entity)
|
||||
),
|
||||
])
|
||||
),
|
||||
];
|
||||
|
||||
const computeSecurityAlertIcon = (stateObj: HassEntity): SecurityAlertIcon => {
|
||||
const domain = computeDomain(stateObj.entity_id);
|
||||
if (stateObj.state === UNAVAILABLE && domain === "camera") {
|
||||
return { iconPath: mdiCctvOff };
|
||||
}
|
||||
if (
|
||||
domain === "binary_sensor" &&
|
||||
stateObj.attributes.device_class === "moisture"
|
||||
) {
|
||||
return { iconPath: mdiWater };
|
||||
}
|
||||
if (domain === "lock") {
|
||||
return { iconPath: mdiLockOpen };
|
||||
}
|
||||
if (domain === "alarm_control_panel") {
|
||||
return { iconPath: mdiShieldAlert };
|
||||
}
|
||||
return typeof stateObj.attributes.icon === "string"
|
||||
? { icon: stateObj.attributes.icon }
|
||||
: {};
|
||||
};
|
||||
|
||||
export const computeSecurityAlertItem = (
|
||||
stateObj: HassEntity,
|
||||
alertEntity: SecurityAlertsCardEntityConfig
|
||||
): SecurityAlertItem => ({
|
||||
entityId: stateObj.entity_id,
|
||||
stateObj,
|
||||
color: alertEntity.color ?? computeSecurityAlertEntityDefaultColor(stateObj),
|
||||
pulse: alertEntity.pulse === undefined || alertEntity.pulse === true,
|
||||
...computeSecurityAlertIcon(stateObj),
|
||||
});
|
||||
|
||||
export const computeSecurityAlertItems = (
|
||||
hass: SecurityAlertHass,
|
||||
alertEntities: SecurityAlertsCardEntityConfig[]
|
||||
): SecurityAlertItem[] =>
|
||||
alertEntities
|
||||
.map((alertEntity): SecurityAlertItem | undefined => {
|
||||
const stateObj = hass.states[alertEntity.entity];
|
||||
if (!stateObj) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const visibility =
|
||||
alertEntity.visibility ??
|
||||
computeDefaultSecurityAlertVisibility(alertEntity.entity);
|
||||
|
||||
// checkConditionsMet only reads config, locale, states, and user for
|
||||
// supported condition types. Keep this helper narrowed to avoid
|
||||
// reconstructing a full HomeAssistant object from card contexts.
|
||||
if (
|
||||
!checkConditionsMet(visibility, hass as HomeAssistant, {
|
||||
entity_id: alertEntity.entity,
|
||||
})
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return computeSecurityAlertItem(stateObj, alertEntity);
|
||||
})
|
||||
.filter((item): item is SecurityAlertItem => Boolean(item));
|
||||
@@ -0,0 +1,241 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, 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 { computeStateName } from "../../../../common/entity/compute_state_name";
|
||||
import { consumeEntityStates } from "../../../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-relative-time";
|
||||
import "../../../../components/ha-state-icon";
|
||||
import "../../../../components/tile/ha-tile-container";
|
||||
import "../../../../components/tile/ha-tile-icon";
|
||||
import "../../../../components/tile/ha-tile-info";
|
||||
import {
|
||||
configContext,
|
||||
formattersContext,
|
||||
internationalizationContext,
|
||||
} from "../../../../data/context";
|
||||
import type { ActionHandlerEvent } from "../../../../data/lovelace/action_handler";
|
||||
import { tileCardStyle } from "../tile/tile-card-style";
|
||||
import type { LovelaceCard, LovelaceGridOptions } from "../../types";
|
||||
import type { SecurityAlertCardConfig } from "../types";
|
||||
import {
|
||||
computeSecurityAlertItem,
|
||||
computeSecurityAlertItems,
|
||||
extractSecurityAlertEntityIds,
|
||||
isValidSecurityAlertEntityConfig,
|
||||
type SecurityAlertItem,
|
||||
} from "./helpers";
|
||||
|
||||
@customElement("hui-security-alert-card")
|
||||
export class HuiSecurityAlertCard extends LitElement implements LovelaceCard {
|
||||
public connectedWhileHidden = true;
|
||||
|
||||
@property({ attribute: false }) public alert?: SecurityAlertItem;
|
||||
|
||||
@property({ type: Boolean }) public preview = false;
|
||||
|
||||
@state() private _config?: SecurityAlertCardConfig;
|
||||
|
||||
@state() private _alertEntityIds?: string[];
|
||||
|
||||
@state()
|
||||
@consumeEntityStates({ entityIdPath: ["_alertEntityIds"] })
|
||||
private _states?: Record<string, HassEntity>;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _hassConfig!: ContextType<typeof configContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: formattersContext, subscribe: true })
|
||||
private _formatters!: ContextType<typeof formattersContext>;
|
||||
|
||||
public setConfig(config: SecurityAlertCardConfig): void {
|
||||
if (!isValidSecurityAlertEntityConfig(config)) {
|
||||
throw new Error("Invalid configuration");
|
||||
}
|
||||
this._config = config;
|
||||
this._alertEntityIds = extractSecurityAlertEntityIds([config]);
|
||||
}
|
||||
|
||||
public getCardSize(): number {
|
||||
return 1;
|
||||
}
|
||||
|
||||
public getGridOptions(): LovelaceGridOptions {
|
||||
return {
|
||||
columns: 6,
|
||||
rows: 1,
|
||||
min_columns: 6,
|
||||
min_rows: 1,
|
||||
};
|
||||
}
|
||||
|
||||
private get _visibleAlert(): SecurityAlertItem | undefined {
|
||||
if (this.alert) {
|
||||
return this.alert;
|
||||
}
|
||||
if (!this._config || !this._alertEntityIds?.length || !this._states) {
|
||||
return undefined;
|
||||
}
|
||||
if (this.preview) {
|
||||
const stateObj = this._states[this._config.entity];
|
||||
return stateObj
|
||||
? computeSecurityAlertItem(stateObj, this._config)
|
||||
: undefined;
|
||||
}
|
||||
return computeSecurityAlertItems(
|
||||
{ ...this._hassConfig, ...this._i18n, states: this._states },
|
||||
[this._config]
|
||||
)[0];
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
if (!this._config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldBeHidden = !this.preview && !this._visibleAlert;
|
||||
if (shouldBeHidden !== this.hidden) {
|
||||
this.style.display = shouldBeHidden ? "none" : "";
|
||||
this.toggleAttribute("hidden", shouldBeHidden);
|
||||
fireEvent(this, "card-visibility-changed", { value: !shouldBeHidden });
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const alert = this._visibleAlert;
|
||||
if (!alert || !this._formatters || (this._config && this.hidden)) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const stateDisplay = this._formatters.formatEntityState(alert.stateObj);
|
||||
const pulse = alert.pulse === true;
|
||||
const hasColor = alert.color !== undefined && alert.color !== "none";
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
class=${classMap({ pulse, "no-color": !hasColor })}
|
||||
style=${styleMap({
|
||||
"--ha-security-alert-color":
|
||||
alert.color && hasColor ? computeCssColor(alert.color) : undefined,
|
||||
"--ha-security-alert-static-opacity": pulse
|
||||
? undefined
|
||||
: "var(--ha-security-alert-pulse-opacity)",
|
||||
})}
|
||||
>
|
||||
<ha-tile-container
|
||||
.interactive=${true}
|
||||
.actionHandlerOptions=${{ hasHold: false, hasDoubleClick: false }}
|
||||
@action=${this._handleAction}
|
||||
>
|
||||
<ha-tile-icon
|
||||
slot="icon"
|
||||
.icon=${alert.icon}
|
||||
.iconPath=${alert.iconPath}
|
||||
>
|
||||
${
|
||||
!alert.icon && !alert.iconPath
|
||||
? html`<ha-state-icon
|
||||
slot="icon"
|
||||
.stateObj=${alert.stateObj}
|
||||
></ha-state-icon>`
|
||||
: nothing
|
||||
}
|
||||
</ha-tile-icon>
|
||||
<ha-tile-info slot="info">
|
||||
<span slot="primary">${computeStateName(alert.stateObj)}</span>
|
||||
<span slot="secondary">
|
||||
${stateDisplay} ·
|
||||
<ha-relative-time
|
||||
.datetime=${alert.stateObj.last_changed}
|
||||
></ha-relative-time>
|
||||
</span>
|
||||
</ha-tile-info>
|
||||
</ha-tile-container>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleAction(ev: ActionHandlerEvent): void {
|
||||
const alert = this._visibleAlert;
|
||||
if (ev.detail.action === "tap" && alert) {
|
||||
fireEvent(this, "hass-more-info", { entityId: alert.entityId });
|
||||
}
|
||||
}
|
||||
|
||||
static styles = [
|
||||
tileCardStyle,
|
||||
css`
|
||||
@keyframes pulse-opacity {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: var(--ha-pulse-opacity, 0.3);
|
||||
}
|
||||
}
|
||||
:host {
|
||||
display: block;
|
||||
--ha-security-alert-pulse-duration: 1s;
|
||||
--ha-security-alert-pulse-opacity: 0.3;
|
||||
--ha-security-alert-static-opacity: 0;
|
||||
}
|
||||
ha-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
ha-card:not(.no-color) {
|
||||
--tile-color: var(--ha-security-alert-color);
|
||||
}
|
||||
ha-card.no-color {
|
||||
--tile-color: var(--secondary-text-color);
|
||||
}
|
||||
ha-card::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
|
||||
background-color: var(--ha-security-alert-color);
|
||||
content: "";
|
||||
opacity: var(--ha-security-alert-static-opacity);
|
||||
pointer-events: none;
|
||||
}
|
||||
ha-card.pulse::before {
|
||||
--ha-pulse-opacity: var(--ha-security-alert-pulse-opacity);
|
||||
animation: pulse-opacity var(--ha-security-alert-pulse-duration)
|
||||
ease-in-out infinite alternate;
|
||||
}
|
||||
ha-card:not(.pulse)::before {
|
||||
animation: none;
|
||||
}
|
||||
ha-tile-container {
|
||||
position: relative;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
ha-card::before {
|
||||
animation: none;
|
||||
opacity: var(--ha-security-alert-pulse-opacity);
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-security-alert-card": HuiSecurityAlertCard;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { ContextProvider, consume, type ContextType } from "@lit/context";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { consumeEntityStates } from "../../../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import {
|
||||
configContext,
|
||||
internationalizationContext,
|
||||
} from "../../../../data/context";
|
||||
import {
|
||||
computeSecurityAlertItem,
|
||||
computeSecurityAlertItems,
|
||||
extractSecurityAlertEntityIds,
|
||||
isValidSecurityAlertEntityConfig,
|
||||
type SecurityAlertItem,
|
||||
} from "./helpers";
|
||||
import type { LovelaceCard, LovelaceGridOptions } from "../../types";
|
||||
import type { SecurityAlertsCardConfig } from "../types";
|
||||
import { securityAlertsContext } from "./context";
|
||||
import "./hui-security-alerts-heading";
|
||||
import "./hui-security-alerts-list";
|
||||
|
||||
@customElement("hui-security-alerts-card")
|
||||
export class HuiSecurityAlertsCard extends LitElement implements LovelaceCard {
|
||||
public connectedWhileHidden = true;
|
||||
|
||||
@property({ type: Boolean }) public preview = false;
|
||||
|
||||
private _alertsProvider = new ContextProvider<{
|
||||
__context__: SecurityAlertItem[];
|
||||
}>(this, {
|
||||
context: securityAlertsContext,
|
||||
initialValue: [],
|
||||
});
|
||||
|
||||
@state() private _config?: SecurityAlertsCardConfig;
|
||||
|
||||
@state() private _alertEntityIds?: string[];
|
||||
|
||||
@state()
|
||||
@consumeEntityStates({ entityIdPath: ["_alertEntityIds"] })
|
||||
private _states?: Record<string, HassEntity>;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _hassConfig!: ContextType<typeof configContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
public setConfig(config: SecurityAlertsCardConfig): void {
|
||||
if (
|
||||
!Array.isArray(config.alert_entities) ||
|
||||
config.alert_entities.some(
|
||||
(alertEntity) => !isValidSecurityAlertEntityConfig(alertEntity)
|
||||
)
|
||||
) {
|
||||
throw new Error("Invalid configuration");
|
||||
}
|
||||
this._config = config;
|
||||
this._alertEntityIds = extractSecurityAlertEntityIds(config.alert_entities);
|
||||
}
|
||||
|
||||
public getCardSize(): number {
|
||||
return this._visibleAlerts.length + 1;
|
||||
}
|
||||
|
||||
public getGridOptions(): LovelaceGridOptions {
|
||||
return {
|
||||
columns: 12,
|
||||
rows: "auto",
|
||||
min_columns: 6,
|
||||
min_rows: 1,
|
||||
};
|
||||
}
|
||||
|
||||
private get _visibleAlerts(): SecurityAlertItem[] {
|
||||
const states = this._states;
|
||||
if (!this._config || !this._alertEntityIds?.length || !states) {
|
||||
return [];
|
||||
}
|
||||
if (this.preview) {
|
||||
return this._config.alert_entities
|
||||
.map((alertEntity) => {
|
||||
const stateObj = states[alertEntity.entity];
|
||||
return stateObj
|
||||
? computeSecurityAlertItem(stateObj, alertEntity)
|
||||
: undefined;
|
||||
})
|
||||
.filter((item): item is SecurityAlertItem => Boolean(item));
|
||||
}
|
||||
return computeSecurityAlertItems(
|
||||
{ ...this._hassConfig, ...this._i18n, states },
|
||||
this._config.alert_entities
|
||||
);
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues): void {
|
||||
super.updated(changedProps);
|
||||
|
||||
if (!this._config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const alerts = this._visibleAlerts;
|
||||
this._alertsProvider.setValue(alerts);
|
||||
const shouldBeHidden = !this.preview && alerts.length === 0;
|
||||
|
||||
if (shouldBeHidden !== this.hidden) {
|
||||
this.style.display = shouldBeHidden ? "none" : "";
|
||||
this.toggleAttribute("hidden", shouldBeHidden);
|
||||
fireEvent(this, "card-visibility-changed", { value: !shouldBeHidden });
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._config || this.hidden) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
${
|
||||
this.preview
|
||||
? nothing
|
||||
: html`<hui-security-alerts-heading></hui-security-alerts-heading>`
|
||||
}
|
||||
<hui-security-alerts-list></hui-security-alerts-list>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-security-alerts-card": HuiSecurityAlertsCard;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { consumeLocalize } from "../../../../common/decorators/consume-context-entry";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import type { SecurityAlertItem } from "./helpers";
|
||||
import { securityAlertsContext } from "./context";
|
||||
|
||||
@customElement("hui-security-alerts-heading")
|
||||
export class HuiSecurityAlertsHeading extends LitElement {
|
||||
@state()
|
||||
@consume({ context: securityAlertsContext, subscribe: true })
|
||||
private _alerts: SecurityAlertItem[] = [];
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
protected render() {
|
||||
if (!this._alerts.length) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<h2>${this._localize("ui.card.security-alerts.title")}</h2>`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 24px;
|
||||
padding: 0 var(--ha-space-1);
|
||||
}
|
||||
h2 {
|
||||
color: var(--ha-heading-card-title-color, var(--primary-text-color));
|
||||
font-size: var(--ha-heading-card-title-font-size, var(--ha-font-size-l));
|
||||
font-weight: var(
|
||||
--ha-heading-card-title-font-weight,
|
||||
var(--ha-font-weight-normal)
|
||||
);
|
||||
line-height: var(
|
||||
--ha-heading-card-title-line-height,
|
||||
var(--ha-line-height-normal)
|
||||
);
|
||||
letter-spacing: 0.1px;
|
||||
margin: 0 0 var(--ha-space-2);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-security-alerts-heading": HuiSecurityAlertsHeading;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import type { SecurityAlertItem } from "./helpers";
|
||||
import { securityAlertsContext } from "./context";
|
||||
import "./hui-security-alert-card";
|
||||
|
||||
@customElement("hui-security-alerts-list")
|
||||
export class HuiSecurityAlertsList extends LitElement {
|
||||
@state()
|
||||
@consume({ context: securityAlertsContext, subscribe: true })
|
||||
private _alerts: SecurityAlertItem[] = [];
|
||||
|
||||
protected render() {
|
||||
if (!this._alerts.length) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="alerts">
|
||||
${this._alerts.map(
|
||||
(alert) => html`
|
||||
<hui-security-alert-card .alert=${alert}></hui-security-alert-card>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.alerts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hui-security-alerts-list": HuiSecurityAlertsList;
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,11 @@ import type {
|
||||
LovelaceCardFeaturePosition,
|
||||
} from "../card-features/types";
|
||||
import type { LegacyStateFilter } from "../common/evaluate-filter";
|
||||
import type { Condition, LegacyCondition } from "../common/validate-condition";
|
||||
import type {
|
||||
Condition,
|
||||
LegacyCondition,
|
||||
StateCondition,
|
||||
} from "../common/validate-condition";
|
||||
import type { HuiImage } from "../components/hui-image";
|
||||
import type { LogbookNameDetail } from "../../logbook/logbook-entry-model";
|
||||
import type { TimestampRenderingFormat } from "../components/types";
|
||||
@@ -718,6 +722,25 @@ export interface ShortcutCardConfig extends LovelaceCardConfig {
|
||||
double_tap_action?: ActionConfig;
|
||||
}
|
||||
|
||||
export interface SecurityAlertsCardEntityConfig {
|
||||
entity: string;
|
||||
color?: string;
|
||||
pulse?: boolean;
|
||||
visibility?: StateCondition[];
|
||||
}
|
||||
|
||||
export interface SecurityAlertCardConfig
|
||||
extends
|
||||
Omit<LovelaceCardConfig, "visibility">,
|
||||
SecurityAlertsCardEntityConfig {
|
||||
type: "security-alert";
|
||||
}
|
||||
|
||||
export interface SecurityAlertsCardConfig extends LovelaceCardConfig {
|
||||
type: "security-alerts";
|
||||
alert_entities: SecurityAlertsCardEntityConfig[];
|
||||
}
|
||||
|
||||
export interface ToggleGroupCardConfig extends LovelaceCardConfig {
|
||||
title: string;
|
||||
entities: string[];
|
||||
|
||||
@@ -80,6 +80,10 @@ const LAZY_LOAD_TYPES = {
|
||||
shortcut: () => import("../cards/hui-shortcut-card"),
|
||||
"discovered-devices": () => import("../cards/hui-discovered-devices-card"),
|
||||
repairs: () => import("../cards/hui-repairs-card"),
|
||||
"security-alert": () =>
|
||||
import("../cards/security-alerts/hui-security-alert-card"),
|
||||
"security-alerts": () =>
|
||||
import("../cards/security-alerts/hui-security-alerts-card"),
|
||||
updates: () => import("../cards/hui-updates-card"),
|
||||
gauge: () => import("../cards/hui-gauge-card"),
|
||||
"history-graph": () => import("../cards/hui-history-graph-card"),
|
||||
|
||||
@@ -256,6 +256,9 @@
|
||||
"count_updates": "{count} {count, plural,\n one {update available}\n other {updates available}\n}",
|
||||
"no_updates": "Up to date"
|
||||
},
|
||||
"security-alerts": {
|
||||
"title": "Active alerts"
|
||||
},
|
||||
"media_player": {
|
||||
"source": "Source",
|
||||
"sound_mode": "Sound mode",
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type {
|
||||
SecurityAlertHass,
|
||||
SecurityAlertItem,
|
||||
} from "../../../../../src/panels/lovelace/cards/security-alerts/helpers";
|
||||
import type { SecurityAlertCardConfig } from "../../../../../src/panels/lovelace/cards/types";
|
||||
import "../../../../../src/panels/lovelace/cards/security-alerts/hui-security-alert-card";
|
||||
|
||||
interface TestSecurityAlertCard extends HTMLElement {
|
||||
updateComplete: Promise<boolean>;
|
||||
alert?: SecurityAlertItem;
|
||||
preview: boolean;
|
||||
setConfig(config: SecurityAlertCardConfig): void;
|
||||
getCardSize(): number;
|
||||
getGridOptions(): {
|
||||
columns?: number | "full";
|
||||
rows?: number | "auto";
|
||||
min_columns?: number;
|
||||
min_rows?: number;
|
||||
};
|
||||
_states?: Record<string, HassEntity>;
|
||||
_hassConfig: Pick<SecurityAlertHass, "config" | "user">;
|
||||
_i18n: Pick<SecurityAlertHass, "locale">;
|
||||
_formatters: {
|
||||
formatEntityState: (stateObj: HassEntity) => string;
|
||||
};
|
||||
}
|
||||
|
||||
const state = (value = "on"): HassEntity => ({
|
||||
entity_id: "binary_sensor.window",
|
||||
state: value,
|
||||
attributes: {
|
||||
device_class: "window",
|
||||
friendly_name: "Window",
|
||||
},
|
||||
last_changed: "2026-01-01T00:00:00Z",
|
||||
last_updated: "2026-01-01T00:00:00Z",
|
||||
context: { id: "", parent_id: null, user_id: null },
|
||||
});
|
||||
|
||||
const alert = (pulse: boolean, color?: string): SecurityAlertItem => {
|
||||
const stateObj = state();
|
||||
return {
|
||||
entityId: stateObj.entity_id,
|
||||
stateObj,
|
||||
pulse,
|
||||
color,
|
||||
};
|
||||
};
|
||||
|
||||
const createCard = async (item: SecurityAlertItem) => {
|
||||
const element = document.createElement(
|
||||
"hui-security-alert-card"
|
||||
) as unknown as TestSecurityAlertCard;
|
||||
element.alert = item;
|
||||
element._formatters = { formatEntityState: () => "On" };
|
||||
document.body.appendChild(element);
|
||||
await element.updateComplete;
|
||||
return element;
|
||||
};
|
||||
|
||||
const createConfiguredCard = async (
|
||||
config: SecurityAlertCardConfig,
|
||||
stateObj = state(),
|
||||
preview = false
|
||||
) => {
|
||||
const element = document.createElement(
|
||||
"hui-security-alert-card"
|
||||
) as unknown as TestSecurityAlertCard;
|
||||
element.setConfig(config);
|
||||
element.preview = preview;
|
||||
element._states = { [stateObj.entity_id]: stateObj };
|
||||
element._hassConfig = {
|
||||
config: { time_zone: "UTC" } as SecurityAlertHass["config"],
|
||||
user: undefined,
|
||||
};
|
||||
element._i18n = {
|
||||
locale: { time_zone: "server" } as SecurityAlertHass["locale"],
|
||||
};
|
||||
element._formatters = { formatEntityState: () => "On" };
|
||||
document.body.appendChild(element);
|
||||
await element.updateComplete;
|
||||
await element.updateComplete;
|
||||
return element;
|
||||
};
|
||||
|
||||
const config = (
|
||||
overrides: Partial<SecurityAlertCardConfig> = {}
|
||||
): SecurityAlertCardConfig => ({
|
||||
type: "security-alert",
|
||||
entity: "binary_sensor.window",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("hui-security-alert-card", () => {
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it("validates manual state-only configuration", () => {
|
||||
const element = document.createElement("hui-security-alert-card");
|
||||
|
||||
expect(() =>
|
||||
element.setConfig(
|
||||
config({
|
||||
visibility: [
|
||||
{
|
||||
condition: "state",
|
||||
entity: "binary_sensor.window",
|
||||
state: "on",
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
element.setConfig({
|
||||
type: "security-alert",
|
||||
entity: "binary_sensor.window",
|
||||
visibility: [{ condition: "screen", media_query: "(min-width: 1px)" }],
|
||||
} as unknown as SecurityAlertCardConfig)
|
||||
).toThrow("Invalid configuration");
|
||||
expect(() =>
|
||||
element.setConfig({
|
||||
type: "security-alert",
|
||||
entity: "binary_sensor.window",
|
||||
visibility: "on",
|
||||
} as unknown as SecurityAlertCardConfig)
|
||||
).toThrow("Invalid configuration");
|
||||
});
|
||||
|
||||
it("renders from manual configuration", async () => {
|
||||
const element = await createConfiguredCard(
|
||||
config({ color: "amber", pulse: false })
|
||||
);
|
||||
const card = element.shadowRoot!.querySelector("ha-card")!;
|
||||
|
||||
expect(card.classList.contains("pulse")).toBe(false);
|
||||
expect(card.style.getPropertyValue("--ha-security-alert-color")).toBe(
|
||||
"var(--amber-color)"
|
||||
);
|
||||
expect(element.getCardSize()).toBe(1);
|
||||
expect(element.getGridOptions()).toEqual({
|
||||
columns: 6,
|
||||
rows: 1,
|
||||
min_columns: 6,
|
||||
min_rows: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("hides when manual visibility does not match", async () => {
|
||||
const element = document.createElement(
|
||||
"hui-security-alert-card"
|
||||
) as unknown as TestSecurityAlertCard;
|
||||
let visible: boolean | undefined;
|
||||
element.addEventListener("card-visibility-changed", (ev) => {
|
||||
visible = ev.detail.value;
|
||||
});
|
||||
element.setConfig(config());
|
||||
element._states = { "binary_sensor.window": state("off") };
|
||||
element._hassConfig = {
|
||||
config: { time_zone: "UTC" } as SecurityAlertHass["config"],
|
||||
user: undefined,
|
||||
};
|
||||
element._i18n = {
|
||||
locale: { time_zone: "server" } as SecurityAlertHass["locale"],
|
||||
};
|
||||
element._formatters = { formatEntityState: () => "Off" };
|
||||
document.body.appendChild(element);
|
||||
await element.updateComplete;
|
||||
await element.updateComplete;
|
||||
|
||||
expect(element.hidden).toBe(true);
|
||||
expect(element.shadowRoot!.querySelector("ha-card")).toBeNull();
|
||||
expect(visible).toBe(false);
|
||||
});
|
||||
|
||||
it("renders non-matching manual configuration in preview", async () => {
|
||||
const element = await createConfiguredCard(config(), state("off"), true);
|
||||
|
||||
expect(element.hidden).toBe(false);
|
||||
expect(element.shadowRoot!.querySelector("ha-card")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does not pulse when pulse is disabled", async () => {
|
||||
const element = await createCard(alert(false));
|
||||
const card = element.shadowRoot!.querySelector("ha-card")!;
|
||||
|
||||
expect(card.classList.contains("pulse")).toBe(false);
|
||||
expect(
|
||||
card.style.getPropertyValue("--ha-security-alert-static-opacity")
|
||||
).toBe("var(--ha-security-alert-pulse-opacity)");
|
||||
});
|
||||
|
||||
it("pulses when pulse is enabled", async () => {
|
||||
const element = await createCard(alert(true));
|
||||
const card = element.shadowRoot!.querySelector("ha-card")!;
|
||||
|
||||
expect(card.classList.contains("pulse")).toBe(true);
|
||||
});
|
||||
|
||||
it("applies configured colors", async () => {
|
||||
const element = await createCard(alert(true, "amber"));
|
||||
const card = element.shadowRoot!.querySelector("ha-card")!;
|
||||
|
||||
expect(card.classList.contains("warning")).toBe(false);
|
||||
expect(card.classList.contains("no-color")).toBe(false);
|
||||
expect(card.style.getPropertyValue("--ha-security-alert-color")).toBe(
|
||||
"var(--amber-color)"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not apply a color when color is none", async () => {
|
||||
const element = await createCard(alert(true, "none"));
|
||||
const card = element.shadowRoot!.querySelector("ha-card")!;
|
||||
|
||||
expect(card.classList.contains("warning")).toBe(false);
|
||||
expect(card.classList.contains("no-color")).toBe(true);
|
||||
expect(card.style.getPropertyValue("--ha-security-alert-color")).toBe("");
|
||||
});
|
||||
|
||||
it("opens more info on tap", async () => {
|
||||
const element = await createCard(alert(true));
|
||||
let entityId: string | undefined;
|
||||
element.addEventListener("hass-more-info", (ev) => {
|
||||
entityId = ev.detail.entityId;
|
||||
});
|
||||
|
||||
element.shadowRoot!.querySelector("ha-tile-container")!.dispatchEvent(
|
||||
new CustomEvent("action", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: { action: "tap" },
|
||||
})
|
||||
);
|
||||
|
||||
expect(entityId).toBe("binary_sensor.window");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ContextProvider } from "@lit/context";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { securityAlertsContext } from "../../../../../src/panels/lovelace/cards/security-alerts/context";
|
||||
import type { SecurityAlertItem } from "../../../../../src/panels/lovelace/cards/security-alerts/helpers";
|
||||
import "../../../../../src/panels/lovelace/cards/security-alerts/hui-security-alerts-list";
|
||||
|
||||
interface TestSecurityAlertsList extends HTMLElement {
|
||||
updateComplete: Promise<boolean>;
|
||||
}
|
||||
|
||||
interface TestSecurityAlertCard extends HTMLElement {
|
||||
updateComplete: Promise<boolean>;
|
||||
alert: SecurityAlertItem;
|
||||
_formatters: {
|
||||
formatEntityState: () => string;
|
||||
};
|
||||
}
|
||||
|
||||
const state = () => ({
|
||||
entity_id: "binary_sensor.window",
|
||||
state: "on",
|
||||
attributes: {
|
||||
device_class: "window",
|
||||
friendly_name: "Window",
|
||||
},
|
||||
last_changed: "2026-01-01T00:00:00Z",
|
||||
last_updated: "2026-01-01T00:00:00Z",
|
||||
context: { id: "", parent_id: null, user_id: null },
|
||||
});
|
||||
|
||||
const alert = (pulse: boolean, color?: string): SecurityAlertItem => {
|
||||
const stateObj = state();
|
||||
return {
|
||||
entityId: stateObj.entity_id,
|
||||
stateObj,
|
||||
pulse,
|
||||
color,
|
||||
};
|
||||
};
|
||||
|
||||
const createList = async (alerts: SecurityAlertItem[]) => {
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const provider = new ContextProvider(host, {
|
||||
context: securityAlertsContext,
|
||||
initialValue: alerts,
|
||||
});
|
||||
const element = document.createElement(
|
||||
"hui-security-alerts-list"
|
||||
) as unknown as TestSecurityAlertsList;
|
||||
host.appendChild(element);
|
||||
await element.updateComplete;
|
||||
return { element, provider };
|
||||
};
|
||||
|
||||
describe("hui-security-alerts-list", () => {
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it("renders standalone cards from alert context", async () => {
|
||||
const alerts = [alert(true, "amber"), alert(false, "none")];
|
||||
const { element, provider } = await createList(alerts);
|
||||
let cards = element.shadowRoot!.querySelectorAll(
|
||||
"hui-security-alert-card"
|
||||
) as unknown as NodeListOf<TestSecurityAlertCard>;
|
||||
cards.forEach((card) => {
|
||||
card._formatters = { formatEntityState: () => "On" };
|
||||
});
|
||||
await Promise.all(Array.from(cards, (card) => card.updateComplete));
|
||||
|
||||
expect(cards).toHaveLength(2);
|
||||
expect(cards[0].alert).toBe(alerts[0]);
|
||||
expect(cards[1].alert).toBe(alerts[1]);
|
||||
|
||||
const replacementAlert = alert(true, "red");
|
||||
provider.setValue([replacementAlert]);
|
||||
await element.updateComplete;
|
||||
cards = element.shadowRoot!.querySelectorAll(
|
||||
"hui-security-alert-card"
|
||||
) as unknown as NodeListOf<TestSecurityAlertCard>;
|
||||
expect(cards).toHaveLength(1);
|
||||
expect(cards[0].alert).toBe(replacementAlert);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeDefaultSecurityAlertVisibility,
|
||||
computeSecurityAlertEntityDefaultColor,
|
||||
computeSecurityAlertItems,
|
||||
type SecurityAlertHass,
|
||||
} from "../../../../../src/panels/lovelace/cards/security-alerts/helpers";
|
||||
import type { SecurityAlertsCardConfig } from "../../../../../src/panels/lovelace/cards/types";
|
||||
import "../../../../../src/panels/lovelace/cards/security-alerts/hui-security-alerts-card";
|
||||
|
||||
const state = (
|
||||
entityId: string,
|
||||
value: string,
|
||||
deviceClass: string | undefined,
|
||||
lastChanged: string
|
||||
): HassEntity => ({
|
||||
entity_id: entityId,
|
||||
state: value,
|
||||
attributes: {
|
||||
...(deviceClass ? { device_class: deviceClass } : {}),
|
||||
friendly_name: entityId,
|
||||
},
|
||||
last_changed: lastChanged,
|
||||
last_updated: lastChanged,
|
||||
context: { id: "", parent_id: null, user_id: null },
|
||||
});
|
||||
|
||||
const hass = (states: Record<string, HassEntity>): SecurityAlertHass => ({
|
||||
states,
|
||||
user: undefined,
|
||||
config: {
|
||||
time_zone: "UTC",
|
||||
} as SecurityAlertHass["config"],
|
||||
locale: {
|
||||
time_zone: "server",
|
||||
} as SecurityAlertHass["locale"],
|
||||
});
|
||||
|
||||
describe("computeDefaultSecurityAlertVisibility", () => {
|
||||
it("defaults alarm panels to triggered", () => {
|
||||
expect(
|
||||
computeDefaultSecurityAlertVisibility("alarm_control_panel.house")
|
||||
).toEqual([
|
||||
{
|
||||
condition: "state",
|
||||
entity: "alarm_control_panel.house",
|
||||
state: "triggered",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("defaults binary sensors to on", () => {
|
||||
expect(computeDefaultSecurityAlertVisibility("binary_sensor.leak")).toEqual(
|
||||
[
|
||||
{
|
||||
condition: "state",
|
||||
entity: "binary_sensor.leak",
|
||||
state: "on",
|
||||
},
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults locks and covers to any unsecured state", () => {
|
||||
expect(computeDefaultSecurityAlertVisibility("lock.front_door")).toEqual([
|
||||
{
|
||||
condition: "state",
|
||||
entity: "lock.front_door",
|
||||
state_not: "locked",
|
||||
},
|
||||
]);
|
||||
expect(computeDefaultSecurityAlertVisibility("cover.garage_door")).toEqual([
|
||||
{
|
||||
condition: "state",
|
||||
entity: "cover.garage_door",
|
||||
state_not: "closed",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("defaults cameras to unavailable or unknown", () => {
|
||||
expect(computeDefaultSecurityAlertVisibility("camera.patio")).toEqual([
|
||||
{
|
||||
condition: "state",
|
||||
entity: "camera.patio",
|
||||
state: ["unavailable", "unknown"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hui-security-alerts-card", () => {
|
||||
const createCard = () => document.createElement("hui-security-alerts-card");
|
||||
|
||||
it("rejects a non-array alert entity configuration", () => {
|
||||
const card = createCard();
|
||||
|
||||
expect(() =>
|
||||
card.setConfig({
|
||||
type: "security-alerts",
|
||||
alert_entities: "binary_sensor.window",
|
||||
} as unknown as SecurityAlertsCardConfig)
|
||||
).toThrow("Invalid configuration");
|
||||
});
|
||||
|
||||
it("rejects unsupported visibility conditions", () => {
|
||||
const card = createCard();
|
||||
|
||||
expect(() =>
|
||||
card.setConfig({
|
||||
type: "security-alerts",
|
||||
alert_entities: [
|
||||
{
|
||||
entity: "binary_sensor.window",
|
||||
visibility: [
|
||||
{ condition: "screen", media_query: "(min-width: 1px)" },
|
||||
],
|
||||
},
|
||||
],
|
||||
} as unknown as SecurityAlertsCardConfig)
|
||||
).toThrow("Invalid configuration");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ entity: "" },
|
||||
{ entity: "window" },
|
||||
{ entity: "binary_sensor.window", color: 123 },
|
||||
{ entity: "binary_sensor.window", pulse: "true" },
|
||||
])("rejects invalid alert entity configuration %#", (alertEntity) => {
|
||||
const card = createCard();
|
||||
|
||||
expect(() =>
|
||||
card.setConfig({
|
||||
type: "security-alerts",
|
||||
alert_entities: [alertEntity],
|
||||
} as unknown as SecurityAlertsCardConfig)
|
||||
).toThrow("Invalid configuration");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeSecurityAlertEntityDefaultColor", () => {
|
||||
it("uses device class defaults independent of current state", () => {
|
||||
expect(
|
||||
computeSecurityAlertEntityDefaultColor(
|
||||
state(
|
||||
"binary_sensor.carbon_monoxide",
|
||||
"unavailable",
|
||||
"carbon_monoxide",
|
||||
"2026-01-01T00:00:00Z"
|
||||
)
|
||||
)
|
||||
).toBe("red");
|
||||
expect(
|
||||
computeSecurityAlertEntityDefaultColor(
|
||||
state(
|
||||
"binary_sensor.window",
|
||||
"unavailable",
|
||||
"window",
|
||||
"2026-01-01T00:00:00Z"
|
||||
)
|
||||
)
|
||||
).toBe("amber");
|
||||
expect(
|
||||
computeSecurityAlertEntityDefaultColor(
|
||||
state("camera.patio", "unavailable", undefined, "2026-01-01T00:00:00Z")
|
||||
)
|
||||
).toBe("blue");
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeSecurityAlertItems", () => {
|
||||
it("does not infer alerts without configured rows", () => {
|
||||
const states = {
|
||||
"binary_sensor.dishwasher_leak": state(
|
||||
"binary_sensor.dishwasher_leak",
|
||||
"on",
|
||||
"moisture",
|
||||
"2026-01-01T00:00:00Z"
|
||||
),
|
||||
};
|
||||
|
||||
expect(computeSecurityAlertItems(hass(states), [])).toEqual([]);
|
||||
});
|
||||
|
||||
it("shows configured entities when their default visibility matches", () => {
|
||||
const states = {
|
||||
"binary_sensor.dishwasher_leak": state(
|
||||
"binary_sensor.dishwasher_leak",
|
||||
"on",
|
||||
"moisture",
|
||||
"2026-01-01T00:00:00Z"
|
||||
),
|
||||
};
|
||||
|
||||
expect(
|
||||
computeSecurityAlertItems(hass(states), [
|
||||
{ entity: "binary_sensor.dishwasher_leak" },
|
||||
]).map((item) => item.entityId)
|
||||
).toEqual(["binary_sensor.dishwasher_leak"]);
|
||||
});
|
||||
|
||||
it("shows carbon monoxide sensors when active", () => {
|
||||
const states = {
|
||||
"binary_sensor.carbon_monoxide": state(
|
||||
"binary_sensor.carbon_monoxide",
|
||||
"on",
|
||||
"carbon_monoxide",
|
||||
"2026-01-01T00:00:00Z"
|
||||
),
|
||||
};
|
||||
|
||||
expect(
|
||||
computeSecurityAlertItems(hass(states), [
|
||||
{ entity: "binary_sensor.carbon_monoxide" },
|
||||
]).map((item) => item.entityId)
|
||||
).toEqual(["binary_sensor.carbon_monoxide"]);
|
||||
});
|
||||
|
||||
it("hides configured entities when their default visibility does not match", () => {
|
||||
const states = {
|
||||
"binary_sensor.dishwasher_leak": state(
|
||||
"binary_sensor.dishwasher_leak",
|
||||
"off",
|
||||
"moisture",
|
||||
"2026-01-01T00:00:00Z"
|
||||
),
|
||||
};
|
||||
|
||||
expect(
|
||||
computeSecurityAlertItems(hass(states), [
|
||||
{ entity: "binary_sensor.dishwasher_leak" },
|
||||
])
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses custom visibility conditions", () => {
|
||||
const states = {
|
||||
"lock.front_door": state(
|
||||
"lock.front_door",
|
||||
"unlocked",
|
||||
undefined,
|
||||
"2026-01-01T00:00:00Z"
|
||||
),
|
||||
};
|
||||
|
||||
expect(
|
||||
computeSecurityAlertItems(hass(states), [
|
||||
{
|
||||
entity: "lock.front_door",
|
||||
visibility: [
|
||||
{
|
||||
condition: "state",
|
||||
entity: "lock.front_door",
|
||||
state: "unlocked",
|
||||
},
|
||||
],
|
||||
},
|
||||
]).map((item) => item.entityId)
|
||||
).toEqual(["lock.front_door"]);
|
||||
});
|
||||
|
||||
it("applies configured color and pulse", () => {
|
||||
const states = {
|
||||
"binary_sensor.window": state(
|
||||
"binary_sensor.window",
|
||||
"on",
|
||||
"window",
|
||||
"2026-01-01T00:00:00Z"
|
||||
),
|
||||
};
|
||||
|
||||
expect(
|
||||
computeSecurityAlertItems(hass(states), [
|
||||
{
|
||||
entity: "binary_sensor.window",
|
||||
color: "red",
|
||||
pulse: false,
|
||||
},
|
||||
])[0]
|
||||
).toMatchObject({ color: "red", pulse: false });
|
||||
});
|
||||
|
||||
it("uses the entity default color when color is not configured", () => {
|
||||
const states = {
|
||||
"binary_sensor.window": state(
|
||||
"binary_sensor.window",
|
||||
"on",
|
||||
"window",
|
||||
"2026-01-01T00:00:00Z"
|
||||
),
|
||||
};
|
||||
|
||||
expect(
|
||||
computeSecurityAlertItems(hass(states), [
|
||||
{ entity: "binary_sensor.window" },
|
||||
])[0]
|
||||
).toMatchObject({ color: "amber" });
|
||||
});
|
||||
|
||||
it("keeps no color as an explicit color choice", () => {
|
||||
const states = {
|
||||
"binary_sensor.window": state(
|
||||
"binary_sensor.window",
|
||||
"on",
|
||||
"window",
|
||||
"2026-01-01T00:00:00Z"
|
||||
),
|
||||
};
|
||||
|
||||
expect(
|
||||
computeSecurityAlertItems(hass(states), [
|
||||
{ entity: "binary_sensor.window", color: "none" },
|
||||
])[0]
|
||||
).toMatchObject({ color: "none" });
|
||||
});
|
||||
|
||||
it("keeps configured order", () => {
|
||||
const states = {
|
||||
"binary_sensor.window": state(
|
||||
"binary_sensor.window",
|
||||
"on",
|
||||
"window",
|
||||
"2026-01-01T00:02:00Z"
|
||||
),
|
||||
"binary_sensor.leak": state(
|
||||
"binary_sensor.leak",
|
||||
"on",
|
||||
"moisture",
|
||||
"2026-01-01T00:01:00Z"
|
||||
),
|
||||
};
|
||||
|
||||
expect(
|
||||
computeSecurityAlertItems(hass(states), [
|
||||
{ entity: "binary_sensor.window" },
|
||||
{ entity: "binary_sensor.leak" },
|
||||
]).map((item) => item.entityId)
|
||||
).toEqual(["binary_sensor.window", "binary_sensor.leak"]);
|
||||
});
|
||||
});
|
||||
@@ -4697,22 +4697,22 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/client@npm:1.6.3":
|
||||
version: 1.6.3
|
||||
resolution: "@rsdoctor/client@npm:1.6.3"
|
||||
checksum: 10/407d2509dc829e8f89aada7690b63c98a1d18ecffd3e23740be5f506a043cbc16d75e1f0a80f1299b60ca07d6151b7fae848568dd53b71e211dcefc163f55dd5
|
||||
"@rsdoctor/client@npm:1.6.2":
|
||||
version: 1.6.2
|
||||
resolution: "@rsdoctor/client@npm:1.6.2"
|
||||
checksum: 10/7b57cf3a9c6e3dc1b64b0f07969330579fd0e84963843cb3517b7c1160bc9b2fa08e709eaf835139d8a7e8c32977af8aae050d100df80daed44371ced8272642
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/core@npm:1.6.3":
|
||||
version: 1.6.3
|
||||
resolution: "@rsdoctor/core@npm:1.6.3"
|
||||
"@rsdoctor/core@npm:1.6.2":
|
||||
version: 1.6.2
|
||||
resolution: "@rsdoctor/core@npm:1.6.2"
|
||||
dependencies:
|
||||
"@rsbuild/plugin-check-syntax": "npm:^1.6.1"
|
||||
"@rsdoctor/graph": "npm:1.6.3"
|
||||
"@rsdoctor/sdk": "npm:1.6.3"
|
||||
"@rsdoctor/types": "npm:1.6.3"
|
||||
"@rsdoctor/utils": "npm:1.6.3"
|
||||
"@rsdoctor/graph": "npm:1.6.2"
|
||||
"@rsdoctor/sdk": "npm:1.6.2"
|
||||
"@rsdoctor/types": "npm:1.6.2"
|
||||
"@rsdoctor/utils": "npm:1.6.2"
|
||||
"@rspack/resolver": "npm:^0.2.8"
|
||||
browserslist-load-config: "npm:^1.0.2"
|
||||
es-toolkit: "npm:^1.49.0"
|
||||
@@ -4720,60 +4720,60 @@ __metadata:
|
||||
fs-extra: "npm:^11.1.1"
|
||||
semver: "npm:^7.8.5"
|
||||
source-map: "npm:^0.7.6"
|
||||
checksum: 10/89da82b0cf60be5560c5f0b9ac2fd8695ca2d53b0bed698202ef12182b507c8021989e4a118f27a36d6791f1e0998e9d58e44f5f564cfe03b33e4b0288324780
|
||||
checksum: 10/803bb41042b2af07b096f2dde6383d516bb22b62705aeb9693959788e8a63df80e6d25514f1d92639a267e46e73732689abf5ba2c25679fbd80da9dd126f7ed9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/graph@npm:1.6.3":
|
||||
version: 1.6.3
|
||||
resolution: "@rsdoctor/graph@npm:1.6.3"
|
||||
"@rsdoctor/graph@npm:1.6.2":
|
||||
version: 1.6.2
|
||||
resolution: "@rsdoctor/graph@npm:1.6.2"
|
||||
dependencies:
|
||||
"@rsdoctor/types": "npm:1.6.3"
|
||||
"@rsdoctor/utils": "npm:1.6.3"
|
||||
"@rsdoctor/types": "npm:1.6.2"
|
||||
"@rsdoctor/utils": "npm:1.6.2"
|
||||
es-toolkit: "npm:^1.49.0"
|
||||
path-browserify: "npm:1.0.1"
|
||||
source-map: "npm:^0.7.6"
|
||||
checksum: 10/b91aed0faed7771d74cd27015ee0e3c1bf81b8783d701b867328945f1533d90ac4590d0aa5da06b56126fa7caaaa0ea0dbbf66dcec2d64a1da98b436dab3f0dd
|
||||
checksum: 10/f28aaedc529a6cff782f1813faa6d19d10f76ab8f529bbe2899385b5659d247f0543149e2fcd700264a00e9568605a85fde04cbf634e821f24e9bf239eb1b009
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/rspack-plugin@npm:1.6.3":
|
||||
version: 1.6.3
|
||||
resolution: "@rsdoctor/rspack-plugin@npm:1.6.3"
|
||||
"@rsdoctor/rspack-plugin@npm:1.6.2":
|
||||
version: 1.6.2
|
||||
resolution: "@rsdoctor/rspack-plugin@npm:1.6.2"
|
||||
dependencies:
|
||||
"@rsdoctor/core": "npm:1.6.3"
|
||||
"@rsdoctor/graph": "npm:1.6.3"
|
||||
"@rsdoctor/sdk": "npm:1.6.3"
|
||||
"@rsdoctor/types": "npm:1.6.3"
|
||||
"@rsdoctor/utils": "npm:1.6.3"
|
||||
"@rsdoctor/core": "npm:1.6.2"
|
||||
"@rsdoctor/graph": "npm:1.6.2"
|
||||
"@rsdoctor/sdk": "npm:1.6.2"
|
||||
"@rsdoctor/types": "npm:1.6.2"
|
||||
"@rsdoctor/utils": "npm:1.6.2"
|
||||
peerDependencies:
|
||||
"@rspack/core": "*"
|
||||
peerDependenciesMeta:
|
||||
"@rspack/core":
|
||||
optional: true
|
||||
checksum: 10/3b1cc8611ba2e2047a1ac8143aa8e5749e6a19fdb021dfa33ea06ad9a7ac78cb4a025baacce3a6d659107ee612d34c7bbabfeba15784003e0efa376f4dc00071
|
||||
checksum: 10/dec9f0424d14ec7ed03cd6cbc5e0b11cd74c29be12c3f79466bf8da0f317a4bdba858a58daf64e4a7c16d20a1d58433b1d2c038b62ff771c1d045bca3d71c3ad
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/sdk@npm:1.6.3":
|
||||
version: 1.6.3
|
||||
resolution: "@rsdoctor/sdk@npm:1.6.3"
|
||||
"@rsdoctor/sdk@npm:1.6.2":
|
||||
version: 1.6.2
|
||||
resolution: "@rsdoctor/sdk@npm:1.6.2"
|
||||
dependencies:
|
||||
"@rsdoctor/client": "npm:1.6.3"
|
||||
"@rsdoctor/graph": "npm:1.6.3"
|
||||
"@rsdoctor/types": "npm:1.6.3"
|
||||
"@rsdoctor/utils": "npm:1.6.3"
|
||||
"@rsdoctor/client": "npm:1.6.2"
|
||||
"@rsdoctor/graph": "npm:1.6.2"
|
||||
"@rsdoctor/types": "npm:1.6.2"
|
||||
"@rsdoctor/utils": "npm:1.6.2"
|
||||
launch-editor: "npm:^2.13.2"
|
||||
safer-buffer: "npm:2.1.2"
|
||||
socket.io: "npm:4.8.1"
|
||||
tapable: "npm:2.3.3"
|
||||
checksum: 10/9363930b637869262c93913a0bc90e76c7662cebf869778300b6184e82fe41ca3af8b065c774327557b22f7e24a3ed517ff1db437f161297c11d03894e25c930
|
||||
checksum: 10/89d5850145b490bbf3f43486176ebdd1f9eb67cdbe96a07549bb0b400ab76d85f3b6d5667be7cf8304434281f0db74fbaaab3360228eb1650f0a8ce640101208
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/types@npm:1.6.3":
|
||||
version: 1.6.3
|
||||
resolution: "@rsdoctor/types@npm:1.6.3"
|
||||
"@rsdoctor/types@npm:1.6.2":
|
||||
version: 1.6.2
|
||||
resolution: "@rsdoctor/types@npm:1.6.2"
|
||||
dependencies:
|
||||
"@types/connect": "npm:3.4.38"
|
||||
"@types/estree": "npm:1.0.5"
|
||||
@@ -4787,16 +4787,16 @@ __metadata:
|
||||
optional: true
|
||||
webpack:
|
||||
optional: true
|
||||
checksum: 10/b3ee6089b671ac0f2dc656f06a03ef475e982f44b34faa664bd7f9121a6ec7609d35cd814f093b7bf3326c8fce64998542fca0645fc4c0ad7e3c136c23e90a0d
|
||||
checksum: 10/39328739b31f7df46b663a9f6b5ac8c1e290c6b7ce0f68d9b45a423b89a1a296d32fbee0c26f555fc40d60b356a3605909f7bbe19c77d9c6bca3603d6928aef2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@rsdoctor/utils@npm:1.6.3":
|
||||
version: 1.6.3
|
||||
resolution: "@rsdoctor/utils@npm:1.6.3"
|
||||
"@rsdoctor/utils@npm:1.6.2":
|
||||
version: 1.6.2
|
||||
resolution: "@rsdoctor/utils@npm:1.6.2"
|
||||
dependencies:
|
||||
"@babel/code-frame": "npm:7.26.2"
|
||||
"@rsdoctor/types": "npm:1.6.3"
|
||||
"@rsdoctor/types": "npm:1.6.2"
|
||||
"@types/estree": "npm:1.0.5"
|
||||
acorn: "npm:^8.10.0"
|
||||
acorn-import-attributes: "npm:^1.9.5"
|
||||
@@ -4810,7 +4810,7 @@ __metadata:
|
||||
picocolors: "npm:^1.1.1"
|
||||
rslog: "npm:^2.1.2"
|
||||
strip-ansi: "npm:^7.2.0"
|
||||
checksum: 10/06b18159d64809e9c764371a00e284d344c2912b17e6eebe71bd04fe6c6e5bc05bd4bd7dd5860351f3c9c434b96ff1acbaa7d0be02f0b44f6fed42ddb18748ae
|
||||
checksum: 10/fb6a147e452756018e93789e07dbe015354fc2690699755ef411748a393c7db86fdff2614cdf9ce07822434abcac3eef362aea8c63cbca9fe3f96c958fde56dc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -8970,9 +8970,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"eslint@npm:10.9.0":
|
||||
version: 10.9.0
|
||||
resolution: "eslint@npm:10.9.0"
|
||||
"eslint@npm:10.8.1":
|
||||
version: 10.8.1
|
||||
resolution: "eslint@npm:10.8.1"
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils": "npm:^4.8.0"
|
||||
"@eslint-community/regexpp": "npm:^4.12.2"
|
||||
@@ -9011,7 +9011,7 @@ __metadata:
|
||||
optional: true
|
||||
bin:
|
||||
eslint: bin/eslint.js
|
||||
checksum: 10/1cd63bbf4f2c003ed759ab6ae9e2030be0abafdcd7b9ffb5be9e9c3aa3ea6142e6af0a337aac21044957750807fde06c9d052e2c9734cca6792dc2d095f9c180
|
||||
checksum: 10/7174710e740f95bfa97d7501419d717c03cf7760c3eea4a91cd10fb804538bbd8cdc0849b26afa789a56a89ee838b727e4b9329e347376f96b8e524727b90ba3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -10024,7 +10024,7 @@ __metadata:
|
||||
"@octokit/rest": "npm:22.0.1"
|
||||
"@playwright/test": "npm:1.62.1"
|
||||
"@replit/codemirror-indentation-markers": "npm:6.5.3"
|
||||
"@rsdoctor/rspack-plugin": "npm:1.6.3"
|
||||
"@rsdoctor/rspack-plugin": "npm:1.6.2"
|
||||
"@rspack/core": "npm:2.1.10"
|
||||
"@rspack/dev-server": "npm:2.2.0"
|
||||
"@swc/helpers": "npm:0.5.23"
|
||||
@@ -10070,7 +10070,7 @@ __metadata:
|
||||
echarts: "npm:6.1.0"
|
||||
echarts-extension-chart2music: "npm:0.1.1"
|
||||
element-internals-polyfill: "npm:3.0.2"
|
||||
eslint: "npm:10.9.0"
|
||||
eslint: "npm:10.8.1"
|
||||
eslint-config-prettier: "npm:10.1.8"
|
||||
eslint-import-resolver-webpack: "npm:0.13.11"
|
||||
eslint-plugin-import-x: "npm:4.17.1"
|
||||
|
||||
Reference in New Issue
Block a user