Compare commits

...

16 Commits

Author SHA1 Message Date
Aidan Timson 6c04b3ae3d e2e tests 2026-07-07 14:15:58 +01:00
Aidan Timson 9e76f97fa8 Self review 2026-07-07 14:15:58 +01:00
Aidan Timson 771d4c231c Remove active alerts heading in preview 2026-07-07 14:15:58 +01:00
Aidan Timson fecf887f92 Remove heading 2026-07-07 14:15:58 +01:00
Aidan Timson e48ec88e5f Cleanup 2026-07-07 14:15:58 +01:00
Aidan Timson 7565450408 Cleanup 2026-07-07 14:15:58 +01:00
Aidan Timson 209512b498 Swap warning color 2026-07-07 14:15:58 +01:00
Aidan Timson c39fa964f0 Cleanup 2026-07-07 14:15:58 +01:00
Aidan Timson 05f7a5c399 Fix color 2026-07-07 14:15:58 +01:00
Aidan Timson 82c6f90cb0 Preview 2026-07-07 14:15:58 +01:00
Aidan Timson 59171d0ed7 Fix no color 2026-07-07 14:15:58 +01:00
Aidan Timson e4f9244ef3 Fix pulse 2026-07-07 14:15:58 +01:00
Aidan Timson b93be88feb Type 2026-07-07 14:15:58 +01:00
Aidan Timson 6e5114df91 Move into seperate components, use context for heading to hide 2026-07-07 14:15:58 +01:00
Aidan Timson 245c152957 Settings UI, remove assumptions/mocks 2026-07-07 14:15:58 +01:00
Aidan Timson 5af3808b1b Add active security alerts card 2026-07-07 14:15:58 +01:00
21 changed files with 1987 additions and 29 deletions
@@ -4,6 +4,7 @@ import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { EntitySources } from "../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../data/entity/entity_sources";
import type { EntitySelector } from "../../data/selector";
@@ -35,6 +36,10 @@ export class HaEntitySelector extends LitElement {
@property({ type: Boolean }) public required = true;
@property({ attribute: false }) public context?: {
entityFilter?: HaEntityPickerEntityFilterFunc;
};
@state() private _createDomains: string[] | undefined;
private _hasIntegration(selector: EntitySelector) {
@@ -111,6 +116,9 @@ export class HaEntitySelector extends LitElement {
}
private _filterEntities = (entity: HassEntity): boolean => {
if (this.context?.entityFilter && !this.context.entityFilter(entity)) {
return false;
}
if (!this.selector?.entity?.filter) {
return true;
}
+13
View File
@@ -1,4 +1,5 @@
import type { Connection } from "home-assistant-js-websocket";
import type { Condition } from "../panels/lovelace/common/validate-condition";
import type { ShortcutItem } from "./home_shortcuts";
export interface CoreFrontendUserData {
@@ -26,6 +27,17 @@ export interface HomeFrontendSystemData {
shortcuts?: ShortcutItem[];
}
export interface SecurityAlertEntityConfig {
entity: string;
color?: string;
pulse?: boolean;
visibility?: Condition[];
}
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),
@@ -42,6 +54,7 @@ declare global {
core: CoreFrontendSystemData;
home: HomeFrontendSystemData;
energy: EnergyFrontendSystemData;
security: SecurityFrontendSystemData;
}
}
@@ -0,0 +1,5 @@
import { createContext } from "@lit/context";
import type { SecurityAlertItem } from "../../../security/strategies/security-alerts";
export const securityAlertsContext =
createContext<SecurityAlertItem[]>("security-alerts");
@@ -0,0 +1,140 @@
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,
type SecurityAlertItem,
} from "../../../security/strategies/security-alerts";
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 (!config.alert_entities) {
throw new Error("Specify alert entities");
}
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 "../../../security/strategies/security-alerts";
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,160 @@
import { consume, type ContextType } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { computeCssColor } from "../../../../common/color/compute-color";
import { computeStateName } from "../../../../common/entity/compute_state_name";
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 { formattersContext } from "../../../../data/context";
import type { ActionHandlerEvent } from "../../../../data/lovelace/action_handler";
import { pulseOpacityAnimation } from "../../../../resources/animations";
import type { SecurityAlertItem } from "../../../security/strategies/security-alerts";
import { tileCardStyle } from "../tile/tile-card-style";
import { securityAlertsContext } from "./context";
@customElement("hui-security-alerts-list")
export class HuiSecurityAlertsList extends LitElement {
@state()
@consume({ context: securityAlertsContext, subscribe: true })
private _alerts: SecurityAlertItem[] = [];
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters!: ContextType<typeof formattersContext>;
protected render() {
if (!this._alerts.length) {
return nothing;
}
return html`
<div class="alerts">
${this._alerts.map((alert) => this._renderAlert(alert))}
</div>
`;
}
private _handleAction(ev: ActionHandlerEvent): void {
const entityId = (ev.currentTarget as HTMLElement).dataset.entityId;
if (ev.detail.action === "tap" && entityId) {
fireEvent(this, "hass-more-info", { entityId });
}
}
private _renderAlert(alert: SecurityAlertItem) {
const stateDisplay = this._formatters.formatEntityState(alert.stateObj);
const pulse = alert.pulse === true;
const hasColor = 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 }}
data-entity-id=${alert.entityId}
@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>
`;
}
static styles = [
tileCardStyle,
pulseOpacityAnimation,
css`
:host {
display: block;
--ha-security-alert-pulse-duration: 1s;
--ha-security-alert-pulse-opacity: 0.3;
--ha-security-alert-static-opacity: 0;
}
.alerts {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
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-alerts-list": HuiSecurityAlertsList;
}
}
+5
View File
@@ -3,6 +3,7 @@ import type { EntityNameItem } from "../../../common/entity/compute_entity_name_
import type { HaDurationData } from "../../../components/ha-duration-input";
import type { MapCardMarkerLabelMode } from "../../../components/map/ha-map";
import type { EnergySourceByType } from "../../../data/energy";
import type { SecurityAlertEntityConfig } from "../../../data/frontend";
import type { ActionConfig } from "../../../data/lovelace/config/action";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type {
@@ -696,6 +697,10 @@ export interface ShortcutCardConfig extends LovelaceCardConfig {
double_tap_action?: ActionConfig;
}
export interface SecurityAlertsCardConfig extends LovelaceCardConfig {
alert_entities: SecurityAlertEntityConfig[];
}
export interface ToggleGroupCardConfig extends LovelaceCardConfig {
title: string;
entities: string[];
@@ -80,6 +80,8 @@ 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-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"),
@@ -0,0 +1,216 @@
import { mdiClose, mdiDragHorizontalVariant, mdiPencil } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { computeEntityPickerDisplay } from "../../../common/entity/compute_entity_name_display";
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
import type { SecurityAlertEntityConfig } from "../../../data/frontend";
import "../../../components/entity/ha-entity-picker";
import "../../../components/entity/state-badge";
import "../../../components/ha-icon-button";
import "../../../components/ha-settings-row";
import "../../../components/ha-sortable";
import "../../../components/ha-svg-icon";
import { computeDefaultSecurityAlertVisibility } from "../strategies/security-alerts";
import { isSecurityPanelEntity } from "../strategies/security-view-strategy";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
declare global {
interface HASSDomEvents {
"edit-security-alert-entity": { index: number };
}
}
@customElement("security-alerts-editor")
export class SecurityAlertsEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false })
public alertEntities: SecurityAlertEntityConfig[] = [];
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.hass.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.hass.states[alertEntity.entity];
const { primary, secondary } = stateObj
? computeEntityPickerDisplay(this.hass, stateObj)
: { primary: alertEntity.entity, secondary: undefined };
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-icon-button
.path=${mdiPencil}
.label=${this.hass.localize("ui.common.edit")}
data-index=${index}
@click=${this._editClicked}
></ha-icon-button>
<ha-icon-button
.path=${mdiClose}
.label=${this.hass.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) =>
isSecurityPanelEntity(this.hass, entity);
private _getIndex(ev: Event): number | undefined {
const index = Number((ev.currentTarget as HTMLElement).dataset.index);
return Number.isInteger(index) ? index : undefined;
}
private _editClicked(ev: Event): void {
ev.stopPropagation();
const index = this._getIndex(ev);
if (index !== undefined) {
fireEvent(this, "edit-security-alert-entity", { index });
}
}
private _removeClicked(ev: Event): void {
ev.stopPropagation();
const index = this._getIndex(ev);
if (index !== undefined) {
const next = [...this.alertEntities];
next.splice(index, 1);
this._changed(next);
}
}
private _add(ev: ValueChangedEvent<string | undefined>): void {
ev.stopPropagation();
const entity = ev.detail.value;
if (!entity) return;
(ev.currentTarget as HaEntityPicker).value = "";
if (
this.alertEntities.some((alertEntity) => alertEntity.entity === entity)
) {
return;
}
this._changed([
...this.alertEntities,
{
entity,
visibility: computeDefaultSecurityAlertVisibility(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;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"security-alerts-editor": SecurityAlertsEditor;
}
}
@@ -0,0 +1,521 @@
import { ContextProvider } from "@lit/context";
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 { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-button";
import "../../../components/ha-dialog";
import "../../../components/ha-dialog-footer";
import "../../../components/ha-expansion-panel";
import "../../../components/ha-form/ha-form";
import type { HaFormSchema } from "../../../components/ha-form/types";
import "../../../components/ha-icon";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-button-prev";
import type {
SecurityAlertEntityConfig,
SecurityFrontendSystemData,
} from "../../../data/frontend";
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import "../../lovelace/cards/hui-card";
import type { SecurityAlertsCardConfig } from "../../lovelace/cards/types";
import "../../lovelace/editor/conditions/ha-card-conditions-editor";
import "../../lovelace/editor/conditions/ha-visibility-status";
import type { Condition } from "../../lovelace/common/validate-condition";
import { conditionsEntityContext } from "../../lovelace/editor/conditions/context";
import "../components/security-alerts-editor";
import {
computeSecurityAlertEntityDefaultColor,
computeDefaultSecurityAlertVisibility,
} from "../strategies/security-alerts";
import { isSecurityPanelEntity } from "../strategies/security-view-strategy";
import type { EditSecurityDialogParams } from "./show-dialog-edit-security";
import { withViewTransition } from "../../../common/util/view-transition";
interface AlertEntityEditorData {
entity: string;
color: string;
pulse: boolean;
}
@customElement("dialog-edit-security")
export class DialogEditSecurity
extends DirtyStateProviderMixin<SecurityFrontendSystemData>()(LitElement)
implements HassDialog<EditSecurityDialogParams>
{
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: EditSecurityDialogParams;
@state() private _state?: SecurityFrontendSystemData;
@state() private _open = false;
@state() private _submitting = false;
@state() private _editingAlertEntityIndex?: number;
private _conditionContextProvider = new ContextProvider(this, {
context: conditionsEntityContext,
initialValue: undefined,
});
protected willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties);
if (
changedProperties.has("_editingAlertEntityIndex") ||
changedProperties.has("_state")
) {
const alertEntity = this._editingAlertEntity;
this._conditionContextProvider.setValue(
alertEntity
? { mode: "current", entityId: alertEntity.entity }
: undefined
);
}
}
private get _editingAlertEntity(): SecurityAlertEntityConfig | undefined {
return this._editingAlertEntityIndex === undefined
? undefined
: this._state?.alert_entities?.[this._editingAlertEntityIndex];
}
public showDialog(params: EditSecurityDialogParams): void {
this._params = params;
this._state = {
...params.config,
alert_entities: params.config.alert_entities
? [...params.config.alert_entities]
: [],
};
this._initDirtyTracking({ type: "shallow" }, this._state);
this._open = true;
}
public closeDialog(): boolean {
this._open = false;
return true;
}
private _dialogClosed(): void {
this._params = undefined;
this._state = undefined;
this._submitting = false;
this._editingAlertEntityIndex = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params || !this._state) {
return nothing;
}
return html`
<ha-dialog
class=${classMap({ subview: Boolean(this._editingAlertEntity) })}
.open=${this._open}
.width=${this._editingAlertEntity ? "large" : "medium"}
.headerTitle=${this.hass.localize("ui.panel.security.editor.title")}
.headerSubtitle=${
this._editingAlertEntity
? undefined
: this.hass.localize("ui.panel.security.editor.description")
}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${
this._editingAlertEntity
? html` ${this._renderAlertEntityEditor(this._editingAlertEntity)} `
: this._renderMainEditor()
}
<ha-dialog-footer slot="footer">
<ha-button
appearance="plain"
slot="secondaryAction"
@click=${this.closeDialog}
.disabled=${this._submitting}
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${this._submitting || !this.isDirtyState}
>
${this.hass.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
private _renderMainEditor() {
return html`
<ha-expansion-panel
outlined
expanded
no-collapse
.header=${this.hass.localize(
"ui.panel.security.editor.active_alert_entities"
)}
.secondary=${this.hass.localize(
"ui.panel.security.editor.active_alert_entities_description"
)}
>
<ha-icon slot="leading-icon" icon="mdi:shield-alert"></ha-icon>
<div class="expansion-content">
<security-alerts-editor
.hass=${this.hass}
.alertEntities=${this._state?.alert_entities ?? []}
@value-changed=${this._alertEntitiesChanged}
@edit-security-alert-entity=${this._editAlertEntity}
></security-alerts-editor>
</div>
</ha-expansion-panel>
`;
}
private _renderAlertEntityEditor(alertEntity: SecurityAlertEntityConfig) {
return html`
<div class="entity-editor">
<div class="subpage-header">
<ha-icon-button-prev
.label=${this.hass.localize("ui.common.back")}
@click=${this._closeAlertEntityEditor}
></ha-icon-button-prev>
<span class="subpage-title">
${this.hass.localize("ui.panel.security.editor.edit_alert_entity")}
</span>
</div>
<div class="entity-editor-content">
<div class="element-editor">
<p class="entity-editor-description">
${this.hass.localize(
"ui.panel.security.editor.alert_entity_description"
)}
</p>
<ha-form
.hass=${this.hass}
.data=${{
entity: alertEntity.entity,
color: alertEntity.color,
pulse: alertEntity.pulse ?? true,
}}
.schema=${this._alertEntityFormSchema()}
.context=${{ entityFilter: this._alertEntityFilter }}
.computeLabel=${this._computeAlertEntityEditorLabel}
@value-changed=${this._alertEntityFormChanged}
></ha-form>
<div class="conditions">
<p class="field-label">
${this.hass.localize(
"ui.panel.security.editor.visibility_conditions"
)}
</p>
<ha-visibility-status
.hass=${this.hass}
.conditions=${
alertEntity.visibility ??
computeDefaultSecurityAlertVisibility(alertEntity.entity)
}
></ha-visibility-status>
<ha-card-conditions-editor
.hass=${this.hass}
.conditions=${
alertEntity.visibility ??
computeDefaultSecurityAlertVisibility(alertEntity.entity)
}
@value-changed=${this._alertEntityConditionsChanged}
></ha-card-conditions-editor>
</div>
</div>
<div class="element-preview">
<hui-card
.hass=${this.hass}
.config=${this._previewCardConfig(alertEntity)}
preview
></hui-card>
</div>
</div>
</div>
`;
}
private _previewCardConfig = memoizeOne(
(alertEntity: SecurityAlertEntityConfig): SecurityAlertsCardConfig => ({
type: "security-alerts",
alert_entities: [alertEntity],
})
);
private _alertEntitiesChanged(
ev: ValueChangedEvent<SecurityFrontendSystemData["alert_entities"]>
): void {
this._state = {
...this._state,
alert_entities: ev.detail.value,
};
this._updateDirtyState(this._state);
}
private _editAlertEntity(
ev: HASSDomEvent<HASSDomEvents["edit-security-alert-entity"]>
): void {
ev.stopPropagation();
withViewTransition(() => {
this._editingAlertEntityIndex = ev.detail.index;
});
}
private _closeAlertEntityEditor(): void {
this._editingAlertEntityIndex = undefined;
}
private _alertEntityFilter = (entity: HassEntity) =>
isSecurityPanelEntity(this.hass, entity);
private _alertEntityFormSchema(): HaFormSchema[] {
return [
{
name: "entity",
required: true,
selector: {
entity: {
exclude_entities: (this._state?.alert_entities ?? [])
.filter((_, index) => index !== this._editingAlertEntityIndex)
.map(({ entity }) => entity),
},
},
},
{
type: "grid",
name: "highlight",
flatten: true,
column_min_width: "0",
schema: [
{
name: "color",
selector: {
ui_color: {
include_none: true,
default_color: computeSecurityAlertEntityDefaultColor(
this.hass.states[this._editingAlertEntity?.entity ?? ""]
),
},
},
},
{
name: "pulse",
selector: { boolean: {} },
},
],
},
];
}
private _computeAlertEntityEditorLabel = (schema: HaFormSchema): string => {
switch (schema.name) {
case "entity":
return this.hass.localize("ui.panel.security.editor.entity");
case "color":
return this.hass.localize("ui.panel.security.editor.alert_color.label");
case "pulse":
return this.hass.localize("ui.panel.security.editor.pulse");
default:
return schema.name;
}
};
private _updateEditingAlertEntity(
updates: Partial<SecurityAlertEntityConfig>
): void {
if (this._editingAlertEntityIndex === undefined || !this._state) {
return;
}
const alertEntities = [...(this._state.alert_entities ?? [])];
const alertEntity = alertEntities[this._editingAlertEntityIndex];
if (!alertEntity) {
return;
}
alertEntities[this._editingAlertEntityIndex] = {
...alertEntity,
...updates,
};
this._state = {
...this._state,
alert_entities: alertEntities,
};
this._updateDirtyState(this._state);
}
private _alertEntityFormChanged(
ev: ValueChangedEvent<AlertEntityEditorData>
): void {
const updates: Partial<SecurityAlertEntityConfig> = {
entity: ev.detail.value.entity,
color: ev.detail.value.color,
pulse: ev.detail.value.pulse,
};
if (this._editingAlertEntity?.entity !== ev.detail.value.entity) {
updates.visibility = computeDefaultSecurityAlertVisibility(
ev.detail.value.entity
);
}
this._updateEditingAlertEntity(updates);
}
private _alertEntityConditionsChanged(
ev: ValueChangedEvent<Condition[]>
): void {
this._updateEditingAlertEntity({ visibility: ev.detail.value });
}
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-dialog.subview {
--dialog-content-padding: var(--ha-space-2);
}
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);
}
.entity-editor {
display: flex;
flex-direction: column;
}
.subpage-header {
display: flex;
align-items: center;
gap: var(--ha-space-2);
padding: 0 var(--ha-space-1);
}
.subpage-title {
color: var(--primary-text-color);
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-medium);
}
.entity-editor-content {
display: flex;
flex-direction: column;
gap: var(--ha-space-4);
padding: var(--ha-space-4) 0;
}
.element-editor {
display: flex;
flex-direction: column;
gap: var(--ha-space-4);
padding: var(--ha-space-4);
}
.element-preview {
position: relative;
background: var(--primary-background-color);
padding: var(--ha-space-4);
border-radius: var(--ha-border-radius-sm);
}
.element-preview hui-card {
display: block;
width: 100%;
box-sizing: border-box;
}
@media (min-width: 1000px) {
.entity-editor-content {
flex-direction: row;
max-height: calc(100vh - 209px);
}
.entity-editor-content > .element-editor,
.entity-editor-content > .element-preview {
flex-basis: 0;
flex-grow: 1;
flex-shrink: 1;
min-width: 0;
}
.entity-editor-content > .element-preview {
overflow-y: auto;
}
.entity-editor-content > .element-editor {
padding-inline-end: var(--ha-space-4);
}
}
.entity-editor-description {
margin: 0;
font-size: var(--ha-font-size-m);
line-height: var(--ha-line-height-normal);
}
ha-form-grid {
direction: ltr;
--form-grid-column-count: 2;
}
.field-label {
margin: 0 0 var(--ha-space-2) 0;
font-size: 14px;
color: var(--primary-text-color);
}
ha-visibility-status {
display: block;
margin-bottom: 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,
});
};
+75 -3
View File
@@ -1,14 +1,23 @@
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 { 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";
@@ -29,8 +38,12 @@ class PanelSecurity extends LitElement {
@state() private _lovelace?: Lovelace;
@state() private _config: SecurityFrontendSystemData = {};
@state() private _searchParms = new URLSearchParams(window.location.search);
private _loadConfigPromise?: Promise<void>;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Initial setup
@@ -50,7 +63,7 @@ class PanelSecurity extends LitElement {
}
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 ||
@@ -74,10 +87,25 @@ class PanelSecurity extends LitElement {
}
private async _setup() {
await this.hass.loadFragmentTranslation("lovelace");
this._loadConfigPromise = this._loadConfig();
await this._loadConfigPromise;
this._setLovelace();
}
private async _loadConfig() {
try {
const [, data] = await Promise.all([
this.hass.loadFragmentTranslation("lovelace"),
fetchFrontendSystemData(this.hass.connection, "security"),
]);
this._config = data || {};
} catch (err) {
// eslint-disable-next-line no-console
console.error("Failed to load security configuration:", err);
this._config = {};
}
}
private _debounceRegistriesChanged = debounce(
() => this._registriesChanged(),
200
@@ -94,6 +122,12 @@ class PanelSecurity extends LitElement {
.backButton=${this._searchParms.has("historyBack")}
>
<div slot="title">${this.hass.localize("panel.security")}</div>
<ha-icon-button
slot="actionItems"
.path=${mdiPencil}
.label=${this.hass.localize("ui.panel.security.editor.title")}
@click=${this._editSecurity}
></ha-icon-button>
${
this._lovelace
? html`
@@ -115,8 +149,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
);
@@ -142,6 +185,35 @@ class PanelSecurity extends LitElement {
};
}
private _editSecurity = () => {
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"),
});
this._setLovelace();
}
static get styles(): CSSResultGroup {
return [
haStyle,
@@ -0,0 +1,224 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { mdiCctvOff, mdiLockOpen, mdiShieldAlert, mdiWater } from "@mdi/js";
import { computeDomain } from "../../../common/entity/compute_domain";
import { UNAVAILABLE } from "../../../data/entity/entity";
import type { SecurityAlertEntityConfig } from "../../../data/frontend";
import type { HomeAssistant } from "../../../types";
import type { Condition } from "../../lovelace/common/validate-condition";
import {
checkConditionsMet,
extractConditionEntityIds,
} from "../../lovelace/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 DANGER_BINARY_SENSOR_DEVICE_CLASSES = [
"carbon_monoxide",
"gas",
"moisture",
"safety",
"smoke",
] as const;
const WARNING_BINARY_SENSOR_DEVICE_CLASSES = [
"door",
"garage_door",
"lock",
"opening",
"tamper",
"window",
] as const;
const WARNING_COVER_DEVICE_CLASSES = [
"door",
"garage",
"gate",
"window",
] as const;
type DangerBinarySensorDeviceClass =
(typeof DANGER_BINARY_SENSOR_DEVICE_CLASSES)[number];
type WarningBinarySensorDeviceClass =
(typeof WARNING_BINARY_SENSOR_DEVICE_CLASSES)[number];
type WarningCoverDeviceClass = (typeof WARNING_COVER_DEVICE_CLASSES)[number];
const DANGER_BINARY_SENSOR_DEVICE_CLASS_SET =
new Set<DangerBinarySensorDeviceClass>(DANGER_BINARY_SENSOR_DEVICE_CLASSES);
const WARNING_BINARY_SENSOR_DEVICE_CLASS_SET =
new Set<WarningBinarySensorDeviceClass>(WARNING_BINARY_SENSOR_DEVICE_CLASSES);
const WARNING_COVER_DEVICE_CLASS_SET = new Set<WarningCoverDeviceClass>(
WARNING_COVER_DEVICE_CLASSES
);
const isDangerBinarySensorDeviceClass = (
deviceClass: string
): deviceClass is DangerBinarySensorDeviceClass =>
DANGER_BINARY_SENSOR_DEVICE_CLASS_SET.has(
deviceClass as DangerBinarySensorDeviceClass
);
const isWarningBinarySensorDeviceClass = (
deviceClass: string
): deviceClass is WarningBinarySensorDeviceClass =>
WARNING_BINARY_SENSOR_DEVICE_CLASS_SET.has(
deviceClass as WarningBinarySensorDeviceClass
);
const isWarningCoverDeviceClass = (
deviceClass: string
): deviceClass is WarningCoverDeviceClass =>
WARNING_COVER_DEVICE_CLASS_SET.has(deviceClass as WarningCoverDeviceClass);
export const isSecurityAlertEntity = (stateObj: HassEntity): boolean => {
const domain = computeDomain(stateObj.entity_id);
switch (domain) {
case "alarm_control_panel":
case "camera":
case "lock":
return true;
case "binary_sensor": {
const deviceClass = stateObj.attributes.device_class;
return (
typeof deviceClass === "string" &&
(isDangerBinarySensorDeviceClass(deviceClass) ||
isWarningBinarySensorDeviceClass(deviceClass))
);
}
case "cover": {
const deviceClass = stateObj.attributes.device_class;
return (
typeof deviceClass === "string" &&
isWarningCoverDeviceClass(deviceClass)
);
}
default:
return false;
}
};
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
): Condition[] => [
{
condition: "state",
entity: entityId,
state:
computeDomain(entityId) === "alarm_control_panel" ? "triggered" : "on",
},
];
export const extractSecurityAlertEntityIds = (
alertEntities: SecurityAlertEntityConfig[]
): 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: SecurityAlertEntityConfig
): 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: SecurityAlertEntityConfig[]
): 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));
@@ -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,14 @@ 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";
export interface SecurityViewStrategyConfig {
type: "security";
alert_entities?: SecurityAlertEntityConfig[];
}
export const securityEntityFilters: EntityFilter[] = [
@@ -69,6 +72,14 @@ export const securityEntityFilters: EntityFilter[] = [
},
];
export const isSecurityPanelEntity = (
hass: HomeAssistant,
stateObj: HassEntity
): boolean =>
securityEntityFilters.some((filter) =>
generateEntityFilter(hass, filter)(stateObj.entity_id)
);
const processAreasForSecurity = (
areaIds: string[],
hass: HomeAssistant,
@@ -132,7 +143,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 +253,51 @@ 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[] = [];
if (config.alert_entities?.length) {
sidebarSections.push({
type: "grid",
cards: [
{
type: "security-alerts",
alert_entities: config.alert_entities,
grid_options: { columns: 12 },
},
] satisfies LovelaceCardConfig[],
});
}
if (hasLogbook && logbookEntityIds.length > 0) {
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,
content_label: hass.localize(
"ui.panel.lovelace.strategy.security.devices"
),
+12
View File
@@ -0,0 +1,12 @@
import { css } from "lit";
export const pulseOpacityAnimation = css`
@keyframes pulse-opacity {
from {
opacity: 0;
}
to {
opacity: var(--ha-pulse-opacity, 0.3);
}
}
`;
+21
View File
@@ -244,6 +244,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",
@@ -2574,6 +2577,24 @@
"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.",
"edit_alert_entity": "Edit alert entity",
"alert_entity_description": "This entity will be displayed and highlighted with the selected color when all the conditions are fulfilled.",
"entity": "Entity",
"add_alert_entity": "Add entity",
"alert_color": {
"label": "Alert color"
},
"pulse": "Pulsate",
"visibility_conditions": "Visibility conditions",
"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.",
+37
View File
@@ -344,6 +344,43 @@ test.describe("Lovelace dashboard", () => {
});
});
// ---------------------------------------------------------------------------
// Security panel
// ---------------------------------------------------------------------------
test.describe("Security panel", () => {
test("renders configured active security alerts", async ({ page }) => {
await goToPanel(page, "/?scenario=security-alerts#/security");
await expect(page.locator("ha-panel-security")).toBeAttached({
timeout: PANEL_TIMEOUT,
});
const alertCard = page.locator("hui-security-alerts-card").first();
await expect(alertCard).toBeAttached({ timeout: PANEL_TIMEOUT });
if (!(await alertCard.isVisible().catch(() => false))) {
const activityTab = page.getByRole("radio", { name: "Activity" });
if (await activityTab.isVisible().catch(() => false)) {
await activityTab.dispatchEvent("click");
}
}
await expect(alertCard).toBeVisible({ timeout: QUICK_TIMEOUT });
await expect(alertCard.locator("text=Front door")).toBeVisible({
timeout: QUICK_TIMEOUT,
});
await page.evaluate(() => {
(window as any).__mockHass.mockEntities[
"binary_sensor.front_door"
].update({ state: "off" });
});
await expect(alertCard).toBeHidden({ timeout: QUICK_TIMEOUT });
});
});
// ---------------------------------------------------------------------------
// More-info dialog (light)
// ---------------------------------------------------------------------------
+7
View File
@@ -29,6 +29,13 @@ export const e2eTestPanels: Panels = {
config: null,
url_path: "history",
},
security: {
component_name: "security",
icon: "mdi:shield-home",
title: "security",
config: null,
url_path: "security",
},
config: {
component_name: "config",
icon: "mdi:cog",
+23
View File
@@ -1,4 +1,5 @@
import type { ExtEntityRegistryEntry } from "../../../../../src/data/entity/entity_registry";
import type { SecurityFrontendSystemData } from "../../../../../src/data/frontend";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
export type Scenario = (hass: MockHomeAssistant) => Promise<void> | void;
@@ -89,6 +90,27 @@ const lightMoreInfoScenario: Scenario = async (hass) => {
hass.mockWS("config/entity_registry/get", () => registryEntry);
};
const securityAlertsScenario: Scenario = async (hass) => {
const securityData: SecurityFrontendSystemData = {
alert_entities: [{ entity: "binary_sensor.front_door" }],
};
hass.addEntities([
{
entity_id: "binary_sensor.front_door",
state: "on",
attributes: {
friendly_name: "Front door",
device_class: "door",
},
},
]);
hass.mockWS("frontend/get_system_data", (msg: { key: string }) => ({
value: msg.key === "security" ? securityData : null,
}));
};
// ── Registry ──────────────────────────────────────────────────────────────
export const scenarios: Record<string, Scenario> = {
@@ -97,4 +119,5 @@ export const scenarios: Record<string, Scenario> = {
"dark-theme": darkThemeScenario,
"custom-theme": customThemeScenario,
"light-more-info": lightMoreInfoScenario,
"security-alerts": securityAlertsScenario,
};
@@ -0,0 +1,91 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { afterEach, describe, expect, it } from "vitest";
import type { SecurityAlertItem } from "../../../../../src/panels/security/strategies/security-alerts";
import "../../../../../src/panels/lovelace/cards/security-alerts/hui-security-alerts-list";
interface TestSecurityAlertsList extends HTMLElement {
updateComplete: Promise<boolean>;
_alerts: SecurityAlertItem[];
_formatters: {
formatEntityState: (stateObj: HassEntity) => string;
};
}
const state = (): HassEntity => ({
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 element = document.createElement(
"hui-security-alerts-list"
) as unknown as TestSecurityAlertsList;
element._alerts = alerts;
element._formatters = { formatEntityState: () => "On" };
document.body.appendChild(element);
await element.updateComplete;
return element;
};
describe("hui-security-alerts-list", () => {
afterEach(() => {
document.body.replaceChildren();
});
it("does not pulse when pulse is disabled", async () => {
const element = await createList([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 createList([alert(true)]);
const card = element.shadowRoot!.querySelector("ha-card")!;
expect(card.classList.contains("pulse")).toBe(true);
});
it("applies configured colors", async () => {
const element = await createList([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 createList([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("");
});
});
@@ -0,0 +1,302 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { describe, expect, it } from "vitest";
import {
computeDefaultSecurityAlertVisibility,
computeSecurityAlertEntityDefaultColor,
computeSecurityAlertItems,
isSecurityAlertEntity,
type SecurityAlertHass,
} from "../../../../../src/panels/security/strategies/security-alerts";
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 other entities to on", () => {
expect(computeDefaultSecurityAlertVisibility("binary_sensor.leak")).toEqual(
[
{
condition: "state",
entity: "binary_sensor.leak",
state: "on",
},
]
);
});
});
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("isSecurityAlertEntity", () => {
it("includes entities that can be active alerts", () => {
expect(
isSecurityAlertEntity(
state(
"binary_sensor.dishwasher_leak",
"off",
"moisture",
"2026-01-01T00:00:00Z"
)
)
).toBe(true);
expect(
isSecurityAlertEntity(
state("lock.front_door", "locked", undefined, "2026-01-01T00:00:00Z")
)
).toBe(true);
expect(
isSecurityAlertEntity(
state("camera.patio", "idle", undefined, "2026-01-01T00:00:00Z")
)
).toBe(true);
});
it("excludes entities that do not classify as alerts", () => {
expect(
isSecurityAlertEntity(
state("binary_sensor.motion", "off", "motion", "2026-01-01T00:00:00Z")
)
).toBe(false);
expect(
isSecurityAlertEntity(
state("light.kitchen", "on", undefined, "2026-01-01T00:00:00Z")
)
).toBe(false);
});
});
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"]);
});
});