Add active alerts to Security dashboard (#53031)

* Add security dashboard alerts

* Fix security alerts editor and card defaults

* Use consistent security editor icons

* Fix security dashboard alert state

* Fix lint

* Render security alerts as individual cards

* Use context data in security alerts editor

* Add pulse for alerts

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>

* Use explicit cover and lock alert states

* Fix test

* Remove security panel rendering tests

* Use complete states

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>

* Remove camera

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>

* open state only

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>

* Remove security entity filter cache

* Format

* Update security alert visibility tests

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
This commit is contained in:
Aidan Timson authored and GitHub committed 2026-08-25 17:28:46 +02:00
1 parent 872205c352
commit d0371e506f
12 files changed
+982 -39

No files matched your search

+4 -1
View File
@@ -33,7 +33,10 @@ const normalizeFilterArray = <T>(
};
export const generateEntityFilter = (
hass: HomeAssistant,
hass: Pick<
HomeAssistant,
"states" | "entities" | "devices" | "areas" | "floors"
>,
filter: EntityFilter
): EntityFilterFunc => {
const domains = filter.domain
+12
View File
@@ -35,6 +35,17 @@ export interface HomeFrontendSystemData {
shortcuts?: ShortcutItem[];
}
export type SecurityAlertSeverity = "alert" | "warning";
export interface SecurityAlertEntityConfig {
entity: string;
severity?: SecurityAlertSeverity;
}
export interface SecurityFrontendSystemData {
alert_entities?: SecurityAlertEntityConfig[];
}
export interface EnergyFrontendSystemData {
// Stable "<view>.<card-type>" keys of energy dashboard cards the user has
// hidden. An absent key or array means nothing is hidden (all cards visible),
@@ -51,6 +62,7 @@ declare global {
core: CoreFrontendSystemData;
home: HomeFrontendSystemData;
energy: EnergyFrontendSystemData;
security: SecurityFrontendSystemData;
}
}
@@ -0,0 +1,285 @@
import { consume, type ContextType } from "@lit/context";
import { mdiDelete, mdiDragHorizontalVariant } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { computeEntityPickerDisplay } from "../../../common/entity/compute_entity_name_display";
import {
fireEvent,
type HASSDomCurrentTargetEvent,
type HASSDomEvent,
} from "../../../common/dom/fire_event";
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import type {
SecurityAlertEntityConfig,
SecurityAlertSeverity,
} from "../../../data/frontend";
import {
internationalizationContext,
registriesContext,
statesContext,
} from "../../../data/context";
import "../../../components/entity/ha-entity-picker";
import "../../../components/entity/state-badge";
import "../../../components/ha-control-select-menu";
import "../../../components/ha-icon-button";
import "../../../components/ha-settings-row";
import "../../../components/ha-sortable";
import "../../../components/ha-svg-icon";
import { computeDefaultSecurityAlertSeverity } from "../strategies/security-alerts";
import { isSecurityPanelEntity } from "../strategies/security-view-strategy";
import type { ValueChangedEvent } from "../../../types";
type SecurityEditorContext = ContextType<typeof registriesContext> & {
states: ContextType<typeof statesContext>;
language: ContextType<typeof internationalizationContext>["language"];
translationMetadata: ContextType<
typeof internationalizationContext
>["translationMetadata"];
};
@customElement("security-alerts-editor")
export class SecurityAlertsEditor extends LitElement {
@property({ attribute: false })
public alertEntities: SecurityAlertEntityConfig[] = [];
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@state()
@consume({ context: registriesContext, subscribe: true })
private _registries!: ContextType<typeof registriesContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
private _entityContext?: SecurityEditorContext;
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (
changedProps.has("_states") ||
changedProps.has("_registries") ||
changedProps.has("_i18n")
) {
this._entityContext = {
states: this._states,
...this._registries,
language: this._i18n.language,
translationMetadata: this._i18n.translationMetadata,
};
}
}
protected render() {
return html`
<ha-sortable handle-selector=".handle" @item-moved=${this._moved}>
<div class="alert-list">
${repeat(
this.alertEntities,
(alertEntity) => alertEntity.entity,
(alertEntity, index) => this._renderAlertEntity(alertEntity, index)
)}
</div>
</ha-sortable>
<ha-entity-picker
add-button
.addButtonLabel=${this._i18n.localize(
"ui.panel.security.editor.add_alert_entity"
)}
.excludeEntities=${this.alertEntities.map(({ entity }) => entity)}
.entityFilter=${this._alertEntityFilter}
@value-changed=${this._add}
></ha-entity-picker>
`;
}
private _renderAlertEntity(
alertEntity: SecurityAlertEntityConfig,
index: number
) {
const stateObj = this._states[alertEntity.entity];
const { primary, secondary } =
stateObj && this._entityContext
? computeEntityPickerDisplay(this._entityContext, stateObj)
: { primary: alertEntity.entity, secondary: undefined };
const severity =
alertEntity.severity ?? computeDefaultSecurityAlertSeverity(stateObj);
return html`
<div class="alert-row">
<div class="handle">
<ha-svg-icon .path=${mdiDragHorizontalVariant}></ha-svg-icon>
</div>
<ha-settings-row slim>
<state-badge slot="prefix" .stateObj=${stateObj}></state-badge>
<span slot="heading">${primary}</span>
${
secondary
? html`<span slot="description">${secondary}</span>`
: nothing
}
<ha-control-select-menu
show-arrow
hide-label
.label=${this._i18n.localize(
"ui.panel.security.editor.severity.label"
)}
.value=${severity}
.options=${(["alert", "warning"] as const).map((option) => ({
value: option,
label: this._i18n.localize(
`ui.panel.security.editor.severity.${option}`
),
}))}
data-index=${index}
@wa-select=${this._severityChanged}
></ha-control-select-menu>
<ha-icon-button
.path=${mdiDelete}
.label=${this._i18n.localize("ui.common.delete")}
data-index=${index}
@click=${this._removeClicked}
></ha-icon-button>
</ha-settings-row>
</div>
`;
}
private _changed(next: SecurityAlertEntityConfig[]): void {
fireEvent(this, "value-changed", { value: next });
}
private _alertEntityFilter = (entity: HassEntity) =>
this._entityContext
? isSecurityPanelEntity(this._entityContext, entity)
: false;
private _getIndex(
ev: HASSDomCurrentTargetEvent<HTMLElement>
): number | undefined {
const index = Number(ev.currentTarget.dataset.index);
return Number.isInteger(index) ? index : undefined;
}
private _removeClicked(ev: HASSDomCurrentTargetEvent<HTMLElement>): void {
ev.stopPropagation();
const index = this._getIndex(ev);
if (index !== undefined) {
const next = [...this.alertEntities];
next.splice(index, 1);
this._changed(next);
}
}
private _severityChanged(
ev: HaDropdownSelectEvent<SecurityAlertSeverity> &
HASSDomCurrentTargetEvent<HTMLElement>
): void {
ev.stopPropagation();
const index = this._getIndex(ev);
if (index === undefined) {
return;
}
const next = [...this.alertEntities];
next[index] = { ...next[index], severity: ev.detail.item.value };
this._changed(next);
}
private _add(
ev: ValueChangedEvent<string | undefined> &
HASSDomCurrentTargetEvent<HaEntityPicker>
): void {
ev.stopPropagation();
const entity = ev.detail.value;
if (!entity) return;
ev.currentTarget.value = "";
if (
this.alertEntities.some((alertEntity) => alertEntity.entity === entity)
) {
return;
}
this._changed([...this.alertEntities, { entity }]);
}
private _moved(ev: HASSDomEvent<HASSDomEvents["item-moved"]>): void {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const next = [...this.alertEntities];
const [moved] = next.splice(oldIndex, 1);
next.splice(newIndex, 0, moved);
this._changed(next);
}
static styles = css`
:host {
display: block;
}
.alert-list {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
.alert-row {
display: flex;
align-items: flex-start;
gap: var(--ha-space-2);
}
.handle {
cursor: grab;
color: var(--secondary-text-color);
flex-shrink: 0;
display: flex;
align-items: center;
min-height: 48px;
}
ha-settings-row {
flex: 1;
min-width: 0;
padding: 0;
gap: var(--ha-space-3);
min-height: 48px;
--settings-row-prefix-display: contents;
--settings-row-content-display: contents;
--settings-row-body-padding-top: var(--ha-space-1);
--settings-row-body-padding-bottom: var(--ha-space-1);
}
state-badge {
flex-shrink: 0;
width: 24px;
height: 24px;
--state-icon-color: var(--secondary-text-color);
}
[slot="heading"],
[slot="description"] {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
ha-entity-picker {
display: block;
padding-top: var(--ha-space-3);
}
ha-icon-button {
--ha-icon-button-size: 40px;
}
ha-control-select-menu {
width: 118px;
--control-select-menu-height: 42px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"security-alerts-editor": SecurityAlertsEditor;
}
}
@@ -0,0 +1,162 @@
import { consume, type ContextType } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import "../../../components/ha-button";
import "../../../components/ha-dialog";
import "../../../components/ha-dialog-footer";
import "../../../components/ha-expansion-panel";
import "../../../components/ha-icon";
import type { SecurityFrontendSystemData } from "../../../data/frontend";
import { internationalizationContext } from "../../../data/context";
import { DialogMixin } from "../../../dialogs/dialog-mixin";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { ValueChangedEvent } from "../../../types";
import "../components/security-alerts-editor";
import type { EditSecurityDialogParams } from "./show-dialog-edit-security";
@customElement("dialog-edit-security")
export class DialogEditSecurity extends DirtyStateProviderMixin<SecurityFrontendSystemData>()(
DialogMixin<EditSecurityDialogParams>(LitElement)
) {
@state() private _state?: SecurityFrontendSystemData;
@state() private _submitting = false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
public connectedCallback(): void {
super.connectedCallback();
if (!this.params) {
return;
}
this._state = {
...this.params.config,
alert_entities: this.params.config.alert_entities
? [...this.params.config.alert_entities]
: [],
};
this._initDirtyTracking({ type: "deep" }, this._state);
}
protected render() {
if (!this.params || !this._state) {
return nothing;
}
return html`
<ha-dialog
open
width="medium"
.headerTitle=${this._i18n.localize("ui.panel.security.editor.title")}
.headerSubtitle=${this._i18n.localize(
"ui.panel.security.editor.description"
)}
.preventScrimClose=${this.isDirtyState}
>
${this._renderMainEditor()}
<ha-dialog-footer slot="footer">
<ha-button
autofocus
appearance="plain"
slot="secondaryAction"
@click=${this.closeDialog}
.disabled=${this._submitting}
>
${this._i18n.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${this._submitting || !this.isDirtyState}
>
${this._i18n.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
private _renderMainEditor() {
return html`
<ha-expansion-panel
outlined
expanded
no-collapse
.header=${this._i18n.localize(
"ui.panel.security.editor.active_alert_entities"
)}
.secondary=${this._i18n.localize(
"ui.panel.security.editor.active_alert_entities_description"
)}
>
<ha-icon slot="leading-icon" icon="mdi:shield-alert-outline"></ha-icon>
<div class="expansion-content">
<security-alerts-editor
.alertEntities=${this._state?.alert_entities ?? []}
@value-changed=${this._alertEntitiesChanged}
></security-alerts-editor>
</div>
</ha-expansion-panel>
`;
}
private _alertEntitiesChanged(
ev: ValueChangedEvent<SecurityFrontendSystemData["alert_entities"]>
): void {
this._state = {
...this._state,
alert_entities: ev.detail.value,
};
this._updateDirtyState(this._state);
}
private async _save(): Promise<void> {
if (!this.params || !this._state) return;
this._submitting = true;
try {
await this.params.saveConfig({
...this.params.config,
alert_entities: this._state.alert_entities?.length
? this._state.alert_entities
: undefined,
});
this._markDirtyStateClean();
this.closeDialog();
} catch {
return;
} finally {
this._submitting = false;
}
}
static styles = [
haStyleDialog,
css`
ha-dialog {
--dialog-content-padding: var(--ha-space-6);
}
ha-expansion-panel {
display: block;
--expansion-panel-content-padding: 0;
border-radius: var(--ha-border-radius-md);
--ha-card-border-radius: var(--ha-border-radius-md);
}
.expansion-content {
padding: var(--ha-space-3);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"dialog-edit-security": DialogEditSecurity;
}
}
@@ -0,0 +1,20 @@
import { fireEvent } from "../../../common/dom/fire_event";
import type { SecurityFrontendSystemData } from "../../../data/frontend";
export interface EditSecurityDialogParams {
config: SecurityFrontendSystemData;
saveConfig: (config: SecurityFrontendSystemData) => Promise<void>;
}
export const loadEditSecurityDialog = () => import("./dialog-edit-security");
export const showEditSecurityDialog = (
element: HTMLElement,
params: EditSecurityDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-edit-security",
dialogImport: loadEditSecurityDialog,
dialogParams: params,
});
};
+109 -12
View File
@@ -1,15 +1,24 @@
import { mdiPencil } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-icon-button";
import "../../components/ha-top-app-bar-fixed";
import {
fetchFrontendSystemData,
saveFrontendSystemData,
type SecurityFrontendSystemData,
} from "../../data/frontend";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import { ChildPanelReady } from "../../layouts/panel-ready";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { showToast } from "../../util/toast";
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
import type { Lovelace } from "../lovelace/types";
import { showEditSecurityDialog } from "./dialogs/show-dialog-edit-security";
import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-container";
import "../lovelace/views/hui-view-background";
@@ -30,10 +39,16 @@ class PanelSecurity extends LitElement {
@state() private _lovelace?: Lovelace;
@state() private _config?: SecurityFrontendSystemData;
@state() private _searchParms = new URLSearchParams(window.location.search);
private _childPanelReady?: ChildPanelReady;
private _loadConfigPromise?: Promise<void>;
private _loadConfigRevision = 0;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Initial setup
@@ -47,13 +62,22 @@ class PanelSecurity extends LitElement {
}
const oldHass = changedProps.get("hass") as this["hass"];
if (
oldHass &&
this.hass.config.state === "RUNNING" &&
oldHass.config.state !== "RUNNING"
) {
this._setup();
return;
}
if (oldHass && oldHass.localize !== this.hass.localize) {
this._setLovelace();
return;
}
if (oldHass && this.hass) {
// If the entity registry changed, ask the user if they want to refresh the config
// Refresh the generated view when registries or panels change.
if (
oldHass.entities !== this.hass.entities ||
oldHass.devices !== this.hass.devices ||
@@ -63,22 +87,44 @@ class PanelSecurity extends LitElement {
) {
if (this.hass.config.state === "RUNNING") {
this._debounceRegistriesChanged();
return;
}
}
// If ha started, refresh the config
if (
this.hass.config.state === "RUNNING" &&
oldHass.config.state !== "RUNNING"
) {
this._setLovelace();
}
}
}
private async _setup() {
await this.hass.loadFragmentTranslation("lovelace");
this._setLovelace();
this._loadConfigPromise = this._loadConfig();
await Promise.all([
this.hass.loadFragmentTranslation("lovelace"),
this._loadConfigPromise,
]);
await this._setLovelace();
}
private async _loadConfig() {
const revision = ++this._loadConfigRevision;
this._config = undefined;
try {
const data = await fetchFrontendSystemData(
this.hass.connection,
"security"
);
if (revision !== this._loadConfigRevision) {
return;
}
this._config = data || {};
} catch (err) {
if (revision !== this._loadConfigRevision) {
return;
}
// eslint-disable-next-line no-console
console.error("Failed to load security configuration:", err);
showToast(this, {
message: this.hass.localize("ui.panel.security.editor.load_failed"),
duration: 0,
dismissable: true,
});
}
}
private _debounceRegistriesChanged = debounce(
@@ -97,6 +143,16 @@ class PanelSecurity extends LitElement {
.backButton=${this._searchParms.has("historyBack")}
>
<div slot="title">${this.hass.localize("panel.security")}</div>
${
this.hass.user?.is_admin && this._config
? html`<ha-icon-button
slot="actionItems"
.path=${mdiPencil}
.label=${this.hass.localize("ui.panel.security.editor.title")}
@click=${this._editSecurity}
></ha-icon-button>`
: nothing
}
${
this._lovelace
? html`
@@ -118,8 +174,17 @@ class PanelSecurity extends LitElement {
}
private async _setLovelace() {
if (this._loadConfigPromise) {
await this._loadConfigPromise;
}
const viewConfig = await generateLovelaceViewStrategy(
SECURITY_LOVELACE_VIEW_CONFIG,
{
strategy: {
...SECURITY_LOVELACE_VIEW_CONFIG.strategy,
alert_entities: this._config?.alert_entities,
},
},
this.hass
);
@@ -146,6 +211,38 @@ class PanelSecurity extends LitElement {
};
}
private _editSecurity = () => {
if (!this.hass.user?.is_admin || !this._config) {
return;
}
showEditSecurityDialog(this, {
config: this._config,
saveConfig: async (config) => {
await this._saveConfig(config);
},
});
};
private async _saveConfig(config: SecurityFrontendSystemData): Promise<void> {
try {
await saveFrontendSystemData(this.hass.connection, "security", config);
this._config = config;
} catch (err) {
// eslint-disable-next-line no-console
console.error("Failed to save security configuration:", err);
showToast(this, {
message: this.hass.localize("ui.panel.security.editor.save_failed"),
duration: 0,
dismissable: true,
});
throw err;
}
showToast(this, {
message: this.hass.localize("ui.common.successfully_saved"),
});
await this._setLovelace();
}
static get styles(): CSSResultGroup {
return [
haStyle,
@@ -0,0 +1,90 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { computeDomain } from "../../../common/entity/compute_domain";
import type {
SecurityAlertEntityConfig,
SecurityAlertSeverity,
} from "../../../data/frontend";
import type { StateCondition } from "../../lovelace/common/validate-condition";
import type { AlertCardConfig } from "../../lovelace/cards/types";
const DANGER_BINARY_SENSOR_DEVICE_CLASSES = [
"carbon_monoxide",
"gas",
"moisture",
"safety",
"smoke",
] as const;
type DangerBinarySensorDeviceClass =
(typeof DANGER_BINARY_SENSOR_DEVICE_CLASSES)[number];
const DANGER_BINARY_SENSOR_DEVICE_CLASS_SET =
new Set<DangerBinarySensorDeviceClass>(DANGER_BINARY_SENSOR_DEVICE_CLASSES);
const isDangerBinarySensorDeviceClass = (
deviceClass: string
): deviceClass is DangerBinarySensorDeviceClass =>
DANGER_BINARY_SENSOR_DEVICE_CLASS_SET.has(
deviceClass as DangerBinarySensorDeviceClass
);
export const computeDefaultSecurityAlertSeverity = (
stateObj?: HassEntity
): SecurityAlertSeverity => {
if (!stateObj) {
return "warning";
}
const domain = computeDomain(stateObj.entity_id);
if (domain === "alarm_control_panel") {
return "alert";
}
if (domain === "binary_sensor") {
const deviceClass = stateObj.attributes.device_class;
return typeof deviceClass === "string" &&
isDangerBinarySensorDeviceClass(deviceClass)
? "alert"
: "warning";
}
return "warning";
};
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 "cover":
condition.state = "open";
break;
case "lock":
condition.state = ["jammed", "unlocked", "open"];
break;
default:
condition.state = "on";
}
return [condition];
};
export const computeSecurityAlertCardConfig = (
stateObj: HassEntity | undefined,
alertEntity: SecurityAlertEntityConfig
): AlertCardConfig => {
const severity =
alertEntity.severity ?? computeDefaultSecurityAlertSeverity(stateObj);
return {
type: "alert",
entity: alertEntity.entity,
color: severity === "alert" ? "red" : "amber",
pulse: severity === "alert",
visibility: computeDefaultSecurityAlertVisibility(alertEntity.entity),
};
};
@@ -1,3 +1,4 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { getAreasFloorHierarchy } from "../../../common/areas/areas-floor-hierarchy";
@@ -15,12 +16,15 @@ import type {
LovelaceSectionRawConfig,
} from "../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { SecurityAlertEntityConfig } from "../../../data/frontend";
import type { HomeAssistant } from "../../../types";
import type { LogbookCardConfig } from "../../lovelace/cards/types";
import { computeAreaTileCardConfig } from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
import { computeSecurityAlertCardConfig } from "./security-alerts";
export interface SecurityViewStrategyConfig {
type: "security";
alert_entities?: SecurityAlertEntityConfig[];
}
export const securityEntityFilters: EntityFilter[] = [
@@ -69,6 +73,17 @@ export const securityEntityFilters: EntityFilter[] = [
},
];
export const isSecurityPanelEntity = (
hass: Pick<
HomeAssistant,
"states" | "entities" | "devices" | "areas" | "floors"
>,
stateObj: HassEntity
): boolean =>
securityEntityFilters.some((filter) =>
generateEntityFilter(hass, filter)(stateObj.entity_id)
);
const processAreasForSecurity = (
areaIds: string[],
hass: HomeAssistant,
@@ -132,7 +147,7 @@ const processUnassignedEntities = (
@customElement("security-view-strategy")
export class SecurityViewStrategy extends ReactiveElement {
static async generate(
_config: SecurityViewStrategyConfig,
config: SecurityViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const areas = Object.values(hass.areas);
@@ -242,37 +257,87 @@ export class SecurityViewStrategy extends ReactiveElement {
const logbookEntityIds = [...entities, ...personEntities];
const sidebarSection: LovelaceSectionConfig | undefined =
hasLogbook && logbookEntityIds.length > 0
? {
type: "grid",
cards: [
{
type: "heading",
heading: hass.localize(
"ui.panel.lovelace.strategy.security.activity"
),
heading_style: "title",
} as LovelaceCardConfig,
{
type: "logbook",
target: {
entity_id: logbookEntityIds,
},
hours_to_show: 24,
grid_options: { columns: 12 },
} satisfies LogbookCardConfig,
],
}
: undefined;
const sidebarSections: LovelaceSectionConfig[] = [];
const alertCards = config.alert_entities?.map((alertEntity) =>
computeSecurityAlertCardConfig(
hass.states[alertEntity.entity],
alertEntity
)
);
if (alertCards?.length) {
sidebarSections.push({
type: "grid",
visibility: [
{
condition: "or",
conditions: alertCards.map((alertCard) => ({
condition: "and",
conditions: alertCard.visibility!,
})),
},
],
cards: [
{
type: "heading",
heading: hass.localize(
"ui.panel.lovelace.strategy.security.active_alerts"
),
heading_style: "title",
},
...alertCards.map((alertCard) => ({
...alertCard,
grid_options: { columns: 12 },
})),
] satisfies LovelaceCardConfig[],
});
}
const hasLogbookSection = hasLogbook && logbookEntityIds.length > 0;
if (hasLogbookSection) {
sidebarSections.push({
type: "grid",
cards: [
{
type: "heading",
heading: hass.localize(
"ui.panel.lovelace.strategy.security.activity"
),
heading_style: "title",
} as LovelaceCardConfig,
{
type: "logbook",
target: {
entity_id: logbookEntityIds,
},
hours_to_show: 24,
grid_options: { columns: 12 },
} satisfies LogbookCardConfig,
],
});
}
return {
type: "sections",
max_columns: 3,
sections: sections,
...(sidebarSection && {
...(sidebarSections.length > 0 && {
sidebar: {
sections: [sidebarSection],
sections: sidebarSections,
...(!hasLogbookSection && alertCards?.length
? {
visibility: [
{
condition: "or" as const,
conditions: alertCards.map((alertCard) => ({
condition: "and" as const,
conditions: alertCard.visibility!,
})),
},
],
}
: {}),
content_label: hass.localize(
"ui.panel.lovelace.strategy.security.devices"
),
+17
View File
@@ -2640,6 +2640,22 @@
"learn_more": "Learn more"
}
},
"security": {
"editor": {
"title": "Edit security and safety page",
"description": "Configure your security and safety display preferences.",
"active_alert_entities": "Active alert entities",
"active_alert_entities_description": "Display any entities that require attention.",
"add_alert_entity": "Add entity",
"severity": {
"label": "Severity",
"alert": "Alert",
"warning": "Warning"
},
"load_failed": "Failed to load security and safety page configuration",
"save_failed": "Failed to save security and safety page configuration"
}
},
"my": {
"not_supported": "This redirect is not supported by your Home Assistant instance. Check the {link} for the supported redirects and the version they where introduced.",
"component_not_loaded": "This redirect is not supported by your Home Assistant instance. You need the integration {integration} to use this redirect.",
@@ -9161,6 +9177,7 @@
"security": {
"devices": "Devices",
"other_devices": "Other devices",
"active_alerts": "Active alerts",
"activity": "Activity"
},
"climate": {
@@ -0,0 +1,70 @@
import { describe, expect, it, vi } from "vitest";
import type { SecurityFrontendSystemData } from "../../../../src/data/frontend";
import type { EditSecurityDialogParams } from "../../../../src/panels/security/dialogs/show-dialog-edit-security";
import type { HomeAssistantInternationalization } from "../../../../src/types";
import "../../../../src/panels/security/dialogs/dialog-edit-security";
import { createMockHass } from "../../../fixtures/hass";
interface TestEditSecurityDialog extends HTMLElement {
params: EditSecurityDialogParams;
_i18n: HomeAssistantInternationalization;
isDirtyState: boolean;
connectedCallback(): void;
disconnectedCallback(): void;
performUpdate(): void;
}
const alertEntitiesChanged = (
dialog: TestEditSecurityDialog,
ev: CustomEvent
) =>
(
dialog as unknown as Record<
"_alertEntitiesChanged",
(event: CustomEvent) => void
>
)["_alertEntitiesChanged"](ev);
describe("dialog-edit-security", () => {
const createDialog = (config: SecurityFrontendSystemData = {}) => {
const hass = createMockHass();
const dialog = document.createElement(
"dialog-edit-security"
) as unknown as TestEditSecurityDialog;
dialog._i18n = hass;
dialog.params = {
config,
saveConfig: vi.fn(),
};
dialog.performUpdate = vi.fn();
dialog.connectedCallback();
return dialog;
};
it("becomes clean after nested configuration is restored", () => {
const alertEntities = [
{ entity: "binary_sensor.window", severity: "warning" as const },
];
const dialog = createDialog({ alert_entities: alertEntities });
alertEntitiesChanged(
dialog,
new CustomEvent("value-changed", {
detail: {
value: [{ ...alertEntities[0], severity: "alert" }],
},
})
);
expect(dialog.isDirtyState).toBe(true);
alertEntitiesChanged(
dialog,
new CustomEvent("value-changed", {
detail: { value: [{ ...alertEntities[0] }] },
})
);
expect(dialog.isDirtyState).toBe(false);
dialog.disconnectedCallback();
});
});
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import {
computeDefaultSecurityAlertVisibility,
computeSecurityAlertCardConfig,
} from "../../../../src/panels/security/strategies/security-alerts";
import { createMockEntityState } from "../../../fixtures/hass";
describe("computeDefaultSecurityAlertVisibility", () => {
it.each([
["alarm_control_panel.house", { state: "triggered" }],
["binary_sensor.leak", { state: "on" }],
["cover.garage_door", { state: "open" }],
[
"lock.front_door",
{
state: ["jammed", "unlocked", "open"],
},
],
])("uses the active state for %s", (entityId, stateCondition) => {
expect(computeDefaultSecurityAlertVisibility(entityId)).toEqual([
{
condition: "state",
entity: entityId,
...stateCondition,
},
]);
});
});
describe("computeSecurityAlertCardConfig", () => {
it("maps alert severity to a red alert card", () => {
expect(
computeSecurityAlertCardConfig(undefined, {
entity: "binary_sensor.smoke",
severity: "alert",
})
).toEqual({
type: "alert",
entity: "binary_sensor.smoke",
color: "red",
pulse: true,
visibility: [
{
condition: "state",
entity: "binary_sensor.smoke",
state: "on",
},
],
});
});
it("uses the entity device class for the default severity", () => {
const stateObj = createMockEntityState("binary_sensor.smoke", "off", {
device_class: "smoke",
});
expect(
computeSecurityAlertCardConfig(stateObj, {
entity: "binary_sensor.smoke",
}).color
).toBe("red");
});
});
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { SecurityViewStrategy } from "../../../../src/panels/security/strategies/security-view-strategy";
import { createMockHass } from "../../../fixtures/hass";
describe("security-view-strategy", () => {
it("renders active alerts as individual cards in a visible section", async () => {
const hass = createMockHass();
hass.config = { ...hass.config, components: [] };
const view = await SecurityViewStrategy.generate(
{
type: "security",
alert_entities: [{ entity: "binary_sensor.window" }],
},
hass
);
const alertSection = view.sidebar?.sections?.[0];
expect(alertSection?.visibility).toEqual([
{
condition: "or",
conditions: [
{
condition: "and",
conditions: [
{
condition: "state",
entity: "binary_sensor.window",
state: "on",
},
],
},
],
},
]);
expect(alertSection?.cards).toEqual([
{
type: "heading",
heading: "ui.panel.lovelace.strategy.security.active_alerts",
heading_style: "title",
},
{
type: "alert",
entity: "binary_sensor.window",
color: "amber",
pulse: false,
visibility: [
{
condition: "state",
entity: "binary_sensor.window",
state: "on",
},
],
grid_options: { columns: 12 },
},
]);
});
});