Compare commits

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

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

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

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

The dialog, its alerts editor, the entity filter helper and the dialog test go away. Strategy tests pin down that a hidden entity keeps its favorite and alert entries, that stale favorites and alerts only show in edit mode, and the shape of the edit-mode wrappers.
2026-11-05 16:42:19 +01:00
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
renovate[bot] a1355e5116 Update babel monorepo to v8.1.0 (#55667)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-05 17:19:21 +02:00
Petar Petrov ae4366020c Choose the area when creating a helper without a device (#55646)
* Choose the area of a helper that has no device

Storage helpers get an Area field in their create form. Config flow
helpers get an area picker on the success step when any of their
entities has no device; the area is set on all of them. The redirect
after creating then uses the entity ID as regenerated for the area.

* Count the area as an unsaved change in the helper dialog

* Disable the helper area picker while saving
2026-11-05 15:03:47 +00:00
renovate[bot] 7f73d29b2f Update dependency @versatiles/style to v8 (#55668)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-05 15:46:36 +01:00
Petar Petrov ebf453ccc7 Show the new helper after creating it (#55628)
* Open the new helper after creating it

From the helpers page, a new helper now opens its device page when its
entities belong to one device, or its more-info dialog otherwise.

* Open the right entity after creating a helper

Look up the storage helper's entity by its unique ID, because its entity
ID can differ from the helper ID. Start the config entry lookup when the
success step shows and skip the redirect if the dialog closed meanwhile.

* Ignore the helper lookup after the dialog closes or reopens

* Ignore a helper created after its dialog closed or reopened
2026-11-05 15:23:30 +01:00
TimothyandClaude Opus 5.5 a0d9afe695 Keep dialogs while navigating (#55627)
* Keep dialogs while navigating

* Apply suggestions

* Keep navigate async when keeping dialogs

Skipping the await committed the navigation synchronously, so a navigate()
called right after it saw the committed count change and was dropped as
stale.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-05 15:11:49 +01:00
43 changed files with 2575 additions and 1280 deletions

No files matched your search

+3 -3
View File
@@ -40,7 +40,7 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "8.0.10",
"@babel/runtime": "8.1.0",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.4",
"@codemirror/commands": "6.11.1",
@@ -80,7 +80,7 @@
"@swc/helpers": "0.5.23",
"@tsparticles/engine": "4.5.0",
"@tsparticles/preset-links": "4.5.0",
"@versatiles/style": "7.1.0",
"@versatiles/style": "8.0.0",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.202.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
@@ -137,7 +137,7 @@
},
"devDependencies": {
"@ampproject/remapping": "2.3.0",
"@babel/core": "8.0.10",
"@babel/core": "8.1.0",
"@babel/helper-compilation-targets": "8.0.7",
"@babel/helper-define-polyfill-provider": "1.0.0",
"@babel/plugin-transform-class-properties": "8.0.1",
+358 -358
View File
File diff suppressed because it is too large. Load diff
+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
);
};
+13 -2
View File
@@ -16,6 +16,13 @@ declare global {
export interface NavigateOptions {
replace?: boolean;
data?: Record<string, unknown>;
/**
* Leave open dialogs open. For a replace that only canonicalizes the current
* URL, like a router filling in its default page: a dialog restored from the
* URL, such as a more-info deep link, may already be open at that point, and
* the entry keeps its state so back still closes it.
*/
keepDialogs?: boolean;
}
// max time to wait for dialogs to close before navigating
@@ -196,7 +203,8 @@ const buildHistoryState = (
};
const performNavigation = async (path: string, options?: NavigateOptions) => {
const canProceed = await ensureDialogsClosed(Date.now());
const keepDialogs = options?.keepDialogs || false;
const canProceed = await (keepDialogs || ensureDialogsClosed(Date.now()));
if (!canProceed) {
return false;
}
@@ -215,7 +223,10 @@ const performNavigation = async (path: string, options?: NavigateOptions) => {
const { history } = mainWindow;
if (replace) {
if (replace && keepDialogs) {
// Same entry under its canonical URL: its state, dialog included, stays.
history.replaceState(history.state, "", path);
} else if (replace) {
// A replaced entry keeps its predecessor, so it keeps `from`.
const { root, from } = history.state ?? {};
const data = root ? { root: true } : options?.data;
+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)) {
+29
View File
@@ -390,3 +390,32 @@ export const getAutomaticEntityIds = (
type: "config/entity_registry/get_automatic_entity_ids",
entity_ids,
});
/**
* Set the area of entities and regenerate their entity IDs, which can include
* the area. Returns the entity ID of each entity after the change.
*/
export const updateEntitiesArea = async (
hass: HomeAssistant,
entityIds: string[],
areaId: string
): Promise<Record<string, string>> => {
await Promise.all(
entityIds.map((entityId) =>
updateEntityRegistryEntry(hass, entityId, { area_id: areaId })
)
);
const newEntityIds = await getAutomaticEntityIds(hass, entityIds);
const result: Record<string, string> = {};
for (const entityId of entityIds) {
const newEntityId = newEntityIds[entityId];
if (newEntityId) {
// eslint-disable-next-line no-await-in-loop
await updateEntityRegistryEntry(hass, entityId, {
new_entity_id: newEntityId,
});
}
result[entityId] = newEntityId || entityId;
}
return result;
};
+37
View File
@@ -49,6 +49,15 @@ export interface SecurityAlertEntityConfig {
export interface SecurityFrontendSystemData {
alert_entities?: SecurityAlertEntityConfig[];
favorite_entities?: string[];
hidden_entities?: string[];
}
export interface MaintenanceFrontendSystemData {
hidden_entities?: string[];
}
export interface ClimateFrontendSystemData {
hidden_entities?: string[];
}
export interface EnergyFrontendSystemData {
@@ -69,6 +78,8 @@ declare global {
home: HomeFrontendSystemData;
energy: EnergyFrontendSystemData;
security: SecurityFrontendSystemData;
maintenance: MaintenanceFrontendSystemData;
climate: ClimateFrontendSystemData;
}
}
@@ -145,6 +156,32 @@ export const saveFrontendSystemData = async <
value,
});
const pendingSystemDataSaves = new Map<ValidSystemDataKey, Promise<void>>();
export const queueFrontendSystemDataSave = <
SystemDataKey extends ValidSystemDataKey,
>(
conn: Connection,
key: SystemDataKey,
value: FrontendSystemData[SystemDataKey]
): Promise<void> => {
const save = (pendingSystemDataSaves.get(key) ?? Promise.resolve())
.catch(() => undefined)
.then(() => saveFrontendSystemData(conn, key, value));
pendingSystemDataSaves.set(key, save);
return save;
};
export const fetchFrontendSystemDataAfterSaves = async <
SystemDataKey extends ValidSystemDataKey,
>(
conn: Connection,
key: SystemDataKey
): Promise<FrontendSystemData[SystemDataKey] | null> => {
await pendingSystemDataSaves.get(key)?.catch(() => undefined);
return fetchFrontendSystemData(conn, key);
};
export const subscribeFrontendSystemData = <
SystemDataKey extends ValidSystemDataKey,
>(
@@ -481,6 +481,7 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
.flowConfig=${this._params.flowConfig}
.step=${this._step}
.hass=${this.hass}
.manifest=${this._params.manifest}
.navigateToResult=${
this._params.navigateToResult ?? false
}
+162 -17
View File
@@ -20,13 +20,18 @@ import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import { updateDeviceRegistryEntry } from "../../data/device/device_registry";
import {
getAutomaticEntityIds,
getExtendedEntityRegistryEntries,
updateEntitiesArea,
updateEntityRegistryEntry,
type EntityRegistryDisplayEntry,
type ExtEntityRegistryEntry,
} from "../../data/entity/entity_registry";
import { domainToName } from "../../data/integration";
import { domainToName, type IntegrationManifest } from "../../data/integration";
import { findRelated } from "../../data/search";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import { showAlertDialog } from "../generic/show-dialog-box";
import { showMoreInfoDialog } from "../more-info/show-ha-more-info-dialog";
import { showVoiceAssistantSetupDialog } from "../voice-assistant-setup/show-voice-assistant-setup-dialog";
import type { FlowConfig } from "./show-dialog-data-entry-flow";
import { configFlowContentStyles } from "./styles";
@@ -35,6 +40,28 @@ interface DeviceTarget {
device: string;
}
interface HelperResult {
deviceId?: string;
entityId?: string;
}
const computeHelperResult = (
entities: ExtEntityRegistryEntry[]
): HelperResult | undefined => {
const deviceIds = [
...new Set(
entities.flatMap((entity) => (entity.device_id ? [entity.device_id] : []))
),
];
if (deviceIds.length === 1) {
return { deviceId: deviceIds[0] };
}
if (!deviceIds.length && entities.length === 1) {
return { entityId: entities[0].entity_id };
}
return undefined;
};
@customElement("step-flow-create-entry")
class StepFlowCreateEntry extends LitElement {
@property({ attribute: false }) public flowConfig!: FlowConfig;
@@ -46,10 +73,18 @@ class StepFlowCreateEntry extends LitElement {
@property({ attribute: false }) public devices!: DeviceRegistryEntry[];
@property({ attribute: false }) public manifest?: IntegrationManifest | null;
private _domains: Record<string, string> = {};
public navigateToResult = false;
private _helperEntitiesLookup?: Promise<ExtEntityRegistryEntry[]>;
@state() private _helperEntities?: ExtEntityRegistryEntry[];
@state() private _helperArea?: string;
@state() private _deviceUpdate: Record<
string,
{ name?: string; area?: string }
@@ -73,6 +108,18 @@ class StepFlowCreateEntry extends LitElement {
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this._loadDomains();
if (
this.flowConfig.flowType === "config_flow" &&
this.step.result &&
this.manifest?.integration_type === "helper"
) {
this._helperEntitiesLookup = this._findHelperEntities(
this.step.result.entry_id
);
this._helperEntitiesLookup.then((entities) => {
this._helperEntities = entities;
});
}
}
protected willUpdate(changedProps: PropertyValues<this>) {
@@ -133,11 +180,23 @@ class StepFlowCreateEntry extends LitElement {
? nothing
: this.devices.length === 0
? html`<p>
${localize(
"ui.panel.config.integrations.config_flow.created_config",
{ name: this.step.title }
)}
</p>`
${localize(
"ui.panel.config.integrations.config_flow.created_config",
{ name: html`<b>${this.step.title}</b>` }
)}
</p>
${
this._entitiesWithoutDevice(this._helperEntities).length
? html`<ha-area-picker
.value=${this._helperArea}
.disabled=${this._loading}
.helper=${localize(
"ui.panel.config.integrations.config_flow.helper_area"
)}
@value-changed=${this._helperAreaPicked}
></ha-area-picker>`
: nothing
}`
: html`
<div class="devices">
${this.devices.map(
@@ -214,9 +273,11 @@ class StepFlowCreateEntry extends LitElement {
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (changedProps.has("_deviceUpdate")) {
if (changedProps.has("_deviceUpdate") || changedProps.has("_helperArea")) {
fireEvent(this, "flow-step-footer-state-changed", {
hasPendingUpdates: Object.keys(this._deviceUpdate).length > 0,
hasPendingUpdates:
Object.keys(this._deviceUpdate).length > 0 ||
this._helperArea !== undefined,
});
}
if (changedProps.has("_loading")) {
@@ -226,6 +287,11 @@ class StepFlowCreateEntry extends LitElement {
}
}
private _entitiesWithoutDevice = memoizeOne(
(entities?: ExtEntityRegistryEntry[]): ExtEntityRegistryEntry[] =>
(entities ?? []).filter((entity) => !entity.device_id)
);
private async _loadDomains() {
const entries = await getConfigEntries(this.hass);
this._domains = Object.fromEntries(
@@ -290,16 +356,95 @@ class StepFlowCreateEntry extends LitElement {
await Promise.allSettled(entityUpdates);
}
fireEvent(this, "flow-update", { step: undefined });
if (this.step.result && this.navigateToResult) {
if (this.devices.length === 1) {
navigate(`/config/devices/device/${this.devices[0].id}`);
} else {
navigate(
`/config/integrations/integration/${this.step.result.domain}#config_entry=${this.step.result.entry_id}`
);
}
// Helpers link their entities to an existing device instead of creating one
let helperEntities =
this._helperEntitiesLookup && !this.devices.length
? await this._helperEntitiesLookup
: [];
if (this._helperArea) {
helperEntities = await this._saveHelperArea(
helperEntities,
this._helperArea
);
}
const entry = this.navigateToResult ? this.step.result : undefined;
const helperResult = entry
? computeHelperResult(helperEntities)
: undefined;
// The dialog was closed while waiting
if (!this.isConnected) {
return;
}
fireEvent(this, "flow-update", { step: undefined });
if (!entry) {
return;
}
const deviceId =
this.devices.length === 1 ? this.devices[0].id : helperResult?.deviceId;
if (deviceId) {
navigate(`/config/devices/device/${deviceId}`);
} else if (helperResult?.entityId) {
showMoreInfoDialog(this, { entityId: helperResult.entityId });
} else {
navigate(
`/config/integrations/integration/${entry.domain}#config_entry=${entry.entry_id}`
);
}
}
private async _findHelperEntities(
entryId: string
): Promise<ExtEntityRegistryEntry[]> {
try {
const { entity: entityIds = [] } = await findRelated(
this.hass,
"config_entry",
entryId
);
if (!entityIds.length) {
return [];
}
return Object.values(
await getExtendedEntityRegistryEntries(this.hass, entityIds)
).filter(
(entity): entity is ExtEntityRegistryEntry =>
entity?.config_entry_id === entryId
);
} catch (_err) {
return [];
}
}
private async _saveHelperArea(
entities: ExtEntityRegistryEntry[],
areaId: string
): Promise<ExtEntityRegistryEntry[]> {
try {
const newEntityIds = await updateEntitiesArea(
this.hass,
this._entitiesWithoutDevice(entities).map((entity) => entity.entity_id),
areaId
);
return entities.map((entity) =>
entity.entity_id in newEntityIds
? { ...entity, entity_id: newEntityIds[entity.entity_id] }
: entity
);
} catch (err: unknown) {
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_flow.error_saving_entity",
{ error: (err as { message?: string })?.message ?? "Unknown error" }
),
});
return entities;
}
}
private _helperAreaPicked(ev: ValueChangedEvent<string | undefined>): void {
this._helperArea = ev.detail.value || undefined;
}
public async finish(): Promise<void> {
+2
View File
@@ -104,6 +104,7 @@ export class HassRouterPage extends ReactiveElement {
const queryParams = window.location.search;
navigate(`${route.prefix}/${defaultPage}${queryParams}`, {
replace: true,
keepDialogs: true,
});
}
@@ -134,6 +135,7 @@ export class HassRouterPage extends ReactiveElement {
if (route) {
navigate(`${route.prefix}/${result}${location.search}`, {
replace: true,
keepDialogs: true,
});
}
}
+1 -1
View File
@@ -189,7 +189,7 @@ class PartialPanelResolver extends HassRouterPage {
const { pathname, search, hash } = mainWindow.location;
navigate(
`${pathname.replace(/^\/hacs/, "/marketplace")}${search}${hash}`,
{ replace: true }
{ replace: true, keepDialogs: true }
);
return undefined;
}
+156 -14
View File
@@ -1,18 +1,28 @@
import { mdiPencil } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-button";
import "../../components/ha-icon-button";
import "../../components/ha-top-app-bar-fixed";
import {
fetchFrontendSystemDataAfterSaves,
queueFrontendSystemDataSave,
type ClimateFrontendSystemData,
} from "../../data/frontend";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import { ChildPanelReady } from "../../layouts/panel-ready";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { showToast } from "../../util/toast";
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
import type { Lovelace } from "../lovelace/types";
import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-background";
import "../lovelace/views/hui-view-container";
import "../../components/ha-top-app-bar-fixed";
const CLIMATE_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = {
strategy: {
@@ -30,10 +40,20 @@ class PanelClimate extends LitElement {
@state() private _lovelace?: Lovelace;
@state() private _config?: ClimateFrontendSystemData;
@state() private _editVisibility = false;
@state() private _searchParams = new URLSearchParams(window.location.search);
private _childPanelReady?: ChildPanelReady;
private _loadConfigPromise?: Promise<void>;
private _loadConfigRevision = 0;
private _setLovelaceRevision = 0;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Initial setup
@@ -47,13 +67,22 @@ class PanelClimate extends LitElement {
}
const oldHass = changedProps.get("hass") as this["hass"];
if (
oldHass &&
this.hass.config.state === "RUNNING" &&
oldHass.config.state !== "RUNNING"
) {
this._setup();
return;
}
if (oldHass && oldHass.localize !== this.hass.localize) {
this._setLovelace();
return;
}
if (oldHass && this.hass) {
// If the entity registry changed, ask the user if they want to refresh the config
// Refresh the generated view when registries or panels change.
if (
oldHass.entities !== this.hass.entities ||
oldHass.devices !== this.hass.devices ||
@@ -63,24 +92,46 @@ class PanelClimate extends LitElement {
) {
if (this.hass.config.state === "RUNNING") {
this._debounceRegistriesChanged();
return;
}
}
// If ha started, refresh the config
if (
this.hass.config.state === "RUNNING" &&
oldHass.config.state !== "RUNNING"
) {
this._setLovelace();
}
}
}
private async _setup() {
await this.hass.loadFragmentTranslation("lovelace");
this._loadConfigPromise = this._loadConfig();
await Promise.all([
this.hass.loadFragmentTranslation("lovelace"),
this._loadConfigPromise,
]);
this._setLovelace();
}
private async _loadConfig() {
const revision = ++this._loadConfigRevision;
this._config = undefined;
try {
const data = await fetchFrontendSystemDataAfterSaves(
this.hass.connection,
"climate"
);
if (revision !== this._loadConfigRevision) {
return;
}
this._config = data || {};
} catch (err) {
if (revision !== this._loadConfigRevision) {
return;
}
// eslint-disable-next-line no-console
console.error("Failed to load climate configuration:", err);
showToast(this, {
message: this.hass.localize("ui.panel.climate.editor.load_failed"),
duration: 0,
dismissable: true,
});
}
}
private _debounceRegistriesChanged = debounce(
() => this._registriesChanged(),
200
@@ -97,6 +148,27 @@ class PanelClimate extends LitElement {
.backButton=${this._searchParams.has("historyBack")}
>
<div slot="title">${this.hass.localize("panel.climate")}</div>
${
!this.hass.user?.is_admin
? nothing
: this._editVisibility
? html`<ha-button
slot="actionItems"
appearance="filled"
size="s"
@click=${this._stopEditVisibility}
>
${this.hass.localize("ui.panel.lovelace.menu.exit_edit_mode")}
</ha-button>`
: this._config
? html`<ha-icon-button
slot="actionItems"
.path=${mdiPencil}
.label=${this.hass.localize("ui.panel.climate.editor.title")}
@click=${this._startEditVisibility}
></ha-icon-button>`
: nothing
}
${
this._lovelace
? html`
@@ -108,6 +180,7 @@ class PanelClimate extends LitElement {
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
@entity-visibility-changed=${this._visibilityChanged}
></hui-view
></hui-view-container>
`
@@ -117,16 +190,31 @@ class PanelClimate extends LitElement {
`;
}
private async _setLovelace() {
private async _setLovelace(force = false) {
const revision = ++this._setLovelaceRevision;
if (this._loadConfigPromise) {
await this._loadConfigPromise;
}
const viewConfig = await generateLovelaceViewStrategy(
CLIMATE_LOVELACE_VIEW_CONFIG,
{
strategy: {
...CLIMATE_LOVELACE_VIEW_CONFIG.strategy,
hidden_entities: this._config?.hidden_entities,
edit_visibility: this._editVisibility || undefined,
},
},
this.hass
);
if (revision !== this._setLovelaceRevision) {
return;
}
const config = { views: [viewConfig] };
const rawConfig = { views: [CLIMATE_LOVELACE_VIEW_CONFIG] };
if (deepEqual(config, this._lovelace?.config)) {
if (!force && deepEqual(config, this._lovelace?.config)) {
return;
}
@@ -146,6 +234,60 @@ class PanelClimate extends LitElement {
};
}
private _startEditVisibility = () => {
this._editVisibility = true;
this._setLovelace();
};
private _stopEditVisibility = () => {
this._editVisibility = false;
if (!this._config) {
this._loadConfigPromise = this._loadConfig();
}
this._setLovelace();
};
private _visibilityChanged = (
ev: HASSDomEvent<HASSDomEvents["entity-visibility-changed"]>
) => {
if (!this._config) {
return;
}
const { entity, hidden } = ev.detail;
const hiddenEntities = (this._config.hidden_entities ?? []).filter(
(entityId) => entityId !== entity
);
if (hidden) {
hiddenEntities.push(entity);
}
this._saveConfig({
...this._config,
hidden_entities: hiddenEntities.length ? hiddenEntities : undefined,
});
};
private _saveConfig(config: ClimateFrontendSystemData): Promise<void> {
this._config = config;
return queueFrontendSystemDataSave(
this.hass.connection,
"climate",
config
).catch(async (err) => {
// eslint-disable-next-line no-console
console.error("Failed to save climate configuration:", err);
showToast(this, {
message: this.hass.localize("ui.panel.climate.editor.save_failed"),
duration: 0,
dismissable: true,
});
if (config !== this._config) {
return;
}
await this._loadConfig();
await this._setLovelace(true);
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
@@ -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 = {
+110 -12
View File
@@ -13,6 +13,7 @@ import type {
import { fireEvent } from "../../../common/dom/fire_event";
import { stopPropagation } from "../../../common/dom/stop_propagation";
import { stringCompare } from "../../../common/string/compare";
import "../../../components/ha-area-picker";
import "../../../components/ha-button";
import "../../../components/ha-dialog";
import "../../../components/ha-dialog-footer";
@@ -28,6 +29,12 @@ import type { HaListSelectable } from "../../../components/list/ha-list-selectab
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { getConfigFlowHandlers } from "../../../data/config_flow";
import { createCounter } from "../../../data/counter";
import {
fetchEntityRegistry,
getExtendedEntityRegistryEntry,
updateEntitiesArea,
type EntityRegistryEntry,
} from "../../../data/entity/entity_registry";
import { createInputBoolean } from "../../../data/input_boolean";
import { createInputButton } from "../../../data/input_button";
import { createInputDateTime } from "../../../data/input_datetime";
@@ -43,8 +50,9 @@ import { createSchedule } from "../../../data/schedule";
import { createTimer } from "../../../data/timer";
import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
import { haStyleDialog, haStyleScrollbar } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
import type { Helper, HelperDomain, HelperFormItem } from "./const";
import { isHelperDomain } from "./const";
@@ -110,12 +118,14 @@ const HELPERS: HelperCreators = {
@customElement("dialog-helper-detail")
export class DialogHelperDetail extends DirtyStateProviderMixin<
HelperFormItem | undefined
{ item?: HelperFormItem; area?: string } | undefined
>()(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _item?: HelperFormItem;
@state() private _area?: string;
@state() private _open = false;
@state() private _domain?: string;
@@ -137,6 +147,7 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
startFlowHandler: string;
manifest: IntegrationManifest;
dialogClosedCallback?: ShowDialogHelperDetailParams["dialogClosedCallback"];
navigateToResult?: boolean;
};
private _params?: ShowDialogHelperDetailParams;
@@ -145,11 +156,15 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
this._params = params;
this._domain = params.domain;
this._item = params.item;
this._area = undefined;
if (this._domain && this._domain in HELPERS) {
this._loading = true;
try {
await HELPERS[this._domain].import();
this._initDirtyTracking({ type: "deep" }, undefined);
this._initDirtyTracking(
{ type: "deep" },
{ item: this._item, area: undefined }
);
} finally {
this._loading = false;
}
@@ -185,6 +200,7 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
startFlowHandler: pendingConfigFlow.startFlowHandler,
manifest: pendingConfigFlow.manifest,
dialogClosedCallback: pendingConfigFlow.dialogClosedCallback,
navigateToResult: pendingConfigFlow.navigateToResult,
});
}
}
@@ -206,6 +222,11 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
new: true,
autofocus: true,
})}
<ha-area-picker
.value=${this._area}
.disabled=${this._submitting}
@value-changed=${this._areaChanged}
></ha-area-picker>
</div>
`;
footer = html`
@@ -380,28 +401,45 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
private _valueChanged(ev: CustomEvent): void {
this._item = ev.detail.value;
this._updateDirtyState(this._item);
this._updateDirtyState({ item: this._item, area: this._area });
}
private async _createItem(): Promise<void> {
if (!this._domain || !this._item) {
return;
}
const domain = this._domain;
const params = this._params;
const area = this._area;
this._submitting = true;
this._error = "";
try {
const createdEntity = await HELPERS[this._domain].create(
this.hass,
this._item
);
if (this._params?.dialogClosedCallback && createdEntity.id) {
this._params.dialogClosedCallback({
const createdEntity = await HELPERS[domain].create(this.hass, this._item);
// The dialog was closed or reopened while waiting
if (this._params !== params) {
return;
}
let entityId: string | undefined;
if (createdEntity.id && (area || params?.navigateToResult)) {
entityId = await this._findEntityId(domain, createdEntity.id);
if (area) {
entityId = await this._saveArea(entityId, area);
}
if (this._params !== params) {
return;
}
}
if (params?.dialogClosedCallback && createdEntity.id) {
params.dialogClosedCallback({
flowFinished: true,
entityId: `${this._domain}.${createdEntity.id}`,
entityId: entityId ?? `${domain}.${createdEntity.id}`,
});
}
this._markDirtyStateClean();
this.closeDialog();
if (params?.navigateToResult && entityId) {
showMoreInfoDialog(this, { entityId });
}
} catch (err: any) {
this._error = err.message || "Unknown error";
} finally {
@@ -409,6 +447,57 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
}
}
private async _findEntityId(
domain: string,
uniqueId: string
): Promise<string | undefined> {
const isCreated = (entry: EntityRegistryEntry) =>
entry.platform === domain && entry.unique_id === uniqueId;
try {
// The entity ID usually matches the helper ID, unless it was taken
const entry = await getExtendedEntityRegistryEntry(
this.hass,
`${domain}.${uniqueId}`
).catch(() => undefined);
if (entry && isCreated(entry)) {
return entry.entity_id;
}
return (await fetchEntityRegistry(this.hass.connection)).find(isCreated)
?.entity_id;
} catch (_err) {
return undefined;
}
}
private async _saveArea(
entityId: string | undefined,
areaId: string
): Promise<string | undefined> {
let error = "Entity not found";
if (entityId) {
try {
return (await updateEntitiesArea(this.hass, [entityId], areaId))[
entityId
];
} catch (err: unknown) {
error = (err as { message?: string })?.message ?? "Unknown error";
}
}
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_flow.error_saving_entity",
{ error }
),
});
return entityId;
}
private _areaChanged(ev: ValueChangedEvent<string | undefined>): void {
ev.stopPropagation();
this._area = ev.detail.value || undefined;
this._updateDirtyState({ item: this._item, area: this._area });
}
private async _domainPicked(
ev: HASSDomEvent<number> & HASSDomCurrentTargetEvent<HaListSelectable>
): Promise<void> {
@@ -435,7 +524,10 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
try {
await HELPERS[domain].import();
this._domain = domain;
this._initDirtyTracking({ type: "deep" }, undefined);
this._initDirtyTracking(
{ type: "deep" },
{ item: this._item, area: undefined }
);
} finally {
this._loading = false;
}
@@ -444,6 +536,7 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
startFlowHandler: domain,
manifest: await fetchIntegrationManifest(this.hass, domain),
dialogClosedCallback: this._params?.dialogClosedCallback,
navigateToResult: this._params?.navigateToResult,
};
this.closeDialog();
}
@@ -452,6 +545,7 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
private async _goBack() {
this._domain = undefined;
this._item = undefined;
this._area = undefined;
this._error = undefined;
await this.updateComplete;
await this._focusSearchInput();
@@ -490,6 +584,10 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
.form {
padding: var(--ha-space-6);
}
.form ha-area-picker {
display: block;
margin-top: var(--ha-space-4);
}
ha-input-search {
margin: 0 var(--ha-space-4) var(--ha-space-3);
}
@@ -1300,6 +1300,7 @@ ${rejected
if (isHelperDomain(domain)) {
showHelperDetailDialog(this, {
domain,
navigateToResult: true,
});
return;
}
@@ -1343,6 +1344,7 @@ ${rejected
showConfigFlowDialog(this, {
startFlowHandler: domain,
manifest: await fetchIntegrationManifest(this.hass, domain),
navigateToResult: true,
});
}
@@ -1568,11 +1570,12 @@ ${rejected
{ name: config.name }
),
},
navigateToResult: true,
});
}
private _createHelper() {
showHelperDetailDialog(this, {});
showHelperDetailDialog(this, { navigateToResult: true });
}
private _bulkCreateCategory = () => {
@@ -11,6 +11,7 @@ export interface ShowDialogHelperDetailParams {
entryId?: string;
entityId?: string;
}) => void;
navigateToResult?: boolean;
}
export const showHelperDetailDialog = (
+84 -6
View File
@@ -16,7 +16,9 @@ import {
fetchFrontendSystemData,
saveFrontendSystemData,
subscribeFrontendSystemData,
type ClimateFrontendSystemData,
type HomeFrontendSystemData,
type MaintenanceFrontendSystemData,
type SecurityFrontendSystemData,
} from "../../data/frontend";
import type { LovelaceDashboardStrategyConfig } from "../../data/lovelace/config/types";
@@ -54,6 +56,10 @@ class PanelHome extends SubscribeMixin(LitElement) {
@state() private _securityConfig: SecurityFrontendSystemData = {};
@state() private _maintenanceConfig: MaintenanceFrontendSystemData = {};
@state() private _climateConfig: ClimateFrontendSystemData = {};
@state() private _extraActionItems?: ExtraActionItem[];
@query(".banner") private _banner?: HTMLElement;
@@ -62,6 +68,10 @@ class PanelHome extends SubscribeMixin(LitElement) {
private _securityConfigRevision = 0;
private _maintenanceConfigRevision = 0;
private _climateConfigRevision = 0;
public hassSubscribe() {
return [
subscribeFrontendSystemData(
@@ -79,6 +89,36 @@ class PanelHome extends SubscribeMixin(LitElement) {
}
}
),
subscribeFrontendSystemData(
this.hass.connection,
"maintenance",
({ value }) => {
this._maintenanceConfigRevision++;
const config = value || {};
if (deepEqual(config, this._maintenanceConfig)) {
return;
}
this._maintenanceConfig = config;
if (this.hasUpdated) {
this._setLovelace();
}
}
),
subscribeFrontendSystemData(
this.hass.connection,
"climate",
({ value }) => {
this._climateConfigRevision++;
const config = value || {};
if (deepEqual(config, this._climateConfig)) {
return;
}
this._climateConfig = config;
if (this.hasUpdated) {
this._setLovelace();
}
}
),
];
}
@@ -179,12 +219,21 @@ class PanelHome extends SubscribeMixin(LitElement) {
private async _loadConfig() {
const securityConfigRevision = this._securityConfigRevision;
const [translationsResult, homeResult, securityResult] =
await Promise.allSettled([
this.hass.loadFragmentTranslation("lovelace"),
fetchFrontendSystemData(this.hass.connection, "home"),
fetchFrontendSystemData(this.hass.connection, "security"),
]);
const maintenanceConfigRevision = this._maintenanceConfigRevision;
const climateConfigRevision = this._climateConfigRevision;
const [
translationsResult,
homeResult,
securityResult,
maintenanceResult,
climateResult,
] = await Promise.allSettled([
this.hass.loadFragmentTranslation("lovelace"),
fetchFrontendSystemData(this.hass.connection, "home"),
fetchFrontendSystemData(this.hass.connection, "security"),
fetchFrontendSystemData(this.hass.connection, "maintenance"),
fetchFrontendSystemData(this.hass.connection, "climate"),
]);
if (translationsResult.status === "rejected") {
// eslint-disable-next-line no-console
@@ -214,6 +263,32 @@ class PanelHome extends SubscribeMixin(LitElement) {
} else if (securityConfigRevision === this._securityConfigRevision) {
this._securityConfig = securityResult.value || {};
}
if (maintenanceResult.status === "rejected") {
// eslint-disable-next-line no-console
console.error(
"Failed to load maintenance configuration:",
maintenanceResult.reason
);
if (maintenanceConfigRevision === this._maintenanceConfigRevision) {
this._maintenanceConfig = {};
}
} else if (maintenanceConfigRevision === this._maintenanceConfigRevision) {
this._maintenanceConfig = maintenanceResult.value || {};
}
if (climateResult.status === "rejected") {
// eslint-disable-next-line no-console
console.error(
"Failed to load climate configuration:",
climateResult.reason
);
if (climateConfigRevision === this._climateConfigRevision) {
this._climateConfig = {};
}
} else if (climateConfigRevision === this._climateConfigRevision) {
this._climateConfig = climateResult.value || {};
}
}
private _debounceRegenerateStrategy = debounce(
@@ -400,6 +475,9 @@ class PanelHome extends SubscribeMixin(LitElement) {
strategy: {
type: "home",
alert_entities: this._securityConfig.alert_entities,
security_hidden_entities: this._securityConfig.hidden_entities,
maintenance_hidden_entities: this._maintenanceConfig.hidden_entities,
climate_hidden_entities: this._climateConfig.hidden_entities,
favorite_entities: this._config?.favorite_entities,
home_panel: true,
hide_welcome_message: this._config?.hide_welcome_message,
@@ -0,0 +1,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": () =>
+8 -2
View File
@@ -291,6 +291,12 @@ export class LovelacePanel extends LitElement {
this._fetchConfig(true);
}
private _redirectToHome() {
// Keeps the query and any open dialog: a more-info deep link may be
// restored from the URL around this redirect.
navigate(`/home${location.search}`, { replace: true, keepDialogs: true });
}
private async _fetchConfig(forceDiskRefresh: boolean) {
this._loading = true;
@@ -300,7 +306,7 @@ export class LovelacePanel extends LitElement {
// If no mode, redirect to /home as there is no "lovelace" dashboard
if (!confMode) {
navigate("/home", { replace: true });
this._redirectToHome();
return;
}
@@ -361,7 +367,7 @@ export class LovelacePanel extends LitElement {
const dashboards = await fetchDashboards(this.hass!);
const dashboard = dashboards.find((d) => d.url_path === "lovelace");
if (!dashboard) {
navigate("/home", { replace: true });
this._redirectToHome();
return;
}
}
+13 -8
View File
@@ -32,6 +32,7 @@ import { UndoRedoController } from "../../common/controllers/undo-redo-controlle
import { fireEvent } from "../../common/dom/fire_event";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { ctrlOrCmdLabel } from "../../common/keyboard/ctrl-or-cmd";
import type { NavigateOptions } from "../../common/navigate";
import { goBack, navigate, replaceCurrentUrl } from "../../common/navigate";
import type { LocalizeKeys } from "../../common/translations/localize";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
@@ -831,7 +832,12 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
if (!viewPath && views.length) {
newSelectView = views.findIndex(this._isVisible);
this._navigateToView(views[newSelectView].path || newSelectView, true);
// Filling in the default view keeps the entry as is: a dialog restored
// from the URL, like a more-info deep link, can already be open.
this._navigateToView(views[newSelectView].path || newSelectView, {
replace: true,
keepDialogs: true,
});
} else if (viewPath === "hass-unused-entities") {
newSelectView = "hass-unused-entities";
} else if (viewPath) {
@@ -865,10 +871,9 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
viewPath === "hass-unused-entities"
) {
newSelectView = views.findIndex(this._isVisible);
this._navigateToView(
views[newSelectView].path || newSelectView,
true
);
this._navigateToView(views[newSelectView].path || newSelectView, {
replace: true,
});
}
}
@@ -1197,7 +1202,7 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
});
}
private _navigateToView(path: string | number, replace?: boolean) {
private _navigateToView(path: string | number, options?: NavigateOptions) {
if (this.embedded) {
fireEvent(this, "view-navigate", { path: String(path) });
@@ -1208,7 +1213,7 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
const currentUrl = `${location.pathname}${location.search}`;
if (currentUrl !== url) {
navigate(url, { replace });
navigate(url, options);
}
}
@@ -1278,7 +1283,7 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
newCurView = indexAfterMove(curView, oldIndex, newIndex);
// Views without a path are addressed by their index in the URL
if (newCurView !== curView && !this.config.views[curView].path) {
this._navigateToView(newCurView, true);
this._navigateToView(newCurView, { replace: true });
}
}
lovelace.saveConfig(moveView(lovelace.config, oldIndex, newIndex));
@@ -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
+156 -13
View File
@@ -1,13 +1,23 @@
import { mdiPencil } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-button";
import "../../components/ha-icon-button";
import "../../components/ha-top-app-bar-fixed";
import {
fetchFrontendSystemDataAfterSaves,
queueFrontendSystemDataSave,
type MaintenanceFrontendSystemData,
} from "../../data/frontend";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import { ChildPanelReady } from "../../layouts/panel-ready";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { showToast } from "../../util/toast";
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
import type { Lovelace } from "../lovelace/types";
import "../lovelace/views/hui-view";
@@ -30,10 +40,20 @@ class PanelMaintenance extends LitElement {
@state() private _lovelace?: Lovelace;
@state() private _config?: MaintenanceFrontendSystemData;
@state() private _editVisibility = false;
@state() private _searchParams = new URLSearchParams(window.location.search);
private _childPanelReady?: ChildPanelReady;
private _loadConfigPromise?: Promise<void>;
private _loadConfigRevision = 0;
private _setLovelaceRevision = 0;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Initial setup
@@ -47,13 +67,22 @@ class PanelMaintenance extends LitElement {
}
const oldHass = changedProps.get("hass") as this["hass"];
if (
oldHass &&
this.hass.config.state === "RUNNING" &&
oldHass.config.state !== "RUNNING"
) {
this._setup();
return;
}
if (oldHass && oldHass.localize !== this.hass.localize) {
this._setLovelace();
return;
}
if (oldHass && this.hass) {
// If the entity registry changed, ask the user if they want to refresh the config
// Refresh the generated view when registries or panels change.
if (
oldHass.entities !== this.hass.entities ||
oldHass.devices !== this.hass.devices ||
@@ -63,24 +92,47 @@ class PanelMaintenance extends LitElement {
) {
if (this.hass.config.state === "RUNNING") {
this._debounceRegistriesChanged();
return;
}
}
// If ha started, refresh the config
if (
this.hass.config.state === "RUNNING" &&
oldHass.config.state !== "RUNNING"
) {
this._setLovelace();
}
}
}
private async _setup() {
await this.hass.loadFragmentTranslation("lovelace");
this._loadConfigPromise = this._loadConfig();
await Promise.all([
this.hass.loadFragmentTranslation("lovelace"),
this._loadConfigPromise,
]);
this._setLovelace();
}
private async _loadConfig() {
const revision = ++this._loadConfigRevision;
this._config = undefined;
try {
const data = await fetchFrontendSystemDataAfterSaves(
this.hass.connection,
"maintenance"
);
if (revision !== this._loadConfigRevision) {
return;
}
this._config = data || {};
} catch (err) {
if (revision !== this._loadConfigRevision) {
return;
}
this._editVisibility = false;
// eslint-disable-next-line no-console
console.error("Failed to load maintenance configuration:", err);
showToast(this, {
message: this.hass.localize("ui.panel.maintenance.editor.load_failed"),
duration: 0,
dismissable: true,
});
}
}
private _debounceRegistriesChanged = debounce(
() => this._registriesChanged(),
200
@@ -97,6 +149,27 @@ class PanelMaintenance extends LitElement {
.backButton=${this._searchParams.has("historyBack")}
>
<div slot="title">${this.hass.localize("panel.maintenance")}</div>
${
!this.hass.user?.is_admin
? nothing
: this._editVisibility
? html`<ha-button
slot="actionItems"
appearance="filled"
size="s"
@click=${this._stopEditVisibility}
>
${this.hass.localize("ui.panel.lovelace.menu.exit_edit_mode")}
</ha-button>`
: this._config
? html`<ha-icon-button
slot="actionItems"
.path=${mdiPencil}
.label=${this.hass.localize("ui.panel.maintenance.editor.title")}
@click=${this._startEditVisibility}
></ha-icon-button>`
: nothing
}
${
this._lovelace
? html`
@@ -108,6 +181,7 @@ class PanelMaintenance extends LitElement {
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
@entity-visibility-changed=${this._visibilityChanged}
></hui-view>
</hui-view-container>
`
@@ -117,16 +191,31 @@ class PanelMaintenance extends LitElement {
`;
}
private async _setLovelace() {
private async _setLovelace(force = false) {
const revision = ++this._setLovelaceRevision;
if (this._loadConfigPromise) {
await this._loadConfigPromise;
}
const viewConfig = await generateLovelaceViewStrategy(
MAINTENANCE_LOVELACE_VIEW_CONFIG,
{
strategy: {
...MAINTENANCE_LOVELACE_VIEW_CONFIG.strategy,
hidden_entities: this._config?.hidden_entities,
edit_visibility: this._editVisibility || undefined,
},
},
this.hass
);
if (revision !== this._setLovelaceRevision) {
return;
}
const config = { views: [viewConfig] };
const rawConfig = { views: [MAINTENANCE_LOVELACE_VIEW_CONFIG] };
if (deepEqual(config, this._lovelace?.config)) {
if (!force && deepEqual(config, this._lovelace?.config)) {
return;
}
@@ -146,6 +235,60 @@ class PanelMaintenance extends LitElement {
};
}
private _startEditVisibility = () => {
this._editVisibility = true;
this._setLovelace();
};
private _stopEditVisibility = () => {
this._editVisibility = false;
if (!this._config) {
this._loadConfigPromise = this._loadConfig();
}
this._setLovelace();
};
private _visibilityChanged = (
ev: HASSDomEvent<HASSDomEvents["entity-visibility-changed"]>
) => {
if (!this._config) {
return;
}
const { entity, hidden } = ev.detail;
const hiddenEntities = (this._config.hidden_entities ?? []).filter(
(entityId) => entityId !== entity
);
if (hidden) {
hiddenEntities.push(entity);
}
this._saveConfig({
...this._config,
hidden_entities: hiddenEntities.length ? hiddenEntities : undefined,
});
};
private _saveConfig(config: MaintenanceFrontendSystemData): Promise<void> {
this._config = config;
return queueFrontendSystemDataSave(
this.hass.connection,
"maintenance",
config
).catch(async (err) => {
// eslint-disable-next-line no-console
console.error("Failed to save maintenance configuration:", err);
showToast(this, {
message: this.hass.localize("ui.panel.maintenance.editor.save_failed"),
duration: 0,
dismissable: true,
});
if (config !== this._config) {
return;
}
await this._loadConfig();
await this._setLovelace(true);
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
@@ -14,6 +14,10 @@ import type { LovelaceSectionRawConfig } from "../../../data/lovelace/config/sec
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import type { TileCardConfig } from "../../lovelace/cards/types";
import {
wrapEntityVisibility,
type ComputeEntityCard,
} from "../../lovelace/strategies/helpers/entity-visibility";
import { BINARY_STATE_ON } from "../../../common/const";
import { computeDomain } from "../../../common/entity/compute_domain";
import { getDeviceEntityDisplayLookup } from "../../../data/device/device_registry";
@@ -21,6 +25,8 @@ import { findBatteryChargingEntity } from "../../../data/entity/entity_registry"
export interface MaintenanceViewStrategyConfig {
type: "maintenance";
hidden_entities?: string[];
edit_visibility?: boolean;
}
export const maintenanceEntityFilters: EntityFilter[] = [
@@ -35,8 +41,10 @@ export const maintenanceEntityFilters: EntityFilter[] = [
];
export const findMaintenanceEntities = (
sources: EntityFilterSources
): string[] => findFilteredEntities(sources, maintenanceEntityFilters);
sources: EntityFilterSources,
hiddenEntities?: string[]
): string[] =>
findFilteredEntities(sources, maintenanceEntityFilters, hiddenEntities);
const LOW_BATTERY_THRESHOLD = 20;
@@ -119,7 +127,8 @@ const computeMaintenanceTileCard = (
const processAreasForBattery = (
areaIds: string[],
hass: HomeAssistant,
entities: string[]
entities: string[],
computeCard: ComputeEntityCard
): LovelaceCardConfig[] => {
const cards: LovelaceCardConfig[] = [];
@@ -141,9 +150,7 @@ const processAreasForBattery = (
const areaCards: LovelaceCardConfig[] = [];
for (const entityId of areaBatteryEntities) {
areaCards.push(
computeMaintenanceTileCard(hass.states, hass.entities, entityId)
);
areaCards.push(computeCard(entityId));
}
if (areaCards.length > 0) {
@@ -167,7 +174,8 @@ const processAreasForBattery = (
const processUnassignedEntities = (
hass: HomeAssistant,
entities: string[]
entities: string[],
computeCard: ComputeEntityCard
): LovelaceCardConfig[] => {
const unassignedFilter = generateEntityFilter(
hass.states,
@@ -183,9 +191,7 @@ const processUnassignedEntities = (
const cards: LovelaceCardConfig[] = [];
for (const entityId of unassignedEntities) {
cards.push(
computeMaintenanceTileCard(hass.states, hass.entities, entityId)
);
cards.push(computeCard(entityId));
}
return cards;
@@ -194,7 +200,7 @@ const processUnassignedEntities = (
@customElement("maintenance-view-strategy")
export class MaintenanceViewStrategy extends ReactiveElement {
static async generate(
_config: MaintenanceViewStrategyConfig,
config: MaintenanceViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const areas = Object.values(hass.areas);
@@ -203,7 +209,16 @@ export class MaintenanceViewStrategy extends ReactiveElement {
const sections: LovelaceSectionRawConfig[] = [];
const entities = findMaintenanceEntities(hass);
const entities = findMaintenanceEntities(
hass,
config.edit_visibility ? undefined : config.hidden_entities
);
const computeTileCard = (entityId: string) =>
computeMaintenanceTileCard(hass.states, hass.entities, entityId);
const computeCard: ComputeEntityCard = config.edit_visibility
? wrapEntityVisibility(new Set(config.hidden_entities), computeTileCard)
: computeTileCard;
const floorCount =
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
@@ -229,7 +244,12 @@ export class MaintenanceViewStrategy extends ReactiveElement {
],
};
const areaCards = processAreasForBattery(areaIds, hass, entities);
const areaCards = processAreasForBattery(
areaIds,
hass,
entities,
computeCard
);
if (areaCards.length > 0) {
section.cards!.push(...areaCards);
@@ -255,7 +275,12 @@ export class MaintenanceViewStrategy extends ReactiveElement {
],
};
const areaCards = processAreasForBattery(hierarchy.areas, hass, entities);
const areaCards = processAreasForBattery(
hierarchy.areas,
hass,
entities,
computeCard
);
if (areaCards.length > 0) {
section.cards!.push(...areaCards);
@@ -264,7 +289,11 @@ export class MaintenanceViewStrategy extends ReactiveElement {
}
// Process unassigned entities
const unassignedCards = processUnassignedEntities(hass, entities);
const unassignedCards = processUnassignedEntities(
hass,
entities,
computeCard
);
if (unassignedCards.length > 0) {
const section: LovelaceSectionRawConfig = {
@@ -1,286 +0,0 @@
import type { ContextType } from "@lit/context";
import { mdiDelete, mdiDragHorizontalVariant } from "@mdi/js";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { consume } from "../../../common/decorators/consume";
import { ALERT_DOMAINS } from "../../../common/entity/alert_active";
import { computeEntityPickerDisplay } from "../../../common/entity/compute_entity_name_display";
import {
fireEvent,
type HASSDomCurrentTargetEvent,
type HASSDomEvent,
} from "../../../common/dom/fire_event";
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import type {
SecurityAlertEntityConfig,
SecurityAlertSeverity,
} from "../../../data/frontend";
import {
internationalizationContext,
registriesContext,
statesContext,
} from "../../../data/context";
import "../../../components/entity/ha-entity-picker";
import "../../../components/entity/state-badge";
import "../../../components/ha-control-select-menu";
import "../../../components/ha-icon-button";
import "../../../components/ha-settings-row";
import "../../../components/ha-sortable";
import "../../../components/ha-svg-icon";
import { computeDefaultSecurityAlertSeverity } from "../strategies/security-alerts";
import {
createSecurityEntityFilter,
type SecurityEntityContext,
} from "../security-entity-filter";
import type { ValueChangedEvent } from "../../../types";
type SecurityEditorContext = SecurityEntityContext & {
language: ContextType<typeof internationalizationContext>["language"];
translationMetadata: ContextType<
typeof internationalizationContext
>["translationMetadata"];
};
@customElement("security-alerts-editor")
export class SecurityAlertsEditor extends LitElement {
@property({ attribute: false })
public alertEntities: SecurityAlertEntityConfig[] = [];
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@state()
@consume({ context: registriesContext, subscribe: true })
private _registries!: ContextType<typeof registriesContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
private _entityContext?: SecurityEditorContext;
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (
changedProps.has("_states") ||
changedProps.has("_registries") ||
changedProps.has("_i18n")
) {
this._entityContext = {
states: this._states,
...this._registries,
language: this._i18n.language,
translationMetadata: this._i18n.translationMetadata,
};
}
}
protected render() {
return html`
<ha-sortable handle-selector=".handle" @item-moved=${this._moved}>
<div class="alert-list">
${repeat(
this.alertEntities,
(alertEntity) => alertEntity.entity,
(alertEntity, index) => this._renderAlertEntity(alertEntity, index)
)}
</div>
</ha-sortable>
<ha-entity-picker
add-button
.addButtonLabel=${this._i18n.localize(
"ui.panel.security.editor.add_alert_entity"
)}
.excludeEntities=${this.alertEntities.map(({ entity }) => entity)}
.includeDomains=${ALERT_DOMAINS}
.entityFilter=${this._entityFilter}
@value-changed=${this._add}
></ha-entity-picker>
`;
}
private _renderAlertEntity(
alertEntity: SecurityAlertEntityConfig,
index: number
) {
const stateObj = this._states[alertEntity.entity];
const { primary, secondary } =
stateObj && this._entityContext
? computeEntityPickerDisplay(this._entityContext, stateObj)
: { primary: alertEntity.entity, secondary: undefined };
const severity =
alertEntity.severity ?? computeDefaultSecurityAlertSeverity(stateObj);
return html`
<div class="alert-row">
<div class="handle">
<ha-svg-icon .path=${mdiDragHorizontalVariant}></ha-svg-icon>
</div>
<ha-settings-row slim>
<state-badge slot="prefix" .stateObj=${stateObj}></state-badge>
<span slot="heading">${primary}</span>
${
secondary
? html`<span slot="description">${secondary}</span>`
: nothing
}
<ha-control-select-menu
show-arrow
hide-label
.label=${this._i18n.localize(
"ui.panel.security.editor.severity.label"
)}
.value=${severity}
.options=${(["alert", "warning"] as const).map((option) => ({
value: option,
label: this._i18n.localize(
`ui.panel.security.editor.severity.${option}`
),
}))}
data-index=${index}
@wa-select=${this._severityChanged}
></ha-control-select-menu>
<ha-icon-button
.path=${mdiDelete}
.label=${this._i18n.localize("ui.common.delete")}
data-index=${index}
@click=${this._removeClicked}
></ha-icon-button>
</ha-settings-row>
</div>
`;
}
private _changed(next: SecurityAlertEntityConfig[]): void {
fireEvent(this, "value-changed", { value: next });
}
private _entityFilter = createSecurityEntityFilter(() => this._entityContext);
private _getIndex(
ev: HASSDomCurrentTargetEvent<HTMLElement>
): number | undefined {
const index = Number(ev.currentTarget.dataset.index);
return Number.isInteger(index) ? index : undefined;
}
private _removeClicked(ev: HASSDomCurrentTargetEvent<HTMLElement>): void {
ev.stopPropagation();
const index = this._getIndex(ev);
if (index !== undefined) {
const next = [...this.alertEntities];
next.splice(index, 1);
this._changed(next);
}
}
private _severityChanged(
ev: HaDropdownSelectEvent<SecurityAlertSeverity> &
HASSDomCurrentTargetEvent<HTMLElement>
): void {
ev.stopPropagation();
const index = this._getIndex(ev);
if (index === undefined) {
return;
}
const next = [...this.alertEntities];
next[index] = { ...next[index], severity: ev.detail.item.value };
this._changed(next);
}
private _add(
ev: ValueChangedEvent<string | undefined> &
HASSDomCurrentTargetEvent<HaEntityPicker>
): void {
ev.stopPropagation();
const entity = ev.detail.value;
if (!entity) return;
ev.currentTarget.value = "";
if (
this.alertEntities.some((alertEntity) => alertEntity.entity === entity)
) {
return;
}
this._changed([...this.alertEntities, { entity }]);
}
private _moved(ev: HASSDomEvent<HASSDomEvents["item-moved"]>): void {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const next = [...this.alertEntities];
const [moved] = next.splice(oldIndex, 1);
next.splice(newIndex, 0, moved);
this._changed(next);
}
static styles = css`
:host {
display: block;
}
.alert-list {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
.alert-row {
display: flex;
align-items: flex-start;
gap: var(--ha-space-2);
}
.handle {
cursor: grab;
color: var(--secondary-text-color);
flex-shrink: 0;
display: flex;
align-items: center;
min-height: 48px;
}
ha-settings-row {
flex: 1;
min-width: 0;
padding: 0;
gap: var(--ha-space-3);
min-height: 48px;
--settings-row-prefix-display: contents;
--settings-row-content-display: contents;
--settings-row-body-padding-top: var(--ha-space-1);
--settings-row-body-padding-bottom: var(--ha-space-1);
}
state-badge {
flex-shrink: 0;
width: 24px;
height: 24px;
--state-icon-color: var(--secondary-text-color);
}
[slot="heading"],
[slot="description"] {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
ha-entity-picker {
display: block;
padding-top: var(--ha-space-3);
}
ha-icon-button {
--ha-icon-button-size: 40px;
}
ha-control-select-menu {
width: 118px;
--control-select-menu-height: 42px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"security-alerts-editor": SecurityAlertsEditor;
}
}
@@ -1,237 +0,0 @@
import type { ContextType } from "@lit/context";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, state } from "lit/decorators";
import { consume } from "../../../common/decorators/consume";
import "../../../components/ha-button";
import "../../../components/ha-dialog";
import "../../../components/ha-dialog-footer";
import "../../../components/ha-expansion-panel";
import "../../../components/ha-icon";
import type { SecurityFrontendSystemData } from "../../../data/frontend";
import {
internationalizationContext,
registriesContext,
statesContext,
} from "../../../data/context";
import { DialogMixin } from "../../../dialogs/dialog-mixin";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../resources/styles";
import type { ValueChangedEvent } from "../../../types";
import "../../../components/entity/ha-favorites-editor";
import "../components/security-alerts-editor";
import {
createSecurityEntityFilter,
type SecurityEntityContext,
} from "../security-entity-filter";
import type { EditSecurityDialogParams } from "./show-dialog-edit-security";
@customElement("dialog-edit-security")
export class DialogEditSecurity extends DirtyStateProviderMixin<SecurityFrontendSystemData>()(
DialogMixin<EditSecurityDialogParams>(LitElement)
) {
@state() private _state?: SecurityFrontendSystemData;
@state() private _submitting = false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@state()
@consume({ context: registriesContext, subscribe: true })
private _registries!: ContextType<typeof registriesContext>;
private _entityContext?: SecurityEntityContext;
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("_states") || changedProps.has("_registries")) {
this._entityContext = {
states: this._states,
...this._registries,
};
}
}
public connectedCallback(): void {
super.connectedCallback();
if (!this.params) {
return;
}
this._state = {
...this.params.config,
favorite_entities: this.params.config.favorite_entities
? [...this.params.config.favorite_entities]
: [],
alert_entities: this.params.config.alert_entities
? [...this.params.config.alert_entities]
: [],
};
this._initDirtyTracking({ type: "deep" }, this._state);
}
protected render() {
if (!this.params || !this._state) {
return nothing;
}
return html`
<ha-dialog
open
width="medium"
.headerTitle=${this._i18n.localize("ui.panel.security.editor.title")}
.headerSubtitle=${this._i18n.localize(
"ui.panel.security.editor.description"
)}
.preventScrimClose=${this.isDirtyState}
>
${this._renderMainEditor()}
<ha-dialog-footer slot="footer">
<ha-button
autofocus
appearance="plain"
slot="secondaryAction"
@click=${this.closeDialog}
.disabled=${this._submitting}
>
${this._i18n.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${this._submitting || !this.isDirtyState}
>
${this._i18n.localize("ui.common.save")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
private _renderMainEditor() {
return html`
<ha-expansion-panel
outlined
expanded
no-collapse
.header=${this._i18n.localize(
"ui.panel.security.editor.favorite_entities"
)}
.secondary=${this._i18n.localize(
"ui.panel.security.editor.favorite_entities_description"
)}
>
<ha-icon slot="leading-icon" icon="mdi:star-outline"></ha-icon>
<div class="expansion-content">
<ha-favorites-editor
.favorites=${this._state?.favorite_entities ?? []}
.entityFilter=${this._entityFilter}
.addButtonLabel=${this._i18n.localize(
"ui.panel.security.editor.add_favorite_entity"
)}
@value-changed=${this._favoriteEntitiesChanged}
></ha-favorites-editor>
</div>
</ha-expansion-panel>
<ha-expansion-panel
outlined
expanded
no-collapse
.header=${this._i18n.localize(
"ui.panel.security.editor.active_alert_entities"
)}
.secondary=${this._i18n.localize(
"ui.panel.security.editor.active_alert_entities_description"
)}
>
<ha-icon slot="leading-icon" icon="mdi:shield-alert-outline"></ha-icon>
<div class="expansion-content">
<security-alerts-editor
.alertEntities=${this._state?.alert_entities ?? []}
@value-changed=${this._alertEntitiesChanged}
></security-alerts-editor>
</div>
</ha-expansion-panel>
`;
}
private _alertEntitiesChanged(
ev: ValueChangedEvent<SecurityFrontendSystemData["alert_entities"]>
): void {
this._state = {
...this._state,
alert_entities: ev.detail.value,
};
this._updateDirtyState(this._state);
}
private _favoriteEntitiesChanged(
ev: ValueChangedEvent<SecurityFrontendSystemData["favorite_entities"]>
): void {
this._state = {
...this._state,
favorite_entities: ev.detail.value,
};
this._updateDirtyState(this._state);
}
private _entityFilter = createSecurityEntityFilter(() => this._entityContext);
private async _save(): Promise<void> {
if (!this.params || !this._state) return;
this._submitting = true;
try {
await this.params.saveConfig({
...this.params.config,
favorite_entities: this._state.favorite_entities?.length
? this._state.favorite_entities
: undefined,
alert_entities: this._state.alert_entities?.length
? this._state.alert_entities
: undefined,
});
this._markDirtyStateClean();
this.closeDialog();
} catch {
return;
} finally {
this._submitting = false;
}
}
static styles = [
haStyleDialog,
css`
ha-dialog {
--dialog-content-padding: var(--ha-space-6);
}
ha-expansion-panel {
display: block;
--expansion-panel-content-padding: 0;
border-radius: var(--ha-border-radius-md);
--ha-card-border-radius: var(--ha-border-radius-md);
}
ha-expansion-panel + ha-expansion-panel {
margin-top: var(--ha-space-4);
}
.expansion-content {
padding: var(--ha-space-3);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"dialog-edit-security": DialogEditSecurity;
}
}
@@ -1,20 +0,0 @@
import { fireEvent } from "../../../common/dom/fire_event";
import type { SecurityFrontendSystemData } from "../../../data/frontend";
export interface EditSecurityDialogParams {
config: SecurityFrontendSystemData;
saveConfig: (config: SecurityFrontendSystemData) => Promise<void>;
}
export const loadEditSecurityDialog = () => import("./dialog-edit-security");
export const showEditSecurityDialog = (
element: HTMLElement,
params: EditSecurityDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-edit-security",
dialogImport: loadEditSecurityDialog,
dialogParams: params,
});
};
+134 -32
View File
@@ -1,14 +1,17 @@
import { mdiPencil } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-button";
import type { HaButton } from "../../components/ha-button";
import "../../components/ha-icon-button";
import "../../components/ha-top-app-bar-fixed";
import {
fetchFrontendSystemData,
saveFrontendSystemData,
fetchFrontendSystemDataAfterSaves,
queueFrontendSystemDataSave,
type SecurityFrontendSystemData,
} from "../../data/frontend";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
@@ -18,7 +21,7 @@ import type { HomeAssistant } from "../../types";
import { showToast } from "../../util/toast";
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
import type { Lovelace } from "../lovelace/types";
import { showEditSecurityDialog } from "./dialogs/show-dialog-edit-security";
import { clearEntityVisibilityFocus } from "../lovelace/cards/hui-entity-visibility-card";
import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-container";
import "../lovelace/views/hui-view-background";
@@ -41,14 +44,20 @@ class PanelSecurity extends LitElement {
@state() private _config?: SecurityFrontendSystemData;
@state() private _editVisibility = false;
@state() private _searchParms = new URLSearchParams(window.location.search);
@query("ha-button") private _exitEditButton?: HaButton;
private _childPanelReady?: ChildPanelReady;
private _loadConfigPromise?: Promise<void>;
private _loadConfigRevision = 0;
private _setLovelaceRevision = 0;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Initial setup
@@ -105,7 +114,7 @@ class PanelSecurity extends LitElement {
const revision = ++this._loadConfigRevision;
this._config = undefined;
try {
const data = await fetchFrontendSystemData(
const data = await fetchFrontendSystemDataAfterSaves(
this.hass.connection,
"security"
);
@@ -144,14 +153,25 @@ class PanelSecurity extends LitElement {
>
<div slot="title">${this.hass.localize("panel.security")}</div>
${
this.hass.user?.is_admin && this._config
? html`<ha-icon-button
slot="actionItems"
.path=${mdiPencil}
.label=${this.hass.localize("ui.panel.security.editor.title")}
@click=${this._editSecurity}
></ha-icon-button>`
: nothing
!this.hass.user?.is_admin
? nothing
: this._editVisibility
? html`<ha-button
slot="actionItems"
appearance="filled"
size="s"
@click=${this._stopEditVisibility}
>
${this.hass.localize("ui.panel.lovelace.menu.exit_edit_mode")}
</ha-button>`
: this._config
? html`<ha-icon-button
slot="actionItems"
.path=${mdiPencil}
.label=${this.hass.localize("ui.panel.security.editor.title")}
@click=${this._startEditVisibility}
></ha-icon-button>`
: nothing
}
${
this._lovelace
@@ -164,6 +184,10 @@ class PanelSecurity extends LitElement {
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
@entity-visibility-changed=${this._visibilityChanged}
@entity-favorite-changed=${this._favoriteChanged}
@entity-alert-changed=${this._alertChanged}
@entity-visibility-focus-lost=${this._focusLost}
></hui-view
></hui-view-container>
`
@@ -173,7 +197,8 @@ class PanelSecurity extends LitElement {
`;
}
private async _setLovelace() {
private async _setLovelace(force = false) {
const revision = ++this._setLovelaceRevision;
if (this._loadConfigPromise) {
await this._loadConfigPromise;
}
@@ -184,15 +209,21 @@ class PanelSecurity extends LitElement {
...SECURITY_LOVELACE_VIEW_CONFIG.strategy,
alert_entities: this._config?.alert_entities,
favorite_entities: this._config?.favorite_entities,
hidden_entities: this._config?.hidden_entities,
edit_visibility: this._editVisibility || undefined,
},
},
this.hass
);
if (revision !== this._setLovelaceRevision) {
return;
}
const config = { views: [viewConfig] };
const rawConfig = { views: [SECURITY_LOVELACE_VIEW_CONFIG] };
if (deepEqual(config, this._lovelace?.config)) {
if (!force && deepEqual(config, this._lovelace?.config)) {
return;
}
@@ -212,23 +243,94 @@ class PanelSecurity extends LitElement {
};
}
private _editSecurity = () => {
if (!this.hass.user?.is_admin || !this._config) {
private _startEditVisibility = () => {
clearEntityVisibilityFocus();
this._editVisibility = true;
this._setLovelace();
};
private _stopEditVisibility = () => {
this._editVisibility = false;
if (!this._config) {
this._loadConfigPromise = this._loadConfig();
}
this._setLovelace();
};
private _focusLost = () => {
this._exitEditButton?.focus();
};
private _visibilityChanged = (
ev: HASSDomEvent<HASSDomEvents["entity-visibility-changed"]>
) => {
if (!this._config) {
return;
}
showEditSecurityDialog(this, {
config: this._config,
saveConfig: async (config) => {
await this._saveConfig(config);
},
const { entity, hidden } = ev.detail;
const hiddenEntities = (this._config.hidden_entities ?? []).filter(
(entityId) => entityId !== entity
);
if (hidden) {
hiddenEntities.push(entity);
}
this._saveConfig({
...this._config,
hidden_entities: hiddenEntities.length ? hiddenEntities : undefined,
});
};
private async _saveConfig(config: SecurityFrontendSystemData): Promise<void> {
try {
await saveFrontendSystemData(this.hass.connection, "security", config);
this._config = config;
} catch (err) {
private _favoriteChanged = (
ev: HASSDomEvent<HASSDomEvents["entity-favorite-changed"]>
) => {
if (!this._config) {
return;
}
const { entity, favorite } = ev.detail;
const favoriteEntities = (this._config.favorite_entities ?? []).filter(
(entityId) => entityId !== entity
);
if (favorite) {
favoriteEntities.push(entity);
}
this._saveConfig({
...this._config,
favorite_entities: favoriteEntities.length ? favoriteEntities : undefined,
});
this._setLovelace();
};
private _alertChanged = (
ev: HASSDomEvent<HASSDomEvents["entity-alert-changed"]>
) => {
if (!this._config) {
return;
}
const { entity, severity } = ev.detail;
const current = this._config.alert_entities ?? [];
const alertEntities = !severity
? current.filter((alertEntity) => alertEntity.entity !== entity)
: current.some((alertEntity) => alertEntity.entity === entity)
? current.map((alertEntity) =>
alertEntity.entity === entity
? { ...alertEntity, severity }
: alertEntity
)
: [...current, { entity, severity }];
this._saveConfig({
...this._config,
alert_entities: alertEntities.length ? alertEntities : undefined,
});
this._setLovelace();
};
private _saveConfig(config: SecurityFrontendSystemData): Promise<void> {
this._config = config;
return queueFrontendSystemDataSave(
this.hass.connection,
"security",
config
).catch(async (err) => {
// eslint-disable-next-line no-console
console.error("Failed to save security configuration:", err);
showToast(this, {
@@ -236,12 +338,12 @@ class PanelSecurity extends LitElement {
duration: 0,
dismissable: true,
});
throw err;
}
showToast(this, {
message: this.hass.localize("ui.common.successfully_saved"),
if (config !== this._config) {
return;
}
await this._loadConfig();
await this._setLovelace(true);
});
await this._setLovelace();
}
static get styles(): CSSResultGroup {
@@ -1,15 +0,0 @@
import type { ContextType } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { registriesContext, statesContext } from "../../data/context";
import { isSecurityPanelEntity } from "./strategies/security-view-strategy";
export type SecurityEntityContext = ContextType<typeof registriesContext> & {
states: ContextType<typeof statesContext>;
};
export const createSecurityEntityFilter =
(getContext: () => SecurityEntityContext | undefined) =>
(entity: HassEntity): boolean => {
const context = getContext();
return context ? isSecurityPanelEntity(context, entity) : false;
};
@@ -97,6 +97,10 @@ export const filterSecurityAlertEntities = (
) === severity
);
export const computeSecurityAlertColor = (
severity: SecurityAlertSeverity
): string => (severity === "alert" ? "red" : "amber");
export const computeSecurityAlertCardConfig = (
stateObj: HassEntity | undefined,
alertEntity: SecurityAlertEntityConfig
@@ -105,7 +109,7 @@ export const computeSecurityAlertCardConfig = (
return {
type: "alert",
entity: alertEntity.entity,
alert_color: severity === "alert" ? "red" : "amber",
alert_color: computeSecurityAlertColor(severity),
visibility: computeDefaultSecurityAlertVisibility(alertEntity.entity),
};
};
@@ -14,6 +14,7 @@ import {
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { floorDefaultIcon } from "../../../components/ha-floor-icon";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import { isStrategySection } from "../../../data/lovelace/config/section";
import type {
LovelaceSectionConfig,
LovelaceSectionRawConfig,
@@ -21,15 +22,28 @@ import type {
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { SecurityAlertEntityConfig } from "../../../data/frontend";
import type { HomeAssistant } from "../../../types";
import type { LogbookCardConfig } from "../../lovelace/cards/types";
import type {
EntityVisibilityCardConfig,
LogbookCardConfig,
} from "../../lovelace/cards/types";
import { computeAreaTileCardConfig } from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
import {
wrapEntityVisibility,
type ComputeEntityCard,
} from "../../lovelace/strategies/helpers/entity-visibility";
import { computeFavoriteCardConfig } from "../../lovelace/strategies/helpers/favorite-cards";
import { computeSecurityAlertCardConfig } from "./security-alerts";
import {
computeSecurityAlertCardConfig,
computeSecurityAlertColor,
resolveSecurityAlertSeverity,
} from "./security-alerts";
export interface SecurityViewStrategyConfig {
type: "security";
alert_entities?: SecurityAlertEntityConfig[];
favorite_entities?: string[];
hidden_entities?: string[];
edit_visibility?: boolean;
}
export const securityEntityFilters: EntityFilter[] = [
@@ -79,8 +93,11 @@ export const securityEntityFilters: EntityFilter[] = [
},
];
export const findSecurityEntities = (sources: EntityFilterSources): string[] =>
findFilteredEntities(sources, securityEntityFilters);
export const findSecurityEntities = (
sources: EntityFilterSources,
hiddenEntities?: string[]
): string[] =>
findFilteredEntities(sources, securityEntityFilters, hiddenEntities);
export const isSecurityPanelEntity = (
hass: EntityFilterSources,
@@ -100,7 +117,8 @@ export const isSecurityPanelEntity = (
const processAreasForSecurity = (
areaIds: string[],
hass: HomeAssistant,
entities: string[]
entities: string[],
computeCard: ComputeEntityCard
): LovelaceCardConfig[] => {
const cards: LovelaceCardConfig[] = [];
@@ -121,10 +139,8 @@ const processAreasForSecurity = (
const areaSecurityEntities = entities.filter(areaFilter);
const areaCards: LovelaceCardConfig[] = [];
const computeTileCard = computeAreaTileCardConfig(hass, false);
for (const entityId of areaSecurityEntities) {
areaCards.push(computeTileCard(entityId));
areaCards.push(computeCard(entityId));
}
if (areaCards.length > 0) {
@@ -148,7 +164,8 @@ const processAreasForSecurity = (
const processUnassignedEntities = (
hass: HomeAssistant,
entities: string[]
entities: string[],
computeCard: ComputeEntityCard
): LovelaceCardConfig[] => {
const unassignedFilter = generateEntityFilter(
hass.states,
@@ -162,10 +179,9 @@ const processUnassignedEntities = (
);
const unassignedLights = entities.filter(unassignedFilter);
const areaCards: LovelaceCardConfig[] = [];
const computeTileCard = computeAreaTileCardConfig(hass, false);
for (const entityId of unassignedLights) {
areaCards.push(computeTileCard(entityId));
areaCards.push(computeCard(entityId));
}
return areaCards;
@@ -183,30 +199,50 @@ export class SecurityViewStrategy extends ReactiveElement {
const sections: LovelaceSectionRawConfig[] = [];
const entities = findSecurityEntities(hass);
const favoriteEntities = (config.favorite_entities ?? []).filter(
(entityId) =>
hass.states[entityId] &&
isSecurityPanelEntity(hass, hass.states[entityId])
const editVisibility = Boolean(config.edit_visibility);
const entities = findSecurityEntities(
hass,
editVisibility ? undefined : config.hidden_entities
);
const pageEntities = new Set(entities);
if (favoriteEntities.length > 0) {
sections.push({
type: "grid",
column_span: 2,
cards: [
{
type: "heading",
heading: hass.localize(
"ui.panel.lovelace.strategy.security.favorites"
),
heading_style: "title",
},
...favoriteEntities.map(computeFavoriteCardConfig),
],
});
}
const favoriteEntities = editVisibility
? (config.favorite_entities ?? [])
: (config.favorite_entities ?? []).filter(
(entityId) =>
hass.states[entityId] &&
isSecurityPanelEntity(hass, hass.states[entityId])
);
const favoriteSet = new Set(config.favorite_entities);
const alertEntities = new Map(
(config.alert_entities ?? []).map((alertEntity) => [
alertEntity.entity,
alertEntity,
])
);
const computeEditExtras = (
entityId: string
): Pick<EntityVisibilityCardConfig, "favorite" | "alert"> => {
const alertEntity = alertEntities.get(entityId);
return {
favorite: favoriteSet.has(entityId),
alert: ALERT_DOMAINS.includes(computeDomain(entityId))
? alertEntity
? resolveSecurityAlertSeverity(alertEntity, hass.states[entityId])
: "none"
: undefined,
};
};
const wrapCard = (computeCard: ComputeEntityCard): ComputeEntityCard =>
editVisibility
? wrapEntityVisibility(
new Set(config.hidden_entities),
computeCard,
computeEditExtras
)
: computeCard;
const computeCard = wrapCard(computeAreaTileCardConfig(hass, false));
const floorCount =
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
@@ -232,7 +268,12 @@ export class SecurityViewStrategy extends ReactiveElement {
],
};
const areaCards = processAreasForSecurity(areaIds, hass, entities);
const areaCards = processAreasForSecurity(
areaIds,
hass,
entities,
computeCard
);
if (areaCards.length > 0) {
section.cards!.push(...areaCards);
@@ -259,7 +300,8 @@ export class SecurityViewStrategy extends ReactiveElement {
const areaCards = processAreasForSecurity(
hierarchy.areas,
hass,
entities
entities,
computeCard
);
if (areaCards.length > 0) {
@@ -269,7 +311,11 @@ export class SecurityViewStrategy extends ReactiveElement {
}
// Process unassigned entities
const unassignedCards = processUnassignedEntities(hass, entities);
const unassignedCards = processUnassignedEntities(
hass,
entities,
computeCard
);
if (unassignedCards.length > 0) {
const section: LovelaceSectionRawConfig = {
@@ -279,7 +325,7 @@ export class SecurityViewStrategy extends ReactiveElement {
{
type: "heading",
heading:
sections.length > 0
sections.length > 0 || favoriteEntities.length > 0
? hass.localize(
"ui.panel.lovelace.strategy.security.other_devices"
)
@@ -291,6 +337,41 @@ export class SecurityViewStrategy extends ReactiveElement {
sections.push(section);
}
if (favoriteEntities.length > 0) {
const firstPageEntity = editVisibility
? sections
.flatMap((section) =>
isStrategySection(section) ? [] : (section.cards ?? [])
)
.find((card) => card.type === "entity-visibility")?.entity
: undefined;
const computeFavoriteCard = editVisibility
? (entityId: string, index: number): EntityVisibilityCardConfig => ({
type: "entity-visibility",
entity: entityId,
neighbor_entity:
favoriteEntities[index + 1] ?? favoriteEntities[index - 1],
fallback_entity: firstPageEntity,
...computeEditExtras(entityId),
card: computeFavoriteCardConfig(entityId),
})
: computeFavoriteCardConfig;
sections.unshift({
type: "grid",
column_span: 2,
cards: [
{
type: "heading",
heading: hass.localize(
"ui.panel.lovelace.strategy.security.favorites"
),
heading_style: "title",
},
...favoriteEntities.map(computeFavoriteCard),
],
});
}
// Build sidebar with activity log
const hasLogbook = isComponentLoaded(hass.config, "logbook");
@@ -303,18 +384,63 @@ export class SecurityViewStrategy extends ReactiveElement {
const sidebarSections: LovelaceSectionConfig[] = [];
const alertCards = config.alert_entities
?.filter((alertEntity) =>
ALERT_DOMAINS.includes(computeDomain(alertEntity.entity))
)
.map((alertEntity) =>
computeSecurityAlertCardConfig(
hass.states[alertEntity.entity],
alertEntity
)
);
const alertEntityConfigs = (config.alert_entities ?? []).filter(
(alertEntity) => ALERT_DOMAINS.includes(computeDomain(alertEntity.entity))
);
if (alertCards?.length) {
const alertCards = editVisibility
? undefined
: alertEntityConfigs.map((alertEntity) =>
computeSecurityAlertCardConfig(
hass.states[alertEntity.entity],
alertEntity
)
);
if (editVisibility && alertEntityConfigs.length) {
sidebarSections.push({
type: "grid",
cards: [
{
type: "heading",
heading: hass.localize(
"ui.panel.lovelace.strategy.security.active_alerts"
),
heading_style: "title",
},
...alertEntityConfigs.map(
(alertEntity, index): EntityVisibilityCardConfig => {
const stateObj = hass.states[alertEntity.entity];
const severity = resolveSecurityAlertSeverity(
alertEntity,
stateObj
);
const color = computeSecurityAlertColor(severity);
return {
type: "entity-visibility",
entity: alertEntity.entity,
favorite: favoriteSet.has(alertEntity.entity),
alert: severity,
alert_select: true,
neighbor_entity: (
alertEntityConfigs[index + 1] ?? alertEntityConfigs[index - 1]
)?.entity,
fallback_entity: pageEntities.has(alertEntity.entity)
? alertEntity.entity
: undefined,
grid_options: { columns: 12 },
card: {
type: "alert",
entity: alertEntity.entity,
alert_color: color,
clear_color: color,
},
};
}
),
] satisfies LovelaceCardConfig[],
});
} else if (alertCards?.length) {
sidebarSections.push({
type: "grid",
visibility: [
+27 -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.",
@@ -7974,6 +7987,7 @@
"error_saving_device": "Error updating device: {error}",
"error_saving_entity": "Error updating entity: {error}",
"created_config": "Created configuration for {name}.",
"helper_area": "This helper isn't part of a device, so you can add it to an area.",
"external_step": {
"description": "This step requires you to visit an external website to be completed.",
"open_site": "Open website"
+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");
});
});
+43 -2
View File
@@ -12,10 +12,15 @@ import {
registerUnsavedChangesGuard,
unregisterUnsavedChangesGuard,
} from "../../src/common/navigate";
import { closeAllDialogs } from "../../src/dialogs/make-dialog-manager";
// navigate() closes open dialogs before touching history.
// navigate() closes open dialogs before touching history. A closed dialog
// drops its state from the entry, which is what navigate() waits for.
vi.mock("../../src/dialogs/make-dialog-manager", () => ({
closeAllDialogs: vi.fn(async () => true),
closeAllDialogs: vi.fn(async () => {
window.history.replaceState(null, "");
return true;
}),
}));
// Fabricates a raw entry like a document load, which navigate() never produces.
@@ -56,6 +61,7 @@ const trackDeferredGuard = (isDirty: () => boolean = () => true) => {
afterEach(() => {
registeredGuards.splice(0).forEach(unregisterUnsavedChangesGuard);
vi.mocked(closeAllDialogs).mockClear();
});
describe("navigate", () => {
@@ -97,6 +103,41 @@ describe("navigate", () => {
expect(window.history.state).toBeNull();
expect(canGoBack()).toBe(false);
});
it("closes an open dialog before replacing the entry", async () => {
setEntry("/home", { dialog: "ha-more-info-dialog" });
await navigate("/home/overview", { replace: true });
expect(closeAllDialogs).toHaveBeenCalledOnce();
expect(window.location.pathname).toEqual("/home/overview");
expect(window.history.state).toBeNull();
});
it("keeps an open dialog and the entry state when asked to", async () => {
setEntry("/home", { dialog: "ha-more-info-dialog", from: "/" });
await navigate("/home/overview", { replace: true, keepDialogs: true });
expect(closeAllDialogs).not.toHaveBeenCalled();
expect(window.location.pathname).toEqual("/home/overview");
expect(window.history.state).toEqual({
dialog: "ha-more-info-dialog",
from: "/",
});
});
it("does not drop a navigation right after one that keeps dialogs", async () => {
setEntry("/home");
const results = await Promise.all([
navigate("/home/overview", { replace: true, keepDialogs: true }),
navigate("/config"),
]);
expect(results).toEqual([true, true]);
expect(window.location.pathname).toEqual("/config");
});
});
describe("unsaved changes guard", () => {
+1 -1
View File
@@ -45,7 +45,7 @@ describe("partial-panel-resolver", () => {
// The Marketplace loads its translations for the path it is at
expect(navigate).toHaveBeenCalledWith(
"/marketplace/repository/1?category=theme#top",
{ replace: true }
{ replace: true, keepDialogs: true }
);
});
@@ -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",
},
},
]);
});
});