Add support for virtual integrations (#14138)

This commit is contained in:
Bram Kragten 2022-10-21 05:08:55 +02:00 committed by GitHub
parent 1b4989a7dc
commit 112ec10b30
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 246 additions and 225 deletions

View File

@ -1,6 +1,6 @@
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { HomeAssistant } from "../types";
import { integrationType } from "./integration";
import { IntegrationType } from "./integration";
export interface ConfigEntry {
entry_id: string;
@ -56,7 +56,7 @@ export const subscribeConfigEntries = (
hass: HomeAssistant,
callbackFunction: (message: ConfigEntryUpdate[]) => void,
filters?: {
type?: Array<integrationType>;
type?: IntegrationType[];
domain?: string;
}
): Promise<UnsubscribeFunc> => {
@ -75,7 +75,7 @@ export const subscribeConfigEntries = (
export const getConfigEntries = (
hass: HomeAssistant,
filters?: {
type?: Array<integrationType>;
type?: IntegrationType[];
domain?: string;
}
): Promise<ConfigEntry[]> => {

View File

@ -3,7 +3,7 @@ import { LocalizeFunc } from "../common/translations/localize";
import { debounce } from "../common/util/debounce";
import { HomeAssistant } from "../types";
import { DataEntryFlowProgress, DataEntryFlowStep } from "./data_entry_flow";
import { domainToName, integrationType } from "./integration";
import { domainToName, IntegrationType } from "./integration";
export const DISCOVERY_SOURCES = [
"bluetooth",
@ -68,7 +68,7 @@ export const deleteConfigFlow = (hass: HomeAssistant, flowId: string) =>
export const getConfigFlowHandlers = (
hass: HomeAssistant,
type?: Array<integrationType>
type?: IntegrationType[]
) =>
hass.callApi<string[]>(
"GET",

View File

@ -1,7 +1,7 @@
import { LocalizeFunc } from "../common/translations/localize";
import { HomeAssistant } from "../types";
export type integrationType = "device" | "helper" | "hub" | "service";
export type IntegrationType = "device" | "helper" | "hub" | "service";
export interface IntegrationManifest {
is_built_in: boolean;
@ -17,7 +17,7 @@ export interface IntegrationManifest {
ssdp?: Array<{ manufacturer?: string; modelName?: string; st?: string }>;
zeroconf?: string[];
homekit?: { models: string[] };
integration_type?: integrationType;
integration_type?: IntegrationType;
quality_scale?: "gold" | "internal" | "platinum" | "silver";
iot_class:
| "assumed_state"

View File

@ -1,29 +1,42 @@
import { HomeAssistant } from "../types";
import { IntegrationType } from "./integration";
export type IotStandards = "zwave" | "zigbee" | "homekit" | "matter";
export interface Integration {
integration_type: IntegrationType;
name?: string;
config_flow?: boolean;
integrations?: Integrations;
iot_standards?: IotStandards[];
is_built_in?: boolean;
iot_class?: string;
supported_by?: string;
is_built_in?: boolean;
}
export interface Integrations {
[domain: string]: Integration;
}
export interface Brand {
name?: string;
integrations?: Integrations;
iot_standards?: IotStandards[];
is_built_in?: boolean;
}
export interface Brands {
[domain: string]: Integration | Brand;
}
export interface IntegrationDescriptions {
core: {
integration: Integrations;
integration: Brands;
hardware: Integrations;
helper: Integrations;
translated_name: string[];
};
custom: {
integration: Integrations;
integration: Brands;
hardware: Integrations;
helper: Integrations;
};
@ -35,3 +48,28 @@ export const getIntegrationDescriptions = (
hass.callWS<IntegrationDescriptions>({
type: "integration/descriptions",
});
export const findIntegration = (
integrations: Brands | undefined,
domain: string
): Integration | undefined => {
if (!integrations) {
return undefined;
}
if (domain in integrations) {
const integration = integrations[domain];
if ("integration_type" in integration) {
return integration;
}
}
for (const integration of Object.values(integrations)) {
if (
"integrations" in integration &&
integration.integrations &&
domain in integration.integrations
) {
return integration.integrations[domain];
}
}
return undefined;
};

View File

@ -1,34 +0,0 @@
import type { HomeAssistant } from "../types";
export interface SupportedBrandObj {
name: string;
slug: string;
is_add?: boolean;
is_helper?: boolean;
supported_flows: string[];
}
export type SupportedBrandHandler = Record<string, string>;
export const getSupportedBrands = (hass: HomeAssistant) =>
hass.callWS<Record<string, SupportedBrandHandler>>({
type: "supported_brands",
});
export const getSupportedBrandsLookup = (
supportedBrands: Record<string, SupportedBrandHandler>
): Record<string, Partial<SupportedBrandObj>> => {
const supportedBrandsIntegrations: Record<
string,
Partial<SupportedBrandObj>
> = {};
for (const [d, domainBrands] of Object.entries(supportedBrands)) {
for (const [slug, name] of Object.entries(domainBrands)) {
supportedBrandsIntegrations[slug] = {
name,
supported_flows: [d],
};
}
}
return supportedBrandsIntegrations;
};

View File

@ -11,14 +11,8 @@ import {
DataEntryFlowStepProgress,
} from "../../data/data_entry_flow";
import type { IntegrationManifest } from "../../data/integration";
import type { SupportedBrandHandler } from "../../data/supported_brands";
import type { HomeAssistant } from "../../types";
export interface FlowHandlers {
integrations: string[];
helpers: string[];
supportedBrands: Record<string, SupportedBrandHandler>;
}
export interface FlowConfig {
loadDevicesAndAreas: boolean;

View File

@ -21,14 +21,13 @@ import {
fetchIntegrationManifest,
} from "../../../data/integration";
import {
Brand,
Brands,
findIntegration,
getIntegrationDescriptions,
Integration,
Integrations,
} from "../../../data/integrations";
import {
getSupportedBrands,
SupportedBrandHandler,
} from "../../../data/supported_brands";
import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow";
import {
showAlertDialog,
@ -50,7 +49,7 @@ export interface IntegrationListItem {
is_helper?: boolean;
integrations?: string[];
iot_standards?: string[];
supported_flows?: string[];
supported_by?: string;
cloud?: boolean;
is_built_in?: boolean;
is_add?: boolean;
@ -60,12 +59,10 @@ export interface IntegrationListItem {
class AddIntegrationDialog extends LitElement {
public hass!: HomeAssistant;
@state() private _integrations?: Integrations;
@state() private _integrations?: Brands;
@state() private _helpers?: Integrations;
@state() private _supportedBrands?: Record<string, SupportedBrandHandler>;
@state() private _initialFilter?: string;
@state() private _filter?: string;
@ -83,6 +80,7 @@ class AddIntegrationDialog extends LitElement {
private _height?: number;
public showDialog(params?: AddIntegrationDialogParams): void {
this._load();
this._open = true;
this._pickedBrand = params?.brand;
this._initialFilter = params?.initialFilter;
@ -95,7 +93,6 @@ class AddIntegrationDialog extends LitElement {
this._open = false;
this._integrations = undefined;
this._helpers = undefined;
this._supportedBrands = undefined;
this._pickedBrand = undefined;
this._flowsInProgress = undefined;
this._filter = undefined;
@ -127,18 +124,10 @@ class AddIntegrationDialog extends LitElement {
}
}
public updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("_open") && this._open) {
this._load();
}
}
private _filterIntegrations = memoizeOne(
(
i: Integrations,
i: Brands,
h: Integrations,
sb: Record<string, SupportedBrandHandler>,
components: HomeAssistant["config"]["components"],
localize: LocalizeFunc,
filter?: string
@ -161,14 +150,35 @@ class AddIntegrationDialog extends LitElement {
Object.entries(i).forEach(([domain, integration]) => {
if (
integration.config_flow ||
integration.iot_standards ||
integration.integrations
"integration_type" in integration &&
(integration.config_flow ||
integration.iot_standards ||
integration.supported_by)
) {
// Integration with a config flow, iot standard, or supported by
const supportedIntegration = integration.supported_by
? findIntegration(this._integrations, integration.supported_by)
: integration;
if (!supportedIntegration) {
return;
}
integrations.push({
domain,
name: integration.name || domainToName(localize, domain),
config_flow: supportedIntegration.config_flow,
iot_standards: supportedIntegration.iot_standards,
supported_by: integration.supported_by,
is_built_in: supportedIntegration.is_built_in !== false,
cloud: supportedIntegration.iot_class?.startsWith("cloud_"),
});
} else if (
!("integration_type" in integration) &&
("iot_standards" in integration || "integrations" in integration)
) {
// Brand
integrations.push({
domain,
name: integration.name || domainToName(localize, domain),
config_flow: integration.config_flow,
iot_standards: integration.iot_standards,
integrations: integration.integrations
? Object.entries(integration.integrations).map(
@ -176,9 +186,9 @@ class AddIntegrationDialog extends LitElement {
)
: undefined,
is_built_in: integration.is_built_in !== false,
cloud: integration.iot_class?.startsWith("cloud_"),
});
} else if (filter) {
} else if (filter && "integration_type" in integration) {
// Integration without a config flow
yamlIntegrations.push({
domain,
name: integration.name || domainToName(localize, domain),
@ -189,29 +199,12 @@ class AddIntegrationDialog extends LitElement {
}
});
for (const [domain, domainBrands] of Object.entries(sb)) {
const integration = this._findIntegration(domain);
if (!integration) {
continue;
}
for (const [slug, name] of Object.entries(domainBrands)) {
integrations.push({
domain: slug,
name,
config_flow: integration.config_flow,
supported_flows: [domain],
is_built_in: true,
cloud: integration.iot_class?.startsWith("cloud_"),
});
}
}
if (filter) {
const options: Fuse.IFuseOptions<IntegrationListItem> = {
keys: [
"name",
"domain",
"supported_flows",
"supported_by",
"integrations",
"iot_standards",
],
@ -219,21 +212,14 @@ class AddIntegrationDialog extends LitElement {
minMatchCharLength: 2,
threshold: 0.2,
};
const helpers = Object.entries(h)
.filter(
([_domain, integration]) =>
integration.config_flow ||
integration.iot_standards ||
integration.integrations
)
.map(([domain, integration]) => ({
domain,
name: integration.name || domainToName(localize, domain),
config_flow: integration.config_flow,
is_helper: true,
is_built_in: integration.is_built_in !== false,
cloud: integration.iot_class?.startsWith("cloud_"),
}));
const helpers = Object.entries(h).map(([domain, integration]) => ({
domain,
name: integration.name || domainToName(localize, domain),
config_flow: integration.config_flow,
is_helper: true,
is_built_in: integration.is_built_in !== false,
cloud: integration.iot_class?.startsWith("cloud_"),
}));
return [
...new Fuse(integrations, options)
.search(filter)
@ -255,26 +241,10 @@ class AddIntegrationDialog extends LitElement {
}
);
private _findIntegration(domain: string): Integration | undefined {
if (!this._integrations) {
return undefined;
}
if (domain in this._integrations) {
return this._integrations[domain];
}
for (const integration of Object.values(this._integrations)) {
if (integration.integrations && domain in integration.integrations) {
return integration.integrations[domain];
}
}
return undefined;
}
private _getIntegrations() {
return this._filterIntegrations(
this._integrations!,
this._helpers!,
this._supportedBrands!,
this.hass.config.components,
this.hass.localize,
this._filter
@ -289,6 +259,11 @@ class AddIntegrationDialog extends LitElement {
? this._getIntegrations()
: undefined;
const pickedIntegration = this._pickedBrand
? this._integrations?.[this._pickedBrand] ||
findIntegration(this._integrations, this._pickedBrand)
: undefined;
return html`<ha-dialog
open
@closed=${this.closeDialog}
@ -300,33 +275,32 @@ class AddIntegrationDialog extends LitElement {
this.hass.localize("ui.panel.config.integrations.new")
)}
>
${this._pickedBrand &&
(!this._integrations || this._pickedBrand in this._integrations)
${this._pickedBrand && (!this._integrations || pickedIntegration)
? html`<div slot="heading">
<ha-icon-button-prev
@click=${this._prevClicked}
></ha-icon-button-prev>
<h2 class="mdc-dialog__title">
${this._calculateBrandHeading()}
${this._calculateBrandHeading(pickedIntegration)}
</h2>
</div>
${this._renderIntegration()}`
${this._renderIntegration(pickedIntegration)}`
: this._renderAll(integrations)}
</ha-dialog>`;
}
private _calculateBrandHeading() {
const brand = this._integrations?.[this._pickedBrand!];
private _calculateBrandHeading(integration: Brand | Integration | undefined) {
if (
brand?.iot_standards &&
!brand.integrations &&
integration?.iot_standards &&
!("integrations" in integration) &&
!this._flowsInProgress?.length
) {
return "What type of device is it?";
}
if (
!brand?.iot_standards &&
!brand?.integrations &&
integration &&
!integration?.iot_standards &&
!("integrations" in integration) &&
this._flowsInProgress?.length
) {
return "Want to add these discovered devices?";
@ -334,20 +308,74 @@ class AddIntegrationDialog extends LitElement {
return "What do you want to add?";
}
private _renderIntegration(): TemplateResult {
private _renderIntegration(
integration: Brand | Integration | undefined
): TemplateResult {
return html`<ha-domain-integrations
.hass=${this.hass}
.domain=${this._pickedBrand}
.integration=${this._integrations?.[this._pickedBrand!]}
.integration=${integration}
.flowsInProgress=${this._flowsInProgress}
style=${styleMap({
minWidth: `${this._width}px`,
minHeight: `581px`,
})}
@close-dialog=${this.closeDialog}
@supported-by=${this._handleSupportedByEvent}
@select-brand=${this._handleSelectBrandEvent}
></ha-domain-integrations>`;
}
private _handleSelectBrandEvent(ev: CustomEvent) {
this._pickedBrand = ev.detail.brand;
}
private _handleSupportedByEvent(ev: CustomEvent) {
this._supportedBy(ev.detail.integration);
}
private _supportedBy(integration) {
const supportIntegration = findIntegration(
this._integrations,
integration.supported_by
);
showConfirmationDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_flow.supported_brand_flow",
{
supported_brand:
integration.name ||
domainToName(this.hass.localize, integration.domain),
flow_domain_name:
supportIntegration?.name ||
domainToName(this.hass.localize, integration.supported_by),
}
),
confirm: () => {
this.closeDialog();
if (["zha", "zwave_js"].includes(integration.supported_by)) {
protocolIntegrationPicked(this, this.hass, integration.supported_by);
return;
}
if (supportIntegration) {
this._handleIntegrationPicked({
domain: integration.supported_by,
name:
supportIntegration.name ||
domainToName(this.hass.localize, integration.supported_by),
config_flow: supportIntegration.config_flow,
iot_standards: supportIntegration.iot_standards,
});
} else {
showAlertDialog(this, {
text: "Integration not found",
warning: true,
});
}
},
});
}
private _renderAll(integrations?: IntegrationListItem[]): TemplateResult {
return html`<search-input
.hass=${this.hass}
@ -393,10 +421,7 @@ class AddIntegrationDialog extends LitElement {
};
private async _load() {
const [descriptions, supportedBrands] = await Promise.all([
getIntegrationDescriptions(this.hass),
getSupportedBrands(this.hass),
]);
const descriptions = await getIntegrationDescriptions(this.hass);
for (const integration in descriptions.custom.integration) {
if (
!Object.prototype.hasOwnProperty.call(
@ -427,7 +452,6 @@ class AddIntegrationDialog extends LitElement {
...descriptions.core.helper,
...descriptions.custom.helper,
};
this._supportedBrands = supportedBrands;
this.hass.loadBackendTranslation(
"title",
descriptions.core.translated_name,
@ -448,48 +472,8 @@ class AddIntegrationDialog extends LitElement {
}
private async _handleIntegrationPicked(integration: IntegrationListItem) {
if ("supported_flows" in integration) {
const domain = integration.supported_flows![0];
showConfirmationDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_flow.supported_brand_flow",
{
supported_brand: integration.name,
flow_domain_name: domainToName(this.hass.localize, domain),
}
),
confirm: () => {
const supportIntegration = this._findIntegration(domain);
this.closeDialog();
if (["zha", "zwave_js"].includes(domain)) {
protocolIntegrationPicked(this, this.hass, domain);
return;
}
if (supportIntegration) {
this._handleIntegrationPicked({
domain,
name:
supportIntegration.name ||
domainToName(this.hass.localize, domain),
config_flow: supportIntegration.config_flow,
iot_standards: supportIntegration.iot_standards,
integrations: supportIntegration.integrations
? Object.entries(supportIntegration.integrations).map(
([dom, val]) =>
val.name || domainToName(this.hass.localize, dom)
)
: undefined,
});
} else {
showAlertDialog(this, {
text: "Integration not found",
warning: true,
});
}
},
});
if (integration.supported_by) {
this._supportedBy(integration);
return;
}
@ -506,9 +490,7 @@ class AddIntegrationDialog extends LitElement {
}
if (integration.integrations) {
const integrations =
this._integrations![integration.domain].integrations!;
let domains = Object.keys(integrations);
let domains = integration.integrations;
if (integration.domain === "apple") {
// we show discoverd homekit devices in their own brand section, dont show them at apple
domains = domains.filter((domain) => domain !== "homekit_controller");

View File

@ -32,7 +32,6 @@ import {
subscribeConfigEntries,
} from "../../../data/config_entries";
import {
getConfigFlowHandlers,
getConfigFlowInProgressCollection,
localizeConfigFlowTitle,
subscribeConfigFlowInProgress,
@ -49,13 +48,14 @@ import {
} from "../../../data/entity_registry";
import {
domainToName,
fetchIntegrationManifest,
fetchIntegrationManifests,
IntegrationManifest,
} from "../../../data/integration";
import {
getSupportedBrands,
getSupportedBrandsLookup,
} from "../../../data/supported_brands";
getIntegrationDescriptions,
findIntegration,
} from "../../../data/integrations";
import { scanUSBDevices } from "../../../data/usb";
import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow";
import {
@ -693,19 +693,21 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
return;
}
const handlers = await getConfigFlowHandlers(this.hass, [
"device",
"hub",
"service",
]);
const descriptions = await getIntegrationDescriptions(this.hass);
const integrations = {
...descriptions.core.integration,
...descriptions.custom.integration,
};
// Integration exists, so we can just create a flow
if (handlers.includes(domain)) {
const integration = findIntegration(integrations, domain);
if (integration?.config_flow) {
// Integration exists, so we can just create a flow
const localize = await localizePromise;
if (
await showConfirmationDialog(this, {
title: localize("ui.panel.config.integrations.confirm_new", {
integration: domainToName(localize, domain),
integration: integration.name || domainToName(localize, domain),
}),
})
) {
@ -714,46 +716,57 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
this._handleFlowUpdated();
},
startFlowHandler: domain,
manifest: this._manifests[domain],
manifest: await fetchIntegrationManifest(this.hass, domain),
showAdvanced: this.hass.userData?.showAdvanced,
});
}
return;
}
const supportedBrands = await getSupportedBrands(this.hass);
const supportedBrandsIntegrations =
getSupportedBrandsLookup(supportedBrands);
if (integration?.supported_by) {
// Integration is a alias, so we can just create a flow
const localize = await localizePromise;
const supportedIntegration = findIntegration(
integrations,
integration.supported_by
);
// Supported brand exists, so we can just create a flow
if (Object.keys(supportedBrandsIntegrations).includes(domain)) {
const supBrand = supportedBrandsIntegrations[domain];
const slug = supBrand.supported_flows![0];
if (!supportedIntegration) {
return;
}
showConfirmationDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_flow.supported_brand_flow",
{
supported_brand: supBrand.name,
flow_domain_name: domainToName(this.hass.localize, slug),
supported_brand: integration.name || domainToName(localize, domain),
flow_domain_name:
supportedIntegration.name ||
domainToName(localize, integration.supported_by),
}
),
confirm: () => {
if (["zha", "zwave_js"].includes(slug)) {
protocolIntegrationPicked(this, this.hass, slug);
confirm: async () => {
if (["zha", "zwave_js"].includes(integration.supported_by!)) {
protocolIntegrationPicked(
this,
this.hass,
integration.supported_by!
);
return;
}
showConfigFlowDialog(this, {
dialogClosedCallback: () => {
this._handleFlowUpdated();
},
startFlowHandler: slug,
manifest: this._manifests[slug],
startFlowHandler: integration.supported_by,
manifest: await fetchIntegrationManifest(
this.hass,
integration.supported_by!
),
showAdvanced: this.hass.userData?.showAdvanced,
});
},
});
return;
}
@ -764,8 +777,12 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
});
return;
}
const helpers = await getConfigFlowHandlers(this.hass, ["helper"]);
if (helpers.includes(domain)) {
const helpers = {
...descriptions.core.helper,
...descriptions.custom.helper,
};
const helper = findIntegration(helpers, domain);
if (helper) {
navigate(`/config/helpers/add?domain=${domain}`, {
replace: true,
});

View File

@ -13,7 +13,7 @@ import {
domainToName,
fetchIntegrationManifest,
} from "../../../data/integration";
import { Integration } from "../../../data/integrations";
import { Brand, Integration } from "../../../data/integrations";
import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow";
import { haStyle } from "../../../resources/styles";
import { HomeAssistant } from "../../../types";
@ -29,7 +29,7 @@ class HaDomainIntegrations extends LitElement {
@property() public domain!: string;
@property({ attribute: false }) public integration?: Integration;
@property({ attribute: false }) public integration?: Brand | Integration;
@property({ attribute: false })
public flowsInProgress?: DataEntryFlowProgress[];
@ -65,7 +65,9 @@ class HaDomainIntegrations extends LitElement {
</mwc-list-item>`
)}
<li divider role="separator"></li>
${this.integration?.integrations
${this.integration &&
"integrations" in this.integration &&
this.integration.integrations
? html`<h3>
${this.hass.localize(
"ui.panel.config.integrations.available_integrations"
@ -106,7 +108,9 @@ class HaDomainIntegrations extends LitElement {
</mwc-list-item>`;
})
: ""}
${this.integration?.integrations
${this.integration &&
"integrations" in this.integration &&
this.integration.integrations
? Object.entries(this.integration.integrations)
.sort((a, b) => {
if (a[1].config_flow && !b[1].config_flow) {
@ -163,7 +167,9 @@ class HaDomainIntegrations extends LitElement {
<ha-icon-next slot="meta"></ha-icon-next>
</mwc-list-item>`
: ""}
${this.integration?.config_flow
${this.integration &&
"config_flow" in this.integration &&
this.integration.config_flow
? html`${this.flowsInProgress?.length
? html`<mwc-list-item
.domain=${this.domain}
@ -211,12 +217,30 @@ class HaDomainIntegrations extends LitElement {
return;
}
const integration = (ev.currentTarget as any).integration;
if (integration.supported_by) {
// @ts-ignore
fireEvent(this, "supported-by", { integration });
return;
}
if (integration.iot_standards) {
// @ts-ignore
fireEvent(this, "select-brand", {
brand: integration.domain,
});
return;
}
if (
(domain === this.domain &&
(!this.integration!.integrations ||
!(domain in this.integration!.integrations)) &&
!this.integration!.config_flow) ||
this.integration!.integrations?.[domain]?.config_flow === false
(("integration_type" in this.integration! &&
!this.integration.config_flow) ||
(!("integration_type" in this.integration!) &&
(!this.integration!.integrations ||
!(domain in this.integration!.integrations))))) ||
(this.integration as Brand)!.integrations?.[domain]?.config_flow === false
) {
const manifest = await fetchIntegrationManifest(this.hass, domain);
showYamlIntegrationDialog(this, { manifest });