From 442f73b8c586210fa38994f3c51bcfb549747a08 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 12 Apr 2023 18:33:40 +0200 Subject: [PATCH] Add initial expose UI (#16138) Co-authored-by: Paul Bottein --- hassio/src/addon-store/hassio-addon-store.ts | 2 +- src/components/data-table/ha-data-table.ts | 11 +- src/components/ha-aliases-editor.ts | 129 ++++ src/data/cloud.ts | 40 +- src/data/google_assistant.ts | 9 + src/data/voice.ts | 45 ++ src/dialogs/aliases/dialog-aliases.ts | 77 +- .../domain-toggler/dialog-domain-toggler.ts | 117 --- .../show-dialog-domain-toggler.ts | 23 - .../ha-more-info-view-voice-assistants.ts | 49 ++ .../voice/show-view-voice-assistants.ts | 16 + src/dialogs/more-info/ha-more-info-dialog.ts | 7 +- .../config/blueprint/ha-blueprint-overview.ts | 2 +- .../config/cloud/account/cloud-account.ts | 21 +- .../config/cloud/account/cloud-alexa-pref.ts | 203 ----- .../config/cloud/account/cloud-google-pref.ts | 274 ------- src/panels/config/cloud/alexa/cloud-alexa.ts | 569 -------------- .../cloud-google-assistant.ts | 727 ------------------ src/panels/config/cloud/ha-config-cloud.ts | 8 - .../core/ha-config-system-navigation.ts | 4 - .../entities/entity-registry-settings.ts | 75 +- src/panels/config/ha-panel-config.ts | 15 + .../config/lovelace/ha-config-lovelace.ts | 3 +- .../voice-assistants/cloud-alexa-pref.ts | 272 +++++++ .../voice-assistants/cloud-google-pref.ts | 352 +++++++++ .../voice-assistants/dialog-expose-entity.ts | 218 ++++++ .../voice-assistants/dialog-voice-settings.ts | 82 ++ .../voice-assistants/entity-voice-settings.ts | 327 ++++++++ .../ha-config-voice-assistants-assistants.ts | 111 +++ .../ha-config-voice-assistants-expose.ts | 710 +++++++++++++++++ .../ha-config-voice-assistants.ts | 29 +- .../show-dialog-expose-entity.ts | 21 + .../show-dialog-voice-settings.ts | 18 + src/panels/my/ha-panel-my.ts | 3 + src/translations/en.json | 100 ++- 35 files changed, 2514 insertions(+), 2155 deletions(-) create mode 100644 src/components/ha-aliases-editor.ts create mode 100644 src/data/voice.ts delete mode 100644 src/dialogs/domain-toggler/dialog-domain-toggler.ts delete mode 100644 src/dialogs/domain-toggler/show-dialog-domain-toggler.ts create mode 100644 src/dialogs/more-info/components/voice/ha-more-info-view-voice-assistants.ts create mode 100644 src/dialogs/more-info/components/voice/show-view-voice-assistants.ts delete mode 100644 src/panels/config/cloud/account/cloud-alexa-pref.ts delete mode 100644 src/panels/config/cloud/account/cloud-google-pref.ts delete mode 100644 src/panels/config/cloud/alexa/cloud-alexa.ts delete mode 100644 src/panels/config/cloud/google-assistant/cloud-google-assistant.ts create mode 100644 src/panels/config/voice-assistants/cloud-alexa-pref.ts create mode 100644 src/panels/config/voice-assistants/cloud-google-pref.ts create mode 100644 src/panels/config/voice-assistants/dialog-expose-entity.ts create mode 100644 src/panels/config/voice-assistants/dialog-voice-settings.ts create mode 100644 src/panels/config/voice-assistants/entity-voice-settings.ts create mode 100644 src/panels/config/voice-assistants/ha-config-voice-assistants-assistants.ts create mode 100644 src/panels/config/voice-assistants/ha-config-voice-assistants-expose.ts create mode 100644 src/panels/config/voice-assistants/show-dialog-expose-entity.ts create mode 100644 src/panels/config/voice-assistants/show-dialog-voice-settings.ts diff --git a/hassio/src/addon-store/hassio-addon-store.ts b/hassio/src/addon-store/hassio-addon-store.ts index ab434eb8ca..f6fdb1d6cb 100644 --- a/hassio/src/addon-store/hassio-addon-store.ts +++ b/hassio/src/addon-store/hassio-addon-store.ts @@ -216,7 +216,7 @@ export class HassioAddonStore extends LitElement { }); } - private async _filterChanged(e) { + private _filterChanged(e) { this._filter = e.detail.value; } diff --git a/src/components/data-table/ha-data-table.ts b/src/components/data-table/ha-data-table.ts index f83244a3e4..518e554e4a 100644 --- a/src/components/data-table/ha-data-table.ts +++ b/src/components/data-table/ha-data-table.ts @@ -73,7 +73,7 @@ export interface DataTableColumnData extends DataTableSortColumnData { main?: boolean; title: TemplateResult | string; label?: TemplateResult | string; - type?: "numeric" | "icon" | "icon-button" | "overflow-menu"; + type?: "numeric" | "icon" | "icon-button" | "overflow-menu" | "flex"; template?: (data: any, row: T) => TemplateResult | string | typeof nothing; width?: string; maxWidth?: string; @@ -359,10 +359,10 @@ export class HaDataTable extends LitElement { return nothing; } if (row.append) { - return html`
${row.content}
`; + return html`
${row.content}
`; } if (row.empty) { - return html`
`; + return html`
`; } return html`
html` +
+ + +
+ ` + )} +
+ + ${this.hass!.localize("ui.dialogs.aliases.add_alias")} + + +
+ `; + } + + private async _addAlias() { + this.aliases = [...this.aliases, ""]; + this._fireChanged(this.aliases); + await this.updateComplete; + const field = this.shadowRoot?.querySelector(`ha-textfield[data-last]`) as + | HaTextField + | undefined; + field?.focus(); + } + + private async _editAlias(ev: Event) { + const index = (ev.target as any).index; + const aliases = [...this.aliases]; + aliases[index] = (ev.target as any).value; + this._fireChanged(aliases); + } + + private async _keyDownAlias(ev: KeyboardEvent) { + if (ev.key === "Enter") { + ev.stopPropagation(); + this._addAlias(); + } + } + + private async _removeAlias(ev: Event) { + const index = (ev.target as any).index; + const aliases = [...this.aliases]; + aliases.splice(index, 1); + this._fireChanged(aliases); + } + + private _fireChanged(value) { + fireEvent(this, "value-changed", { value }); + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + css` + .row { + margin-bottom: 8px; + } + ha-textfield { + display: block; + } + ha-icon-button { + display: block; + } + mwc-button { + margin-left: 8px; + } + #alias_input { + margin-top: 8px; + } + .alias { + border: 1px solid var(--divider-color); + border-radius: 4px; + margin-top: 4px; + --mdc-icon-button-size: 24px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-aliases-editor": AliasesEditor; + } +} diff --git a/src/data/cloud.ts b/src/data/cloud.ts index 58a747ffd0..353a5601a7 100644 --- a/src/data/cloud.ts +++ b/src/data/cloud.ts @@ -9,15 +9,6 @@ interface CloudStatusNotLoggedIn { http_use_ssl: boolean; } -export interface GoogleEntityConfig { - should_expose?: boolean | null; - disable_2fa?: boolean; -} - -export interface AlexaEntityConfig { - should_expose?: boolean | null; -} - export interface CertificateInformation { common_name: string; expire_date: string; @@ -30,14 +21,6 @@ export interface CloudPreferences { remote_enabled: boolean; google_secure_devices_pin: string | undefined; cloudhooks: { [webhookId: string]: CloudWebhook }; - google_default_expose: string[] | null; - google_entity_configs: { - [entityId: string]: GoogleEntityConfig; - }; - alexa_default_expose: string[] | null; - alexa_entity_configs: { - [entityId: string]: AlexaEntityConfig; - }; alexa_report_state: boolean; google_report_state: boolean; tts_default_voice: [string, string]; @@ -150,10 +133,8 @@ export const updateCloudPref = ( prefs: { google_enabled?: CloudPreferences["google_enabled"]; alexa_enabled?: CloudPreferences["alexa_enabled"]; - alexa_default_expose?: CloudPreferences["alexa_default_expose"]; alexa_report_state?: CloudPreferences["alexa_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"]; tts_default_voice?: CloudPreferences["tts_default_voice"]; } @@ -165,25 +146,14 @@ export const updateCloudPref = ( export const updateCloudGoogleEntityConfig = ( hass: HomeAssistant, - entityId: string, - values: GoogleEntityConfig + entity_id: string, + disable_2fa: boolean ) => - hass.callWS({ + hass.callWS({ type: "cloud/google_assistant/entities/update", - entity_id: entityId, - ...values, + entity_id, + disable_2fa, }); export const cloudSyncGoogleAssistant = (hass: HomeAssistant) => hass.callApi("POST", "cloud/google_actions/sync"); - -export const updateCloudAlexaEntityConfig = ( - hass: HomeAssistant, - entityId: string, - values: AlexaEntityConfig -) => - hass.callWS({ - type: "cloud/alexa/entities/update", - entity_id: entityId, - ...values, - }); diff --git a/src/data/google_assistant.ts b/src/data/google_assistant.ts index 2665be90b3..1381e697ed 100644 --- a/src/data/google_assistant.ts +++ b/src/data/google_assistant.ts @@ -9,5 +9,14 @@ export interface GoogleEntity { export const fetchCloudGoogleEntities = (hass: HomeAssistant) => hass.callWS({ type: "cloud/google_assistant/entities" }); +export const fetchCloudGoogleEntity = ( + hass: HomeAssistant, + entity_id: string +) => + hass.callWS({ + type: "cloud/google_assistant/entities/get", + entity_id, + }); + export const syncCloudGoogleEntities = (hass: HomeAssistant) => hass.callApi("POST", "cloud/google_actions/sync"); diff --git a/src/data/voice.ts b/src/data/voice.ts new file mode 100644 index 0000000000..e2d12bdf49 --- /dev/null +++ b/src/data/voice.ts @@ -0,0 +1,45 @@ +import { HomeAssistant } from "../types"; + +export const voiceAssistants = { + conversation: { domain: "conversation", name: "Assist" }, + "cloud.alexa": { + domain: "alexa", + name: "Amazon Alexa", + }, + "cloud.google_assistant": { + domain: "google_assistant", + name: "Google Assistant", + }, +} as const; + +export const voiceAssistantKeys = Object.keys(voiceAssistants); + +export const setExposeNewEntities = ( + hass: HomeAssistant, + assistant: string, + expose_new: boolean +) => + hass.callWS({ + type: "homeassistant/expose_new_entities/set", + assistant, + expose_new, + }); + +export const getExposeNewEntities = (hass: HomeAssistant, assistant: string) => + hass.callWS<{ expose_new: boolean }>({ + type: "homeassistant/expose_new_entities/get", + assistant, + }); + +export const exposeEntities = ( + hass: HomeAssistant, + assistants: string[], + entity_ids: string[], + should_expose: boolean +) => + hass.callWS({ + type: "homeassistant/expose_entity", + assistants, + entity_ids, + should_expose, + }); diff --git a/src/dialogs/aliases/dialog-aliases.ts b/src/dialogs/aliases/dialog-aliases.ts index 27e94660ec..e2fe281b5e 100644 --- a/src/dialogs/aliases/dialog-aliases.ts +++ b/src/dialogs/aliases/dialog-aliases.ts @@ -1,16 +1,13 @@ import "@material/mwc-button/mwc-button"; -import { mdiDeleteOutline, mdiPlus } from "@mdi/js"; -import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; +import { CSSResultGroup, LitElement, css, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { fireEvent } from "../../common/dom/fire_event"; import "../../components/ha-alert"; -import "../../components/ha-area-picker"; import "../../components/ha-dialog"; -import "../../components/ha-textfield"; -import type { HaTextField } from "../../components/ha-textfield"; import { haStyle, haStyleDialog } from "../../resources/styles"; import { HomeAssistant } from "../../types"; import { AliasesDialogParams } from "./show-dialog-aliases"; +import "../../components/ha-aliases-editor"; @customElement("dialog-aliases") class DialogAliases extends LitElement { @@ -57,43 +54,11 @@ class DialogAliases extends LitElement { ${this._error ? html`${this._error}` : ""} -
- ${this._aliases.map( - (alias, index) => html` -
- - -
- ` - )} -
- - ${this.hass!.localize("ui.dialogs.aliases.add_alias")} - - -
-
+
{ diff --git a/src/dialogs/domain-toggler/dialog-domain-toggler.ts b/src/dialogs/domain-toggler/dialog-domain-toggler.ts deleted file mode 100644 index 3b168d4002..0000000000 --- a/src/dialogs/domain-toggler/dialog-domain-toggler.ts +++ /dev/null @@ -1,117 +0,0 @@ -import "@material/mwc-button/mwc-button"; -import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; -import { customElement, state } from "lit/decorators"; -import { fireEvent } from "../../common/dom/fire_event"; -import { createCloseHeading } from "../../components/ha-dialog"; -import "../../components/ha-formfield"; -import "../../components/ha-switch"; -import { domainToName } from "../../data/integration"; -import { haStyleDialog } from "../../resources/styles"; -import { HomeAssistant } from "../../types"; -import { HassDialog } from "../make-dialog-manager"; -import { HaDomainTogglerDialogParams } from "./show-dialog-domain-toggler"; - -@customElement("dialog-domain-toggler") -class DomainTogglerDialog - extends LitElement - implements HassDialog -{ - public hass!: HomeAssistant; - - @state() private _params?: HaDomainTogglerDialogParams; - - public showDialog(params: HaDomainTogglerDialogParams): void { - this._params = params; - } - - public closeDialog() { - this._params = undefined; - fireEvent(this, "dialog-closed", { dialog: this.localName }); - } - - protected render() { - if (!this._params) { - return nothing; - } - - const domains = this._params.domains - .map((domain) => [domainToName(this.hass.localize, domain), domain]) - .sort(); - - return html` - - ${this._params.description - ? html`
${this._params.description}
` - : ""} -
- ${domains.map( - (domain) => - html` - - - - - - ${this.hass.localize( - "ui.dialogs.domain_toggler.reset_entities" - )} - - ` - )} -
-
- `; - } - - private _handleSwitch(ev) { - this._params!.toggleDomain(ev.currentTarget.domain, ev.target.checked); - ev.currentTarget.blur(); - } - - private _handleReset(ev) { - this._params!.resetDomain(ev.currentTarget.domain); - ev.currentTarget.blur(); - } - - static get styles(): CSSResultGroup { - return [ - haStyleDialog, - css` - ha-dialog { - --mdc-dialog-max-width: 500px; - } - .description { - margin-bottom: 8px; - } - .domains { - display: grid; - grid-template-columns: auto auto; - grid-row-gap: 8px; - align-items: center; - } - `, - ]; - } -} - -declare global { - interface HTMLElementTagNameMap { - "dialog-domain-toggler": DomainTogglerDialog; - } -} diff --git a/src/dialogs/domain-toggler/show-dialog-domain-toggler.ts b/src/dialogs/domain-toggler/show-dialog-domain-toggler.ts deleted file mode 100644 index 18426ba68e..0000000000 --- a/src/dialogs/domain-toggler/show-dialog-domain-toggler.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { fireEvent } from "../../common/dom/fire_event"; - -export interface HaDomainTogglerDialogParams { - title?: string; - description?: string; - domains: string[]; - exposedDomains: string[] | null; - toggleDomain: (domain: string, turnOn: boolean) => void; - resetDomain: (domain: string) => void; -} - -export const loadDomainTogglerDialog = () => import("./dialog-domain-toggler"); - -export const showDomainTogglerDialog = ( - element: HTMLElement, - dialogParams: HaDomainTogglerDialogParams -): void => { - fireEvent(element, "show-dialog", { - dialogTag: "dialog-domain-toggler", - dialogImport: loadDomainTogglerDialog, - dialogParams, - }); -}; diff --git a/src/dialogs/more-info/components/voice/ha-more-info-view-voice-assistants.ts b/src/dialogs/more-info/components/voice/ha-more-info-view-voice-assistants.ts new file mode 100644 index 0000000000..f43b7fc14e --- /dev/null +++ b/src/dialogs/more-info/components/voice/ha-more-info-view-voice-assistants.ts @@ -0,0 +1,49 @@ +import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; +import { customElement, property } from "lit/decorators"; +import { ExtEntityRegistryEntry } from "../../../../data/entity_registry"; +import "../../../../panels/config/voice-assistants/entity-voice-settings"; +import { HomeAssistant } from "../../../../types"; + +@customElement("ha-more-info-view-voice-assistants") +class MoreInfoViewVoiceAssistants extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public entry!: ExtEntityRegistryEntry; + + @property() public params?; + + protected render() { + if (!this.params) { + return nothing; + } + return html``; + } + + static get styles(): CSSResultGroup { + return [ + css` + :host { + display: flex; + flex-direction: column; + } + .content { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px; + flex: 1; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-more-info-view-voice-assistants": MoreInfoViewVoiceAssistants; + } +} diff --git a/src/dialogs/more-info/components/voice/show-view-voice-assistants.ts b/src/dialogs/more-info/components/voice/show-view-voice-assistants.ts new file mode 100644 index 0000000000..010bf20088 --- /dev/null +++ b/src/dialogs/more-info/components/voice/show-view-voice-assistants.ts @@ -0,0 +1,16 @@ +import { fireEvent } from "../../../../common/dom/fire_event"; + +export const loadVoiceAssistantsView = () => + import("./ha-more-info-view-voice-assistants"); + +export const showVoiceAssistantsView = ( + element: HTMLElement, + title: string +): void => { + fireEvent(element, "show-child-view", { + viewTag: "ha-more-info-view-voice-assistants", + viewImport: loadVoiceAssistantsView, + viewTitle: title, + viewParams: {}, + }); +}; diff --git a/src/dialogs/more-info/ha-more-info-dialog.ts b/src/dialogs/more-info/ha-more-info-dialog.ts index 3bcc18b883..984136adb5 100644 --- a/src/dialogs/more-info/ha-more-info-dialog.ts +++ b/src/dialogs/more-info/ha-more-info-dialog.ts @@ -181,10 +181,10 @@ export class MoreInfoDialog extends LitElement { this.setView("settings"); } - private async _showChildView(ev: CustomEvent): Promise { + private _showChildView(ev: CustomEvent): void { const view = ev.detail as ChildView; if (view.viewImport) { - await view.viewImport(); + view.viewImport(); } this._childView = view; } @@ -369,12 +369,14 @@ export class MoreInfoDialog extends LitElement { tabindex="-1" dialogInitialFocus @show-child-view=${this._showChildView} + @entity-entry-updated=${this._entryUpdated} > ${this._childView ? html`
${dynamicElement(this._childView.viewTag, { hass: this.hass, + entry: this._entry, params: this._childView.viewParams, })}
@@ -401,7 +403,6 @@ export class MoreInfoDialog extends LitElement { .hass=${this.hass} .entityId=${this._entityId} .entry=${this._entry} - @entity-entry-updated=${this._entryUpdated} > ` : this._currView === "related" diff --git a/src/panels/config/blueprint/ha-blueprint-overview.ts b/src/panels/config/blueprint/ha-blueprint-overview.ts index 885a06a343..2436d5b039 100644 --- a/src/panels/config/blueprint/ha-blueprint-overview.ts +++ b/src/panels/config/blueprint/ha-blueprint-overview.ts @@ -196,7 +196,7 @@ class HaBlueprintOverview extends LitElement { template: (_, blueprint: any) => blueprint.error ? "" - : html` - - - + + + ${this.hass.localize( + "ui.panel.config.cloud.account.tip_moved_voice_assistants" + )} + + -
- - - - -
-
-

- ${this.hass!.localize("ui.panel.config.cloud.account.alexa.info")} -

- ${!alexa_enabled - ? "" - : !alexa_registered - ? html` - - ${this.hass.localize( - "ui.panel.config.cloud.account.alexa.not_configured_text" - )} - - - - ` - : html` - - - ${this.hass!.localize( - "ui.panel.config.cloud.account.alexa.enable_state_reporting" - )} - - - ${this.hass!.localize( - "ui.panel.config.cloud.account.alexa.info_state_reporting" - )} - - - - `} -
- - - `; - } - - private async _enabledToggleChanged(ev) { - const toggle = ev.target as HaSwitch; - try { - await updateCloudPref(this.hass!, { alexa_enabled: toggle.checked! }); - fireEvent(this, "ha-refresh-cloud-status"); - } catch (err: any) { - toggle.checked = !toggle.checked; - } - } - - private async _reportToggleChanged(ev) { - const toggle = ev.target as HaSwitch; - try { - await updateCloudPref(this.hass!, { - alexa_report_state: toggle.checked!, - }); - fireEvent(this, "ha-refresh-cloud-status"); - } catch (err: any) { - alert( - `${this.hass!.localize( - "ui.panel.config.cloud.account.alexa.state_reporting_error", - "enable_disable", - this.hass!.localize( - toggle.checked - ? "ui.panel.config.cloud.account.alexa.enable" - : "ui.panel.config.cloud.account.alexa.disable" - ) - )} ${err.message}` - ); - toggle.checked = !toggle.checked; - } - } - - static get styles(): CSSResultGroup { - return css` - a { - color: var(--primary-color); - } - ha-settings-row { - padding: 0; - } - .header-actions { - position: absolute; - right: 24px; - top: 24px; - display: flex; - flex-direction: row; - } - :host([dir="rtl"]) .header-actions { - right: auto; - left: 24px; - } - .header-actions .icon-link { - margin-top: -16px; - margin-inline-end: 8px; - margin-right: 8px; - direction: var(--direction); - color: var(--secondary-text-color); - } - .card-actions { - display: flex; - } - .card-actions a { - text-decoration: none; - } - `; - } -} - -declare global { - interface HTMLElementTagNameMap { - "cloud-alexa-pref": CloudAlexaPref; - } -} - -customElements.define("cloud-alexa-pref", CloudAlexaPref); diff --git a/src/panels/config/cloud/account/cloud-google-pref.ts b/src/panels/config/cloud/account/cloud-google-pref.ts deleted file mode 100644 index 675e432eb8..0000000000 --- a/src/panels/config/cloud/account/cloud-google-pref.ts +++ /dev/null @@ -1,274 +0,0 @@ -import "@material/mwc-button"; -import { mdiHelpCircle } from "@mdi/js"; -import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; -import { property } from "lit/decorators"; -import { fireEvent } from "../../../../common/dom/fire_event"; -import "../../../../components/ha-alert"; -import "../../../../components/ha-card"; -import "../../../../components/ha-settings-row"; -import type { HaSwitch } from "../../../../components/ha-switch"; -import "../../../../components/ha-textfield"; -import type { HaTextField } from "../../../../components/ha-textfield"; -import { CloudStatusLoggedIn, updateCloudPref } from "../../../../data/cloud"; -import type { HomeAssistant } from "../../../../types"; -import { showSaveSuccessToast } from "../../../../util/toast-saved-success"; - -export class CloudGooglePref extends LitElement { - @property({ attribute: false }) public hass!: HomeAssistant; - - @property({ attribute: false }) public cloudStatus?: CloudStatusLoggedIn; - - protected render() { - if (!this.cloudStatus) { - return nothing; - } - - const google_registered = this.cloudStatus.google_registered; - const { google_enabled, google_report_state, google_secure_devices_pin } = - this.cloudStatus.prefs; - - return html` - -
- - - - -
-
-

- ${this.hass.localize("ui.panel.config.cloud.account.google.info")} -

- ${!google_enabled - ? "" - : !google_registered - ? html` - - ${this.hass.localize( - "ui.panel.config.cloud.account.google.not_configured_text" - )} - - - - ` - : html` - ${this.cloudStatus.http_use_ssl - ? html` - - ${this.hass.localize( - "ui.panel.config.cloud.account.google.http_use_ssl_warning_text" - )} - ${this.hass.localize( - "ui.panel.config.common.learn_more" - )} - - ` - : ""} - - - - ${this.hass!.localize( - "ui.panel.config.cloud.account.google.enable_state_reporting" - )} - - - ${this.hass!.localize( - "ui.panel.config.cloud.account.google.info_state_reporting" - )} - - - - - - - ${this.hass.localize( - "ui.panel.config.cloud.account.google.security_devices" - )} - - - ${this.hass.localize( - "ui.panel.config.cloud.account.google.enter_pin_info" - )} - - - - - `} -
- -
- `; - } - - private async _enabledToggleChanged(ev) { - const toggle = ev.target as HaSwitch; - try { - await updateCloudPref(this.hass, { google_enabled: toggle.checked! }); - fireEvent(this, "ha-refresh-cloud-status"); - } catch (err: any) { - toggle.checked = !toggle.checked; - } - } - - private async _reportToggleChanged(ev) { - const toggle = ev.target as HaSwitch; - try { - await updateCloudPref(this.hass, { - google_report_state: toggle.checked!, - }); - fireEvent(this, "ha-refresh-cloud-status"); - } catch (err: any) { - alert( - `Unable to ${toggle.checked ? "enable" : "disable"} report state. ${ - err.message - }` - ); - toggle.checked = !toggle.checked; - } - } - - private async _pinChanged(ev) { - const input = ev.target as HaTextField; - try { - await updateCloudPref(this.hass, { - [input.id]: input.value || null, - }); - showSaveSuccessToast(this, this.hass); - fireEvent(this, "ha-refresh-cloud-status"); - } catch (err: any) { - alert( - `${this.hass.localize( - "ui.panel.config.cloud.account.google.enter_pin_error" - )} ${err.message}` - ); - input.value = this.cloudStatus!.prefs.google_secure_devices_pin || ""; - } - } - - static get styles(): CSSResultGroup { - return css` - a { - color: var(--primary-color); - } - .header-actions { - position: absolute; - right: 24px; - top: 24px; - display: flex; - flex-direction: row; - } - :host([dir="rtl"]) .header-actions { - right: auto; - left: 24px; - } - .header-actions .icon-link { - margin-top: -16px; - margin-inline-end: 8px; - margin-right: 8px; - direction: var(--direction); - color: var(--secondary-text-color); - } - ha-settings-row { - padding: 0; - } - ha-textfield { - width: 250px; - display: block; - margin-top: 8px; - } - .card-actions { - display: flex; - } - .card-actions a { - text-decoration: none; - } - .warning { - color: var(--error-color); - } - `; - } -} - -declare global { - interface HTMLElementTagNameMap { - "cloud-google-pref": CloudGooglePref; - } -} - -customElements.define("cloud-google-pref", CloudGooglePref); diff --git a/src/panels/config/cloud/alexa/cloud-alexa.ts b/src/panels/config/cloud/alexa/cloud-alexa.ts deleted file mode 100644 index 6c8dbda913..0000000000 --- a/src/panels/config/cloud/alexa/cloud-alexa.ts +++ /dev/null @@ -1,569 +0,0 @@ -import { ActionDetail } from "@material/mwc-list/mwc-list-foundation"; -import "@material/mwc-list/mwc-list-item"; -import { - mdiCheckboxMarked, - mdiCheckboxMultipleMarked, - mdiCloseBox, - mdiCloseBoxMultiple, - mdiDotsVertical, - mdiFormatListChecks, - mdiSync, -} from "@mdi/js"; -import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; -import { customElement, property, state } from "lit/decorators"; -import { classMap } from "lit/directives/class-map"; -import memoizeOne from "memoize-one"; -import { fireEvent } from "../../../../common/dom/fire_event"; -import { computeDomain } from "../../../../common/entity/compute_domain"; -import { computeStateName } from "../../../../common/entity/compute_state_name"; -import { - EntityFilter, - generateFilter, - isEmptyFilter, -} from "../../../../common/entity/entity_filter"; -import { stringCompare } from "../../../../common/string/compare"; -import "../../../../components/entity/state-info"; -import "../../../../components/ha-button-menu"; -import "../../../../components/ha-card"; -import "../../../../components/ha-formfield"; -import "../../../../components/ha-icon-button"; -import "../../../../components/ha-switch"; -import { - AlexaEntity, - fetchCloudAlexaEntities, - syncCloudAlexaEntities, -} from "../../../../data/alexa"; -import { - AlexaEntityConfig, - CloudPreferences, - CloudStatusLoggedIn, - updateCloudAlexaEntityConfig, - updateCloudPref, -} from "../../../../data/cloud"; -import { EntityRegistryEntry } from "../../../../data/entity_registry"; -import { showDomainTogglerDialog } from "../../../../dialogs/domain-toggler/show-dialog-domain-toggler"; -import "../../../../layouts/hass-loading-screen"; -import "../../../../layouts/hass-subpage"; -import { haStyle } from "../../../../resources/styles"; -import type { HomeAssistant } from "../../../../types"; - -const DEFAULT_CONFIG_EXPOSE = true; - -@customElement("cloud-alexa") -class CloudAlexa extends LitElement { - @property({ attribute: false }) public hass!: HomeAssistant; - - @property() - public cloudStatus!: CloudStatusLoggedIn; - - @property({ type: Boolean }) public narrow!: boolean; - - @state() private _entities?: AlexaEntity[]; - - @state() private _syncing = false; - - @state() - private _entityConfigs: CloudPreferences["alexa_entity_configs"] = {}; - - @state() - private _entityCategories?: Record< - string, - EntityRegistryEntry["entity_category"] - >; - - private _popstateSyncAttached = false; - - private _popstateReloadStatusAttached = false; - - private _isInitialExposed?: Set; - - private _getEntityFilterFunc = memoizeOne((filter: EntityFilter) => - generateFilter( - filter.include_domains, - filter.include_entities, - filter.exclude_domains, - filter.exclude_entities - ) - ); - - protected render(): TemplateResult { - if (this._entities === undefined || this._entityCategories === undefined) { - return html` `; - } - const emptyFilter = isEmptyFilter(this.cloudStatus.alexa_entities); - const filterFunc = this._getEntityFilterFunc( - this.cloudStatus.alexa_entities - ); - - // We will only generate `isInitialExposed` during first render. - // On each subsequent render we will use the same set so that cards - // will not jump around when we change the exposed setting. - const showInExposed = this._isInitialExposed || new Set(); - const trackExposed = this._isInitialExposed === undefined; - - let selected = 0; - - // On first render we decide which cards show in which category. - // That way cards won't jump around when changing values. - const exposedCards: TemplateResult[] = []; - const notExposedCards: TemplateResult[] = []; - - this._entities.forEach((entity) => { - const stateObj = this.hass.states[entity.entity_id]; - const config = this._entityConfigs[entity.entity_id] || { - should_expose: null, - }; - const isExposed = emptyFilter - ? this._configIsExposed( - entity.entity_id, - config, - this._entityCategories![entity.entity_id] - ) - : filterFunc(entity.entity_id); - const isDomainExposed = emptyFilter - ? this._configIsDomainExposed( - entity.entity_id, - this._entityCategories![entity.entity_id] - ) - : filterFunc(entity.entity_id); - if (isExposed) { - selected++; - - if (trackExposed) { - showInExposed.add(entity.entity_id); - } - } - - const target = showInExposed.has(entity.entity_id) - ? exposedCards - : notExposedCards; - - const iconButton = html``; - - target.push(html` - -
-
- - - ${!emptyFilter - ? html`${iconButton}` - : html` - ${iconButton} - - ${this.hass!.localize( - "ui.panel.config.cloud.alexa.expose_entity" - )} - - - - ${this.hass!.localize( - "ui.panel.config.cloud.alexa.dont_expose_entity" - )} - - - - ${this.hass!.localize( - "ui.panel.config.cloud.alexa.follow_domain" - )} - - - `} -
-
-
- `); - }); - - if (trackExposed) { - this._isInitialExposed = showInExposed; - } - - return html` - - - - - - ${this.hass.localize("ui.panel.config.cloud.alexa.manage_defaults")} - - - - - ${this.hass.localize("ui.panel.config.cloud.alexa.sync_entities")} - - - - ${!emptyFilter - ? html` - - ` - : ""} - ${exposedCards.length > 0 - ? html` -
-

- ${this.hass!.localize( - "ui.panel.config.cloud.alexa.exposed_entities" - )} -

- ${!this.narrow - ? this.hass!.localize( - "ui.panel.config.cloud.alexa.exposed", - "selected", - selected - ) - : selected} -
-
${exposedCards}
- ` - : ""} - ${notExposedCards.length > 0 - ? html` -
-

- ${this.hass!.localize( - "ui.panel.config.cloud.alexa.not_exposed_entities" - )} -

- ${!this.narrow - ? this.hass!.localize( - "ui.panel.config.cloud.alexa.not_exposed", - "selected", - this._entities.length - selected - ) - : this._entities.length - selected} -
-
${notExposedCards}
- ` - : ""} -
- `; - } - - protected firstUpdated(changedProps) { - super.firstUpdated(changedProps); - this._fetchData(); - } - - protected updated(changedProps) { - super.updated(changedProps); - if (changedProps.has("cloudStatus")) { - this._entityConfigs = this.cloudStatus.prefs.alexa_entity_configs; - } - if ( - changedProps.has("hass") && - changedProps.get("hass")?.entities !== this.hass.entities - ) { - const categories = {}; - - for (const entry of Object.values(this.hass.entities)) { - categories[entry.entity_id] = entry.entity_category; - } - - this._entityCategories = categories; - } - } - - private async _fetchData() { - const entities = await fetchCloudAlexaEntities(this.hass); - entities.sort((a, b) => { - const stateA = this.hass.states[a.entity_id]; - const stateB = this.hass.states[b.entity_id]; - return stringCompare( - stateA ? computeStateName(stateA) : a.entity_id, - stateB ? computeStateName(stateB) : b.entity_id, - this.hass.locale.language - ); - }); - this._entities = entities; - } - - private _showMoreInfo(ev) { - const entityId = ev.currentTarget.stateObj.entity_id; - fireEvent(this, "hass-more-info", { entityId }); - } - - private _configIsDomainExposed( - entityId: string, - entityCategory: EntityRegistryEntry["entity_category"] | undefined - ) { - const domain = computeDomain(entityId); - return this.cloudStatus.prefs.alexa_default_expose - ? !entityCategory && - this.cloudStatus.prefs.alexa_default_expose.includes(domain) - : DEFAULT_CONFIG_EXPOSE; - } - - private _configIsExposed( - entityId: string, - config: AlexaEntityConfig, - entityCategory: EntityRegistryEntry["entity_category"] | undefined - ) { - return ( - config.should_expose ?? - this._configIsDomainExposed(entityId, entityCategory) - ); - } - - private async _exposeChanged(ev: CustomEvent) { - 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, { - should_expose: newExposed, - }); - this._ensureEntitySync(); - } - - private async _updateConfig(entityId: string, values: AlexaEntityConfig) { - const updatedConfig = await updateCloudAlexaEntityConfig( - this.hass, - entityId, - values - ); - this._entityConfigs = { - ...this._entityConfigs, - [entityId]: updatedConfig, - }; - this._ensureStatusReload(); - } - - private _openDomainToggler() { - showDomainTogglerDialog(this, { - title: this.hass!.localize("ui.panel.config.cloud.alexa.manage_defaults"), - description: this.hass!.localize( - "ui.panel.config.cloud.alexa.manage_defaults_dialog_description" - ), - domains: this._entities!.map((entity) => - computeDomain(entity.entity_id) - ).filter((value, idx, self) => self.indexOf(value) === idx), - exposedDomains: this.cloudStatus.prefs.alexa_default_expose, - toggleDomain: (domain, expose) => { - this._updateDomainExposed(domain, expose); - }, - resetDomain: (domain) => { - this._entities!.forEach((entity) => { - if (computeDomain(entity.entity_id) === domain) { - this._updateExposed(entity.entity_id, null); - } - }); - }, - }); - } - - private async _handleSync() { - this._syncing = true; - try { - await syncCloudAlexaEntities(this.hass!); - } catch (err: any) { - alert( - `${this.hass!.localize( - "ui.panel.config.cloud.alexa.sync_entities_error" - )} ${err.body.message}` - ); - } finally { - this._syncing = false; - } - } - - 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() { - if (this._popstateReloadStatusAttached) { - return; - } - this._popstateReloadStatusAttached = true; - // Cache parent because by the time popstate happens, - // this element is detached - const parent = this.parentElement!; - window.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 - window.addEventListener( - "popstate", - () => { - // We don't have anything yet. - }, - { once: true } - ); - } - - static get styles(): CSSResultGroup { - return [ - haStyle, - css` - mwc-list-item > [slot="meta"] { - margin-left: 4px; - } - .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; - height: 40px; - } - 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%; - } - } - `, - ]; - } -} - -declare global { - interface HTMLElementTagNameMap { - "cloud-alexa": CloudAlexa; - } -} diff --git a/src/panels/config/cloud/google-assistant/cloud-google-assistant.ts b/src/panels/config/cloud/google-assistant/cloud-google-assistant.ts deleted file mode 100644 index a2b4dada34..0000000000 --- a/src/panels/config/cloud/google-assistant/cloud-google-assistant.ts +++ /dev/null @@ -1,727 +0,0 @@ -import { ActionDetail } from "@material/mwc-list/mwc-list-foundation"; -import "@material/mwc-list/mwc-list-item"; -import { - mdiCheckboxMarked, - mdiCheckboxMultipleMarked, - mdiCloseBox, - mdiCloseBoxMultiple, - mdiDotsVertical, - mdiFormatListChecks, - mdiSync, -} from "@mdi/js"; -import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; -import { customElement, property, state } from "lit/decorators"; -import { classMap } from "lit/directives/class-map"; -import memoizeOne from "memoize-one"; -import { fireEvent } from "../../../../common/dom/fire_event"; -import { computeDomain } from "../../../../common/entity/compute_domain"; -import { computeStateName } from "../../../../common/entity/compute_state_name"; -import { - EntityFilter, - generateFilter, - isEmptyFilter, -} from "../../../../common/entity/entity_filter"; -import { stringCompare } from "../../../../common/string/compare"; -import { computeRTLDirection } from "../../../../common/util/compute_rtl"; -import "../../../../components/entity/state-info"; -import "../../../../components/ha-button-menu"; -import "../../../../components/ha-card"; -import "../../../../components/ha-formfield"; -import "../../../../components/ha-icon-button"; -import "../../../../components/ha-switch"; -import type { HaSwitch } from "../../../../components/ha-switch"; -import { - CloudPreferences, - CloudStatusLoggedIn, - cloudSyncGoogleAssistant, - GoogleEntityConfig, - updateCloudGoogleEntityConfig, - updateCloudPref, -} from "../../../../data/cloud"; -import { - EntityRegistryEntry, - ExtEntityRegistryEntry, - getExtendedEntityRegistryEntries, - updateEntityRegistryEntry, -} from "../../../../data/entity_registry"; -import { - fetchCloudGoogleEntities, - GoogleEntity, -} from "../../../../data/google_assistant"; -import { showDomainTogglerDialog } from "../../../../dialogs/domain-toggler/show-dialog-domain-toggler"; -import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box"; -import "../../../../layouts/hass-loading-screen"; -import "../../../../layouts/hass-subpage"; -import { buttonLinkStyle, haStyle } from "../../../../resources/styles"; -import type { HomeAssistant } from "../../../../types"; -import { showToast } from "../../../../util/toast"; -import { showAliasesDialog } from "../../../../dialogs/aliases/show-dialog-aliases"; - -const DEFAULT_CONFIG_EXPOSE = true; - -@customElement("cloud-google-assistant") -class CloudGoogleAssistant extends LitElement { - @property({ attribute: false }) public hass!: HomeAssistant; - - @property() public cloudStatus!: CloudStatusLoggedIn; - - @property() public narrow!: boolean; - - @state() private _entities?: GoogleEntity[]; - - @state() private _entries?: { [id: string]: ExtEntityRegistryEntry }; - - @state() private _syncing = false; - - @state() - private _entityConfigs: CloudPreferences["google_entity_configs"] = {}; - - @state() - private _entityCategories?: Record< - string, - EntityRegistryEntry["entity_category"] - >; - - private _popstateSyncAttached = false; - - private _popstateReloadStatusAttached = false; - - private _isInitialExposed?: Set; - - private _getEntityFilterFunc = memoizeOne((filter: EntityFilter) => - generateFilter( - filter.include_domains, - filter.include_entities, - filter.exclude_domains, - filter.exclude_entities - ) - ); - - protected render(): TemplateResult { - if (this._entities === undefined || this._entityCategories === undefined) { - return html` `; - } - const emptyFilter = isEmptyFilter(this.cloudStatus.google_entities); - const filterFunc = this._getEntityFilterFunc( - this.cloudStatus.google_entities - ); - const dir = computeRTLDirection(this.hass!); - - // We will only generate `isInitialExposed` during first render. - // On each subsequent render we will use the same set so that cards - // will not jump around when we change the exposed setting. - const showInExposed = this._isInitialExposed || new Set(); - const trackExposed = this._isInitialExposed === undefined; - - let selected = 0; - - // On first render we decide which cards show in which category. - // That way cards won't jump around when changing values. - const exposedCards: TemplateResult[] = []; - const notExposedCards: TemplateResult[] = []; - - this._entities.forEach((entity) => { - const stateObj = this.hass.states[entity.entity_id]; - const config = this._entityConfigs[entity.entity_id] || { - should_expose: null, - }; - const isExposed = emptyFilter - ? this._configIsExposed( - entity.entity_id, - config, - this._entityCategories![entity.entity_id] - ) - : filterFunc(entity.entity_id); - const isDomainExposed = emptyFilter - ? this._configIsDomainExposed( - entity.entity_id, - this._entityCategories![entity.entity_id] - ) - : filterFunc(entity.entity_id); - if (isExposed) { - selected++; - - if (trackExposed) { - showInExposed.add(entity.entity_id); - } - } - - const target = showInExposed.has(entity.entity_id) - ? exposedCards - : notExposedCards; - - const iconButton = html``; - - const aliases = this._entries?.[entity.entity_id]?.aliases; - - target.push(html` - -
-
- - ${aliases - ? html` - - ${aliases.length > 0 - ? [...aliases] - .sort((a, b) => - stringCompare(a, b, this.hass.locale.language) - ) - .join(", ") - : this.hass.localize( - "ui.panel.config.cloud.google.no_aliases" - )} - -
- - ` - : html` - - ${this.hass.localize( - "ui.panel.config.cloud.google.aliases_not_available" - )} - -
- - `} -
- ${!emptyFilter - ? html`${iconButton}` - : html` - ${iconButton} - - ${this.hass!.localize( - "ui.panel.config.cloud.google.expose_entity" - )} - - - - ${this.hass!.localize( - "ui.panel.config.cloud.google.dont_expose_entity" - )} - - - - ${this.hass!.localize( - "ui.panel.config.cloud.google.follow_domain" - )} - - - `} -
- ${entity.might_2fa - ? html` -
- - - -
- ` - : ""} -
-
- `); - }); - - if (trackExposed) { - this._isInitialExposed = showInExposed; - } - - return html` - - - - - - ${this.hass.localize( - "ui.panel.config.cloud.google.manage_defaults" - )} - - - - - ${this.hass.localize("ui.panel.config.cloud.google.sync_entities")} - - - - ${ - !emptyFilter - ? html` - - ` - : "" - } - ${ - exposedCards.length > 0 - ? html` -
-

- ${this.hass!.localize( - "ui.panel.config.cloud.google.exposed_entities" - )} -

- ${!this.narrow - ? this.hass!.localize( - "ui.panel.config.cloud.google.exposed", - "selected", - selected - ) - : selected} -
-
${exposedCards}
- ` - : "" - } - ${ - notExposedCards.length > 0 - ? html` -
-

- ${this.hass!.localize( - "ui.panel.config.cloud.google.not_exposed_entities" - )} -

- ${!this.narrow - ? this.hass!.localize( - "ui.panel.config.cloud.google.not_exposed", - "selected", - this._entities.length - selected - ) - : this._entities.length - selected} -
-
${notExposedCards}
- ` - : "" - } - -
- `; - } - - 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; - } - if ( - changedProps.has("hass") && - changedProps.get("hass")?.entities !== this.hass.entities - ) { - const categories = {}; - - for (const entry of Object.values(this.hass.entities)) { - categories[entry.entity_id] = entry.entity_category; - } - - this._entityCategories = categories; - } - } - - private async _openAliasesSettings(ev) { - ev.stopPropagation(); - const entityId = ev.target.entityId; - const entry = this._entries![entityId]; - if (!entry) { - return; - } - const stateObj = this.hass.states[entityId]; - const name = (stateObj && computeStateName(stateObj)) || entityId; - - showAliasesDialog(this, { - name, - aliases: entry.aliases, - updateAliases: async (aliases: string[]) => { - const result = await updateEntityRegistryEntry(this.hass, entityId, { - aliases, - }); - this._entries![entityId] = result.entity_entry; - }, - }); - } - - private _configIsDomainExposed( - entityId: string, - entityCategory: EntityRegistryEntry["entity_category"] | undefined - ) { - const domain = computeDomain(entityId); - return this.cloudStatus.prefs.google_default_expose - ? !entityCategory && - this.cloudStatus.prefs.google_default_expose.includes(domain) - : DEFAULT_CONFIG_EXPOSE; - } - - private _configIsExposed( - entityId: string, - config: GoogleEntityConfig, - entityCategory: EntityRegistryEntry["entity_category"] | undefined - ) { - return ( - config.should_expose ?? - this._configIsDomainExposed(entityId, entityCategory) - ); - } - - private async _fetchData() { - const entities = await fetchCloudGoogleEntities(this.hass); - this._entries = await getExtendedEntityRegistryEntries( - this.hass, - entities - .filter((ent) => this.hass.entities[ent.entity_id]) - .map((e) => e.entity_id) - ); - - entities.sort((a, b) => { - const stateA = this.hass.states[a.entity_id]; - const stateB = this.hass.states[b.entity_id]; - return stringCompare( - stateA ? computeStateName(stateA) : a.entity_id, - stateB ? computeStateName(stateB) : b.entity_id, - this.hass.locale.language - ); - }); - this._entities = entities; - } - - private _showMoreInfo(ev) { - const entityId = ev.currentTarget.stateObj.entity_id; - fireEvent(this, "hass-more-info", { entityId }); - } - - private _showMoreInfoSettings(ev) { - ev.stopPropagation(); - const entityId = ev.currentTarget.stateObj.entity_id; - fireEvent(this, "hass-more-info", { entityId, view: "settings" }); - } - - private async _exposeChanged(ev: CustomEvent) { - 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, { - should_expose: newExposed, - }); - if (this.cloudStatus.google_registered) { - this._ensureEntitySync(); - } - } - - private async _disable2FAChanged(ev: Event) { - const entityId = (ev.currentTarget as any).entityId; - const newDisable2FA = (ev.target as HaSwitch).checked; - 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 _openDomainToggler() { - showDomainTogglerDialog(this, { - title: this.hass!.localize( - "ui.panel.config.cloud.google.manage_defaults" - ), - description: this.hass!.localize( - "ui.panel.config.cloud.google.manage_defaults_dialog_description" - ), - domains: this._entities!.map((entity) => - computeDomain(entity.entity_id) - ).filter((value, idx, self) => self.indexOf(value) === idx), - exposedDomains: this.cloudStatus.prefs.google_default_expose, - toggleDomain: (domain, expose) => { - this._updateDomainExposed(domain, expose); - }, - resetDomain: (domain) => { - this._entities!.forEach((entity) => { - if (computeDomain(entity.entity_id) === domain) { - 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() { - if (this._popstateReloadStatusAttached) { - return; - } - this._popstateReloadStatusAttached = true; - // Cache parent because by the time popstate happens, - // this element is detached - const parent = this.parentElement!; - window.addEventListener( - "popstate", - () => fireEvent(parent, "ha-refresh-cloud-status"), - { once: true } - ); - } - - private async _handleSync() { - this._syncing = true; - try { - await cloudSyncGoogleAssistant(this.hass!); - } catch (err: any) { - showAlertDialog(this, { - title: this.hass.localize( - `ui.panel.config.cloud.google.${ - err.status_code === 404 - ? "not_configured_title" - : "sync_failed_title" - }` - ), - text: this.hass.localize( - `ui.panel.config.cloud.google.${ - err.status_code === 404 ? "not_configured_text" : "sync_failed_text" - }` - ), - }); - fireEvent(this, "ha-refresh-cloud-status"); - } finally { - this._syncing = false; - } - } - - 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: this.hass!.localize( - "ui.panel.config.cloud.google.sync_to_google" - ), - }); - cloudSyncGoogleAssistant(this.hass); - }, - { once: true } - ); - } - - static get styles(): CSSResultGroup { - return [ - haStyle, - buttonLinkStyle, - css` - mwc-list-item > [slot="meta"] { - margin-left: 4px; - } - .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%; - } - } - `, - ]; - } -} - -declare global { - interface HTMLElementTagNameMap { - "cloud-google-assistant": CloudGoogleAssistant; - } -} diff --git a/src/panels/config/cloud/ha-config-cloud.ts b/src/panels/config/cloud/ha-config-cloud.ts index 8fe5c0a085..e5ccd56148 100644 --- a/src/panels/config/cloud/ha-config-cloud.ts +++ b/src/panels/config/cloud/ha-config-cloud.ts @@ -56,14 +56,6 @@ class HaConfigCloud extends HassRouterPage { account: { tag: "cloud-account", }, - "google-assistant": { - tag: "cloud-google-assistant", - load: () => import("./google-assistant/cloud-google-assistant"), - }, - alexa: { - tag: "cloud-alexa", - load: () => import("./alexa/cloud-alexa"), - }, }, }; diff --git a/src/panels/config/core/ha-config-system-navigation.ts b/src/panels/config/core/ha-config-system-navigation.ts index 54a22a9988..9225670215 100644 --- a/src/panels/config/core/ha-config-system-navigation.ts +++ b/src/panels/config/core/ha-config-system-navigation.ts @@ -8,7 +8,6 @@ import { blankBeforePercent } from "../../../common/translations/blank_before_pe import "../../../components/ha-card"; import "../../../components/ha-icon-button"; import "../../../components/ha-navigation-list"; -import "../../../components/ha-tip"; import { BackupContent, fetchBackupInfo } from "../../../data/backup"; import { CloudStatus, fetchCloudStatus } from "../../../data/cloud"; import { BOARD_NAMES, HardwareInfo } from "../../../data/hardware"; @@ -270,9 +269,6 @@ class HaConfigSystemNavigation extends LitElement { ha-navigation-list { --navigation-list-item-title-font-size: 16px; } - ha-tip { - margin-bottom: max(env(safe-area-inset-bottom), 8px); - } `, ]; } diff --git a/src/panels/config/entities/entity-registry-settings.ts b/src/panels/config/entities/entity-registry-settings.ts index 817819b7b5..1ab9238576 100644 --- a/src/panels/config/entities/entity-registry-settings.ts +++ b/src/panels/config/entities/entity-registry-settings.ts @@ -1,15 +1,14 @@ import "@material/mwc-button/mwc-button"; import "@material/mwc-formfield/mwc-formfield"; import "@material/mwc-list/mwc-list-item"; -import { mdiPencil } from "@mdi/js"; import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; import { css, CSSResultGroup, html, LitElement, - PropertyValues, nothing, + PropertyValues, } from "lit"; import { customElement, property, state } from "lit/decorators"; import memoizeOne from "memoize-one"; @@ -17,7 +16,6 @@ import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { fireEvent } from "../../../common/dom/fire_event"; import { stopPropagation } from "../../../common/dom/stop_propagation"; import { computeDomain } from "../../../common/entity/compute_domain"; -import { computeStateName } from "../../../common/entity/compute_state_name"; import { domainIcon } from "../../../common/entity/domain_icon"; import { supportsFeature } from "../../../common/entity/supports-feature"; import { formatNumber } from "../../../common/number/format_number"; @@ -30,6 +28,7 @@ import "../../../components/ha-alert"; import "../../../components/ha-area-picker"; import "../../../components/ha-expansion-panel"; import "../../../components/ha-icon"; +import "../../../components/ha-icon-button-next"; import "../../../components/ha-icon-picker"; import "../../../components/ha-radio"; import "../../../components/ha-select"; @@ -73,15 +72,15 @@ import { domainToName } from "../../../data/integration"; import { getNumberDeviceClassConvertibleUnits } from "../../../data/number"; import { getSensorDeviceClassConvertibleUnits } from "../../../data/sensor"; import { - WeatherUnits, getWeatherConvertibleUnits, + WeatherUnits, } from "../../../data/weather"; -import { showAliasesDialog } from "../../../dialogs/aliases/show-dialog-aliases"; import { showOptionsFlowDialog } from "../../../dialogs/config-flow/show-dialog-options-flow"; import { showAlertDialog, showConfirmationDialog, } from "../../../dialogs/generic/show-dialog-box"; +import { showVoiceAssistantsView } from "../../../dialogs/more-info/components/voice/show-view-voice-assistants"; import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog"; import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import { haStyle } from "../../../resources/styles"; @@ -699,6 +698,23 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) { @value-changed=${this._areaPicked} >` : ""} + + 0} hasMeta> + Voice assistants + + ${this.entry.aliases.length + ? [...this.entry.aliases] + .sort((a, b) => + stringCompare(a, b, this.hass.locale.language) + ) + .join(", ") + : this.hass.localize( + "ui.dialogs.entity_registry.editor.no_aliases" + )} + + + + ${this._cameraPrefs ? html` @@ -848,34 +864,6 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) { ` : ""} - -
- ${this.hass.localize( - "ui.dialogs.entity_registry.editor.aliases_section" - )} -
- - 0} hasMeta> - - ${this.entry.aliases.length > 0 - ? this.hass.localize( - "ui.dialogs.entity_registry.editor.configured_aliases", - { count: this.entry.aliases.length } - ) - : this.hass.localize( - "ui.dialogs.entity_registry.editor.no_aliases" - )} - - - ${[...this.entry.aliases] - .sort((a, b) => - stringCompare(a, b, this.hass.locale.language) - ) - .join(", ")} - - - -
${this.hass.localize( "ui.dialogs.entity_registry.editor.aliases_description" @@ -1070,25 +1058,8 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) { }); } - private _handleAliasesClicked(ev: CustomEvent) { - if (ev.detail.index !== 0) return; - - const stateObj = this.hass.states[this.entry.entity_id]; - const name = - (stateObj && computeStateName(stateObj)) || this.entry.entity_id; - - showAliasesDialog(this, { - name, - aliases: this.entry!.aliases, - updateAliases: async (aliases: string[]) => { - const result = await updateEntityRegistryEntry( - this.hass, - this.entry.entity_id, - { aliases } - ); - fireEvent(this, "entity-entry-updated", result.entity_entry); - }, - }); + private _handleVoiceAssistantsClicked() { + showVoiceAssistantsView(this, "Voice assistants"); } private async _enableEntry() { diff --git a/src/panels/config/ha-panel-config.ts b/src/panels/config/ha-panel-config.ts index c00faf3711..57011cf0e4 100644 --- a/src/panels/config/ha-panel-config.ts +++ b/src/panels/config/ha-panel-config.ts @@ -12,6 +12,7 @@ import { mdiMapMarkerRadius, mdiMathLog, mdiMemory, + mdiMicrophone, mdiNetwork, mdiNfcVariant, mdiPalette, @@ -82,6 +83,12 @@ export const configSections: { [name: string]: PageNavigation[] } = { iconColor: "#B1345C", component: "lovelace", }, + { + path: "/config/voice-assistants", + translationKey: "voice_assistants", + iconPath: mdiMicrophone, + iconColor: "#3263C3", + }, { path: "/config/tags", translationKey: "tags", @@ -199,6 +206,14 @@ export const configSections: { [name: string]: PageNavigation[] } = { iconColor: "#616161", }, ], + voice_assistants: [ + { + path: "/config/voice-assistants", + translationKey: "ui.panel.config.dashboard.voice_assistants.main", + iconPath: mdiMicrophone, + iconColor: "#3263C3", + }, + ], // Not used as a tab, but this way it will stay in the quick bar energy: [ { diff --git a/src/panels/config/lovelace/ha-config-lovelace.ts b/src/panels/config/lovelace/ha-config-lovelace.ts index 280a951b38..727438f3b6 100644 --- a/src/panels/config/lovelace/ha-config-lovelace.ts +++ b/src/panels/config/lovelace/ha-config-lovelace.ts @@ -1,3 +1,4 @@ +import { mdiViewDashboard } from "@mdi/js"; import { customElement, property } from "lit/decorators"; import { HassRouterPage, @@ -10,7 +11,7 @@ export const lovelaceTabs = [ component: "lovelace", path: "/config/lovelace/dashboards", translationKey: "ui.panel.config.lovelace.dashboards.caption", - icon: "hass:view-dashboard", + iconPath: mdiViewDashboard, }, ]; diff --git a/src/panels/config/voice-assistants/cloud-alexa-pref.ts b/src/panels/config/voice-assistants/cloud-alexa-pref.ts new file mode 100644 index 0000000000..5f6bce0416 --- /dev/null +++ b/src/panels/config/voice-assistants/cloud-alexa-pref.ts @@ -0,0 +1,272 @@ +import "@material/mwc-button"; +import { mdiHelpCircle } from "@mdi/js"; +import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; +import { property, state } from "lit/decorators"; +import { fireEvent } from "../../../common/dom/fire_event"; +import { isEmptyFilter } from "../../../common/entity/entity_filter"; +import "../../../components/ha-alert"; +import "../../../components/ha-card"; +import "../../../components/ha-settings-row"; +import "../../../components/ha-switch"; +import type { HaSwitch } from "../../../components/ha-switch"; +import { CloudStatusLoggedIn, updateCloudPref } from "../../../data/cloud"; +import { + getExposeNewEntities, + setExposeNewEntities, +} from "../../../data/voice"; +import type { HomeAssistant } from "../../../types"; +import { brandsUrl } from "../../../util/brands-url"; + +export class CloudAlexaPref extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public cloudStatus?: CloudStatusLoggedIn; + + @state() private _exposeNew?: boolean; + + protected willUpdate() { + if (!this.hasUpdated) { + getExposeNewEntities(this.hass, "cloud.alexa").then((value) => { + this._exposeNew = value.expose_new; + }); + } + } + + protected render() { + if (!this.cloudStatus) { + return nothing; + } + + const alexa_registered = this.cloudStatus.alexa_registered; + const { alexa_enabled, alexa_report_state } = this.cloudStatus!.prefs; + + const manualConfig = !isEmptyFilter(this.cloudStatus.alexa_entities); + + return html` + +

+ ${this.hass.localize("ui.panel.config.cloud.account.alexa.title")} +

+
+ + + + +
+
+

+ ${this.hass!.localize("ui.panel.config.cloud.account.alexa.info")} +

+ ${manualConfig + ? html` + ${this.hass.localize( + "ui.panel.config.cloud.account.alexa.manual_config" + )} + ` + : ""} + ${!alexa_enabled + ? "" + : html`${!alexa_registered + ? html` + ${this.hass.localize( + "ui.panel.config.cloud.account.alexa.not_configured_text" + )} + + + ` + : ""} + + ${this.hass!.localize( + "ui.panel.config.cloud.account.alexa.expose_new_entities" + )} + + + ${this.hass!.localize( + "ui.panel.config.cloud.account.alexa.expose_new_entities_info" + )} + + ${alexa_registered + ? html` + + + ${this.hass!.localize( + "ui.panel.config.cloud.account.alexa.enable_state_reporting" + )} + + + ${this.hass!.localize( + "ui.panel.config.cloud.account.alexa.info_state_reporting" + )} + + + + ` + : ""}`} +
+ +
+ `; + } + + private async _exposeNewToggleChanged(ev) { + const toggle = ev.target as HaSwitch; + if (this._exposeNew === undefined || this._exposeNew === toggle.checked) { + return; + } + try { + await setExposeNewEntities(this.hass, "cloud.alexa", toggle.checked); + } catch (err: any) { + toggle.checked = !toggle.checked; + } + } + + private async _enabledToggleChanged(ev) { + const toggle = ev.target as HaSwitch; + try { + await updateCloudPref(this.hass!, { alexa_enabled: toggle.checked! }); + fireEvent(this, "ha-refresh-cloud-status"); + } catch (err: any) { + toggle.checked = !toggle.checked; + } + } + + private async _reportToggleChanged(ev) { + const toggle = ev.target as HaSwitch; + try { + await updateCloudPref(this.hass!, { + alexa_report_state: toggle.checked!, + }); + fireEvent(this, "ha-refresh-cloud-status"); + } catch (err: any) { + alert( + `${this.hass!.localize( + "ui.panel.config.cloud.account.alexa.state_reporting_error", + "enable_disable", + this.hass!.localize( + toggle.checked + ? "ui.panel.config.cloud.account.alexa.enable" + : "ui.panel.config.cloud.account.alexa.disable" + ) + )} ${err.message}` + ); + toggle.checked = !toggle.checked; + } + } + + static get styles(): CSSResultGroup { + return css` + a { + color: var(--primary-color); + } + ha-settings-row { + padding: 0; + } + .header-actions { + position: absolute; + right: 24px; + top: 24px; + display: flex; + flex-direction: row; + } + :host([dir="rtl"]) .header-actions { + right: auto; + left: 24px; + } + .header-actions .icon-link { + margin-top: -16px; + margin-inline-end: 8px; + margin-right: 8px; + direction: var(--direction); + color: var(--secondary-text-color); + } + .card-actions { + display: flex; + } + .card-actions a { + text-decoration: none; + } + .card-header { + display: flex; + align-items: center; + } + img { + height: 28px; + margin-right: 16px; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "cloud-alexa-pref": CloudAlexaPref; + } +} + +customElements.define("cloud-alexa-pref", CloudAlexaPref); diff --git a/src/panels/config/voice-assistants/cloud-google-pref.ts b/src/panels/config/voice-assistants/cloud-google-pref.ts new file mode 100644 index 0000000000..531e05dd0b --- /dev/null +++ b/src/panels/config/voice-assistants/cloud-google-pref.ts @@ -0,0 +1,352 @@ +import "@material/mwc-button"; +import { mdiHelpCircle } from "@mdi/js"; +import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; +import { property, state } from "lit/decorators"; +import { fireEvent } from "../../../common/dom/fire_event"; +import "../../../components/ha-alert"; +import "../../../components/ha-card"; +import "../../../components/ha-settings-row"; +import type { HaSwitch } from "../../../components/ha-switch"; +import "../../../components/ha-textfield"; +import type { HaTextField } from "../../../components/ha-textfield"; +import { CloudStatusLoggedIn, updateCloudPref } from "../../../data/cloud"; +import { showSaveSuccessToast } from "../../../util/toast-saved-success"; +import { HomeAssistant } from "../../../types"; +import { brandsUrl } from "../../../util/brands-url"; +import { isEmptyFilter } from "../../../common/entity/entity_filter"; +import { + getExposeNewEntities, + setExposeNewEntities, +} from "../../../data/voice"; + +export class CloudGooglePref extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public cloudStatus?: CloudStatusLoggedIn; + + @state() private _exposeNew?: boolean; + + protected willUpdate() { + if (!this.hasUpdated) { + getExposeNewEntities(this.hass, "cloud.google_assistant").then( + (value) => { + this._exposeNew = value.expose_new; + } + ); + } + } + + protected render() { + if (!this.cloudStatus) { + return nothing; + } + + const google_registered = this.cloudStatus.google_registered; + const { google_enabled, google_report_state, google_secure_devices_pin } = + this.cloudStatus.prefs; + + const manualConfig = !isEmptyFilter(this.cloudStatus.google_entities); + + return html` + +

+ ${this.hass.localize("ui.panel.config.cloud.account.google.title")} +

+
+ + + + +
+
+

+ ${this.hass.localize("ui.panel.config.cloud.account.google.info")} +

+ ${manualConfig + ? html` + ${this.hass.localize( + "ui.panel.config.cloud.account.google.manual_config" + )} + ` + : ""} + ${!google_enabled + ? "" + : html`${!google_registered + ? html` + + ${this.hass.localize( + "ui.panel.config.cloud.account.google.not_configured_text" + )} + + + + ` + : ""} + + + ${this.hass!.localize( + "ui.panel.config.cloud.account.google.expose_new_entities" + )} + + + ${this.hass!.localize( + "ui.panel.config.cloud.account.google.expose_new_entities_info" + )} + + ${google_registered + ? html` + ${this.cloudStatus.http_use_ssl + ? html` + + ${this.hass.localize( + "ui.panel.config.cloud.account.google.http_use_ssl_warning_text" + )} + ${this.hass.localize( + "ui.panel.config.common.learn_more" + )} + + ` + : ""} + + + + ${this.hass!.localize( + "ui.panel.config.cloud.account.google.enable_state_reporting" + )} + + + ${this.hass!.localize( + "ui.panel.config.cloud.account.google.info_state_reporting" + )} + + + + + + + ${this.hass.localize( + "ui.panel.config.cloud.account.google.security_devices" + )} + + + ${this.hass.localize( + "ui.panel.config.cloud.account.google.enter_pin_info" + )} + + + + + ` + : ""}`} +
+ +
+ `; + } + + private async _exposeNewToggleChanged(ev) { + const toggle = ev.target as HaSwitch; + if (this._exposeNew === undefined || this._exposeNew === toggle.checked) { + return; + } + try { + await setExposeNewEntities( + this.hass, + "cloud.google_assistant", + toggle.checked + ); + } catch (err: any) { + toggle.checked = !toggle.checked; + } + } + + private async _enabledToggleChanged(ev) { + const toggle = ev.target as HaSwitch; + try { + await updateCloudPref(this.hass, { google_enabled: toggle.checked! }); + fireEvent(this, "ha-refresh-cloud-status"); + } catch (err: any) { + toggle.checked = !toggle.checked; + } + } + + private async _reportToggleChanged(ev) { + const toggle = ev.target as HaSwitch; + try { + await updateCloudPref(this.hass, { + google_report_state: toggle.checked!, + }); + fireEvent(this, "ha-refresh-cloud-status"); + } catch (err: any) { + alert( + `Unable to ${toggle.checked ? "enable" : "disable"} report state. ${ + err.message + }` + ); + toggle.checked = !toggle.checked; + } + } + + private async _pinChanged(ev) { + const input = ev.target as HaTextField; + try { + await updateCloudPref(this.hass, { + [input.id]: input.value || null, + }); + showSaveSuccessToast(this, this.hass); + fireEvent(this, "ha-refresh-cloud-status"); + } catch (err: any) { + alert( + `${this.hass.localize( + "ui.panel.config.cloud.account.google.enter_pin_error" + )} ${err.message}` + ); + input.value = this.cloudStatus!.prefs.google_secure_devices_pin || ""; + } + } + + static get styles(): CSSResultGroup { + return css` + a { + color: var(--primary-color); + } + .header-actions { + position: absolute; + right: 24px; + top: 24px; + display: flex; + flex-direction: row; + } + :host([dir="rtl"]) .header-actions { + right: auto; + left: 24px; + } + .header-actions .icon-link { + margin-top: -16px; + margin-inline-end: 8px; + margin-right: 8px; + direction: var(--direction); + color: var(--secondary-text-color); + } + ha-settings-row { + padding: 0; + } + ha-textfield { + width: 250px; + display: block; + margin-top: 8px; + } + .card-actions { + display: flex; + } + .card-actions a { + text-decoration: none; + } + .warning { + color: var(--error-color); + } + .card-header { + display: flex; + align-items: center; + } + img { + height: 28px; + margin-right: 16px; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "cloud-google-pref": CloudGooglePref; + } +} + +customElements.define("cloud-google-pref", CloudGooglePref); diff --git a/src/panels/config/voice-assistants/dialog-expose-entity.ts b/src/panels/config/voice-assistants/dialog-expose-entity.ts new file mode 100644 index 0000000000..36f3409e82 --- /dev/null +++ b/src/panels/config/voice-assistants/dialog-expose-entity.ts @@ -0,0 +1,218 @@ +import "@material/mwc-button"; +import "@material/mwc-list"; +import { mdiClose } from "@mdi/js"; +import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { ifDefined } from "lit/directives/if-defined"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../../../common/dom/fire_event"; +import "../../../components/ha-check-list-item"; +import "../../../components/search-input"; +import { + computeEntityRegistryName, + ExtEntityRegistryEntry, +} from "../../../data/entity_registry"; +import { haStyle, haStyleDialog } from "../../../resources/styles"; +import { HomeAssistant } from "../../../types"; +import "./entity-voice-settings"; +import { ExposeEntityDialogParams } from "./show-dialog-expose-entity"; + +@customElement("dialog-expose-entity") +class DialogExposeEntity extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _params?: ExposeEntityDialogParams; + + @state() private _filter?: string; + + @state() private _selected: string[] = []; + + public async showDialog(params: ExposeEntityDialogParams): Promise { + this._params = params; + } + + public closeDialog(): void { + this._params = undefined; + this._selected = []; + this._filter = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + protected render() { + if (!this._params) { + return nothing; + } + + return html` + +
+

+ ${this.hass.localize( + "ui.panel.config.voice_assistants.expose.expose_dialog.header" + )} +

+ + +
+ + ${this._filterEntities( + this._params.extendedEntities, + this._filter + ).map((entity) => this._renderItem(entity))} + + + ${this.hass.localize( + "ui.panel.config.voice_assistants.expose.expose_dialog.expose_entities", + { count: this._selected.length } + )} + +
+ `; + } + + private _handleSelected(ev) { + if (ev.detail.source !== "property") { + return; + } + const entityId = ev.target.value; + if (ev.detail.selected) { + if (this._selected.includes(entityId)) { + return; + } + this._selected = [...this._selected, entityId]; + } else { + this._selected = this._selected.filter((item) => item !== entityId); + } + } + + private _filterChanged(e) { + this._filter = e.detail.value; + } + + private _filterEntities = memoizeOne( + (RegEntries: Record, filter?: string) => + Object.values(RegEntries).filter( + (entity) => + this._params!.filterAssistants.some( + (ass) => !entity.options?.[ass]?.should_expose + ) && + (!filter || + entity.entity_id.includes(filter) || + computeEntityRegistryName(this.hass!, entity)?.includes(filter)) + ) + ); + + private _renderItem = (entity: ExtEntityRegistryEntry) => { + const entityState = this.hass.states[entity.entity_id]; + return html` + + ${computeEntityRegistryName(this.hass!, entity)} + `; + }; + + private _expose() { + this._params!.exposeEntities(this._selected); + this.closeDialog(); + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + haStyleDialog, + css` + ha-dialog { + --dialog-content-padding: 0; + } + search-input { + width: 100%; + display: block; + padding: 24px 16px 0; + box-sizing: border-box; + } + .header { + pointer-events: auto; + -webkit-font-smoothing: antialiased; + font-family: var( + --mdc-typography-headline6-font-family, + var(--mdc-typography-font-family, Roboto, sans-serif) + ); + font-size: var(--mdc-typography-headline6-font-size, 1.25rem); + line-height: var(--mdc-typography-headline6-line-height, 2rem); + font-weight: var(--mdc-typography-headline6-font-weight, 500); + letter-spacing: var( + --mdc-typography-headline6-letter-spacing, + 0.0125em + ); + text-decoration: var( + --mdc-typography-headline6-text-decoration, + inherit + ); + text-transform: var( + --mdc-typography-headline6-text-transform, + inherit + ); + display: block; + position: relative; + flex-shrink: 0; + box-sizing: border-box; + margin: 0 0 1px; + padding: 24px 24px 0 24px; + padding-bottom: 15px; + color: var(--mdc-dialog-heading-ink-color, rgba(0, 0, 0, 0.87)); + border-bottom: 1px solid rgba(0, 0, 0, 0.12); + margin-bottom: 0; + border-color: var( + --mdc-dialog-scroll-divider-color, + rgba(0, 0, 0, 0.12) + ); + } + .header_button { + position: absolute; + right: 16px; + top: 14px; + text-decoration: none; + color: inherit; + } + .header_button { + inset-inline-start: initial; + inset-inline-end: 16px; + direction: var(--direction); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-expose-entity": DialogExposeEntity; + } +} diff --git a/src/panels/config/voice-assistants/dialog-voice-settings.ts b/src/panels/config/voice-assistants/dialog-voice-settings.ts new file mode 100644 index 0000000000..77884e7e82 --- /dev/null +++ b/src/panels/config/voice-assistants/dialog-voice-settings.ts @@ -0,0 +1,82 @@ +import "@material/mwc-button/mwc-button"; +import { CSSResultGroup, LitElement, css, html, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../../common/dom/fire_event"; +import { + ExtEntityRegistryEntry, + computeEntityRegistryName, + getExtendedEntityRegistryEntry, +} from "../../../data/entity_registry"; +import { haStyle, haStyleDialog } from "../../../resources/styles"; +import { HomeAssistant } from "../../../types"; +import { VoiceSettingsDialogParams } from "./show-dialog-voice-settings"; +import "./entity-voice-settings"; +import { createCloseHeading } from "../../../components/ha-dialog"; + +@customElement("dialog-voice-settings") +class DialogVoiceSettings extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _extEntityReg?: ExtEntityRegistryEntry; + + public async showDialog(params: VoiceSettingsDialogParams): Promise { + this._extEntityReg = await getExtendedEntityRegistryEntry( + this.hass, + params.entityId + ); + } + + public closeDialog(): void { + this._extEntityReg = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + protected render() { + if (!this._extEntityReg) { + return nothing; + } + + return html` + +
+ +
+
+ `; + } + + private _entityEntryUpdated(ev: CustomEvent) { + this._extEntityReg = ev.detail; + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + haStyleDialog, + css` + ha-dialog { + --dialog-content-padding: 0; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-voice-settings": DialogVoiceSettings; + } +} diff --git a/src/panels/config/voice-assistants/entity-voice-settings.ts b/src/panels/config/voice-assistants/entity-voice-settings.ts new file mode 100644 index 0000000000..fbe5de072e --- /dev/null +++ b/src/panels/config/voice-assistants/entity-voice-settings.ts @@ -0,0 +1,327 @@ +import { css, CSSResultGroup, html, LitElement, PropertyValues } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { isComponentLoaded } from "../../../common/config/is_component_loaded"; +import { fireEvent } from "../../../common/dom/fire_event"; +import { + EntityFilter, + FilterFunc, + generateFilter, + isEmptyFilter, +} from "../../../common/entity/entity_filter"; +import "../../../components/ha-aliases-editor"; +import "../../../components/ha-settings-row"; +import "../../../components/ha-switch"; +import { + CloudStatus, + CloudStatusLoggedIn, + fetchCloudStatus, + updateCloudGoogleEntityConfig, +} from "../../../data/cloud"; +import { + ExtEntityRegistryEntry, + getExtendedEntityRegistryEntry, + updateEntityRegistryEntry, +} from "../../../data/entity_registry"; +import { + GoogleEntity, + fetchCloudGoogleEntity, +} from "../../../data/google_assistant"; +import { + exposeEntities, + voiceAssistantKeys, + voiceAssistants, +} from "../../../data/voice"; +import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; +import { haStyle } from "../../../resources/styles"; +import type { HomeAssistant } from "../../../types"; +import { brandsUrl } from "../../../util/brands-url"; +import { EntityRegistrySettings } from "../entities/entity-registry-settings"; + +@customElement("entity-voice-settings") +export class EntityVoiceSettings extends SubscribeMixin(LitElement) { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Object }) public entry!: ExtEntityRegistryEntry; + + @state() private _cloudStatus?: CloudStatus; + + @state() private _aliases?: string[]; + + @state() private _googleEntity?: GoogleEntity; + + protected willUpdate(changedProps: PropertyValues) { + if (!isComponentLoaded(this.hass, "cloud")) { + return; + } + if (changedProps.has("entry") && this.entry) { + fetchCloudGoogleEntity(this.hass, this.entry.entity_id).then( + (googleEntity) => { + this._googleEntity = googleEntity; + } + ); + } + if (!this.hasUpdated) { + fetchCloudStatus(this.hass).then((status) => { + this._cloudStatus = status; + }); + } + } + + private _getEntityFilterFuncs = memoizeOne( + (googleFilter: EntityFilter, alexaFilter: EntityFilter) => ({ + google: generateFilter( + googleFilter.include_domains, + googleFilter.include_entities, + googleFilter.exclude_domains, + googleFilter.exclude_entities + ), + alexa: generateFilter( + alexaFilter.include_domains, + alexaFilter.include_entities, + alexaFilter.exclude_domains, + alexaFilter.exclude_entities + ), + }) + ); + + protected render() { + const googleEnabled = + this._cloudStatus?.logged_in === true && + this._cloudStatus.prefs.google_enabled === true; + + const alexaEnabled = + this._cloudStatus?.logged_in === true && + this._cloudStatus.prefs.alexa_enabled === true; + + const showAssistants = [...voiceAssistantKeys]; + const uiAssistants = [...voiceAssistantKeys]; + + const alexaManual = + alexaEnabled && + !isEmptyFilter((this._cloudStatus as CloudStatusLoggedIn).alexa_entities); + const googleManual = + googleEnabled && + !isEmptyFilter( + (this._cloudStatus as CloudStatusLoggedIn).google_entities + ); + + if (!googleEnabled) { + showAssistants.splice( + showAssistants.indexOf("cloud.google_assistant"), + 1 + ); + uiAssistants.splice(showAssistants.indexOf("cloud.google_assistant"), 1); + } else if (googleManual) { + uiAssistants.splice(uiAssistants.indexOf("cloud.google_assistant"), 1); + } + + if (!alexaEnabled) { + showAssistants.splice(showAssistants.indexOf("cloud.alexa"), 1); + uiAssistants.splice(uiAssistants.indexOf("cloud.alexa"), 1); + } else if (alexaManual) { + uiAssistants.splice(uiAssistants.indexOf("cloud.alexa"), 1); + } + + const uiExposed = uiAssistants.some( + (key) => this.entry.options?.[key]?.should_expose + ); + + let manFilterFuncs: + | { + google: FilterFunc; + alexa: FilterFunc; + } + | undefined; + + if (alexaManual || googleManual) { + manFilterFuncs = this._getEntityFilterFuncs( + (this._cloudStatus as CloudStatusLoggedIn).google_entities, + (this._cloudStatus as CloudStatusLoggedIn).alexa_entities + ); + } + + const manExposedAlexa = + alexaManual && manFilterFuncs!.alexa(this.entry.entity_id); + const manExposedGoogle = + googleManual && manFilterFuncs!.google(this.entry.entity_id); + + const anyExposed = uiExposed || manExposedAlexa || manExposedGoogle; + + return html` + ${this.hass.localize( + "ui.dialogs.voice-settings.expose_header" + )} + + + ${anyExposed + ? showAssistants.map( + (key) => html` + + ${voiceAssistants[key].name} + ${key === "cloud.google_assistant" && + !googleManual && + this._googleEntity?.might_2fa + ? html` + + ` + : (alexaManual && key === "cloud.alexa") || + (googleManual && key === "cloud.google_assistant") + ? html`${this.hass.localize( + "ui.dialogs.voice-settings.manual_config" + )}` + : ""} + + ` + ) + : ""} + +

+ ${this.hass.localize("ui.dialogs.voice-settings.aliasses_header")} +

+ + `; + } + + private _aliasesChanged(ev) { + this._aliases = ev.detail.value; + } + + private async _2faChanged(ev) { + try { + await updateCloudGoogleEntityConfig( + this.hass, + this.entry.entity_id, + !ev.target.checked + ); + } catch (_err) { + ev.target.checked = !ev.target.checked; + } + } + + private async _saveAliases() { + if (!this._aliases) { + return; + } + const result = await updateEntityRegistryEntry( + this.hass, + this.entry.entity_id, + { + aliases: this._aliases + .map((alias) => alias.trim()) + .filter((alias) => alias), + } + ); + fireEvent(this, "entity-entry-updated", result.entity_entry); + } + + private async _toggleAssistant(ev) { + exposeEntities( + this.hass, + [ev.target.assistant], + [this.entry.entity_id], + ev.target.checked + ); + const entry = await getExtendedEntityRegistryEntry( + this.hass, + this.entry.entity_id + ); + fireEvent(this, "entity-entry-updated", entry); + } + + private async _toggleAll(ev) { + exposeEntities( + this.hass, + voiceAssistantKeys, + [this.entry.entity_id], + ev.target.checked + ); + const entry = await getExtendedEntityRegistryEntry( + this.hass, + this.entry.entity_id + ); + fireEvent(this, "entity-entry-updated", entry); + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + css` + :host { + display: block; + margin: 32px; + margin-top: 0; + --settings-row-prefix-display: contents; + } + ha-settings-row { + padding: 0; + } + img { + height: 32px; + margin-right: 16px; + } + ha-aliases-editor { + display: block; + } + ha-alert { + display: block; + margin-top: 16px; + } + ha-formfield { + margin-left: -8px; + } + ha-checkbox { + --mdc-checkbox-state-layer-size: 40px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "entity-registry-settings": EntityRegistrySettings; + } + interface HASSDomEvents { + "entity-entry-updated": ExtEntityRegistryEntry; + } +} diff --git a/src/panels/config/voice-assistants/ha-config-voice-assistants-assistants.ts b/src/panels/config/voice-assistants/ha-config-voice-assistants-assistants.ts new file mode 100644 index 0000000000..191fe9f8b5 --- /dev/null +++ b/src/panels/config/voice-assistants/ha-config-voice-assistants-assistants.ts @@ -0,0 +1,111 @@ +import { css, html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators"; +import { computeRTLDirection } from "../../../common/util/compute_rtl"; +import { CloudStatus } from "../../../data/cloud"; +import "../../../layouts/hass-tabs-subpage"; +import { HomeAssistant, Route } from "../../../types"; +import "./cloud-alexa-pref"; +import "./cloud-google-pref"; +import { voiceAssistantTabs } from "./ha-config-voice-assistants"; +import "@polymer/paper-item/paper-item"; +import { isComponentLoaded } from "../../../common/config/is_component_loaded"; + +@customElement("ha-config-voice-assistants-assistants") +export class HaConfigVoiceAssistantsAssistants extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public cloudStatus?: CloudStatus; + + @property() public isWide!: boolean; + + @property() public narrow!: boolean; + + @property() public route!: Route; + + protected render() { + if (!this.hass) { + return html``; + } + + return html` + +
+ ${this.cloudStatus?.logged_in + ? html` + ` + : html` +
+ With Home Assistant Cloud, you can connect your Home + Assistant instance in a few simple clicks to both Google + Assistant and Amazon Alexa. If you can connect it to Home + Assistant, you can now control it with your voice using the + Amazon Echo, Google Home or your Android phone. +
+ +
+ ${isComponentLoaded(this.hass, "cloud") + ? html` + + + + ${this.hass.localize( + "ui.panel.config.cloud.login.start_trial" + )} +
+ ${this.hass.localize( + "ui.panel.config.cloud.login.trial_info" + )} +
+
+ +
+
+
` + : ""}`} +
+
+ `; + } + + static styles = css` + .content { + padding: 28px 20px 0; + max-width: 1040px; + margin: 0 auto; + } + .content > * { + display: block; + margin: auto; + max-width: 800px; + margin-bottom: 24px; + } + a { + text-decoration: none; + color: inherit; + } + `; +} diff --git a/src/panels/config/voice-assistants/ha-config-voice-assistants-expose.ts b/src/panels/config/voice-assistants/ha-config-voice-assistants-expose.ts new file mode 100644 index 0000000000..74b060b03c --- /dev/null +++ b/src/panels/config/voice-assistants/ha-config-voice-assistants-expose.ts @@ -0,0 +1,710 @@ +import { consume } from "@lit-labs/context"; +import "@lrnwebcomponents/simple-tooltip/simple-tooltip"; +import { + mdiMinusCircle, + mdiMinusCircleOutline, + mdiPlus, + mdiPlusCircle, +} from "@mdi/js"; +import { css, CSSResultGroup, html, LitElement, PropertyValues } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import { ifDefined } from "lit/directives/if-defined"; +import { styleMap } from "lit/directives/style-map"; +import memoize from "memoize-one"; +import { HASSDomEvent } from "../../../common/dom/fire_event"; +import { + EntityFilter, + generateFilter, + isEmptyFilter, +} from "../../../common/entity/entity_filter"; +import { navigate } from "../../../common/navigate"; +import { computeRTL } from "../../../common/util/compute_rtl"; +import { + DataTableColumnContainer, + DataTableRowData, + RowClickedEvent, + SelectionChangedEvent, +} from "../../../components/data-table/ha-data-table"; +import "../../../components/ha-fab"; +import { CloudStatus, CloudStatusLoggedIn } from "../../../data/cloud"; +import { entitiesContext } from "../../../data/context"; +import { + computeEntityRegistryName, + EntityRegistryEntry, + ExtEntityRegistryEntry, + getExtendedEntityRegistryEntries, +} from "../../../data/entity_registry"; +import { + exposeEntities, + voiceAssistantKeys, + voiceAssistants, +} from "../../../data/voice"; +import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box"; +import "../../../layouts/hass-loading-screen"; +import "../../../layouts/hass-tabs-subpage-data-table"; +import type { HaTabsSubpageDataTable } from "../../../layouts/hass-tabs-subpage-data-table"; +import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; +import { haStyle } from "../../../resources/styles"; +import { HomeAssistant, Route } from "../../../types"; +import { brandsUrl } from "../../../util/brands-url"; +import { voiceAssistantTabs } from "./ha-config-voice-assistants"; +import { showExposeEntityDialog } from "./show-dialog-expose-entity"; +import { showVoiceSettingsDialog } from "./show-dialog-voice-settings"; + +@customElement("ha-config-voice-assistants-expose") +export class VoiceAssistantsExpose extends SubscribeMixin(LitElement) { + @property() public hass!: HomeAssistant; + + @property({ attribute: false }) public cloudStatus?: CloudStatus; + + @property({ type: Boolean }) public isWide!: boolean; + + @property({ type: Boolean }) public narrow!: boolean; + + @property({ attribute: false }) public route!: Route; + + @state() + @consume({ context: entitiesContext, subscribe: true }) + _entities!: HomeAssistant["entities"]; + + @state() private _extEntities?: Record; + + @state() private _filter: string = history.state?.filter || ""; + + @state() private _numHiddenEntities = 0; + + @state() private _searchParms = new URLSearchParams(window.location.search); + + @state() private _selectedEntities: string[] = []; + + @query("hass-tabs-subpage-data-table", true) + private _dataTable!: HaTabsSubpageDataTable; + + private _activeFilters = memoize( + (filters: URLSearchParams): string[] | undefined => { + const filterTexts: string[] = []; + filters.forEach((value, key) => { + switch (key) { + case "assistants": { + const assistants = value.split(","); + assistants.forEach((assistant) => { + filterTexts.push(voiceAssistants[assistant]?.name || assistant); + }); + } + } + }); + return filterTexts.length ? filterTexts : undefined; + } + ); + + private _columns = memoize( + (narrow, _language): DataTableColumnContainer => ({ + icon: { + title: "", + type: "icon", + template: (_, entry) => html` + + `, + }, + name: { + main: true, + title: this.hass.localize( + "ui.panel.config.voice_assistants.expose.headers.name" + ), + sortable: true, + filterable: true, + direction: "asc", + grows: true, + template: narrow + ? (name, entry) => + html` + ${name}
+
${entry.entity_id}
+ ` + : undefined, + }, + area: { + title: this.hass.localize( + "ui.panel.config.voice_assistants.expose.headers.area" + ), + sortable: true, + hidden: narrow, + filterable: true, + width: "15%", + }, + assistants: { + title: this.hass.localize( + "ui.panel.config.voice_assistants.expose.headers.assistants" + ), + sortable: true, + filterable: true, + width: "160px", + type: "flex", + template: (assistants, entry) => + html`${voiceAssistantKeys.map((key) => + assistants.includes(key) + ? html`
+ ${entry.manAssistants?.includes(key) + ? html` + Configured in YAML, not editable in UI + ` + : ""} +
` + : html`
` + )}`, + }, + aliases: { + title: this.hass.localize( + "ui.panel.config.voice_assistants.expose.headers.aliases" + ), + sortable: true, + filterable: true, + width: "15%", + template: (aliases) => + aliases.length === 0 + ? "-" + : aliases.length === 1 + ? aliases[0] + : this.hass.localize( + "ui.panel.config.voice_assistants.expose.aliases", + { count: aliases.length } + ), + }, + remove: { + title: "", + type: "icon-button", + template: () => + html``, + }, + }) + ); + + private _getEntityFilterFuncs = memoize( + (googleFilter: EntityFilter, alexaFilter: EntityFilter) => ({ + google: generateFilter( + googleFilter.include_domains, + googleFilter.include_entities, + googleFilter.exclude_domains, + googleFilter.exclude_entities + ), + amazon: generateFilter( + alexaFilter.include_domains, + alexaFilter.include_entities, + alexaFilter.exclude_domains, + alexaFilter.exclude_entities + ), + }) + ); + + private _filteredEntities = memoize( + ( + entities: HomeAssistant["entities"], + extEntities: Record | undefined, + devices: HomeAssistant["devices"], + areas: HomeAssistant["areas"], + cloudStatus: CloudStatus | undefined, + filters: URLSearchParams + ) => { + const googleEnabled = + cloudStatus?.logged_in === true && + cloudStatus.prefs.google_enabled === true; + const alexaEnabled = + cloudStatus?.logged_in === true && + cloudStatus.prefs.alexa_enabled === true; + + const showAssistants = [...voiceAssistantKeys]; + + const alexaManual = + alexaEnabled && + !isEmptyFilter( + (this.cloudStatus as CloudStatusLoggedIn).alexa_entities + ); + const googleManual = + googleEnabled && + !isEmptyFilter( + (this.cloudStatus as CloudStatusLoggedIn).google_entities + ); + + if (!googleEnabled || googleManual) { + showAssistants.splice( + showAssistants.indexOf("cloud.google_assistant"), + 1 + ); + } + + if (!alexaEnabled || alexaManual) { + showAssistants.splice(showAssistants.indexOf("cloud.alexa"), 1); + } + + const result: Record = {}; + + let filteredEntities = Object.values(entities); + + filteredEntities = filteredEntities.filter((entity) => + showAssistants.some( + (assis) => + extEntities?.[entity.entity_id].options?.[assis]?.should_expose + ) + ); + + // If nothing gets filtered, this is our correct count of entities + const startLength = filteredEntities.length; + + let filteredAssistants: string[]; + + filters.forEach((value, key) => { + if (key === "assistants") { + filteredAssistants = value.split(","); + filteredEntities = filteredEntities.filter((entity) => + filteredAssistants.some( + (assis) => + !(assis === "cloud.alexa" && alexaManual) && + extEntities?.[entity.entity_id].options?.[assis]?.should_expose + ) + ); + } + }); + + for (const entry of filteredEntities) { + const entity = this.hass.states[entry.entity_id]; + const areaId = entry.area_id ?? devices[entry.device_id!]?.area_id; + const area = areaId ? areas[areaId] : undefined; + + result[entry.entity_id] = { + entity_id: entry.entity_id, + entity, + name: computeEntityRegistryName( + this.hass!, + entry as EntityRegistryEntry + ), + area: area ? area.name : "—", + assistants: Object.keys( + extEntities![entry.entity_id].options! + ).filter( + (key) => + showAssistants.includes(key) && + extEntities![entry.entity_id].options![key]?.should_expose + ), + aliases: extEntities?.[entry.entity_id].aliases, + }; + } + + this._numHiddenEntities = startLength - Object.values(result).length; + + if (alexaManual || googleManual) { + const manFilterFuncs = this._getEntityFilterFuncs( + (this.cloudStatus as CloudStatusLoggedIn).google_entities, + (this.cloudStatus as CloudStatusLoggedIn).alexa_entities + ); + Object.keys(entities).forEach((entityId) => { + const assistants: string[] = []; + if ( + alexaManual && + (!filteredAssistants || + filteredAssistants.includes("cloud.alexa")) && + manFilterFuncs.amazon(entityId) + ) { + assistants.push("cloud.alexa"); + } + if ( + googleManual && + (!filteredAssistants || + filteredAssistants.includes("cloud.google_assistant")) && + manFilterFuncs.google(entityId) + ) { + assistants.push("cloud.google_assistant"); + } + if (!assistants.length) { + return; + } + if (entityId in result) { + result[entityId].assistants.push(...assistants); + result[entityId].manAssistants = assistants; + } else { + const entry = entities[entityId]; + const areaId = entry.area_id ?? devices[entry.device_id!]?.area_id; + const area = areaId ? areas[areaId] : undefined; + result[entityId] = { + entity_id: entry.entity_id, + entity: this.hass.states[entityId], + name: computeEntityRegistryName( + this.hass!, + entry as EntityRegistryEntry + ), + area: area ? area.name : "—", + assistants: [ + ...(extEntities + ? Object.keys(extEntities[entry.entity_id].options!).filter( + (key) => + showAssistants.includes(key) && + extEntities[entry.entity_id].options![key] + ?.should_expose + ) + : []), + ...assistants, + ], + manAssistants: assistants, + aliases: extEntities?.[entityId].aliases, + }; + } + }); + } + + return Object.values(result); + } + ); + + public constructor() { + super(); + window.addEventListener("location-changed", () => { + if ( + window.location.search.substring(1) !== this._searchParms.toString() + ) { + this._searchParms = new URLSearchParams(window.location.search); + } + }); + window.addEventListener("popstate", () => { + if ( + window.location.search.substring(1) !== this._searchParms.toString() + ) { + this._searchParms = new URLSearchParams(window.location.search); + } + }); + } + + private async _fetchExtendedEntities() { + this._extEntities = await getExtendedEntityRegistryEntries( + this.hass, + Object.keys(this._entities) + ); + } + + public willUpdate(changedProperties: PropertyValues): void { + if (changedProperties.has("_entities")) { + this._fetchExtendedEntities(); + } + } + + protected render() { + if (!this.hass || this.hass.entities === undefined) { + return html``; + } + const activeFilters = this._activeFilters(this._searchParms); + + const filteredEntities = this._filteredEntities( + this._entities, + this._extEntities, + this.hass.devices, + this.hass.areas, + this.cloudStatus, + this._searchParms + ); + + return html` + 0} + .searchLabel=${this.hass.localize( + "ui.panel.config.entities.picker.search" + )} + .hiddenLabel=${this.hass.localize( + "ui.panel.config.entities.picker.filter.hidden_entities", + "number", + this._numHiddenEntities + )} + .filter=${this._filter} + selectable + clickable + @selection-changed=${this._handleSelectionChanged} + @clear-filter=${this._clearFilter} + @search-changed=${this._handleSearchChange} + @row-click=${this._openEditEntry} + id="entity_id" + hasFab + > + ${this._selectedEntities.length + ? html` +
+

+ ${this.hass.localize( + "ui.panel.config.entities.picker.selected", + "number", + this._selectedEntities.length + )} +

+
+ ${!this.narrow + ? html` + ${this.hass.localize( + "ui.panel.config.voice_assistants.expose.expose" + )} + ${this.hass.localize( + "ui.panel.config.voice_assistants.expose.unexpose" + )} + ` + : html` + + + ${this.hass.localize( + "ui.panel.config.voice_assistants.expose.expose" + )} + + + + ${this.hass.localize( + "ui.panel.config.voice_assistants.expose.unexpose" + )} + + `} +
+
+ ` + : ""} + + + +
+ `; + } + + private _addEntry() { + const assistants = this._searchParms.has("assistants") + ? this._searchParms.get("assistants")!.split(",") + : voiceAssistantKeys; + showExposeEntityDialog(this, { + filterAssistants: assistants, + extendedEntities: this._extEntities!, + exposeEntities: (entities) => { + exposeEntities(this.hass, assistants, entities, true); + }, + }); + } + + private _handleSearchChange(ev: CustomEvent) { + this._filter = ev.detail.value; + history.replaceState({ filter: this._filter }, ""); + } + + private _handleSelectionChanged( + ev: HASSDomEvent + ): void { + this._selectedEntities = ev.detail.value; + } + + private _removeEntity = (ev) => { + ev.stopPropagation(); + const entityId = ev.currentTarget.closest(".mdc-data-table__row").rowId; + const assistants = this._searchParms.has("assistants") + ? this._searchParms.get("assistants")!.split(",") + : voiceAssistantKeys; + exposeEntities(this.hass, assistants, [entityId], false); + }; + + private _unexposeSelected() { + const assistants = this._searchParms.has("assistants") + ? this._searchParms.get("assistants")!.split(",") + : voiceAssistantKeys; + showConfirmationDialog(this, { + title: this.hass.localize( + "ui.panel.config.voice_assistants.expose.unexpose_confirm_title" + ), + text: this.hass.localize( + "ui.panel.config.voice_assistants.expose.unexpose_confirm_text", + { + assistants: assistants + .map((ass) => voiceAssistants[ass].name) + .join(", "), + entities: this._selectedEntities.length, + } + ), + confirmText: this.hass.localize( + "ui.panel.config.voice_assistants.expose.unexpose" + ), + dismissText: this.hass.localize("ui.common.cancel"), + confirm: () => { + exposeEntities(this.hass, assistants, this._selectedEntities, false); + this._clearSelection(); + }, + }); + } + + private _exposeSelected() { + const assistants = this._searchParms.has("assistants") + ? this._searchParms.get("assistants")!.split(",") + : voiceAssistantKeys; + showConfirmationDialog(this, { + title: this.hass.localize( + "ui.panel.config.voice_assistants.expose.expose_confirm_title" + ), + text: this.hass.localize( + "ui.panel.config.voice_assistants.expose.expose_confirm_text", + { + assistants: assistants + .map((ass) => voiceAssistants[ass].name) + .join(", "), + entities: this._selectedEntities.length, + } + ), + confirmText: this.hass.localize( + "ui.panel.config.voice_assistants.expose.expose" + ), + dismissText: this.hass.localize("ui.common.cancel"), + confirm: () => { + exposeEntities(this.hass, assistants, this._selectedEntities, true); + this._clearSelection(); + }, + }); + } + + private _clearSelection() { + this._dataTable.clearSelection(); + } + + private _openEditEntry(ev: CustomEvent): void { + const entityId = (ev.detail as RowClickedEvent).id; + showVoiceSettingsDialog(this, { entityId }); + } + + private _clearFilter() { + if (this._activeFilters(this._searchParms)) { + navigate(window.location.pathname, { replace: true }); + } + } + + static get styles(): CSSResultGroup { + return [ + haStyle, + css` + hass-loading-screen { + --app-header-background-color: var(--sidebar-background-color); + --app-header-text-color: var(--sidebar-text-color); + } + .table-header { + display: flex; + justify-content: space-between; + align-items: center; + height: 56px; + background-color: var(--mdc-text-field-fill-color, whitesmoke); + border-bottom: 1px solid + var(--mdc-text-field-idle-line-color, rgba(0, 0, 0, 0.42)); + box-sizing: border-box; + } + .header-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + color: var(--secondary-text-color); + position: relative; + top: -4px; + } + .selected-txt { + font-weight: bold; + padding-left: 16px; + padding-inline-start: 16px; + direction: var(--direction); + } + .table-header .selected-txt { + margin-top: 20px; + } + .header-toolbar .selected-txt { + font-size: 16px; + } + .header-toolbar .header-btns { + margin-right: -12px; + margin-inline-end: -12px; + direction: var(--direction); + } + .header-btns { + display: flex; + } + .header-btns > mwc-button, + .header-btns > ha-icon-button { + margin: 8px; + } + ha-button-menu { + margin-left: 8px; + } + .clear { + color: var(--primary-color); + padding-left: 8px; + padding-inline-start: 8px; + text-transform: uppercase; + direction: var(--direction); + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-voice-assistants-expose": VoiceAssistantsExpose; + } +} diff --git a/src/panels/config/voice-assistants/ha-config-voice-assistants.ts b/src/panels/config/voice-assistants/ha-config-voice-assistants.ts index 9ebea3faad..4009b404ad 100644 --- a/src/panels/config/voice-assistants/ha-config-voice-assistants.ts +++ b/src/panels/config/voice-assistants/ha-config-voice-assistants.ts @@ -1,21 +1,47 @@ +import { mdiDevices, mdiMicrophone } from "@mdi/js"; import { customElement, property } from "lit/decorators"; import { HassRouterPage, RouterOptions, } from "../../../layouts/hass-router-page"; import { HomeAssistant } from "../../../types"; +import { CloudStatus } from "../../../data/cloud"; + +export const voiceAssistantTabs = [ + { + path: "/config/voice-assistants/assistants", + translationKey: "ui.panel.config.voice_assistants.assistants.caption", + iconPath: mdiMicrophone, + }, + { + path: "/config/voice-assistants/expose", + translationKey: "ui.panel.config.voice_assistants.expose.caption", + iconPath: mdiDevices, + }, +]; @customElement("ha-config-voice-assistants") class HaConfigVoiceAssistants extends HassRouterPage { @property({ attribute: false }) public hass!: HomeAssistant; + @property({ attribute: false }) public cloudStatus!: CloudStatus; + @property() public narrow!: boolean; @property() public isWide!: boolean; protected routerOptions: RouterOptions = { - defaultPage: "debug", + defaultPage: "assistants", routes: { + assistants: { + tag: "ha-config-voice-assistants-assistants", + load: () => import("./ha-config-voice-assistants-assistants"), + cache: true, + }, + expose: { + tag: "ha-config-voice-assistants-expose", + load: () => import("./ha-config-voice-assistants-expose"), + }, debug: { tag: "assist-pipeline-debug", load: () => @@ -28,6 +54,7 @@ class HaConfigVoiceAssistants extends HassRouterPage { protected updatePageEl(pageEl) { pageEl.hass = this.hass; + pageEl.cloudStatus = this.cloudStatus; pageEl.narrow = this.narrow; pageEl.isWide = this.isWide; pageEl.route = this.routeTail; diff --git a/src/panels/config/voice-assistants/show-dialog-expose-entity.ts b/src/panels/config/voice-assistants/show-dialog-expose-entity.ts new file mode 100644 index 0000000000..acb406e711 --- /dev/null +++ b/src/panels/config/voice-assistants/show-dialog-expose-entity.ts @@ -0,0 +1,21 @@ +import { fireEvent } from "../../../common/dom/fire_event"; +import { ExtEntityRegistryEntry } from "../../../data/entity_registry"; + +export interface ExposeEntityDialogParams { + filterAssistants: string[]; + extendedEntities: Record; + exposeEntities: (entities: string[]) => void; +} + +export const loadExposeEntityDialog = () => import("./dialog-expose-entity"); + +export const showExposeEntityDialog = ( + element: HTMLElement, + dialogParams: ExposeEntityDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-expose-entity", + dialogImport: loadExposeEntityDialog, + dialogParams, + }); +}; diff --git a/src/panels/config/voice-assistants/show-dialog-voice-settings.ts b/src/panels/config/voice-assistants/show-dialog-voice-settings.ts new file mode 100644 index 0000000000..1acc94bb10 --- /dev/null +++ b/src/panels/config/voice-assistants/show-dialog-voice-settings.ts @@ -0,0 +1,18 @@ +import { fireEvent } from "../../../common/dom/fire_event"; + +export interface VoiceSettingsDialogParams { + entityId: string; +} + +export const loadVoiceSettingsDialog = () => import("./dialog-voice-settings"); + +export const showVoiceSettingsDialog = ( + element: HTMLElement, + aliasesParams: VoiceSettingsDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-voice-settings", + dialogImport: loadVoiceSettingsDialog, + dialogParams: aliasesParams, + }); +}; diff --git a/src/panels/my/ha-panel-my.ts b/src/panels/my/ha-panel-my.ts index 2cfc2d7e50..49285d1687 100644 --- a/src/panels/my/ha-panel-my.ts +++ b/src/panels/my/ha-panel-my.ts @@ -141,6 +141,9 @@ export const getMyRedirects = (hasSupervisor: boolean): Redirects => ({ component: "tag", redirect: "/config/tags", }, + "voice-assistants": { + redirect: "/config/voice-assistants", + }, lovelace_dashboards: { component: "lovelace", redirect: "/config/lovelace/dashboards", diff --git a/src/translations/en.json b/src/translations/en.json index 33394b18ab..f05539e79e 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1060,6 +1060,12 @@ "aliases_description": "Aliases are alternative names used in voice assistants to refer to this entity." } }, + "voice-settings": { + "expose_header": "Expose", + "aliasses_header": "Aliasses", + "ask_pin": "Ask for PIN", + "manual_config": "Managed with filters in configuration.yaml" + }, "restart": { "heading": "Restart Home Assistant", "advanced_options": "Advanced options", @@ -1246,10 +1252,7 @@ "device_name_placeholder": "Change device name" } }, - "domain_toggler": { - "title": "Toggle Domains", - "reset_entities": "Reset Entity overrides" - }, + "entity_voice_settings": {}, "mqtt_device_debug_info": { "title": "{device} debug info", "deserialize": "Attempt to parse MQTT messages as JSON", @@ -1406,6 +1409,10 @@ "main": "Dashboards", "secondary": "Organize how you interact with your home" }, + "voice_assistants": { + "main": "Voice assistants", + "secondary": "Manage your voice assistants" + }, "energy": { "main": "Energy", "secondary": "Monitor your energy production and consumption" @@ -1992,6 +1999,32 @@ } } }, + "voice_assistants": { + "assistants": { + "caption": "Assistants" + }, + "expose": { + "caption": "Expose", + "headers": { + "name": "Name", + "area": "Area", + "assistants": "Assistants", + "aliases": "Aliases" + }, + "aliases": "{count} aliases", + "expose": "Expose", + "unexpose": "Unexpose", + "add": "Expose entities", + "expose_confirm_title": "Expose selected entities?", + "expose_confirm_text": "Do you want to expose {entities} entities to {assistants}?", + "unexpose_confirm_title": "Stop exposing selected entities?", + "unexpose_confirm_text": "Do you want to stop exposing {entities} entities to {assistants}?", + "expose_dialog": { + "header": "Expose entity", + "expose_entities": "Expose {count} {count, plural,\n one {entity}\n other {entities}\n}" + } + } + }, "automation": { "caption": "Automations", "description": "Create custom behavior rules for your home", @@ -2677,6 +2710,7 @@ "integrations_introduction": "Integrations for Home Assistant Cloud allow you to connect with services in the cloud without having to expose your Home Assistant instance publicly on the internet.", "integrations_introduction2": "Check the website for ", "integrations_link_all_features": " all available features", + "tip_moved_voice_assistants": "Looking for Google Assistant and Amazon Alexa settings? They are moved to the new voice assistants page.", "connected": "Connected", "connecting": "Connecting…", "not_connected": "Not Connected", @@ -2718,11 +2752,14 @@ "info_state_reporting": "If you enable state reporting, Home Assistant will send all state changes of exposed entities to Amazon. This allows you to always see the latest states in the Alexa app and use the state changes to create routines.", "state_reporting_error": "Unable to {enable_disable} report state.", "manage_entities": "[%key:ui::panel::config::cloud::account::google::manage_entities%]", + "manual_config": "[%key:ui::panel::config::cloud::account::google::manual_config%]", "enable": "enable", "disable": "disable", - "not_configured_title": "Alexa is not activated", + "not_configured_title": "Continue setting up Alexa", "not_configured_text": "Before you can use Alexa, you need to activate the Home Assistant skill for Alexa in the Alexa app.", - "link_learn_how_it_works": "[%key:ui::panel::config::cloud::account::remote::link_learn_how_it_works%]" + "link_learn_how_it_works": "[%key:ui::panel::config::cloud::account::remote::link_learn_how_it_works%]", + "expose_new_entities": "[%key:ui::panel::config::cloud::account::google::expose_new_entities%]", + "expose_new_entities_info": "Should new entities, that are supported and have no security risks be exposed to Alexa automatically?" }, "google": { "title": "Google Assistant", @@ -2738,10 +2775,13 @@ "devices_pin": "Security Devices PIN", "enter_pin_hint": "Enter a PIN to use security devices", "manage_entities": "Manage Entities", + "manual_config": "Editing which entities are exposed via the UI is disabled because you have configured entity filters in configuration.yaml.", "enter_pin_error": "Unable to store PIN:", - "not_configured_title": "Google Assistant is not activated", + "not_configured_title": "Continue setting up Google Assistant", "not_configured_text": "Before you can use Google Assistant, you need to activate the Home Assistant Cloud skill for Google Assistant in the Google Home app.", - "link_learn_how_it_works": "[%key:ui::panel::config::cloud::account::remote::link_learn_how_it_works%]" + "link_learn_how_it_works": "[%key:ui::panel::config::cloud::account::remote::link_learn_how_it_works%]", + "expose_new_entities": "Expose new entities", + "expose_new_entities_info": "Should new entities, that are supported and have no security risks be exposed to Google Assistant automatically?" }, "webhooks": { "title": "Webhooks", @@ -2756,23 +2796,6 @@ "disable_hook_error_msg": "Failed to disable webhook:" } }, - "alexa": { - "title": "Alexa", - "banner": "[%key:ui::panel::config::cloud::google::banner%]", - "exposed_entities": "[%key:ui::panel::config::cloud::google::exposed_entities%]", - "not_exposed_entities": "[%key:ui::panel::config::cloud::google::not_exposed_entities%]", - "manage_defaults": "[%key:ui::panel::config::cloud::google::manage_defaults%]", - "manage_defaults_dialog_description": "[%key:ui::panel::config::cloud::google::manage_defaults_dialog_description%]", - "expose_entity": "[%key:ui::panel::config::cloud::google::expose_entity%]", - "dont_expose_entity": "[%key:ui::panel::config::cloud::google::dont_expose_entity%]", - "follow_domain": "[%key:ui::panel::config::cloud::google::follow_domain%]", - "exposed": "[%key:ui::panel::config::cloud::google::exposed%]", - "not_exposed": "[%key:ui::panel::config::cloud::google::not_exposed%]", - "manage_aliases": "[%key:ui::panel::config::cloud::google::manage_aliases%]", - "expose": "Expose to Alexa", - "sync_entities": "Synchronize entities", - "sync_entities_error": "Failed to sync entities:" - }, "dialog_certificate": { "certificate_information": "Certificate Information", "certificate_expiration_date": "Certificate expiration date:", @@ -2780,33 +2803,6 @@ "fingerprint": "Certificate fingerprint:", "close": "Close" }, - "google": { - "title": "Google Assistant", - "expose": "Expose to Google Assistant", - "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.", - "exposed_entities": "Exposed entities", - "not_exposed_entities": "Not exposed entities", - "manage_defaults": "Manage defaults", - "manage_defaults_dialog_description": "Entities can be exposed by default based on their type.", - "expose_entity": "Expose entity", - "dont_expose_entity": "Don't expose entity", - "follow_domain": "Follow domain", - "exposed": "{selected} exposed", - "not_exposed": "{selected} not exposed", - "manage_aliases": "Manage aliases", - "add_aliases": "Add aliases", - "no_aliases": "No aliases", - "aliases_not_available": "Aliases not available", - "aliases_not_available_learn_more": "Learn more", - "sync_to_google": "Synchronizing changes to Google.", - "sync_entities": "Synchronize entities", - "sync_entities_error": "Failed to sync entities:", - "not_configured_title": "[%key:ui::panel::config::cloud::account::google::not_configured_title%]", - "not_configured_text": "[%key:ui::panel::config::cloud::account::google::not_configured_text%]", - "sync_failed_title": "Syncing failed", - "sync_failed_text": "Syncing your entities failed, try again or check the logs." - }, "dialog_cloudhook": { "webhook_for": "Webhook for {name}", "managed_by_integration": "This webhook is managed by an integration and cannot be disabled.",