Compare commits

..
Author SHA1 Message Date
Maarten Lakerveld 8d895ac1fc Let users hide entities from the lights page
Move the lights panel onto HaHomeSummaryPanelBase, which brings the light hidden_entities system data, config loading and the inline visibility edit mode the climate, maintenance and security pages have. It also drops the panel's leftover min-height: 100vh, which overflowed the top app bar's scroll area.

Hidden lights leave the page's tiles, the desktop toggle group and the mobile area on/off buttons. Those buttons now target the visible lights instead of the whole area, so they match the toggle group and no longer switch hidden lights. In edit mode the page shows only the area headings and each light with its visibility toggle, since the toggle group and its spacer cards only make sense for the visible set.

The Home overview's light badge and on count skip hidden lights too, and so does the Lights section of area pages, whose on/off buttons get the same visible-lights target.
2026-11-05 16:42:36 +01:00
Maarten Lakerveld f075d544b8 Share one base class across the home summary panels
The maintenance, climate and security panels carried the same ~300 line shell: config loading with revision guards, queued saves with failure recovery, the edit visibility toggle and view generation. Move it into HaHomeSummaryPanelBase, keyed on the system data key, which doubles as the strategy type, URL path and translation namespace. Security only adds its strategy options and the favorite and alert handlers, which the base binds as optional hooks.

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

The home panel subscribed to and loaded the three configs in three copies of the same block. Keep them in one record keyed the same way and loop over the keys.
2026-11-05 16:42:36 +01:00
15 changed files with 741 additions and 1373 deletions

No files matched your search

+8
View File
@@ -60,6 +60,13 @@ export interface ClimateFrontendSystemData {
hidden_entities?: string[];
}
export interface LightFrontendSystemData {
hidden_entities?: string[];
}
export type HomeSummaryPanelSystemDataKey =
"security" | "maintenance" | "climate" | "light";
export interface EnergyFrontendSystemData {
// Stable "<view>.<card-type>" keys of energy dashboard cards the user has
// hidden. An absent key or array means nothing is hidden (all cards visible),
@@ -80,6 +87,7 @@ declare global {
security: SecurityFrontendSystemData;
maintenance: MaintenanceFrontendSystemData;
climate: ClimateFrontendSystemData;
light: LightFrontendSystemData;
}
}
+4 -308
View File
@@ -1,313 +1,9 @@
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";
const CLIMATE_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = {
strategy: {
type: "climate",
},
};
import { customElement } from "lit/decorators";
import { HaHomeSummaryPanelBase } from "../home/ha-home-summary-panel-base";
@customElement("ha-panel-climate")
class PanelClimate extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@state() private _viewIndex = 0;
@state() private _lovelace?: Lovelace;
@state() private _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
if (!this.hasUpdated) {
this._setup();
return;
}
if (!changedProps.has("hass")) {
return;
}
const oldHass = changedProps.get("hass") as this["hass"];
if (
oldHass &&
this.hass.config.state === "RUNNING" &&
oldHass.config.state !== "RUNNING"
) {
this._setup();
return;
}
if (oldHass && oldHass.localize !== this.hass.localize) {
this._setLovelace();
return;
}
if (oldHass && this.hass) {
// Refresh the generated view when registries or panels change.
if (
oldHass.entities !== this.hass.entities ||
oldHass.devices !== this.hass.devices ||
oldHass.areas !== this.hass.areas ||
oldHass.floors !== this.hass.floors ||
oldHass.panels !== this.hass.panels
) {
if (this.hass.config.state === "RUNNING") {
this._debounceRegistriesChanged();
}
}
}
}
private async _setup() {
this._loadConfigPromise = this._loadConfig();
await Promise.all([
this.hass.loadFragmentTranslation("lovelace"),
this._loadConfigPromise,
]);
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
);
private _registriesChanged = async () => {
this._setLovelace();
};
protected render() {
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${this._searchParams.has("historyBack")}
>
<div slot="title">${this.hass.localize("panel.climate")}</div>
${
!this.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`
<hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}>
</hui-view-background>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
@entity-visibility-changed=${this._visibilityChanged}
></hui-view
></hui-view-container>
`
: nothing
}
</ha-top-app-bar-fixed>
`;
}
private async _setLovelace(force = false) {
const revision = ++this._setLovelaceRevision;
if (this._loadConfigPromise) {
await this._loadConfigPromise;
}
const viewConfig = await generateLovelaceViewStrategy(
{
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 (!force && deepEqual(config, this._lovelace?.config)) {
return;
}
this._childPanelReady ??= new ChildPanelReady(this);
this._lovelace = {
config: config,
rawConfig: rawConfig,
editMode: false,
urlPath: "climate",
mode: "generated",
locale: this.hass.locale,
enableFullEditMode: () => undefined,
saveConfig: async () => undefined,
deleteConfig: async () => undefined,
setEditMode: () => undefined,
showToast: () => undefined,
};
}
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,
css`
:host {
user-select: none;
}
hui-view-container {
position: relative;
display: flex;
min-height: 100vh;
box-sizing: border-box;
}
hui-view {
flex: 1 1 100%;
max-width: 100%;
}
`,
];
}
class PanelClimate extends HaHomeSummaryPanelBase<"climate"> {
protected readonly _systemDataKey = "climate";
}
declare global {
@@ -0,0 +1,324 @@
import { mdiPencil } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { property, query, state } from "lit/decorators";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-button";
import type { HaButton } from "../../components/ha-button";
import "../../components/ha-icon-button";
import "../../components/ha-top-app-bar-fixed";
import {
fetchFrontendSystemDataAfterSaves,
queueFrontendSystemDataSave,
type HomeSummaryPanelSystemDataKey,
} from "../../data/frontend";
import { ChildPanelReady } from "../../layouts/panel-ready";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { showToast } from "../../util/toast";
import { clearEntityVisibilityFocus } from "../lovelace/cards/hui-entity-visibility-card";
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
import type { Lovelace } from "../lovelace/types";
import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-background";
import "../lovelace/views/hui-view-container";
export abstract class HaHomeSummaryPanelBase<
K extends HomeSummaryPanelSystemDataKey,
> extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@state() protected _config?: FrontendSystemData[K];
@state() private _lovelace?: Lovelace;
@state() private _editVisibility = false;
@state() private _searchParams = new URLSearchParams(window.location.search);
@query("ha-button") private _exitEditButton?: HaButton;
protected abstract readonly _systemDataKey: K;
private _childPanelReady?: ChildPanelReady;
private _loadConfigPromise?: Promise<void>;
private _loadConfigRevision = 0;
private _setLovelaceRevision = 0;
protected _strategyOptions(): Record<string, unknown> {
return {};
}
protected _favoriteChanged?(
ev: HASSDomEvent<HASSDomEvents["entity-favorite-changed"]>
): void;
protected _alertChanged?(
ev: HASSDomEvent<HASSDomEvents["entity-alert-changed"]>
): void;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._setup();
return;
}
if (!changedProps.has("hass")) {
return;
}
const oldHass = changedProps.get("hass") as this["hass"];
if (
oldHass &&
this.hass.config.state === "RUNNING" &&
oldHass.config.state !== "RUNNING"
) {
this._setup();
return;
}
if (oldHass && oldHass.localize !== this.hass.localize) {
this._setLovelace();
return;
}
if (
oldHass &&
(oldHass.entities !== this.hass.entities ||
oldHass.devices !== this.hass.devices ||
oldHass.areas !== this.hass.areas ||
oldHass.floors !== this.hass.floors ||
oldHass.panels !== this.hass.panels) &&
this.hass.config.state === "RUNNING"
) {
this._debounceRegistriesChanged();
}
}
private async _setup() {
this._loadConfigPromise = this._loadConfig();
await Promise.all([
this.hass.loadFragmentTranslation("lovelace"),
this._loadConfigPromise,
]);
await this._setLovelace();
}
private async _loadConfig() {
const revision = ++this._loadConfigRevision;
this._config = undefined;
try {
const data = await fetchFrontendSystemDataAfterSaves(
this.hass.connection,
this._systemDataKey
);
if (revision !== this._loadConfigRevision) {
return;
}
this._config = data || ({} as FrontendSystemData[K]);
} catch (err) {
if (revision !== this._loadConfigRevision) {
return;
}
this._editVisibility = false;
const key: HomeSummaryPanelSystemDataKey = this._systemDataKey;
// eslint-disable-next-line no-console
console.error(`Failed to load ${key} configuration:`, err);
showToast(this, {
message: this.hass.localize(`ui.panel.${key}.editor.load_failed`),
duration: 0,
dismissable: true,
});
}
}
private _debounceRegistriesChanged = debounce(() => this._setLovelace(), 200);
protected render() {
const key: HomeSummaryPanelSystemDataKey = this._systemDataKey;
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${this._searchParams.has("historyBack")}
>
<div slot="title">${this.hass.localize(`panel.${key}`)}</div>
${
!this.hass.user?.is_admin
? nothing
: this._editVisibility
? html`<ha-button
slot="actionItems"
appearance="filled"
size="s"
@click=${this._stopEditVisibility}
>
${this.hass.localize("ui.panel.lovelace.menu.exit_edit_mode")}
</ha-button>`
: this._config
? html`<ha-icon-button
slot="actionItems"
.path=${mdiPencil}
.label=${this.hass.localize(`ui.panel.${key}.editor.title`)}
@click=${this._startEditVisibility}
></ha-icon-button>`
: nothing
}
${
this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}>
</hui-view-background>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${0}
@entity-visibility-changed=${this._visibilityChanged}
@entity-favorite-changed=${this._favoriteChanged}
@entity-alert-changed=${this._alertChanged}
@entity-visibility-focus-lost=${this._focusLost}
></hui-view>
</hui-view-container>
`
: nothing
}
</ha-top-app-bar-fixed>
`;
}
protected async _setLovelace(force = false) {
const revision = ++this._setLovelaceRevision;
if (this._loadConfigPromise) {
await this._loadConfigPromise;
}
const strategy = { type: this._systemDataKey };
const viewConfig = await generateLovelaceViewStrategy(
{
strategy: {
...strategy,
...this._strategyOptions(),
hidden_entities: this._config?.hidden_entities,
edit_visibility: this._editVisibility || undefined,
},
},
this.hass
);
if (revision !== this._setLovelaceRevision) {
return;
}
const config = { views: [viewConfig] };
const rawConfig = { views: [{ strategy }] };
if (!force && deepEqual(config, this._lovelace?.config)) {
return;
}
this._childPanelReady ??= new ChildPanelReady(this);
this._lovelace = {
config: config,
rawConfig: rawConfig,
editMode: false,
urlPath: this._systemDataKey,
mode: "generated",
locale: this.hass.locale,
enableFullEditMode: () => undefined,
saveConfig: async () => undefined,
deleteConfig: async () => undefined,
setEditMode: () => undefined,
showToast: () => undefined,
};
}
private _startEditVisibility = () => {
clearEntityVisibilityFocus();
this._editVisibility = true;
this._setLovelace();
};
private _stopEditVisibility = () => {
this._editVisibility = false;
if (!this._config) {
this._loadConfigPromise = this._loadConfig();
}
this._setLovelace();
};
private _focusLost = () => {
this._exitEditButton?.focus();
};
private _visibilityChanged = (
ev: HASSDomEvent<HASSDomEvents["entity-visibility-changed"]>
) => {
if (!this._config) {
return;
}
const { entity, hidden } = ev.detail;
const hiddenEntities = (this._config.hidden_entities ?? []).filter(
(entityId) => entityId !== entity
);
if (hidden) {
hiddenEntities.push(entity);
}
this._saveConfig({
...this._config,
hidden_entities: hiddenEntities.length ? hiddenEntities : undefined,
});
};
protected _saveConfig(config: FrontendSystemData[K]): Promise<void> {
this._config = config;
const key: HomeSummaryPanelSystemDataKey = this._systemDataKey;
return queueFrontendSystemDataSave(
this.hass.connection,
this._systemDataKey,
config
).catch(async (err) => {
// eslint-disable-next-line no-console
console.error(`Failed to save ${key} configuration:`, err);
showToast(this, {
message: this.hass.localize(`ui.panel.${key}.editor.save_failed`),
duration: 0,
dismissable: true,
});
if (config !== this._config) {
return;
}
await this._loadConfig();
await this._setLovelace(true);
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
user-select: none;
}
hui-view-container {
position: relative;
display: flex;
min-height: 100%;
box-sizing: border-box;
}
hui-view {
flex: 1 1 100%;
max-width: 100%;
}
`,
];
}
}
+65 -114
View File
@@ -16,10 +16,8 @@ import {
fetchFrontendSystemData,
saveFrontendSystemData,
subscribeFrontendSystemData,
type ClimateFrontendSystemData,
type HomeFrontendSystemData,
type MaintenanceFrontendSystemData,
type SecurityFrontendSystemData,
type HomeSummaryPanelSystemDataKey,
} from "../../data/frontend";
import type { LovelaceDashboardStrategyConfig } from "../../data/lovelace/config/types";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
@@ -40,6 +38,17 @@ import { showEditHomeDialog } from "./dialogs/show-dialog-edit-home";
import { showNewOverviewDialog } from "./dialogs/show-dialog-new-overview";
import { hasLegacyOverviewPanel } from "../../data/panel";
const SUMMARY_PANEL_KEYS: HomeSummaryPanelSystemDataKey[] = [
"security",
"maintenance",
"climate",
"light",
];
type SummaryPanelConfigs = {
[K in HomeSummaryPanelSystemDataKey]: FrontendSystemData[K];
};
@customElement("ha-panel-home")
class PanelHome extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -54,11 +63,12 @@ class PanelHome extends SubscribeMixin(LitElement) {
@state() private _config?: HomeFrontendSystemData;
@state() private _securityConfig: SecurityFrontendSystemData = {};
@state() private _maintenanceConfig: MaintenanceFrontendSystemData = {};
@state() private _climateConfig: ClimateFrontendSystemData = {};
@state() private _summaryConfigs: SummaryPanelConfigs = {
security: {},
maintenance: {},
climate: {},
light: {},
};
@state() private _extraActionItems?: ExtraActionItem[];
@@ -66,60 +76,30 @@ class PanelHome extends SubscribeMixin(LitElement) {
private _loadConfigPromise?: Promise<void>;
private _securityConfigRevision = 0;
private _maintenanceConfigRevision = 0;
private _climateConfigRevision = 0;
private _summaryConfigRevisions: Record<
HomeSummaryPanelSystemDataKey,
number
> = {
security: 0,
maintenance: 0,
climate: 0,
light: 0,
};
public hassSubscribe() {
return [
subscribeFrontendSystemData(
this.hass.connection,
"security",
({ value }) => {
this._securityConfigRevision++;
const config = value || {};
if (deepEqual(config, this._securityConfig)) {
return;
}
this._securityConfig = config;
if (this.hasUpdated) {
this._setLovelace();
}
return SUMMARY_PANEL_KEYS.map((key) =>
subscribeFrontendSystemData(this.hass.connection, key, ({ value }) => {
this._summaryConfigRevisions[key]++;
const config = value || {};
if (deepEqual(config, this._summaryConfigs[key])) {
return;
}
),
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();
}
this._summaryConfigs = { ...this._summaryConfigs, [key]: 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();
}
}
),
];
})
);
}
private get _showBanner(): boolean {
@@ -218,22 +198,15 @@ class PanelHome extends SubscribeMixin(LitElement) {
}
private async _loadConfig() {
const securityConfigRevision = this._securityConfigRevision;
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"),
]);
const summaryRevisions = { ...this._summaryConfigRevisions };
const [translationsResult, homeResult, ...summaryResults] =
await Promise.allSettled([
this.hass.loadFragmentTranslation("lovelace"),
fetchFrontendSystemData(this.hass.connection, "home"),
...SUMMARY_PANEL_KEYS.map((key) =>
fetchFrontendSystemData(this.hass.connection, key)
),
]);
if (translationsResult.status === "rejected") {
// eslint-disable-next-line no-console
@@ -251,44 +224,20 @@ class PanelHome extends SubscribeMixin(LitElement) {
this._config = homeResult.value || {};
}
if (securityResult.status === "rejected") {
// eslint-disable-next-line no-console
console.error(
"Failed to load security configuration:",
securityResult.reason
);
if (securityConfigRevision === this._securityConfigRevision) {
this._securityConfig = {};
const summaryConfigs = { ...this._summaryConfigs };
SUMMARY_PANEL_KEYS.forEach((key, index) => {
if (summaryRevisions[key] !== this._summaryConfigRevisions[key]) {
return;
}
} else if (securityConfigRevision === this._securityConfigRevision) {
this._securityConfig = securityResult.value || {};
}
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 = {};
const result = summaryResults[index];
if (result.status === "rejected") {
// eslint-disable-next-line no-console
console.error(`Failed to load ${key} configuration:`, result.reason);
}
} 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 || {};
}
summaryConfigs[key] =
result.status === "fulfilled" && result.value ? result.value : {};
});
this._summaryConfigs = summaryConfigs;
}
private _debounceRegenerateStrategy = debounce(
@@ -474,10 +423,12 @@ class PanelHome extends SubscribeMixin(LitElement) {
return {
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,
alert_entities: this._summaryConfigs.security.alert_entities,
security_hidden_entities: this._summaryConfigs.security.hidden_entities,
maintenance_hidden_entities:
this._summaryConfigs.maintenance.hidden_entities,
climate_hidden_entities: this._summaryConfigs.climate.hidden_entities,
light_hidden_entities: this._summaryConfigs.light.hidden_entities,
favorite_entities: this._config?.favorite_entities,
home_panel: true,
hide_welcome_message: this._config?.hide_welcome_message,
+4 -166
View File
@@ -1,171 +1,9 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import { ChildPanelReady } from "../../layouts/panel-ready";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
import type { Lovelace } from "../lovelace/types";
import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-container";
import "../lovelace/views/hui-view-background";
import "../../components/ha-top-app-bar-fixed";
const LIGHT_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = {
strategy: {
type: "light",
},
};
import { customElement } from "lit/decorators";
import { HaHomeSummaryPanelBase } from "../home/ha-home-summary-panel-base";
@customElement("ha-panel-light")
class PanelLight extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@state() private _viewIndex = 0;
@state() private _lovelace?: Lovelace;
@state() private _searchParms = new URLSearchParams(window.location.search);
private _childPanelReady?: ChildPanelReady;
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Initial setup
if (!this.hasUpdated) {
this._setup();
return;
}
if (!changedProps.has("hass")) {
return;
}
const oldHass = changedProps.get("hass") as this["hass"];
if (oldHass && oldHass.localize !== this.hass.localize) {
this._setLovelace();
return;
}
if (oldHass && this.hass) {
// If the entity registry changed, ask the user if they want to refresh the config
if (
oldHass.entities !== this.hass.entities ||
oldHass.devices !== this.hass.devices ||
oldHass.areas !== this.hass.areas ||
oldHass.floors !== this.hass.floors ||
oldHass.panels !== this.hass.panels
) {
if (this.hass.config.state === "RUNNING") {
this._debounceRegistriesChanged();
return;
}
}
// If ha started, refresh the config
if (
this.hass.config.state === "RUNNING" &&
oldHass.config.state !== "RUNNING"
) {
this._setLovelace();
}
}
}
private async _setup() {
await this.hass.loadFragmentTranslation("lovelace");
this._setLovelace();
}
private _debounceRegistriesChanged = debounce(
() => this._registriesChanged(),
200
);
private _registriesChanged = async () => {
this._setLovelace();
};
protected render() {
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${this._searchParms.has("historyBack")}
>
<div slot="title">${this.hass.localize("panel.light")}</div>
${
this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}>
</hui-view-background>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view
></hui-view-container>
`
: nothing
}
</ha-top-app-bar-fixed>
`;
}
private async _setLovelace() {
const viewConfig = await generateLovelaceViewStrategy(
LIGHT_LOVELACE_VIEW_CONFIG,
this.hass
);
const config = { views: [viewConfig] };
const rawConfig = { views: [LIGHT_LOVELACE_VIEW_CONFIG] };
if (deepEqual(config, this._lovelace?.config)) {
return;
}
this._childPanelReady ??= new ChildPanelReady(this);
this._lovelace = {
config: config,
rawConfig: rawConfig,
editMode: false,
urlPath: "light",
mode: "generated",
locale: this.hass.locale,
enableFullEditMode: () => undefined,
saveConfig: async () => undefined,
deleteConfig: async () => undefined,
setEditMode: () => undefined,
showToast: () => undefined,
};
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
user-select: none;
}
hui-view-container {
position: relative;
display: flex;
min-height: 100vh;
box-sizing: border-box;
}
hui-view {
flex: 1 1 100%;
max-width: 100%;
}
`,
];
}
class PanelLight extends HaHomeSummaryPanelBase<"light"> {
protected readonly _systemDataKey = "light";
}
declare global {
+146 -133
View File
@@ -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";
@@ -18,19 +19,37 @@ import {
} from "../../lovelace/strategies/helpers/view-columns-conditions";
import type { ToggleGroupCardConfig } from "../../lovelace/cards/types";
import type { ButtonHeadingBadgeConfig } from "../../lovelace/heading-badges/types";
import {
wrapEntityVisibility,
type ComputeEntityCard,
} from "../../lovelace/strategies/helpers/entity-visibility";
export interface LightViewStrategyConfig {
type: "light";
hidden_entities?: string[];
edit_visibility?: boolean;
}
export const lightEntityFilters: EntityFilter[] = [
{ domain: "light", entity_category: "none" },
];
export const findLightEntities = (
sources: EntityFilterSources,
hiddenEntities?: string[]
): string[] =>
findFilteredEntities(sources, lightEntityFilters, hiddenEntities);
interface LightCardOptions {
entities: string[];
computeCard: ComputeEntityCard;
editVisibility: boolean;
}
const processAreasForLight = (
areaIds: string[],
hass: HomeAssistant,
entities: string[]
{ entities, computeCard, editVisibility }: LightCardOptions
): LovelaceCardConfig[] => {
const cards: LovelaceCardConfig[] = [];
@@ -49,114 +68,114 @@ const processAreasForLight = (
}
);
const areaLights = entities.filter(areaFilter);
const areaCards: LovelaceCardConfig[] = [];
const areaCards = areaLights.map(computeCard);
const computeTileCard = computeAreaTileCardConfig(hass, false);
for (const entityId of areaLights) {
areaCards.push(computeTileCard(entityId));
if (areaCards.length === 0) {
continue;
}
if (areaCards.length > 0) {
// Visibility condition: any light is on
const anyOnCondition = {
condition: "or" as const,
conditions: areaLights.map((entityId) => ({
condition: "state" as const,
entity: entityId,
state: "on",
})),
};
const heading: LovelaceCardConfig = {
heading_style: "subtitle",
type: "heading",
heading: area.name,
tap_action: hass.panels.home
? {
action: "navigate",
navigation_path: `/home/areas-${area.area_id}`,
}
: undefined,
};
cards.push({
heading_style: "subtitle",
type: "heading",
heading: area.name,
tap_action: hass.panels.home
? {
action: "navigate",
navigation_path: `/home/areas-${area.area_id}`,
}
: undefined,
badges: [
// Toggle buttons for mobile
{
type: "button",
icon: "mdi:power",
text: hass.localize("ui.panel.lovelace.strategy.light.off"),
label: hass.localize(
"ui.panel.lovelace.strategy.light.turn_on_area",
{ area: area.name }
),
tap_action: {
action: "perform-action",
perform_action: "light.turn_on",
target: {
area_id: area.area_id,
},
},
visibility: [
SMALL_SCREEN_CONDITION,
{
condition: "not",
conditions: [anyOnCondition],
},
],
} satisfies ButtonHeadingBadgeConfig,
{
type: "button",
icon: "mdi:power",
color: "orange",
text: hass.localize("ui.panel.lovelace.strategy.light.on"),
label: hass.localize(
"ui.panel.lovelace.strategy.light.turn_off_area",
{ area: area.name }
),
tap_action: {
action: "perform-action",
perform_action: "light.turn_off",
target: {
area_id: area.area_id,
},
},
visibility: [SMALL_SCREEN_CONDITION, anyOnCondition],
} satisfies ButtonHeadingBadgeConfig,
] satisfies LovelaceCardConfig[],
});
// Toggle group card for desktop
cards.push({
type: "toggle-group",
color: "amber",
entities: areaLights,
visibility: [LARGE_SCREEN_CONDITION],
grid_options: {
columns: 6,
rows: 1,
},
} as ToggleGroupCardConfig);
areaCards.forEach((card) => {
// Insert a blank card before every 3rd card to align the individual
// cards with the toggle group card on desktop
if (
areaCards.indexOf(card) % 3 === 0 &&
areaCards.indexOf(card) !== 0
) {
cards.push({
type: "vertical-stack",
cards: [],
visibility: [LARGE_SCREEN_CONDITION],
grid_options: {
columns: 6,
rows: 1,
},
});
}
cards.push(card);
});
if (editVisibility) {
cards.push(heading, ...areaCards);
continue;
}
// Visibility condition: any light is on
const anyOnCondition = {
condition: "or" as const,
conditions: areaLights.map((entityId) => ({
condition: "state" as const,
entity: entityId,
state: "on",
})),
};
const target = { entity_id: areaLights };
cards.push({
...heading,
badges: [
// Toggle buttons for mobile
{
type: "button",
icon: "mdi:power",
text: hass.localize("ui.panel.lovelace.strategy.light.off"),
label: hass.localize(
"ui.panel.lovelace.strategy.light.turn_on_area",
{ area: area.name }
),
tap_action: {
action: "perform-action",
perform_action: "light.turn_on",
target,
},
visibility: [
SMALL_SCREEN_CONDITION,
{
condition: "not",
conditions: [anyOnCondition],
},
],
} satisfies ButtonHeadingBadgeConfig,
{
type: "button",
icon: "mdi:power",
color: "orange",
text: hass.localize("ui.panel.lovelace.strategy.light.on"),
label: hass.localize(
"ui.panel.lovelace.strategy.light.turn_off_area",
{ area: area.name }
),
tap_action: {
action: "perform-action",
perform_action: "light.turn_off",
target,
},
visibility: [SMALL_SCREEN_CONDITION, anyOnCondition],
} satisfies ButtonHeadingBadgeConfig,
] satisfies LovelaceCardConfig[],
});
// Toggle group card for desktop
cards.push({
type: "toggle-group",
color: "amber",
entities: areaLights,
visibility: [LARGE_SCREEN_CONDITION],
grid_options: {
columns: 6,
rows: 1,
},
} as ToggleGroupCardConfig);
areaCards.forEach((card) => {
// Insert a blank card before every 3rd card to align the individual
// cards with the toggle group card on desktop
if (areaCards.indexOf(card) % 3 === 0 && areaCards.indexOf(card) !== 0) {
cards.push({
type: "vertical-stack",
cards: [],
visibility: [LARGE_SCREEN_CONDITION],
grid_options: {
columns: 6,
rows: 1,
},
});
}
cards.push(card);
});
}
return cards;
@@ -164,7 +183,7 @@ const processAreasForLight = (
const processUnassignedLights = (
hass: HomeAssistant,
entities: string[]
{ entities, computeCard }: LightCardOptions
): LovelaceCardConfig[] => {
const unassignedFilter = generateEntityFilter(
hass.states,
@@ -176,21 +195,13 @@ const processUnassignedLights = (
area: null,
}
);
const unassignedLights = entities.filter(unassignedFilter);
const areaCards: LovelaceCardConfig[] = [];
const computeTileCard = computeAreaTileCardConfig(hass, false);
for (const entityId of unassignedLights) {
areaCards.push(computeTileCard(entityId));
}
return areaCards;
return entities.filter(unassignedFilter).map(computeCard);
};
@customElement("light-view-strategy")
export class LightViewStrategy extends ReactiveElement {
static async generate(
_config: LightViewStrategyConfig,
config: LightViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const areas = Object.values(hass.areas);
@@ -199,20 +210,18 @@ export class LightViewStrategy extends ReactiveElement {
const sections: LovelaceSectionRawConfig[] = [];
const allEntities = Object.keys(hass.states);
const lightFilters = lightEntityFilters.map((filter) =>
generateEntityFilter(
hass.states,
hass.entities,
hass.devices,
hass.areas,
hass.floors,
filter
)
);
const entities = findEntities(allEntities, lightFilters);
const editVisibility = Boolean(config.edit_visibility);
const computeTileCard = computeAreaTileCardConfig(hass, false);
const cardOptions: LightCardOptions = {
entities: findLightEntities(
hass,
editVisibility ? undefined : config.hidden_entities
),
computeCard: editVisibility
? wrapEntityVisibility(new Set(config.hidden_entities), computeTileCard)
: computeTileCard,
editVisibility,
};
const floorCount =
hierarchy.floors.length + (hierarchy.areas.length ? 1 : 0);
@@ -238,7 +247,7 @@ export class LightViewStrategy extends ReactiveElement {
],
};
const areaCards = processAreasForLight(areaIds, hass, entities);
const areaCards = processAreasForLight(areaIds, hass, cardOptions);
if (areaCards.length > 0) {
section.cards!.push(...areaCards);
@@ -262,7 +271,11 @@ export class LightViewStrategy extends ReactiveElement {
],
};
const areaCards = processAreasForLight(hierarchy.areas, hass, entities);
const areaCards = processAreasForLight(
hierarchy.areas,
hass,
cardOptions
);
if (areaCards.length > 0) {
section.cards!.push(...areaCards);
@@ -271,7 +284,7 @@ export class LightViewStrategy extends ReactiveElement {
}
// Process unassigned lights
const unassignedCards = processUnassignedLights(hass, entities);
const unassignedCards = processUnassignedLights(hass, cardOptions);
if (unassignedCards.length > 0) {
const section: LovelaceSectionRawConfig = {
type: "grid",
@@ -57,6 +57,7 @@ import {
isSecurityAlertActive,
resolveSecurityAlertSeverity,
} from "../../security/strategies/security-alerts";
import { findLightEntities } from "../../light/strategies/light-view-strategy";
import { findSecurityEntities } from "../../security/strategies/security-view-strategy";
import type { LovelaceCard, LovelaceGridOptions } from "../types";
import { tileCardStyle } from "./tile/tile-card-style";
@@ -172,12 +173,11 @@ export class HuiHomeSummaryCard extends LitElement implements LovelaceCard {
switch (this._config.summary) {
case "light": {
// Number of lights on
const lightsFilters = HOME_SUMMARIES_FILTERS.light.map((filter) =>
generateEntityFilter(states, entities, devices, areas, floors, filter)
const lightEntities = findLightEntities(
{ states, entities, devices, areas, floors },
this._config.hidden_entities
);
const lightEntities = findEntities(allEntities, lightsFilters);
const onLights = lightEntities.filter((entityId) => {
const s = states[entityId]?.state;
return s === "on";
@@ -32,6 +32,7 @@ export interface HomeAreaViewStrategyConfig {
area?: string;
security_hidden_entities?: string[];
climate_hidden_entities?: string[];
light_hidden_entities?: string[];
home_panel?: boolean;
}
@@ -97,6 +98,7 @@ export class HomeAreaViewStrategy extends ReactiveElement {
const hiddenEntities: Partial<Record<HomeSummary, string[]>> = {
security: config.security_hidden_entities,
climate: config.climate_hidden_entities,
light: config.light_hidden_entities,
};
const entitiesBySummary = HOME_SUMMARIES.reduce(
@@ -163,9 +165,7 @@ export class HomeAreaViewStrategy extends ReactiveElement {
tap_action: {
action: "perform-action",
perform_action: "light.turn_on",
target: {
area_id: config.area,
},
target: { entity_id: light },
},
visibility: [
{
@@ -186,9 +186,7 @@ export class HomeAreaViewStrategy extends ReactiveElement {
tap_action: {
action: "perform-action",
perform_action: "light.turn_off",
target: {
area_id: config.area,
},
target: { entity_id: light },
},
visibility: [anyOnCondition],
} satisfies ButtonHeadingBadgeConfig,
@@ -24,6 +24,7 @@ export interface HomeDashboardStrategyConfig {
security_hidden_entities?: string[];
maintenance_hidden_entities?: string[];
climate_hidden_entities?: string[];
light_hidden_entities?: string[];
favorite_entities?: string[];
home_panel?: boolean;
hide_welcome_message?: boolean;
@@ -77,6 +78,7 @@ export class HomeDashboardStrategy extends ReactiveElement {
area: area.area_id,
security_hidden_entities: config.security_hidden_entities,
climate_hidden_entities: config.climate_hidden_entities,
light_hidden_entities: config.light_hidden_entities,
home_panel: config.home_panel,
} satisfies HomeAreaViewStrategyConfig,
};
@@ -114,6 +116,7 @@ export class HomeDashboardStrategy extends ReactiveElement {
security_hidden_entities: config.security_hidden_entities,
maintenance_hidden_entities: config.maintenance_hidden_entities,
climate_hidden_entities: config.climate_hidden_entities,
light_hidden_entities: config.light_hidden_entities,
favorite_entities: config.favorite_entities,
home_panel: config.home_panel,
hide_welcome_message: config.hide_welcome_message,
@@ -37,6 +37,7 @@ import type {
UpdatesCardConfig,
} from "../../cards/types";
import { computeFavoriteCardConfig } from "../helpers/favorite-cards";
import { findLightEntities } from "../../../light/strategies/light-view-strategy";
import { findMaintenanceEntities } from "../../../maintenance/strategies/maintenance-view-strategy";
import {
computeDefaultSecurityAlertVisibility,
@@ -60,6 +61,7 @@ export interface HomeOverviewViewStrategyConfig {
security_hidden_entities?: string[];
maintenance_hidden_entities?: string[];
climate_hidden_entities?: string[];
light_hidden_entities?: string[];
favorite_entities?: string[];
home_panel?: boolean;
hide_welcome_message?: boolean;
@@ -348,19 +350,9 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
)
);
const lightsFilters = HOME_SUMMARIES_FILTERS.light.map((filter) =>
generateEntityFilter(
hass.states,
hass.entities,
hass.devices,
hass.areas,
hass.floors,
filter
)
);
const hasLights =
hass.panels.light && findEntities(allEntities, lightsFilters).length > 0;
hass.panels.light &&
findLightEntities(hass, config.light_hidden_entities).length > 0;
const hasMediaPlayers =
findEntities(allEntities, mediaPlayerFilter).length > 0;
const hasClimate =
@@ -421,6 +413,9 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
? ({
type: "home-summary",
summary: "light",
hidden_entities: config.light_hidden_entities?.length
? config.light_hidden_entities
: undefined,
tap_action: {
action: "navigate",
navigation_path: "/light?historyBack=1",
+4 -308
View File
@@ -1,313 +1,9 @@
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";
import "../lovelace/views/hui-view-background";
import "../lovelace/views/hui-view-container";
const MAINTENANCE_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = {
strategy: {
type: "maintenance",
},
};
import { customElement } from "lit/decorators";
import { HaHomeSummaryPanelBase } from "../home/ha-home-summary-panel-base";
@customElement("ha-panel-maintenance")
class PanelMaintenance extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@state() private _viewIndex = 0;
@state() private _lovelace?: Lovelace;
@state() private _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
if (!this.hasUpdated) {
this._setup();
return;
}
if (!changedProps.has("hass")) {
return;
}
const oldHass = changedProps.get("hass") as this["hass"];
if (
oldHass &&
this.hass.config.state === "RUNNING" &&
oldHass.config.state !== "RUNNING"
) {
this._setup();
return;
}
if (oldHass && oldHass.localize !== this.hass.localize) {
this._setLovelace();
return;
}
if (oldHass && this.hass) {
// Refresh the generated view when registries or panels change.
if (
oldHass.entities !== this.hass.entities ||
oldHass.devices !== this.hass.devices ||
oldHass.areas !== this.hass.areas ||
oldHass.floors !== this.hass.floors ||
oldHass.panels !== this.hass.panels
) {
if (this.hass.config.state === "RUNNING") {
this._debounceRegistriesChanged();
}
}
}
}
private async _setup() {
this._loadConfigPromise = this._loadConfig();
await Promise.all([
this.hass.loadFragmentTranslation("lovelace"),
this._loadConfigPromise,
]);
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
);
private _registriesChanged = async () => {
this._setLovelace();
};
protected render() {
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${this._searchParams.has("historyBack")}
>
<div slot="title">${this.hass.localize("panel.maintenance")}</div>
${
!this.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`
<hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}>
</hui-view-background>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
@entity-visibility-changed=${this._visibilityChanged}
></hui-view>
</hui-view-container>
`
: nothing
}
</ha-top-app-bar-fixed>
`;
}
private async _setLovelace(force = false) {
const revision = ++this._setLovelaceRevision;
if (this._loadConfigPromise) {
await this._loadConfigPromise;
}
const viewConfig = await generateLovelaceViewStrategy(
{
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 (!force && deepEqual(config, this._lovelace?.config)) {
return;
}
this._childPanelReady ??= new ChildPanelReady(this);
this._lovelace = {
config: config,
rawConfig: rawConfig,
editMode: false,
urlPath: "maintenance",
mode: "generated",
locale: this.hass.locale,
enableFullEditMode: () => undefined,
saveConfig: async () => undefined,
deleteConfig: async () => undefined,
setEditMode: () => undefined,
showToast: () => undefined,
};
}
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,
css`
:host {
user-select: none;
}
hui-view-container {
position: relative;
display: flex;
box-sizing: border-box;
}
hui-view {
flex: 1 1 100%;
max-width: 100%;
}
`,
];
}
class PanelMaintenance extends HaHomeSummaryPanelBase<"maintenance"> {
protected readonly _systemDataKey = "maintenance";
}
declare global {
+13 -322
View File
@@ -1,288 +1,21 @@
import { mdiPencil } 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 { customElement } from "lit/decorators";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
import "../../components/ha-button";
import type { HaButton } from "../../components/ha-button";
import "../../components/ha-icon-button";
import "../../components/ha-top-app-bar-fixed";
import {
fetchFrontendSystemDataAfterSaves,
queueFrontendSystemDataSave,
type SecurityFrontendSystemData,
} from "../../data/frontend";
import type { LovelaceStrategyViewConfig } from "../../data/lovelace/config/view";
import { ChildPanelReady } from "../../layouts/panel-ready";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { showToast } from "../../util/toast";
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
import type { Lovelace } from "../lovelace/types";
import { 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";
const SECURITY_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = {
strategy: {
type: "security",
},
};
import { HaHomeSummaryPanelBase } from "../home/ha-home-summary-panel-base";
@customElement("ha-panel-security")
class PanelSecurity extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
class PanelSecurity extends HaHomeSummaryPanelBase<"security"> {
protected readonly _systemDataKey = "security";
@property({ type: Boolean, reflect: true }) public narrow = false;
@state() private _viewIndex = 0;
@state() private _lovelace?: Lovelace;
@state() private _config?: SecurityFrontendSystemData;
@state() private _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
if (!this.hasUpdated) {
this._setup();
return;
}
if (!changedProps.has("hass")) {
return;
}
const oldHass = changedProps.get("hass") as this["hass"];
if (
oldHass &&
this.hass.config.state === "RUNNING" &&
oldHass.config.state !== "RUNNING"
) {
this._setup();
return;
}
if (oldHass && oldHass.localize !== this.hass.localize) {
this._setLovelace();
return;
}
if (oldHass && this.hass) {
// Refresh the generated view when registries or panels change.
if (
oldHass.entities !== this.hass.entities ||
oldHass.devices !== this.hass.devices ||
oldHass.areas !== this.hass.areas ||
oldHass.floors !== this.hass.floors ||
oldHass.panels !== this.hass.panels
) {
if (this.hass.config.state === "RUNNING") {
this._debounceRegistriesChanged();
}
}
}
}
private async _setup() {
this._loadConfigPromise = this._loadConfig();
await Promise.all([
this.hass.loadFragmentTranslation("lovelace"),
this._loadConfigPromise,
]);
await this._setLovelace();
}
private async _loadConfig() {
const revision = ++this._loadConfigRevision;
this._config = undefined;
try {
const data = await fetchFrontendSystemDataAfterSaves(
this.hass.connection,
"security"
);
if (revision !== this._loadConfigRevision) {
return;
}
this._config = data || {};
} catch (err) {
if (revision !== this._loadConfigRevision) {
return;
}
// eslint-disable-next-line no-console
console.error("Failed to load security configuration:", err);
showToast(this, {
message: this.hass.localize("ui.panel.security.editor.load_failed"),
duration: 0,
dismissable: true,
});
}
}
private _debounceRegistriesChanged = debounce(
() => this._registriesChanged(),
200
);
private _registriesChanged = async () => {
this._setLovelace();
};
protected render() {
return html`
<ha-top-app-bar-fixed
.narrow=${this.narrow}
.backButton=${this._searchParms.has("historyBack")}
>
<div slot="title">${this.hass.localize("panel.security")}</div>
${
!this.hass.user?.is_admin
? 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
? html`
<hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}>
</hui-view-background>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
@entity-visibility-changed=${this._visibilityChanged}
@entity-favorite-changed=${this._favoriteChanged}
@entity-alert-changed=${this._alertChanged}
@entity-visibility-focus-lost=${this._focusLost}
></hui-view
></hui-view-container>
`
: nothing
}
</ha-top-app-bar-fixed>
`;
}
private async _setLovelace(force = false) {
const revision = ++this._setLovelaceRevision;
if (this._loadConfigPromise) {
await this._loadConfigPromise;
}
const viewConfig = await generateLovelaceViewStrategy(
{
strategy: {
...SECURITY_LOVELACE_VIEW_CONFIG.strategy,
alert_entities: this._config?.alert_entities,
favorite_entities: this._config?.favorite_entities,
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 (!force && deepEqual(config, this._lovelace?.config)) {
return;
}
this._childPanelReady ??= new ChildPanelReady(this);
this._lovelace = {
config: config,
rawConfig: rawConfig,
editMode: false,
urlPath: "security",
mode: "generated",
locale: this.hass.locale,
enableFullEditMode: () => undefined,
saveConfig: async () => undefined,
deleteConfig: async () => undefined,
setEditMode: () => undefined,
showToast: () => undefined,
protected _strategyOptions() {
return {
alert_entities: this._config?.alert_entities,
favorite_entities: this._config?.favorite_entities,
};
}
private _startEditVisibility = () => {
clearEntityVisibilityFocus();
this._editVisibility = true;
this._setLovelace();
};
private _stopEditVisibility = () => {
this._editVisibility = false;
if (!this._config) {
this._loadConfigPromise = this._loadConfig();
}
this._setLovelace();
};
private _focusLost = () => {
this._exitEditButton?.focus();
};
private _visibilityChanged = (
ev: HASSDomEvent<HASSDomEvents["entity-visibility-changed"]>
) => {
if (!this._config) {
return;
}
const { entity, hidden } = ev.detail;
const hiddenEntities = (this._config.hidden_entities ?? []).filter(
(entityId) => entityId !== entity
);
if (hidden) {
hiddenEntities.push(entity);
}
this._saveConfig({
...this._config,
hidden_entities: hiddenEntities.length ? hiddenEntities : undefined,
});
};
private _favoriteChanged = (
protected _favoriteChanged(
ev: HASSDomEvent<HASSDomEvents["entity-favorite-changed"]>
) => {
) {
if (!this._config) {
return;
}
@@ -298,11 +31,11 @@ class PanelSecurity extends LitElement {
favorite_entities: favoriteEntities.length ? favoriteEntities : undefined,
});
this._setLovelace();
};
}
private _alertChanged = (
protected _alertChanged(
ev: HASSDomEvent<HASSDomEvents["entity-alert-changed"]>
) => {
) {
if (!this._config) {
return;
}
@@ -322,48 +55,6 @@ class PanelSecurity extends LitElement {
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, {
message: this.hass.localize("ui.panel.security.editor.save_failed"),
duration: 0,
dismissable: true,
});
if (config !== this._config) {
return;
}
await this._loadConfig();
await this._setLovelace(true);
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
user-select: none;
}
hui-view-container {
position: relative;
display: flex;
box-sizing: border-box;
}
hui-view {
flex: 1 1 100%;
max-width: 100%;
}
`,
];
}
}
+7
View File
@@ -2941,6 +2941,13 @@
"save_failed": "Failed to save climate page configuration"
}
},
"light": {
"editor": {
"title": "Edit visible entities",
"load_failed": "Failed to load lights page configuration",
"save_failed": "Failed to save lights page configuration"
}
},
"maintenance": {
"editor": {
"title": "Edit visible entities",
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import type { LovelaceCardConfig } from "../../../../src/data/lovelace/config/card";
import type { LovelaceSectionConfig } from "../../../../src/data/lovelace/config/section";
import { LightViewStrategy } from "../../../../src/panels/light/strategies/light-view-strategy";
import type { HomeAssistant } from "../../../../src/types";
import {
mockArea,
mockEntity,
} from "../../../common/entity/context/context-mock";
import { createMockEntityState, createMockHass } from "../../../fixtures/hass";
const createLightHass = (): HomeAssistant => ({
...createMockHass(
{
"light.ceiling": createMockEntityState("light.ceiling", "on"),
"light.status_led": createMockEntityState("light.status_led", "off"),
},
{
entities: {
"light.ceiling": mockEntity({
entity_id: "light.ceiling",
area_id: "kitchen",
}),
"light.status_led": mockEntity({
entity_id: "light.status_led",
area_id: "kitchen",
}),
},
areas: {
kitchen: mockArea({ area_id: "kitchen", name: "Kitchen" }),
},
}
),
panels: {},
});
const kitchenCards = async (
config: Parameters<typeof LightViewStrategy.generate>[0]
): Promise<LovelaceCardConfig[]> => {
const view = await LightViewStrategy.generate(config, createLightHass());
const section = view.sections?.[0] as LovelaceSectionConfig;
return section.cards!.slice(1);
};
describe("LightViewStrategy", () => {
it("leaves hidden lights out of the tiles, toggle group and area buttons", async () => {
const cards = await kitchenCards({
type: "light",
hidden_entities: ["light.status_led"],
});
const heading = cards.find((card) => card.type === "heading")!;
expect(
heading.badges.map((badge: LovelaceCardConfig) => badge.tap_action.target)
).toEqual([
{ entity_id: ["light.ceiling"] },
{ entity_id: ["light.ceiling"] },
]);
expect(
cards.find((card) => card.type === "toggle-group")?.entities
).toEqual(["light.ceiling"]);
expect(
cards.filter((card) => card.type === "tile").map((card) => card.entity)
).toEqual(["light.ceiling"]);
});
it("shows every light as a visibility wrapper while editing", async () => {
const cards = await kitchenCards({
type: "light",
hidden_entities: ["light.status_led"],
edit_visibility: true,
});
expect(cards[0]).toMatchObject({ type: "heading", heading: "Kitchen" });
expect(cards[0].badges).toBeUndefined();
expect(
cards.slice(1).map(({ type, entity, hidden }) => ({
type,
entity,
hidden,
}))
).toEqual([
{ type: "entity-visibility", entity: "light.ceiling", hidden: false },
{ type: "entity-visibility", entity: "light.status_led", hidden: true },
]);
});
});
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import type { LovelaceCardConfig } from "../../../../../src/data/lovelace/config/card";
import type { LovelaceSectionConfig } from "../../../../../src/data/lovelace/config/section";
import { HomeAreaViewStrategy } from "../../../../../src/panels/lovelace/strategies/home/home-area-view-strategy";
import type { HomeAssistant } from "../../../../../src/types";
import {
mockArea,
mockEntity,
} from "../../../../common/entity/context/context-mock";
import {
createMockEntityState,
createMockHass,
} from "../../../../fixtures/hass";
const createKitchenHass = (): HomeAssistant => ({
...createMockHass(
{
"light.ceiling": createMockEntityState("light.ceiling", "on"),
"light.status_led": createMockEntityState("light.status_led", "off"),
},
{
entities: {
"light.ceiling": mockEntity({
entity_id: "light.ceiling",
area_id: "kitchen",
}),
"light.status_led": mockEntity({
entity_id: "light.status_led",
area_id: "kitchen",
}),
},
areas: {
kitchen: mockArea({ area_id: "kitchen", name: "Kitchen" }),
},
}
),
panels: {},
});
describe("HomeAreaViewStrategy", () => {
it("leaves hidden lights out of the Lights section and its buttons", async () => {
const view = await HomeAreaViewStrategy.generate(
{
type: "home-area",
area: "kitchen",
light_hidden_entities: ["light.status_led"],
},
createKitchenHass()
);
const [heading, ...tiles] = (view.sections?.[0] as LovelaceSectionConfig)
.cards!;
expect(
heading.badges.map((badge: LovelaceCardConfig) => badge.tap_action.target)
).toEqual([
{ entity_id: ["light.ceiling"] },
{ entity_id: ["light.ceiling"] },
]);
expect(tiles.map((card) => card.entity)).toEqual(["light.ceiling"]);
});
});