diff --git a/src/common/entity/entity_filter.ts b/src/common/entity/entity_filter.ts index 6267b61f05..2b59721fe7 100644 --- a/src/common/entity/entity_filter.ts +++ b/src/common/entity/entity_filter.ts @@ -2,6 +2,20 @@ import computeDomain from "./compute_domain"; export type FilterFunc = (entityId: string) => boolean; +export interface EntityFilter { + include_domains: string[]; + include_entities: string[]; + exclude_domains: string[]; + exclude_entities: string[]; +} + +export const isEmptyFilter = (filter: EntityFilter) => + filter.include_domains.length + + filter.include_entities.length + + filter.exclude_domains.length + + filter.exclude_entities.length === + 0; + export const generateFilter = ( includeDomains?: string[], includeEntities?: string[], diff --git a/src/components/entity/state-info.js b/src/components/entity/state-info.js index d2859d15d5..c9d5033a48 100644 --- a/src/components/entity/state-info.js +++ b/src/components/entity/state-info.js @@ -91,13 +91,12 @@ class StateInfo extends PolymerElement { static get properties() { return { - detailed: { - type: Boolean, - value: false, - }, hass: Object, stateObj: Object, - inDialog: Boolean, + inDialog: { + type: Boolean, + value: () => false, + }, rtl: { type: Boolean, reflectToAttribute: true, diff --git a/src/data/cloud.ts b/src/data/cloud.ts index 502ec10cd5..5135867376 100644 --- a/src/data/cloud.ts +++ b/src/data/cloud.ts @@ -1,27 +1,33 @@ import { HomeAssistant } from "../types"; +import { EntityFilter } from "../common/entity/entity_filter"; -export interface EntityFilter { - include_domains: string[]; - include_entities: string[]; - exclude_domains: string[]; - exclude_entities: string[]; -} interface CloudStatusBase { logged_in: boolean; cloud: "disconnected" | "connecting" | "connected"; } +export interface GoogleEntityConfig { + should_expose?: boolean; + override_name?: string; + aliases?: string[]; + disable_2fa?: boolean; +} + export interface CertificateInformation { common_name: string; expire_date: string; fingerprint: string; } -interface CloudPreferences { +export interface CloudPreferences { google_enabled: boolean; alexa_enabled: boolean; + remote_enabled: boolean; google_secure_devices_pin: string | undefined; cloudhooks: { [webhookId: string]: CloudWebhook }; + google_entity_configs: { + [entityId: string]: GoogleEntityConfig; + }; } export type CloudStatusLoggedIn = CloudStatusBase & { @@ -49,6 +55,12 @@ export interface CloudWebhook { managed?: boolean; } +export interface GoogleEntity { + entity_id: string; + traits: string[]; + might_2fa: boolean; +} + export const fetchCloudStatus = (hass: HomeAssistant) => hass.callWS({ type: "cloud/status" }); @@ -89,3 +101,20 @@ export const updateCloudPref = ( type: "cloud/update_prefs", ...prefs, }); + +export const fetchCloudGoogleEntities = (hass: HomeAssistant) => + hass.callWS({ type: "cloud/google_assistant/entities" }); + +export const updateCloudGoogleEntityConfig = ( + hass: HomeAssistant, + entityId: string, + values: GoogleEntityConfig +) => + hass.callWS({ + type: "cloud/google_assistant/entities/update", + entity_id: entityId, + ...values, + }); + +export const cloudSyncGoogleAssistant = (hass: HomeAssistant) => + hass.callApi("POST", "cloud/google_actions/sync"); diff --git a/src/panels/config/cloud/cloud-exposed-entities.ts b/src/panels/config/cloud/cloud-exposed-entities.ts index a1aca9a007..6035110396 100644 --- a/src/panels/config/cloud/cloud-exposed-entities.ts +++ b/src/panels/config/cloud/cloud-exposed-entities.ts @@ -16,8 +16,8 @@ import computeStateName from "../../../common/entity/compute_state_name"; import { FilterFunc, generateFilter, + EntityFilter, } from "../../../common/entity/entity_filter"; -import { EntityFilter } from "../../../data/cloud"; export class CloudExposedEntities extends LitElement { public hass?: HomeAssistant; diff --git a/src/panels/config/cloud/cloud-google-pref.ts b/src/panels/config/cloud/cloud-google-pref.ts index 52be2cb5a4..628d524704 100644 --- a/src/panels/config/cloud/cloud-google-pref.ts +++ b/src/panels/config/cloud/cloud-google-pref.ts @@ -16,7 +16,6 @@ import "../../../components/ha-card"; import { fireEvent } from "../../../common/dom/fire_event"; import { HomeAssistant } from "../../../types"; -import "./cloud-exposed-entities"; import { CloudStatusLoggedIn, updateCloudPref } from "../../../data/cloud"; import { PaperInputElement } from "@polymer/paper-input/paper-input"; @@ -89,12 +88,6 @@ export class CloudGooglePref extends LitElement { @change="${this._pinChanged}" > -

Exposed entities:

- ` : ""} @@ -103,8 +96,12 @@ export class CloudGooglePref extends LitElement { .hass="${this.hass}" .disabled="${!google_enabled}" path="cloud/google_actions/sync" - >Sync devices + Sync entities to Google + + + Manage Entities + `; @@ -154,6 +151,12 @@ export class CloudGooglePref extends LitElement { paper-input { width: 200px; } + .card-actions a { + text-decoration: none; + } + .card-actions mwc-button { + float: right; + } `; } } diff --git a/src/panels/config/cloud/ha-config-cloud-google-assistant.ts b/src/panels/config/cloud/ha-config-cloud-google-assistant.ts new file mode 100644 index 0000000000..eee1464fde --- /dev/null +++ b/src/panels/config/cloud/ha-config-cloud-google-assistant.ts @@ -0,0 +1,275 @@ +import { + LitElement, + TemplateResult, + html, + CSSResult, + css, + customElement, + property, +} from "lit-element"; +import "@polymer/paper-toggle-button"; +import "../../../layouts/hass-subpage"; +import "../../../layouts/hass-loading-screen"; +import "../../../components/ha-card"; +import "../../../components/entity/state-info"; +import { HomeAssistant } from "../../../types"; +import { + GoogleEntity, + fetchCloudGoogleEntities, + CloudStatusLoggedIn, + CloudPreferences, + updateCloudGoogleEntityConfig, + cloudSyncGoogleAssistant, + GoogleEntityConfig, +} from "../../../data/cloud"; +import memoizeOne from "memoize-one"; +import { + generateFilter, + isEmptyFilter, + EntityFilter, +} from "../../../common/entity/entity_filter"; +import { compare } from "../../../common/string/compare"; +import computeStateName from "../../../common/entity/compute_state_name"; +import { fireEvent } from "../../../common/dom/fire_event"; +import { showToast } from "../../../util/toast"; +import { PolymerChangedEvent } from "../../../polymer-types"; + +@customElement("ha-config-cloud-google-assistant") +class CloudGoogleAssistant extends LitElement { + @property() public hass!: HomeAssistant; + @property() public cloudStatus!: CloudStatusLoggedIn; + @property() public isWide!: boolean; + @property() private _entities?: GoogleEntity[]; + @property() + private _entityConfigs: CloudPreferences["google_entity_configs"] = {}; + private _popstateSyncAttached = false; + private _popstateReloadStatusAttached = false; + + private _getEntityFilterFunc = memoizeOne((filter: EntityFilter) => + generateFilter( + filter.include_domains, + filter.include_entities, + filter.exclude_domains, + filter.exclude_entities + ) + ); + + protected render(): TemplateResult | void { + if (this._entities === undefined) { + return html` + + `; + } + const emptyFilter = true || isEmptyFilter(this.cloudStatus.google_entities); + const filterFunc = this._getEntityFilterFunc( + this.cloudStatus.google_entities + ); + let selected = 0; + const cards = this._entities.map((entity) => { + const stateObj = this.hass.states[entity.entity_id]; + const config = this._entityConfigs[entity.entity_id] || {}; + const isExposed = emptyFilter + ? Boolean(config.should_expose) + : filterFunc(entity.entity_id); + if (isExposed) { + selected++; + } + + return html` + +
+ + ${entity.traits + .map((trait) => trait.substr(trait.lastIndexOf(".") + 1)) + .join(", ")} + + + Expose to Google Assistant + + ${entity.might_2fa + ? html` + + Disable two factor authentication + + ` + : ""} +
+
+ `; + }); + + return html` + + ${selected} selected + ${!emptyFilter + ? html` + + ` + : ""} +
+ ${cards} +
+
+ `; + } + + protected firstUpdated(changedProps) { + super.firstUpdated(changedProps); + this._fetchData(); + } + + protected updated(changedProps) { + super.updated(changedProps); + if (changedProps.has("cloudStatus")) { + this._entityConfigs = this.cloudStatus.prefs.google_entity_configs; + } + } + + private async _fetchData() { + const entities = await fetchCloudGoogleEntities(this.hass); + entities.sort((a, b) => { + const stateA = this.hass.states[a.entity_id]; + const stateB = this.hass.states[b.entity_id]; + return compare( + stateA ? computeStateName(stateA) : a.entity_id, + stateB ? computeStateName(stateB) : b.entity_id + ); + }); + this._entities = entities; + } + + private _showMoreInfo(ev) { + const entityId = ev.currentTarget.stateObj.entity_id; + fireEvent(this, "hass-more-info", { entityId }); + } + + private async _exposeChanged(ev: PolymerChangedEvent) { + const entityId = (ev.currentTarget as any).entityId; + const newExposed = ev.detail.value; + const curExposed = Boolean( + (this._entityConfigs[entityId] || {}).should_expose + ); + if (newExposed === curExposed) { + return; + } + await this._updateConfig(entityId, { + should_expose: newExposed, + }); + this._ensureEntitySync(); + } + + private async _disable2FAChanged(ev: PolymerChangedEvent) { + const entityId = (ev.currentTarget as any).entityId; + const newDisable2FA = ev.detail.value; + const curDisable2FA = Boolean( + (this._entityConfigs[entityId] || {}).disable_2fa + ); + if (newDisable2FA === curDisable2FA) { + return; + } + await this._updateConfig(entityId, { + disable_2fa: newDisable2FA, + }); + } + + private async _updateConfig(entityId: string, values: GoogleEntityConfig) { + const updatedConfig = await updateCloudGoogleEntityConfig( + this.hass, + entityId, + values + ); + this._entityConfigs = { + ...this._entityConfigs, + [entityId]: updatedConfig, + }; + this._ensureStatusReload(); + } + + private _ensureStatusReload() { + if (this._popstateReloadStatusAttached) { + return; + } + this._popstateReloadStatusAttached = true; + // Cache parent because by the time popstate happens, + // this element is detached + const parent = this.parentElement!; + this.addEventListener( + "popstate", + () => fireEvent(parent, "ha-refresh-cloud-status"), + { once: true } + ); + } + + private _ensureEntitySync() { + if (this._popstateSyncAttached) { + return; + } + this._popstateSyncAttached = true; + // Cache parent because by the time popstate happens, + // this element is detached + const parent = this.parentElement!; + window.addEventListener( + "popstate", + () => { + showToast(parent, { message: "Synchronizing changes to Google." }); + cloudSyncGoogleAssistant(this.hass); + }, + { once: true } + ); + } + + static get styles(): CSSResult { + return css` + .banner { + color: var(--primary-text-color); + background-color: var(--card-background-color); + padding: 16px 8px; + text-align: center; + } + .content { + display: flex; + flex-wrap: wrap; + padding: 4px; + --paper-toggle-button-label-spacing: 16px; + } + ha-card { + margin: 4px; + width: 100%; + max-width: 300px; + } + .card-content { + padding-bottom: 12px; + } + state-info { + cursor: pointer; + } + paper-toggle-button { + padding: 8px 0; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-cloud-google-assistant": CloudGoogleAssistant; + } +} diff --git a/src/panels/config/cloud/ha-config-cloud.ts b/src/panels/config/cloud/ha-config-cloud.ts index 038130495a..5a465c2395 100644 --- a/src/panels/config/cloud/ha-config-cloud.ts +++ b/src/panels/config/cloud/ha-config-cloud.ts @@ -11,7 +11,7 @@ import { CloudStatus } from "../../../data/cloud"; import { PolymerChangedEvent } from "../../../polymer-types"; import { PolymerElement } from "@polymer/polymer"; -const LOGGED_IN_URLS = ["account"]; +const LOGGED_IN_URLS = ["account", "google-assistant"]; const NOT_LOGGED_IN_URLS = ["login", "register", "forgot-password"]; @customElement("ha-config-cloud") @@ -53,6 +53,10 @@ class HaConfigCloud extends HassRouterPage { account: { tag: "ha-config-cloud-account", }, + "google-assistant": { + tag: "ha-config-cloud-google-assistant", + load: () => import("./ha-config-cloud-google-assistant"), + }, }, };