Compare commits

...
Author SHA1 Message Date
Maarten Lakerveld c84f7870e5 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.

The wrapper card's focus restoration now also 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, or on the entity's page tile when the
list runs out, and 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.

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-04 20:32:42 +01:00
Maarten Lakerveld 34fcf666da Let users hide entities from the climate page
Give the climate page the same hidden_entities system data, config
loading and inline visibility edit mode the maintenance page has. The
climate resolver also covers the area temperature and humidity
sensors, which the page adds outside the entity filters, so they can
be hidden too and the Home climate badge skips them.
2026-11-04 20:32:42 +01:00
Maarten Lakerveld bc5b0056a7 Let users hide entities from the maintenance page
Battery and problem sensors land on Maintenance purely by device class,
so a car's target charge level showed up with no way out short of hiding
the entity everywhere.

Store a hidden_entities list in maintenance frontend system data and let
the resolver take it, so the Home dashboard summary badge, the overview
check and the area views drop the same entities as the page. The pencil
enters an inline edit mode: the page regenerates with every tile wrapped
in a new entity-visibility card that dims hidden tiles with a dotted
border and toggles them on tap, saving at once.

Saves go through a queue shared by all panel instances, and a panel
loads its config only after that queue drains, so leaving and
reopening the page while a save is in flight cannot reload a snapshot
that lacks it. A failed save shows a toast and reloads, unless a newer
save is already queued on top of it.

Every tap regenerates the view, which replaces the focused tile. The
wrapper card remembers that its button fired while it had focus and the
recreated wrapper for the same entity focuses its button, so keyboard
users stay where they were.
2026-11-04 20:32:41 +01:00
27 changed files with 1669 additions and 850 deletions

No files matched your search

+17 -2
View File
@@ -163,9 +163,21 @@ export const findEntities = (
return results;
};
export const excludeEntities = (
entityIds: string[],
excludedEntityIds?: string[]
): string[] => {
if (!excludedEntityIds?.length) {
return entityIds;
}
const excluded = new Set(excludedEntityIds);
return entityIds.filter((entityId) => !excluded.has(entityId));
};
export const findFilteredEntities = (
sources: EntityFilterSources,
filters: EntityFilter[]
filters: EntityFilter[],
excludedEntityIds?: string[]
): string[] => {
const filterFuncs = filters.map((filter) =>
generateEntityFilter(
@@ -177,5 +189,8 @@ export const findFilteredEntities = (
filter
)
);
return findEntities(Object.keys(sources.states), filterFuncs);
return excludeEntities(
findEntities(Object.keys(sources.states), filterFuncs),
excludedEntityIds
);
};
+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)) {
+37
View File
@@ -49,6 +49,15 @@ export interface SecurityAlertEntityConfig {
export interface SecurityFrontendSystemData {
alert_entities?: SecurityAlertEntityConfig[];
favorite_entities?: string[];
hidden_entities?: string[];
}
export interface MaintenanceFrontendSystemData {
hidden_entities?: string[];
}
export interface ClimateFrontendSystemData {
hidden_entities?: string[];
}
export interface EnergyFrontendSystemData {
@@ -69,6 +78,8 @@ declare global {
home: HomeFrontendSystemData;
energy: EnergyFrontendSystemData;
security: SecurityFrontendSystemData;
maintenance: MaintenanceFrontendSystemData;
climate: ClimateFrontendSystemData;
}
}
@@ -145,6 +156,32 @@ export const saveFrontendSystemData = async <
value,
});
const pendingSystemDataSaves = new Map<ValidSystemDataKey, Promise<void>>();
export const queueFrontendSystemDataSave = <
SystemDataKey extends ValidSystemDataKey,
>(
conn: Connection,
key: SystemDataKey,
value: FrontendSystemData[SystemDataKey]
): Promise<void> => {
const save = (pendingSystemDataSaves.get(key) ?? Promise.resolve())
.catch(() => undefined)
.then(() => saveFrontendSystemData(conn, key, value));
pendingSystemDataSaves.set(key, save);
return save;
};
export const fetchFrontendSystemDataAfterSaves = async <
SystemDataKey extends ValidSystemDataKey,
>(
conn: Connection,
key: SystemDataKey
): Promise<FrontendSystemData[SystemDataKey] | null> => {
await pendingSystemDataSaves.get(key)?.catch(() => undefined);
return fetchFrontendSystemData(conn, key);
};
export const subscribeFrontendSystemData = <
SystemDataKey extends ValidSystemDataKey,
>(
+155 -12
View File
@@ -1,18 +1,28 @@
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 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 "../../components/ha-icon-button";
import "../../components/ha-top-app-bar-fixed";
import {
fetchFrontendSystemDataAfterSaves,
queueFrontendSystemDataSave,
type ClimateFrontendSystemData,
} 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 "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-background";
import "../lovelace/views/hui-view-container";
import "../../components/ha-top-app-bar-fixed";
const CLIMATE_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = {
strategy: {
@@ -30,10 +40,20 @@ class PanelClimate extends LitElement {
@state() private _lovelace?: Lovelace;
@state() private _config?: ClimateFrontendSystemData;
@state() private _editVisibility = false;
@state() private _searchParams = new URLSearchParams(window.location.search);
private _childPanelReady?: ChildPanelReady;
private _loadConfigPromise?: Promise<void>;
private _loadConfigRevision = 0;
private _setLovelaceRevision = 0;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Initial setup
@@ -47,13 +67,22 @@ class PanelClimate 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,24 +92,46 @@ class PanelClimate 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._loadConfigPromise = this._loadConfig();
await Promise.all([
this.hass.loadFragmentTranslation("lovelace"),
this._loadConfigPromise,
]);
this._setLovelace();
}
private async _loadConfig() {
const revision = ++this._loadConfigRevision;
this._config = undefined;
try {
const data = await fetchFrontendSystemDataAfterSaves(
this.hass.connection,
"climate"
);
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 climate configuration:", err);
showToast(this, {
message: this.hass.localize("ui.panel.climate.editor.load_failed"),
duration: 0,
dismissable: true,
});
}
}
private _debounceRegistriesChanged = debounce(
() => this._registriesChanged(),
200
@@ -97,6 +148,27 @@ class PanelClimate extends LitElement {
.backButton=${this._searchParams.has("historyBack")}
>
<div slot="title">${this.hass.localize("panel.climate")}</div>
${
!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.climate.editor.title")}
@click=${this._startEditVisibility}
></ha-icon-button>`
: nothing
}
${
this._lovelace
? html`
@@ -108,6 +180,7 @@ class PanelClimate extends LitElement {
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
@entity-visibility-changed=${this._visibilityChanged}
></hui-view
></hui-view-container>
`
@@ -118,11 +191,26 @@ class PanelClimate extends LitElement {
}
private async _setLovelace() {
const revision = ++this._setLovelaceRevision;
if (this._loadConfigPromise) {
await this._loadConfigPromise;
}
const viewConfig = await generateLovelaceViewStrategy(
CLIMATE_LOVELACE_VIEW_CONFIG,
{
strategy: {
...CLIMATE_LOVELACE_VIEW_CONFIG.strategy,
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: [CLIMATE_LOVELACE_VIEW_CONFIG] };
@@ -146,6 +234,61 @@ class PanelClimate extends LitElement {
};
}
private _startEditVisibility = () => {
this._editVisibility = true;
this._setLovelace();
};
private _stopEditVisibility = () => {
this._editVisibility = false;
if (!this._config) {
this._loadConfigPromise = this._loadConfig();
}
this._setLovelace();
};
private _visibilityChanged = (
ev: HASSDomEvent<HASSDomEvents["entity-visibility-changed"]>
) => {
if (!this._config) {
return;
}
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 _saveConfig(config: ClimateFrontendSystemData): Promise<void> {
this._config = config;
this._setLovelace();
return queueFrontendSystemDataSave(
this.hass.connection,
"climate",
config
).catch(async (err) => {
// eslint-disable-next-line no-console
console.error("Failed to save climate configuration:", err);
showToast(this, {
message: this.hass.localize("ui.panel.climate.editor.save_failed"),
duration: 0,
dismissable: true,
});
if (config !== this._config) {
return;
}
await this._loadConfig();
await this._setLovelace();
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
@@ -2,9 +2,11 @@ import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { getAreasFloorHierarchy } from "../../../common/areas/areas-floor-hierarchy";
import {
findEntities,
excludeEntities,
findFilteredEntities,
generateEntityFilter,
type EntityFilter,
type EntityFilterSources,
} from "../../../common/entity/entity_filter";
import { floorDefaultIcon } from "../../../components/ha-floor-icon";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
@@ -12,9 +14,15 @@ import type { LovelaceSectionRawConfig } from "../../../data/lovelace/config/sec
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import { computeAreaTileCardConfig } from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
import {
wrapEntityVisibility,
type ComputeEntityCard,
} from "../../lovelace/strategies/helpers/entity-visibility";
export interface ClimateViewStrategyConfig {
type: "climate";
hidden_entities?: string[];
edit_visibility?: boolean;
}
export const climateEntityFilters: EntityFilter[] = [
@@ -42,40 +50,45 @@ export const climateEntityFilters: EntityFilter[] = [
},
];
export const hasClimateEntities = (hass: HomeAssistant): boolean => {
const hasAreaSensor = Object.values(hass.areas).some(
(area) =>
(area.temperature_entity_id && hass.states[area.temperature_entity_id]) ||
(area.humidity_entity_id && hass.states[area.humidity_entity_id])
);
export const findClimateEntities = (
sources: EntityFilterSources,
hiddenEntities?: string[]
): string[] =>
findFilteredEntities(sources, climateEntityFilters, hiddenEntities);
if (hasAreaSensor) {
return true;
}
const entityIds = Object.keys(hass.states);
return climateEntityFilters.some((filter) =>
entityIds.some(
generateEntityFilter(
hass.states,
hass.entities,
hass.devices,
hass.areas,
hass.floors,
filter
export const findClimateAreaSensors = (
sources: EntityFilterSources,
hiddenEntities?: string[]
): string[] =>
excludeEntities(
Object.values(sources.areas).flatMap((area) =>
[area.temperature_entity_id, area.humidity_entity_id].filter(
(entityId): entityId is string =>
!!entityId && entityId in sources.states
)
)
),
hiddenEntities
);
};
export const hasClimateEntities = (
hass: HomeAssistant,
hiddenEntities?: string[]
): boolean =>
findClimateAreaSensors(hass, hiddenEntities).length > 0 ||
findClimateEntities(hass, hiddenEntities).length > 0;
interface ClimateCardOptions {
entities: string[];
showAreaSensor: (entityId: string) => boolean;
computeCard: ComputeEntityCard;
}
const processAreasForClimate = (
areaIds: string[],
hass: HomeAssistant,
entities: string[]
{ entities, showAreaSensor, computeCard }: ClimateCardOptions
): LovelaceCardConfig[] => {
const cards: LovelaceCardConfig[] = [];
const computeTileCard = computeAreaTileCardConfig(hass, true);
for (const areaId of areaIds) {
const area = hass.areas[areaId];
@@ -96,25 +109,21 @@ const processAreasForClimate = (
// Add temperature and humidity sensors with trend graphs for areas
const temperatureEntityId = area.temperature_entity_id;
if (temperatureEntityId && hass.states[temperatureEntityId]) {
areaCards.push({
...computeTileCard(temperatureEntityId),
name:
hass.localize("component.sensor.entity_component.temperature.name") ||
"Temperature",
features: [{ type: "trend-graph" }],
});
if (
temperatureEntityId &&
hass.states[temperatureEntityId] &&
showAreaSensor(temperatureEntityId)
) {
areaCards.push(computeCard(temperatureEntityId));
}
const humidityEntityId = area.humidity_entity_id;
if (humidityEntityId && hass.states[humidityEntityId]) {
areaCards.push({
...computeTileCard(humidityEntityId),
name:
hass.localize("component.sensor.entity_component.humidity.name") ||
"Humidity",
features: [{ type: "trend-graph" }],
});
if (
humidityEntityId &&
hass.states[humidityEntityId] &&
showAreaSensor(humidityEntityId)
) {
areaCards.push(computeCard(humidityEntityId));
}
// Add other climate entities
@@ -124,18 +133,7 @@ const processAreasForClimate = (
continue;
}
const state = hass.states[entityId];
if (
state?.attributes.device_class === "temperature" ||
state?.attributes.device_class === "humidity"
) {
areaCards.push({
...computeTileCard(entityId),
features: [{ type: "trend-graph" }],
});
} else {
areaCards.push(computeTileCard(entityId));
}
areaCards.push(computeCard(entityId));
}
if (areaCards.length > 0) {
@@ -159,7 +157,7 @@ const processAreasForClimate = (
const processUnassignedEntities = (
hass: HomeAssistant,
entities: string[]
{ entities, computeCard }: ClimateCardOptions
): LovelaceCardConfig[] => {
const unassignedFilter = generateEntityFilter(
hass.states,
@@ -173,10 +171,9 @@ const processUnassignedEntities = (
);
const unassignedEntities = entities.filter(unassignedFilter);
const areaCards: LovelaceCardConfig[] = [];
const computeTileCard = computeAreaTileCardConfig(hass, true);
for (const entityId of unassignedEntities) {
areaCards.push(computeTileCard(entityId));
areaCards.push(computeCard(entityId));
}
return areaCards;
@@ -185,7 +182,7 @@ const processUnassignedEntities = (
@customElement("climate-view-strategy")
export class ClimateViewStrategy extends ReactiveElement {
static async generate(
_config: ClimateViewStrategyConfig,
config: ClimateViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const areas = Object.values(hass.areas);
@@ -194,20 +191,41 @@ export class ClimateViewStrategy extends ReactiveElement {
const sections: LovelaceSectionRawConfig[] = [];
const allEntities = Object.keys(hass.states);
const climateFilters = climateEntityFilters.map((filter) =>
generateEntityFilter(
hass.states,
hass.entities,
hass.devices,
hass.areas,
hass.floors,
filter
)
const hiddenEntities = new Set(config.hidden_entities);
const editVisibility = Boolean(config.edit_visibility);
const entities = findClimateEntities(
hass,
editVisibility ? undefined : config.hidden_entities
);
const entities = findEntities(allEntities, climateFilters);
const areaSensorIds = new Set(findClimateAreaSensors(hass));
const computeTileCard = computeAreaTileCardConfig(hass, true);
const computeClimateCard = (entityId: string): LovelaceCardConfig => {
const tileCard = computeTileCard(entityId);
const deviceClass = hass.states[entityId]?.attributes.device_class;
if (areaSensorIds.has(entityId)) {
return {
...tileCard,
name:
hass.localize(
`component.sensor.entity_component.${deviceClass}.name`
) || undefined,
features: [{ type: "trend-graph" }],
};
}
if (deviceClass === "temperature" || deviceClass === "humidity") {
return { ...tileCard, features: [{ type: "trend-graph" }] };
}
return tileCard;
};
const cardOptions: ClimateCardOptions = {
entities,
showAreaSensor: (entityId) =>
editVisibility || !hiddenEntities.has(entityId),
computeCard: editVisibility
? wrapEntityVisibility(hiddenEntities, computeClimateCard)
: computeClimateCard,
};
const floorCount =
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
@@ -233,7 +251,7 @@ export class ClimateViewStrategy extends ReactiveElement {
],
};
const areaCards = processAreasForClimate(areaIds, hass, entities);
const areaCards = processAreasForClimate(areaIds, hass, cardOptions);
if (areaCards.length > 0) {
section.cards!.push(...areaCards);
@@ -257,7 +275,11 @@ export class ClimateViewStrategy extends ReactiveElement {
],
};
const areaCards = processAreasForClimate(hierarchy.areas, hass, entities);
const areaCards = processAreasForClimate(
hierarchy.areas,
hass,
cardOptions
);
if (areaCards.length > 0) {
section.cards!.push(...areaCards);
@@ -266,7 +288,7 @@ export class ClimateViewStrategy extends ReactiveElement {
}
// Process unassigned entities
const unassignedCards = processUnassignedEntities(hass, entities);
const unassignedCards = processUnassignedEntities(hass, cardOptions);
if (unassignedCards.length > 0) {
const section: LovelaceSectionRawConfig = {
+84 -6
View File
@@ -16,7 +16,9 @@ import {
fetchFrontendSystemData,
saveFrontendSystemData,
subscribeFrontendSystemData,
type ClimateFrontendSystemData,
type HomeFrontendSystemData,
type MaintenanceFrontendSystemData,
type SecurityFrontendSystemData,
} from "../../data/frontend";
import type { LovelaceDashboardStrategyConfig } from "../../data/lovelace/config/types";
@@ -54,6 +56,10 @@ class PanelHome extends SubscribeMixin(LitElement) {
@state() private _securityConfig: SecurityFrontendSystemData = {};
@state() private _maintenanceConfig: MaintenanceFrontendSystemData = {};
@state() private _climateConfig: ClimateFrontendSystemData = {};
@state() private _extraActionItems?: ExtraActionItem[];
@query(".banner") private _banner?: HTMLElement;
@@ -62,6 +68,10 @@ class PanelHome extends SubscribeMixin(LitElement) {
private _securityConfigRevision = 0;
private _maintenanceConfigRevision = 0;
private _climateConfigRevision = 0;
public hassSubscribe() {
return [
subscribeFrontendSystemData(
@@ -79,6 +89,36 @@ class PanelHome extends SubscribeMixin(LitElement) {
}
}
),
subscribeFrontendSystemData(
this.hass.connection,
"maintenance",
({ value }) => {
this._maintenanceConfigRevision++;
const config = value || {};
if (deepEqual(config, this._maintenanceConfig)) {
return;
}
this._maintenanceConfig = config;
if (this.hasUpdated) {
this._setLovelace();
}
}
),
subscribeFrontendSystemData(
this.hass.connection,
"climate",
({ value }) => {
this._climateConfigRevision++;
const config = value || {};
if (deepEqual(config, this._climateConfig)) {
return;
}
this._climateConfig = config;
if (this.hasUpdated) {
this._setLovelace();
}
}
),
];
}
@@ -179,12 +219,21 @@ class PanelHome extends SubscribeMixin(LitElement) {
private async _loadConfig() {
const securityConfigRevision = this._securityConfigRevision;
const [translationsResult, homeResult, securityResult] =
await Promise.allSettled([
this.hass.loadFragmentTranslation("lovelace"),
fetchFrontendSystemData(this.hass.connection, "home"),
fetchFrontendSystemData(this.hass.connection, "security"),
]);
const maintenanceConfigRevision = this._maintenanceConfigRevision;
const climateConfigRevision = this._climateConfigRevision;
const [
translationsResult,
homeResult,
securityResult,
maintenanceResult,
climateResult,
] = await Promise.allSettled([
this.hass.loadFragmentTranslation("lovelace"),
fetchFrontendSystemData(this.hass.connection, "home"),
fetchFrontendSystemData(this.hass.connection, "security"),
fetchFrontendSystemData(this.hass.connection, "maintenance"),
fetchFrontendSystemData(this.hass.connection, "climate"),
]);
if (translationsResult.status === "rejected") {
// eslint-disable-next-line no-console
@@ -214,6 +263,32 @@ class PanelHome extends SubscribeMixin(LitElement) {
} else if (securityConfigRevision === this._securityConfigRevision) {
this._securityConfig = securityResult.value || {};
}
if (maintenanceResult.status === "rejected") {
// eslint-disable-next-line no-console
console.error(
"Failed to load maintenance configuration:",
maintenanceResult.reason
);
if (maintenanceConfigRevision === this._maintenanceConfigRevision) {
this._maintenanceConfig = {};
}
} else if (maintenanceConfigRevision === this._maintenanceConfigRevision) {
this._maintenanceConfig = maintenanceResult.value || {};
}
if (climateResult.status === "rejected") {
// eslint-disable-next-line no-console
console.error(
"Failed to load climate configuration:",
climateResult.reason
);
if (climateConfigRevision === this._climateConfigRevision) {
this._climateConfig = {};
}
} else if (climateConfigRevision === this._climateConfigRevision) {
this._climateConfig = climateResult.value || {};
}
}
private _debounceRegenerateStrategy = debounce(
@@ -400,6 +475,9 @@ 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,
home_panel: true,
hide_welcome_message: this._config?.hide_welcome_message,
@@ -0,0 +1,481 @@
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, 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 { 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 { 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 { createEntityNotFoundWarning } from "../components/hui-warning";
import type {
EntityVisibilityAlertLevel,
EntityVisibilityCardConfig,
} from "./types";
const ALERT_LEVELS: EntityVisibilityAlertLevel[] = ["none", "warning", "alert"];
type EntityVisibilityControl = "hidden" | "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
extends LitElement
implements LovelaceCard
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ type: Boolean }) public preview = false;
@state() private _config?: EntityVisibilityCardConfig;
@query(".control") private _hiddenControl?: HTMLButtonElement;
@query(".favorite-control") private _favoriteControl?: HTMLButtonElement;
@query(".alert-control") private _alertControl?: HTMLElement;
private _card?: HuiCard;
public setConfig(config: EntityVisibilityCardConfig): void {
if (!config.entity) {
throw new Error("No entity configured");
}
if (!config.card) {
throw new Error("No card configured");
}
this._config = config;
this._card = document.createElement("hui-card");
this._card.hass = this.hass;
this._card.preview = this.preview;
this._card.config = config.card;
this._card.inert = true;
this._card.load();
}
public getCardSize(): number | Promise<number> {
return this._card?.getCardSize() ?? 1;
}
public getGridOptions(): LovelaceGridOptions {
return this._card?.getGridOptions() ?? {};
}
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
if (!this._card) {
return;
}
if (changedProps.has("hass")) {
this._card.hass = this.hass;
}
if (changedProps.has("preview")) {
this._card.preview = this.preview;
}
}
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 === "hidden"
? this._hiddenControl
: control === "favorite"
? this._favoriteControl
: this._alertControl;
element?.focus();
}
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}>
${
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!;
pendingFocus = {
key: !entryRemoved
? this._focusKey()
: config.neighbor_entity
? computeFocusKey(
config.neighbor_entity,
false,
Boolean(config.alert_select)
)
: computeFocusKey(config.entity, true, false),
control,
source: this,
};
}
private _toggleHidden(): void {
if (this._config?.hidden === undefined) {
return;
}
this._rememberFocus("hidden");
fireEvent(this, "entity-visibility-changed", {
entity: this._config.entity,
hidden: !this._config.hidden,
});
}
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-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;
}
.wrapper[hidden-entity]::after {
content: "";
position: absolute;
inset: 0;
border: 2px dotted var(--secondary-text-color);
border-radius: inherit;
pointer-events: none;
}
.control {
position: absolute;
inset: 0;
z-index: 1;
margin: 0;
padding: 0;
border: none;
background: none;
border-radius: inherit;
cursor: pointer;
}
.control:focus-visible {
outline: 2px solid var(--primary-color);
}
.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);
background: var(--secondary-background-color);
box-shadow: var(--ha-box-shadow-s);
--mdc-icon-size: 20px;
}
.eye {
color: var(--primary-text-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-entity-visibility-card": HuiEntityVisibilityCard;
}
interface HASSDomEvents {
"entity-visibility-changed": { entity: string; hidden: boolean };
"entity-favorite-changed": { entity: string; favorite: boolean };
"entity-alert-changed": {
entity: string;
severity: SecurityAlertSeverity | undefined;
};
}
}
@@ -191,12 +191,16 @@ export class HuiHomeSummaryCard extends LitElement implements LovelaceCard {
}
case "climate": {
// Min/Max temperature of the areas
const hiddenEntities = new Set(this._config.hidden_entities);
const areaSensors = Object.values(areas)
.map((area) => area.temperature_entity_id)
.filter(Boolean);
.filter(
(entityId): entityId is string =>
!!entityId && !hiddenEntities.has(entityId)
);
const sensorsValues = areaSensors
.map((entityId) => parseFloat(states[entityId!]?.state) || NaN)
.map((entityId) => parseFloat(states[entityId]?.state) || NaN)
.filter((value) => !isNaN(value));
if (sensorsValues.length === 0) {
@@ -233,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);
@@ -322,13 +323,10 @@ export class HuiHomeSummaryCard extends LitElement implements LovelaceCard {
: localize("ui.card.home-summary.no_media_playing");
}
case "maintenance": {
const maintenanceEntities = findMaintenanceEntities({
states,
entities,
devices,
areas,
floors,
});
const maintenanceEntities = findMaintenanceEntities(
{ states, entities, devices, areas, floors },
this._config.hidden_entities
);
const lowBatteryEntities = filterLowBatteryEntities(
states,
+17 -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,6 +74,18 @@ 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;
card: LovelaceCardConfig;
}
export interface EmptyStateButtonConfig {
text: string;
icon?: string;
@@ -751,6 +766,7 @@ export interface HeadingCardConfig extends LovelaceCardConfig {
export interface HomeSummaryCard extends LovelaceCardConfig {
summary: HomeSummary;
alert_entities?: SecurityAlertEntityConfig[];
hidden_entities?: string[];
vertical?: boolean;
tap_action?: ActionConfig;
hold_action?: ActionConfig;
@@ -38,6 +38,7 @@ const LAZY_LOAD_TYPES = {
area: () => import("../cards/hui-area-card"),
calendar: () => import("../cards/hui-calendar-card"),
conditional: () => import("../cards/hui-conditional-card"),
"entity-visibility": () => import("../cards/hui-entity-visibility-card"),
"empty-state": () => import("../cards/hui-empty-state-card"),
"energy-compare": () => import("../cards/energy/hui-energy-compare-card"),
"energy-carbon-consumed-gauge": () =>
@@ -0,0 +1,20 @@
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type { EntityVisibilityCardConfig } from "../../cards/types";
export type ComputeEntityCard = (entityId: string) => LovelaceCardConfig;
export const wrapEntityVisibility =
(
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),
});
@@ -3,6 +3,7 @@ import { customElement } from "lit/decorators";
import { computeDeviceName } from "../../../../common/entity/compute_device_name";
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
import {
excludeEntities,
findEntities,
generateEntityFilter,
} from "../../../../common/entity/entity_filter";
@@ -29,6 +30,9 @@ import {
export interface HomeAreaViewStrategyConfig {
type: "home-area";
area?: string;
security_hidden_entities?: string[];
maintenance_hidden_entities?: string[];
climate_hidden_entities?: string[];
home_panel?: boolean;
}
@@ -91,6 +95,12 @@ export class HomeAreaViewStrategy extends ReactiveElement {
const allEntities = Object.keys(hass.states);
const areaEntities = allEntities.filter(areaFilter);
const hiddenEntities: Partial<Record<HomeSummary, string[]>> = {
security: config.security_hidden_entities,
maintenance: config.maintenance_hidden_entities,
climate: config.climate_hidden_entities,
};
const entitiesBySummary = HOME_SUMMARIES.reduce(
(acc, summary) => {
const summariesFilters = HOME_SUMMARIES_FILTERS[summary];
@@ -104,7 +114,10 @@ export class HomeAreaViewStrategy extends ReactiveElement {
filter
)
);
acc[summary] = findEntities(areaEntities, filterFunctions);
acc[summary] = excludeEntities(
findEntities(areaEntities, filterFunctions),
hiddenEntities[summary]
);
return acc;
},
{} as Record<HomeSummary, string[]>
@@ -21,6 +21,9 @@ 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[];
home_panel?: boolean;
hide_welcome_message?: boolean;
@@ -72,6 +75,9 @@ export class HomeDashboardStrategy extends ReactiveElement {
strategy: {
type: "home-area",
area: area.area_id,
security_hidden_entities: config.security_hidden_entities,
maintenance_hidden_entities: config.maintenance_hidden_entities,
climate_hidden_entities: config.climate_hidden_entities,
home_panel: config.home_panel,
} satisfies HomeAreaViewStrategyConfig,
};
@@ -106,6 +112,9 @@ 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,
home_panel: config.home_panel,
hide_welcome_message: config.hide_welcome_message,
@@ -57,6 +57,9 @@ 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[];
home_panel?: boolean;
hide_welcome_message?: boolean;
@@ -360,11 +363,17 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
hass.panels.light && findEntities(allEntities, lightsFilters).length > 0;
const hasMediaPlayers =
findEntities(allEntities, mediaPlayerFilter).length > 0;
const hasClimate = hass.panels.climate && hasClimateEntities(hass);
const hasClimate =
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).length > 0;
hass.panels.maintenance &&
findMaintenanceEntities(hass, config.maintenance_hidden_entities).length >
0;
const alertEntities = config.alert_entities ?? [];
const alertSeverityEntities = filterSecurityAlertEntities(
@@ -423,6 +432,9 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
? ({
type: "home-summary",
summary: "climate",
hidden_entities: config.climate_hidden_entities?.length
? config.climate_hidden_entities
: undefined,
tap_action: {
action: "navigate",
navigation_path: "/climate?historyBack=1",
@@ -444,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: () =>
@@ -462,6 +477,9 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
? ({
type: "home-summary",
summary: "maintenance",
hidden_entities: config.maintenance_hidden_entities?.length
? config.maintenance_hidden_entities
: undefined,
tap_action: {
action: "navigate",
navigation_path: config.home_panel
+154 -11
View File
@@ -1,13 +1,23 @@
import { mdiPencil } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import 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 "../../components/ha-icon-button";
import "../../components/ha-top-app-bar-fixed";
import {
fetchFrontendSystemDataAfterSaves,
queueFrontendSystemDataSave,
type MaintenanceFrontendSystemData,
} 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 "../lovelace/views/hui-view";
@@ -30,10 +40,20 @@ class PanelMaintenance extends LitElement {
@state() private _lovelace?: Lovelace;
@state() private _config?: MaintenanceFrontendSystemData;
@state() private _editVisibility = false;
@state() private _searchParams = new URLSearchParams(window.location.search);
private _childPanelReady?: ChildPanelReady;
private _loadConfigPromise?: Promise<void>;
private _loadConfigRevision = 0;
private _setLovelaceRevision = 0;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Initial setup
@@ -47,13 +67,22 @@ class PanelMaintenance 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,24 +92,46 @@ class PanelMaintenance 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._loadConfigPromise = this._loadConfig();
await Promise.all([
this.hass.loadFragmentTranslation("lovelace"),
this._loadConfigPromise,
]);
this._setLovelace();
}
private async _loadConfig() {
const revision = ++this._loadConfigRevision;
this._config = undefined;
try {
const data = await fetchFrontendSystemDataAfterSaves(
this.hass.connection,
"maintenance"
);
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 maintenance configuration:", err);
showToast(this, {
message: this.hass.localize("ui.panel.maintenance.editor.load_failed"),
duration: 0,
dismissable: true,
});
}
}
private _debounceRegistriesChanged = debounce(
() => this._registriesChanged(),
200
@@ -97,6 +148,27 @@ class PanelMaintenance extends LitElement {
.backButton=${this._searchParams.has("historyBack")}
>
<div slot="title">${this.hass.localize("panel.maintenance")}</div>
${
!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.maintenance.editor.title")}
@click=${this._startEditVisibility}
></ha-icon-button>`
: nothing
}
${
this._lovelace
? html`
@@ -108,6 +180,7 @@ class PanelMaintenance extends LitElement {
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
@entity-visibility-changed=${this._visibilityChanged}
></hui-view>
</hui-view-container>
`
@@ -118,11 +191,26 @@ class PanelMaintenance extends LitElement {
}
private async _setLovelace() {
const revision = ++this._setLovelaceRevision;
if (this._loadConfigPromise) {
await this._loadConfigPromise;
}
const viewConfig = await generateLovelaceViewStrategy(
MAINTENANCE_LOVELACE_VIEW_CONFIG,
{
strategy: {
...MAINTENANCE_LOVELACE_VIEW_CONFIG.strategy,
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: [MAINTENANCE_LOVELACE_VIEW_CONFIG] };
@@ -146,6 +234,61 @@ class PanelMaintenance extends LitElement {
};
}
private _startEditVisibility = () => {
this._editVisibility = true;
this._setLovelace();
};
private _stopEditVisibility = () => {
this._editVisibility = false;
if (!this._config) {
this._loadConfigPromise = this._loadConfig();
}
this._setLovelace();
};
private _visibilityChanged = (
ev: HASSDomEvent<HASSDomEvents["entity-visibility-changed"]>
) => {
if (!this._config) {
return;
}
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 _saveConfig(config: MaintenanceFrontendSystemData): Promise<void> {
this._config = config;
this._setLovelace();
return queueFrontendSystemDataSave(
this.hass.connection,
"maintenance",
config
).catch(async (err) => {
// eslint-disable-next-line no-console
console.error("Failed to save maintenance configuration:", err);
showToast(this, {
message: this.hass.localize("ui.panel.maintenance.editor.save_failed"),
duration: 0,
dismissable: true,
});
if (config !== this._config) {
return;
}
await this._loadConfig();
await this._setLovelace();
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
@@ -14,6 +14,10 @@ import type { LovelaceSectionRawConfig } from "../../../data/lovelace/config/sec
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import type { TileCardConfig } from "../../lovelace/cards/types";
import {
wrapEntityVisibility,
type ComputeEntityCard,
} from "../../lovelace/strategies/helpers/entity-visibility";
import { BINARY_STATE_ON } from "../../../common/const";
import { computeDomain } from "../../../common/entity/compute_domain";
import { getDeviceEntityDisplayLookup } from "../../../data/device/device_registry";
@@ -21,6 +25,8 @@ import { findBatteryChargingEntity } from "../../../data/entity/entity_registry"
export interface MaintenanceViewStrategyConfig {
type: "maintenance";
hidden_entities?: string[];
edit_visibility?: boolean;
}
export const maintenanceEntityFilters: EntityFilter[] = [
@@ -35,8 +41,10 @@ export const maintenanceEntityFilters: EntityFilter[] = [
];
export const findMaintenanceEntities = (
sources: EntityFilterSources
): string[] => findFilteredEntities(sources, maintenanceEntityFilters);
sources: EntityFilterSources,
hiddenEntities?: string[]
): string[] =>
findFilteredEntities(sources, maintenanceEntityFilters, hiddenEntities);
const LOW_BATTERY_THRESHOLD = 20;
@@ -119,7 +127,8 @@ const computeMaintenanceTileCard = (
const processAreasForBattery = (
areaIds: string[],
hass: HomeAssistant,
entities: string[]
entities: string[],
computeCard: ComputeEntityCard
): LovelaceCardConfig[] => {
const cards: LovelaceCardConfig[] = [];
@@ -141,9 +150,7 @@ const processAreasForBattery = (
const areaCards: LovelaceCardConfig[] = [];
for (const entityId of areaBatteryEntities) {
areaCards.push(
computeMaintenanceTileCard(hass.states, hass.entities, entityId)
);
areaCards.push(computeCard(entityId));
}
if (areaCards.length > 0) {
@@ -167,7 +174,8 @@ const processAreasForBattery = (
const processUnassignedEntities = (
hass: HomeAssistant,
entities: string[]
entities: string[],
computeCard: ComputeEntityCard
): LovelaceCardConfig[] => {
const unassignedFilter = generateEntityFilter(
hass.states,
@@ -183,9 +191,7 @@ const processUnassignedEntities = (
const cards: LovelaceCardConfig[] = [];
for (const entityId of unassignedEntities) {
cards.push(
computeMaintenanceTileCard(hass.states, hass.entities, entityId)
);
cards.push(computeCard(entityId));
}
return cards;
@@ -194,7 +200,7 @@ const processUnassignedEntities = (
@customElement("maintenance-view-strategy")
export class MaintenanceViewStrategy extends ReactiveElement {
static async generate(
_config: MaintenanceViewStrategyConfig,
config: MaintenanceViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const areas = Object.values(hass.areas);
@@ -203,7 +209,16 @@ export class MaintenanceViewStrategy extends ReactiveElement {
const sections: LovelaceSectionRawConfig[] = [];
const entities = findMaintenanceEntities(hass);
const entities = findMaintenanceEntities(
hass,
config.edit_visibility ? undefined : config.hidden_entities
);
const computeTileCard = (entityId: string) =>
computeMaintenanceTileCard(hass.states, hass.entities, entityId);
const computeCard: ComputeEntityCard = config.edit_visibility
? wrapEntityVisibility(new Set(config.hidden_entities), computeTileCard)
: computeTileCard;
const floorCount =
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
@@ -229,7 +244,12 @@ export class MaintenanceViewStrategy extends ReactiveElement {
],
};
const areaCards = processAreasForBattery(areaIds, hass, entities);
const areaCards = processAreasForBattery(
areaIds,
hass,
entities,
computeCard
);
if (areaCards.length > 0) {
section.cards!.push(...areaCards);
@@ -255,7 +275,12 @@ export class MaintenanceViewStrategy extends ReactiveElement {
],
};
const areaCards = processAreasForBattery(hierarchy.areas, hass, entities);
const areaCards = processAreasForBattery(
hierarchy.areas,
hass,
entities,
computeCard
);
if (areaCards.length > 0) {
section.cards!.push(...areaCards);
@@ -264,7 +289,11 @@ export class MaintenanceViewStrategy extends ReactiveElement {
}
// Process unassigned entities
const unassignedCards = processUnassignedEntities(hass, entities);
const unassignedCards = processUnassignedEntities(
hass,
entities,
computeCard
);
if (unassignedCards.length > 0) {
const section: LovelaceSectionRawConfig = {
@@ -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,
});
};
+122 -29
View File
@@ -2,13 +2,15 @@ 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 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 "../../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 +20,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,6 +43,8 @@ class PanelSecurity extends LitElement {
@state() private _config?: SecurityFrontendSystemData;
@state() private _editVisibility = false;
@state() private _searchParms = new URLSearchParams(window.location.search);
private _childPanelReady?: ChildPanelReady;
@@ -49,6 +53,8 @@ class PanelSecurity extends LitElement {
private _loadConfigRevision = 0;
private _setLovelaceRevision = 0;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Initial setup
@@ -105,7 +111,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 +150,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 +181,9 @@ 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}
></hui-view
></hui-view-container>
`
@@ -174,6 +194,7 @@ class PanelSecurity extends LitElement {
}
private async _setLovelace() {
const revision = ++this._setLovelaceRevision;
if (this._loadConfigPromise) {
await this._loadConfigPromise;
}
@@ -184,11 +205,17 @@ 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] };
@@ -212,23 +239,89 @@ 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 _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,
});
};
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,
});
};
private _saveConfig(config: SecurityFrontendSystemData): Promise<void> {
this._config = config;
this._setLovelace();
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 +329,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();
});
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),
};
};
@@ -21,15 +21,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 +92,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 +116,8 @@ export const isSecurityPanelEntity = (
const processAreasForSecurity = (
areaIds: string[],
hass: HomeAssistant,
entities: string[]
entities: string[],
computeCard: ComputeEntityCard
): LovelaceCardConfig[] => {
const cards: LovelaceCardConfig[] = [];
@@ -121,10 +138,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 +163,8 @@ const processAreasForSecurity = (
const processUnassignedEntities = (
hass: HomeAssistant,
entities: string[]
entities: string[],
computeCard: ComputeEntityCard
): LovelaceCardConfig[] => {
const unassignedFilter = generateEntityFilter(
hass.states,
@@ -162,10 +178,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,14 +198,60 @@ 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 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 computeFavoriteCard = editVisibility
? (entityId: string, index: number): EntityVisibilityCardConfig => ({
type: "entity-visibility",
entity: entityId,
neighbor_entity:
favoriteEntities[index + 1] ?? favoriteEntities[index - 1],
...computeEditExtras(entityId),
card: computeFavoriteCardConfig(entityId),
})
: computeFavoriteCardConfig;
const computeCard = wrapCard(computeAreaTileCardConfig(hass, false));
if (favoriteEntities.length > 0) {
sections.push({
type: "grid",
@@ -203,7 +264,7 @@ export class SecurityViewStrategy extends ReactiveElement {
),
heading_style: "title",
},
...favoriteEntities.map(computeFavoriteCardConfig),
...favoriteEntities.map(computeFavoriteCard),
],
});
}
@@ -232,7 +293,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 +325,8 @@ export class SecurityViewStrategy extends ReactiveElement {
const areaCards = processAreasForSecurity(
hierarchy.areas,
hass,
entities
entities,
computeCard
);
if (areaCards.length > 0) {
@@ -269,7 +336,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 = {
@@ -303,18 +374,60 @@ 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,
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: [
+26 -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}",
@@ -2915,23 +2926,25 @@
},
"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"
}
},
"climate": {
"editor": {
"title": "Edit visible entities",
"load_failed": "Failed to load climate page configuration",
"save_failed": "Failed to save climate page configuration"
}
},
"maintenance": {
"editor": {
"title": "Edit visible entities",
"load_failed": "Failed to load maintenance page configuration",
"save_failed": "Failed to save maintenance 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.",
+15 -7
View File
@@ -710,15 +710,23 @@ describe("generateEntityFilter", () => {
});
describe("findFilteredEntities", () => {
it("should match entities against any of the filters", () => {
const entities = findFilteredEntities(mockHass, [
{ domain: "light" },
{ domain: "switch" },
]);
it("should match any filter and drop excluded entities", () => {
const entities = findFilteredEntities(
mockHass,
[{ domain: "light" }, { domain: "switch" }],
["light.bedroom"]
);
expect(entities).toContain("light.living_room");
expect(entities).toContain("switch.kitchen");
expect(entities).not.toContain("light.bedroom");
expect(entities).not.toContain("sensor.temperature");
});
it("should return every match when nothing is excluded", () => {
const entities = findFilteredEntities(mockHass, [{ domain: "light" }]);
expect(entities).toContain("light.living_room");
expect(entities).toContain("light.bedroom");
expect(entities).toContain("switch.kitchen");
expect(entities).not.toContain("sensor.temperature");
});
});
@@ -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,152 @@ 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",
favorite: true,
alert: "none",
card: expect.objectContaining({ entity: "binary_sensor.front_door" }),
},
{
type: "entity-visibility",
entity: "binary_sensor.gone",
neighbor_entity: "sensor.temperature",
favorite: true,
alert: "none",
card: expect.objectContaining({ entity: "binary_sensor.gone" }),
},
{
type: "entity-visibility",
entity: "sensor.temperature",
neighbor_entity: "binary_sensor.gone",
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",
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",
grid_options: { columns: 12 },
card: {
type: "alert",
entity: "binary_sensor.gone",
alert_color: "amber",
clear_color: "amber",
},
},
]);
});
});