Add UI to manage Google Entities exposed to Cloud (#3224)

* Add UI to manage Google Entities exposed to Cloud

* Add selected count
This commit is contained in:
Paulus Schoutsen 2019-05-29 08:38:52 -07:00 committed by GitHub
parent b2b18cb814
commit 1ad9d2e54c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 346 additions and 22 deletions

View File

@ -2,6 +2,20 @@ import computeDomain from "./compute_domain";
export type FilterFunc = (entityId: string) => boolean; 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 = ( export const generateFilter = (
includeDomains?: string[], includeDomains?: string[],
includeEntities?: string[], includeEntities?: string[],

View File

@ -91,13 +91,12 @@ class StateInfo extends PolymerElement {
static get properties() { static get properties() {
return { return {
detailed: {
type: Boolean,
value: false,
},
hass: Object, hass: Object,
stateObj: Object, stateObj: Object,
inDialog: Boolean, inDialog: {
type: Boolean,
value: () => false,
},
rtl: { rtl: {
type: Boolean, type: Boolean,
reflectToAttribute: true, reflectToAttribute: true,

View File

@ -1,27 +1,33 @@
import { HomeAssistant } from "../types"; 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 { interface CloudStatusBase {
logged_in: boolean; logged_in: boolean;
cloud: "disconnected" | "connecting" | "connected"; cloud: "disconnected" | "connecting" | "connected";
} }
export interface GoogleEntityConfig {
should_expose?: boolean;
override_name?: string;
aliases?: string[];
disable_2fa?: boolean;
}
export interface CertificateInformation { export interface CertificateInformation {
common_name: string; common_name: string;
expire_date: string; expire_date: string;
fingerprint: string; fingerprint: string;
} }
interface CloudPreferences { export interface CloudPreferences {
google_enabled: boolean; google_enabled: boolean;
alexa_enabled: boolean; alexa_enabled: boolean;
remote_enabled: boolean;
google_secure_devices_pin: string | undefined; google_secure_devices_pin: string | undefined;
cloudhooks: { [webhookId: string]: CloudWebhook }; cloudhooks: { [webhookId: string]: CloudWebhook };
google_entity_configs: {
[entityId: string]: GoogleEntityConfig;
};
} }
export type CloudStatusLoggedIn = CloudStatusBase & { export type CloudStatusLoggedIn = CloudStatusBase & {
@ -49,6 +55,12 @@ export interface CloudWebhook {
managed?: boolean; managed?: boolean;
} }
export interface GoogleEntity {
entity_id: string;
traits: string[];
might_2fa: boolean;
}
export const fetchCloudStatus = (hass: HomeAssistant) => export const fetchCloudStatus = (hass: HomeAssistant) =>
hass.callWS<CloudStatus>({ type: "cloud/status" }); hass.callWS<CloudStatus>({ type: "cloud/status" });
@ -89,3 +101,20 @@ export const updateCloudPref = (
type: "cloud/update_prefs", type: "cloud/update_prefs",
...prefs, ...prefs,
}); });
export const fetchCloudGoogleEntities = (hass: HomeAssistant) =>
hass.callWS<GoogleEntity[]>({ type: "cloud/google_assistant/entities" });
export const updateCloudGoogleEntityConfig = (
hass: HomeAssistant,
entityId: string,
values: GoogleEntityConfig
) =>
hass.callWS<GoogleEntityConfig>({
type: "cloud/google_assistant/entities/update",
entity_id: entityId,
...values,
});
export const cloudSyncGoogleAssistant = (hass: HomeAssistant) =>
hass.callApi("POST", "cloud/google_actions/sync");

View File

@ -16,8 +16,8 @@ import computeStateName from "../../../common/entity/compute_state_name";
import { import {
FilterFunc, FilterFunc,
generateFilter, generateFilter,
EntityFilter,
} from "../../../common/entity/entity_filter"; } from "../../../common/entity/entity_filter";
import { EntityFilter } from "../../../data/cloud";
export class CloudExposedEntities extends LitElement { export class CloudExposedEntities extends LitElement {
public hass?: HomeAssistant; public hass?: HomeAssistant;

View File

@ -16,7 +16,6 @@ import "../../../components/ha-card";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
import "./cloud-exposed-entities";
import { CloudStatusLoggedIn, updateCloudPref } from "../../../data/cloud"; import { CloudStatusLoggedIn, updateCloudPref } from "../../../data/cloud";
import { PaperInputElement } from "@polymer/paper-input/paper-input"; import { PaperInputElement } from "@polymer/paper-input/paper-input";
@ -89,12 +88,6 @@ export class CloudGooglePref extends LitElement {
@change="${this._pinChanged}" @change="${this._pinChanged}"
></paper-input> ></paper-input>
</div> </div>
<p>Exposed entities:</p>
<cloud-exposed-entities
.hass="${this.hass}"
.filter="${this.cloudStatus!.google_entities}"
.supportedDomains="${this.cloudStatus!.google_domains}"
></cloud-exposed-entities>
` `
: ""} : ""}
</div> </div>
@ -103,8 +96,12 @@ export class CloudGooglePref extends LitElement {
.hass="${this.hass}" .hass="${this.hass}"
.disabled="${!google_enabled}" .disabled="${!google_enabled}"
path="cloud/google_actions/sync" path="cloud/google_actions/sync"
>Sync devices</ha-call-api-button
> >
Sync entities to Google
</ha-call-api-button>
<a href="/config/cloud/google-assistant">
<mwc-button>Manage Entities</mwc-button>
</a>
</div> </div>
</ha-card> </ha-card>
`; `;
@ -154,6 +151,12 @@ export class CloudGooglePref extends LitElement {
paper-input { paper-input {
width: 200px; width: 200px;
} }
.card-actions a {
text-decoration: none;
}
.card-actions mwc-button {
float: right;
}
`; `;
} }
} }

View File

@ -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`
<hass-loading-screen></hass-loading-screen>
`;
}
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`
<ha-card>
<div class="card-content">
<state-info
.hass=${this.hass}
.stateObj=${stateObj}
secondary-line
@click=${this._showMoreInfo}
>
${entity.traits
.map((trait) => trait.substr(trait.lastIndexOf(".") + 1))
.join(", ")}
</state-info>
<paper-toggle-button
.entityId=${entity.entity_id}
.disabled=${!emptyFilter}
.checked=${isExposed}
@checked-changed=${this._exposeChanged}
>
Expose to Google Assistant
</paper-toggle-button>
${entity.might_2fa
? html`
<paper-toggle-button
.entityId=${entity.entity_id}
.checked=${Boolean(config.disable_2fa)}
@checked-changed=${this._disable2FAChanged}
>
Disable two factor authentication
</paper-toggle-button>
`
: ""}
</div>
</ha-card>
`;
});
return html`
<hass-subpage header="Google Assistant">
<span slot="toolbar-icon">${selected} selected</span>
${!emptyFilter
? html`
<div class="banner">
Editing which entities are exposed via this UI is disabled
because you have configured entity filters in
configuration.yaml.
</div>
`
: ""}
<div class="content">
${cards}
</div>
</hass-subpage>
`;
}
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<boolean>) {
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<boolean>) {
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;
}
}

View File

@ -11,7 +11,7 @@ import { CloudStatus } from "../../../data/cloud";
import { PolymerChangedEvent } from "../../../polymer-types"; import { PolymerChangedEvent } from "../../../polymer-types";
import { PolymerElement } from "@polymer/polymer"; 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"]; const NOT_LOGGED_IN_URLS = ["login", "register", "forgot-password"];
@customElement("ha-config-cloud") @customElement("ha-config-cloud")
@ -53,6 +53,10 @@ class HaConfigCloud extends HassRouterPage {
account: { account: {
tag: "ha-config-cloud-account", tag: "ha-config-cloud-account",
}, },
"google-assistant": {
tag: "ha-config-cloud-google-assistant",
load: () => import("./ha-config-cloud-google-assistant"),
},
}, },
}; };