diff --git a/cast/src/receiver/layout/hc-lovelace.ts b/cast/src/receiver/layout/hc-lovelace.ts index be23073207..ba94b42394 100644 --- a/cast/src/receiver/layout/hc-lovelace.ts +++ b/cast/src/receiver/layout/hc-lovelace.ts @@ -35,6 +35,7 @@ class HcLovelace extends LitElement { } const lovelace: Lovelace = { config: this.lovelaceConfig, + rawConfig: this.lovelaceConfig, editMode: false, urlPath: this.urlPath!, enableFullEditMode: () => undefined, diff --git a/cast/src/receiver/layout/hc-main.ts b/cast/src/receiver/layout/hc-main.ts index e035d5dd4e..e8623dfa65 100644 --- a/cast/src/receiver/layout/hc-main.ts +++ b/cast/src/receiver/layout/hc-main.ts @@ -221,11 +221,17 @@ export class HcMain extends HassElement { } private async _generateLovelaceConfig() { - const { generateLovelaceConfigFromHass } = await import( - "../../../../src/panels/lovelace/common/generate-lovelace-config" + const { generateLovelaceDashboardStrategy } = await import( + "../../../../src/panels/lovelace/strategies/get-strategy" ); this._handleNewLovelaceConfig( - await generateLovelaceConfigFromHass(this.hass!) + await generateLovelaceDashboardStrategy( + { + hass: this.hass!, + narrow: false, + }, + "original-states" + ) ); } diff --git a/src/data/lovelace.ts b/src/data/lovelace.ts index 89611b44ef..48bff25ffb 100644 --- a/src/data/lovelace.ts +++ b/src/data/lovelace.ts @@ -19,6 +19,10 @@ export interface LovelacePanelConfig { export interface LovelaceConfig { title?: string; + strategy?: { + name: string; + options?: Record; + }; views: LovelaceViewConfig[]; background?: string; } @@ -77,6 +81,10 @@ export interface LovelaceViewConfig { index?: number; title?: string; type?: string; + strategy?: { + name: string; + options?: Record; + }; badges?: Array; cards?: LovelaceCardConfig[]; path?: string; @@ -94,6 +102,7 @@ export interface LovelaceViewElement extends HTMLElement { index?: number; cards?: Array; badges?: LovelaceBadge[]; + isStrategy: boolean; setConfig(config: LovelaceViewConfig): void; } diff --git a/src/panels/lovelace/cards/hui-error-card.ts b/src/panels/lovelace/cards/hui-error-card.ts index 7739390717..1e9b0fc779 100644 --- a/src/panels/lovelace/cards/hui-error-card.ts +++ b/src/panels/lovelace/cards/hui-error-card.ts @@ -31,11 +31,18 @@ export class HuiErrorCard extends LitElement implements LovelaceCard { return html``; } + let dumped: string | undefined; + + if (this._config.origConfig) { + try { + dumped = safeDump(this._config.origConfig); + } catch (err) { + dumped = `[Error dumping ${this._config.origConfig}]`; + } + } + return html` - ${this._config.error} - ${this._config.origConfig - ? html`
${safeDump(this._config.origConfig)}
` - : ""} + ${this._config.error}${dumped ? html`
${dumped}
` : ""} `; } diff --git a/src/panels/lovelace/common/generate-lovelace-config.ts b/src/panels/lovelace/common/generate-lovelace-config.ts index 4251172c82..e07ac79bc5 100644 --- a/src/panels/lovelace/common/generate-lovelace-config.ts +++ b/src/panels/lovelace/common/generate-lovelace-config.ts @@ -1,41 +1,16 @@ -import { - HassEntities, - HassEntity, - STATE_NOT_RUNNING, -} from "home-assistant-js-websocket"; -import { isComponentLoaded } from "../../../common/config/is_component_loaded"; -import { DEFAULT_VIEW_ENTITY_ID } from "../../../common/const"; +import { HassEntities, HassEntity } from "home-assistant-js-websocket"; import { computeDomain } from "../../../common/entity/compute_domain"; -import { computeObjectId } from "../../../common/entity/compute_object_id"; import { computeStateDomain } from "../../../common/entity/compute_state_domain"; import { computeStateName } from "../../../common/entity/compute_state_name"; -import { extractViews } from "../../../common/entity/extract_views"; -import { getViewEntities } from "../../../common/entity/get_view_entities"; import { splitByGroups } from "../../../common/entity/split_by_groups"; import { compare } from "../../../common/string/compare"; import { LocalizeFunc } from "../../../common/translations/localize"; -import { subscribeOne } from "../../../common/util/subscribe-one"; -import { - AreaRegistryEntry, - subscribeAreaRegistry, -} from "../../../data/area_registry"; -import { - DeviceRegistryEntry, - subscribeDeviceRegistry, -} from "../../../data/device_registry"; -import { - EntityRegistryEntry, - subscribeEntityRegistry, -} from "../../../data/entity_registry"; -import { GroupEntity } from "../../../data/group"; +import type { AreaRegistryEntry } from "../../../data/area_registry"; +import type { DeviceRegistryEntry } from "../../../data/device_registry"; +import type { EntityRegistryEntry } from "../../../data/entity_registry"; import { domainToName } from "../../../data/integration"; -import { - LovelaceCardConfig, - LovelaceConfig, - LovelaceViewConfig, -} from "../../../data/lovelace"; +import { LovelaceCardConfig, LovelaceViewConfig } from "../../../data/lovelace"; import { SENSOR_DEVICE_CLASS_BATTERY } from "../../../data/sensor"; -import { HomeAssistant } from "../../../types"; import { AlarmPanelCardConfig, EntitiesCardConfig, @@ -57,8 +32,6 @@ const HIDE_DOMAIN = new Set([ const HIDE_PLATFORM = new Set(["mobile_app"]); -let subscribedRegistries = false; - interface SplittedByAreas { areasWithEntities: Array<[AreaRegistryEntry, HassEntity[]]>; otherEntities: HassEntities; @@ -239,7 +212,7 @@ const computeDefaultViewStates = ( return states; }; -const generateViewConfig = ( +export const generateViewConfig = ( localize: LocalizeFunc, path: string, title: string | undefined, @@ -373,141 +346,3 @@ export const generateDefaultViewConfig = ( return config; }; - -export const generateLovelaceConfigFromData = async ( - hass: HomeAssistant, - areaEntries: AreaRegistryEntry[], - deviceEntries: DeviceRegistryEntry[], - entityEntries: EntityRegistryEntry[], - entities: HassEntities, - localize: LocalizeFunc -): Promise => { - if (hass.config.safe_mode) { - return { - title: hass.config.location_name, - views: [ - { - cards: [{ type: "safe-mode" }], - }, - ], - }; - } - - const viewEntities = extractViews(entities); - - const views = viewEntities.map((viewEntity: GroupEntity) => { - const states = getViewEntities(entities, viewEntity); - - // In the case of a normal view, we use group order as specified in view - const groupOrders = {}; - Object.keys(states).forEach((entityId, idx) => { - groupOrders[entityId] = idx; - }); - - return generateViewConfig( - localize, - computeObjectId(viewEntity.entity_id), - computeStateName(viewEntity), - viewEntity.attributes.icon, - states, - groupOrders - ); - }); - - let title = hass.config.location_name; - - // User can override default view. If they didn't, we will add one - // that contains all entities. - if ( - viewEntities.length === 0 || - viewEntities[0].entity_id !== DEFAULT_VIEW_ENTITY_ID - ) { - views.unshift( - generateDefaultViewConfig( - areaEntries, - deviceEntries, - entityEntries, - entities, - localize - ) - ); - - // Add map of geo locations to default view if loaded - if (isComponentLoaded(hass, "geo_location")) { - if (views[0] && views[0].cards) { - views[0].cards.push({ - type: "map", - geo_location_sources: ["all"], - }); - } - } - - // Make sure we don't have Home as title and first tab. - if (views.length > 1 && title === "Home") { - title = "Home Assistant"; - } - } - - // User has no entities - if (views.length === 1 && views[0].cards!.length === 0) { - views[0].cards!.push({ - type: "empty-state", - }); - } - - return { - title, - views, - }; -}; - -export const generateLovelaceConfigFromHass = async ( - hass: HomeAssistant, - localize?: LocalizeFunc -): Promise => { - if (hass.config.state === STATE_NOT_RUNNING) { - return { - title: hass.config.location_name, - views: [ - { - cards: [{ type: "starting" }], - }, - ], - }; - } - - if (hass.config.safe_mode) { - return { - title: hass.config.location_name, - views: [ - { - cards: [{ type: "safe-mode" }], - }, - ], - }; - } - - // We want to keep the registry subscriptions alive after generating the UI - // so that we don't serve up stale data after changing areas. - if (!subscribedRegistries) { - subscribedRegistries = true; - subscribeAreaRegistry(hass.connection, () => undefined); - subscribeDeviceRegistry(hass.connection, () => undefined); - subscribeEntityRegistry(hass.connection, () => undefined); - } - - const [areaEntries, deviceEntries, entityEntries] = await Promise.all([ - subscribeOne(hass.connection, subscribeAreaRegistry), - subscribeOne(hass.connection, subscribeDeviceRegistry), - subscribeOne(hass.connection, subscribeEntityRegistry), - ]); - - return generateLovelaceConfigFromData( - hass, - areaEntries, - deviceEntries, - entityEntries, - hass.states, - localize || hass.localize - ); -}; diff --git a/src/panels/lovelace/create-element/create-view-element.ts b/src/panels/lovelace/create-element/create-view-element.ts index d8cec5fa48..3e5b69b612 100644 --- a/src/panels/lovelace/create-element/create-view-element.ts +++ b/src/panels/lovelace/create-element/create-view-element.ts @@ -2,6 +2,7 @@ import { LovelaceViewConfig, LovelaceViewElement, } from "../../../data/lovelace"; +import { HuiErrorCard } from "../cards/hui-error-card"; import "../views/hui-masonry-view"; import { createLovelaceElement } from "./create-element-base"; @@ -13,7 +14,7 @@ const LAZY_LOAD_LAYOUTS = { export const createViewElement = ( config: LovelaceViewConfig -): LovelaceViewElement => { +): LovelaceViewElement | HuiErrorCard => { return createLovelaceElement( "view", config, diff --git a/src/panels/lovelace/editor/hui-dialog-save-config.ts b/src/panels/lovelace/editor/hui-dialog-save-config.ts index 3cf9fae524..acf32993e4 100644 --- a/src/panels/lovelace/editor/hui-dialog-save-config.ts +++ b/src/panels/lovelace/editor/hui-dialog-save-config.ts @@ -19,13 +19,15 @@ import "../../../components/ha-formfield"; import "../../../components/ha-svg-icon"; import "../../../components/ha-switch"; import "../../../components/ha-yaml-editor"; +import type { LovelaceConfig } from "../../../data/lovelace"; import type { HassDialog } from "../../../dialogs/make-dialog-manager"; import { haStyleDialog } from "../../../resources/styles"; import type { HomeAssistant } from "../../../types"; import { documentationUrl } from "../../../util/documentation-url"; +import { expandLovelaceConfigStrategies } from "../strategies/get-strategy"; import type { SaveDialogParams } from "./show-save-config-dialog"; -const EMPTY_CONFIG = { views: [] }; +const EMPTY_CONFIG: LovelaceConfig = { views: [{ title: "Home" }] }; @customElement("hui-dialog-save-config") export class HuiSaveConfig extends LitElement implements HassDialog { @@ -125,14 +127,17 @@ export class HuiSaveConfig extends LitElement implements HassDialog { ${this._params.mode === "storage" ? html` - ${this.hass!.localize( - "ui.common.cancel" - )} - + ${this._saving @@ -148,11 +153,13 @@ export class HuiSaveConfig extends LitElement implements HassDialog { ` : html` - ${this.hass!.localize( + + @click=${this.closeDialog} + > `} `; @@ -177,7 +184,13 @@ export class HuiSaveConfig extends LitElement implements HassDialog { try { const lovelace = this._params!.lovelace; await lovelace.saveConfig( - this._emptyConfig ? EMPTY_CONFIG : lovelace.config + this._emptyConfig + ? EMPTY_CONFIG + : await expandLovelaceConfigStrategies({ + config: lovelace.config, + hass: this.hass!, + narrow: this._params!.narrow, + }) ); lovelace.setEditMode(true); this._saving = false; diff --git a/src/panels/lovelace/editor/show-save-config-dialog.ts b/src/panels/lovelace/editor/show-save-config-dialog.ts index a1be39ddcf..cda486ef15 100644 --- a/src/panels/lovelace/editor/show-save-config-dialog.ts +++ b/src/panels/lovelace/editor/show-save-config-dialog.ts @@ -14,6 +14,7 @@ const dialogTag = "hui-dialog-save-config"; export interface SaveDialogParams { lovelace: Lovelace; mode: "yaml" | "storage"; + narrow: boolean; } let registeredDialog = false; diff --git a/src/panels/lovelace/ha-panel-lovelace.ts b/src/panels/lovelace/ha-panel-lovelace.ts index 0d6b38716a..a499aa54e0 100644 --- a/src/panels/lovelace/ha-panel-lovelace.ts +++ b/src/panels/lovelace/ha-panel-lovelace.ts @@ -7,6 +7,11 @@ import { property, TemplateResult, } from "lit-element"; +import { constructUrlCurrentPath } from "../../common/url/construct-url"; +import { + addSearchParam, + removeSearchParam, +} from "../../common/url/search-params"; import { domainToName } from "../../data/integration"; import { deleteConfig, @@ -21,14 +26,16 @@ import "../../layouts/hass-error-screen"; import "../../layouts/hass-loading-screen"; import { HomeAssistant, PanelInfo, Route } from "../../types"; import { showToast } from "../../util/toast"; -import { generateLovelaceConfigFromHass } from "./common/generate-lovelace-config"; import { loadLovelaceResources } from "./common/load-resources"; import { showSaveDialog } from "./editor/show-save-config-dialog"; import "./hui-root"; +import { generateLovelaceDashboardStrategy } from "./strategies/get-strategy"; import { Lovelace } from "./types"; (window as any).loadCardHelpers = () => import("./custom-card-helpers"); +const DEFAULT_STRATEGY = "original-states"; + interface LovelacePanelConfig { mode: "yaml" | "storage"; } @@ -71,7 +78,11 @@ class LovelacePanel extends LitElement { this.lovelace.locale !== this.hass.locale ) { // language has been changed, rebuild UI - this._setLovelaceConfig(this.lovelace.config, this.lovelace.mode); + this._setLovelaceConfig( + this.lovelace.config, + this.lovelace.rawConfig, + this.lovelace.mode + ); } else if (this.lovelace && this.lovelace.mode === "generated") { // When lovelace is generated, we re-generate each time a user goes // to the states panel to make sure new entities are shown. @@ -139,7 +150,9 @@ class LovelacePanel extends LitElement { `; } - protected firstUpdated() { + protected firstUpdated(changedProps) { + super.firstUpdated(changedProps); + this._fetchConfig(false); if (!this._unsubUpdates) { this._subscribeUpdates(); @@ -153,8 +166,14 @@ class LovelacePanel extends LitElement { } private async _regenerateConfig() { - const conf = await generateLovelaceConfigFromHass(this.hass!); - this._setLovelaceConfig(conf, "generated"); + const conf = await generateLovelaceDashboardStrategy( + { + hass: this.hass!, + narrow: this.narrow, + }, + DEFAULT_STRATEGY + ); + this._setLovelaceConfig(conf, undefined, "generated"); this._state = "loaded"; } @@ -202,6 +221,7 @@ class LovelacePanel extends LitElement { private async _fetchConfig(forceDiskRefresh: boolean) { let conf: LovelaceConfig; + let rawConf: LovelaceConfig | undefined; let confMode: Lovelace["mode"] = this.panel!.config.mode; let confProm: Promise | undefined; const llWindow = window as WindowWithLovelaceProm; @@ -236,7 +256,18 @@ class LovelacePanel extends LitElement { } try { - conf = await confProm!; + rawConf = await confProm!; + + // If strategy defined, apply it here. + if (rawConf.strategy) { + conf = await generateLovelaceDashboardStrategy({ + config: rawConf, + hass: this.hass!, + narrow: this.narrow, + }); + } else { + conf = rawConf; + } } catch (err) { if (err.code !== "config_not_found") { // eslint-disable-next-line @@ -245,8 +276,13 @@ class LovelacePanel extends LitElement { this._errorMsg = err.message; return; } - const localize = await this.hass!.loadBackendTranslation("title"); - conf = await generateLovelaceConfigFromHass(this.hass!, localize); + conf = await generateLovelaceDashboardStrategy( + { + hass: this.hass!, + narrow: this.narrow, + }, + DEFAULT_STRATEGY + ); confMode = "generated"; } finally { // Ignore updates for another 2 seconds. @@ -258,7 +294,7 @@ class LovelacePanel extends LitElement { } this._state = this._state === "yaml-editor" ? this._state : "loaded"; - this._setLovelaceConfig(conf, confMode); + this._setLovelaceConfig(conf, rawConf, confMode); } private _checkLovelaceConfig(config: LovelaceConfig) { @@ -277,11 +313,16 @@ class LovelacePanel extends LitElement { return checkedConfig ? deepFreeze(checkedConfig) : config; } - private _setLovelaceConfig(config: LovelaceConfig, mode: Lovelace["mode"]) { + private _setLovelaceConfig( + config: LovelaceConfig, + rawConfig: LovelaceConfig | undefined, + mode: Lovelace["mode"] + ) { config = this._checkLovelaceConfig(config); const urlPath = this.urlPath; this.lovelace = { config, + rawConfig, mode, urlPath: this.urlPath, editMode: this.lovelace ? this.lovelace.editMode : false, @@ -294,22 +335,39 @@ class LovelacePanel extends LitElement { this._state = "yaml-editor"; }, setEditMode: (editMode: boolean) => { + // If we use a strategy for dashboard, we cannot show the edit UI + // So go straight to the YAML editor + if ( + this.lovelace!.rawConfig && + this.lovelace!.rawConfig !== this.lovelace!.config + ) { + this.lovelace!.enableFullEditMode(); + return; + } + if (!editMode || this.lovelace!.mode !== "generated") { this._updateLovelace({ editMode }); return; } + showSaveDialog(this, { lovelace: this.lovelace!, mode: this.panel!.config.mode, + narrow: this.narrow!, }); }, saveConfig: async (newConfig: LovelaceConfig): Promise => { - const { config: previousConfig, mode: previousMode } = this.lovelace!; + const { + config: previousConfig, + rawConfig: previousRawConfig, + mode: previousMode, + } = this.lovelace!; newConfig = this._checkLovelaceConfig(newConfig); try { // Optimistic update this._updateLovelace({ config: newConfig, + rawConfig: undefined, mode: "storage", }); this._ignoreNextUpdateEvent = true; @@ -320,18 +378,30 @@ class LovelacePanel extends LitElement { // Rollback the optimistic update this._updateLovelace({ config: previousConfig, + rawConfig: previousRawConfig, mode: previousMode, }); throw err; } }, deleteConfig: async (): Promise => { - const { config: previousConfig, mode: previousMode } = this.lovelace!; + const { + config: previousConfig, + rawConfig: previousRawConfig, + mode: previousMode, + } = this.lovelace!; try { // Optimistic update - const localize = await this.hass!.loadBackendTranslation("title"); + const generatedConf = await generateLovelaceDashboardStrategy( + { + hass: this.hass!, + narrow: this.narrow, + }, + DEFAULT_STRATEGY + ); this._updateLovelace({ - config: await generateLovelaceConfigFromHass(this.hass!, localize), + config: generatedConf, + rawConfig: undefined, mode: "generated", editMode: false, }); @@ -343,6 +413,7 @@ class LovelacePanel extends LitElement { // Rollback the optimistic update this._updateLovelace({ config: previousConfig, + rawConfig: previousRawConfig, mode: previousMode, }); throw err; @@ -356,6 +427,18 @@ class LovelacePanel extends LitElement { ...this.lovelace!, ...props, }; + + if ("editMode" in props) { + window.history.replaceState( + null, + "", + constructUrlCurrentPath( + props.editMode + ? addSearchParam({ edit: "1" }) + : removeSearchParam("edit") + ) + ); + } } } diff --git a/src/panels/lovelace/hui-editor.ts b/src/panels/lovelace/hui-editor.ts index 9766279d3d..eb0a8805e7 100644 --- a/src/panels/lovelace/hui-editor.ts +++ b/src/panels/lovelace/hui-editor.ts @@ -106,7 +106,7 @@ class LovelaceFullConfigEditor extends LitElement { protected firstUpdated(changedProps: PropertyValues) { super.firstUpdated(changedProps); - this.yamlEditor.value = safeDump(this.lovelace!.config); + this.yamlEditor.value = safeDump(this.lovelace!.rawConfig); } protected updated(changedProps: PropertyValues) { diff --git a/src/panels/lovelace/hui-root.ts b/src/panels/lovelace/hui-root.ts index 2dd95ba627..199c979e66 100644 --- a/src/panels/lovelace/hui-root.ts +++ b/src/panels/lovelace/hui-root.ts @@ -43,9 +43,7 @@ import { navigate } from "../../common/navigate"; import { addSearchParam, extractSearchParam, - removeSearchParam, } from "../../common/url/search-params"; -import { constructUrlCurrentPath } from "../../common/url/construct-url"; import { computeRTLDirection } from "../../common/util/compute_rtl"; import { debounce } from "../../common/util/debounce"; import { afterNextRender } from "../../common/util/render-status"; @@ -539,7 +537,7 @@ class HUIRoot extends LitElement { protected firstUpdated() { // Check for requested edit mode if (extractSearchParam("edit") === "1") { - this._enableEditMode(); + this.lovelace!.setEditMode(true); } } @@ -715,25 +713,11 @@ class HUIRoot extends LitElement { }); return; } - this._enableEditMode(); - } - - private _enableEditMode(): void { this.lovelace!.setEditMode(true); - window.history.replaceState( - null, - "", - constructUrlCurrentPath(addSearchParam({ edit: "1" })) - ); } private _editModeDisable(): void { this.lovelace!.setEditMode(false); - window.history.replaceState( - null, - "", - constructUrlCurrentPath(removeSearchParam("edit")) - ); } private _editLovelace() { @@ -837,7 +821,7 @@ class HUIRoot extends LitElement { const viewConfig = this.config.views[viewIndex]; if (!viewConfig) { - this._enableEditMode(); + this.lovelace!.setEditMode(true); return; } diff --git a/src/panels/lovelace/strategies/get-strategy.ts b/src/panels/lovelace/strategies/get-strategy.ts new file mode 100644 index 0000000000..72271daf5c --- /dev/null +++ b/src/panels/lovelace/strategies/get-strategy.ts @@ -0,0 +1,158 @@ +import { LovelaceConfig, LovelaceViewConfig } from "../../../data/lovelace"; +import { AsyncReturnType, HomeAssistant } from "../../../types"; +import { OriginalStatesStrategy } from "./original-states-strategy"; + +const MAX_WAIT_STRATEGY_LOAD = 5000; +const CUSTOM_PREFIX = "custom:"; + +export interface LovelaceDashboardStrategy { + generateDashboard(info: { + config?: LovelaceConfig; + hass: HomeAssistant; + narrow: boolean | undefined; + }): Promise; +} + +export interface LovelaceViewStrategy { + generateView(info: { + view: LovelaceViewConfig; + config: LovelaceConfig; + hass: HomeAssistant; + narrow: boolean | undefined; + }): Promise; +} + +const strategies: Record< + string, + LovelaceDashboardStrategy & LovelaceViewStrategy +> = { + "original-states": OriginalStatesStrategy, +}; + +const getLovelaceStrategy = async < + T extends LovelaceDashboardStrategy | LovelaceViewStrategy +>( + name: string +): Promise => { + if (name in strategies) { + return strategies[name] as T; + } + + if (!name.startsWith(CUSTOM_PREFIX)) { + throw new Error("Unknown strategy"); + } + + const tag = `ll-strategy-${name.substr(CUSTOM_PREFIX.length)}`; + + if ( + (await Promise.race([ + customElements.whenDefined(tag), + new Promise((resolve) => + setTimeout(() => resolve(true), MAX_WAIT_STRATEGY_LOAD) + ), + ])) === true + ) { + throw new Error( + `Timeout waiting for strategy element ${tag} to be registered` + ); + } + + return customElements.get(tag); +}; + +interface GenerateMethods { + generateDashboard: LovelaceDashboardStrategy["generateDashboard"]; + generateView: LovelaceViewStrategy["generateView"]; +} + +const generateStrategy = async ( + generateMethod: T, + renderError: (err: string | Error) => AsyncReturnType, + info: Parameters[0], + name: string | undefined +): Promise> => { + if (!name) { + return renderError("No strategy name found"); + } + + try { + const strategy = (await getLovelaceStrategy(name)) as any; + return await strategy[generateMethod](info); + } catch (err) { + if (err.message !== "timeout") { + // eslint-disable-next-line + console.error(err); + } + + return renderError(err); + } +}; + +export const generateLovelaceDashboardStrategy = async ( + info: Parameters[0], + name?: string +): ReturnType => + generateStrategy( + "generateDashboard", + (err) => ({ + views: [ + { + title: "Error", + cards: [ + { + type: "markdown", + content: `Error loading the dashboard strategy:\n> ${err}`, + }, + ], + }, + ], + }), + info, + name || info.config?.strategy?.name + ); + +export const generateLovelaceViewStrategy = async ( + info: Parameters[0], + name?: string +): ReturnType => + generateStrategy( + "generateView", + (err) => ({ + cards: [ + { + type: "markdown", + content: `Error loading the view strategy:\n> ${err}`, + }, + ], + }), + info, + name || info.view?.strategy?.name + ); + +/** + * Find all references to strategies and replaces them with the generated output + */ +export const expandLovelaceConfigStrategies = async ( + info: Parameters[0] & { + config: LovelaceConfig; + } +): Promise => { + const config = info.config.strategy + ? await generateLovelaceDashboardStrategy(info) + : { ...info.config }; + + config.views = await Promise.all( + config.views.map((view) => + view.strategy + ? generateLovelaceViewStrategy({ + hass: info.hass, + narrow: info.narrow, + config, + view, + }) + : view + ) + ); + + return config; +}; diff --git a/src/panels/lovelace/strategies/original-states-strategy.ts b/src/panels/lovelace/strategies/original-states-strategy.ts new file mode 100644 index 0000000000..0762bedabe --- /dev/null +++ b/src/panels/lovelace/strategies/original-states-strategy.ts @@ -0,0 +1,94 @@ +import { STATE_NOT_RUNNING } from "home-assistant-js-websocket"; +import { subscribeOne } from "../../../common/util/subscribe-one"; +import { subscribeAreaRegistry } from "../../../data/area_registry"; +import { subscribeDeviceRegistry } from "../../../data/device_registry"; +import { subscribeEntityRegistry } from "../../../data/entity_registry"; +import { generateDefaultViewConfig } from "../common/generate-lovelace-config"; +import { + LovelaceViewStrategy, + LovelaceDashboardStrategy, +} from "./get-strategy"; + +let subscribedRegistries = false; + +export class OriginalStatesStrategy { + static async generateView( + info: Parameters[0] + ): ReturnType { + const hass = info.hass; + + if (hass.config.state === STATE_NOT_RUNNING) { + return { + cards: [{ type: "starting" }], + }; + } + + if (hass.config.safe_mode) { + return { + cards: [{ type: "safe-mode" }], + }; + } + + // We leave this here so we always have the freshest data. + if (!subscribedRegistries) { + subscribedRegistries = true; + subscribeAreaRegistry(hass.connection, () => undefined); + subscribeDeviceRegistry(hass.connection, () => undefined); + subscribeEntityRegistry(hass.connection, () => undefined); + } + + const [ + areaEntries, + deviceEntries, + entityEntries, + localize, + ] = await Promise.all([ + subscribeOne(hass.connection, subscribeAreaRegistry), + subscribeOne(hass.connection, subscribeDeviceRegistry), + subscribeOne(hass.connection, subscribeEntityRegistry), + hass.loadBackendTranslation("title"), + ]); + + // User can override default view. If they didn't, we will add one + // that contains all entities. + const view = generateDefaultViewConfig( + areaEntries, + deviceEntries, + entityEntries, + hass.states, + localize + ); + + // Add map of geo locations to default view if loaded + if (hass.config.components.includes("geo_location")) { + if (view && view.cards) { + view.cards.push({ + type: "map", + geo_location_sources: ["all"], + }); + } + } + + // User has no entities + if (view.cards!.length === 0) { + view.cards!.push({ + type: "empty-state", + }); + } + + return view; + } + + static async generateDashboard( + info: Parameters[0] + ): ReturnType { + return { + views: [ + { + strategy: { name: "original-states" }, + title: info.hass.config.location_name, + }, + ], + }; + } +} diff --git a/src/panels/lovelace/types.ts b/src/panels/lovelace/types.ts index 17bd8bc465..f9763be955 100644 --- a/src/panels/lovelace/types.ts +++ b/src/panels/lovelace/types.ts @@ -18,6 +18,8 @@ declare global { export interface Lovelace { config: LovelaceConfig; + // If not set, a strategy was used to generate everything + rawConfig: LovelaceConfig | undefined; editMode: boolean; urlPath: string | null; mode: "generated" | "yaml" | "storage"; diff --git a/src/panels/lovelace/views/hui-masonry-view.ts b/src/panels/lovelace/views/hui-masonry-view.ts index 42914224af..0e7e49d21f 100644 --- a/src/panels/lovelace/views/hui-masonry-view.ts +++ b/src/panels/lovelace/views/hui-masonry-view.ts @@ -53,6 +53,8 @@ export class MasonryView extends LitElement implements LovelaceViewElement { @property({ type: Number }) public index?: number; + @property({ type: Boolean }) public isStrategy = false; + @property({ attribute: false }) public cards: Array< LovelaceCard | HuiErrorCard > = []; @@ -228,7 +230,7 @@ export class MasonryView extends LitElement implements LovelaceViewElement { private _addCardToColumn(columnEl, index, editMode) { const card: LovelaceCard = this.cards[index]; - if (!editMode) { + if (!editMode || this.isStrategy) { card.editMode = false; columnEl.appendChild(card); } else { diff --git a/src/panels/lovelace/views/hui-panel-view.ts b/src/panels/lovelace/views/hui-panel-view.ts index 515673759c..23898074aa 100644 --- a/src/panels/lovelace/views/hui-panel-view.ts +++ b/src/panels/lovelace/views/hui-panel-view.ts @@ -31,6 +31,8 @@ export class PanelView extends LitElement implements LovelaceViewElement { @property({ type: Number }) public index?: number; + @property({ type: Boolean }) public isStrategy = false; + @property({ attribute: false }) public cards: Array< LovelaceCard | HuiErrorCard > = []; @@ -109,7 +111,7 @@ export class PanelView extends LitElement implements LovelaceViewElement { const card: LovelaceCard = this.cards[0]; card.isPanel = true; - if (!this.lovelace?.editMode) { + if (this.isStrategy || !this.lovelace?.editMode) { card.editMode = false; this._card = card; return; diff --git a/src/panels/lovelace/views/hui-view.ts b/src/panels/lovelace/views/hui-view.ts index 100d5ef1f1..938662e24a 100644 --- a/src/panels/lovelace/views/hui-view.ts +++ b/src/panels/lovelace/views/hui-view.ts @@ -23,6 +23,7 @@ import { createViewElement } from "../create-element/create-view-element"; import { showCreateCardDialog } from "../editor/card-editor/show-create-card-dialog"; import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog"; import { confDeleteCard } from "../editor/delete-card"; +import { generateLovelaceViewStrategy } from "../strategies/get-strategy"; import type { Lovelace, LovelaceBadge, LovelaceCard } from "../types"; const DEFAULT_VIEW_LAYOUT = "masonry"; @@ -55,6 +56,8 @@ export class HUIView extends UpdatingElement { private _layoutElement?: LovelaceViewElement; + private _viewConfigTheme?: string; + // Public to make demo happy public createCardElement(cardConfig: LovelaceCardConfig) { const element = createCardElement(cardConfig) as LovelaceCard; @@ -100,51 +103,21 @@ export class HUIView extends UpdatingElement { */ const oldLovelace = changedProperties.get("lovelace") as this["lovelace"]; - const configChanged = + + // If config has changed, create element if necessary and set all values. + if ( changedProperties.has("index") || (changedProperties.has("lovelace") && (!oldLovelace || this.lovelace.config.views[this.index] !== - oldLovelace.config.views[this.index])); + oldLovelace.config.views[this.index])) + ) { + this._initializeConfig(); + return; + } - // If config has changed, create element if necessary and set all values. - if (configChanged) { - let viewConfig = this.lovelace.config.views[this.index]; - viewConfig = { - ...viewConfig, - type: viewConfig.panel - ? PANEL_VIEW_LAYOUT - : viewConfig.type || DEFAULT_VIEW_LAYOUT, - }; - - this._createBadges(viewConfig!); - this._createCards(viewConfig!); - - // Create a new layout element if necessary. - let addLayoutElement = false; - - if ( - !this._layoutElement || - this._layoutElementType !== viewConfig!.type - ) { - addLayoutElement = true; - this._createLayoutElement(viewConfig!); - } - - this._layoutElement!.hass = this.hass; - this._layoutElement!.narrow = this.narrow; - this._layoutElement!.lovelace = this.lovelace; - this._layoutElement!.index = this.index; - this._layoutElement!.cards = this._cards; - this._layoutElement!.badges = this._badges; - - if (addLayoutElement) { - while (this.lastChild) { - this.removeChild(this.lastChild); - } - this.appendChild(this._layoutElement!); - } - } else { + // If no layout element, we're still creating one + if (this._layoutElement) { // Config has not changed. Just props if (changedProperties.has("hass")) { this._badges.forEach((badge) => { @@ -155,56 +128,98 @@ export class HUIView extends UpdatingElement { element.hass = this.hass; }); - this._layoutElement!.hass = this.hass; + this._layoutElement.hass = this.hass; } if (changedProperties.has("narrow")) { - this._layoutElement!.narrow = this.narrow; + this._layoutElement.narrow = this.narrow; } if (changedProperties.has("lovelace")) { - this._layoutElement!.lovelace = this.lovelace; + this._layoutElement.lovelace = this.lovelace; } if (changedProperties.has("_cards")) { - this._layoutElement!.cards = this._cards; + this._layoutElement.cards = this._cards; } if (changedProperties.has("_badges")) { - this._layoutElement!.badges = this._badges; + this._layoutElement.badges = this._badges; } } const oldHass = changedProperties.get("hass") as this["hass"] | undefined; - // Update theme if necessary: - // - If config changed, the theme could have changed - // - if hass themes preferences have changed if ( - configChanged || - (changedProperties.has("hass") && - (!oldHass || - this.hass.themes !== oldHass.themes || - this.hass.selectedTheme !== oldHass.selectedTheme)) + changedProperties.has("hass") && + (!oldHass || + this.hass.themes !== oldHass.themes || + this.hass.selectedTheme !== oldHass.selectedTheme) ) { - applyThemesOnElement( - this, - this.hass.themes, - this.lovelace.config.views[this.index].theme - ); + applyThemesOnElement(this, this.hass.themes, this._viewConfigTheme); + } + } + + private async _initializeConfig() { + let viewConfig = this.lovelace.config.views[this.index]; + let isStrategy = false; + + if (viewConfig.strategy) { + isStrategy = true; + viewConfig = await generateLovelaceViewStrategy({ + hass: this.hass, + config: this.lovelace.config, + narrow: this.narrow, + view: viewConfig, + }); + } + + viewConfig = { + ...viewConfig, + type: viewConfig.panel + ? PANEL_VIEW_LAYOUT + : viewConfig.type || DEFAULT_VIEW_LAYOUT, + }; + + // Create a new layout element if necessary. + let addLayoutElement = false; + + if (!this._layoutElement || this._layoutElementType !== viewConfig.type) { + addLayoutElement = true; + this._createLayoutElement(viewConfig); + } + + this._createBadges(viewConfig); + this._createCards(viewConfig); + this._layoutElement!.isStrategy = isStrategy; + this._layoutElement!.hass = this.hass; + this._layoutElement!.narrow = this.narrow; + this._layoutElement!.lovelace = this.lovelace; + this._layoutElement!.index = this.index; + this._layoutElement!.cards = this._cards; + this._layoutElement!.badges = this._badges; + + applyThemesOnElement(this, this.hass.themes, viewConfig.theme); + this._viewConfigTheme = viewConfig.theme; + + if (addLayoutElement) { + while (this.lastChild) { + this.removeChild(this.lastChild); + } + this.appendChild(this._layoutElement!); } } private _createLayoutElement(config: LovelaceViewConfig): void { - this._layoutElement = createViewElement(config); + this._layoutElement = createViewElement(config) as LovelaceViewElement; this._layoutElementType = config.type; this._layoutElement.addEventListener("ll-create-card", () => { showCreateCardDialog(this, { - lovelaceConfig: this.lovelace!.config, - saveConfig: this.lovelace!.saveConfig, + lovelaceConfig: this.lovelace.config, + saveConfig: this.lovelace.saveConfig, path: [this.index], }); }); this._layoutElement.addEventListener("ll-edit-card", (ev) => { showEditCardDialog(this, { - lovelaceConfig: this.lovelace!.config, - saveConfig: this.lovelace!.saveConfig, + lovelaceConfig: this.lovelace.config, + saveConfig: this.lovelace.saveConfig, path: ev.detail.path, }); }); diff --git a/src/types.ts b/src/types.ts index 5b9b6ed980..7c6d699ce1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -256,3 +256,12 @@ export interface LocalizeMixin { hass?: HomeAssistant; localize: LocalizeFunc; } + +// https://www.jpwilliams.dev/how-to-unpack-the-return-type-of-a-promise-in-typescript +export type AsyncReturnType any> = T extends ( + ...args: any +) => Promise + ? U + : T extends (...args: any) => infer U + ? U + : never;