From 98bfce1b7a9ef196b5754a15c97508fd3100fc04 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 23 Nov 2018 14:46:50 +0100 Subject: [PATCH 1/3] Generate Lovelace config on the fly --- src/common/entity/extract_views.ts | 9 +- src/common/entity/get_view_entities.ts | 2 +- src/common/entity/split_by_groups.ts | 7 +- .../common/generate-lovelace-config.ts | 221 ++++++++++++++++++ src/panels/lovelace/ha-panel-lovelace.js | 18 +- 5 files changed, 245 insertions(+), 12 deletions(-) create mode 100644 src/panels/lovelace/common/generate-lovelace-config.ts diff --git a/src/common/entity/extract_views.ts b/src/common/entity/extract_views.ts index bcc708464686..d5679e55180f 100644 --- a/src/common/entity/extract_views.ts +++ b/src/common/entity/extract_views.ts @@ -1,14 +1,15 @@ -import { HassEntities, HassEntity } from "home-assistant-js-websocket"; +import { HassEntities } from "home-assistant-js-websocket"; import { DEFAULT_VIEW_ENTITY_ID } from "../const"; +import { GroupEntity } from "../../types"; // Return an ordered array of available views -export default function extractViews(entities: HassEntities): HassEntity[] { - const views: HassEntity[] = []; +export default function extractViews(entities: HassEntities): GroupEntity[] { + const views: GroupEntity[] = []; Object.keys(entities).forEach((entityId) => { const entity = entities[entityId]; if (entity.attributes.view) { - views.push(entity); + views.push(entity as GroupEntity); } }); diff --git a/src/common/entity/get_view_entities.ts b/src/common/entity/get_view_entities.ts index 62aa7e5d8902..c3d249cfcd6b 100644 --- a/src/common/entity/get_view_entities.ts +++ b/src/common/entity/get_view_entities.ts @@ -8,7 +8,7 @@ import { GroupEntity } from "../../types"; export default function getViewEntities( entities: HassEntities, view: GroupEntity -) { +): HassEntities { const viewEntities = {}; view.attributes.entity_id.forEach((entityId) => { diff --git a/src/common/entity/split_by_groups.ts b/src/common/entity/split_by_groups.ts index a894472d8ce5..aed1ab7f0ee4 100644 --- a/src/common/entity/split_by_groups.ts +++ b/src/common/entity/split_by_groups.ts @@ -1,18 +1,19 @@ import computeDomain from "./compute_domain"; -import { HassEntity, HassEntities } from "home-assistant-js-websocket"; +import { HassEntities } from "home-assistant-js-websocket"; +import { GroupEntity } from "../../types"; // Split a collection into a list of groups and a 'rest' list of ungrouped // entities. // Returns { groups: [], ungrouped: {} } export default function splitByGroups(entities: HassEntities) { - const groups: HassEntity[] = []; + const groups: GroupEntity[] = []; const ungrouped: HassEntities = {}; Object.keys(entities).forEach((entityId) => { const entity = entities[entityId]; if (computeDomain(entityId) === "group") { - groups.push(entity); + groups.push(entity as GroupEntity); } else { ungrouped[entityId] = entity; } diff --git a/src/panels/lovelace/common/generate-lovelace-config.ts b/src/panels/lovelace/common/generate-lovelace-config.ts new file mode 100644 index 000000000000..4f4d8e13198a --- /dev/null +++ b/src/panels/lovelace/common/generate-lovelace-config.ts @@ -0,0 +1,221 @@ +import { HomeAssistant, GroupEntity } from "../../../types"; +import { HassEntity, HassEntities } from "home-assistant-js-websocket"; +import extractViews from "../../../common/entity/extract_views"; +import getViewEntities from "../../../common/entity/get_view_entities"; +import computeStateName from "../../../common/entity/compute_state_name"; +import splitByGroups from "../../../common/entity/split_by_groups"; +import computeObjectId from "../../../common/entity/compute_object_id"; +import computeStateDomain from "../../../common/entity/compute_state_domain"; + +interface CardConfig { + id?: string; + type: string; + [key: string]: any; +} + +interface ViewConfig { + title?: string; + badges?: string[]; + cards?: CardConfig[]; + id?: string; + icon?: string; +} + +interface LovelaceConfig { + title?: string; + views: ViewConfig[]; +} + +const DEFAULT_VIEW_ENTITY_ID = "group.default_view"; +const DOMAINS_BADGES = [ + "binary_sensor", + "device_tracker", + "mailbox", + "sensor", + "sun", + "timer", +]; +const HIDE_DOMAIN = new Set(["persistent_notification", "configurator"]); + +const computeCards = (title: string, states: HassEntity[]): CardConfig[] => { + const cards: CardConfig[] = []; + + // For entity card + const entities: string[] = []; + + states.forEach((stateObj) => { + const domain = computeStateDomain(stateObj); + if (domain === "alarm_control_panel") { + cards.push({ + type: "alarm-panel", + entity: stateObj.entity_id, + }); + } else if (domain === "climate") { + cards.push({ + type: "thermostat", + entity: stateObj.entity_id, + }); + } else if (domain === "media_player") { + cards.push({ + type: "media-control", + entity: stateObj.entity_id, + }); + } else if (domain === "weather") { + cards.push({ + type: "weather-forecast", + entity: stateObj.entity_id, + }); + } else { + entities.push(stateObj.entity_id); + } + }); + + if (entities.length > 0) { + cards.unshift({ + title, + type: "entities", + entities, + }); + } + + return cards; +}; + +const computeDefaultViewStates = (hass: HomeAssistant): HassEntities => { + const states = {}; + Object.keys(hass.states).forEach((entityId) => { + const stateObj = hass.states[entityId]; + if ( + !stateObj.attributes.hidden && + !HIDE_DOMAIN.has(computeStateDomain(stateObj)) + ) { + states[entityId] = hass.states[entityId]; + } + }); + return states; +}; + +const generateViewConfig = ( + id: string, + title: string | undefined, + icon: string | undefined, + entities: HassEntities, + groupOrders: { [entityId: string]: number } +): ViewConfig => { + const splitted = splitByGroups(entities); + splitted.groups.sort( + (gr1, gr2) => groupOrders[gr1.entity_id] - groupOrders[gr2.entity_id] + ); + + const badgeEntities: { [domain: string]: string[] } = {}; + const ungroupedEntitites: { [domain: string]: string[] } = {}; + + // Organize ungrouped entities in badges/ungrouped things + Object.keys(splitted.ungrouped).forEach((entityId) => { + const state = splitted.ungrouped[entityId]; + const domain = computeStateDomain(state); + + const coll = DOMAINS_BADGES.includes(domain) + ? badgeEntities + : ungroupedEntitites; + + if (!(domain in coll)) { + coll[domain] = []; + } + + coll[domain].push(state.entity_id); + }); + + let badges: string[] = []; + DOMAINS_BADGES.forEach((domain) => { + if (domain in badgeEntities) { + badges = badges.concat(badgeEntities[domain]); + } + }); + + let cards: CardConfig[] = []; + + splitted.groups.forEach((groupEntity) => { + cards = cards.concat( + computeCards( + computeStateName(groupEntity), + groupEntity.attributes.entity_id.map((entityId) => entities[entityId]) + ) + ); + }); + + Object.keys(ungroupedEntitites) + .sort() + .forEach((domain) => { + cards = cards.concat( + computeCards( + domain, + ungroupedEntitites[domain].map((entityId) => entities[entityId]) + ) + ); + }); + + return { + id, + title, + icon, + badges, + cards, + }; +}; + +export const generateLovelaceConfig = (hass: HomeAssistant): LovelaceConfig => { + const viewEntities = extractViews(hass.states); + + const views = viewEntities.map((viewEntity: GroupEntity) => { + const states = getViewEntities(hass.states, 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( + 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 + ) { + const states = computeDefaultViewStates(hass); + + // In the case of a default view, we want to use the group order attribute + const groupOrders = {}; + Object.keys(states).forEach((entityId) => { + const stateObj = states[entityId]; + if (stateObj.attributes.order) { + groupOrders[entityId] = stateObj.attributes.order; + } + }); + + views.unshift( + generateViewConfig("default_view", "Home", undefined, states, groupOrders) + ); + + // Make sure we don't have Home as title and first tab. + if (views.length > 1 && title === "Home") { + title = "Home Assistant"; + } + } + + return { + title, + views, + }; +}; diff --git a/src/panels/lovelace/ha-panel-lovelace.js b/src/panels/lovelace/ha-panel-lovelace.js index a866a9eda933..041d679f88fc 100644 --- a/src/panels/lovelace/ha-panel-lovelace.js +++ b/src/panels/lovelace/ha-panel-lovelace.js @@ -116,10 +116,20 @@ class Lovelace extends PolymerElement { _state: "loaded", }); } catch (err) { - this.setProperties({ - _state: "error", - _errorMsg: err.message, - }); + if (err.code === "file_not_found") { + const { + generateLovelaceConfig, + } = await import("./common/generate-lovelace-config"); + this.setProperties({ + _config: generateLovelaceConfig(this.hass), + _state: "loaded", + }); + } else { + this.setProperties({ + _state: "error", + _errorMsg: err.message, + }); + } } } From d08174c445694296929d49c05a8eef71621999c0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 23 Nov 2018 14:55:33 +0100 Subject: [PATCH 2/3] Disable editing --- src/panels/lovelace/common/generate-lovelace-config.ts | 2 ++ src/panels/lovelace/hui-root.js | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/src/panels/lovelace/common/generate-lovelace-config.ts b/src/panels/lovelace/common/generate-lovelace-config.ts index 4f4d8e13198a..78d3e7a2aba0 100644 --- a/src/panels/lovelace/common/generate-lovelace-config.ts +++ b/src/panels/lovelace/common/generate-lovelace-config.ts @@ -22,6 +22,7 @@ interface ViewConfig { } interface LovelaceConfig { + _frontendAuto: boolean; title?: string; views: ViewConfig[]; } @@ -215,6 +216,7 @@ export const generateLovelaceConfig = (hass: HomeAssistant): LovelaceConfig => { } return { + _frontendAuto: true, title, views, }; diff --git a/src/panels/lovelace/hui-root.js b/src/panels/lovelace/hui-root.js index 4d52bc1850a2..c04fb9dedd05 100644 --- a/src/panels/lovelace/hui-root.js +++ b/src/panels/lovelace/hui-root.js @@ -274,6 +274,10 @@ class HUIRoot extends NavigateMixin(EventsMixin(PolymerElement)) { } _editModeEnable() { + if (this.config._frontendAuto) { + alert("Unable to edit automatic generated UI yet."); + return; + } this._editMode = true; } From e63dc440da97c931634205fe8b82e0c305b68c66 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 23 Nov 2018 15:07:28 +0100 Subject: [PATCH 3/3] Fix domain name title rendering --- .../common/generate-lovelace-config.ts | 19 ++++++++++++++++--- src/panels/lovelace/ha-panel-lovelace.js | 5 +++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/panels/lovelace/common/generate-lovelace-config.ts b/src/panels/lovelace/common/generate-lovelace-config.ts index 78d3e7a2aba0..ca8b4fd0e936 100644 --- a/src/panels/lovelace/common/generate-lovelace-config.ts +++ b/src/panels/lovelace/common/generate-lovelace-config.ts @@ -6,6 +6,7 @@ import computeStateName from "../../../common/entity/compute_state_name"; import splitByGroups from "../../../common/entity/split_by_groups"; import computeObjectId from "../../../common/entity/compute_object_id"; import computeStateDomain from "../../../common/entity/compute_state_domain"; +import { LocalizeFunc } from "../../../mixins/localize-base-mixin"; interface CardConfig { id?: string; @@ -97,6 +98,7 @@ const computeDefaultViewStates = (hass: HomeAssistant): HassEntities => { }; const generateViewConfig = ( + localize: LocalizeFunc, id: string, title: string | undefined, icon: string | undefined, @@ -150,7 +152,7 @@ const generateViewConfig = ( .forEach((domain) => { cards = cards.concat( computeCards( - domain, + localize(`domain.${domain}`), ungroupedEntitites[domain].map((entityId) => entities[entityId]) ) ); @@ -165,7 +167,10 @@ const generateViewConfig = ( }; }; -export const generateLovelaceConfig = (hass: HomeAssistant): LovelaceConfig => { +export const generateLovelaceConfig = ( + hass: HomeAssistant, + localize: LocalizeFunc +): LovelaceConfig => { const viewEntities = extractViews(hass.states); const views = viewEntities.map((viewEntity: GroupEntity) => { @@ -178,6 +183,7 @@ export const generateLovelaceConfig = (hass: HomeAssistant): LovelaceConfig => { }); return generateViewConfig( + localize, computeObjectId(viewEntity.entity_id), computeStateName(viewEntity), viewEntity.attributes.icon, @@ -206,7 +212,14 @@ export const generateLovelaceConfig = (hass: HomeAssistant): LovelaceConfig => { }); views.unshift( - generateViewConfig("default_view", "Home", undefined, states, groupOrders) + generateViewConfig( + localize, + "default_view", + "Home", + undefined, + states, + groupOrders + ) ); // Make sure we don't have Home as title and first tab. diff --git a/src/panels/lovelace/ha-panel-lovelace.js b/src/panels/lovelace/ha-panel-lovelace.js index 041d679f88fc..f69434e714d5 100644 --- a/src/panels/lovelace/ha-panel-lovelace.js +++ b/src/panels/lovelace/ha-panel-lovelace.js @@ -5,8 +5,9 @@ import "@polymer/paper-button/paper-button"; import "../../layouts/hass-loading-screen"; import "../../layouts/hass-error-screen"; import "./hui-root"; +import localizeMixin from "../../mixins/localize-mixin"; -class Lovelace extends PolymerElement { +class Lovelace extends localizeMixin(PolymerElement) { static get template() { return html`