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
This commit is contained in:
Paulus Schoutsen 2018-11-06 14:22:59 +01:00 committed by GitHub
parent 54e43758d3
commit e2b9893b17
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 313 additions and 4 deletions

View File

@ -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);
};

View File

@ -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()}
<paper-card heading="Alexa">
<paper-toggle-button
.checked="${this.cloudStatus!.alexa_enabled}"
.checked="${enabled}"
@change="${this._toggleChanged}"
></paper-toggle-button>
<div class="card-content">
@ -43,6 +46,18 @@ export class CloudAlexaPref extends LitElement {
</li>
</ul>
<em>This integration requires an Alexa-enabled device like the Amazon Echo.</em>
${
enabled
? html`
<p>Exposed entities:</p>
<cloud-exposed-entities
.hass="${this.hass}"
.filter="${this.cloudStatus!.alexa_entities}"
.supportedDomains="${this.cloudStatus!.alexa_domains}"
></cloud-exposed-entities>
`
: ""
}
</div>
</paper-card>
`;

View File

@ -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`
<span>
<ha-state-icon
.stateObj='${stateInfo[1]}'
@click='${this._handleMoreInfo}'
></ha-state-icon>
<paper-tooltip
position="bottom"
>${stateInfo[0]}</paper-tooltip>
</span>
`
)}
`;
}
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`
<style>
ha-state-icon {
color: var(--primary-text-color);
cursor: pointer;
}
</style>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"cloud-exposed-entities": CloudExposedEntities;
}
}
customElements.define("cloud-exposed-entities", CloudExposedEntities);

View File

@ -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()}
<paper-card heading="Google Assistant">
<paper-toggle-button
.checked="${this.cloudStatus!.google_enabled}"
.checked="${enabled}"
@change="${this._toggleChanged}"
></paper-toggle-button>
<div class="card-content">
@ -46,11 +49,23 @@ export class CloudGooglePref extends LitElement {
</li>
</ul>
<em>This integration requires a Google Assistant-enabled device like the Google Home or Android phone.</em>
${
enabled
? html`
<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 class="card-actions">
<ha-call-api-button
.hass="${this.hass}"
.disabled="${!this.cloudStatus!.google_enabled}"
.disabled="${!enabled}"
path="cloud/google_actions/sync"
>Sync devices</ha-call-api-button>
</div>

View File

@ -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;

View File

@ -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"));
});
});