mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-08 16:54:27 +01:00
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:
1 parent
872205c352
commit
d0371e506f
12 files changed
+982
-39
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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"
|
||||
),
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user