Add initial expose UI (#16138)

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
This commit is contained in:
Bram Kragten 2023-04-12 18:33:40 +02:00 committed by GitHub
parent a5edb4caaf
commit 442f73b8c5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 2514 additions and 2155 deletions

View File

@ -216,7 +216,7 @@ export class HassioAddonStore extends LitElement {
});
}
private async _filterChanged(e) {
private _filterChanged(e) {
this._filter = e.detail.value;
}

View File

@ -73,7 +73,7 @@ export interface DataTableColumnData<T = any> 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;
@ -406,6 +406,7 @@ export class HaDataTable extends LitElement {
<div
role=${column.main ? "rowheader" : "cell"}
class="mdc-data-table__cell ${classMap({
"mdc-data-table__cell--flex": column.type === "flex",
"mdc-data-table__cell--numeric": column.type === "numeric",
"mdc-data-table__cell--icon": column.type === "icon",
"mdc-data-table__cell--icon-button":
@ -663,6 +664,10 @@ export class HaDataTable extends LitElement {
box-sizing: border-box;
}
.mdc-data-table__cell.mdc-data-table__cell--flex {
display: flex;
}
.mdc-data-table__cell.mdc-data-table__cell--icon {
overflow: initial;
}

View File

@ -0,0 +1,129 @@
import "@material/mwc-button/mwc-button";
import { mdiDeleteOutline, mdiPlus } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { haStyle } from "../resources/styles";
import { HomeAssistant } from "../types";
import "./ha-area-picker";
import "./ha-textfield";
import type { HaTextField } from "./ha-textfield";
import { fireEvent } from "../common/dom/fire_event";
@customElement("ha-aliases-editor")
class AliasesEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public aliases!: string[];
protected render() {
if (!this.aliases) {
return nothing;
}
return html`
${this.aliases.map(
(alias, index) => html`
<div class="layout horizontal center-center row">
<ha-textfield
dialogInitialFocus=${index}
.index=${index}
class="flex-auto"
.label=${this.hass!.localize("ui.dialogs.aliases.input_label", {
number: index + 1,
})}
.value=${alias}
?data-last=${index === this.aliases.length - 1}
@input=${this._editAlias}
@keydown=${this._keyDownAlias}
></ha-textfield>
<ha-icon-button
.index=${index}
slot="navigationIcon"
label=${this.hass!.localize("ui.dialogs.aliases.remove_alias", {
number: index + 1,
})}
@click=${this._removeAlias}
.path=${mdiDeleteOutline}
></ha-icon-button>
</div>
`
)}
<div class="layout horizontal center-center">
<mwc-button @click=${this._addAlias}>
${this.hass!.localize("ui.dialogs.aliases.add_alias")}
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</mwc-button>
</div>
`;
}
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;
}
}

View File

@ -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<GoogleEntityConfig>({
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<AlexaEntityConfig>({
type: "cloud/alexa/entities/update",
entity_id: entityId,
...values,
});

View File

@ -9,5 +9,14 @@ export interface GoogleEntity {
export const fetchCloudGoogleEntities = (hass: HomeAssistant) =>
hass.callWS<GoogleEntity[]>({ type: "cloud/google_assistant/entities" });
export const fetchCloudGoogleEntity = (
hass: HomeAssistant,
entity_id: string
) =>
hass.callWS<GoogleEntity>({
type: "cloud/google_assistant/entities/get",
entity_id,
});
export const syncCloudGoogleEntities = (hass: HomeAssistant) =>
hass.callApi("POST", "cloud/google_actions/sync");

45
src/data/voice.ts Normal file
View File

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

View File

@ -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`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
<div class="form">
${this._aliases.map(
(alias, index) => html`
<div class="layout horizontal center-center row">
<ha-textfield
dialogInitialFocus=${index}
.index=${index}
class="flex-auto"
.label=${this.hass!.localize(
"ui.dialogs.aliases.input_label",
{ number: index + 1 }
)}
.value=${alias}
?data-last=${index === this._aliases.length - 1}
@input=${this._editAlias}
@keydown=${this._keyDownAlias}
></ha-textfield>
<ha-icon-button
.index=${index}
slot="navigationIcon"
label=${this.hass!.localize(
"ui.dialogs.aliases.remove_alias",
{ number: index + 1 }
)}
@click=${this._removeAlias}
.path=${mdiDeleteOutline}
></ha-icon-button>
</div>
`
)}
<div class="layout horizontal center-center">
<mwc-button @click=${this._addAlias}>
${this.hass!.localize("ui.dialogs.aliases.add_alias")}
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</mwc-button>
</div>
</div>
<ha-aliases-editor
.hass=${this.hass}
.aliases=${this._aliases}
@value-changed=${this._aliasesChanged}
></ha-aliases-editor>
</div>
<mwc-button
slot="secondaryAction"
@ -113,32 +78,8 @@ class DialogAliases extends LitElement {
`;
}
private async _addAlias() {
this._aliases = [...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;
this._aliases[index] = (ev.target as any).value;
}
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._aliases = aliases;
private _aliasesChanged(ev: CustomEvent): void {
this._aliases = ev.detail.value;
}
private async _updateAliases(): Promise<void> {

View File

@ -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<HaDomainTogglerDialogParams>
{
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`
<ha-dialog
open
@closed=${this.closeDialog}
scrimClickAction
escapeKeyAction
hideActions
.heading=${createCloseHeading(
this.hass,
this._params.title ||
this.hass.localize("ui.dialogs.domain_toggler.title")
)}
>
${this._params.description
? html`<div class="description">${this._params.description}</div>`
: ""}
<div class="domains">
${domains.map(
(domain) =>
html`
<ha-formfield .label=${domain[0]}>
<ha-switch
.domain=${domain[1]}
.checked=${!this._params!.exposedDomains ||
this._params!.exposedDomains.includes(domain[1])}
@change=${this._handleSwitch}
>
</ha-switch>
</ha-formfield>
<mwc-button .domain=${domain[1]} @click=${this._handleReset}>
${this.hass.localize(
"ui.dialogs.domain_toggler.reset_entities"
)}
</mwc-button>
`
)}
</div>
</ha-dialog>
`;
}
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;
}
}

View File

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

View File

@ -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`<entity-voice-settings
.hass=${this.hass}
.entry=${this.entry}
></entity-voice-settings>`;
}
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;
}
}

View File

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

View File

@ -181,10 +181,10 @@ export class MoreInfoDialog extends LitElement {
this.setView("settings");
}
private async _showChildView(ev: CustomEvent): Promise<void> {
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`
<div class="child-view">
${dynamicElement(this._childView.viewTag, {
hass: this.hass,
entry: this._entry,
params: this._childView.viewParams,
})}
</div>
@ -401,7 +403,6 @@ export class MoreInfoDialog extends LitElement {
.hass=${this.hass}
.entityId=${this._entityId}
.entry=${this._entry}
@entity-entry-updated=${this._entryUpdated}
></ha-more-info-settings>
`
: this._currView === "related"

View File

@ -10,6 +10,7 @@ import { debounce } from "../../../../common/util/debounce";
import "../../../../components/buttons/ha-call-api-button";
import "../../../../components/ha-alert";
import "../../../../components/ha-card";
import "../../../../components/ha-tip";
import {
cloudLogout,
CloudStatusLoggedIn,
@ -22,8 +23,6 @@ import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import { haStyle } from "../../../../resources/styles";
import { HomeAssistant } from "../../../../types";
import "../../ha-config-section";
import "./cloud-alexa-pref";
import "./cloud-google-pref";
import "./cloud-remote-pref";
import "./cloud-tts-pref";
import "./cloud-webhooks";
@ -185,17 +184,13 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
dir=${this._rtlDirection}
></cloud-tts-pref>
<cloud-alexa-pref
.hass=${this.hass}
.cloudStatus=${this.cloudStatus}
dir=${this._rtlDirection}
></cloud-alexa-pref>
<cloud-google-pref
.hass=${this.hass}
.cloudStatus=${this.cloudStatus}
dir=${this._rtlDirection}
></cloud-google-pref>
<ha-tip .hass=${this.hass}>
<a href="/config/voice-assistants">
${this.hass.localize(
"ui.panel.config.cloud.account.tip_moved_voice_assistants"
)}
</a>
</ha-tip>
<cloud-webhooks
.hass=${this.hass}

View File

@ -1,203 +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 "../../../../components/ha-switch";
import type { HaSwitch } from "../../../../components/ha-switch";
import { CloudStatusLoggedIn, updateCloudPref } from "../../../../data/cloud";
import type { HomeAssistant } from "../../../../types";
export class CloudAlexaPref extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public cloudStatus?: CloudStatusLoggedIn;
protected render() {
if (!this.cloudStatus) {
return nothing;
}
const alexa_registered = this.cloudStatus.alexa_registered;
const { alexa_enabled, alexa_report_state } = this.cloudStatus!.prefs;
return html`
<ha-card
outlined
header=${this.hass!.localize(
"ui.panel.config.cloud.account.alexa.title"
)}
>
<div class="header-actions">
<a
href="https://www.nabucasa.com/config/amazon_alexa/"
target="_blank"
rel="noreferrer"
class="icon-link"
>
<ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.cloud.account.alexa.link_learn_how_it_works"
)}
.path=${mdiHelpCircle}
></ha-icon-button>
</a>
<ha-switch
.checked=${alexa_enabled}
@change=${this._enabledToggleChanged}
></ha-switch>
</div>
<div class="card-content">
<p>
${this.hass!.localize("ui.panel.config.cloud.account.alexa.info")}
</p>
${!alexa_enabled
? ""
: !alexa_registered
? html`
<ha-alert
.title=${this.hass.localize(
"ui.panel.config.cloud.account.alexa.not_configured_title"
)}
>
${this.hass.localize(
"ui.panel.config.cloud.account.alexa.not_configured_text"
)}
<ul>
<li>
<a
href="https://skills-store.amazon.com/deeplink/dp/B0772J1QKB?deviceType=app"
target="_blank"
rel="noreferrer"
>
${this.hass!.localize(
"ui.panel.config.cloud.account.alexa.enable_ha_skill"
)}
</a>
</li>
<li>
<a
href="https://www.nabucasa.com/config/amazon_alexa/"
target="_blank"
rel="noreferrer"
>
${this.hass!.localize(
"ui.panel.config.cloud.account.alexa.config_documentation"
)}
</a>
</li>
</ul>
</ha-alert>
`
: html`
<ha-settings-row>
<span slot="heading">
${this.hass!.localize(
"ui.panel.config.cloud.account.alexa.enable_state_reporting"
)}
</span>
<span slot="description">
${this.hass!.localize(
"ui.panel.config.cloud.account.alexa.info_state_reporting"
)}
</span>
<ha-switch
.checked=${alexa_report_state}
@change=${this._reportToggleChanged}
></ha-switch>
</ha-settings-row>
`}
</div>
<div class="card-actions">
<a href="/config/cloud/alexa">
<mwc-button
>${this.hass!.localize(
"ui.panel.config.cloud.account.alexa.manage_entities"
)}</mwc-button
>
</a>
</div>
</ha-card>
`;
}
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);

View File

@ -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`
<ha-card
outlined
header=${this.hass.localize(
"ui.panel.config.cloud.account.google.title"
)}
>
<div class="header-actions">
<a
href="https://www.nabucasa.com/config/google_assistant/"
target="_blank"
rel="noreferrer"
class="icon-link"
>
<ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.cloud.account.google.link_learn_how_it_works"
)}
.path=${mdiHelpCircle}
></ha-icon-button>
</a>
<ha-switch
.checked=${google_enabled}
@change=${this._enabledToggleChanged}
></ha-switch>
</div>
<div class="card-content">
<p>
${this.hass.localize("ui.panel.config.cloud.account.google.info")}
</p>
${!google_enabled
? ""
: !google_registered
? html`
<ha-alert
.title=${this.hass.localize(
"ui.panel.config.cloud.account.google.not_configured_title"
)}
>
${this.hass.localize(
"ui.panel.config.cloud.account.google.not_configured_text"
)}
<ul>
<li>
<a
href="https://assistant.google.com/services/a/uid/00000091fd5fb875?hl=en-US"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.account.google.enable_ha_skill"
)}
</a>
</li>
<li>
<a
href="https://www.nabucasa.com/config/google_assistant/"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.account.google.config_documentation"
)}
</a>
</li>
</ul>
</ha-alert>
`
: html`
${this.cloudStatus.http_use_ssl
? html`
<ha-alert
alert-type="warning"
.title=${this.hass.localize(
"ui.panel.config.cloud.account.google.http_use_ssl_warning_title"
)}
>
${this.hass.localize(
"ui.panel.config.cloud.account.google.http_use_ssl_warning_text"
)}
<a
href="https://www.nabucasa.com/config/google_assistant/#local-communication"
target="_blank"
rel="noreferrer"
>${this.hass.localize(
"ui.panel.config.common.learn_more"
)}</a
>
</ha-alert>
`
: ""}
<ha-settings-row>
<span slot="heading">
${this.hass!.localize(
"ui.panel.config.cloud.account.google.enable_state_reporting"
)}
</span>
<span slot="description">
${this.hass!.localize(
"ui.panel.config.cloud.account.google.info_state_reporting"
)}
</span>
<ha-switch
.checked=${google_report_state}
@change=${this._reportToggleChanged}
></ha-switch>
</ha-settings-row>
<ha-settings-row>
<span slot="heading">
${this.hass.localize(
"ui.panel.config.cloud.account.google.security_devices"
)}
</span>
<span slot="description">
${this.hass.localize(
"ui.panel.config.cloud.account.google.enter_pin_info"
)}
</span>
</ha-settings-row>
<ha-textfield
id="google_secure_devices_pin"
.label=${this.hass.localize(
"ui.panel.config.cloud.account.google.devices_pin"
)}
.placeholder=${this.hass.localize(
"ui.panel.config.cloud.account.google.enter_pin_hint"
)}
.value=${google_secure_devices_pin || ""}
@change=${this._pinChanged}
></ha-textfield>
`}
</div>
<div class="card-actions">
<a href="/config/cloud/google-assistant">
<mwc-button>
${this.hass.localize(
"ui.panel.config.cloud.account.google.manage_entities"
)}
</mwc-button>
</a>
</div>
</ha-card>
`;
}
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);

View File

@ -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<string>;
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` <hass-loading-screen></hass-loading-screen> `;
}
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`<ha-icon-button
slot="trigger"
class=${classMap({
exposed: isExposed!,
"not-exposed": !isExposed,
})}
.disabled=${!emptyFilter}
.label=${this.hass!.localize("ui.panel.config.cloud.alexa.expose")}
.path=${config.should_expose !== null
? isExposed
? mdiCheckboxMarked
: mdiCloseBox
: isDomainExposed
? mdiCheckboxMultipleMarked
: mdiCloseBoxMultiple}
></ha-icon-button>`;
target.push(html`
<ha-card outlined>
<div class="card-content">
<div class="top-line">
<state-info
.hass=${this.hass}
.stateObj=${stateObj}
@click=${this._showMoreInfo}
>
</state-info>
${!emptyFilter
? html`${iconButton}`
: html`<ha-button-menu
.entityId=${stateObj.entity_id}
@action=${this._exposeChanged}
>
${iconButton}
<mwc-list-item hasMeta>
${this.hass!.localize(
"ui.panel.config.cloud.alexa.expose_entity"
)}
<ha-svg-icon
class="exposed"
slot="meta"
.path=${mdiCheckboxMarked}
></ha-svg-icon>
</mwc-list-item>
<mwc-list-item hasMeta>
${this.hass!.localize(
"ui.panel.config.cloud.alexa.dont_expose_entity"
)}
<ha-svg-icon
class="not-exposed"
slot="meta"
.path=${mdiCloseBox}
></ha-svg-icon>
</mwc-list-item>
<mwc-list-item hasMeta>
${this.hass!.localize(
"ui.panel.config.cloud.alexa.follow_domain"
)}
<ha-svg-icon
class=${classMap({
exposed: isDomainExposed,
"not-exposed": !isDomainExposed,
})}
slot="meta"
.path=${isDomainExposed
? mdiCheckboxMultipleMarked
: mdiCloseBoxMultiple}
></ha-svg-icon>
</mwc-list-item>
</ha-button-menu>`}
</div>
</div>
</ha-card>
`);
});
if (trackExposed) {
this._isInitialExposed = showInExposed;
}
return html`
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass!.localize("ui.panel.config.cloud.alexa.title")}
>
<ha-button-menu slot="toolbar-icon">
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<mwc-list-item
graphic="icon"
.disabled=${!emptyFilter}
@click=${this._openDomainToggler}
>
${this.hass.localize("ui.panel.config.cloud.alexa.manage_defaults")}
<ha-svg-icon
slot="graphic"
.path=${mdiFormatListChecks}
></ha-svg-icon>
</mwc-list-item>
<mwc-list-item
graphic="icon"
.disabled=${this._syncing}
@click=${this._handleSync}
>
${this.hass.localize("ui.panel.config.cloud.alexa.sync_entities")}
<ha-svg-icon slot="graphic" .path=${mdiSync}></ha-svg-icon>
</mwc-list-item>
</ha-button-menu>
${!emptyFilter
? html`
<div class="banner">
${this.hass!.localize("ui.panel.config.cloud.alexa.banner")}
</div>
`
: ""}
${exposedCards.length > 0
? html`
<div class="header">
<h3>
${this.hass!.localize(
"ui.panel.config.cloud.alexa.exposed_entities"
)}
</h3>
${!this.narrow
? this.hass!.localize(
"ui.panel.config.cloud.alexa.exposed",
"selected",
selected
)
: selected}
</div>
<div class="content">${exposedCards}</div>
`
: ""}
${notExposedCards.length > 0
? html`
<div class="header second">
<h3>
${this.hass!.localize(
"ui.panel.config.cloud.alexa.not_exposed_entities"
)}
</h3>
${!this.narrow
? this.hass!.localize(
"ui.panel.config.cloud.alexa.not_exposed",
"selected",
this._entities.length - selected
)
: this._entities.length - selected}
</div>
<div class="content">${notExposedCards}</div>
`
: ""}
</hass-subpage>
`;
}
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<ActionDetail>) {
const entityId = (ev.currentTarget as any).entityId;
let newVal: boolean | null = null;
switch (ev.detail.index) {
case 0:
newVal = true;
break;
case 1:
newVal = false;
break;
case 2:
newVal = null;
break;
}
await this._updateExposed(entityId, newVal);
}
private async _updateExposed(entityId: string, newExposed: boolean | null) {
await this._updateConfig(entityId, {
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;
}
}

View File

@ -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<string>;
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` <hass-loading-screen></hass-loading-screen> `;
}
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`<ha-icon-button
slot="trigger"
class=${classMap({
exposed: isExposed!,
"not-exposed": !isExposed,
})}
.disabled=${!emptyFilter}
.label=${this.hass!.localize("ui.panel.config.cloud.google.expose")}
.path=${config.should_expose !== null
? isExposed
? mdiCheckboxMarked
: mdiCloseBox
: isDomainExposed
? mdiCheckboxMultipleMarked
: mdiCloseBoxMultiple}
></ha-icon-button>`;
const aliases = this._entries?.[entity.entity_id]?.aliases;
target.push(html`
<ha-card outlined>
<div class="card-content">
<div class="top-line">
<state-info
.hass=${this.hass}
.stateObj=${stateObj}
secondary-line
@click=${this._showMoreInfo}
>
${aliases
? html`
<span>
${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"
)}
</span>
<br />
<button
class="link"
.entityId=${entity.entity_id}
@click=${this._openAliasesSettings}
>
${this.hass.localize(
`ui.panel.config.cloud.google.${
aliases.length > 0
? "manage_aliases"
: "add_aliases"
}`
)}
</button>
`
: html`
<span>
${this.hass.localize(
"ui.panel.config.cloud.google.aliases_not_available"
)}
</span>
<br />
<button
class="link"
.stateObj=${stateObj}
@click=${this._showMoreInfoSettings}
>
${this.hass.localize(
"ui.panel.config.cloud.google.aliases_not_available_learn_more"
)}
</button>
`}
</state-info>
${!emptyFilter
? html`${iconButton}`
: html`<ha-button-menu
.entityId=${entity.entity_id}
@action=${this._exposeChanged}
>
${iconButton}
<mwc-list-item hasMeta>
${this.hass!.localize(
"ui.panel.config.cloud.google.expose_entity"
)}
<ha-svg-icon
class="exposed"
slot="meta"
.path=${mdiCheckboxMarked}
></ha-svg-icon>
</mwc-list-item>
<mwc-list-item hasMeta>
${this.hass!.localize(
"ui.panel.config.cloud.google.dont_expose_entity"
)}
<ha-svg-icon
class="not-exposed"
slot="meta"
.path=${mdiCloseBox}
></ha-svg-icon>
</mwc-list-item>
<mwc-list-item hasMeta>
${this.hass!.localize(
"ui.panel.config.cloud.google.follow_domain"
)}
<ha-svg-icon
class=${classMap({
exposed: isDomainExposed,
"not-exposed": !isDomainExposed,
})}
slot="meta"
.path=${isDomainExposed
? mdiCheckboxMultipleMarked
: mdiCloseBoxMultiple}
></ha-svg-icon>
</mwc-list-item>
</ha-button-menu>`}
</div>
${entity.might_2fa
? html`
<div>
<ha-formfield
.label=${this.hass!.localize(
"ui.panel.config.cloud.google.disable_2FA"
)}
.dir=${dir}
>
<ha-switch
.entityId=${entity.entity_id}
.checked=${Boolean(config.disable_2fa)}
@change=${this._disable2FAChanged}
></ha-switch>
</ha-formfield>
</div>
`
: ""}
</div>
</ha-card>
`);
});
if (trackExposed) {
this._isInitialExposed = showInExposed;
}
return html`
<hass-subpage
.hass=${this.hass}
.header=${this.hass!.localize("ui.panel.config.cloud.google.title")}
.narrow=${this.narrow}>
<ha-button-menu slot="toolbar-icon">
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<mwc-list-item
graphic="icon"
.disabled=${!emptyFilter}
@click=${this._openDomainToggler}
>
${this.hass.localize(
"ui.panel.config.cloud.google.manage_defaults"
)}
<ha-svg-icon
slot="graphic"
.path=${mdiFormatListChecks}
></ha-svg-icon>
</mwc-list-item>
<mwc-list-item
graphic="icon"
.disabled=${this._syncing}
@click=${this._handleSync}
>
${this.hass.localize("ui.panel.config.cloud.google.sync_entities")}
<ha-svg-icon
slot="graphic"
.path=${mdiSync}
></ha-svg-icon>
</mwc-list-item>
</ha-button-menu>
${
!emptyFilter
? html`
<div class="banner">
${this.hass!.localize("ui.panel.config.cloud.google.banner")}
</div>
`
: ""
}
${
exposedCards.length > 0
? html`
<div class="header">
<h3>
${this.hass!.localize(
"ui.panel.config.cloud.google.exposed_entities"
)}
</h3>
${!this.narrow
? this.hass!.localize(
"ui.panel.config.cloud.google.exposed",
"selected",
selected
)
: selected}
</div>
<div class="content">${exposedCards}</div>
`
: ""
}
${
notExposedCards.length > 0
? html`
<div class="header second">
<h3>
${this.hass!.localize(
"ui.panel.config.cloud.google.not_exposed_entities"
)}
</h3>
${!this.narrow
? this.hass!.localize(
"ui.panel.config.cloud.google.not_exposed",
"selected",
this._entities.length - selected
)
: this._entities.length - selected}
</div>
<div class="content">${notExposedCards}</div>
`
: ""
}
</div>
</hass-subpage>
`;
}
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<ActionDetail>) {
const entityId = (ev.currentTarget as any).entityId;
let newVal: boolean | null = null;
switch (ev.detail.index) {
case 0:
newVal = true;
break;
case 1:
newVal = false;
break;
case 2:
newVal = null;
break;
}
await this._updateExposed(entityId, newVal);
}
private async _updateExposed(entityId: string, newExposed: boolean | null) {
await this._updateConfig(entityId, {
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;
}
}

View File

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

View File

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

View File

@ -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}
></ha-area-picker>`
: ""}
<mwc-list class="aliases" @action=${this._handleVoiceAssistantsClicked}>
<mwc-list-item .twoline=${this.entry.aliases.length > 0} hasMeta>
<span>Voice assistants</span>
<span slot="secondary">
${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"
)}
</span>
<ha-icon-button-next slot="meta"></ha-icon-button-next>
</mwc-list-item>
</mwc-list>
${this._cameraPrefs
? html`
<ha-settings-row>
@ -848,34 +864,6 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
</div>
`
: ""}
<div class="label">
${this.hass.localize(
"ui.dialogs.entity_registry.editor.aliases_section"
)}
</div>
<mwc-list class="aliases" @action=${this._handleAliasesClicked}>
<mwc-list-item .twoline=${this.entry.aliases.length > 0} hasMeta>
<span>
${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"
)}
</span>
<span slot="secondary">
${[...this.entry.aliases]
.sort((a, b) =>
stringCompare(a, b, this.hass.locale.language)
)
.join(", ")}
</span>
<ha-svg-icon slot="meta" .path=${mdiPencil}></ha-svg-icon>
</mwc-list-item>
</mwc-list>
<div class="secondary">
${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() {

View File

@ -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: [
{

View File

@ -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,
},
];

View File

@ -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`
<ha-card outlined>
<h1 class="card-header">
<img
alt=""
src=${brandsUrl({
domain: "alexa",
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
})}
referrerpolicy="no-referrer"
/>${this.hass.localize("ui.panel.config.cloud.account.alexa.title")}
</h1>
<div class="header-actions">
<a
href="https://www.nabucasa.com/config/amazon_alexa/"
target="_blank"
rel="noreferrer"
class="icon-link"
>
<ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.cloud.account.alexa.link_learn_how_it_works"
)}
.path=${mdiHelpCircle}
></ha-icon-button>
</a>
<ha-switch
.checked=${alexa_enabled}
@change=${this._enabledToggleChanged}
></ha-switch>
</div>
<div class="card-content">
<p>
${this.hass!.localize("ui.panel.config.cloud.account.alexa.info")}
</p>
${manualConfig
? html`<ha-alert alert-type="warning">
${this.hass.localize(
"ui.panel.config.cloud.account.alexa.manual_config"
)}
</ha-alert>`
: ""}
${!alexa_enabled
? ""
: html`${!alexa_registered
? html`<ha-alert
.title=${this.hass.localize(
"ui.panel.config.cloud.account.alexa.not_configured_title"
)}
>
${this.hass.localize(
"ui.panel.config.cloud.account.alexa.not_configured_text"
)}
<ul>
<li>
<a
href="https://skills-store.amazon.com/deeplink/dp/B0772J1QKB?deviceType=app"
target="_blank"
rel="noreferrer"
>
${this.hass!.localize(
"ui.panel.config.cloud.account.alexa.enable_ha_skill"
)}
</a>
</li>
<li>
<a
href="https://www.nabucasa.com/config/amazon_alexa/"
target="_blank"
rel="noreferrer"
>
${this.hass!.localize(
"ui.panel.config.cloud.account.alexa.config_documentation"
)}
</a>
</li>
</ul>
</ha-alert>`
: ""}<ha-settings-row>
<span slot="heading">
${this.hass!.localize(
"ui.panel.config.cloud.account.alexa.expose_new_entities"
)}
</span>
<span slot="description">
${this.hass!.localize(
"ui.panel.config.cloud.account.alexa.expose_new_entities_info"
)}
</span>
<ha-switch
.checked=${this._exposeNew}
.disabled=${this._exposeNew === undefined}
@change=${this._exposeNewToggleChanged}
></ha-switch> </ha-settings-row
>${alexa_registered
? html`
<ha-settings-row>
<span slot="heading">
${this.hass!.localize(
"ui.panel.config.cloud.account.alexa.enable_state_reporting"
)}
</span>
<span slot="description">
${this.hass!.localize(
"ui.panel.config.cloud.account.alexa.info_state_reporting"
)}
</span>
<ha-switch
.checked=${alexa_report_state}
@change=${this._reportToggleChanged}
></ha-switch>
</ha-settings-row>
`
: ""}`}
</div>
<div class="card-actions">
<a
href="/config/voice-assistants/expose?assistants=cloud.alexa&historyBack"
>
<mwc-button
>${this.hass!.localize(
"ui.panel.config.cloud.account.alexa.manage_entities"
)}</mwc-button
>
</a>
</div>
</ha-card>
`;
}
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);

View File

@ -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`
<ha-card outlined>
<h1 class="card-header">
<img
alt=""
src=${brandsUrl({
domain: "google_assistant",
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
})}
referrerpolicy="no-referrer"
/>${this.hass.localize("ui.panel.config.cloud.account.google.title")}
</h1>
<div class="header-actions">
<a
href="https://www.nabucasa.com/config/google_assistant/"
target="_blank"
rel="noreferrer"
class="icon-link"
>
<ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.cloud.account.google.link_learn_how_it_works"
)}
.path=${mdiHelpCircle}
></ha-icon-button>
</a>
<ha-switch
.checked=${google_enabled}
@change=${this._enabledToggleChanged}
></ha-switch>
</div>
<div class="card-content">
<p>
${this.hass.localize("ui.panel.config.cloud.account.google.info")}
</p>
${manualConfig
? html`<ha-alert alert-type="warning">
${this.hass.localize(
"ui.panel.config.cloud.account.google.manual_config"
)}
</ha-alert>`
: ""}
${!google_enabled
? ""
: html`${!google_registered
? html`
<ha-alert
.title=${this.hass.localize(
"ui.panel.config.cloud.account.google.not_configured_title"
)}
>
${this.hass.localize(
"ui.panel.config.cloud.account.google.not_configured_text"
)}
<ul>
<li>
<a
href="https://assistant.google.com/services/a/uid/00000091fd5fb875?hl=en-US"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.account.google.enable_ha_skill"
)}
</a>
</li>
<li>
<a
href="https://www.nabucasa.com/config/google_assistant/"
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.cloud.account.google.config_documentation"
)}
</a>
</li>
</ul>
</ha-alert>
`
: ""}
<ha-settings-row>
<span slot="heading">
${this.hass!.localize(
"ui.panel.config.cloud.account.google.expose_new_entities"
)}
</span>
<span slot="description">
${this.hass!.localize(
"ui.panel.config.cloud.account.google.expose_new_entities_info"
)}
</span>
<ha-switch
.checked=${this._exposeNew}
.disabled=${this._exposeNew === undefined}
@change=${this._exposeNewToggleChanged}
></ha-switch> </ha-settings-row
>${google_registered
? html`
${this.cloudStatus.http_use_ssl
? html`
<ha-alert
alert-type="warning"
.title=${this.hass.localize(
"ui.panel.config.cloud.account.google.http_use_ssl_warning_title"
)}
>
${this.hass.localize(
"ui.panel.config.cloud.account.google.http_use_ssl_warning_text"
)}
<a
href="https://www.nabucasa.com/config/google_assistant/#local-communication"
target="_blank"
rel="noreferrer"
>${this.hass.localize(
"ui.panel.config.common.learn_more"
)}</a
>
</ha-alert>
`
: ""}
<ha-settings-row>
<span slot="heading">
${this.hass!.localize(
"ui.panel.config.cloud.account.google.enable_state_reporting"
)}
</span>
<span slot="description">
${this.hass!.localize(
"ui.panel.config.cloud.account.google.info_state_reporting"
)}
</span>
<ha-switch
.checked=${google_report_state}
@change=${this._reportToggleChanged}
></ha-switch>
</ha-settings-row>
<ha-settings-row>
<span slot="heading">
${this.hass.localize(
"ui.panel.config.cloud.account.google.security_devices"
)}
</span>
<span slot="description">
${this.hass.localize(
"ui.panel.config.cloud.account.google.enter_pin_info"
)}
</span>
</ha-settings-row>
<ha-textfield
id="google_secure_devices_pin"
.label=${this.hass.localize(
"ui.panel.config.cloud.account.google.devices_pin"
)}
.placeholder=${this.hass.localize(
"ui.panel.config.cloud.account.google.enter_pin_hint"
)}
.value=${google_secure_devices_pin || ""}
@change=${this._pinChanged}
></ha-textfield>
`
: ""}`}
</div>
<div class="card-actions">
<a
href="/config/voice-assistants/expose?assistants=cloud.google_assistant&historyBack"
>
<mwc-button>
${this.hass.localize(
"ui.panel.config.cloud.account.google.manage_entities"
)}
</mwc-button>
</a>
</div>
</ha-card>
`;
}
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);

View File

@ -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<void> {
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`
<ha-dialog
open
@closed=${this.closeDialog}
.heading=${this.hass.localize(
"ui.panel.config.voice_assistants.expose.expose_dialog.header"
)}
>
<div slot="heading">
<h2 class="header">
${this.hass.localize(
"ui.panel.config.voice_assistants.expose.expose_dialog.header"
)}
</h2>
<ha-icon-button
.label=${this.hass.localize("ui.dialogs.generic.close")}
.path=${mdiClose}
dialogAction="close"
class="header_button"
></ha-icon-button>
<search-input
.hass=${this.hass}
.filter=${this._filter}
@value-changed=${this._filterChanged}
></search-input>
</div>
<mwc-list multi>
${this._filterEntities(
this._params.extendedEntities,
this._filter
).map((entity) => this._renderItem(entity))}
</mwc-list>
<mwc-button
slot="primaryAction"
@click=${this._expose}
.disabled=${this._selected.length === 0}
>
${this.hass.localize(
"ui.panel.config.voice_assistants.expose.expose_dialog.expose_entities",
{ count: this._selected.length }
)}
</mwc-button>
</ha-dialog>
`;
}
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<string, ExtEntityRegistryEntry>, 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`<ha-check-list-item
graphic="icon"
.value=${entity.entity_id}
.selected=${this._selected.includes(entity.entity_id)}
@request-selected=${this._handleSelected}
>
<ha-state-icon
title=${ifDefined(entityState?.state)}
slot="graphic"
.state=${entityState}
></ha-state-icon>
${computeEntityRegistryName(this.hass!, entity)}
</ha-check-list-item>`;
};
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;
}
}

View File

@ -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<void> {
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`
<ha-dialog
open
@closed=${this.closeDialog}
hideActions
.heading=${createCloseHeading(
this.hass,
computeEntityRegistryName(this.hass, this._extEntityReg) ||
"Unnamed entity"
)}
>
<div>
<entity-voice-settings
.hass=${this.hass}
.entry=${this._extEntityReg}
@entity-entry-updated=${this._entityEntryUpdated}
></entity-voice-settings>
</div>
</ha-dialog>
`;
}
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;
}
}

View File

@ -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<this>) {
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`<ha-settings-row>
<span slot="heading"
>${this.hass.localize(
"ui.dialogs.voice-settings.expose_header"
)}</span
>
<ha-switch
@change=${this._toggleAll}
.checked=${anyExposed}
></ha-switch>
</ha-settings-row>
${anyExposed
? showAssistants.map(
(key) => html`<ha-settings-row>
<img
alt=""
src=${brandsUrl({
domain: voiceAssistants[key].domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
})}
referrerpolicy="no-referrer"
slot="prefix"
/>
<span slot="heading">${voiceAssistants[key].name}</span>
${key === "cloud.google_assistant" &&
!googleManual &&
this._googleEntity?.might_2fa
? html`<ha-formfield
slot="description"
.label=${this.hass.localize(
"ui.dialogs.voice-settings.ask_pin"
)}
>
<ha-checkbox
.checked=${!this.entry.options?.[key]?.disable_2fa}
@change=${this._2faChanged}
></ha-checkbox>
</ha-formfield>`
: (alexaManual && key === "cloud.alexa") ||
(googleManual && key === "cloud.google_assistant")
? html`<span slot="description"
>${this.hass.localize(
"ui.dialogs.voice-settings.manual_config"
)}</span
>`
: ""}
<ha-switch
.assistant=${key}
@change=${this._toggleAssistant}
.disabled=${(alexaManual && key === "cloud.alexa") ||
(googleManual && key === "cloud.google_assistant")}
.checked=${alexaManual && key === "cloud.alexa"
? manExposedAlexa
: googleManual && key === "cloud.google_assistant"
? manExposedGoogle
: this.entry.options?.[key]?.should_expose}
></ha-switch>
</ha-settings-row>`
)
: ""}
<h3>
${this.hass.localize("ui.dialogs.voice-settings.aliasses_header")}
</h3>
<ha-aliases-editor
.hass=${this.hass}
.aliases=${this._aliases ?? this.entry.aliases}
@value-changed=${this._aliasesChanged}
@blur=${this._saveAliases}
></ha-aliases-editor>`;
}
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;
}
}

View File

@ -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`<hass-loading-screen></hass-loading-screen>`;
}
return html`
<hass-tabs-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.route=${this.route}
.tabs=${voiceAssistantTabs}
>
<div class="content">
${this.cloudStatus?.logged_in
? html`<cloud-alexa-pref
.hass=${this.hass}
.cloudStatus=${this.cloudStatus}
dir=${computeRTLDirection(this.hass)}
></cloud-alexa-pref>
<cloud-google-pref
.hass=${this.hass}
.cloudStatus=${this.cloudStatus}
dir=${computeRTLDirection(this.hass)}
></cloud-google-pref>`
: html`<ha-card
header="Easily connect to voice assistants with Home Assistant Cloud"
>
<div class="card-content">
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.
</div>
<div class="card-actions">
<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer"
>
<mwc-button>Learn more</mwc-button>
</a>
</div>
</ha-card>
${isComponentLoaded(this.hass, "cloud")
? html` <ha-card outlined>
<a href="/config/cloud/register">
<paper-item>
<paper-item-body two-line>
${this.hass.localize(
"ui.panel.config.cloud.login.start_trial"
)}
<div secondary>
${this.hass.localize(
"ui.panel.config.cloud.login.trial_info"
)}
</div>
</paper-item-body>
<ha-icon-next></ha-icon-next>
</paper-item>
</a>
</ha-card>`
: ""}`}
</div>
</hass-tabs-subpage>
`;
}
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;
}
`;
}

View File

@ -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<string, ExtEntityRegistryEntry>;
@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`
<ha-state-icon
title=${ifDefined(entry.entity?.state)}
.state=${entry.entity}
></ha-state-icon>
`,
},
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}<br />
<div class="secondary">${entry.entity_id}</div>
`
: 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`<div>
<img
style="height: 24px; margin-right: 16px;${styleMap({
filter: entry.manAssistants?.includes(key)
? "grayscale(100%)"
: "",
})}"
alt=""
src=${brandsUrl({
domain: voiceAssistants[key].domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
})}
referrerpolicy="no-referrer"
slot="prefix"
/>${entry.manAssistants?.includes(key)
? html`<simple-tooltip
animation-delay="0"
position="bottom"
offset="1"
>
Configured in YAML, not editable in UI
</simple-tooltip>`
: ""}
</div>`
: html`<div style="width: 40px;"></div>`
)}`,
},
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`<ha-icon-button
@click=${this._removeEntity}
.path=${mdiMinusCircleOutline}
></ha-icon-button>`,
},
})
);
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<string, ExtEntityRegistryEntry> | 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<string, DataTableRowData> = {};
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`<hass-loading-screen></hass-loading-screen>`;
}
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`
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.backPath=${this._searchParms.has("historyBack")
? undefined
: "/config"}
.route=${this.route}
.tabs=${voiceAssistantTabs}
.columns=${this._columns(this.narrow, this.hass.language)}
.data=${filteredEntities}
.activeFilters=${activeFilters}
.numHidden=${this._numHiddenEntities}
.hideFilterMenu=${this._selectedEntities.length > 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`
<div
class=${classMap({
"header-toolbar": this.narrow,
"table-header": !this.narrow,
})}
slot="header"
>
<p class="selected-txt">
${this.hass.localize(
"ui.panel.config.entities.picker.selected",
"number",
this._selectedEntities.length
)}
</p>
<div class="header-btns">
${!this.narrow
? html`
<mwc-button @click=${this._exposeSelected}
>${this.hass.localize(
"ui.panel.config.voice_assistants.expose.expose"
)}</mwc-button
>
<mwc-button @click=${this._unexposeSelected}
>${this.hass.localize(
"ui.panel.config.voice_assistants.expose.unexpose"
)}</mwc-button
>
`
: html`
<ha-icon-button
id="enable-btn"
@click=${this._exposeSelected}
.path=${mdiPlusCircle}
.label=${this.hass.localize(
"ui.panel.config.voice_assistants.expose.expose"
)}
></ha-icon-button>
<simple-tooltip animation-delay="0" for="enable-btn">
${this.hass.localize(
"ui.panel.config.voice_assistants.expose.expose"
)}
</simple-tooltip>
<ha-icon-button
id="disable-btn"
@click=${this._unexposeSelected}
.path=${mdiMinusCircle}
.label=${this.hass.localize(
"ui.panel.config.voice_assistants.expose.unexpose"
)}
></ha-icon-button>
<simple-tooltip animation-delay="0" for="disable-btn">
${this.hass.localize(
"ui.panel.config.voice_assistants.expose.unexpose"
)}
</simple-tooltip>
`}
</div>
</div>
`
: ""}
<ha-fab
slot="fab"
.label=${this.hass.localize(
"ui.panel.config.voice_assistants.expose.add"
)}
extended
?rtl=${computeRTL(this.hass)}
@click=${this._addEntry}
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-fab>
</hass-tabs-subpage-data-table>
`;
}
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<SelectionChangedEvent>
): 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;
}
}

View File

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

View File

@ -0,0 +1,21 @@
import { fireEvent } from "../../../common/dom/fire_event";
import { ExtEntityRegistryEntry } from "../../../data/entity_registry";
export interface ExposeEntityDialogParams {
filterAssistants: string[];
extendedEntities: Record<string, ExtEntityRegistryEntry>;
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,
});
};

View File

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

View File

@ -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",

View File

@ -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.",