Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions src/common/entity/entity_filter.ts
Original file line number Diff line number Diff line change
@@ -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);
};
17 changes: 16 additions & 1 deletion src/panels/config/cloud/cloud-alexa-pref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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">
Expand All @@ -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>
`;
Expand Down
116 changes: 116 additions & 0 deletions src/panels/config/cloud/cloud-exposed-entities.ts
Original file line number Diff line number Diff line change
@@ -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);
19 changes: 17 additions & 2 deletions src/panels/config/cloud/cloud-google-pref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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">
Expand All @@ -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>
Expand Down
4 changes: 3 additions & 1 deletion src/panels/config/cloud/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
interface EntityFilter {
export interface EntityFilter {
include_domains: string[];
include_entities: string[];
exclude_domains: string[];
Expand All @@ -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;
Expand Down
97 changes: 97 additions & 0 deletions test-mocha/common/entity/entity_filter.ts
Original file line number Diff line number Diff line change
@@ -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"));
});
});