diff --git a/src/common/entity/entity_filter.ts b/src/common/entity/entity_filter.ts index 4a9a125683..e8fa2f9a72 100644 --- a/src/common/entity/entity_filter.ts +++ b/src/common/entity/entity_filter.ts @@ -33,7 +33,10 @@ const normalizeFilterArray = ( }; export const generateEntityFilter = ( - hass: HomeAssistant, + hass: Pick< + HomeAssistant, + "states" | "entities" | "devices" | "areas" | "floors" + >, filter: EntityFilter ): EntityFilterFunc => { const domains = filter.domain diff --git a/src/data/frontend.ts b/src/data/frontend.ts index c1ff529d9f..1d63b1ff41 100644 --- a/src/data/frontend.ts +++ b/src/data/frontend.ts @@ -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 "." 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; } } diff --git a/src/panels/security/components/security-alerts-editor.ts b/src/panels/security/components/security-alerts-editor.ts new file mode 100644 index 0000000000..221e8d404d --- /dev/null +++ b/src/panels/security/components/security-alerts-editor.ts @@ -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 & { + states: ContextType; + language: ContextType["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; + + @state() + @consume({ context: registriesContext, subscribe: true }) + private _registries!: ContextType; + + @state() + @consume({ context: internationalizationContext, subscribe: true }) + private _i18n!: ContextType; + + 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` + +
+ ${repeat( + this.alertEntities, + (alertEntity) => alertEntity.entity, + (alertEntity, index) => this._renderAlertEntity(alertEntity, index) + )} +
+
+ entity)} + .entityFilter=${this._alertEntityFilter} + @value-changed=${this._add} + > + `; + } + + 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` +
+
+ +
+ + + ${primary} + ${ + secondary + ? html`${secondary}` + : nothing + } + ({ + value: option, + label: this._i18n.localize( + `ui.panel.security.editor.severity.${option}` + ), + }))} + data-index=${index} + @wa-select=${this._severityChanged} + > + + +
+ `; + } + + 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 + ): number | undefined { + const index = Number(ev.currentTarget.dataset.index); + return Number.isInteger(index) ? index : undefined; + } + + private _removeClicked(ev: HASSDomCurrentTargetEvent): 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 & + HASSDomCurrentTargetEvent + ): 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 & + HASSDomCurrentTargetEvent + ): 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): 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; + } +} diff --git a/src/panels/security/dialogs/dialog-edit-security.ts b/src/panels/security/dialogs/dialog-edit-security.ts new file mode 100644 index 0000000000..0930256194 --- /dev/null +++ b/src/panels/security/dialogs/dialog-edit-security.ts @@ -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()( + DialogMixin(LitElement) +) { + @state() private _state?: SecurityFrontendSystemData; + + @state() private _submitting = false; + + @state() + @consume({ context: internationalizationContext, subscribe: true }) + private _i18n!: ContextType; + + 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` + + ${this._renderMainEditor()} + + + + ${this._i18n.localize("ui.common.cancel")} + + + ${this._i18n.localize("ui.common.save")} + + + + `; + } + + private _renderMainEditor() { + return html` + + +
+ +
+
+ `; + } + + private _alertEntitiesChanged( + ev: ValueChangedEvent + ): void { + this._state = { + ...this._state, + alert_entities: ev.detail.value, + }; + this._updateDirtyState(this._state); + } + + private async _save(): Promise { + 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; + } +} diff --git a/src/panels/security/dialogs/show-dialog-edit-security.ts b/src/panels/security/dialogs/show-dialog-edit-security.ts new file mode 100644 index 0000000000..1ffa7c5e49 --- /dev/null +++ b/src/panels/security/dialogs/show-dialog-edit-security.ts @@ -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; +} + +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, + }); +}; diff --git a/src/panels/security/ha-panel-security.ts b/src/panels/security/ha-panel-security.ts index 5208c99e44..83449d4a2a 100644 --- a/src/panels/security/ha-panel-security.ts +++ b/src/panels/security/ha-panel-security.ts @@ -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; + + private _loadConfigRevision = 0; + public willUpdate(changedProps: PropertyValues) { 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")} >
${this.hass.localize("panel.security")}
+ ${ + this.hass.user?.is_admin && this._config + ? html`` + : 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 { + 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, diff --git a/src/panels/security/strategies/security-alerts.ts b/src/panels/security/strategies/security-alerts.ts new file mode 100644 index 0000000000..b321e638a6 --- /dev/null +++ b/src/panels/security/strategies/security-alerts.ts @@ -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(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), + }; +}; diff --git a/src/panels/security/strategies/security-view-strategy.ts b/src/panels/security/strategies/security-view-strategy.ts index f88c2c137b..b31f499245 100644 --- a/src/panels/security/strategies/security-view-strategy.ts +++ b/src/panels/security/strategies/security-view-strategy.ts @@ -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 { 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" ), diff --git a/src/translations/en.json b/src/translations/en.json index 8f1f0176bf..612d6bd2c4 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -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": { diff --git a/test/panels/security/dialogs/dialog-edit-security.test.ts b/test/panels/security/dialogs/dialog-edit-security.test.ts new file mode 100644 index 0000000000..feba078a9c --- /dev/null +++ b/test/panels/security/dialogs/dialog-edit-security.test.ts @@ -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(); + }); +}); diff --git a/test/panels/security/strategies/security-alerts.test.ts b/test/panels/security/strategies/security-alerts.test.ts new file mode 100644 index 0000000000..05cc5b3c9f --- /dev/null +++ b/test/panels/security/strategies/security-alerts.test.ts @@ -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"); + }); +}); diff --git a/test/panels/security/strategies/security-view-strategy.test.ts b/test/panels/security/strategies/security-view-strategy.test.ts new file mode 100644 index 0000000000..80ebce3fcf --- /dev/null +++ b/test/panels/security/strategies/security-view-strategy.test.ts @@ -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 }, + }, + ]); + }); +});