Allow exposing domains in cloud (#6696)

* Allow exposing domains in cloud

https://github.com/home-assistant/core/pull/39216

* Update styles

* Lint

* Apply suggestions from code review

Co-authored-by: Joakim Sørensen <joasoe@gmail.com>

* Comments

* Add translations

* Apply suggestions from code review

Co-authored-by: Joakim Sørensen <joasoe@gmail.com>

Co-authored-by: Joakim Sørensen <joasoe@gmail.com>
This commit is contained in:
Bram Kragten 2020-09-01 10:23:59 +02:00 committed by GitHub
parent 994a397231
commit 5292119e6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 551 additions and 243 deletions

View File

@ -9,14 +9,14 @@ interface CloudStatusBase {
} }
export interface GoogleEntityConfig { export interface GoogleEntityConfig {
should_expose?: boolean; should_expose?: boolean | null;
override_name?: string; override_name?: string;
aliases?: string[]; aliases?: string[];
disable_2fa?: boolean; disable_2fa?: boolean;
} }
export interface AlexaEntityConfig { export interface AlexaEntityConfig {
should_expose?: boolean; should_expose?: boolean | null;
} }
export interface CertificateInformation { export interface CertificateInformation {
@ -31,9 +31,11 @@ export interface CloudPreferences {
remote_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_default_expose: string[] | null;
google_entity_configs: { google_entity_configs: {
[entityId: string]: GoogleEntityConfig; [entityId: string]: GoogleEntityConfig;
}; };
alexa_default_expose: string[] | null;
alexa_entity_configs: { alexa_entity_configs: {
[entityId: string]: AlexaEntityConfig; [entityId: string]: AlexaEntityConfig;
}; };
@ -106,8 +108,10 @@ export const updateCloudPref = (
prefs: { prefs: {
google_enabled?: CloudPreferences["google_enabled"]; google_enabled?: CloudPreferences["google_enabled"];
alexa_enabled?: CloudPreferences["alexa_enabled"]; alexa_enabled?: CloudPreferences["alexa_enabled"];
alexa_default_expose?: CloudPreferences["alexa_default_expose"];
alexa_report_state?: CloudPreferences["alexa_report_state"]; alexa_report_state?: CloudPreferences["alexa_report_state"];
google_report_state?: CloudPreferences["google_report_state"]; google_report_state?: CloudPreferences["google_report_state"];
google_default_expose?: CloudPreferences["google_default_expose"];
google_secure_devices_pin?: CloudPreferences["google_secure_devices_pin"]; google_secure_devices_pin?: CloudPreferences["google_secure_devices_pin"];
} }
) => ) =>

View File

@ -4,27 +4,35 @@ import {
CSSResultArray, CSSResultArray,
customElement, customElement,
html, html,
LitElement,
internalProperty, internalProperty,
LitElement,
TemplateResult, TemplateResult,
} from "lit-element"; } from "lit-element";
import "../../components/dialog/ha-paper-dialog"; import { fireEvent } from "../../common/dom/fire_event";
import { createCloseHeading } from "../../components/ha-dialog";
import "../../components/ha-switch";
import "../../components/ha-formfield";
import { domainToName } from "../../data/integration"; import { domainToName } from "../../data/integration";
import { PolymerChangedEvent } from "../../polymer-types";
import { haStyleDialog } from "../../resources/styles"; import { haStyleDialog } from "../../resources/styles";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
import { HassDialog } from "../make-dialog-manager";
import { HaDomainTogglerDialogParams } from "./show-dialog-domain-toggler"; import { HaDomainTogglerDialogParams } from "./show-dialog-domain-toggler";
@customElement("dialog-domain-toggler") @customElement("dialog-domain-toggler")
class DomainTogglerDialog extends LitElement { class DomainTogglerDialog extends LitElement implements HassDialog {
public hass!: HomeAssistant; public hass!: HomeAssistant;
@internalProperty() private _params?: HaDomainTogglerDialogParams; @internalProperty() private _params?: HaDomainTogglerDialogParams;
public async showDialog(params: HaDomainTogglerDialogParams): Promise<void> { public showDialog(params: HaDomainTogglerDialogParams): void {
this._params = params; this._params = params;
} }
public closeDialog() {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render(): TemplateResult { protected render(): TemplateResult {
if (!this._params) { if (!this._params) {
return html``; return html``;
@ -35,46 +43,47 @@ class DomainTogglerDialog extends LitElement {
.sort(); .sort();
return html` return html`
<ha-paper-dialog <ha-dialog
with-backdrop open
opened @closed=${this.closeDialog}
@opened-changed=${this._openedChanged} scrimClickAction
escapeKeyAction
hideActions
.heading=${createCloseHeading(
this.hass,
this.hass.localize("ui.dialogs.domain_toggler.title")
)}
> >
<h2>
${this.hass.localize("ui.dialogs.domain_toggler.title")}
</h2>
<div> <div>
${domains.map( ${domains.map(
(domain) => (domain) =>
html` html`
<div>${domain[0]}</div> <ha-formfield .label=${domain[0]}>
<mwc-button .domain=${domain[1]} @click=${this._handleOff}> <ha-switch
${this.hass.localize("state.default.off")} .domain=${domain[1]}
</mwc-button> .checked=${!this._params!.exposedDomains ||
<mwc-button .domain=${domain[1]} @click=${this._handleOn}> this._params!.exposedDomains.includes(domain[1])}
${this.hass.localize("state.default.on")} @change=${this._handleSwitch}
>
</ha-switch>
</ha-formfield>
<mwc-button .domain=${domain[1]} @click=${this._handleReset}>
${this.hass.localize("ui.dialogs.domain_toggler.reset_entities")}
</mwc-button> </mwc-button>
` `
)} )}
</div> </div>
</ha-paper-dialog> </ha-dialog>
`; `;
} }
private _openedChanged(ev: PolymerChangedEvent<boolean>): void { private _handleSwitch(ev) {
// Closed dialog by clicking on the overlay this._params!.toggleDomain(ev.currentTarget.domain, ev.target.checked);
if (!ev.detail.value) {
this._params = undefined;
}
}
private _handleOff(ev) {
this._params!.toggleDomain(ev.currentTarget.domain, false);
ev.currentTarget.blur(); ev.currentTarget.blur();
} }
private _handleOn(ev) { private _handleReset(ev) {
this._params!.toggleDomain(ev.currentTarget.domain, true); this._params!.resetDomain(ev.currentTarget.domain);
ev.currentTarget.blur(); ev.currentTarget.blur();
} }
@ -82,8 +91,8 @@ class DomainTogglerDialog extends LitElement {
return [ return [
haStyleDialog, haStyleDialog,
css` css`
ha-paper-dialog { ha-dialog {
max-width: 500px; --mdc-dialog-max-width: 500px;
} }
div { div {
display: grid; display: grid;

View File

@ -2,7 +2,9 @@ import { fireEvent } from "../../common/dom/fire_event";
export interface HaDomainTogglerDialogParams { export interface HaDomainTogglerDialogParams {
domains: string[]; domains: string[];
exposedDomains: string[] | null;
toggleDomain: (domain: string, turnOn: boolean) => void; toggleDomain: (domain: string, turnOn: boolean) => void;
resetDomain: (domain: string) => void;
} }
export const loadDomainTogglerDialog = () => export const loadDomainTogglerDialog = () =>

View File

@ -1,14 +1,22 @@
import "../../../../components/ha-icon-button"; import { ActionDetail } from "@material/mwc-list/mwc-list-foundation";
import "@material/mwc-list/mwc-list-item";
import {
mdiCheckboxMarked,
mdiCheckboxMultipleMarked,
mdiCloseBox,
mdiCloseBoxMultiple,
} from "@mdi/js";
import { import {
css, css,
CSSResult, CSSResult,
customElement, customElement,
html, html,
internalProperty,
LitElement, LitElement,
property, property,
internalProperty,
TemplateResult, TemplateResult,
} from "lit-element"; } from "lit-element";
import { classMap } from "lit-html/directives/class-map";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event";
import { computeDomain } from "../../../../common/entity/compute_domain"; import { computeDomain } from "../../../../common/entity/compute_domain";
@ -20,31 +28,28 @@ import {
} from "../../../../common/entity/entity_filter"; } from "../../../../common/entity/entity_filter";
import { compare } from "../../../../common/string/compare"; import { compare } from "../../../../common/string/compare";
import "../../../../components/entity/state-info"; import "../../../../components/entity/state-info";
import "../../../../components/ha-button-menu";
import "../../../../components/ha-card"; import "../../../../components/ha-card";
import "../../../../components/ha-formfield";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-switch"; import "../../../../components/ha-switch";
import type { HaSwitch } from "../../../../components/ha-switch";
import { AlexaEntity, fetchCloudAlexaEntities } from "../../../../data/alexa"; import { AlexaEntity, fetchCloudAlexaEntities } from "../../../../data/alexa";
import { import {
AlexaEntityConfig, AlexaEntityConfig,
CloudPreferences, CloudPreferences,
CloudStatusLoggedIn, CloudStatusLoggedIn,
updateCloudAlexaEntityConfig, updateCloudAlexaEntityConfig,
updateCloudPref,
} from "../../../../data/cloud"; } from "../../../../data/cloud";
import { showDomainTogglerDialog } from "../../../../dialogs/domain-toggler/show-dialog-domain-toggler"; import { showDomainTogglerDialog } from "../../../../dialogs/domain-toggler/show-dialog-domain-toggler";
import "../../../../layouts/hass-loading-screen"; import "../../../../layouts/hass-loading-screen";
import "../../../../layouts/hass-subpage"; import "../../../../layouts/hass-subpage";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import "../../../../components/ha-formfield";
import { computeRTLDirection } from "../../../../common/util/compute_rtl";
const DEFAULT_CONFIG_EXPOSE = true; const DEFAULT_CONFIG_EXPOSE = true;
const IGNORE_INTERFACES = ["Alexa.EndpointHealth"]; const IGNORE_INTERFACES = ["Alexa.EndpointHealth"];
const configIsExposed = (config: AlexaEntityConfig) =>
config.should_expose === undefined
? DEFAULT_CONFIG_EXPOSE
: config.should_expose;
@customElement("cloud-alexa") @customElement("cloud-alexa")
class CloudAlexa extends LitElement { class CloudAlexa extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@ -100,7 +105,10 @@ class CloudAlexa extends LitElement {
const stateObj = this.hass.states[entity.entity_id]; const stateObj = this.hass.states[entity.entity_id];
const config = this._entityConfigs[entity.entity_id] || {}; const config = this._entityConfigs[entity.entity_id] || {};
const isExposed = emptyFilter const isExposed = emptyFilter
? configIsExposed(config) ? this._configIsExposed(entity.entity_id, config)
: filterFunc(entity.entity_id);
const isDomainExposed = emptyFilter
? this._configIsDomainExposed(entity.entity_id)
: filterFunc(entity.entity_id); : filterFunc(entity.entity_id);
if (isExposed) { if (isExposed) {
selected++; selected++;
@ -117,33 +125,80 @@ class CloudAlexa extends LitElement {
target.push(html` target.push(html`
<ha-card> <ha-card>
<div class="card-content"> <div class="card-content">
<state-info <div class="top-line">
.hass=${this.hass} <state-info
.stateObj=${stateObj} .hass=${this.hass}
secondary-line .stateObj=${stateObj}
@click=${this._showMoreInfo} secondary-line
> @click=${this._showMoreInfo}
${entity.interfaces
.filter((ifc) => !IGNORE_INTERFACES.includes(ifc))
.map((ifc) =>
ifc.replace("Alexa.", "").replace("Controller", "")
)
.join(", ")}
</state-info>
<ha-formfield
.label=${this.hass!.localize(
"ui.panel.config.cloud.alexa.expose"
)}
.dir=${computeRTLDirection(this.hass!)}
>
<ha-switch
.entityId=${entity.entity_id}
.disabled=${!emptyFilter}
.checked=${isExposed}
@change=${this._exposeChanged}
> >
</ha-switch> ${entity.interfaces
</ha-formfield> .filter((ifc) => !IGNORE_INTERFACES.includes(ifc))
.map((ifc) => ifc.replace(/(Alexa.|Controller)/g, ""))
.join(", ")}
</state-info>
<ha-button-menu
corner="BOTTOM_START"
.entityId=${stateObj.entity_id}
@action=${this._exposeChanged}
>
<mwc-icon-button
slot="trigger"
class=${classMap({
exposed: isExposed!,
"not-exposed": !isExposed,
})}
.title=${this.hass!.localize(
"ui.panel.config.cloud.alexa.expose"
)}
>
<ha-svg-icon
.path=${config.should_expose !== null
? isExposed
? mdiCheckboxMarked
: mdiCloseBox
: isDomainExposed
? mdiCheckboxMultipleMarked
: mdiCloseBoxMultiple}
></ha-svg-icon>
</mwc-icon-button>
<mwc-list-item hasMeta>
${this.hass!.localize(
"ui.panel.config.cloud.alexa.expose_entity"
)}
<ha-svg-icon
class="exposed"
slot="meta"
.path=${mdiCheckboxMarked}
></ha-svg-icon>
</mwc-list-item>
<mwc-list-item hasMeta>
${this.hass!.localize(
"ui.panel.config.cloud.alexa.dont_expose_entity"
)}
<ha-svg-icon
class="not-exposed"
slot="meta"
.path=${mdiCloseBox}
></ha-svg-icon>
</mwc-list-item>
<mwc-list-item hasMeta>
${this.hass!.localize(
"ui.panel.config.cloud.alexa.follow_domain"
)}
<ha-svg-icon
class=${classMap({
exposed: isDomainExposed,
"not-exposed": !isDomainExposed,
})}
slot="meta"
.path=${isDomainExposed
? mdiCheckboxMultipleMarked
: mdiCloseBoxMultiple}
></ha-svg-icon>
</mwc-list-item>
</ha-button-menu>
</div>
</div> </div>
</ha-card> </ha-card>
`); `);
@ -157,17 +212,16 @@ class CloudAlexa extends LitElement {
<hass-subpage header="${this.hass!.localize( <hass-subpage header="${this.hass!.localize(
"ui.panel.config.cloud.alexa.title" "ui.panel.config.cloud.alexa.title"
)}"> )}">
<span slot="toolbar-icon">
${selected}${!this.narrow ? html` selected ` : ""}
</span>
${ ${
emptyFilter emptyFilter
? html` ? html`
<ha-icon-button <mwc-button
slot="toolbar-icon" slot="toolbar-icon"
icon="hass:tune"
@click=${this._openDomainToggler} @click=${this._openDomainToggler}
></ha-icon-button> >${this.hass!.localize(
"ui.panel.config.cloud.alexa.manage_domains"
)}</mwc-button
>
` `
: "" : ""
} }
@ -183,11 +237,20 @@ class CloudAlexa extends LitElement {
${ ${
exposedCards.length > 0 exposedCards.length > 0
? html` ? html`
<h1> <div class="header">
${this.hass!.localize( <h3>
"ui.panel.config.cloud.alexa.exposed_entities" ${this.hass!.localize(
)} "ui.panel.config.cloud.alexa.exposed_entities"
</h1> )}
</h3>
${!this.narrow
? this.hass!.localize(
"ui.panel.config.cloud.alexa.exposed",
"selected",
selected
)
: selected}
</div>
<div class="content">${exposedCards}</div> <div class="content">${exposedCards}</div>
` `
: "" : ""
@ -195,11 +258,20 @@ class CloudAlexa extends LitElement {
${ ${
notExposedCards.length > 0 notExposedCards.length > 0
? html` ? html`
<h1> <div class="header second">
${this.hass!.localize( <h3>
"ui.panel.config.cloud.alexa.not_exposed_entities" ${this.hass!.localize(
)} "ui.panel.config.cloud.alexa.not_exposed_entities"
</h1> )}
</h3>
${!this.narrow
? this.hass!.localize(
"ui.panel.config.cloud.alexa.not_exposed",
"selected",
this._entities.length - selected
)
: this._entities.length - selected}
</div>
<div class="content">${notExposedCards}</div> <div class="content">${notExposedCards}</div>
` `
: "" : ""
@ -239,17 +311,37 @@ class CloudAlexa extends LitElement {
fireEvent(this, "hass-more-info", { entityId }); fireEvent(this, "hass-more-info", { entityId });
} }
private async _exposeChanged(ev: Event) { private _configIsDomainExposed(entityId: string) {
const entityId = (ev.currentTarget as any).entityId; const domain = computeDomain(entityId);
const newExposed = (ev.target as HaSwitch).checked; return this.cloudStatus.prefs.alexa_default_expose
await this._updateExposed(entityId, newExposed); ? this.cloudStatus.prefs.alexa_default_expose.includes(domain)
: DEFAULT_CONFIG_EXPOSE;
} }
private async _updateExposed(entityId: string, newExposed: boolean) { private _configIsExposed(entityId: string, config: AlexaEntityConfig) {
const curExposed = configIsExposed(this._entityConfigs[entityId] || {}); return config.should_expose === null
if (newExposed === curExposed) { ? this._configIsDomainExposed(entityId)
return; : config.should_expose;
}
private async _exposeChanged(ev: CustomEvent<ActionDetail>) {
const entityId = (ev.currentTarget as any).entityId;
let newVal: boolean | null = null;
switch (ev.detail.index) {
case 0:
newVal = true;
break;
case 1:
newVal = false;
break;
case 2:
newVal = null;
break;
} }
await this._updateExposed(entityId, newVal);
}
private async _updateExposed(entityId: string, newExposed: boolean | null) {
await this._updateConfig(entityId, { await this._updateConfig(entityId, {
should_expose: newExposed, should_expose: newExposed,
}); });
@ -274,16 +366,46 @@ class CloudAlexa extends LitElement {
domains: this._entities!.map((entity) => domains: this._entities!.map((entity) =>
computeDomain(entity.entity_id) computeDomain(entity.entity_id)
).filter((value, idx, self) => self.indexOf(value) === idx), ).filter((value, idx, self) => self.indexOf(value) === idx),
toggleDomain: (domain, turnOn) => { exposedDomains: this.cloudStatus.prefs.alexa_default_expose,
toggleDomain: (domain, expose) => {
this._updateDomainExposed(domain, expose);
},
resetDomain: (domain) => {
this._entities!.forEach((entity) => { this._entities!.forEach((entity) => {
if (computeDomain(entity.entity_id) === domain) { if (computeDomain(entity.entity_id) === domain) {
this._updateExposed(entity.entity_id, turnOn); this._updateExposed(entity.entity_id, null);
} }
}); });
}, },
}); });
} }
private async _updateDomainExposed(domain: string, expose: boolean) {
const defaultExpose =
this.cloudStatus.prefs.alexa_default_expose ||
this._entities!.map((entity) => computeDomain(entity.entity_id)).filter(
(value, idx, self) => self.indexOf(value) === idx
);
if (
(expose && defaultExpose.includes(domain)) ||
(!expose && !defaultExpose.includes(domain))
) {
return;
}
if (expose) {
defaultExpose.push(domain);
} else {
defaultExpose.splice(defaultExpose.indexOf(domain), 1);
}
await updateCloudPref(this.hass!, {
alexa_default_expose: defaultExpose,
});
fireEvent(this, "ha-refresh-cloud-status");
}
private _ensureStatusReload() { private _ensureStatusReload() {
if (this._popstateReloadStatusAttached) { if (this._popstateReloadStatusAttached) {
return; return;
@ -306,61 +428,75 @@ class CloudAlexa extends LitElement {
this._popstateSyncAttached = true; this._popstateSyncAttached = true;
// Cache parent because by the time popstate happens, // Cache parent because by the time popstate happens,
// this element is detached // this element is detached
// const parent = this.parentElement!;
window.addEventListener( window.addEventListener(
"popstate", "popstate",
() => { () => {
// We don't have anything yet. // We don't have anything yet.
// showToast(parent, { message: "Synchronizing changes to Google." });
// cloudSyncGoogleAssistant(this.hass);
}, },
{ once: true } { once: true }
); );
} }
static get styles(): CSSResult { static get styles(): CSSResult[] {
return css` return [
.banner { haStyle,
color: var(--primary-text-color); css`
background-color: var( mwc-list-item > [slot="meta"] {
--ha-card-background, margin-left: 4px;
var(--card-background-color, white)
);
padding: 16px 8px;
text-align: center;
}
h1 {
color: var(--primary-text-color);
font-size: 24px;
letter-spacing: -0.012em;
margin-bottom: 0;
padding: 0 8px;
}
.content {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
grid-gap: 8px 8px;
padding: 8px;
}
ha-switch {
clear: both;
}
.card-content {
padding-bottom: 12px;
}
state-info {
cursor: pointer;
}
ha-switch {
padding: 8px 0;
}
@media all and (max-width: 450px) {
ha-card {
max-width: 100%;
} }
} .banner {
`; color: var(--primary-text-color);
background-color: var(
--ha-card-background,
var(--card-background-color, white)
);
padding: 16px 8px;
text-align: center;
}
.content {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
grid-gap: 8px 8px;
padding: 8px;
}
.card-content {
padding-bottom: 12px;
}
state-info {
cursor: pointer;
}
ha-switch {
padding: 8px 0;
}
.top-line {
display: flex;
align-items: center;
justify-content: space-between;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
border-bottom: 1px solid var(--divider-color);
background: var(--app-header-background-color);
}
.header.second {
border-top: 1px solid var(--divider-color);
}
.exposed {
color: var(--success-color);
}
.not-exposed {
color: var(--error-color);
}
@media all and (max-width: 450px) {
ha-card {
max-width: 100%;
}
}
`,
];
} }
} }

View File

@ -1,14 +1,22 @@
import "../../../../components/ha-icon-button"; import { ActionDetail } from "@material/mwc-list/mwc-list-foundation";
import "@material/mwc-list/mwc-list-item";
import {
mdiCheckboxMarked,
mdiCheckboxMultipleMarked,
mdiCloseBox,
mdiCloseBoxMultiple,
} from "@mdi/js";
import { import {
css, css,
CSSResult, CSSResult,
customElement, customElement,
html, html,
internalProperty,
LitElement, LitElement,
property, property,
internalProperty,
TemplateResult, TemplateResult,
} from "lit-element"; } from "lit-element";
import { classMap } from "lit-html/directives/class-map";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event";
import { computeDomain } from "../../../../common/entity/compute_domain"; import { computeDomain } from "../../../../common/entity/compute_domain";
@ -19,8 +27,12 @@ import {
isEmptyFilter, isEmptyFilter,
} from "../../../../common/entity/entity_filter"; } from "../../../../common/entity/entity_filter";
import { compare } from "../../../../common/string/compare"; import { compare } from "../../../../common/string/compare";
import { computeRTLDirection } from "../../../../common/util/compute_rtl";
import "../../../../components/entity/state-info"; import "../../../../components/entity/state-info";
import "../../../../components/ha-button-menu";
import "../../../../components/ha-card"; import "../../../../components/ha-card";
import "../../../../components/ha-formfield";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-switch"; import "../../../../components/ha-switch";
import type { HaSwitch } from "../../../../components/ha-switch"; import type { HaSwitch } from "../../../../components/ha-switch";
import { import {
@ -29,6 +41,7 @@ import {
cloudSyncGoogleAssistant, cloudSyncGoogleAssistant,
GoogleEntityConfig, GoogleEntityConfig,
updateCloudGoogleEntityConfig, updateCloudGoogleEntityConfig,
updateCloudPref,
} from "../../../../data/cloud"; } from "../../../../data/cloud";
import { import {
fetchCloudGoogleEntities, fetchCloudGoogleEntities,
@ -37,18 +50,12 @@ import {
import { showDomainTogglerDialog } from "../../../../dialogs/domain-toggler/show-dialog-domain-toggler"; import { showDomainTogglerDialog } from "../../../../dialogs/domain-toggler/show-dialog-domain-toggler";
import "../../../../layouts/hass-loading-screen"; import "../../../../layouts/hass-loading-screen";
import "../../../../layouts/hass-subpage"; import "../../../../layouts/hass-subpage";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import { showToast } from "../../../../util/toast"; import { showToast } from "../../../../util/toast";
import "../../../../components/ha-formfield";
import { computeRTLDirection } from "../../../../common/util/compute_rtl";
const DEFAULT_CONFIG_EXPOSE = true; const DEFAULT_CONFIG_EXPOSE = true;
const configIsExposed = (config: GoogleEntityConfig) =>
config.should_expose === undefined
? DEFAULT_CONFIG_EXPOSE
: config.should_expose;
@customElement("cloud-google-assistant") @customElement("cloud-google-assistant")
class CloudGoogleAssistant extends LitElement { class CloudGoogleAssistant extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@ -104,7 +111,10 @@ class CloudGoogleAssistant extends LitElement {
const stateObj = this.hass.states[entity.entity_id]; const stateObj = this.hass.states[entity.entity_id];
const config = this._entityConfigs[entity.entity_id] || {}; const config = this._entityConfigs[entity.entity_id] || {};
const isExposed = emptyFilter const isExposed = emptyFilter
? configIsExposed(config) ? this._configIsExposed(entity.entity_id, config)
: filterFunc(entity.entity_id);
const isDomainExposed = emptyFilter
? this._configIsDomainExposed(entity.entity_id)
: filterFunc(entity.entity_id); : filterFunc(entity.entity_id);
if (isExposed) { if (isExposed) {
selected++; selected++;
@ -121,31 +131,78 @@ class CloudGoogleAssistant extends LitElement {
target.push(html` target.push(html`
<ha-card> <ha-card>
<div class="card-content"> <div class="card-content">
<state-info <div class="top-line">
.hass=${this.hass} <state-info
.stateObj=${stateObj} .hass=${this.hass}
secondary-line .stateObj=${stateObj}
@click=${this._showMoreInfo} secondary-line
> @click=${this._showMoreInfo}
${entity.traits
.map((trait) => trait.substr(trait.lastIndexOf(".") + 1))
.join(", ")}
</state-info>
<div>
<ha-formfield
.label=${this.hass!.localize(
"ui.panel.config.cloud.google.expose"
)}
.dir=${dir}
> >
<ha-switch ${entity.traits
.entityId=${entity.entity_id} .map((trait) => trait.substr(trait.lastIndexOf(".") + 1))
.disabled=${!emptyFilter} .join(", ")}
.checked=${isExposed} </state-info>
@change=${this._exposeChanged} <ha-button-menu
corner="BOTTOM_START"
.entityId=${stateObj.entity_id}
@action=${this._exposeChanged}
>
<mwc-icon-button
slot="trigger"
class=${classMap({
exposed: isExposed!,
"not-exposed": !isExposed,
})}
.title=${this.hass!.localize(
"ui.panel.config.cloud.google.expose"
)}
> >
</ha-switch> <ha-svg-icon
</ha-formfield> .path=${config.should_expose !== null
? isExposed
? mdiCheckboxMarked
: mdiCloseBox
: isDomainExposed
? mdiCheckboxMultipleMarked
: mdiCloseBoxMultiple}
></ha-svg-icon>
</mwc-icon-button>
<mwc-list-item hasMeta>
${this.hass!.localize(
"ui.panel.config.cloud.google.expose_entity"
)}
<ha-svg-icon
class="exposed"
slot="meta"
.path=${mdiCheckboxMarked}
></ha-svg-icon>
</mwc-list-item>
<mwc-list-item hasMeta>
${this.hass!.localize(
"ui.panel.config.cloud.google.dont_expose_entity"
)}
<ha-svg-icon
class="not-exposed"
slot="meta"
.path=${mdiCloseBox}
></ha-svg-icon>
</mwc-list-item>
<mwc-list-item hasMeta>
${this.hass!.localize(
"ui.panel.config.cloud.google.follow_domain"
)}
<ha-svg-icon
class=${classMap({
exposed: isDomainExposed,
"not-exposed": !isDomainExposed,
})}
slot="meta"
.path=${isDomainExposed
? mdiCheckboxMultipleMarked
: mdiCloseBoxMultiple}
></ha-svg-icon>
</mwc-list-item>
</ha-button-menu>
</div> </div>
${entity.might_2fa ${entity.might_2fa
? html` ? html`
@ -178,17 +235,16 @@ class CloudGoogleAssistant extends LitElement {
<hass-subpage header="${this.hass!.localize( <hass-subpage header="${this.hass!.localize(
"ui.panel.config.cloud.google.title" "ui.panel.config.cloud.google.title"
)}"> )}">
<span slot="toolbar-icon">
${selected}${!this.narrow ? html` selected ` : ""}
</span>
${ ${
emptyFilter emptyFilter
? html` ? html`
<ha-icon-button <mwc-button
slot="toolbar-icon" slot="toolbar-icon"
icon="hass:tune"
@click=${this._openDomainToggler} @click=${this._openDomainToggler}
></ha-icon-button> >${this.hass!.localize(
"ui.panel.config.cloud.google.manage_domains"
)}</mwc-button
>
` `
: "" : ""
} }
@ -204,11 +260,20 @@ class CloudGoogleAssistant extends LitElement {
${ ${
exposedCards.length > 0 exposedCards.length > 0
? html` ? html`
<h1> <div class="header">
${this.hass!.localize( <h3>
"ui.panel.config.cloud.google.exposed_entities" ${this.hass!.localize(
)} "ui.panel.config.cloud.google.exposed_entities"
</h1> )}
</h3>
${!this.narrow
? this.hass!.localize(
"ui.panel.config.cloud.alexa.exposed",
"selected",
selected
)
: selected}
</div>
<div class="content">${exposedCards}</div> <div class="content">${exposedCards}</div>
` `
: "" : ""
@ -216,11 +281,20 @@ class CloudGoogleAssistant extends LitElement {
${ ${
notExposedCards.length > 0 notExposedCards.length > 0
? html` ? html`
<h1> <div class="header second">
${this.hass!.localize( <h3>
"ui.panel.config.cloud.google.not_exposed_entities" ${this.hass!.localize(
)} "ui.panel.config.cloud.google.not_exposed_entities"
</h1> )}
</h3>
${!this.narrow
? this.hass!.localize(
"ui.panel.config.cloud.alexa.not_exposed",
"selected",
this._entities.length - selected
)
: this._entities.length - selected}
</div>
<div class="content">${notExposedCards}</div> <div class="content">${notExposedCards}</div>
` `
: "" : ""
@ -242,6 +316,19 @@ class CloudGoogleAssistant extends LitElement {
} }
} }
private _configIsDomainExposed(entityId: string) {
const domain = computeDomain(entityId);
return this.cloudStatus.prefs.google_default_expose
? this.cloudStatus.prefs.google_default_expose.includes(domain)
: DEFAULT_CONFIG_EXPOSE;
}
private _configIsExposed(entityId: string, config: GoogleEntityConfig) {
return config.should_expose === null
? this._configIsDomainExposed(entityId)
: config.should_expose;
}
private async _fetchData() { private async _fetchData() {
const entities = await fetchCloudGoogleEntities(this.hass); const entities = await fetchCloudGoogleEntities(this.hass);
entities.sort((a, b) => { entities.sort((a, b) => {
@ -260,17 +347,24 @@ class CloudGoogleAssistant extends LitElement {
fireEvent(this, "hass-more-info", { entityId }); fireEvent(this, "hass-more-info", { entityId });
} }
private async _exposeChanged(ev: Event) { private async _exposeChanged(ev: CustomEvent<ActionDetail>) {
const entityId = (ev.currentTarget as any).entityId; const entityId = (ev.currentTarget as any).entityId;
const newExposed = (ev.target as HaSwitch).checked; let newVal: boolean | null = null;
await this._updateExposed(entityId, newExposed); switch (ev.detail.index) {
case 0:
newVal = true;
break;
case 1:
newVal = false;
break;
case 2:
newVal = null;
break;
}
await this._updateExposed(entityId, newVal);
} }
private async _updateExposed(entityId: string, newExposed: boolean) { private async _updateExposed(entityId: string, newExposed: boolean | null) {
const curExposed = configIsExposed(this._entityConfigs[entityId] || {});
if (newExposed === curExposed) {
return;
}
await this._updateConfig(entityId, { await this._updateConfig(entityId, {
should_expose: newExposed, should_expose: newExposed,
}); });
@ -309,16 +403,46 @@ class CloudGoogleAssistant extends LitElement {
domains: this._entities!.map((entity) => domains: this._entities!.map((entity) =>
computeDomain(entity.entity_id) computeDomain(entity.entity_id)
).filter((value, idx, self) => self.indexOf(value) === idx), ).filter((value, idx, self) => self.indexOf(value) === idx),
toggleDomain: (domain, turnOn) => { exposedDomains: this.cloudStatus.prefs.google_default_expose,
toggleDomain: (domain, expose) => {
this._updateDomainExposed(domain, expose);
},
resetDomain: (domain) => {
this._entities!.forEach((entity) => { this._entities!.forEach((entity) => {
if (computeDomain(entity.entity_id) === domain) { if (computeDomain(entity.entity_id) === domain) {
this._updateExposed(entity.entity_id, turnOn); this._updateExposed(entity.entity_id, null);
} }
}); });
}, },
}); });
} }
private async _updateDomainExposed(domain: string, expose: boolean) {
const defaultExpose =
this.cloudStatus.prefs.google_default_expose ||
this._entities!.map((entity) => computeDomain(entity.entity_id)).filter(
(value, idx, self) => self.indexOf(value) === idx
);
if (
(expose && defaultExpose.includes(domain)) ||
(!expose && !defaultExpose.includes(domain))
) {
return;
}
if (expose) {
defaultExpose.push(domain);
} else {
defaultExpose.splice(defaultExpose.indexOf(domain), 1);
}
await updateCloudPref(this.hass!, {
google_default_expose: defaultExpose,
});
fireEvent(this, "ha-refresh-cloud-status");
}
private _ensureStatusReload() { private _ensureStatusReload() {
if (this._popstateReloadStatusAttached) { if (this._popstateReloadStatusAttached) {
return; return;
@ -356,46 +480,66 @@ class CloudGoogleAssistant extends LitElement {
); );
} }
static get styles(): CSSResult { static get styles(): CSSResult[] {
return css` return [
.banner { haStyle,
color: var(--primary-text-color); css`
background-color: var( mwc-list-item > [slot="meta"] {
--ha-card-background, margin-left: 4px;
var(--card-background-color, white)
);
padding: 16px 8px;
text-align: center;
}
h1 {
color: var(--primary-text-color);
font-size: 24px;
letter-spacing: -0.012em;
margin-bottom: 0;
padding: 0 8px;
}
.content {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
grid-gap: 8px 8px;
padding: 8px;
}
.card-content {
padding-bottom: 12px;
}
state-info {
cursor: pointer;
}
ha-switch {
padding: 8px 0;
}
@media all and (max-width: 450px) {
ha-card {
max-width: 100%;
} }
} .banner {
`; color: var(--primary-text-color);
background-color: var(
--ha-card-background,
var(--card-background-color, white)
);
padding: 16px 8px;
text-align: center;
}
.content {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
grid-gap: 8px 8px;
padding: 8px;
}
.card-content {
padding-bottom: 12px;
}
state-info {
cursor: pointer;
}
ha-switch {
padding: 8px 0;
}
.top-line {
display: flex;
align-items: center;
justify-content: space-between;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
border-bottom: 1px solid var(--divider-color);
background: var(--app-header-background-color);
}
.header.second {
border-top: 1px solid var(--divider-color);
}
.exposed {
color: var(--success-color);
}
.not-exposed {
color: var(--error-color);
}
@media all and (max-width: 450px) {
ha-card {
max-width: 100%;
}
}
`,
];
} }
} }

View File

@ -526,7 +526,8 @@
} }
}, },
"domain_toggler": { "domain_toggler": {
"title": "Toggle Domains" "title": "Toggle Domains",
"reset_entities": "Reset Entities"
}, },
"mqtt_device_debug_info": { "mqtt_device_debug_info": {
"title": "{device} debug info", "title": "{device} debug info",
@ -1383,7 +1384,13 @@
"title": "Alexa", "title": "Alexa",
"banner": "Editing which entities are exposed via this UI is disabled because you have configured entity filters in configuration.yaml.", "banner": "Editing which entities are exposed via this UI is disabled because you have configured entity filters in configuration.yaml.",
"exposed_entities": "Exposed entities", "exposed_entities": "Exposed entities",
"not_exposed_entities": "Not Exposed entities", "not_exposed_entities": "Not exposed entities",
"manage_domains": "Manage domains",
"expose_entity": "Expose entity",
"dont_expose_entity": "Don't expose entity",
"follow_domain": "Follow domain",
"exposed": "{selected} exposed",
"not_exposed": "{selected} not exposed",
"expose": "Expose to Alexa" "expose": "Expose to Alexa"
}, },
"dialog_certificate": { "dialog_certificate": {
@ -1399,7 +1406,13 @@
"disable_2FA": "Disable two factor authentication", "disable_2FA": "Disable two factor authentication",
"banner": "Editing which entities are exposed via this UI is disabled because you have configured entity filters in configuration.yaml.", "banner": "Editing which entities are exposed via this UI is disabled because you have configured entity filters in configuration.yaml.",
"exposed_entities": "Exposed entities", "exposed_entities": "Exposed entities",
"not_exposed_entities": "Not Exposed entities", "not_exposed_entities": "Not exposed entities",
"manage_domains": "Manage domains",
"expose_entity": "Expose entity",
"dont_expose_entity": "Don't expose entity",
"follow_domain": "Follow domain",
"exposed": "{selected} exposed",
"not_exposed": "{selected} not exposed",
"sync_to_google": "Synchronizing changes to Google." "sync_to_google": "Synchronizing changes to Google."
}, },
"dialog_cloudhook": { "dialog_cloudhook": {