diff --git a/public/static/icons/casita/loading.png b/public/static/icons/casita/loading.png new file mode 100644 index 0000000000..b2bcd76052 Binary files /dev/null and b/public/static/icons/casita/loading.png differ diff --git a/public/static/icons/casita/loving.png b/public/static/icons/casita/loving.png new file mode 100644 index 0000000000..5f1b518683 Binary files /dev/null and b/public/static/icons/casita/loving.png differ diff --git a/public/static/icons/casita/normal.png b/public/static/icons/casita/normal.png new file mode 100644 index 0000000000..0b72c54b99 Binary files /dev/null and b/public/static/icons/casita/normal.png differ diff --git a/public/static/icons/casita/sad.png b/public/static/icons/casita/sad.png new file mode 100644 index 0000000000..d97183685e Binary files /dev/null and b/public/static/icons/casita/sad.png differ diff --git a/public/static/icons/casita/sleeping.png b/public/static/icons/casita/sleeping.png new file mode 100644 index 0000000000..957a10b6e3 Binary files /dev/null and b/public/static/icons/casita/sleeping.png differ diff --git a/public/static/icons/casita/smiling.png b/public/static/icons/casita/smiling.png new file mode 100644 index 0000000000..5fb4d94587 Binary files /dev/null and b/public/static/icons/casita/smiling.png differ diff --git a/src/data/assist_satellite.ts b/src/data/assist_satellite.ts new file mode 100644 index 0000000000..15c91527b9 --- /dev/null +++ b/src/data/assist_satellite.ts @@ -0,0 +1,81 @@ +import { HassEntity } from "home-assistant-js-websocket"; +import { HomeAssistant } from "../types"; +import { supportsFeature } from "../common/entity/supports-feature"; +import { UNAVAILABLE } from "./entity"; + +export const enum AssistSatelliteEntityFeature { + ANNOUNCE = 1, +} + +export interface WakeWordInterceptMessage { + wake_word_phrase: string; +} + +export interface WakeWordOption { + id: string; + wake_word: string; + trained_languages: string[]; +} + +export interface AssistSatelliteConfiguration { + active_wake_words: string[]; + available_wake_words: WakeWordOption[]; + max_active_wake_words: number; + pipeline_entity_id: string; + vad_entity_id: string; +} + +export const interceptWakeWord = ( + hass: HomeAssistant, + entity_id: string, + callback: (result: WakeWordInterceptMessage) => void +) => + hass.connection.subscribeMessage(callback, { + type: "assist_satellite/intercept_wake_word", + entity_id, + }); + +export const testAssistSatelliteConnection = ( + hass: HomeAssistant, + entity_id: string +) => + hass.callWS<{ + status: "success" | "timeout"; + }>({ + type: "assist_satellite/test_connection", + entity_id, + }); + +export const assistSatelliteAnnounce = ( + hass: HomeAssistant, + entity_id: string, + message: string +) => + hass.callService("assist_satellite", "announce", { message }, { entity_id }); + +export const fetchAssistSatelliteConfiguration = ( + hass: HomeAssistant, + entity_id: string +) => + hass.callWS({ + type: "assist_satellite/get_configuration", + entity_id, + }); + +export const setWakeWords = ( + hass: HomeAssistant, + entity_id: string, + wake_word_ids: string[] +) => + hass.callWS({ + type: "assist_satellite/set_wake_words", + entity_id, + wake_word_ids, + }); + +export const assistSatelliteSupportsSetupFlow = ( + assistSatelliteEntity: HassEntity | undefined +) => + assistSatelliteEntity && + assistSatelliteEntity.state !== UNAVAILABLE && + supportsFeature(assistSatelliteEntity, AssistSatelliteEntityFeature.ANNOUNCE); diff --git a/src/dialogs/config-flow/step-flow-create-entry.ts b/src/dialogs/config-flow/step-flow-create-entry.ts index 43c5e93e3c..a897378c04 100644 --- a/src/dialogs/config-flow/step-flow-create-entry.ts +++ b/src/dialogs/config-flow/step-flow-create-entry.ts @@ -1,6 +1,14 @@ import "@material/mwc-button"; -import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { + css, + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, +} from "lit"; import { customElement, property } from "lit/decorators"; +import memoizeOne from "memoize-one"; import { fireEvent } from "../../common/dom/fire_event"; import "../../components/ha-area-picker"; import { DataEntryFlowStepCreateEntry } from "../../data/data_entry_flow"; @@ -9,10 +17,14 @@ import { DeviceRegistryEntry, updateDeviceRegistryEntry, } from "../../data/device_registry"; +import { EntityRegistryDisplayEntry } from "../../data/entity_registry"; import { HomeAssistant } from "../../types"; import { showAlertDialog } from "../generic/show-dialog-box"; import { FlowConfig } from "./show-dialog-data-entry-flow"; import { configFlowContentStyles } from "./styles"; +import { computeDomain } from "../../common/entity/compute_domain"; +import { showVoiceAssistantSetupDialog } from "../voice-assistant-setup/show-voice-assistant-setup-dialog"; +import { assistSatelliteSupportsSetupFlow } from "../../data/assist_satellite"; @customElement("step-flow-create-entry") class StepFlowCreateEntry extends LitElement { @@ -24,6 +36,46 @@ class StepFlowCreateEntry extends LitElement { @property({ attribute: false }) public devices!: DeviceRegistryEntry[]; + private _deviceEntities = memoizeOne( + ( + deviceId: string, + entities: EntityRegistryDisplayEntry[], + domain?: string + ): EntityRegistryDisplayEntry[] => + entities.filter( + (entity) => + entity.device_id === deviceId && + (!domain || computeDomain(entity.entity_id) === domain) + ) + ); + + protected willUpdate(changedProps: PropertyValues) { + if ( + (changedProps.has("devices") || changedProps.has("hass")) && + this.devices.length === 1 + ) { + // integration_type === "device" + const assistSatellites = this._deviceEntities( + this.devices[0].id, + Object.values(this.hass.entities), + "assist_satellite" + ); + if ( + assistSatellites.length && + assistSatellites.some((satellite) => + assistSatelliteSupportsSetupFlow( + this.hass.states[satellite.entity_id] + ) + ) + ) { + this._flowDone(); + showVoiceAssistantSetupDialog(this, { + deviceId: this.devices[0].id, + }); + } + } + } + protected render(): TemplateResult { const localize = this.hass.localize; diff --git a/src/dialogs/voice-assistant-setup/show-voice-assistant-setup-dialog.ts b/src/dialogs/voice-assistant-setup/show-voice-assistant-setup-dialog.ts new file mode 100644 index 0000000000..26c85f979d --- /dev/null +++ b/src/dialogs/voice-assistant-setup/show-voice-assistant-setup-dialog.ts @@ -0,0 +1,19 @@ +import { fireEvent } from "../../common/dom/fire_event"; + +const loadVoiceAssistantSetupDialog = () => + import("./voice-assistant-setup-dialog"); + +export interface VoiceAssistantSetupDialogParams { + deviceId: string; +} + +export const showVoiceAssistantSetupDialog = ( + element: HTMLElement, + dialogParams: VoiceAssistantSetupDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "ha-voice-assistant-setup-dialog", + dialogImport: loadVoiceAssistantSetupDialog, + dialogParams: dialogParams, + }); +}; diff --git a/src/dialogs/voice-assistant-setup/styles.ts b/src/dialogs/voice-assistant-setup/styles.ts new file mode 100644 index 0000000000..e0357bb996 --- /dev/null +++ b/src/dialogs/voice-assistant-setup/styles.ts @@ -0,0 +1,36 @@ +import { css } from "lit"; +import { haStyle } from "../../resources/styles"; + +export const AssistantSetupStyles = [ + haStyle, + css` + :host { + align-items: center; + text-align: center; + min-height: 300px; + max-width: 500px; + display: flex; + flex-direction: column; + justify-content: space-between; + height: 100%; + padding: 24px; + box-sizing: border-box; + } + .content { + flex: 1; + } + .content img { + width: 120px; + margin-top: 68px; + margin-bottom: 68px; + } + .footer { + width: 100%; + display: flex; + flex-direction: column; + } + .footer ha-button { + width: 100%; + } + `, +]; diff --git a/src/dialogs/voice-assistant-setup/voice-assistant-setup-dialog.ts b/src/dialogs/voice-assistant-setup/voice-assistant-setup-dialog.ts new file mode 100644 index 0000000000..899d72ac89 --- /dev/null +++ b/src/dialogs/voice-assistant-setup/voice-assistant-setup-dialog.ts @@ -0,0 +1,276 @@ +import "@material/mwc-button/mwc-button"; +import { mdiChevronLeft } from "@mdi/js"; +import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../../common/dom/fire_event"; +import { computeDomain } from "../../common/entity/compute_domain"; +import "../../components/ha-dialog"; +import { + AssistSatelliteConfiguration, + fetchAssistSatelliteConfiguration, +} from "../../data/assist_satellite"; +import { EntityRegistryDisplayEntry } from "../../data/entity_registry"; +import { haStyleDialog } from "../../resources/styles"; +import type { HomeAssistant } from "../../types"; +import { VoiceAssistantSetupDialogParams } from "./show-voice-assistant-setup-dialog"; +import "./voice-assistant-setup-step-addons"; +import "./voice-assistant-setup-step-area"; +import "./voice-assistant-setup-step-change-wake-word"; +import "./voice-assistant-setup-step-check"; +import "./voice-assistant-setup-step-cloud"; +import "./voice-assistant-setup-step-pipeline"; +import "./voice-assistant-setup-step-success"; +import "./voice-assistant-setup-step-update"; +import "./voice-assistant-setup-step-wake-word"; +import { UNAVAILABLE } from "../../data/entity"; + +export const enum STEP { + INIT, + UPDATE, + CHECK, + WAKEWORD, + AREA, + PIPELINE, + SUCCESS, + CLOUD, + ADDONS, + CHANGE_WAKEWORD, +} + +@customElement("ha-voice-assistant-setup-dialog") +export class HaVoiceAssistantSetupDialog extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _params?: VoiceAssistantSetupDialogParams; + + @state() private _step: STEP = STEP.INIT; + + @state() private _assistConfiguration?: AssistSatelliteConfiguration; + + private _previousSteps: STEP[] = []; + + public async showDialog( + params: VoiceAssistantSetupDialogParams + ): Promise { + this._params = params; + + await this._fetchAssistConfiguration(); + + this._step = STEP.UPDATE; + } + + public async closeDialog(): Promise { + this.renderRoot.querySelector("ha-dialog")?.close(); + } + + private _dialogClosed() { + this._params = undefined; + this._assistConfiguration = undefined; + this._step = STEP.INIT; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + private _deviceEntities = memoizeOne( + ( + deviceId: string, + entities: HomeAssistant["entities"] + ): EntityRegistryDisplayEntry[] => + Object.values(entities).filter((entity) => entity.device_id === deviceId) + ); + + private _findDomainEntityId = memoizeOne( + ( + deviceId: string, + entities: HomeAssistant["entities"], + domain: string + ): string | undefined => { + const deviceEntities = this._deviceEntities(deviceId, entities); + return deviceEntities.find( + (ent) => computeDomain(ent.entity_id) === domain + )?.entity_id; + } + ); + + protected render() { + if (!this._params) { + return nothing; + } + + const assistSatelliteEntityId = this._findDomainEntityId( + this._params.deviceId, + this.hass.entities, + "assist_satellite" + ); + + const assistEntityState = assistSatelliteEntityId + ? this.hass.states[assistSatelliteEntityId] + : undefined; + + return html` + + + ${this._previousSteps.length + ? html`` + : nothing} + +
+ ${this._step === STEP.UPDATE + ? html`` + : assistEntityState?.state === UNAVAILABLE + ? html`Your voice assistant is not available.` + : this._step === STEP.CHECK + ? html`` + : this._step === STEP.WAKEWORD + ? html`` + : this._step === STEP.CHANGE_WAKEWORD + ? html` + + ` + : this._step === STEP.AREA + ? html` + + ` + : this._step === STEP.PIPELINE + ? html`` + : this._step === STEP.CLOUD + ? html`` + : this._step === STEP.ADDONS + ? html`` + : this._step === STEP.SUCCESS + ? html`` + : nothing} +
+
+ `; + } + + private async _fetchAssistConfiguration() { + this._assistConfiguration = await fetchAssistSatelliteConfiguration( + this.hass, + this._findDomainEntityId( + this._params!.deviceId, + this.hass.entities, + "assist_satellite" + )! + ); + return this._assistConfiguration; + } + + private _goToPreviousStep() { + if (!this._previousSteps.length) { + return; + } + this._step = this._previousSteps.pop()!; + } + + private _nextStep(ev) { + if (ev.detail?.updateConfig) { + this._fetchAssistConfiguration(); + } + if (!ev.detail?.noPrevious) { + this._previousSteps.push(this._step); + } + if (ev.detail?.step) { + this._step = ev.detail.step; + } else { + this._step += 1; + } + } + + static get styles(): CSSResultGroup { + return [ + haStyleDialog, + css` + ha-dialog { + --dialog-content-padding: 0; + } + ha-dialog-header { + height: 56px; + } + @media all and (max-width: 450px), all and (max-height: 500px) { + .content { + height: calc(100vh - 56px); + } + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-voice-assistant-setup-dialog": HaVoiceAssistantSetupDialog; + } + + interface HASSDomEvents { + "next-step": + | { step?: STEP; updateConfig?: boolean; noPrevious?: boolean } + | undefined; + } +} diff --git a/src/dialogs/voice-assistant-setup/voice-assistant-setup-step-addons.ts b/src/dialogs/voice-assistant-setup/voice-assistant-setup-step-addons.ts new file mode 100644 index 0000000000..69c3785e6f --- /dev/null +++ b/src/dialogs/voice-assistant-setup/voice-assistant-setup-step-addons.ts @@ -0,0 +1,187 @@ +import { css, html, LitElement, nothing, PropertyValues } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../common/dom/fire_event"; +import { HomeAssistant } from "../../types"; +import { AssistantSetupStyles } from "./styles"; +import { STEP } from "./voice-assistant-setup-dialog"; +import { documentationUrl } from "../../util/documentation-url"; + +@customElement("ha-voice-assistant-setup-step-addons") +export class HaVoiceAssistantSetupStepAddons extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _showFirst = false; + + @state() private _showSecond = false; + + @state() private _showThird = false; + + @state() private _showFourth = false; + + protected override firstUpdated(changedProperties: PropertyValues) { + super.firstUpdated(changedProperties); + setTimeout(() => { + this._showFirst = true; + }, 200); + setTimeout(() => { + this._showSecond = true; + }, 600); + setTimeout(() => { + this._showThird = true; + }, 3000); + setTimeout(() => { + this._showFourth = true; + }, 8000); + } + + protected override render() { + return html`
+

Local

+

+ Are you sure you want to use the local voice assistant? It requires a + powerful device to run. If you device is not powerful enough, Home + Assistant cloud might be a better option. +

+

Home Assistant Cloud:

+
+
+ ${!this._showFirst ? "…" : "Turn on the lights in the bedroom"} +
+ ${this._showFirst + ? html`
0.2 seconds
` + : nothing} + ${this._showFirst + ? html`
+ ${!this._showSecond ? "…" : "Turned on the lights"} +
` + : nothing} + ${this._showSecond + ? html`
0.4 seconds
` + : nothing} +
+

Raspberry Pi 4:

+
+
+ ${!this._showThird ? "…" : "Turn on the lights in the bedroom"} +
+ ${this._showThird + ? html`
3 seconds
` + : nothing} + ${this._showThird + ? html`
+ ${!this._showFourth ? "…" : "Turned on the lights"} +
` + : nothing} + ${this._showFourth + ? html`
5 seconds
` + : nothing} +
+
+ `; + } + + private _close() { + fireEvent(this, "closed"); + } + + private _skip() { + fireEvent(this, "next-step", { step: STEP.SUCCESS }); + } + + static styles = [ + AssistantSetupStyles, + css` + .messages-container { + padding: 24px; + box-sizing: border-box; + height: 195px; + background: var(--input-fill-color); + border-radius: 16px; + border: 1px solid var(--divider-color); + display: flex; + flex-direction: column; + } + .message { + white-space: nowrap; + font-size: 18px; + clear: both; + margin: 8px 0; + padding: 8px; + border-radius: 15px; + height: 36px; + box-sizing: border-box; + overflow: hidden; + text-overflow: ellipsis; + width: 30px; + } + .rpi .message { + transition: width 1s; + } + .cloud .message { + transition: width 0.5s; + } + + .message.user { + margin-left: 24px; + margin-inline-start: 24px; + margin-inline-end: initial; + align-self: self-end; + text-align: right; + border-bottom-right-radius: 0px; + background-color: var(--primary-color); + color: var(--text-primary-color); + direction: var(--direction); + } + .timing.user { + align-self: self-end; + } + + .message.user.show { + width: 295px; + } + + .message.hass { + margin-right: 24px; + margin-inline-end: 24px; + margin-inline-start: initial; + align-self: self-start; + border-bottom-left-radius: 0px; + background-color: var(--secondary-background-color); + color: var(--primary-text-color); + direction: var(--direction); + } + .timing.hass { + align-self: self-start; + } + + .message.hass.show { + width: 184px; + } + .footer { + margin-top: 24px; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-voice-assistant-setup-step-addons": HaVoiceAssistantSetupStepAddons; + } +} diff --git a/src/dialogs/voice-assistant-setup/voice-assistant-setup-step-area.ts b/src/dialogs/voice-assistant-setup/voice-assistant-setup-step-area.ts new file mode 100644 index 0000000000..6552172e0d --- /dev/null +++ b/src/dialogs/voice-assistant-setup/voice-assistant-setup-step-area.ts @@ -0,0 +1,67 @@ +import { css, html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators"; +import { fireEvent } from "../../common/dom/fire_event"; +import { updateDeviceRegistryEntry } from "../../data/device_registry"; +import { HomeAssistant } from "../../types"; +import { showAlertDialog } from "../generic/show-dialog-box"; +import { AssistantSetupStyles } from "./styles"; + +@customElement("ha-voice-assistant-setup-step-area") +export class HaVoiceAssistantSetupStepArea extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public deviceId!: string; + + protected override render() { + const device = this.hass.devices[this.deviceId]; + + return html`
+ +

Select area

+

+ When you voice assistant knows where it is, it can better control the + devices around it. +

+ +
+ `; + } + + private async _setArea() { + const area = this.shadowRoot!.querySelector("ha-area-picker")!.value; + if (!area) { + showAlertDialog(this, { text: "Please select an area" }); + return; + } + await updateDeviceRegistryEntry(this.hass, this.deviceId, { + area_id: area, + }); + this._nextStep(); + } + + private _nextStep() { + fireEvent(this, "next-step"); + } + + static styles = [ + AssistantSetupStyles, + css` + ha-area-picker { + display: block; + width: 100%; + margin-bottom: 24px; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-voice-assistant-setup-step-area": HaVoiceAssistantSetupStepArea; + } +} diff --git a/src/dialogs/voice-assistant-setup/voice-assistant-setup-step-change-wake-word.ts b/src/dialogs/voice-assistant-setup/voice-assistant-setup-step-change-wake-word.ts new file mode 100644 index 0000000000..4a229691b3 --- /dev/null +++ b/src/dialogs/voice-assistant-setup/voice-assistant-setup-step-change-wake-word.ts @@ -0,0 +1,84 @@ +import { css, html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators"; +import { fireEvent } from "../../common/dom/fire_event"; +import { + AssistSatelliteConfiguration, + setWakeWords, +} from "../../data/assist_satellite"; +import { HomeAssistant } from "../../types"; +import { STEP } from "./voice-assistant-setup-dialog"; +import { AssistantSetupStyles } from "./styles"; +import "../../components/ha-md-list"; +import "../../components/ha-md-list-item"; + +@customElement("ha-voice-assistant-setup-step-change-wake-word") +export class HaVoiceAssistantSetupStepChangeWakeWord extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) + public assistConfiguration?: AssistSatelliteConfiguration; + + @property() public assistEntityId?: string; + + protected override render() { + return html`
+ +

Change wake word

+

+ When you voice assistant knows where it is, it can better control the + devices around it. +

+
+ + ${this.assistConfiguration!.available_wake_words.map( + (wakeWord) => + html` + ${wakeWord.wake_word} + + ` + )} + `; + } + + private async _wakeWordPicked(ev) { + if (!this.assistEntityId) { + return; + } + + const wakeWordId = ev.currentTarget.value; + + await setWakeWords(this.hass, this.assistEntityId, [wakeWordId]); + this._nextStep(); + } + + private _nextStep() { + fireEvent(this, "next-step", { step: STEP.WAKEWORD, updateConfig: true }); + } + + static styles = [ + AssistantSetupStyles, + css` + :host { + padding: 0; + } + .padding { + padding: 24px; + } + ha-md-list { + width: 100%; + text-align: initial; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-voice-assistant-setup-step-change-wake-word": HaVoiceAssistantSetupStepChangeWakeWord; + } +} diff --git a/src/dialogs/voice-assistant-setup/voice-assistant-setup-step-check.ts b/src/dialogs/voice-assistant-setup/voice-assistant-setup-step-check.ts new file mode 100644 index 0000000000..0d61e8bd73 --- /dev/null +++ b/src/dialogs/voice-assistant-setup/voice-assistant-setup-step-check.ts @@ -0,0 +1,87 @@ +import { html, LitElement, PropertyValues } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../common/dom/fire_event"; +import { testAssistSatelliteConnection } from "../../data/assist_satellite"; +import { HomeAssistant } from "../../types"; +import { AssistantSetupStyles } from "./styles"; + +@customElement("ha-voice-assistant-setup-step-check") +export class HaVoiceAssistantSetupStepCheck extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public assistEntityId?: string; + + @state() private _status?: "success" | "timeout"; + + protected override willUpdate(changedProperties: PropertyValues): void { + super.willUpdate(changedProperties); + if (!this.hasUpdated) { + this._testConnection(); + return; + } + if ( + this._status === "success" && + changedProperties.has("hass") && + this.hass.states[this.assistEntityId!]?.state === "listening_wake_word" + ) { + this._nextStep(); + } + } + + protected override render() { + return html`
+ ${this._status === "success" + ? html` +

Hi

+

+ With a couple of steps we are going to setup your voice assistant. +

` + : this._status === "timeout" + ? html` +

Error

+

+ Your device was unable to reach Home Assistant. Make sure you + have setup your + Home Assistant URL's + correctly. +

+ ` + : html` +

Checking...

+

+ We are checking if the device can reach your Home Assistant + instance. +

+ `} +
`; + } + + private async _testConnection() { + this._status = undefined; + const result = await testAssistSatelliteConnection( + this.hass, + this.assistEntityId! + ); + this._status = result.status; + } + + private _nextStep() { + fireEvent(this, "next-step", { noPrevious: true }); + } + + private _close() { + fireEvent(this, "closed"); + } + + static styles = AssistantSetupStyles; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-voice-assistant-setup-step-check": HaVoiceAssistantSetupStepCheck; + } +} diff --git a/src/dialogs/voice-assistant-setup/voice-assistant-setup-step-cloud.ts b/src/dialogs/voice-assistant-setup/voice-assistant-setup-step-cloud.ts new file mode 100644 index 0000000000..84a69f065e --- /dev/null +++ b/src/dialogs/voice-assistant-setup/voice-assistant-setup-step-cloud.ts @@ -0,0 +1,38 @@ +import { html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators"; +import { fireEvent } from "../../common/dom/fire_event"; +import { HomeAssistant } from "../../types"; +import { AssistantSetupStyles } from "./styles"; + +@customElement("ha-voice-assistant-setup-step-cloud") +export class HaVoiceAssistantSetupStepCloud extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + protected override render() { + return html`
+ +

Home Assistant Cloud

+

+ With Home Assistant Cloud, you get the best results for your voice + assistant, sign up for a free trial now. +

+
+ `; + } + + private _close() { + fireEvent(this, "closed"); + } + + static styles = AssistantSetupStyles; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-voice-assistant-setup-step-cloud": HaVoiceAssistantSetupStepCloud; + } +} diff --git a/src/dialogs/voice-assistant-setup/voice-assistant-setup-step-pipeline.ts b/src/dialogs/voice-assistant-setup/voice-assistant-setup-step-pipeline.ts new file mode 100644 index 0000000000..99204a3736 --- /dev/null +++ b/src/dialogs/voice-assistant-setup/voice-assistant-setup-step-pipeline.ts @@ -0,0 +1,304 @@ +import { mdiOpenInNew } from "@mdi/js"; +import { css, html, LitElement, nothing, PropertyValues } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { isComponentLoaded } from "../../common/config/is_component_loaded"; +import { fireEvent } from "../../common/dom/fire_event"; +import { computeDomain } from "../../common/entity/compute_domain"; +import { + createAssistPipeline, + listAssistPipelines, +} from "../../data/assist_pipeline"; +import { AssistSatelliteConfiguration } from "../../data/assist_satellite"; +import { fetchCloudStatus } from "../../data/cloud"; +import { listSTTEngines } from "../../data/stt"; +import { listTTSEngines, listTTSVoices } from "../../data/tts"; +import { HomeAssistant } from "../../types"; +import { documentationUrl } from "../../util/documentation-url"; +import { AssistantSetupStyles } from "./styles"; +import { STEP } from "./voice-assistant-setup-dialog"; + +@customElement("ha-voice-assistant-setup-step-pipeline") +export class HaVoiceAssistantSetupStepPipeline extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) + public assistConfiguration?: AssistSatelliteConfiguration; + + @property() public deviceId!: string; + + @property() public assistEntityId?: string; + + @state() private _showFirst = false; + + @state() private _showSecond = false; + + protected override willUpdate(changedProperties: PropertyValues): void { + super.willUpdate(changedProperties); + + if (!this.hasUpdated) { + this._checkCloud(); + } + } + + protected override firstUpdated(changedProperties: PropertyValues) { + super.firstUpdated(changedProperties); + setTimeout(() => { + this._showFirst = true; + }, 1); + setTimeout(() => { + this._showSecond = true; + }, 1500); + } + + protected override render() { + return html`
+
+
+ ${!this._showFirst ? "…" : "Turn on the lights in the bedroom"} +
+ ${this._showFirst + ? html`
+ ${!this._showSecond ? "…" : "Turned on the lights"} +
` + : nothing} +
+

Select system

+

+ How quickly your voice assistant responds depends on the power of your + system. +

+
+ + + Home Assistant Cloud + Ideal if you don't have a powerful system at home + + + + On this system + Local setup with the Whisper and Piper add-ons + + + + Use external system + Learn more about how to host it on another system + + + `; + } + + private async _checkCloud() { + if (!isComponentLoaded(this.hass, "cloud")) { + return; + } + const cloudStatus = await fetchCloudStatus(this.hass); + if (!cloudStatus.logged_in || !cloudStatus.active_subscription) { + return; + } + let cloudTtsEntityId; + let cloudSttEntityId; + for (const entity of Object.values(this.hass.entities)) { + if (entity.platform === "cloud") { + const domain = computeDomain(entity.entity_id); + if (domain === "tts") { + cloudTtsEntityId = entity.entity_id; + } else if (domain === "stt") { + cloudSttEntityId = entity.entity_id; + } else { + continue; + } + if (cloudTtsEntityId && cloudSttEntityId) { + break; + } + } + } + const pipelines = await listAssistPipelines(this.hass); + const preferredPipeline = pipelines.pipelines.find( + (pipeline) => pipeline.id === pipelines.preferred_pipeline + ); + + if (preferredPipeline) { + if ( + preferredPipeline.tts_engine === cloudTtsEntityId && + preferredPipeline.stt_engine === cloudSttEntityId + ) { + await this.hass.callService( + "select", + "select_option", + { option: "preferred" }, + { entity_id: this.assistConfiguration?.pipeline_entity_id } + ); + this._nextStep(STEP.SUCCESS); + return; + } + } + + let cloudPipeline = pipelines.pipelines.find( + (pipeline) => + pipeline.tts_engine === cloudTtsEntityId && + pipeline.stt_engine === cloudSttEntityId + ); + + if (!cloudPipeline) { + const ttsEngine = ( + await listTTSEngines( + this.hass, + this.hass.config.language, + this.hass.config.country || undefined + ) + ).providers.find((provider) => provider.engine_id === cloudTtsEntityId); + const ttsVoices = await listTTSVoices( + this.hass, + cloudTtsEntityId, + ttsEngine?.supported_languages![0] || this.hass.config.language + ); + + const sttEngine = ( + await listSTTEngines( + this.hass, + this.hass.config.language, + this.hass.config.country || undefined + ) + ).providers.find((provider) => provider.engine_id === cloudSttEntityId); + + let pipelineName = "Home Assistant Cloud"; + let i = 1; + while ( + pipelines.pipelines.find( + // eslint-disable-next-line @typescript-eslint/no-loop-func + (pipeline) => pipeline.name === pipelineName + ) + ) { + pipelineName = `${pipelineName} ${i}`; + i++; + } + + cloudPipeline = await createAssistPipeline(this.hass, { + name: pipelineName, + language: this.hass.config.language, + conversation_engine: "conversation.home_assistant", + conversation_language: this.hass.config.language, + stt_engine: cloudSttEntityId, + stt_language: sttEngine!.supported_languages![0], + tts_engine: cloudTtsEntityId, + tts_language: ttsEngine!.supported_languages![0], + tts_voice: ttsVoices.voices![0].voice_id, + wake_word_entity: null, + wake_word_id: null, + }); + } + + await this.hass.callService( + "select", + "select_option", + { option: cloudPipeline.name }, + { entity_id: this.assistConfiguration?.pipeline_entity_id } + ); + this._nextStep(STEP.SUCCESS); + } + + private async _setupCloud() { + fireEvent(this, "next-step", { step: STEP.CLOUD }); + } + + private async _thisSystem() { + fireEvent(this, "next-step", { step: STEP.ADDONS }); + } + + private _nextStep(step?: STEP) { + fireEvent(this, "next-step", { step }); + } + + private _close() { + fireEvent(this, "closed"); + } + + static styles = [ + AssistantSetupStyles, + css` + :host { + padding: 0; + } + .padding { + padding: 24px; + } + ha-md-list { + width: 100%; + text-align: initial; + } + + .messages-container { + padding: 24px; + box-sizing: border-box; + height: 152px; + } + .message { + white-space: nowrap; + font-size: 18px; + clear: both; + margin: 8px 0; + padding: 8px; + border-radius: 15px; + height: 36px; + box-sizing: border-box; + overflow: hidden; + text-overflow: ellipsis; + transition: width 1s; + width: 30px; + } + + .message.user { + margin-left: 24px; + margin-inline-start: 24px; + margin-inline-end: initial; + float: var(--float-end); + text-align: right; + border-bottom-right-radius: 0px; + background-color: var(--primary-color); + color: var(--text-primary-color); + direction: var(--direction); + } + + .message.user.show { + width: 295px; + } + + .message.hass { + margin-right: 24px; + margin-inline-end: 24px; + margin-inline-start: initial; + float: var(--float-start); + border-bottom-left-radius: 0px; + background-color: var(--secondary-background-color); + color: var(--primary-text-color); + direction: var(--direction); + } + + .message.hass.show { + width: 184px; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-voice-assistant-setup-step-pipeline": HaVoiceAssistantSetupStepPipeline; + } +} diff --git a/src/dialogs/voice-assistant-setup/voice-assistant-setup-step-success.ts b/src/dialogs/voice-assistant-setup/voice-assistant-setup-step-success.ts new file mode 100644 index 0000000000..bb97940892 --- /dev/null +++ b/src/dialogs/voice-assistant-setup/voice-assistant-setup-step-success.ts @@ -0,0 +1,226 @@ +import { css, html, LitElement, nothing, PropertyValues } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../../common/dom/fire_event"; +import { stopPropagation } from "../../common/dom/stop_propagation"; +import "../../components/ha-md-list-item"; +import "../../components/ha-tts-voice-picker"; +import { + AssistPipeline, + listAssistPipelines, + setAssistPipelinePreferred, + updateAssistPipeline, +} from "../../data/assist_pipeline"; +import { + assistSatelliteAnnounce, + AssistSatelliteConfiguration, +} from "../../data/assist_satellite"; +import { fetchCloudStatus } from "../../data/cloud"; +import { showVoiceAssistantPipelineDetailDialog } from "../../panels/config/voice-assistants/show-dialog-voice-assistant-pipeline-detail"; +import "../../panels/lovelace/entity-rows/hui-select-entity-row"; +import { HomeAssistant } from "../../types"; +import { AssistantSetupStyles } from "./styles"; +import { STEP } from "./voice-assistant-setup-dialog"; + +@customElement("ha-voice-assistant-setup-step-success") +export class HaVoiceAssistantSetupStepSuccess extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) + public assistConfiguration?: AssistSatelliteConfiguration; + + @property() public deviceId!: string; + + @property() public assistEntityId?: string; + + @state() private _ttsSettings?: any; + + protected override willUpdate(changedProperties: PropertyValues): void { + super.willUpdate(changedProperties); + + if (changedProperties.has("assistConfiguration")) { + this._setTtsSettings(); + return; + } + if (changedProperties.has("hass") && this.assistConfiguration) { + const oldHass = changedProperties.get("hass") as this["hass"] | undefined; + if (oldHass) { + const oldState = + oldHass.states[this.assistConfiguration.pipeline_entity_id]; + const newState = + this.hass.states[this.assistConfiguration.pipeline_entity_id]; + if (oldState.state !== newState.state) { + this._setTtsSettings(); + } + } + } + } + + private _activeWakeWord = memoizeOne( + (config: AssistSatelliteConfiguration | undefined) => { + if (!config) { + return ""; + } + const activeId = config.active_wake_words[0]; + return config.available_wake_words.find((ww) => ww.id === activeId) + ?.wake_word; + } + ); + + protected override render() { + return html`
+ +

Ready to assist!

+

+ Make your assistant more personal by customizing shizzle to the + manizzle +

+ + Change wake word + ${this._activeWakeWord(this.assistConfiguration)} + + + + ${this._ttsSettings + ? html`` + : nothing} +
+ `; + } + + private async _getPipeline(): Promise< + [AssistPipeline | undefined, string | undefined | null] + > { + if (!this.assistConfiguration?.pipeline_entity_id) { + return [undefined, undefined]; + } + + const pipelineName = + this.hass.states[this.assistConfiguration?.pipeline_entity_id].state; + + const pipelines = await listAssistPipelines(this.hass); + + let pipeline: AssistPipeline | undefined; + + if (pipelineName === "preferred") { + pipeline = pipelines.pipelines.find( + (ppln) => ppln.id === pipelines.preferred_pipeline + ); + } else { + pipeline = pipelines.pipelines.find((ppln) => ppln.name === pipelineName); + } + return [pipeline, pipelines.preferred_pipeline]; + } + + private async _setTtsSettings() { + const [pipeline] = await this._getPipeline(); + if (!pipeline) { + this._ttsSettings = undefined; + return; + } + this._ttsSettings = { + engine: pipeline.tts_engine, + voice: pipeline.tts_voice, + language: pipeline.tts_language, + }; + } + + private async _voicePicked(ev) { + const [pipeline] = await this._getPipeline(); + + if (!pipeline) { + return; + } + + await updateAssistPipeline(this.hass, pipeline.id, { + ...pipeline, + tts_voice: ev.detail.value, + }); + this._announce("Hello, how can I help you?"); + } + + private async _announce(message: string) { + if (!this.assistEntityId) { + return; + } + await assistSatelliteAnnounce(this.hass, this.assistEntityId, message); + } + + private _changeWakeWord() { + fireEvent(this, "next-step", { step: STEP.CHANGE_WAKEWORD }); + } + + private async _openPipeline() { + const [pipeline, preferred_pipeline] = await this._getPipeline(); + + if (!pipeline) { + return; + } + + const cloudStatus = await fetchCloudStatus(this.hass); + + showVoiceAssistantPipelineDetailDialog(this, { + cloudActiveSubscription: + cloudStatus.logged_in && cloudStatus.active_subscription, + pipeline, + preferred: pipeline.id === preferred_pipeline, + updatePipeline: async (values) => { + await updateAssistPipeline(this.hass!, pipeline!.id, values); + }, + setPipelinePreferred: async () => { + await setAssistPipelinePreferred(this.hass!, pipeline!.id); + }, + hideWakeWord: true, + }); + } + + private _close() { + fireEvent(this, "closed"); + } + + static styles = [ + AssistantSetupStyles, + css` + ha-md-list-item { + text-align: initial; + } + ha-tts-voice-picker { + margin-top: 16px; + display: block; + } + .footer { + margin-top: 24px; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-voice-assistant-setup-step-success": HaVoiceAssistantSetupStepSuccess; + } +} diff --git a/src/dialogs/voice-assistant-setup/voice-assistant-setup-step-update.ts b/src/dialogs/voice-assistant-setup/voice-assistant-setup-step-update.ts new file mode 100644 index 0000000000..14add40019 --- /dev/null +++ b/src/dialogs/voice-assistant-setup/voice-assistant-setup-step-update.ts @@ -0,0 +1,119 @@ +import { css, html, LitElement, PropertyValues } from "lit"; +import { customElement, property } from "lit/decorators"; +import { fireEvent } from "../../common/dom/fire_event"; +import "../../components/ha-circular-progress"; +import { UNAVAILABLE } from "../../data/entity"; +import { HomeAssistant } from "../../types"; +import { AssistantSetupStyles } from "./styles"; + +@customElement("ha-voice-assistant-setup-step-update") +export class HaVoiceAssistantSetupStepUpdate extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public updateEntityId?: string; + + private _updated = false; + + protected override willUpdate(changedProperties: PropertyValues): void { + super.willUpdate(changedProperties); + + if (changedProperties.has("hass") && this.updateEntityId) { + const oldHass = changedProperties.get("hass") as this["hass"] | undefined; + if (oldHass) { + const oldState = oldHass.states[this.updateEntityId]; + const newState = this.hass.states[this.updateEntityId]; + if ( + oldState?.state === UNAVAILABLE && + newState?.state !== UNAVAILABLE + ) { + // Device is rebooted, let's move on + this._tryUpdate(); + } + } + } + + if (!changedProperties.has("updateEntityId")) { + return; + } + + if (!this.updateEntityId) { + this._nextStep(); + return; + } + + this._tryUpdate(); + } + + protected override render() { + const stateObj = this.hass.states[this.updateEntityId!]; + + const progressIsNumeric = + typeof stateObj?.attributes.in_progress === "number"; + + return html`
+ +

Updating your voice assistant

+

+ We are making sure you have the latest and greatest version of your + voice assistant. This may take a few minutes. +

+ +

+ ${stateObj.state === "unavailable" + ? "Restarting voice assistant" + : progressIsNumeric + ? `Installing ${stateObj.attributes.in_progress}%` + : ""} +

+
`; + } + + private async _tryUpdate() { + if (!this.updateEntityId) { + return; + } + const updateEntity = this.hass.states[this.updateEntityId]; + if ( + updateEntity && + this.hass.states[updateEntity.entity_id].state === "on" + ) { + this._updated = true; + await this.hass.callService( + "update", + "install", + {}, + { entity_id: updateEntity.entity_id } + ); + } else { + this._nextStep(); + } + } + + private _nextStep() { + fireEvent(this, "next-step", { + noPrevious: true, + updateConfig: this._updated, + }); + } + + static styles = [ + AssistantSetupStyles, + css` + ha-circular-progress { + margin-top: 24px; + margin-bottom: 24px; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-voice-assistant-setup-step-update": HaVoiceAssistantSetupStepUpdate; + } +} diff --git a/src/dialogs/voice-assistant-setup/voice-assistant-setup-step-wake-word.ts b/src/dialogs/voice-assistant-setup/voice-assistant-setup-step-wake-word.ts new file mode 100644 index 0000000000..4c9b9306a0 --- /dev/null +++ b/src/dialogs/voice-assistant-setup/voice-assistant-setup-step-wake-word.ts @@ -0,0 +1,129 @@ +import { UnsubscribeFunc } from "home-assistant-js-websocket"; +import { html, LitElement, nothing, PropertyValues } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../../common/dom/fire_event"; +import "../../components/ha-button"; +import "../../components/ha-dialog-header"; +import { + AssistSatelliteConfiguration, + interceptWakeWord, +} from "../../data/assist_satellite"; +import { HomeAssistant } from "../../types"; +import { AssistantSetupStyles } from "./styles"; +import { STEP } from "./voice-assistant-setup-dialog"; + +@customElement("ha-voice-assistant-setup-step-wake-word") +export class HaVoiceAssistantSetupStepWakeWord extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) + public assistConfiguration?: AssistSatelliteConfiguration; + + @property() public assistEntityId?: string; + + @state() private _detected = false; + + private _sub?: Promise; + + disconnectedCallback(): void { + super.disconnectedCallback(); + this._stopListeningWakeWord(); + } + + protected override willUpdate(changedProperties: PropertyValues) { + super.willUpdate(changedProperties); + + if (changedProperties.has("assistEntityId")) { + this._detected = false; + this._listenWakeWord(); + } + } + + private _activeWakeWord = memoizeOne( + (config: AssistSatelliteConfiguration | undefined) => { + if (!config) { + return ""; + } + const activeId = config.active_wake_words[0]; + return config.available_wake_words.find((ww) => ww.id === activeId) + ?.wake_word; + } + ); + + protected override render() { + if (!this.assistEntityId) { + return nothing; + } + + const entityState = this.hass.states[this.assistEntityId]; + + if (entityState.state !== "listening_wake_word") { + return html``; + } + + return html`
+ ${!this._detected + ? html` + +

+ Say “${this._activeWakeWord(this.assistConfiguration)}” to wake the + device up +

+

Setup will continue once the device is awake.

+
` + : html` +

+ Say “${this._activeWakeWord(this.assistConfiguration)}” again +

+

+ To make sure the wake word works for you. +

`} + + `; + } + + private async _listenWakeWord() { + const entityId = this.assistEntityId; + if (!entityId) { + return; + } + await this._stopListeningWakeWord(); + this._sub = interceptWakeWord(this.hass, entityId, () => { + this._stopListeningWakeWord(); + if (this._detected) { + this._nextStep(); + } else { + this._detected = true; + this._listenWakeWord(); + } + }); + } + + private async _stopListeningWakeWord() { + try { + (await this._sub)?.(); + } catch (_e) { + // ignore + } + this._sub = undefined; + } + + private _nextStep() { + fireEvent(this, "next-step"); + } + + private _changeWakeWord() { + fireEvent(this, "next-step", { step: STEP.CHANGE_WAKEWORD }); + } + + static styles = AssistantSetupStyles; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-voice-assistant-setup-step-wake-word": HaVoiceAssistantSetupStepWakeWord; + } +} diff --git a/src/panels/config/devices/ha-config-device-page.ts b/src/panels/config/devices/ha-config-device-page.ts index 60bc1c78bf..ef6e98f2b3 100644 --- a/src/panels/config/devices/ha-config-device-page.ts +++ b/src/panels/config/devices/ha-config-device-page.ts @@ -5,6 +5,7 @@ import { mdiDelete, mdiDotsVertical, mdiDownload, + mdiMicrophone, mdiOpenInNew, mdiPencil, mdiPlusCircle, @@ -82,6 +83,8 @@ import { loadDeviceRegistryDetailDialog, showDeviceRegistryDetailDialog, } from "./device-registry-detail/show-dialog-device-registry-detail"; +import { showVoiceAssistantSetupDialog } from "../../../dialogs/voice-assistant-setup/show-voice-assistant-setup-dialog"; +import { assistSatelliteSupportsSetupFlow } from "../../../data/assist_satellite"; export interface EntityRegistryStateEntry extends EntityRegistryEntry { stateName?: string | null; @@ -1062,6 +1065,25 @@ export class HaConfigDevicePage extends LitElement { }); } + const entities = this._entities(this.deviceId, this._entityReg); + + const assistSatellite = entities.find( + (ent) => computeDomain(ent.entity_id) === "assist_satellite" + ); + + if ( + assistSatellite && + assistSatelliteSupportsSetupFlow( + this.hass.states[assistSatellite.entity_id] + ) + ) { + deviceActions.push({ + action: this._voiceAssistantSetup, + label: "Set up voice assistant", + icon: mdiMicrophone, + }); + } + const domains = this._integrations( device, this.entries, @@ -1396,6 +1418,12 @@ export class HaConfigDevicePage extends LitElement { (ev.currentTarget as any).action(ev); } + private _voiceAssistantSetup = () => { + showVoiceAssistantSetupDialog(this, { + deviceId: this.deviceId, + }); + }; + static get styles(): CSSResultGroup { return [ haStyle, diff --git a/src/panels/config/voice-assistants/dialog-voice-assistant-pipeline-detail.ts b/src/panels/config/voice-assistants/dialog-voice-assistant-pipeline-detail.ts index 14f4f804c4..fca855ec9d 100644 --- a/src/panels/config/voice-assistants/dialog-voice-assistant-pipeline-detail.ts +++ b/src/panels/config/voice-assistants/dialog-voice-assistant-pipeline-detail.ts @@ -215,14 +215,16 @@ export class DialogVoiceAssistantPipelineDetail extends LitElement { keys="tts_engine,tts_language,tts_voice" @value-changed=${this._valueChanged} > - + ${this._params.hideWakeWord + ? nothing + : html``} - ${this._params.pipeline?.id + ${this._params.pipeline?.id && this._params.deletePipeline ? html` Promise; + hideWakeWord?: boolean; updatePipeline: (updates: AssistPipelineMutableParams) => Promise; setPipelinePreferred: () => Promise; - deletePipeline: () => Promise; + createPipeline?: (values: AssistPipelineMutableParams) => Promise; + deletePipeline?: () => Promise; } export const loadVoiceAssistantPipelineDetailDialog = () =>