From e2b9893b171f221fc7337ed48c797b5bac2fcc7e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 6 Nov 2018 14:22:59 +0100 Subject: [PATCH] Expose entities for Google/Alexa (#680) * Add entity filter * Show exposed entities on cloud panel * Fix tests * Revert some testing changes * Cursor: pointer * Fix * Update tests to TS --- src/common/entity/entity_filter.ts | 64 ++++++++++ src/panels/config/cloud/cloud-alexa-pref.ts | 17 ++- .../config/cloud/cloud-exposed-entities.ts | 116 ++++++++++++++++++ src/panels/config/cloud/cloud-google-pref.ts | 19 ++- src/panels/config/cloud/types.ts | 4 +- test-mocha/common/entity/entity_filter.ts | 97 +++++++++++++++ 6 files changed, 313 insertions(+), 4 deletions(-) create mode 100644 src/common/entity/entity_filter.ts create mode 100644 src/panels/config/cloud/cloud-exposed-entities.ts create mode 100644 test-mocha/common/entity/entity_filter.ts diff --git a/src/common/entity/entity_filter.ts b/src/common/entity/entity_filter.ts new file mode 100644 index 0000000000..6267b61f05 --- /dev/null +++ b/src/common/entity/entity_filter.ts @@ -0,0 +1,64 @@ +import computeDomain from "./compute_domain"; + +export type FilterFunc = (entityId: string) => boolean; + +export const generateFilter = ( + includeDomains?: string[], + includeEntities?: string[], + excludeDomains?: string[], + excludeEntities?: string[] +): FilterFunc => { + const includeDomainsSet = new Set(includeDomains); + const includeEntitiesSet = new Set(includeEntities); + const excludeDomainsSet = new Set(excludeDomains); + const excludeEntitiesSet = new Set(excludeEntities); + + const haveInclude = includeDomainsSet.size > 0 || includeEntitiesSet.size > 0; + const haveExclude = excludeDomainsSet.size > 0 || excludeEntitiesSet.size > 0; + + // Case 1 - no includes or excludes - pass all entities + if (!haveInclude && !haveExclude) { + return () => true; + } + + // Case 2 - includes, no excludes - only include specified entities + if (haveInclude && !haveExclude) { + return (entityId) => + includeEntitiesSet.has(entityId) || + includeDomainsSet.has(computeDomain(entityId)); + } + + // Case 3 - excludes, no includes - only exclude specified entities + if (!haveInclude && haveExclude) { + return (entityId) => + !excludeEntitiesSet.has(entityId) && + !excludeDomainsSet.has(computeDomain(entityId)); + } + + // Case 4 - both includes and excludes specified + // Case 4a - include domain specified + // - if domain is included, pass if entity not excluded + // - if domain is not included, pass if entity is included + // note: if both include and exclude domains specified, + // the exclude domains are ignored + if (includeDomainsSet.size) { + return (entityId) => + includeDomainsSet.has(computeDomain(entityId)) + ? !excludeEntitiesSet.has(entityId) + : includeEntitiesSet.has(entityId); + } + + // Case 4b - exclude domain specified + // - if domain is excluded, pass if entity is included + // - if domain is not excluded, pass if entity not excluded + if (excludeDomainsSet.size) { + return (entityId) => + excludeDomainsSet.has(computeDomain(entityId)) + ? includeEntitiesSet.has(entityId) + : !excludeEntitiesSet.has(entityId); + } + + // Case 4c - neither include or exclude domain specified + // - Only pass if entity is included. Ignore entity excludes. + return (entityId) => includeEntitiesSet.has(entityId); +}; diff --git a/src/panels/config/cloud/cloud-alexa-pref.ts b/src/panels/config/cloud/cloud-alexa-pref.ts index a076612f0d..c195a112fd 100644 --- a/src/panels/config/cloud/cloud-alexa-pref.ts +++ b/src/panels/config/cloud/cloud-alexa-pref.ts @@ -10,6 +10,7 @@ import { fireEvent } from "../../../common/dom/fire_event"; import { HomeAssistant } from "../../../types"; import { updatePref } from "./data"; import { CloudStatusLoggedIn } from "./types"; +import "./cloud-exposed-entities"; export class CloudAlexaPref extends LitElement { public hass?: HomeAssistant; @@ -23,11 +24,13 @@ export class CloudAlexaPref extends LitElement { } protected render(): TemplateResult { + const enabled = this.cloudStatus!.alexa_enabled; + return html` ${this.renderStyle()}
@@ -43,6 +46,18 @@ export class CloudAlexaPref extends LitElement { This integration requires an Alexa-enabled device like the Amazon Echo. + ${ + enabled + ? html` +

Exposed entities:

+ + ` + : "" + }
`; diff --git a/src/panels/config/cloud/cloud-exposed-entities.ts b/src/panels/config/cloud/cloud-exposed-entities.ts new file mode 100644 index 0000000000..bf8fab8084 --- /dev/null +++ b/src/panels/config/cloud/cloud-exposed-entities.ts @@ -0,0 +1,116 @@ +import { + html, + LitElement, + PropertyDeclarations, + PropertyValues, +} from "@polymer/lit-element"; +import { TemplateResult } from "lit-html"; +import { repeat } from "lit-html/directives/repeat"; +import "@polymer/paper-tooltip/paper-tooltip"; +import { HassEntityBase } from "home-assistant-js-websocket"; +import "../../../components/entity/ha-state-icon"; + +import { fireEvent } from "../../../common/dom/fire_event"; +import { HomeAssistant } from "../../../types"; +import { EntityFilter } from "./types"; +import computeStateName from "../../../common/entity/compute_state_name"; +import { + FilterFunc, + generateFilter, +} from "../../../common/entity/entity_filter"; + +export class CloudExposedEntities extends LitElement { + public hass?: HomeAssistant; + public filter?: EntityFilter; + public supportedDomains?: string[]; + private _filterFunc?: FilterFunc; + + static get properties(): PropertyDeclarations { + return { + hass: {}, + filter: {}, + supportedDomains: {}, + _filterFunc: {}, + }; + } + + protected render(): TemplateResult { + if (!this._filterFunc) { + return html``; + } + + const states: Array<[string, HassEntityBase]> = []; + + Object.keys(this.hass!.states).forEach((entityId) => { + if (this._filterFunc!(entityId)) { + const stateObj = this.hass!.states[entityId]; + states.push([computeStateName(stateObj), stateObj]); + } + }); + states.sort(); + + return html` + ${this.renderStyle()} + ${repeat( + states!, + (stateInfo) => stateInfo[1].entity_id, + (stateInfo) => html` + + + ${stateInfo[0]} + + ` + )} + `; + } + + protected updated(changedProperties: PropertyValues) { + if ( + changedProperties.has("filter") && + changedProperties.get("filter") !== this.filter + ) { + const filter = this.filter!; + const filterFunc = generateFilter( + filter.include_domains, + filter.include_entities, + filter.exclude_domains, + filter.exclude_entities + ); + const domains = new Set(this.supportedDomains); + this._filterFunc = (entityId: string) => { + const domain = entityId.split(".")[0]; + return domains.has(domain) && filterFunc(entityId); + }; + } + } + + private _handleMoreInfo(ev: MouseEvent) { + fireEvent(this, "hass-more-info", { + entityId: (ev.currentTarget as any).stateObj.entity_id, + }); + } + + private renderStyle(): TemplateResult { + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "cloud-exposed-entities": CloudExposedEntities; + } +} + +customElements.define("cloud-exposed-entities", CloudExposedEntities); diff --git a/src/panels/config/cloud/cloud-google-pref.ts b/src/panels/config/cloud/cloud-google-pref.ts index 19aaf7445d..7ab042e728 100644 --- a/src/panels/config/cloud/cloud-google-pref.ts +++ b/src/panels/config/cloud/cloud-google-pref.ts @@ -11,6 +11,7 @@ import { fireEvent } from "../../../common/dom/fire_event"; import { HomeAssistant } from "../../../types"; import { updatePref } from "./data"; import { CloudStatusLoggedIn } from "./types"; +import "./cloud-exposed-entities"; export class CloudGooglePref extends LitElement { public hass?: HomeAssistant; @@ -24,11 +25,13 @@ export class CloudGooglePref extends LitElement { } protected render(): TemplateResult { + const enabled = this.cloudStatus!.google_enabled; + return html` ${this.renderStyle()}
@@ -46,11 +49,23 @@ export class CloudGooglePref extends LitElement { This integration requires a Google Assistant-enabled device like the Google Home or Android phone. + ${ + enabled + ? html` +

Exposed entities:

+ + ` + : "" + }
Sync devices
diff --git a/src/panels/config/cloud/types.ts b/src/panels/config/cloud/types.ts index 98d8ff668e..4783d1776c 100644 --- a/src/panels/config/cloud/types.ts +++ b/src/panels/config/cloud/types.ts @@ -1,4 +1,4 @@ -interface EntityFilter { +export interface EntityFilter { include_domains: string[]; include_entities: string[]; exclude_domains: string[]; @@ -13,8 +13,10 @@ export type CloudStatusLoggedIn = CloudStatusBase & { email: string; google_enabled: boolean; google_entities: EntityFilter; + google_domains: string[]; alexa_enabled: boolean; alexa_entities: EntityFilter; + alexa_domains: string[]; }; export type CloudStatus = CloudStatusBase | CloudStatusLoggedIn; diff --git a/test-mocha/common/entity/entity_filter.ts b/test-mocha/common/entity/entity_filter.ts new file mode 100644 index 0000000000..d236a78037 --- /dev/null +++ b/test-mocha/common/entity/entity_filter.ts @@ -0,0 +1,97 @@ +import { generateFilter } from "../../../src/common/entity/entity_filter"; + +import * as assert from "assert"; + +describe("EntityFilter", () => { + // case 1 + it("passes all when no filters passed in", () => { + const filter = generateFilter(); + + assert(filter("sensor.test")); + assert(filter("sun.sun")); + assert(filter("light.test")); + }); + + // case 2 + it("allows whitelisting entities by entity id", () => { + const filter = generateFilter(undefined, ["light.kitchen"]); + + assert(filter("light.kitchen")); + assert(!filter("light.living_room")); + }); + + it("allows whitelisting entities by domain", () => { + const filter = generateFilter(["switch"]); + + assert(filter("switch.bla")); + assert(!filter("light.kitchen")); + }); + + // case 3 + it("allows blacklisting entities by entity id", () => { + const filter = generateFilter(undefined, undefined, undefined, [ + "light.kitchen", + ]); + + assert(!filter("light.kitchen")); + assert(filter("light.living_room")); + }); + + it("allows blacklisting entities by domain", () => { + const filter = generateFilter(undefined, undefined, ["switch"]); + + assert(!filter("switch.bla")); + assert(filter("light.kitchen")); + }); + + // case 4a + it("allows whitelisting domain and blacklisting entity", () => { + const filter = generateFilter(["switch"], undefined, undefined, [ + "switch.kitchen", + ]); + + assert(filter("switch.living_room")); + assert(!filter("switch.kitchen")); + assert(!filter("sensor.bla")); + }); + + it("allows whitelisting entity while whitelisting other domains", () => { + const filter = generateFilter(["switch"], ["light.kitchen"]); + + assert(filter("switch.living_room")); + assert(filter("light.kitchen")); + assert(!filter("sensor.bla")); + }); + + // case 4b + it("allows blacklisting domain and whitelisting entity", () => { + const filter = generateFilter(undefined, ["switch.kitchen"], ["switch"]); + + assert(filter("switch.kitchen")); + assert(!filter("switch.living_room")); + assert(filter("sensor.bla")); + }); + + it("allows blacklisting domain and excluding entities", () => { + const filter = generateFilter( + undefined, + undefined, + ["switch"], + ["light.kitchen"] + ); + + assert(!filter("switch.living_room")); + assert(!filter("light.kitchen")); + assert(filter("sensor.bla")); + }); + + // case 4c + it("allows whitelisting entities", () => { + const filter = generateFilter(undefined, ["light.kitchen"]); + + assert(filter("light.kitchen")); + assert(!filter("switch.living_room")); + assert(!filter("light.living_room")); + assert(!filter("sensor.bla")); + }); +});