mirror of
https://github.com/home-assistant/frontend.git
synced 2026-11-07 05:23:57 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d895ac1fc | ||
|
|
f075d544b8 |
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user