Compare commits

...
Author SHA1 Message Date
Maarten Lakerveld a8ef9c0a15 Edit security favorites, alerts and visibility inline on the page
Give the security page a hidden_entities list like the other pages and replace its edit dialog with the inline edit mode. In edit mode every tile, favorites included, carries an eye, a star and, for alert-capable domains, a bell that cycles through none, warning and alert. Each tap saves at once. The Home dashboard summary badge, overview check and area views take the same hidden list.

Hidden, favorite and alert flags stay independent, so a hidden entity can still be a favorite or raise alerts, and the Home security summary stays while alerts are configured.

In edit mode the sidebar lists every configured alert, active or not, in its configured color, so a saved alert can always be changed or cleared there, including one whose entity no longer matches the page or was deleted. These entries pick the level with a three-way control instead of the cycling bell, so clearing is an explicit choice and an entry does not vanish on the way from alert back to warning. Changing a level keeps the alert in its place in the list. Favorites and alert entries show regardless of the hidden list, so they carry the star and the alert control but no eye. A deleted entity shows its ID in place of its card, so two stale entries stay distinguishable.

Toggling the eye only flips the wrapper's own flag and saves, like on the other pages. A star or bell tap moves the entity between the grid and the sidebar lists, so those regenerate the view, and the wrapper card's focus restoration covers the star and the alert controls and tells the grid tile and the sidebar entry of one entity apart, so the recreated wrapper focuses the control that fired. When a tap removes the entry itself, focus moves to the same control on the next entry in that list. When the list runs out, the last favorite hands focus to the first tile of the section that takes its place at the top of the page, and the last alert entry to the entity's own page tile if it has one; the strategy puts that fallback on the wrapper. When there is no fallback, or the recreated tile cannot take focus because the narrow layout shows the Activity tab, the card tells the panel and the panel focuses its exit-edit-mode button instead, so keyboard focus never drops to the page. Entering edit mode drops any request left over from an entry without either. ha-control-select gains a focus() that lands on its selected option for this. A failed save reloads the configuration and forces a rebuild so the wrappers show the stored state again.

The dialog, its alerts editor, the entity filter helper and the dialog test go away. Strategy tests pin down that a hidden entity keeps its favorite and alert entries, that stale favorites and alerts only show in edit mode, and the shape of the edit-mode wrappers.
2026-11-05 16:42:19 +01:00
20 changed files with 916 additions and 756 deletions

No files matched your search

+4
View File
@@ -62,6 +62,10 @@ export class HaControlSelect extends LitElement {
});
}
public override focus(): void {
this._focusOption(this._tabbableIndex);
}
private _handleBlur(ev: FocusEvent) {
// Only reset if focus is leaving the entire component
if (!this.contains(ev.relatedTarget as Node)) {
+1
View File
@@ -49,6 +49,7 @@ export interface SecurityAlertEntityConfig {
export interface SecurityFrontendSystemData {
alert_entities?: SecurityAlertEntityConfig[];
favorite_entities?: string[];
hidden_entities?: string[];
}
export interface MaintenanceFrontendSystemData {
+1
View File
@@ -475,6 +475,7 @@ class PanelHome extends SubscribeMixin(LitElement) {
strategy: {
type: "home",
alert_entities: this._securityConfig.alert_entities,
security_hidden_entities: this._securityConfig.hidden_entities,
maintenance_hidden_entities: this._maintenanceConfig.hidden_entities,
climate_hidden_entities: this._climateConfig.hidden_entities,
favorite_entities: this._config?.favorite_entities,
@@ -1,15 +1,62 @@
import { mdiEye, mdiEyeOff } from "@mdi/js";
import {
mdiBell,
mdiBellOutline,
mdiBellRing,
mdiEye,
mdiEyeOff,
mdiStar,
mdiStarOutline,
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-control-select";
import type { ControlSelectOption } from "../../../components/ha-control-select";
import "../../../components/ha-svg-icon";
import type { HomeAssistant } from "../../../types";
import type { SecurityAlertSeverity } from "../../../data/frontend";
import type { LocalizeFunc } from "../../../common/translations/localize";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import "./hui-card";
import type { HuiCard } from "./hui-card";
import type { LovelaceCard, LovelaceGridOptions } from "../types";
import type { EntityVisibilityCardConfig } from "./types";
import { createEntityNotFoundWarning } from "../components/hui-warning";
import type {
EntityVisibilityAlertLevel,
EntityVisibilityCardConfig,
} from "./types";
const ALERT_LEVELS: EntityVisibilityAlertLevel[] = ["none", "warning", "alert"];
type EntityVisibilityControl = "favorite" | "alert";
interface PendingFocus {
key: string;
control: EntityVisibilityControl;
source: HuiEntityVisibilityCard;
}
let pendingFocus: PendingFocus | undefined;
const computeFocusKey = (
entity: string,
onPage: boolean,
alertSelect: boolean
): string => `${entity}|${onPage}|${alertSelect}`;
export const clearEntityVisibilityFocus = (): void => {
pendingFocus = undefined;
};
const ALERT_ICONS: Record<EntityVisibilityAlertLevel, string> = {
none: mdiBellOutline,
warning: mdiBell,
alert: mdiBellRing,
};
@customElement("hui-entity-visibility-card")
export class HuiEntityVisibilityCard
@@ -22,6 +69,10 @@ export class HuiEntityVisibilityCard
@state() private _config?: EntityVisibilityCardConfig;
@query(".favorite-control") private _favoriteControl?: HTMLButtonElement;
@query(".alert-control") private _alertControl?: HTMLElement;
private _card?: HuiCard;
public setConfig(config: EntityVisibilityCardConfig): void {
@@ -77,32 +128,188 @@ export class HuiEntityVisibilityCard
}
}
protected updated(changedProps: PropertyValues<this>): void {
super.updated(changedProps);
if (
!this._config ||
!pendingFocus ||
pendingFocus.source === this ||
pendingFocus.key !== this._focusKey()
) {
return;
}
const { control } = pendingFocus;
pendingFocus = undefined;
const element =
control === "favorite" ? this._favoriteControl : this._alertControl;
element?.focus();
this._reportLostFocus(element);
}
private async _reportLostFocus(element?: HTMLElement): Promise<void> {
if (element instanceof LitElement) {
await element.updateComplete;
}
if (this.shadowRoot?.activeElement !== element) {
fireEvent(this, "entity-visibility-focus-lost");
}
}
protected render() {
if (!this._config || !this.hass || !this._card) {
return nothing;
}
const { localize } = this.hass;
const canHide = this._config.hidden !== undefined;
const alertSelect = Boolean(this._config.alert_select);
const hidden = Boolean(this._config.hidden);
const favorite = this._config.favorite;
const alert = this._config.alert;
const stateObj = this.hass.states[this._config.entity];
const name = stateObj
? this.hass.formatEntityName(stateObj)
: this._config.entity;
const alertLabel =
alert !== undefined
? localize("ui.card.entity-visibility.alert_level", {
label: name,
level: localize(`ui.card.entity-visibility.alert_levels.${alert}`),
})
: undefined;
return html`
<div class="wrapper" ?hidden-entity=${hidden}>
${this._card}
<button
class="control"
aria-label=${this.hass.localize(
`ui.components.items-display-editor.${hidden ? "show" : "hide"}`,
{ label: name }
)}
@click=${this._toggle}
>
<ha-svg-icon .path=${hidden ? mdiEyeOff : mdiEye}></ha-svg-icon>
</button>
${
stateObj
? this._card
: html`
<hui-warning>
<div class="missing">
<span class="missing-entity">${this._config.entity}</span>
<span>
${createEntityNotFoundWarning(localize, this.hass.config)}
</span>
</div>
</hui-warning>
`
}
${
canHide
? html`
<button
class="control"
aria-label=${localize(
`ui.components.items-display-editor.${hidden ? "show" : "hide"}`,
{ label: name }
)}
@click=${this._toggleHidden}
></button>
`
: nothing
}
<div class="actions">
${
alert === undefined
? nothing
: !alertSelect
? html`
<button
class=${classMap({
action: true,
"alert-control": true,
[alert]: true,
})}
aria-label=${ifDefined(alertLabel)}
@click=${this._cycleAlert}
>
<ha-svg-icon .path=${ALERT_ICONS[alert]}></ha-svg-icon>
</button>
`
: html`
<ha-control-select
class=${classMap({ "alert-control": true, [alert]: true })}
hide-option-label
.label=${localize(
"ui.card.entity-visibility.alert_level_group",
{ label: name }
)}
.options=${this._alertOptions(localize)}
.value=${alert}
@value-changed=${this._alertSelected}
></ha-control-select>
`
}
${
favorite !== undefined
? html`
<button
class=${classMap({
action: true,
"favorite-control": true,
favorite,
})}
aria-label=${localize(
`ui.card.entity-visibility.${favorite ? "remove_favorite" : "add_favorite"}`,
{ label: name }
)}
@click=${this._toggleFavorite}
>
<ha-svg-icon
.path=${favorite ? mdiStar : mdiStarOutline}
></ha-svg-icon>
</button>
`
: nothing
}
${
canHide
? html`
<ha-svg-icon
class="eye"
.path=${hidden ? mdiEyeOff : mdiEye}
></ha-svg-icon>
`
: nothing
}
</div>
</div>
`;
}
private _focusKey(): string {
const config = this._config!;
return computeFocusKey(
config.entity,
config.hidden !== undefined,
Boolean(config.alert_select)
);
}
private _rememberFocus(
control: EntityVisibilityControl,
entryRemoved = false
): void {
if (!this.shadowRoot?.activeElement) {
return;
}
const config = this._config!;
const key = !entryRemoved
? this._focusKey()
: config.neighbor_entity
? computeFocusKey(
config.neighbor_entity,
false,
Boolean(config.alert_select)
)
: config.fallback_entity
? computeFocusKey(config.fallback_entity, true, false)
: undefined;
if (!key) {
fireEvent(this, "entity-visibility-focus-lost");
return;
}
pendingFocus = { key, control, source: this };
}
private _entityVisibilityChanged = (
ev: HASSDomEvent<HASSDomEvents["entity-visibility-changed"]>
) => {
@@ -116,8 +323,8 @@ export class HuiEntityVisibilityCard
this._config = { ...this._config, hidden: ev.detail.hidden };
};
private _toggle(): void {
if (!this._config) {
private _toggleHidden(): void {
if (this._config?.hidden === undefined) {
return;
}
const hidden = !this._config.hidden;
@@ -128,16 +335,83 @@ export class HuiEntityVisibilityCard
});
}
private _toggleFavorite(ev: Event): void {
ev.stopPropagation();
if (!this._config) {
return;
}
const { favorite, hidden, alert_select: alertSelect } = this._config;
this._rememberFocus(
"favorite",
Boolean(favorite) && hidden === undefined && !alertSelect
);
fireEvent(this, "entity-favorite-changed", {
entity: this._config.entity,
favorite: !this._config.favorite,
});
}
private _alertOptions = memoizeOne(
(localize: LocalizeFunc): ControlSelectOption[] =>
ALERT_LEVELS.map((level) => ({
value: level,
path: ALERT_ICONS[level],
ariaLabel: localize(`ui.card.entity-visibility.alert_levels.${level}`),
}))
);
private _alertSelected(
ev: ValueChangedEvent<EntityVisibilityAlertLevel>
): void {
ev.stopPropagation();
if (!this._config?.alert || ev.detail.value === this._config.alert) {
return;
}
this._rememberFocus("alert", ev.detail.value === "none");
fireEvent(this, "entity-alert-changed", {
entity: this._config.entity,
severity: ev.detail.value === "none" ? undefined : ev.detail.value,
});
}
private _cycleAlert(ev: Event): void {
ev.stopPropagation();
if (!this._config?.alert) {
return;
}
const next =
ALERT_LEVELS[
(ALERT_LEVELS.indexOf(this._config.alert) + 1) % ALERT_LEVELS.length
];
this._rememberFocus("alert");
fireEvent(this, "entity-alert-changed", {
entity: this._config.entity,
severity: next === "none" ? undefined : next,
});
}
static styles: CSSResultGroup = css`
.wrapper {
position: relative;
height: 100%;
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
}
hui-card {
hui-card,
hui-warning {
display: block;
height: 100%;
}
.missing {
display: flex;
flex-direction: column;
min-width: 0;
}
.missing-entity {
font-weight: var(--ha-font-weight-medium);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wrapper[hidden-entity] hui-card {
opacity: 0.8;
}
@@ -153,21 +427,70 @@ export class HuiEntityVisibilityCard
position: absolute;
inset: 0;
z-index: 1;
display: flex;
align-items: flex-end;
justify-content: flex-end;
margin: 0;
padding: var(--ha-space-2);
padding: 0;
border: none;
background: none;
border-radius: inherit;
cursor: pointer;
color: var(--primary-text-color);
}
.control:focus-visible {
outline: 2px solid var(--primary-color);
}
.control ha-svg-icon {
.actions {
position: absolute;
bottom: var(--ha-space-2);
right: var(--ha-space-2);
inset-inline-end: var(--ha-space-2);
inset-inline-start: auto;
z-index: 2;
display: flex;
gap: var(--ha-space-1);
pointer-events: none;
}
.action {
margin: 0;
padding: 0;
border: none;
background: none;
cursor: pointer;
pointer-events: auto;
color: var(--primary-text-color);
}
.action:focus-visible {
outline: 2px solid var(--primary-color);
border-radius: var(--ha-border-radius-circle);
}
.action.favorite {
color: var(--primary-color);
}
.action.warning {
color: var(--warning-color);
}
.action.alert {
color: var(--error-color);
}
ha-control-select {
pointer-events: auto;
width: 96px;
--control-select-thickness: 28px;
--control-select-padding: 2px;
--control-select-border-radius: var(--ha-border-radius-pill);
--control-select-button-border-radius: var(--ha-border-radius-pill);
--control-select-background: var(--secondary-background-color);
--control-select-background-opacity: 1;
--control-select-color: var(--secondary-text-color);
--mdc-icon-size: 16px;
border-radius: var(--ha-border-radius-pill);
box-shadow: var(--ha-box-shadow-s);
}
ha-control-select.warning {
--control-select-color: var(--warning-color);
}
ha-control-select.alert {
--control-select-color: var(--error-color);
}
ha-svg-icon {
display: flex;
padding: var(--ha-space-1);
border-radius: var(--ha-border-radius-circle);
@@ -175,6 +498,9 @@ export class HuiEntityVisibilityCard
box-shadow: var(--ha-box-shadow-s);
--mdc-icon-size: 20px;
}
.eye {
color: var(--primary-text-color);
}
`;
}
@@ -189,5 +515,11 @@ declare global {
}
interface HASSDomEvents {
"entity-visibility-changed": { entity: string; hidden: boolean };
"entity-visibility-focus-lost": undefined;
"entity-favorite-changed": { entity: string; favorite: boolean };
"entity-alert-changed": {
entity: string;
severity: SecurityAlertSeverity | undefined;
};
}
}
@@ -237,13 +237,10 @@ export class HuiHomeSummaryCard extends LitElement implements LovelaceCard {
}
case "security": {
// Alarm and lock status
const securityEntities = findSecurityEntities({
states,
entities,
devices,
areas,
floors,
});
const securityEntities = findSecurityEntities(
{ states, entities, devices, areas, floors },
this._config.hidden_entities
);
const locks = securityEntities.filter((entityId) => {
const domain = computeDomain(entityId);
+11 -1
View File
@@ -4,7 +4,10 @@ import type { MapStyleConfig } from "../../../common/map/map-styles";
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 {
SecurityAlertEntityConfig,
SecurityAlertSeverity,
} from "../../../data/frontend";
import type { ActionConfig } from "../../../data/lovelace/config/action";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type {
@@ -71,9 +74,16 @@ export interface ConditionalCardConfig extends LovelaceCardConfig {
conditions: VisibilityCondition[];
}
export type EntityVisibilityAlertLevel = "none" | SecurityAlertSeverity;
export interface EntityVisibilityCardConfig extends LovelaceCardConfig {
entity: string;
hidden?: boolean;
favorite?: boolean;
alert?: EntityVisibilityAlertLevel;
alert_select?: boolean;
neighbor_entity?: string;
fallback_entity?: string;
card: LovelaceCardConfig;
}
@@ -4,10 +4,17 @@ import type { EntityVisibilityCardConfig } from "../../cards/types";
export type ComputeEntityCard = (entityId: string) => LovelaceCardConfig;
export const wrapEntityVisibility =
(hiddenEntities: Set<string>, computeCard: ComputeEntityCard) =>
(
hiddenEntities: Set<string>,
computeCard: ComputeEntityCard,
computeExtras?: (
entityId: string
) => Pick<EntityVisibilityCardConfig, "favorite" | "alert">
) =>
(entityId: string): EntityVisibilityCardConfig => ({
type: "entity-visibility",
entity: entityId,
hidden: hiddenEntities.has(entityId),
...computeExtras?.(entityId),
card: computeCard(entityId),
});
@@ -30,6 +30,7 @@ import {
export interface HomeAreaViewStrategyConfig {
type: "home-area";
area?: string;
security_hidden_entities?: string[];
climate_hidden_entities?: string[];
home_panel?: boolean;
}
@@ -94,6 +95,7 @@ export class HomeAreaViewStrategy extends ReactiveElement {
const areaEntities = allEntities.filter(areaFilter);
const hiddenEntities: Partial<Record<HomeSummary, string[]>> = {
security: config.security_hidden_entities,
climate: config.climate_hidden_entities,
};
@@ -21,6 +21,7 @@ import type { HomeOverviewViewStrategyConfig } from "./home-overview-view-strate
export interface HomeDashboardStrategyConfig {
type: "home";
alert_entities?: SecurityAlertEntityConfig[];
security_hidden_entities?: string[];
maintenance_hidden_entities?: string[];
climate_hidden_entities?: string[];
favorite_entities?: string[];
@@ -74,6 +75,7 @@ export class HomeDashboardStrategy extends ReactiveElement {
strategy: {
type: "home-area",
area: area.area_id,
security_hidden_entities: config.security_hidden_entities,
climate_hidden_entities: config.climate_hidden_entities,
home_panel: config.home_panel,
} satisfies HomeAreaViewStrategyConfig,
@@ -109,6 +111,7 @@ export class HomeDashboardStrategy extends ReactiveElement {
strategy: {
type: "home-overview",
alert_entities: config.alert_entities,
security_hidden_entities: config.security_hidden_entities,
maintenance_hidden_entities: config.maintenance_hidden_entities,
climate_hidden_entities: config.climate_hidden_entities,
favorite_entities: config.favorite_entities,
@@ -57,6 +57,7 @@ import { OTHER_DEVICES_FILTERS } from "./helpers/other-devices-filters";
export interface HomeOverviewViewStrategyConfig {
type: "home-overview";
alert_entities?: SecurityAlertEntityConfig[];
security_hidden_entities?: string[];
maintenance_hidden_entities?: string[];
climate_hidden_entities?: string[];
favorite_entities?: string[];
@@ -366,7 +367,9 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
hass.panels.climate &&
hasClimateEntities(hass, config.climate_hidden_entities);
const hasSecurity =
hass.panels.security && findSecurityEntities(hass).length > 0;
hass.panels.security &&
(findSecurityEntities(hass, config.security_hidden_entities).length > 0 ||
Boolean(config.alert_entities?.length));
const hasMaintenance =
hass.panels.maintenance &&
findMaintenanceEntities(hass, config.maintenance_hidden_entities).length >
@@ -453,6 +456,9 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
if (alertEntities.length) {
card.alert_entities = alertEntities;
}
if (config.security_hidden_entities?.length) {
card.hidden_entities = config.security_hidden_entities;
}
return card;
},
media_players: () =>
@@ -1,286 +0,0 @@
import type { ContextType } from "@lit/context";
import { mdiDelete, mdiDragHorizontalVariant } from "@mdi/js";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { consume } from "../../../common/decorators/consume";
import { ALERT_DOMAINS } from "../../../common/entity/alert_active";
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 {
createSecurityEntityFilter,
type SecurityEntityContext,
} from "../security-entity-filter";
import type { ValueChangedEvent } from "../../../types";
type SecurityEditorContext = SecurityEntityContext & {
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)}
.includeDomains=${ALERT_DOMAINS}
.entityFilter=${this._entityFilter}
@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 _entityFilter = createSecurityEntityFilter(() => this._entityContext);
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;
}
}
@@ -1,237 +0,0 @@
import type { ContextType } from "@lit/context";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, state } from "lit/decorators";
import { consume } from "../../../common/decorators/consume";
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,
registriesContext,
statesContext,
} 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/entity/ha-favorites-editor";
import "../components/security-alerts-editor";
import {
createSecurityEntityFilter,
type SecurityEntityContext,
} from "../security-entity-filter";
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>;
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@state()
@consume({ context: registriesContext, subscribe: true })
private _registries!: ContextType<typeof registriesContext>;
private _entityContext?: SecurityEntityContext;
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("_states") || changedProps.has("_registries")) {
this._entityContext = {
states: this._states,
...this._registries,
};
}
}
public connectedCallback(): void {
super.connectedCallback();
if (!this.params) {
return;
}
this._state = {
...this.params.config,
favorite_entities: this.params.config.favorite_entities
? [...this.params.config.favorite_entities]
: [],
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.favorite_entities"
)}
.secondary=${this._i18n.localize(
"ui.panel.security.editor.favorite_entities_description"
)}
>
<ha-icon slot="leading-icon" icon="mdi:star-outline"></ha-icon>
<div class="expansion-content">
<ha-favorites-editor
.favorites=${this._state?.favorite_entities ?? []}
.entityFilter=${this._entityFilter}
.addButtonLabel=${this._i18n.localize(
"ui.panel.security.editor.add_favorite_entity"
)}
@value-changed=${this._favoriteEntitiesChanged}
></ha-favorites-editor>
</div>
</ha-expansion-panel>
<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 _favoriteEntitiesChanged(
ev: ValueChangedEvent<SecurityFrontendSystemData["favorite_entities"]>
): void {
this._state = {
...this._state,
favorite_entities: ev.detail.value,
};
this._updateDirtyState(this._state);
}
private _entityFilter = createSecurityEntityFilter(() => this._entityContext);
private async _save(): Promise<void> {
if (!this.params || !this._state) return;
this._submitting = true;
try {
await this.params.saveConfig({
...this.params.config,
favorite_entities: this._state.favorite_entities?.length
? this._state.favorite_entities
: undefined,
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);
}
ha-expansion-panel + ha-expansion-panel {
margin-top: var(--ha-space-4);
}
.expansion-content {
padding: var(--ha-space-3);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"dialog-edit-security": DialogEditSecurity;
}
}
@@ -1,20 +0,0 @@
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,
});
};
+134 -32
View File
@@ -1,14 +1,17 @@
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 { customElement, property, query, state } from "lit/decorators";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-button";
import type { HaButton } from "../../components/ha-button";
import "../../components/ha-icon-button";
import "../../components/ha-top-app-bar-fixed";
import {
fetchFrontendSystemData,
saveFrontendSystemData,
fetchFrontendSystemDataAfterSaves,
queueFrontendSystemDataSave,
type SecurityFrontendSystemData,
} from "../../data/frontend";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
@@ -18,7 +21,7 @@ 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 { clearEntityVisibilityFocus } from "../lovelace/cards/hui-entity-visibility-card";
import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-container";
import "../lovelace/views/hui-view-background";
@@ -41,14 +44,20 @@ class PanelSecurity extends LitElement {
@state() private _config?: SecurityFrontendSystemData;
@state() private _editVisibility = false;
@state() private _searchParms = new URLSearchParams(window.location.search);
@query("ha-button") private _exitEditButton?: HaButton;
private _childPanelReady?: ChildPanelReady;
private _loadConfigPromise?: Promise<void>;
private _loadConfigRevision = 0;
private _setLovelaceRevision = 0;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Initial setup
@@ -105,7 +114,7 @@ class PanelSecurity extends LitElement {
const revision = ++this._loadConfigRevision;
this._config = undefined;
try {
const data = await fetchFrontendSystemData(
const data = await fetchFrontendSystemDataAfterSaves(
this.hass.connection,
"security"
);
@@ -144,14 +153,25 @@ class PanelSecurity extends LitElement {
>
<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.hass.user?.is_admin
? nothing
: this._editVisibility
? html`<ha-button
slot="actionItems"
appearance="filled"
size="s"
@click=${this._stopEditVisibility}
>
${this.hass.localize("ui.panel.lovelace.menu.exit_edit_mode")}
</ha-button>`
: this._config
? html`<ha-icon-button
slot="actionItems"
.path=${mdiPencil}
.label=${this.hass.localize("ui.panel.security.editor.title")}
@click=${this._startEditVisibility}
></ha-icon-button>`
: nothing
}
${
this._lovelace
@@ -164,6 +184,10 @@ class PanelSecurity extends LitElement {
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
@entity-visibility-changed=${this._visibilityChanged}
@entity-favorite-changed=${this._favoriteChanged}
@entity-alert-changed=${this._alertChanged}
@entity-visibility-focus-lost=${this._focusLost}
></hui-view
></hui-view-container>
`
@@ -173,7 +197,8 @@ class PanelSecurity extends LitElement {
`;
}
private async _setLovelace() {
private async _setLovelace(force = false) {
const revision = ++this._setLovelaceRevision;
if (this._loadConfigPromise) {
await this._loadConfigPromise;
}
@@ -184,15 +209,21 @@ class PanelSecurity extends LitElement {
...SECURITY_LOVELACE_VIEW_CONFIG.strategy,
alert_entities: this._config?.alert_entities,
favorite_entities: this._config?.favorite_entities,
hidden_entities: this._config?.hidden_entities,
edit_visibility: this._editVisibility || undefined,
},
},
this.hass
);
if (revision !== this._setLovelaceRevision) {
return;
}
const config = { views: [viewConfig] };
const rawConfig = { views: [SECURITY_LOVELACE_VIEW_CONFIG] };
if (deepEqual(config, this._lovelace?.config)) {
if (!force && deepEqual(config, this._lovelace?.config)) {
return;
}
@@ -212,23 +243,94 @@ class PanelSecurity extends LitElement {
};
}
private _editSecurity = () => {
if (!this.hass.user?.is_admin || !this._config) {
private _startEditVisibility = () => {
clearEntityVisibilityFocus();
this._editVisibility = true;
this._setLovelace();
};
private _stopEditVisibility = () => {
this._editVisibility = false;
if (!this._config) {
this._loadConfigPromise = this._loadConfig();
}
this._setLovelace();
};
private _focusLost = () => {
this._exitEditButton?.focus();
};
private _visibilityChanged = (
ev: HASSDomEvent<HASSDomEvents["entity-visibility-changed"]>
) => {
if (!this._config) {
return;
}
showEditSecurityDialog(this, {
config: this._config,
saveConfig: async (config) => {
await this._saveConfig(config);
},
const { entity, hidden } = ev.detail;
const hiddenEntities = (this._config.hidden_entities ?? []).filter(
(entityId) => entityId !== entity
);
if (hidden) {
hiddenEntities.push(entity);
}
this._saveConfig({
...this._config,
hidden_entities: hiddenEntities.length ? hiddenEntities : undefined,
});
};
private async _saveConfig(config: SecurityFrontendSystemData): Promise<void> {
try {
await saveFrontendSystemData(this.hass.connection, "security", config);
this._config = config;
} catch (err) {
private _favoriteChanged = (
ev: HASSDomEvent<HASSDomEvents["entity-favorite-changed"]>
) => {
if (!this._config) {
return;
}
const { entity, favorite } = ev.detail;
const favoriteEntities = (this._config.favorite_entities ?? []).filter(
(entityId) => entityId !== entity
);
if (favorite) {
favoriteEntities.push(entity);
}
this._saveConfig({
...this._config,
favorite_entities: favoriteEntities.length ? favoriteEntities : undefined,
});
this._setLovelace();
};
private _alertChanged = (
ev: HASSDomEvent<HASSDomEvents["entity-alert-changed"]>
) => {
if (!this._config) {
return;
}
const { entity, severity } = ev.detail;
const current = this._config.alert_entities ?? [];
const alertEntities = !severity
? current.filter((alertEntity) => alertEntity.entity !== entity)
: current.some((alertEntity) => alertEntity.entity === entity)
? current.map((alertEntity) =>
alertEntity.entity === entity
? { ...alertEntity, severity }
: alertEntity
)
: [...current, { entity, severity }];
this._saveConfig({
...this._config,
alert_entities: alertEntities.length ? alertEntities : undefined,
});
this._setLovelace();
};
private _saveConfig(config: SecurityFrontendSystemData): Promise<void> {
this._config = config;
return queueFrontendSystemDataSave(
this.hass.connection,
"security",
config
).catch(async (err) => {
// eslint-disable-next-line no-console
console.error("Failed to save security configuration:", err);
showToast(this, {
@@ -236,12 +338,12 @@ class PanelSecurity extends LitElement {
duration: 0,
dismissable: true,
});
throw err;
}
showToast(this, {
message: this.hass.localize("ui.common.successfully_saved"),
if (config !== this._config) {
return;
}
await this._loadConfig();
await this._setLovelace(true);
});
await this._setLovelace();
}
static get styles(): CSSResultGroup {
@@ -1,15 +0,0 @@
import type { ContextType } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { registriesContext, statesContext } from "../../data/context";
import { isSecurityPanelEntity } from "./strategies/security-view-strategy";
export type SecurityEntityContext = ContextType<typeof registriesContext> & {
states: ContextType<typeof statesContext>;
};
export const createSecurityEntityFilter =
(getContext: () => SecurityEntityContext | undefined) =>
(entity: HassEntity): boolean => {
const context = getContext();
return context ? isSecurityPanelEntity(context, entity) : false;
};
@@ -97,6 +97,10 @@ export const filterSecurityAlertEntities = (
) === severity
);
export const computeSecurityAlertColor = (
severity: SecurityAlertSeverity
): string => (severity === "alert" ? "red" : "amber");
export const computeSecurityAlertCardConfig = (
stateObj: HassEntity | undefined,
alertEntity: SecurityAlertEntityConfig
@@ -105,7 +109,7 @@ export const computeSecurityAlertCardConfig = (
return {
type: "alert",
entity: alertEntity.entity,
alert_color: severity === "alert" ? "red" : "amber",
alert_color: computeSecurityAlertColor(severity),
visibility: computeDefaultSecurityAlertVisibility(alertEntity.entity),
};
};
@@ -14,6 +14,7 @@ import {
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { floorDefaultIcon } from "../../../components/ha-floor-icon";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import { isStrategySection } from "../../../data/lovelace/config/section";
import type {
LovelaceSectionConfig,
LovelaceSectionRawConfig,
@@ -21,15 +22,28 @@ import type {
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 type {
EntityVisibilityCardConfig,
LogbookCardConfig,
} from "../../lovelace/cards/types";
import { computeAreaTileCardConfig } from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
import {
wrapEntityVisibility,
type ComputeEntityCard,
} from "../../lovelace/strategies/helpers/entity-visibility";
import { computeFavoriteCardConfig } from "../../lovelace/strategies/helpers/favorite-cards";
import { computeSecurityAlertCardConfig } from "./security-alerts";
import {
computeSecurityAlertCardConfig,
computeSecurityAlertColor,
resolveSecurityAlertSeverity,
} from "./security-alerts";
export interface SecurityViewStrategyConfig {
type: "security";
alert_entities?: SecurityAlertEntityConfig[];
favorite_entities?: string[];
hidden_entities?: string[];
edit_visibility?: boolean;
}
export const securityEntityFilters: EntityFilter[] = [
@@ -79,8 +93,11 @@ export const securityEntityFilters: EntityFilter[] = [
},
];
export const findSecurityEntities = (sources: EntityFilterSources): string[] =>
findFilteredEntities(sources, securityEntityFilters);
export const findSecurityEntities = (
sources: EntityFilterSources,
hiddenEntities?: string[]
): string[] =>
findFilteredEntities(sources, securityEntityFilters, hiddenEntities);
export const isSecurityPanelEntity = (
hass: EntityFilterSources,
@@ -100,7 +117,8 @@ export const isSecurityPanelEntity = (
const processAreasForSecurity = (
areaIds: string[],
hass: HomeAssistant,
entities: string[]
entities: string[],
computeCard: ComputeEntityCard
): LovelaceCardConfig[] => {
const cards: LovelaceCardConfig[] = [];
@@ -121,10 +139,8 @@ const processAreasForSecurity = (
const areaSecurityEntities = entities.filter(areaFilter);
const areaCards: LovelaceCardConfig[] = [];
const computeTileCard = computeAreaTileCardConfig(hass, false);
for (const entityId of areaSecurityEntities) {
areaCards.push(computeTileCard(entityId));
areaCards.push(computeCard(entityId));
}
if (areaCards.length > 0) {
@@ -148,7 +164,8 @@ const processAreasForSecurity = (
const processUnassignedEntities = (
hass: HomeAssistant,
entities: string[]
entities: string[],
computeCard: ComputeEntityCard
): LovelaceCardConfig[] => {
const unassignedFilter = generateEntityFilter(
hass.states,
@@ -162,10 +179,9 @@ const processUnassignedEntities = (
);
const unassignedLights = entities.filter(unassignedFilter);
const areaCards: LovelaceCardConfig[] = [];
const computeTileCard = computeAreaTileCardConfig(hass, false);
for (const entityId of unassignedLights) {
areaCards.push(computeTileCard(entityId));
areaCards.push(computeCard(entityId));
}
return areaCards;
@@ -183,30 +199,50 @@ export class SecurityViewStrategy extends ReactiveElement {
const sections: LovelaceSectionRawConfig[] = [];
const entities = findSecurityEntities(hass);
const favoriteEntities = (config.favorite_entities ?? []).filter(
(entityId) =>
hass.states[entityId] &&
isSecurityPanelEntity(hass, hass.states[entityId])
const editVisibility = Boolean(config.edit_visibility);
const entities = findSecurityEntities(
hass,
editVisibility ? undefined : config.hidden_entities
);
const pageEntities = new Set(entities);
if (favoriteEntities.length > 0) {
sections.push({
type: "grid",
column_span: 2,
cards: [
{
type: "heading",
heading: hass.localize(
"ui.panel.lovelace.strategy.security.favorites"
),
heading_style: "title",
},
...favoriteEntities.map(computeFavoriteCardConfig),
],
});
}
const favoriteEntities = editVisibility
? (config.favorite_entities ?? [])
: (config.favorite_entities ?? []).filter(
(entityId) =>
hass.states[entityId] &&
isSecurityPanelEntity(hass, hass.states[entityId])
);
const favoriteSet = new Set(config.favorite_entities);
const alertEntities = new Map(
(config.alert_entities ?? []).map((alertEntity) => [
alertEntity.entity,
alertEntity,
])
);
const computeEditExtras = (
entityId: string
): Pick<EntityVisibilityCardConfig, "favorite" | "alert"> => {
const alertEntity = alertEntities.get(entityId);
return {
favorite: favoriteSet.has(entityId),
alert: ALERT_DOMAINS.includes(computeDomain(entityId))
? alertEntity
? resolveSecurityAlertSeverity(alertEntity, hass.states[entityId])
: "none"
: undefined,
};
};
const wrapCard = (computeCard: ComputeEntityCard): ComputeEntityCard =>
editVisibility
? wrapEntityVisibility(
new Set(config.hidden_entities),
computeCard,
computeEditExtras
)
: computeCard;
const computeCard = wrapCard(computeAreaTileCardConfig(hass, false));
const floorCount =
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
@@ -232,7 +268,12 @@ export class SecurityViewStrategy extends ReactiveElement {
],
};
const areaCards = processAreasForSecurity(areaIds, hass, entities);
const areaCards = processAreasForSecurity(
areaIds,
hass,
entities,
computeCard
);
if (areaCards.length > 0) {
section.cards!.push(...areaCards);
@@ -259,7 +300,8 @@ export class SecurityViewStrategy extends ReactiveElement {
const areaCards = processAreasForSecurity(
hierarchy.areas,
hass,
entities
entities,
computeCard
);
if (areaCards.length > 0) {
@@ -269,7 +311,11 @@ export class SecurityViewStrategy extends ReactiveElement {
}
// Process unassigned entities
const unassignedCards = processUnassignedEntities(hass, entities);
const unassignedCards = processUnassignedEntities(
hass,
entities,
computeCard
);
if (unassignedCards.length > 0) {
const section: LovelaceSectionRawConfig = {
@@ -279,7 +325,7 @@ export class SecurityViewStrategy extends ReactiveElement {
{
type: "heading",
heading:
sections.length > 0
sections.length > 0 || favoriteEntities.length > 0
? hass.localize(
"ui.panel.lovelace.strategy.security.other_devices"
)
@@ -291,6 +337,41 @@ export class SecurityViewStrategy extends ReactiveElement {
sections.push(section);
}
if (favoriteEntities.length > 0) {
const firstPageEntity = editVisibility
? sections
.flatMap((section) =>
isStrategySection(section) ? [] : (section.cards ?? [])
)
.find((card) => card.type === "entity-visibility")?.entity
: undefined;
const computeFavoriteCard = editVisibility
? (entityId: string, index: number): EntityVisibilityCardConfig => ({
type: "entity-visibility",
entity: entityId,
neighbor_entity:
favoriteEntities[index + 1] ?? favoriteEntities[index - 1],
fallback_entity: firstPageEntity,
...computeEditExtras(entityId),
card: computeFavoriteCardConfig(entityId),
})
: computeFavoriteCardConfig;
sections.unshift({
type: "grid",
column_span: 2,
cards: [
{
type: "heading",
heading: hass.localize(
"ui.panel.lovelace.strategy.security.favorites"
),
heading_style: "title",
},
...favoriteEntities.map(computeFavoriteCard),
],
});
}
// Build sidebar with activity log
const hasLogbook = isComponentLoaded(hass.config, "logbook");
@@ -303,18 +384,63 @@ export class SecurityViewStrategy extends ReactiveElement {
const sidebarSections: LovelaceSectionConfig[] = [];
const alertCards = config.alert_entities
?.filter((alertEntity) =>
ALERT_DOMAINS.includes(computeDomain(alertEntity.entity))
)
.map((alertEntity) =>
computeSecurityAlertCardConfig(
hass.states[alertEntity.entity],
alertEntity
)
);
const alertEntityConfigs = (config.alert_entities ?? []).filter(
(alertEntity) => ALERT_DOMAINS.includes(computeDomain(alertEntity.entity))
);
if (alertCards?.length) {
const alertCards = editVisibility
? undefined
: alertEntityConfigs.map((alertEntity) =>
computeSecurityAlertCardConfig(
hass.states[alertEntity.entity],
alertEntity
)
);
if (editVisibility && alertEntityConfigs.length) {
sidebarSections.push({
type: "grid",
cards: [
{
type: "heading",
heading: hass.localize(
"ui.panel.lovelace.strategy.security.active_alerts"
),
heading_style: "title",
},
...alertEntityConfigs.map(
(alertEntity, index): EntityVisibilityCardConfig => {
const stateObj = hass.states[alertEntity.entity];
const severity = resolveSecurityAlertSeverity(
alertEntity,
stateObj
);
const color = computeSecurityAlertColor(severity);
return {
type: "entity-visibility",
entity: alertEntity.entity,
favorite: favoriteSet.has(alertEntity.entity),
alert: severity,
alert_select: true,
neighbor_entity: (
alertEntityConfigs[index + 1] ?? alertEntityConfigs[index - 1]
)?.entity,
fallback_entity: pageEntities.has(alertEntity.entity)
? alertEntity.entity
: undefined,
grid_options: { columns: 12 },
card: {
type: "alert",
entity: alertEntity.entity,
alert_color: color,
clear_color: color,
},
};
}
),
] satisfies LovelaceCardConfig[],
});
} else if (alertCards?.length) {
sidebarSections.push({
type: "grid",
visibility: [
+12 -13
View File
@@ -230,6 +230,17 @@
"logbook": {
"device_not_found": "{count, plural,\n one {A device used by this card no longer exists.}\n other {# devices used by this card no longer exist.}\n}"
},
"entity-visibility": {
"add_favorite": "Add {label} to favorites",
"remove_favorite": "Remove {label} from favorites",
"alert_level": "Alert level for {label}: {level}",
"alert_level_group": "Alert level for {label}",
"alert_levels": {
"none": "None",
"warning": "Warning",
"alert": "Alert"
}
},
"home-summary": {
"all_lights_off": "All off",
"count_active_alerts": "{count} {count, plural,\n one {active alert}\n other {active alerts}\n}",
@@ -2918,19 +2929,7 @@
},
"security": {
"editor": {
"title": "Edit security and safety page",
"description": "Configure your security and safety display preferences.",
"favorite_entities": "[%key:ui::panel::lovelace::editor::strategy::home::favorite_entities%]",
"favorite_entities_description": "Pin entities to the top of the page.",
"add_favorite_entity": "[%key:ui::panel::lovelace::editor::strategy::home::add_favorite_entity%]",
"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"
},
"title": "Edit visible entities",
"load_failed": "Failed to load security and safety page configuration",
"save_failed": "Failed to save security and safety page configuration"
}
@@ -1,70 +0,0 @@
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();
});
});
@@ -1,10 +1,51 @@
import { describe, expect, it } from "vitest";
import type { LovelaceCardConfig } from "../../../../src/data/lovelace/config/card";
import type { LovelaceSectionRawConfig } from "../../../../src/data/lovelace/config/section";
import { isStrategySection } from "../../../../src/data/lovelace/config/section";
import {
isSecurityPanelEntity,
SecurityViewStrategy,
} from "../../../../src/panels/security/strategies/security-view-strategy";
import { createMockEntityState, createMockHass } from "../../../fixtures/hass";
const createSecurityHass = () => {
const hass = createMockHass();
hass.config = { ...hass.config, components: [] };
for (const [entityId, deviceClass] of [
["binary_sensor.front_door", "door"],
["binary_sensor.window", "window"],
]) {
hass.states[entityId] = createMockEntityState(entityId, "off", {
device_class: deviceClass,
});
}
hass.states["sensor.temperature"] = createMockEntityState(
"sensor.temperature",
"21"
);
return hass;
};
const cardsOf = (section?: LovelaceSectionRawConfig): LovelaceCardConfig[] =>
!section || isStrategySection(section)
? []
: (section.cards ?? []).filter((card) => card.type !== "heading");
const sectionWithHeading = (
sections: LovelaceSectionRawConfig[] | undefined,
heading: string
) =>
sections?.find(
(section) =>
!isStrategySection(section) &&
section.cards?.some(
(card) => card.type === "heading" && card.heading === heading
)
);
const FAVORITES = "ui.panel.lovelace.strategy.security.favorites";
const ACTIVE_ALERTS = "ui.panel.lovelace.strategy.security.active_alerts";
describe("security-view-strategy", () => {
it("includes glass break binary sensors", () => {
const hass = createMockHass();
@@ -68,4 +109,157 @@ describe("security-view-strategy", () => {
},
]);
});
it("drops hidden entities from the tiles but keeps their favorite and alert entries", async () => {
const view = await SecurityViewStrategy.generate(
{
type: "security",
hidden_entities: ["binary_sensor.front_door"],
favorite_entities: ["binary_sensor.front_door"],
alert_entities: [{ entity: "binary_sensor.front_door" }],
},
createSecurityHass()
);
const favorites = sectionWithHeading(view.sections, FAVORITES);
const tiles = view.sections!.filter((section) => section !== favorites);
expect(tiles.flatMap(cardsOf).map((card) => card.entity)).toEqual([
"binary_sensor.window",
]);
expect(cardsOf(favorites).map((card) => card.entity)).toEqual([
"binary_sensor.front_door",
]);
expect(
cardsOf(sectionWithHeading(view.sidebar?.sections, ACTIVE_ALERTS)).map(
(card) => card.entity
)
).toEqual(["binary_sensor.front_door"]);
});
it("lists deleted and non-matching favorites only in edit mode", async () => {
const config = {
type: "security" as const,
favorite_entities: [
"binary_sensor.front_door",
"binary_sensor.gone",
"sensor.temperature",
],
};
const view = await SecurityViewStrategy.generate(
config,
createSecurityHass()
);
expect(
cardsOf(sectionWithHeading(view.sections, FAVORITES)).map(
(card) => card.entity
)
).toEqual(["binary_sensor.front_door"]);
const editView = await SecurityViewStrategy.generate(
{ ...config, edit_visibility: true },
createSecurityHass()
);
expect(cardsOf(sectionWithHeading(editView.sections, FAVORITES))).toEqual([
{
type: "entity-visibility",
entity: "binary_sensor.front_door",
neighbor_entity: "binary_sensor.gone",
fallback_entity: "binary_sensor.front_door",
favorite: true,
alert: "none",
card: expect.objectContaining({ entity: "binary_sensor.front_door" }),
},
{
type: "entity-visibility",
entity: "binary_sensor.gone",
neighbor_entity: "sensor.temperature",
fallback_entity: "binary_sensor.front_door",
favorite: true,
alert: "none",
card: expect.objectContaining({ entity: "binary_sensor.gone" }),
},
{
type: "entity-visibility",
entity: "sensor.temperature",
neighbor_entity: "binary_sensor.gone",
fallback_entity: "binary_sensor.front_door",
favorite: true,
alert: undefined,
card: expect.objectContaining({ entity: "sensor.temperature" }),
},
]);
});
it("keeps hidden tiles and lists every configured alert in edit mode", async () => {
const view = await SecurityViewStrategy.generate(
{
type: "security",
edit_visibility: true,
hidden_entities: ["binary_sensor.front_door"],
alert_entities: [
{ entity: "binary_sensor.front_door", severity: "alert" },
{ entity: "binary_sensor.gone" },
],
},
createSecurityHass()
);
expect(view.sections!.flatMap(cardsOf)).toEqual([
{
type: "entity-visibility",
entity: "binary_sensor.front_door",
hidden: true,
favorite: false,
alert: "alert",
card: expect.objectContaining({ entity: "binary_sensor.front_door" }),
},
{
type: "entity-visibility",
entity: "binary_sensor.window",
hidden: false,
favorite: false,
alert: "none",
card: expect.objectContaining({ entity: "binary_sensor.window" }),
},
]);
const alerts = sectionWithHeading(view.sidebar?.sections, ACTIVE_ALERTS);
expect(alerts?.visibility).toBeUndefined();
expect(cardsOf(alerts)).toEqual([
{
type: "entity-visibility",
entity: "binary_sensor.front_door",
favorite: false,
alert: "alert",
alert_select: true,
neighbor_entity: "binary_sensor.gone",
fallback_entity: "binary_sensor.front_door",
grid_options: { columns: 12 },
card: {
type: "alert",
entity: "binary_sensor.front_door",
alert_color: "red",
clear_color: "red",
},
},
{
type: "entity-visibility",
entity: "binary_sensor.gone",
favorite: false,
alert: "warning",
alert_select: true,
neighbor_entity: "binary_sensor.front_door",
fallback_entity: undefined,
grid_options: { columns: 12 },
card: {
type: "alert",
entity: "binary_sensor.gone",
alert_color: "amber",
clear_color: "amber",
},
},
]);
});
});