ha-frontend/src/panels/config/voice-assistants/ha-config-voice-assistants-...

768 lines
24 KiB
TypeScript

import { consume } from "@lit-labs/context";
import "@lrnwebcomponents/simple-tooltip/simple-tooltip";
import {
mdiCloseBoxMultiple,
mdiCloseCircleOutline,
mdiPlus,
mdiPlusBoxMultiple,
} from "@mdi/js";
import { CSSResultGroup, LitElement, PropertyValues, css, html } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import memoize from "memoize-one";
import { HASSDomEvent, fireEvent } from "../../../common/dom/fire_event";
import { computeStateName } from "../../../common/entity/compute_state_name";
import {
EntityFilter,
generateFilter,
isEmptyFilter,
} from "../../../common/entity/entity_filter";
import { navigate } from "../../../common/navigate";
import {
DataTableColumnContainer,
DataTableRowData,
RowClickedEvent,
SelectionChangedEvent,
} from "../../../components/data-table/ha-data-table";
import "../../../components/ha-fab";
import { AlexaEntity, fetchCloudAlexaEntities } from "../../../data/alexa";
import { CloudStatus, CloudStatusLoggedIn } from "../../../data/cloud";
import { entitiesContext } from "../../../data/context";
import {
ExtEntityRegistryEntry,
getExtendedEntityRegistryEntries,
} from "../../../data/entity_registry";
import {
ExposeEntitySettings,
exposeEntities,
voiceAssistants,
} from "../../../data/expose";
import {
GoogleEntity,
fetchCloudGoogleEntities,
} from "../../../data/google_assistant";
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 { haStyle } from "../../../resources/styles";
import { HomeAssistant, Route } from "../../../types";
import { LocalizeFunc } from "../../../common/translations/localize";
import "./expose/expose-assistant-icon";
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 LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public cloudStatus?: CloudStatus;
@property({ type: Boolean }) public isWide = false;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public route!: Route;
@property({ attribute: false }) public exposedEntities?: Record<
string,
ExposeEntitySettings
>;
@state()
@consume({ context: entitiesContext, subscribe: true })
_entities!: HomeAssistant["entities"];
@state() private _extEntities?: Record<string, ExtEntityRegistryEntry>;
@state() private _filter: string = history.state?.filter || "";
@state() private _searchParms = new URLSearchParams(window.location.search);
@state() private _selectedEntities: string[] = [];
@state() private _supportedEntities?: Record<
"cloud.google_assistant" | "cloud.alexa" | "conversation",
string[] | undefined
>;
@query("hass-tabs-subpage-data-table", true)
private _dataTable!: HaTabsSubpageDataTable;
private _columns = memoize(
(
narrow: boolean,
availableAssistants: string[],
supportedEntities:
| Record<
"cloud.google_assistant" | "cloud.alexa" | "conversation",
string[] | undefined
>
| undefined,
_language: string,
localize: LocalizeFunc
): DataTableColumnContainer => ({
icon: {
title: "",
type: "icon",
hidden: narrow,
template: (entry) => html`
<ha-state-icon
title=${ifDefined(entry.entity?.state)}
.stateObj=${entry.entity}
.hass=${this.hass}
></ha-state-icon>
`,
},
name: {
main: true,
title: localize("ui.panel.config.voice_assistants.expose.headers.name"),
sortable: true,
filterable: true,
direction: "asc",
grows: true,
template: (entry) => html`
${entry.name}<br />
<div class="secondary">${entry.entity_id}</div>
`,
},
area: {
title: localize("ui.panel.config.voice_assistants.expose.headers.area"),
sortable: true,
hidden: narrow,
filterable: true,
width: "15%",
},
assistants: {
title: localize(
"ui.panel.config.voice_assistants.expose.headers.assistants"
),
sortable: true,
filterable: true,
width: "160px",
type: "flex",
template: (entry) =>
html`${availableAssistants.map((key) => {
const supported =
!supportedEntities?.[key] ||
supportedEntities[key].includes(entry.entity_id);
const manual = entry.manAssistants?.includes(key);
return entry.assistants.includes(key)
? html`
<voice-assistants-expose-assistant-icon
.assistant=${key}
.hass=${this.hass}
.manual=${manual}
.unsupported=${!supported}
>
</voice-assistants-expose-assistant-icon>
`
: html`<div style="width: 40px;"></div>`;
})}`,
},
aliases: {
title: localize(
"ui.panel.config.voice_assistants.expose.headers.aliases"
),
sortable: true,
filterable: true,
hidden: narrow,
width: "15%",
template: (entry) =>
entry.aliases.length === 0
? "-"
: entry.aliases.length === 1
? entry.aliases[0]
: this.hass.localize(
"ui.panel.config.voice_assistants.expose.aliases",
{ count: entry.aliases.length }
),
},
remove: {
title: "",
type: "icon-button",
hidden: narrow,
template: () =>
html`<ha-icon-button
@click=${this._removeEntity}
.path=${mdiCloseCircleOutline}
></ha-icon-button>`,
},
// For search
entity_id: {
title: "",
hidden: true,
filterable: true,
},
})
);
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 _availableAssistants = memoize(
(cloudStatus: CloudStatus | undefined) => {
const googleEnabled =
cloudStatus?.logged_in === true &&
cloudStatus.prefs.google_enabled === true;
const alexaEnabled =
cloudStatus?.logged_in === true &&
cloudStatus.prefs.alexa_enabled === true;
const showAssistants = [...Object.keys(voiceAssistants)];
if (!googleEnabled) {
showAssistants.splice(
showAssistants.indexOf("cloud.google_assistant"),
1
);
}
if (!alexaEnabled) {
showAssistants.splice(showAssistants.indexOf("cloud.alexa"), 1);
}
return showAssistants;
}
);
private _filteredEntities = memoize(
(
entities: Record<string, ExtEntityRegistryEntry>,
exposedEntities: Record<string, ExposeEntitySettings>,
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 = [...this._availableAssistants(cloudStatus)];
const alexaManual =
alexaEnabled &&
!isEmptyFilter(
(this.cloudStatus as CloudStatusLoggedIn).alexa_entities
);
const googleManual =
googleEnabled &&
!isEmptyFilter(
(this.cloudStatus as CloudStatusLoggedIn).google_entities
);
if (googleManual) {
showAssistants.splice(
showAssistants.indexOf("cloud.google_assistant"),
1
);
}
if (alexaManual) {
showAssistants.splice(showAssistants.indexOf("cloud.alexa"), 1);
}
const result: Record<string, DataTableRowData> = {};
let filteredEntities = Object.values(this.hass.states);
filteredEntities = filteredEntities.filter((entity) =>
showAssistants.some(
(assis) => exposedEntities?.[entity.entity_id]?.[assis]
)
);
let filteredAssistants: string[];
filters.forEach((value, key) => {
if (key === "assistants") {
filteredAssistants = value.split(",");
filteredEntities = filteredEntities.filter((entity) =>
filteredAssistants.some(
(assis) =>
!(assis === "cloud.alexa" && alexaManual) &&
!(assis === "cloud.google_assistant" && googleManual) &&
exposedEntities?.[entity.entity_id]?.[assis]
)
);
}
});
for (const entityState of filteredEntities) {
const entry: ExtEntityRegistryEntry | undefined =
entities[entityState.entity_id];
const areaId =
entry?.area_id ??
(entry?.device_id ? devices[entry.device_id!]?.area_id : undefined);
const area = areaId ? areas[areaId] : undefined;
result[entityState.entity_id] = {
entity_id: entityState.entity_id,
entity: entityState,
name:
computeStateName(entityState) ||
this.hass.localize(
"ui.panel.config.entities.picker.unnamed_entity"
),
area: area ? area.name : "—",
assistants: Object.keys(
exposedEntities?.[entityState.entity_id]
).filter(
(key) =>
showAssistants.includes(key) &&
exposedEntities?.[entityState.entity_id]?.[key]
),
aliases: entry?.aliases || [],
};
}
if (alexaManual || googleManual) {
const manFilterFuncs = this._getEntityFilterFuncs(
(this.cloudStatus as CloudStatusLoggedIn).google_entities,
(this.cloudStatus as CloudStatusLoggedIn).alexa_entities
);
Object.keys(this.hass.states).forEach((entityId) => {
const assistants: string[] = [];
if (alexaManual && manFilterFuncs.amazon(entityId)) {
assistants.push("cloud.alexa");
}
if (googleManual && 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 if (
!filteredAssistants ||
filteredAssistants.some((ass) => assistants.includes(ass))
) {
const entityState = this.hass.states[entityId];
const entry: ExtEntityRegistryEntry | undefined =
entities[entityId];
const areaId =
entry?.area_id ??
(entry?.device_id
? devices[entry.device_id!]?.area_id
: undefined);
const area = areaId ? areas[areaId] : undefined;
result[entityId] = {
entity_id: entityState.entity_id,
entity: entityState,
name: computeStateName(entityState),
area: area ? area.name : "—",
assistants: [
...(exposedEntities
? Object.keys(
exposedEntities?.[entityState.entity_id]
).filter(
(key) =>
showAssistants.includes(key) &&
exposedEntities?.[entityState.entity_id]?.[key]
)
: []),
...assistants,
],
manAssistants: assistants,
aliases: entry?.aliases || [],
};
}
});
}
return Object.values(result);
}
);
public connectedCallback() {
super.connectedCallback();
window.addEventListener("location-changed", this._locationChanged);
window.addEventListener("popstate", this._popState);
}
disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener("location-changed", this._locationChanged);
window.removeEventListener("popstate", this._popState);
}
private _locationChanged = () => {
if (window.location.search.substring(1) !== this._searchParms.toString()) {
this._searchParms = new URLSearchParams(window.location.search);
}
};
private _popState = () => {
if (window.location.search.substring(1) !== this._searchParms.toString()) {
this._searchParms = new URLSearchParams(window.location.search);
}
};
private async _fetchEntities() {
this._extEntities = await getExtendedEntityRegistryEntries(
this.hass,
Object.keys(this._entities)
);
this._fetchSupportedEntities();
}
private async _fetchSupportedEntities() {
let alexaEntitiesProm: Promise<AlexaEntity[]> | undefined;
let googleEntitiesProm: Promise<GoogleEntity[]> | undefined;
if (this.cloudStatus?.logged_in && this.cloudStatus.prefs.alexa_enabled) {
alexaEntitiesProm = fetchCloudAlexaEntities(this.hass);
}
if (this.cloudStatus?.logged_in && this.cloudStatus.prefs.google_enabled) {
googleEntitiesProm = fetchCloudGoogleEntities(this.hass);
}
const [alexaEntities, googleEntities] = await Promise.all([
alexaEntitiesProm,
googleEntitiesProm,
]);
this._supportedEntities = {
"cloud.alexa": alexaEntities?.map((entity) => entity.entity_id),
"cloud.google_assistant": googleEntities?.map(
(entity) => entity.entity_id
),
// TODO add supported entity for assist
conversation: undefined,
};
}
public willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.has("_entities")) {
this._fetchEntities();
return;
}
if (
changedProperties.has("hass") &&
this.hass.config.state === "RUNNING" &&
changedProperties.get("hass")?.config.state !== this.hass.config.state
) {
this._fetchSupportedEntities();
}
}
protected render() {
if (!this.hass || !this.exposedEntities || !this._extEntities) {
return html`<hass-loading-screen></hass-loading-screen>`;
}
const filteredEntities = this._filteredEntities(
this._extEntities,
this.exposedEntities,
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._availableAssistants(this.cloudStatus),
this._supportedEntities,
this.hass.language,
this.hass.localize
)}
.data=${filteredEntities}
.searchLabel=${this.hass.localize(
"ui.panel.config.entities.picker.search"
)}
.filter=${this._filter}
selectable
.selected=${this._selectedEntities.length}
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="header-btns" slot="selection-bar">
${!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=${mdiPlusBoxMultiple}
.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=${mdiCloseBoxMultiple}
.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>
`
: ""}
<ha-fab
slot="fab"
.label=${this.hass.localize(
"ui.panel.config.voice_assistants.expose.add"
)}
extended
@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(",")
: this._availableAssistants(this.cloudStatus);
showExposeEntityDialog(this, {
filterAssistants: assistants,
exposedEntities: this.exposedEntities!,
exposeEntities: (entities) => {
exposeEntities(this.hass, assistants, entities, true).then(() =>
fireEvent(this, "exposed-entities-changed")
);
},
});
}
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(",")
: this._availableAssistants(this.cloudStatus);
exposeEntities(this.hass, assistants, [entityId], false).then(() =>
fireEvent(this, "exposed-entities-changed")
);
};
private _unexposeSelected() {
const assistants = this._searchParms.has("assistants")
? this._searchParms.get("assistants")!.split(",")
: this._availableAssistants(this.cloudStatus);
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
).then(() => fireEvent(this, "exposed-entities-changed"));
this._clearSelection();
},
});
}
private _exposeSelected() {
const assistants = this._searchParms.has("assistants")
? this._searchParms.get("assistants")!.split(",")
: this._availableAssistants(this.cloudStatus);
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
).then(() => fireEvent(this, "exposed-entities-changed"));
this._clearSelection();
},
});
}
private _clearSelection() {
this._dataTable.clearSelection();
}
private _openEditEntry(ev: CustomEvent): void {
const entityId = (ev.detail as RowClickedEvent).id;
showVoiceSettingsDialog(this, {
entityId,
exposed: this.exposedEntities![entityId],
extEntityReg: this._extEntities?.[entityId],
exposedEntitiesChanged: () => {
fireEvent(this, "exposed-entities-changed");
},
});
}
private _clearFilter() {
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;
margin-inline-start: 8px;
margin-inline-end: initial;
}
.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;
}
}