Compare commits

...
Author SHA1 Message Date
Maarten Lakerveld f075d544b8 Share one base class across the home summary panels
The maintenance, climate and security panels carried the same ~300 line shell: config loading with revision guards, queued saves with failure recovery, the edit visibility toggle and view generation. Move it into HaHomeSummaryPanelBase, keyed on the system data key, which doubles as the strategy type, URL path and translation namespace. Security only adds its strategy options and the favorite and alert handlers, which the base binds as optional hooks.

The view container now takes min-height: 100% of the top app bar's scroll area. Climate still had min-height: 100vh from before its top bar migration, which overflowed the scroll area by the header height, while security and maintenance had dropped it and cut themed backgrounds off below short content.

The home panel subscribed to and loaded the three configs in three copies of the same block. Keep them in one record keyed the same way and loop over the keys.
2026-11-05 16:42:36 +01:00
Maarten Lakerveld a8ef9c0a15 Edit security favorites, alerts and visibility inline on the page
Give the security page a hidden_entities list like the other pages and replace its edit dialog with the inline edit mode. In edit mode every tile, favorites included, carries an eye, a star and, for alert-capable domains, a bell that cycles through none, warning and alert. Each tap saves at once. The Home dashboard summary badge, overview check and area views take the same hidden list.

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

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

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

The dialog, its alerts editor, the entity filter helper and the dialog test go away. Strategy tests pin down that a hidden entity keeps its favorite and alert entries, that stale favorites and alerts only show in edit mode, and the shape of the edit-mode wrappers.
2026-11-05 16:42:19 +01:00
Maarten Lakerveld fbd68cb997 Resolve climate area sensors once and label them by slot
Build the visible area sensor map a single time with the hidden list applied, so the page and the Home badge share one rule and the separate showAreaSensor check goes away. Label the temperature and humidity tiles from the area slot they fill instead of reading device_class back off the state, which produced an empty name for sensors set through the API without a device class. Drop the trend-graph branch for non-area entities, which the climate filters never admit.
2026-11-05 16:42:19 +01:00
Maarten Lakerveld 64bb47aee5 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-05 16:42:19 +01:00
Maarten Lakerveld ffd7428a5b Test hidden entities in the maintenance strategy
Cover both modes of the generated view: hidden entities are left out
normally, and while editing they are kept as entity-visibility wrappers
with hidden set so users can show them again.
2026-11-05 16:42:19 +01:00
Maarten Lakerveld b0305e3ceb 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 and the overview check 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.

In edit mode every entity is shown anyway, so a tap only flips the wrapper's own hidden flag and the panel just saves; the view is regenerated when edit mode ends. Nothing is rebuilt per tap and focus stays on the tile that fired. A failed save reloads the configuration and forces a rebuild so the wrappers show the stored state again.

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.
2026-11-05 16:42:19 +01:00
29 changed files with 1734 additions and 1425 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)) {
+40
View File
@@ -49,8 +49,20 @@ 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 type HomeSummaryPanelSystemDataKey =
"security" | "maintenance" | "climate";
export interface EnergyFrontendSystemData {
// Stable "<view>.<card-type>" keys of energy dashboard cards the user has
// hidden. An absent key or array means nothing is hidden (all cards visible),
@@ -69,6 +81,8 @@ declare global {
home: HomeFrontendSystemData;
energy: EnergyFrontendSystemData;
security: SecurityFrontendSystemData;
maintenance: MaintenanceFrontendSystemData;
climate: ClimateFrontendSystemData;
}
}
@@ -145,6 +159,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,
>(
+4 -166
View File
@@ -1,171 +1,9 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import { ChildPanelReady } from "../../layouts/panel-ready";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
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: {
type: "climate",
},
};
import { customElement } from "lit/decorators";
import { HaHomeSummaryPanelBase } from "../home/ha-home-summary-panel-base";
@customElement("ha-panel-climate")
class PanelClimate extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@state() private _viewIndex = 0;
@state() private _lovelace?: Lovelace;
@state() private _searchParams = new URLSearchParams(window.location.search);
private _childPanelReady?: ChildPanelReady;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Initial setup
if (!this.hasUpdated) {
this._setup();
return;
}
if (!changedProps.has("hass")) {
return;
}
const oldHass = changedProps.get("hass") as this["hass"];
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
if (
oldHass.entities !== this.hass.entities ||
oldHass.devices !== this.hass.devices ||
oldHass.areas !== this.hass.areas ||
oldHass.floors !== this.hass.floors ||
oldHass.panels !== this.hass.panels
) {
if (this.hass.config.state === "RUNNING") {
this._debounceRegistriesChanged();
return;
}
}
// If ha started, refresh the config
if (
this.hass.config.state === "RUNNING" &&
oldHass.config.state !== "RUNNING"
) {
this._setLovelace();
}
}
}
private async _setup() {
await this.hass.loadFragmentTranslation("lovelace");
this._setLovelace();
}
private _debounceRegistriesChanged = debounce(
() => this._registriesChanged(),
200
);
private _registriesChanged = async () => {
this._setLovelace();
};
protected render() {
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${this._searchParams.has("historyBack")}
>
<div slot="title">${this.hass.localize("panel.climate")}</div>
${
this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}>
</hui-view-background>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view
></hui-view-container>
`
: nothing
}
</ha-top-app-bar-fixed>
`;
}
private async _setLovelace() {
const viewConfig = await generateLovelaceViewStrategy(
CLIMATE_LOVELACE_VIEW_CONFIG,
this.hass
);
const config = { views: [viewConfig] };
const rawConfig = { views: [CLIMATE_LOVELACE_VIEW_CONFIG] };
if (deepEqual(config, this._lovelace?.config)) {
return;
}
this._childPanelReady ??= new ChildPanelReady(this);
this._lovelace = {
config: config,
rawConfig: rawConfig,
editMode: false,
urlPath: "climate",
mode: "generated",
locale: this.hass.locale,
enableFullEditMode: () => undefined,
saveConfig: async () => undefined,
deleteConfig: async () => undefined,
setEditMode: () => undefined,
showToast: () => undefined,
};
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
user-select: none;
}
hui-view-container {
position: relative;
display: flex;
min-height: 100vh;
box-sizing: border-box;
}
hui-view {
flex: 1 1 100%;
max-width: 100%;
}
`,
];
}
class PanelClimate extends HaHomeSummaryPanelBase<"climate"> {
protected readonly _systemDataKey = "climate";
}
declare global {
@@ -2,9 +2,10 @@ import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { getAreasFloorHierarchy } from "../../../common/areas/areas-floor-hierarchy";
import {
findEntities,
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 +13,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 +49,53 @@ 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;
type ClimateAreaSensorKind = "temperature" | "humidity";
export const findClimateAreaSensors = (
sources: EntityFilterSources,
hiddenEntities?: string[]
): Map<string, ClimateAreaSensorKind> => {
const hidden = new Set(hiddenEntities);
const sensors = new Map<string, ClimateAreaSensorKind>();
for (const area of Object.values(sources.areas)) {
const slots = [
[area.temperature_entity_id, "temperature"],
[area.humidity_entity_id, "humidity"],
] as const;
for (const [entityId, kind] of slots) {
if (entityId && entityId in sources.states && !hidden.has(entityId)) {
sensors.set(entityId, kind);
}
}
}
const entityIds = Object.keys(hass.states);
return climateEntityFilters.some((filter) =>
entityIds.some(
generateEntityFilter(
hass.states,
hass.entities,
hass.devices,
hass.areas,
hass.floors,
filter
)
)
);
return sensors;
};
export const hasClimateEntities = (
hass: HomeAssistant,
hiddenEntities?: string[]
): boolean =>
findClimateAreaSensors(hass, hiddenEntities).size > 0 ||
findClimateEntities(hass, hiddenEntities).length > 0;
interface ClimateCardOptions {
entities: string[];
areaSensors: Map<string, ClimateAreaSensorKind>;
computeCard: ComputeEntityCard;
}
const processAreasForClimate = (
areaIds: string[],
hass: HomeAssistant,
entities: string[]
{ entities, areaSensors, computeCard }: ClimateCardOptions
): LovelaceCardConfig[] => {
const cards: LovelaceCardConfig[] = [];
const computeTileCard = computeAreaTileCardConfig(hass, true);
for (const areaId of areaIds) {
const area = hass.areas[areaId];
@@ -96,25 +116,13 @@ 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 && areaSensors.has(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 && areaSensors.has(humidityEntityId)) {
areaCards.push(computeCard(humidityEntityId));
}
// Add other climate entities
@@ -124,18 +132,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 +156,7 @@ const processAreasForClimate = (
const processUnassignedEntities = (
hass: HomeAssistant,
entities: string[]
{ entities, computeCard }: ClimateCardOptions
): LovelaceCardConfig[] => {
const unassignedFilter = generateEntityFilter(
hass.states,
@@ -173,10 +170,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 +181,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 +190,40 @@ 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 areaSensors = findClimateAreaSensors(
hass,
editVisibility ? undefined : config.hidden_entities
);
const computeTileCard = computeAreaTileCardConfig(hass, true);
const computeClimateCard = (entityId: string): LovelaceCardConfig => {
const tileCard = computeTileCard(entityId);
const areaSensor = areaSensors.get(entityId);
if (!areaSensor) {
return tileCard;
}
return {
...tileCard,
name:
hass.localize(
`component.sensor.entity_component.${areaSensor}.name`
) || undefined,
features: [{ type: "trend-graph" }],
};
};
const cardOptions: ClimateCardOptions = {
entities,
areaSensors,
computeCard: editVisibility
? wrapEntityVisibility(hiddenEntities, computeClimateCard)
: computeClimateCard,
};
const floorCount =
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
@@ -233,7 +249,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 +273,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 +286,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 = {
@@ -0,0 +1,324 @@
import { mdiPencil } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { property, query, state } from "lit/decorators";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-button";
import type { HaButton } from "../../components/ha-button";
import "../../components/ha-icon-button";
import "../../components/ha-top-app-bar-fixed";
import {
fetchFrontendSystemDataAfterSaves,
queueFrontendSystemDataSave,
type HomeSummaryPanelSystemDataKey,
} from "../../data/frontend";
import { ChildPanelReady } from "../../layouts/panel-ready";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { showToast } from "../../util/toast";
import { clearEntityVisibilityFocus } from "../lovelace/cards/hui-entity-visibility-card";
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";
export abstract class HaHomeSummaryPanelBase<
K extends HomeSummaryPanelSystemDataKey,
> extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@state() protected _config?: FrontendSystemData[K];
@state() private _lovelace?: Lovelace;
@state() private _editVisibility = false;
@state() private _searchParams = new URLSearchParams(window.location.search);
@query("ha-button") private _exitEditButton?: HaButton;
protected abstract readonly _systemDataKey: K;
private _childPanelReady?: ChildPanelReady;
private _loadConfigPromise?: Promise<void>;
private _loadConfigRevision = 0;
private _setLovelaceRevision = 0;
protected _strategyOptions(): Record<string, unknown> {
return {};
}
protected _favoriteChanged?(
ev: HASSDomEvent<HASSDomEvents["entity-favorite-changed"]>
): void;
protected _alertChanged?(
ev: HASSDomEvent<HASSDomEvents["entity-alert-changed"]>
): void;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._setup();
return;
}
if (!changedProps.has("hass")) {
return;
}
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 &&
(oldHass.entities !== this.hass.entities ||
oldHass.devices !== this.hass.devices ||
oldHass.areas !== this.hass.areas ||
oldHass.floors !== this.hass.floors ||
oldHass.panels !== this.hass.panels) &&
this.hass.config.state === "RUNNING"
) {
this._debounceRegistriesChanged();
}
}
private async _setup() {
this._loadConfigPromise = this._loadConfig();
await Promise.all([
this.hass.loadFragmentTranslation("lovelace"),
this._loadConfigPromise,
]);
await this._setLovelace();
}
private async _loadConfig() {
const revision = ++this._loadConfigRevision;
this._config = undefined;
try {
const data = await fetchFrontendSystemDataAfterSaves(
this.hass.connection,
this._systemDataKey
);
if (revision !== this._loadConfigRevision) {
return;
}
this._config = data || ({} as FrontendSystemData[K]);
} catch (err) {
if (revision !== this._loadConfigRevision) {
return;
}
this._editVisibility = false;
const key: HomeSummaryPanelSystemDataKey = this._systemDataKey;
// eslint-disable-next-line no-console
console.error(`Failed to load ${key} configuration:`, err);
showToast(this, {
message: this.hass.localize(`ui.panel.${key}.editor.load_failed`),
duration: 0,
dismissable: true,
});
}
}
private _debounceRegistriesChanged = debounce(() => this._setLovelace(), 200);
protected render() {
const key: HomeSummaryPanelSystemDataKey = this._systemDataKey;
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${this._searchParams.has("historyBack")}
>
<div slot="title">${this.hass.localize(`panel.${key}`)}</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.${key}.editor.title`)}
@click=${this._startEditVisibility}
></ha-icon-button>`
: nothing
}
${
this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}>
</hui-view-background>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${0}
@entity-visibility-changed=${this._visibilityChanged}
@entity-favorite-changed=${this._favoriteChanged}
@entity-alert-changed=${this._alertChanged}
@entity-visibility-focus-lost=${this._focusLost}
></hui-view>
</hui-view-container>
`
: nothing
}
</ha-top-app-bar-fixed>
`;
}
protected async _setLovelace(force = false) {
const revision = ++this._setLovelaceRevision;
if (this._loadConfigPromise) {
await this._loadConfigPromise;
}
const strategy = { type: this._systemDataKey };
const viewConfig = await generateLovelaceViewStrategy(
{
strategy: {
...strategy,
...this._strategyOptions(),
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: [{ strategy }] };
if (!force && deepEqual(config, this._lovelace?.config)) {
return;
}
this._childPanelReady ??= new ChildPanelReady(this);
this._lovelace = {
config: config,
rawConfig: rawConfig,
editMode: false,
urlPath: this._systemDataKey,
mode: "generated",
locale: this.hass.locale,
enableFullEditMode: () => undefined,
saveConfig: async () => undefined,
deleteConfig: async () => undefined,
setEditMode: () => undefined,
showToast: () => undefined,
};
}
private _startEditVisibility = () => {
clearEntityVisibilityFocus();
this._editVisibility = true;
this._setLovelace();
};
private _stopEditVisibility = () => {
this._editVisibility = false;
if (!this._config) {
this._loadConfigPromise = this._loadConfig();
}
this._setLovelace();
};
private _focusLost = () => {
this._exitEditButton?.focus();
};
private _visibilityChanged = (
ev: HASSDomEvent<HASSDomEvents["entity-visibility-changed"]>
) => {
if (!this._config) {
return;
}
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,
});
};
protected _saveConfig(config: FrontendSystemData[K]): Promise<void> {
this._config = config;
const key: HomeSummaryPanelSystemDataKey = this._systemDataKey;
return queueFrontendSystemDataSave(
this.hass.connection,
this._systemDataKey,
config
).catch(async (err) => {
// eslint-disable-next-line no-console
console.error(`Failed to save ${key} configuration:`, err);
showToast(this, {
message: this.hass.localize(`ui.panel.${key}.editor.save_failed`),
duration: 0,
dismissable: true,
});
if (config !== this._config) {
return;
}
await this._loadConfig();
await this._setLovelace(true);
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
user-select: none;
}
hui-view-container {
position: relative;
display: flex;
min-height: 100%;
box-sizing: border-box;
}
hui-view {
flex: 1 1 100%;
max-width: 100%;
}
`,
];
}
}
+59 -34
View File
@@ -17,7 +17,7 @@ import {
saveFrontendSystemData,
subscribeFrontendSystemData,
type HomeFrontendSystemData,
type SecurityFrontendSystemData,
type HomeSummaryPanelSystemDataKey,
} from "../../data/frontend";
import type { LovelaceDashboardStrategyConfig } from "../../data/lovelace/config/types";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
@@ -38,6 +38,16 @@ import { showEditHomeDialog } from "./dialogs/show-dialog-edit-home";
import { showNewOverviewDialog } from "./dialogs/show-dialog-new-overview";
import { hasLegacyOverviewPanel } from "../../data/panel";
const SUMMARY_PANEL_KEYS: HomeSummaryPanelSystemDataKey[] = [
"security",
"maintenance",
"climate",
];
type SummaryPanelConfigs = {
[K in HomeSummaryPanelSystemDataKey]: FrontendSystemData[K];
};
@customElement("ha-panel-home")
class PanelHome extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -52,7 +62,11 @@ class PanelHome extends SubscribeMixin(LitElement) {
@state() private _config?: HomeFrontendSystemData;
@state() private _securityConfig: SecurityFrontendSystemData = {};
@state() private _summaryConfigs: SummaryPanelConfigs = {
security: {},
maintenance: {},
climate: {},
};
@state() private _extraActionItems?: ExtraActionItem[];
@@ -60,26 +74,29 @@ class PanelHome extends SubscribeMixin(LitElement) {
private _loadConfigPromise?: Promise<void>;
private _securityConfigRevision = 0;
private _summaryConfigRevisions: Record<
HomeSummaryPanelSystemDataKey,
number
> = {
security: 0,
maintenance: 0,
climate: 0,
};
public hassSubscribe() {
return [
subscribeFrontendSystemData(
this.hass.connection,
"security",
({ value }) => {
this._securityConfigRevision++;
const config = value || {};
if (deepEqual(config, this._securityConfig)) {
return;
}
this._securityConfig = config;
if (this.hasUpdated) {
this._setLovelace();
}
return SUMMARY_PANEL_KEYS.map((key) =>
subscribeFrontendSystemData(this.hass.connection, key, ({ value }) => {
this._summaryConfigRevisions[key]++;
const config = value || {};
if (deepEqual(config, this._summaryConfigs[key])) {
return;
}
),
];
this._summaryConfigs = { ...this._summaryConfigs, [key]: config };
if (this.hasUpdated) {
this._setLovelace();
}
})
);
}
private get _showBanner(): boolean {
@@ -178,12 +195,14 @@ class PanelHome extends SubscribeMixin(LitElement) {
}
private async _loadConfig() {
const securityConfigRevision = this._securityConfigRevision;
const [translationsResult, homeResult, securityResult] =
const summaryRevisions = { ...this._summaryConfigRevisions };
const [translationsResult, homeResult, ...summaryResults] =
await Promise.allSettled([
this.hass.loadFragmentTranslation("lovelace"),
fetchFrontendSystemData(this.hass.connection, "home"),
fetchFrontendSystemData(this.hass.connection, "security"),
...SUMMARY_PANEL_KEYS.map((key) =>
fetchFrontendSystemData(this.hass.connection, key)
),
]);
if (translationsResult.status === "rejected") {
@@ -202,18 +221,20 @@ class PanelHome extends SubscribeMixin(LitElement) {
this._config = homeResult.value || {};
}
if (securityResult.status === "rejected") {
// eslint-disable-next-line no-console
console.error(
"Failed to load security configuration:",
securityResult.reason
);
if (securityConfigRevision === this._securityConfigRevision) {
this._securityConfig = {};
const summaryConfigs = { ...this._summaryConfigs };
SUMMARY_PANEL_KEYS.forEach((key, index) => {
if (summaryRevisions[key] !== this._summaryConfigRevisions[key]) {
return;
}
} else if (securityConfigRevision === this._securityConfigRevision) {
this._securityConfig = securityResult.value || {};
}
const result = summaryResults[index];
if (result.status === "rejected") {
// eslint-disable-next-line no-console
console.error(`Failed to load ${key} configuration:`, result.reason);
}
summaryConfigs[key] =
result.status === "fulfilled" && result.value ? result.value : {};
});
this._summaryConfigs = summaryConfigs;
}
private _debounceRegenerateStrategy = debounce(
@@ -399,7 +420,11 @@ class PanelHome extends SubscribeMixin(LitElement) {
return {
strategy: {
type: "home",
alert_entities: this._securityConfig.alert_entities,
alert_entities: this._summaryConfigs.security.alert_entities,
security_hidden_entities: this._summaryConfigs.security.hidden_entities,
maintenance_hidden_entities:
this._summaryConfigs.maintenance.hidden_entities,
climate_hidden_entities: this._summaryConfigs.climate.hidden_entities,
favorite_entities: this._config?.favorite_entities,
home_panel: true,
hide_welcome_message: this._config?.hide_welcome_message,
@@ -0,0 +1,525 @@
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 type { HASSDomEvent } from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-control-select";
import type { ControlSelectOption } from "../../../components/ha-control-select";
import "../../../components/ha-svg-icon";
import type { 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 = "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(".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() ?? {};
}
public connectedCallback(): void {
super.connectedCallback();
window.addEventListener(
"entity-visibility-changed",
this._entityVisibilityChanged
);
}
public disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener(
"entity-visibility-changed",
this._entityVisibilityChanged
);
}
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 === "favorite" ? this._favoriteControl : this._alertControl;
element?.focus();
this._reportLostFocus(element);
}
private async _reportLostFocus(element?: HTMLElement): Promise<void> {
if (element instanceof LitElement) {
await element.updateComplete;
}
if (this.shadowRoot?.activeElement !== element) {
fireEvent(this, "entity-visibility-focus-lost");
}
}
protected render() {
if (!this._config || !this.hass || !this._card) {
return nothing;
}
const { localize } = this.hass;
const canHide = this._config.hidden !== undefined;
const alertSelect = Boolean(this._config.alert_select);
const hidden = Boolean(this._config.hidden);
const favorite = this._config.favorite;
const alert = this._config.alert;
const stateObj = this.hass.states[this._config.entity];
const name = stateObj
? this.hass.formatEntityName(stateObj)
: this._config.entity;
const alertLabel =
alert !== undefined
? localize("ui.card.entity-visibility.alert_level", {
label: name,
level: localize(`ui.card.entity-visibility.alert_levels.${alert}`),
})
: undefined;
return html`
<div class="wrapper" ?hidden-entity=${hidden}>
${
stateObj
? this._card
: html`
<hui-warning>
<div class="missing">
<span class="missing-entity">${this._config.entity}</span>
<span>
${createEntityNotFoundWarning(localize, this.hass.config)}
</span>
</div>
</hui-warning>
`
}
${
canHide
? html`
<button
class="control"
aria-label=${localize(
`ui.components.items-display-editor.${hidden ? "show" : "hide"}`,
{ label: name }
)}
@click=${this._toggleHidden}
></button>
`
: nothing
}
<div class="actions">
${
alert === undefined
? nothing
: !alertSelect
? html`
<button
class=${classMap({
action: true,
"alert-control": true,
[alert]: true,
})}
aria-label=${ifDefined(alertLabel)}
@click=${this._cycleAlert}
>
<ha-svg-icon .path=${ALERT_ICONS[alert]}></ha-svg-icon>
</button>
`
: html`
<ha-control-select
class=${classMap({ "alert-control": true, [alert]: true })}
hide-option-label
.label=${localize(
"ui.card.entity-visibility.alert_level_group",
{ label: name }
)}
.options=${this._alertOptions(localize)}
.value=${alert}
@value-changed=${this._alertSelected}
></ha-control-select>
`
}
${
favorite !== undefined
? html`
<button
class=${classMap({
action: true,
"favorite-control": true,
favorite,
})}
aria-label=${localize(
`ui.card.entity-visibility.${favorite ? "remove_favorite" : "add_favorite"}`,
{ label: name }
)}
@click=${this._toggleFavorite}
>
<ha-svg-icon
.path=${favorite ? mdiStar : mdiStarOutline}
></ha-svg-icon>
</button>
`
: nothing
}
${
canHide
? html`
<ha-svg-icon
class="eye"
.path=${hidden ? mdiEyeOff : mdiEye}
></ha-svg-icon>
`
: nothing
}
</div>
</div>
`;
}
private _focusKey(): string {
const config = this._config!;
return computeFocusKey(
config.entity,
config.hidden !== undefined,
Boolean(config.alert_select)
);
}
private _rememberFocus(
control: EntityVisibilityControl,
entryRemoved = false
): void {
if (!this.shadowRoot?.activeElement) {
return;
}
const config = this._config!;
const key = !entryRemoved
? this._focusKey()
: config.neighbor_entity
? computeFocusKey(
config.neighbor_entity,
false,
Boolean(config.alert_select)
)
: config.fallback_entity
? computeFocusKey(config.fallback_entity, true, false)
: undefined;
if (!key) {
fireEvent(this, "entity-visibility-focus-lost");
return;
}
pendingFocus = { key, control, source: this };
}
private _entityVisibilityChanged = (
ev: HASSDomEvent<HASSDomEvents["entity-visibility-changed"]>
) => {
if (
this._config?.hidden === undefined ||
ev.detail.entity !== this._config.entity ||
ev.detail.hidden === this._config.hidden
) {
return;
}
this._config = { ...this._config, hidden: ev.detail.hidden };
};
private _toggleHidden(): void {
if (this._config?.hidden === undefined) {
return;
}
const hidden = !this._config.hidden;
this._config = { ...this._config, hidden };
fireEvent(this, "entity-visibility-changed", {
entity: this._config.entity,
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 GlobalEventHandlersEventMap {
"entity-visibility-changed": HASSDomEvent<
HASSDomEvents["entity-visibility-changed"]
>;
}
interface HASSDomEvents {
"entity-visibility-changed": { entity: string; hidden: boolean };
"entity-visibility-focus-lost": undefined;
"entity-favorite-changed": { entity: string; favorite: boolean };
"entity-alert-changed": {
entity: string;
severity: SecurityAlertSeverity | undefined;
};
}
}
@@ -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,
+18 -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,19 @@ export interface ConditionalCardConfig extends LovelaceCardConfig {
conditions: VisibilityCondition[];
}
export type EntityVisibilityAlertLevel = "none" | SecurityAlertSeverity;
export interface EntityVisibilityCardConfig extends LovelaceCardConfig {
entity: string;
hidden?: boolean;
favorite?: boolean;
alert?: EntityVisibilityAlertLevel;
alert_select?: boolean;
neighbor_entity?: string;
fallback_entity?: string;
card: LovelaceCardConfig;
}
export interface EmptyStateButtonConfig {
text: string;
icon?: string;
@@ -751,6 +767,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,8 @@ import {
export interface HomeAreaViewStrategyConfig {
type: "home-area";
area?: string;
security_hidden_entities?: string[];
climate_hidden_entities?: string[];
home_panel?: boolean;
}
@@ -91,6 +94,11 @@ 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,
climate: config.climate_hidden_entities,
};
const entitiesBySummary = HOME_SUMMARIES.reduce(
(acc, summary) => {
const summariesFilters = HOME_SUMMARIES_FILTERS[summary];
@@ -104,7 +112,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,8 @@ export class HomeDashboardStrategy extends ReactiveElement {
strategy: {
type: "home-area",
area: area.area_id,
security_hidden_entities: config.security_hidden_entities,
climate_hidden_entities: config.climate_hidden_entities,
home_panel: config.home_panel,
} satisfies HomeAreaViewStrategyConfig,
};
@@ -106,6 +111,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
+4 -165
View File
@@ -1,170 +1,9 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-top-app-bar-fixed";
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 { 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";
const MAINTENANCE_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = {
strategy: {
type: "maintenance",
},
};
import { customElement } from "lit/decorators";
import { HaHomeSummaryPanelBase } from "../home/ha-home-summary-panel-base";
@customElement("ha-panel-maintenance")
class PanelMaintenance extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@state() private _viewIndex = 0;
@state() private _lovelace?: Lovelace;
@state() private _searchParams = new URLSearchParams(window.location.search);
private _childPanelReady?: ChildPanelReady;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Initial setup
if (!this.hasUpdated) {
this._setup();
return;
}
if (!changedProps.has("hass")) {
return;
}
const oldHass = changedProps.get("hass") as this["hass"];
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
if (
oldHass.entities !== this.hass.entities ||
oldHass.devices !== this.hass.devices ||
oldHass.areas !== this.hass.areas ||
oldHass.floors !== this.hass.floors ||
oldHass.panels !== this.hass.panels
) {
if (this.hass.config.state === "RUNNING") {
this._debounceRegistriesChanged();
return;
}
}
// If ha started, refresh the config
if (
this.hass.config.state === "RUNNING" &&
oldHass.config.state !== "RUNNING"
) {
this._setLovelace();
}
}
}
private async _setup() {
await this.hass.loadFragmentTranslation("lovelace");
this._setLovelace();
}
private _debounceRegistriesChanged = debounce(
() => this._registriesChanged(),
200
);
private _registriesChanged = async () => {
this._setLovelace();
};
protected render() {
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${this._searchParams.has("historyBack")}
>
<div slot="title">${this.hass.localize("panel.maintenance")}</div>
${
this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}>
</hui-view-background>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view>
</hui-view-container>
`
: nothing
}
</ha-top-app-bar-fixed>
`;
}
private async _setLovelace() {
const viewConfig = await generateLovelaceViewStrategy(
MAINTENANCE_LOVELACE_VIEW_CONFIG,
this.hass
);
const config = { views: [viewConfig] };
const rawConfig = { views: [MAINTENANCE_LOVELACE_VIEW_CONFIG] };
if (deepEqual(config, this._lovelace?.config)) {
return;
}
this._childPanelReady ??= new ChildPanelReady(this);
this._lovelace = {
config: config,
rawConfig: rawConfig,
editMode: false,
urlPath: "maintenance",
mode: "generated",
locale: this.hass.locale,
enableFullEditMode: () => undefined,
saveConfig: async () => undefined,
deleteConfig: async () => undefined,
setEditMode: () => undefined,
showToast: () => undefined,
};
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
user-select: none;
}
hui-view-container {
position: relative;
display: flex;
box-sizing: border-box;
}
hui-view {
flex: 1 1 100%;
max-width: 100%;
}
`,
];
}
class PanelMaintenance extends HaHomeSummaryPanelBase<"maintenance"> {
protected readonly _systemDataKey = "maintenance";
}
declare global {
@@ -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,
});
};
+45 -252
View File
@@ -1,267 +1,60 @@
import { mdiPencil } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-icon-button";
import "../../components/ha-top-app-bar-fixed";
import {
fetchFrontendSystemData,
saveFrontendSystemData,
type SecurityFrontendSystemData,
} from "../../data/frontend";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import { ChildPanelReady } from "../../layouts/panel-ready";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { showToast } from "../../util/toast";
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
import type { Lovelace } from "../lovelace/types";
import { showEditSecurityDialog } from "./dialogs/show-dialog-edit-security";
import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-container";
import "../lovelace/views/hui-view-background";
const SECURITY_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = {
strategy: {
type: "security",
},
};
import { customElement } from "lit/decorators";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { HaHomeSummaryPanelBase } from "../home/ha-home-summary-panel-base";
@customElement("ha-panel-security")
class PanelSecurity extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
class PanelSecurity extends HaHomeSummaryPanelBase<"security"> {
protected readonly _systemDataKey = "security";
@property({ type: Boolean, reflect: true }) public narrow = false;
@state() private _viewIndex = 0;
@state() private _lovelace?: Lovelace;
@state() private _config?: SecurityFrontendSystemData;
@state() private _searchParms = new URLSearchParams(window.location.search);
private _childPanelReady?: ChildPanelReady;
private _loadConfigPromise?: Promise<void>;
private _loadConfigRevision = 0;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Initial setup
if (!this.hasUpdated) {
this._setup();
return;
}
if (!changedProps.has("hass")) {
return;
}
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) {
// Refresh the generated view when registries or panels change.
if (
oldHass.entities !== this.hass.entities ||
oldHass.devices !== this.hass.devices ||
oldHass.areas !== this.hass.areas ||
oldHass.floors !== this.hass.floors ||
oldHass.panels !== this.hass.panels
) {
if (this.hass.config.state === "RUNNING") {
this._debounceRegistriesChanged();
}
}
}
}
private async _setup() {
this._loadConfigPromise = this._loadConfig();
await Promise.all([
this.hass.loadFragmentTranslation("lovelace"),
this._loadConfigPromise,
]);
await this._setLovelace();
}
private async _loadConfig() {
const revision = ++this._loadConfigRevision;
this._config = undefined;
try {
const data = await fetchFrontendSystemData(
this.hass.connection,
"security"
);
if (revision !== this._loadConfigRevision) {
return;
}
this._config = data || {};
} catch (err) {
if (revision !== this._loadConfigRevision) {
return;
}
// eslint-disable-next-line no-console
console.error("Failed to load security configuration:", err);
showToast(this, {
message: this.hass.localize("ui.panel.security.editor.load_failed"),
duration: 0,
dismissable: true,
});
}
}
private _debounceRegistriesChanged = debounce(
() => this._registriesChanged(),
200
);
private _registriesChanged = async () => {
this._setLovelace();
};
protected render() {
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${this._searchParms.has("historyBack")}
>
<div slot="title">${this.hass.localize("panel.security")}</div>
${
this.hass.user?.is_admin && this._config
? html`<ha-icon-button
slot="actionItems"
.path=${mdiPencil}
.label=${this.hass.localize("ui.panel.security.editor.title")}
@click=${this._editSecurity}
></ha-icon-button>`
: nothing
}
${
this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}>
</hui-view-background>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view
></hui-view-container>
`
: nothing
}
</ha-top-app-bar-fixed>
`;
}
private async _setLovelace() {
if (this._loadConfigPromise) {
await this._loadConfigPromise;
}
const viewConfig = await generateLovelaceViewStrategy(
{
strategy: {
...SECURITY_LOVELACE_VIEW_CONFIG.strategy,
alert_entities: this._config?.alert_entities,
favorite_entities: this._config?.favorite_entities,
},
},
this.hass
);
const config = { views: [viewConfig] };
const rawConfig = { views: [SECURITY_LOVELACE_VIEW_CONFIG] };
if (deepEqual(config, this._lovelace?.config)) {
return;
}
this._childPanelReady ??= new ChildPanelReady(this);
this._lovelace = {
config: config,
rawConfig: rawConfig,
editMode: false,
urlPath: "security",
mode: "generated",
locale: this.hass.locale,
enableFullEditMode: () => undefined,
saveConfig: async () => undefined,
deleteConfig: async () => undefined,
setEditMode: () => undefined,
showToast: () => undefined,
protected _strategyOptions() {
return {
alert_entities: this._config?.alert_entities,
favorite_entities: this._config?.favorite_entities,
};
}
private _editSecurity = () => {
if (!this.hass.user?.is_admin || !this._config) {
protected _favoriteChanged(
ev: HASSDomEvent<HASSDomEvents["entity-favorite-changed"]>
) {
if (!this._config) {
return;
}
showEditSecurityDialog(this, {
config: this._config,
saveConfig: async (config) => {
await this._saveConfig(config);
},
});
};
private async _saveConfig(config: SecurityFrontendSystemData): Promise<void> {
try {
await saveFrontendSystemData(this.hass.connection, "security", config);
this._config = config;
} catch (err) {
// eslint-disable-next-line no-console
console.error("Failed to save security configuration:", err);
showToast(this, {
message: this.hass.localize("ui.panel.security.editor.save_failed"),
duration: 0,
dismissable: true,
});
throw err;
const { entity, favorite } = ev.detail;
const favoriteEntities = (this._config.favorite_entities ?? []).filter(
(entityId) => entityId !== entity
);
if (favorite) {
favoriteEntities.push(entity);
}
showToast(this, {
message: this.hass.localize("ui.common.successfully_saved"),
this._saveConfig({
...this._config,
favorite_entities: favoriteEntities.length ? favoriteEntities : undefined,
});
await this._setLovelace();
this._setLovelace();
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
user-select: none;
}
hui-view-container {
position: relative;
display: flex;
box-sizing: border-box;
}
hui-view {
flex: 1 1 100%;
max-width: 100%;
}
`,
];
protected _alertChanged(
ev: HASSDomEvent<HASSDomEvents["entity-alert-changed"]>
) {
if (!this._config) {
return;
}
const { entity, severity } = ev.detail;
const current = this._config.alert_entities ?? [];
const alertEntities = !severity
? current.filter((alertEntity) => alertEntity.entity !== entity)
: current.some((alertEntity) => alertEntity.entity === entity)
? current.map((alertEntity) =>
alertEntity.entity === entity
? { ...alertEntity, severity }
: alertEntity
)
: [...current, { entity, severity }];
this._saveConfig({
...this._config,
alert_entities: alertEntities.length ? alertEntities : undefined,
});
this._setLovelace();
}
}
@@ -1,15 +0,0 @@
import type { ContextType } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { registriesContext, statesContext } from "../../data/context";
import { isSecurityPanelEntity } from "./strategies/security-view-strategy";
export type SecurityEntityContext = ContextType<typeof registriesContext> & {
states: ContextType<typeof statesContext>;
};
export const createSecurityEntityFilter =
(getContext: () => SecurityEntityContext | undefined) =>
(entity: HassEntity): boolean => {
const context = getContext();
return context ? isSecurityPanelEntity(context, entity) : false;
};
@@ -97,6 +97,10 @@ export const filterSecurityAlertEntities = (
) === severity
);
export const computeSecurityAlertColor = (
severity: SecurityAlertSeverity
): string => (severity === "alert" ? "red" : "amber");
export const computeSecurityAlertCardConfig = (
stateObj: HassEntity | undefined,
alertEntity: SecurityAlertEntityConfig
@@ -105,7 +109,7 @@ export const computeSecurityAlertCardConfig = (
return {
type: "alert",
entity: alertEntity.entity,
alert_color: severity === "alert" ? "red" : "amber",
alert_color: computeSecurityAlertColor(severity),
visibility: computeDefaultSecurityAlertVisibility(alertEntity.entity),
};
};
@@ -14,6 +14,7 @@ import {
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { floorDefaultIcon } from "../../../components/ha-floor-icon";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import { isStrategySection } from "../../../data/lovelace/config/section";
import type {
LovelaceSectionConfig,
LovelaceSectionRawConfig,
@@ -21,15 +22,28 @@ import type {
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { SecurityAlertEntityConfig } from "../../../data/frontend";
import type { HomeAssistant } from "../../../types";
import type { LogbookCardConfig } from "../../lovelace/cards/types";
import type {
EntityVisibilityCardConfig,
LogbookCardConfig,
} from "../../lovelace/cards/types";
import { computeAreaTileCardConfig } from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
import {
wrapEntityVisibility,
type ComputeEntityCard,
} from "../../lovelace/strategies/helpers/entity-visibility";
import { computeFavoriteCardConfig } from "../../lovelace/strategies/helpers/favorite-cards";
import { computeSecurityAlertCardConfig } from "./security-alerts";
import {
computeSecurityAlertCardConfig,
computeSecurityAlertColor,
resolveSecurityAlertSeverity,
} from "./security-alerts";
export interface SecurityViewStrategyConfig {
type: "security";
alert_entities?: SecurityAlertEntityConfig[];
favorite_entities?: string[];
hidden_entities?: string[];
edit_visibility?: boolean;
}
export const securityEntityFilters: EntityFilter[] = [
@@ -79,8 +93,11 @@ export const securityEntityFilters: EntityFilter[] = [
},
];
export const findSecurityEntities = (sources: EntityFilterSources): string[] =>
findFilteredEntities(sources, securityEntityFilters);
export const findSecurityEntities = (
sources: EntityFilterSources,
hiddenEntities?: string[]
): string[] =>
findFilteredEntities(sources, securityEntityFilters, hiddenEntities);
export const isSecurityPanelEntity = (
hass: EntityFilterSources,
@@ -100,7 +117,8 @@ export const isSecurityPanelEntity = (
const processAreasForSecurity = (
areaIds: string[],
hass: HomeAssistant,
entities: string[]
entities: string[],
computeCard: ComputeEntityCard
): LovelaceCardConfig[] => {
const cards: LovelaceCardConfig[] = [];
@@ -121,10 +139,8 @@ const processAreasForSecurity = (
const areaSecurityEntities = entities.filter(areaFilter);
const areaCards: LovelaceCardConfig[] = [];
const computeTileCard = computeAreaTileCardConfig(hass, false);
for (const entityId of areaSecurityEntities) {
areaCards.push(computeTileCard(entityId));
areaCards.push(computeCard(entityId));
}
if (areaCards.length > 0) {
@@ -148,7 +164,8 @@ const processAreasForSecurity = (
const processUnassignedEntities = (
hass: HomeAssistant,
entities: string[]
entities: string[],
computeCard: ComputeEntityCard
): LovelaceCardConfig[] => {
const unassignedFilter = generateEntityFilter(
hass.states,
@@ -162,10 +179,9 @@ const processUnassignedEntities = (
);
const unassignedLights = entities.filter(unassignedFilter);
const areaCards: LovelaceCardConfig[] = [];
const computeTileCard = computeAreaTileCardConfig(hass, false);
for (const entityId of unassignedLights) {
areaCards.push(computeTileCard(entityId));
areaCards.push(computeCard(entityId));
}
return areaCards;
@@ -183,30 +199,50 @@ export class SecurityViewStrategy extends ReactiveElement {
const sections: LovelaceSectionRawConfig[] = [];
const entities = findSecurityEntities(hass);
const favoriteEntities = (config.favorite_entities ?? []).filter(
(entityId) =>
hass.states[entityId] &&
isSecurityPanelEntity(hass, hass.states[entityId])
const editVisibility = Boolean(config.edit_visibility);
const entities = findSecurityEntities(
hass,
editVisibility ? undefined : config.hidden_entities
);
const pageEntities = new Set(entities);
if (favoriteEntities.length > 0) {
sections.push({
type: "grid",
column_span: 2,
cards: [
{
type: "heading",
heading: hass.localize(
"ui.panel.lovelace.strategy.security.favorites"
),
heading_style: "title",
},
...favoriteEntities.map(computeFavoriteCardConfig),
],
});
}
const favoriteEntities = editVisibility
? (config.favorite_entities ?? [])
: (config.favorite_entities ?? []).filter(
(entityId) =>
hass.states[entityId] &&
isSecurityPanelEntity(hass, hass.states[entityId])
);
const favoriteSet = new Set(config.favorite_entities);
const alertEntities = new Map(
(config.alert_entities ?? []).map((alertEntity) => [
alertEntity.entity,
alertEntity,
])
);
const computeEditExtras = (
entityId: string
): Pick<EntityVisibilityCardConfig, "favorite" | "alert"> => {
const alertEntity = alertEntities.get(entityId);
return {
favorite: favoriteSet.has(entityId),
alert: ALERT_DOMAINS.includes(computeDomain(entityId))
? alertEntity
? resolveSecurityAlertSeverity(alertEntity, hass.states[entityId])
: "none"
: undefined,
};
};
const wrapCard = (computeCard: ComputeEntityCard): ComputeEntityCard =>
editVisibility
? wrapEntityVisibility(
new Set(config.hidden_entities),
computeCard,
computeEditExtras
)
: computeCard;
const computeCard = wrapCard(computeAreaTileCardConfig(hass, false));
const floorCount =
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
@@ -232,7 +268,12 @@ export class SecurityViewStrategy extends ReactiveElement {
],
};
const areaCards = processAreasForSecurity(areaIds, hass, entities);
const areaCards = processAreasForSecurity(
areaIds,
hass,
entities,
computeCard
);
if (areaCards.length > 0) {
section.cards!.push(...areaCards);
@@ -259,7 +300,8 @@ export class SecurityViewStrategy extends ReactiveElement {
const areaCards = processAreasForSecurity(
hierarchy.areas,
hass,
entities
entities,
computeCard
);
if (areaCards.length > 0) {
@@ -269,7 +311,11 @@ export class SecurityViewStrategy extends ReactiveElement {
}
// Process unassigned entities
const unassignedCards = processUnassignedEntities(hass, entities);
const unassignedCards = processUnassignedEntities(
hass,
entities,
computeCard
);
if (unassignedCards.length > 0) {
const section: LovelaceSectionRawConfig = {
@@ -279,7 +325,7 @@ export class SecurityViewStrategy extends ReactiveElement {
{
type: "heading",
heading:
sections.length > 0
sections.length > 0 || favoriteEntities.length > 0
? hass.localize(
"ui.panel.lovelace.strategy.security.other_devices"
)
@@ -291,6 +337,41 @@ export class SecurityViewStrategy extends ReactiveElement {
sections.push(section);
}
if (favoriteEntities.length > 0) {
const firstPageEntity = editVisibility
? sections
.flatMap((section) =>
isStrategySection(section) ? [] : (section.cards ?? [])
)
.find((card) => card.type === "entity-visibility")?.entity
: undefined;
const computeFavoriteCard = editVisibility
? (entityId: string, index: number): EntityVisibilityCardConfig => ({
type: "entity-visibility",
entity: entityId,
neighbor_entity:
favoriteEntities[index + 1] ?? favoriteEntities[index - 1],
fallback_entity: firstPageEntity,
...computeEditExtras(entityId),
card: computeFavoriteCardConfig(entityId),
})
: computeFavoriteCardConfig;
sections.unshift({
type: "grid",
column_span: 2,
cards: [
{
type: "heading",
heading: hass.localize(
"ui.panel.lovelace.strategy.security.favorites"
),
heading_style: "title",
},
...favoriteEntities.map(computeFavoriteCard),
],
});
}
// Build sidebar with activity log
const hasLogbook = isComponentLoaded(hass.config, "logbook");
@@ -303,18 +384,63 @@ export class SecurityViewStrategy extends ReactiveElement {
const sidebarSections: LovelaceSectionConfig[] = [];
const alertCards = config.alert_entities
?.filter((alertEntity) =>
ALERT_DOMAINS.includes(computeDomain(alertEntity.entity))
)
.map((alertEntity) =>
computeSecurityAlertCardConfig(
hass.states[alertEntity.entity],
alertEntity
)
);
const alertEntityConfigs = (config.alert_entities ?? []).filter(
(alertEntity) => ALERT_DOMAINS.includes(computeDomain(alertEntity.entity))
);
if (alertCards?.length) {
const alertCards = editVisibility
? undefined
: alertEntityConfigs.map((alertEntity) =>
computeSecurityAlertCardConfig(
hass.states[alertEntity.entity],
alertEntity
)
);
if (editVisibility && alertEntityConfigs.length) {
sidebarSections.push({
type: "grid",
cards: [
{
type: "heading",
heading: hass.localize(
"ui.panel.lovelace.strategy.security.active_alerts"
),
heading_style: "title",
},
...alertEntityConfigs.map(
(alertEntity, index): EntityVisibilityCardConfig => {
const stateObj = hass.states[alertEntity.entity];
const severity = resolveSecurityAlertSeverity(
alertEntity,
stateObj
);
const color = computeSecurityAlertColor(severity);
return {
type: "entity-visibility",
entity: alertEntity.entity,
favorite: favoriteSet.has(alertEntity.entity),
alert: severity,
alert_select: true,
neighbor_entity: (
alertEntityConfigs[index + 1] ?? alertEntityConfigs[index - 1]
)?.entity,
fallback_entity: pageEntities.has(alertEntity.entity)
? alertEntity.entity
: undefined,
grid_options: { columns: 12 },
card: {
type: "alert",
entity: alertEntity.entity,
alert_color: color,
clear_color: color,
},
};
}
),
] satisfies LovelaceCardConfig[],
});
} else if (alertCards?.length) {
sidebarSections.push({
type: "grid",
visibility: [
+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}",
@@ -2918,23 +2929,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");
});
});
@@ -280,4 +280,71 @@ describe("MaintenanceViewStrategy.generate", () => {
},
]);
});
const createBatteryHass = () =>
createMockHass({
"sensor.car_target_charge": createMockEntityState(
"sensor.car_target_charge",
"80",
{ device_class: "battery" }
),
"sensor.remote_battery": createMockEntityState(
"sensor.remote_battery",
"80",
{ device_class: "battery" }
),
});
it("leaves hidden entities out of the view", async () => {
const view = await MaintenanceViewStrategy.generate(
{ type: "maintenance", hidden_entities: ["sensor.car_target_charge"] },
createBatteryHass()
);
const section = view.sections?.[0] as LovelaceSectionConfig;
expect(section.cards?.slice(1)).toEqual([
{
type: "tile",
entity: "sensor.remote_battery",
name: { type: "entity" },
},
]);
});
it("keeps hidden entities as hidden visibility wrappers while editing", async () => {
const view = await MaintenanceViewStrategy.generate(
{
type: "maintenance",
hidden_entities: ["sensor.car_target_charge"],
edit_visibility: true,
},
createBatteryHass()
);
const section = view.sections?.[0] as LovelaceSectionConfig;
expect(section.cards?.slice(1)).toEqual([
{
type: "entity-visibility",
entity: "sensor.car_target_charge",
hidden: true,
card: {
type: "tile",
entity: "sensor.car_target_charge",
name: { type: "entity" },
},
},
{
type: "entity-visibility",
entity: "sensor.remote_battery",
hidden: false,
card: {
type: "tile",
entity: "sensor.remote_battery",
name: { type: "entity" },
},
},
]);
});
});
@@ -1,70 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { SecurityFrontendSystemData } from "../../../../src/data/frontend";
import type { EditSecurityDialogParams } from "../../../../src/panels/security/dialogs/show-dialog-edit-security";
import type { HomeAssistantInternationalization } from "../../../../src/types";
import "../../../../src/panels/security/dialogs/dialog-edit-security";
import { createMockHass } from "../../../fixtures/hass";
interface TestEditSecurityDialog extends HTMLElement {
params: EditSecurityDialogParams;
_i18n: HomeAssistantInternationalization;
isDirtyState: boolean;
connectedCallback(): void;
disconnectedCallback(): void;
performUpdate(): void;
}
const alertEntitiesChanged = (
dialog: TestEditSecurityDialog,
ev: CustomEvent
) =>
(
dialog as unknown as Record<
"_alertEntitiesChanged",
(event: CustomEvent) => void
>
)["_alertEntitiesChanged"](ev);
describe("dialog-edit-security", () => {
const createDialog = (config: SecurityFrontendSystemData = {}) => {
const hass = createMockHass();
const dialog = document.createElement(
"dialog-edit-security"
) as unknown as TestEditSecurityDialog;
dialog._i18n = hass;
dialog.params = {
config,
saveConfig: vi.fn(),
};
dialog.performUpdate = vi.fn();
dialog.connectedCallback();
return dialog;
};
it("becomes clean after nested configuration is restored", () => {
const alertEntities = [
{ entity: "binary_sensor.window", severity: "warning" as const },
];
const dialog = createDialog({ alert_entities: alertEntities });
alertEntitiesChanged(
dialog,
new CustomEvent("value-changed", {
detail: {
value: [{ ...alertEntities[0], severity: "alert" }],
},
})
);
expect(dialog.isDirtyState).toBe(true);
alertEntitiesChanged(
dialog,
new CustomEvent("value-changed", {
detail: { value: [{ ...alertEntities[0] }] },
})
);
expect(dialog.isDirtyState).toBe(false);
dialog.disconnectedCallback();
});
});
@@ -1,10 +1,51 @@
import { describe, expect, it } from "vitest";
import type { LovelaceCardConfig } from "../../../../src/data/lovelace/config/card";
import type { LovelaceSectionRawConfig } from "../../../../src/data/lovelace/config/section";
import { isStrategySection } from "../../../../src/data/lovelace/config/section";
import {
isSecurityPanelEntity,
SecurityViewStrategy,
} from "../../../../src/panels/security/strategies/security-view-strategy";
import { createMockEntityState, createMockHass } from "../../../fixtures/hass";
const createSecurityHass = () => {
const hass = createMockHass();
hass.config = { ...hass.config, components: [] };
for (const [entityId, deviceClass] of [
["binary_sensor.front_door", "door"],
["binary_sensor.window", "window"],
]) {
hass.states[entityId] = createMockEntityState(entityId, "off", {
device_class: deviceClass,
});
}
hass.states["sensor.temperature"] = createMockEntityState(
"sensor.temperature",
"21"
);
return hass;
};
const cardsOf = (section?: LovelaceSectionRawConfig): LovelaceCardConfig[] =>
!section || isStrategySection(section)
? []
: (section.cards ?? []).filter((card) => card.type !== "heading");
const sectionWithHeading = (
sections: LovelaceSectionRawConfig[] | undefined,
heading: string
) =>
sections?.find(
(section) =>
!isStrategySection(section) &&
section.cards?.some(
(card) => card.type === "heading" && card.heading === heading
)
);
const FAVORITES = "ui.panel.lovelace.strategy.security.favorites";
const ACTIVE_ALERTS = "ui.panel.lovelace.strategy.security.active_alerts";
describe("security-view-strategy", () => {
it("includes glass break binary sensors", () => {
const hass = createMockHass();
@@ -68,4 +109,157 @@ describe("security-view-strategy", () => {
},
]);
});
it("drops hidden entities from the tiles but keeps their favorite and alert entries", async () => {
const view = await SecurityViewStrategy.generate(
{
type: "security",
hidden_entities: ["binary_sensor.front_door"],
favorite_entities: ["binary_sensor.front_door"],
alert_entities: [{ entity: "binary_sensor.front_door" }],
},
createSecurityHass()
);
const favorites = sectionWithHeading(view.sections, FAVORITES);
const tiles = view.sections!.filter((section) => section !== favorites);
expect(tiles.flatMap(cardsOf).map((card) => card.entity)).toEqual([
"binary_sensor.window",
]);
expect(cardsOf(favorites).map((card) => card.entity)).toEqual([
"binary_sensor.front_door",
]);
expect(
cardsOf(sectionWithHeading(view.sidebar?.sections, ACTIVE_ALERTS)).map(
(card) => card.entity
)
).toEqual(["binary_sensor.front_door"]);
});
it("lists deleted and non-matching favorites only in edit mode", async () => {
const config = {
type: "security" as const,
favorite_entities: [
"binary_sensor.front_door",
"binary_sensor.gone",
"sensor.temperature",
],
};
const view = await SecurityViewStrategy.generate(
config,
createSecurityHass()
);
expect(
cardsOf(sectionWithHeading(view.sections, FAVORITES)).map(
(card) => card.entity
)
).toEqual(["binary_sensor.front_door"]);
const editView = await SecurityViewStrategy.generate(
{ ...config, edit_visibility: true },
createSecurityHass()
);
expect(cardsOf(sectionWithHeading(editView.sections, FAVORITES))).toEqual([
{
type: "entity-visibility",
entity: "binary_sensor.front_door",
neighbor_entity: "binary_sensor.gone",
fallback_entity: "binary_sensor.front_door",
favorite: true,
alert: "none",
card: expect.objectContaining({ entity: "binary_sensor.front_door" }),
},
{
type: "entity-visibility",
entity: "binary_sensor.gone",
neighbor_entity: "sensor.temperature",
fallback_entity: "binary_sensor.front_door",
favorite: true,
alert: "none",
card: expect.objectContaining({ entity: "binary_sensor.gone" }),
},
{
type: "entity-visibility",
entity: "sensor.temperature",
neighbor_entity: "binary_sensor.gone",
fallback_entity: "binary_sensor.front_door",
favorite: true,
alert: undefined,
card: expect.objectContaining({ entity: "sensor.temperature" }),
},
]);
});
it("keeps hidden tiles and lists every configured alert in edit mode", async () => {
const view = await SecurityViewStrategy.generate(
{
type: "security",
edit_visibility: true,
hidden_entities: ["binary_sensor.front_door"],
alert_entities: [
{ entity: "binary_sensor.front_door", severity: "alert" },
{ entity: "binary_sensor.gone" },
],
},
createSecurityHass()
);
expect(view.sections!.flatMap(cardsOf)).toEqual([
{
type: "entity-visibility",
entity: "binary_sensor.front_door",
hidden: true,
favorite: false,
alert: "alert",
card: expect.objectContaining({ entity: "binary_sensor.front_door" }),
},
{
type: "entity-visibility",
entity: "binary_sensor.window",
hidden: false,
favorite: false,
alert: "none",
card: expect.objectContaining({ entity: "binary_sensor.window" }),
},
]);
const alerts = sectionWithHeading(view.sidebar?.sections, ACTIVE_ALERTS);
expect(alerts?.visibility).toBeUndefined();
expect(cardsOf(alerts)).toEqual([
{
type: "entity-visibility",
entity: "binary_sensor.front_door",
favorite: false,
alert: "alert",
alert_select: true,
neighbor_entity: "binary_sensor.gone",
fallback_entity: "binary_sensor.front_door",
grid_options: { columns: 12 },
card: {
type: "alert",
entity: "binary_sensor.front_door",
alert_color: "red",
clear_color: "red",
},
},
{
type: "entity-visibility",
entity: "binary_sensor.gone",
favorite: false,
alert: "warning",
alert_select: true,
neighbor_entity: "binary_sensor.front_door",
fallback_entity: undefined,
grid_options: { columns: 12 },
card: {
type: "alert",
entity: "binary_sensor.gone",
alert_color: "amber",
clear_color: "amber",
},
},
]);
});
});