From d96ddf968c895cc8e6f6a422a4340c5caad239ef Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 22 Jul 2024 15:44:08 +0200 Subject: [PATCH] Show yaml setup integrations in the UI (#21447) * Show yaml setup integrations in the UI * Update en.json * Move config entry logic to memoize function --- src/components/ha-related-items.ts | 73 +++++++++++--- src/data/repairs.ts | 6 +- src/data/search.ts | 1 + .../config/entities/ha-config-entities.ts | 28 +++++- .../ha-config-integration-page.ts | 64 +++++++++++-- .../ha-config-integrations-dashboard.ts | 78 ++++++++++++++- .../integrations/ha-integration-card.ts | 95 +++++++++++++++---- src/translations/en.json | 2 + 8 files changed, 301 insertions(+), 46 deletions(-) diff --git a/src/components/ha-related-items.ts b/src/components/ha-related-items.ts index be59a28b8835..2b4014337af1 100644 --- a/src/components/ha-related-items.ts +++ b/src/components/ha-related-items.ts @@ -6,12 +6,12 @@ import { mdiSofa, } from "@mdi/js"; import { - css, CSSResultGroup, - html, LitElement, - nothing, PropertyValues, + css, + html, + nothing, } from "lit"; import { customElement, property, state } from "lit/decorators"; import { styleMap } from "lit/directives/style-map"; @@ -20,7 +20,7 @@ import { fireEvent } from "../common/dom/fire_event"; import { caseInsensitiveStringCompare } from "../common/string/compare"; import { Blueprints, fetchBlueprints } from "../data/blueprint"; import { ConfigEntry, getConfigEntries } from "../data/config_entries"; -import { findRelated, ItemType, RelatedResult } from "../data/search"; +import { ItemType, RelatedResult, findRelated } from "../data/search"; import { haStyle } from "../resources/styles"; import { HomeAssistant } from "../types"; import { brandsUrl } from "../util/brands-url"; @@ -109,6 +109,26 @@ export class HaRelatedItems extends LitElement { ) ); + private _getConfigEntries = memoizeOne( + ( + relatedConfigEntries: string[] | undefined, + entries: ConfigEntry[] | undefined + ) => { + const configEntries = + relatedConfigEntries && entries + ? relatedConfigEntries.map((entryId) => + entries!.find((configEntry) => configEntry.entry_id === entryId) + ) + : undefined; + + const configEntryDomains = new Set( + configEntries?.map((entry) => entry?.domain) + ); + + return { configEntries, configEntryDomains }; + } + ); + protected render() { if (!this._related) { return nothing; @@ -128,22 +148,25 @@ export class HaRelatedItems extends LitElement { `; } + + const { configEntries, configEntryDomains } = this._getConfigEntries( + this._related.config_entry, + this._entries + ); + return html` - ${this._related.config_entry && this._entries + ${configEntries || this._related.integration ? html`

${this.hass.localize("ui.components.related-items.integration")}

${this._related.config_entry.map((relatedConfigEntryId) => { - const entry: ConfigEntry | undefined = this._entries!.find( - (configEntry) => configEntry.entry_id === relatedConfigEntryId - ); + >${configEntries?.map((entry) => { if (!entry) { return nothing; } return html` @@ -164,8 +187,34 @@ export class HaRelatedItems extends LitElement { `; - })}` + })} + ${this._related.integration + ?.filter((integration) => !configEntryDomains.has(integration)) + .map( + (integration) => + html` + + ${integration} + ${this.hass.localize(`component.${integration}.title`)} + + + ` + )} + ` : nothing} ${this._related.device ? html`

diff --git a/src/data/repairs.ts b/src/data/repairs.ts index f1e0cbb7f8c8..32213ca56629 100644 --- a/src/data/repairs.ts +++ b/src/data/repairs.ts @@ -32,7 +32,11 @@ export const fetchRepairsIssues = (conn: Connection) => type: "repairs/list_issues", }); -export const fetchRepairsIssueData = (conn: Connection, domain, issue_id) => +export const fetchRepairsIssueData = ( + conn: Connection, + domain: string, + issue_id: string +) => conn.sendMessagePromise<{ issue_data: { string: any } }>({ type: "repairs/get_issue_data", domain, diff --git a/src/data/search.ts b/src/data/search.ts index 5011f9a4c13b..e184f13e7561 100644 --- a/src/data/search.ts +++ b/src/data/search.ts @@ -8,6 +8,7 @@ export interface RelatedResult { device?: string[]; entity?: string[]; group?: string[]; + integration?: string[]; scene?: string[]; script?: string[]; script_blueprint?: string[]; diff --git a/src/panels/config/entities/ha-config-entities.ts b/src/panels/config/entities/ha-config-entities.ts index cdc2d5739953..7c48cd160512 100644 --- a/src/panels/config/entities/ha-config-entities.ts +++ b/src/panels/config/entities/ha-config-entities.ts @@ -507,8 +507,30 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) { ) .map((entry) => entry.entry_id); + const filteredEntitiesByDomain = new Set(); + + const entitySources = this._entitySources || {}; + + const entitiesByDomain = {}; + + for (const [entity, source] of Object.entries(entitySources)) { + if (!(source.domain in entitiesByDomain)) { + entitiesByDomain[source.domain] = []; + } + entitiesByDomain[source.domain].push(entity); + } + + for (const val of filter.value) { + if (val in entitiesByDomain) { + entitiesByDomain[val].forEach((item) => + filteredEntitiesByDomain.add(item) + ); + } + } + filteredEntities = filteredEntities.filter( (entity) => + filteredEntitiesByDomain.has(entity.entity_id) || (filter.value as string[]).includes(entity.platform) || (entity.config_entry_id && entryIds.includes(entity.config_entry_id)) @@ -951,6 +973,9 @@ ${ } protected firstUpdated() { + fetchEntitySourcesWithCache(this.hass).then((sources) => { + this._entitySources = sources; + }); this._setFiltersFromUrl(); if (Object.keys(this._filters).length) { return; @@ -961,9 +986,6 @@ ${ items: undefined, }, }; - fetchEntitySourcesWithCache(this.hass).then((sources) => { - this._entitySources = sources; - }); } private _setFiltersFromUrl() { diff --git a/src/panels/config/integrations/ha-config-integration-page.ts b/src/panels/config/integrations/ha-config-integration-page.ts index 41c2bf18a747..e6d995ad13f6 100644 --- a/src/panels/config/integrations/ha-config-integration-page.ts +++ b/src/panels/config/integrations/ha-config-integration-page.ts @@ -108,6 +108,7 @@ import { documentationUrl } from "../../../util/documentation-url"; import { fileDownload } from "../../../util/file_download"; import { DataEntryFlowProgressExtended } from "./ha-config-integrations"; import { showAddIntegrationDialog } from "./show-add-integration-dialog"; +import { fetchEntitySourcesWithCache } from "../../../data/entity_sources"; @customElement("ha-config-integration-page") class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { @@ -140,6 +141,8 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { window.location.hash.substring(1) ); + @state() private _domainEntities: Record = {}; + private _configPanel = memoizeOne( (domain: string, panels: HomeAssistant["panels"]): string | undefined => Object.values(panels).find( @@ -185,7 +188,23 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { this._extraConfigEntries = undefined; this._fetchManifest(); this._fetchDiagnostics(); + this._fetchEntitySources(); + } + } + + private async _fetchEntitySources() { + const entitySources = await fetchEntitySourcesWithCache(this.hass); + + const entitiesByDomain = {}; + + for (const [entity, source] of Object.entries(entitySources)) { + if (!(source.domain in entitiesByDomain)) { + entitiesByDomain[source.domain] = []; + } + entitiesByDomain[source.domain].push(entity); } + + this._domainEntities = entitiesByDomain; } protected updated(changed: PropertyValues) { @@ -245,6 +264,22 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { const devices = this._getDevices(configEntries, this.hass.devices); const entities = this._getEntities(configEntries, this._entities); + let numberOfEntities = entities.length; + + if ( + this.domain in this._domainEntities && + numberOfEntities !== this._domainEntities[this.domain].length + ) { + if (!numberOfEntities) { + numberOfEntities = this._domainEntities[this.domain].length; + } else { + const entityIds = new Set(entities.map((entity) => entity.entity_id)); + for (const entityId of this._domainEntities[this.domain]) { + entityIds.add(entityId); + } + numberOfEntities = entityIds.size; + } + } const services = !devices.some((device) => device.entry_type !== "service"); @@ -320,7 +355,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { ` : ""} - ${entities.length > 0 + ${numberOfEntities > 0 ? html` @@ -331,7 +366,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { > ${this.hass.localize( `ui.panel.config.integrations.config_entry.entities`, - { count: entities.length } + { count: numberOfEntities } )} @@ -503,9 +538,15 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {

${normalEntries.length === 0 ? html`
- ${this.hass.localize( - "ui.panel.config.integrations.integration_page.no_entries" - )} + ${this.hass.config.components.find( + (comp) => comp.split(".")[0] === this.domain + ) + ? this.hass.localize( + "ui.panel.config.integrations.integration_page.yaml_entry" + ) + : this.hass.localize( + "ui.panel.config.integrations.integration_page.no_entries" + )}
` : nothing} @@ -683,7 +724,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) { const configPanel = this._configPanel(item.domain, this.hass.panels); - return html` { + entry_id?: string; localized_domain_name?: string; } @@ -114,6 +116,8 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) { @state() private _manifests: Record = {}; + @state() private _domainEntities: Record = {}; + private _extraFetchedManifests?: Set; @state() private _showIgnored = false; @@ -149,13 +153,56 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) { private _filterConfigEntries = memoizeOne( ( + components: string[], + manifests: Record, configEntries: ConfigEntryExtended[], + localize: HomeAssistant["localize"], filter?: string ): [ [string, ConfigEntryExtended[]][], ConfigEntryExtended[], ConfigEntryExtended[], ] => { + const entryDomains = new Set(configEntries.map((entry) => entry.domain)); + + const domains = new Set(); + + for (const component of components) { + const componentDomain = component.split(".")[0]; + if ( + !entryDomains.has(componentDomain) && + manifests[componentDomain] && + (!manifests[componentDomain].integration_type || + ["device", "hub", "service", "integration"].includes( + manifests[componentDomain].integration_type! + )) + ) { + domains.add(componentDomain); + } + } + + const nonConfigEntry: ConfigEntryExtended[] = [...domains].map( + (domain) => ({ + domain, + localized_domain_name: domainToName(localize, domain), + title: domain, + source: "yaml", + state: "loaded", + supports_options: false, + supports_remove_device: false, + supports_unload: false, + supports_reconfigure: false, + pref_disable_new_entities: false, + pref_disable_polling: false, + disabled_by: null, + reason: null, + error_reason_translation_key: null, + error_reason_translation_placeholders: null, + }) + ); + + const allEntries = [...configEntries, ...nonConfigEntry]; + let filteredConfigEntries: ConfigEntryExtended[]; const ignored: ConfigEntryExtended[] = []; const disabled: ConfigEntryExtended[] = []; @@ -167,12 +214,12 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) { minMatchCharLength: Math.min(filter.length, 2), threshold: 0.2, }; - const fuse = new Fuse(configEntries, options); + const fuse = new Fuse(allEntries, options); filteredConfigEntries = fuse .search(filter) .map((result) => result.item); } else { - filteredConfigEntries = configEntries; + filteredConfigEntries = allEntries; } for (const entry of filteredConfigEntries) { @@ -232,6 +279,7 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) { protected firstUpdated(changed: PropertyValues) { super.firstUpdated(changed); this._fetchManifests(); + this._fetchEntitySources(); if (this.route.path === "/add") { this._handleAdd(); } @@ -276,7 +324,13 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) { >`; } const [integrations, ignoredConfigEntries, disabledConfigEntries] = - this._filterConfigEntries(this.configEntries, this._filter); + this._filterConfigEntries( + this.hass.config.components, + this._manifests, + this.configEntries, + this.hass.localize, + this._filter + ); const configEntriesInProgress = this._filterConfigEntriesInProgress( this.configEntriesInProgress, this._filter @@ -463,6 +517,7 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) { .items=${items} .manifest=${this._manifests[domain]} .entityRegistryEntries=${this._entityRegistryEntries} + .domainEntities=${this._domainEntities[domain] || []} .supportsDiagnostics=${this._diagnosticHandlers ? this._diagnosticHandlers[domain] : false} @@ -552,6 +607,21 @@ class HaConfigIntegrationsDashboard extends SubscribeMixin(LitElement) { await scanUSBDevices(this.hass); } + private async _fetchEntitySources() { + const entitySources = await fetchEntitySourcesWithCache(this.hass); + + const entitiesByDomain = {}; + + for (const [entity, source] of Object.entries(entitySources)) { + if (!(source.domain in entitiesByDomain)) { + entitiesByDomain[source.domain] = []; + } + entitiesByDomain[source.domain].push(entity); + } + + this._domainEntities = entitiesByDomain; + } + private async _fetchManifests(integrations?: string[]) { const fetched = await fetchIntegrationManifests(this.hass, integrations); // Make a copy so we can keep track of previously loaded manifests diff --git a/src/panels/config/integrations/ha-integration-card.ts b/src/panels/config/integrations/ha-integration-card.ts index 240e82ae2bb3..0ea39700d6e2 100644 --- a/src/panels/config/integrations/ha-integration-card.ts +++ b/src/panels/config/integrations/ha-integration-card.ts @@ -1,5 +1,5 @@ import "@lrnwebcomponents/simple-tooltip/simple-tooltip"; -import { mdiCloud, mdiPackageVariant } from "@mdi/js"; +import { mdiCloud, mdiCodeBraces, mdiPackageVariant } from "@mdi/js"; import { CSSResultGroup, LitElement, @@ -46,6 +46,8 @@ export class HaIntegrationCard extends LitElement { @property({ attribute: false }) public logInfo?: IntegrationLogInfo; + @property({ attribute: false }) public domainEntities: string[] = []; + protected render(): TemplateResult { const entryState = this._getState(this.items); @@ -100,9 +102,13 @@ export class HaIntegrationCard extends LitElement { private _renderSingleEntry(): TemplateResult { const devices = this._getDevices(this.items, this.hass.devices); - const entities = devices.length - ? [] - : this._getEntities(this.items, this.entityRegistryEntries); + const entitiesCount = devices.length + ? 0 + : this._getEntityCount( + this.items, + this.entityRegistryEntries, + this.domainEntities + ); const services = !devices.some((device) => device.entry_type !== "service"); @@ -123,25 +129,32 @@ export class HaIntegrationCard extends LitElement { )}
` - : entities.length > 0 + : entitiesCount > 0 ? html` ${this.hass.localize( `ui.panel.config.integrations.config_entry.entities`, - { count: entities.length } + { count: entitiesCount } )} ` - : html` - - ${this.hass.localize( - `ui.panel.config.integrations.config_entry.entries`, - { count: this.items.length } - )} - - `} + : this.items.find((itm) => itm.source !== "yaml") + ? html` + + ${this.hass.localize( + `ui.panel.config.integrations.config_entry.entries`, + { + count: this.items.filter((itm) => itm.source !== "yaml") + .length, + } + )} + + ` + : html`
`}
${this.manifest && !this.manifest.is_built_in ? html` @@ -169,6 +182,19 @@ export class HaIntegrationCard extends LitElement { >
` : nothing} + ${!this.manifest?.config_flow + ? html`
+ + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.no_config_flow" + )} +
` + : nothing} `; @@ -190,19 +216,42 @@ export class HaIntegrationCard extends LitElement { } ); - private _getEntities = memoizeOne( + private _getEntityCount = memoizeOne( ( configEntry: ConfigEntry[], - entityRegistryEntries: EntityRegistryEntry[] - ): EntityRegistryEntry[] => { + entityRegistryEntries: EntityRegistryEntry[], + domainEntities: string[] + ): number => { if (!entityRegistryEntries) { - return []; + return domainEntities.length; } - const entryIds = configEntry.map((entry) => entry.entry_id); - return entityRegistryEntries.filter( + + const entryIds = configEntry + .map((entry) => entry.entry_id) + .filter(Boolean); + + if (!entryIds.length) { + return domainEntities.length; + } + + const entityRegEntities = entityRegistryEntries.filter( (entity) => entity.config_entry_id && entryIds.includes(entity.config_entry_id) ); + + if (entityRegEntities.length === domainEntities.length) { + return domainEntities.length; + } + + const entityIds = new Set( + entityRegEntities.map((reg) => reg.entity_id) + ); + + for (const entity of domainEntities) { + entityIds.add(entity); + } + + return entityIds.size; } ); @@ -308,6 +357,9 @@ export class HaIntegrationCard extends LitElement { .icon.custom { background: var(--warning-color); } + .icon.yaml { + background: var(--label-badge-grey); + } .icon ha-svg-icon { width: 16px; height: 16px; @@ -316,6 +368,9 @@ export class HaIntegrationCard extends LitElement { simple-tooltip { white-space: nowrap; } + .spacer { + height: 36px; + } `, ]; } diff --git a/src/translations/en.json b/src/translations/en.json index 5eb543eb0cf5..4f69e79e7918 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -4270,6 +4270,7 @@ "entries_system": "[%key:ui::panel::config::integrations::integration_page::entries%]", "entries_entity": "[%key:ui::panel::config::integrations::integration_page::entries%]", "no_entries": "No entries", + "yaml_entry": "This integration was not setup via the UI, you have either set it up in YAML or it is a dependency set up by another integration. If you want to configure it, you will need to do so in your configuration.yaml file.", "attention_entries": "Needs attention", "add_entry": "Add entry", "add_device": "Add device", @@ -4340,6 +4341,7 @@ "custom_integration": "Custom integration", "depends_on_cloud": "Depends on the cloud", "yaml_only": "Needs manual configuration", + "no_config_flow": "This integration was not set up from the UI", "disabled_polling": "Automatic polling for updated data disabled", "debug_logging_enabled": "Debug logging enabled", "state": {