Add add-on installation to voice setup flow (#23018)

This commit is contained in:
Bram Kragten 2024-11-27 14:12:36 +01:00 committed by GitHub
parent 2782b2fb1b
commit 0a954cf1c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 419 additions and 44 deletions

View File

@ -93,7 +93,7 @@ export class CloudStepIntro extends LitElement {
<a
href="https://www.nabucasa.com"
target="_blank"
rel="noreferrer noopenner"
rel="noreferrer noopener"
>
<ha-button>
<ha-svg-icon .path=${mdiOpenInNew} slot="icon"></ha-svg-icon>

View File

@ -22,6 +22,7 @@ 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 "./voice-assistant-setup-step-local";
export const enum STEP {
INIT,
@ -32,6 +33,7 @@ export const enum STEP {
PIPELINE,
SUCCESS,
CLOUD,
LOCAL,
CHANGE_WAKEWORD,
}
@ -118,7 +120,9 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
scrimClickAction
>
<ha-dialog-header slot="heading">
${this._previousSteps.length
${this._step === STEP.LOCAL
? nothing
: this._previousSteps.length
? html`<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.common.back") ?? "Back"}
@ -145,7 +149,11 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
>`
: nothing}
</ha-dialog-header>
<div class="content" @next-step=${this._goToNextStep}>
<div
class="content"
@next-step=${this._goToNextStep}
@prev-step=${this._goToPreviousStep}
>
${this._step === STEP.UPDATE
? html`<ha-voice-assistant-setup-step-update
.hass=${this.hass}
@ -197,6 +205,12 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
? html`<ha-voice-assistant-setup-step-cloud
.hass=${this.hass}
></ha-voice-assistant-setup-step-cloud>`
: this._step === STEP.LOCAL
? html`<ha-voice-assistant-setup-step-local
.hass=${this.hass}
.assistConfiguration=${this
._assistConfiguration}
></ha-voice-assistant-setup-step-local>`
: this._step === STEP.SUCCESS
? html`<ha-voice-assistant-setup-step-success
.hass=${this.hass}
@ -229,17 +243,17 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
this._step = this._previousSteps.pop()!;
}
private _goToNextStep(ev) {
if (ev.detail?.updateConfig) {
private _goToNextStep(ev?: CustomEvent) {
if (ev?.detail?.updateConfig) {
this._fetchAssistConfiguration();
}
if (ev.detail?.nextStep) {
if (ev?.detail?.nextStep) {
this._nextStep = ev.detail.nextStep;
}
if (!ev.detail?.noPrevious) {
if (!ev?.detail?.noPrevious) {
this._previousSteps.push(this._step);
}
if (ev.detail?.step) {
if (ev?.detail?.step) {
this._step = ev.detail.step;
} else if (this._nextStep) {
this._step = this._nextStep;
@ -294,5 +308,6 @@ declare global {
nextStep?: STEP;
}
| undefined;
"prev-step": undefined;
}
}

View File

@ -0,0 +1,350 @@
import { mdiOpenInNew } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } 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 "../../components/ha-circular-progress";
import {
createAssistPipeline,
listAssistPipelines,
} from "../../data/assist_pipeline";
import type { AssistSatelliteConfiguration } from "../../data/assist_satellite";
import { createConfigFlow, handleConfigFlowStep } from "../../data/config_flow";
import type { EntityRegistryDisplayEntry } from "../../data/entity_registry";
import {
fetchHassioAddonsInfo,
installHassioAddon,
startHassioAddon,
} from "../../data/hassio/addon";
import { listSTTEngines } from "../../data/stt";
import { listTTSEngines, listTTSVoices } from "../../data/tts";
import type { 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-local")
export class HaVoiceAssistantSetupStepLocal extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false })
public assistConfiguration?: AssistSatelliteConfiguration;
@state() private _state: "INSTALLING" | "NOT_SUPPORTED" | "ERROR" | "INTRO" =
"INTRO";
@state() private _detailState?: string;
@state() private _localTts?: EntityRegistryDisplayEntry[];
@state() private _localStt?: EntityRegistryDisplayEntry[];
protected override render() {
return html`<div class="content">
${this._state === "INSTALLING"
? html`<img src="/static/images/voice-assistant/update.png" />
<h1>Installing add-ons</h1>
<p>
The Whisper and Piper add-ons are being installed and configured.
</p>
<ha-circular-progress indeterminate></ha-circular-progress>
<p>
${this._detailState || "Installation can take several minutes"}
</p>`
: this._state === "ERROR"
? html` <img src="/static/images/voice-assistant/error.png" />
<h1>Failed to install add-ons</h1>
<p>
We could not automatically install a local TTS and STT provider
for you. Read the documentation to learn how to install them.
</p>
<ha-button @click=${this._prevStep}>Go back</ha-button>
<a
href=${documentationUrl(
this.hass,
"/voice_control/voice_remote_local_assistant/"
)}
target="_blank"
rel="noreferrer noopener"
>
<ha-button>
<ha-svg-icon .path=${mdiOpenInNew} slot="icon"></ha-svg-icon>
Learn more</ha-button
>
</a>`
: this._state === "NOT_SUPPORTED"
? html`<img src="/static/images/voice-assistant/error.png" />
<h1>Installation of add-ons is not supported on your system</h1>
<p>
Your system is not supported to automatically install a local
TTS and STT provider. Learn how to set up local TTS and STT
providers in the documentation.
</p>
<ha-button @click=${this._prevStep}>Go back</ha-button>
<a
href=${documentationUrl(
this.hass,
"/voice_control/voice_remote_local_assistant/"
)}
target="_blank"
rel="noreferrer noopener"
>
<ha-button>
<ha-svg-icon
.path=${mdiOpenInNew}
slot="icon"
></ha-svg-icon>
Learn more</ha-button
>
</a>`
: nothing}
</div>`;
}
protected override willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties);
if (!this.hasUpdated) {
this._checkLocal();
}
}
private _prevStep() {
fireEvent(this, "prev-step");
}
private _nextStep() {
fireEvent(this, "next-step", { step: STEP.SUCCESS, noPrevious: true });
}
private async _checkLocal() {
this._findLocalEntities();
if (!this._localTts || !this._localStt) {
return;
}
if (this._localTts.length && this._localStt.length) {
this._pickOrCreatePipelineExists();
}
if (!isComponentLoaded(this.hass, "hassio")) {
this._state = "NOT_SUPPORTED";
return;
}
this._state = "INSTALLING";
try {
const { addons } = await fetchHassioAddonsInfo(this.hass);
const whisper = addons.find((addon) => addon.slug === "core_whisper");
const piper = addons.find((addon) => addon.slug === "core_piper");
if (piper && !this._localTts.length) {
if (piper.state !== "started") {
this._detailState = "Starting Piper add-on";
await startHassioAddon(this.hass, "core_piper");
}
this._detailState = "Setting up Piper";
await this._setupConfigEntry("piper");
}
if (whisper && !this._localStt.length) {
if (whisper.state !== "started") {
this._detailState = "Starting Whisper add-on";
await startHassioAddon(this.hass, "core_whisper");
}
this._detailState = "Setting up Whisper";
await this._setupConfigEntry("whisper");
}
if (!piper) {
this._detailState = "Installing Piper add-on";
await installHassioAddon(this.hass, "core_piper");
this._detailState = "Starting Piper add-on";
await startHassioAddon(this.hass, "core_piper");
this._detailState = "Setting up Piper";
await this._setupConfigEntry("piper");
}
if (!whisper) {
this._detailState = "Installing Whisper add-on";
await installHassioAddon(this.hass, "core_whisper");
this._detailState = "Starting Whisper add-on";
await startHassioAddon(this.hass, "core_whisper");
this._detailState = "Setting up Whisper";
await this._setupConfigEntry("whisper");
}
this._detailState = "Creating assistant";
this._findEntitiesAndCreatePipeline();
} catch (e) {
this._state = "ERROR";
}
}
private _findLocalEntities() {
const wyomingEntities = Object.values(this.hass.entities).filter(
(entity) => entity.platform === "wyoming"
);
this._localTts = wyomingEntities.filter(
(ent) => computeDomain(ent.entity_id) === "tts"
);
this._localStt = wyomingEntities.filter(
(ent) => computeDomain(ent.entity_id) === "stt"
);
}
private async _setupConfigEntry(addon: string) {
const configFlow = await createConfigFlow(this.hass, "wyoming");
const step = await handleConfigFlowStep(this.hass, configFlow.flow_id, {
host: `core_${addon}`,
port: addon === "piper" ? 10200 : 10300,
});
if (step.type !== "create_entry") {
throw new Error("Failed to create entry");
}
}
private async _pickOrCreatePipelineExists() {
// Check if a pipeline already exists with local TTS and STT
if (!this._localStt?.length || !this._localTts?.length) {
return;
}
const pipelines = await listAssistPipelines(this.hass);
const preferredPipeline = pipelines.pipelines.find(
(pipeline) => pipeline.id === pipelines.preferred_pipeline
);
const ttsEntityIds = this._localTts.map((ent) => ent.entity_id);
const sttEntityIds = this._localStt.map((ent) => ent.entity_id);
if (preferredPipeline) {
if (
preferredPipeline.tts_engine &&
ttsEntityIds.includes(preferredPipeline.tts_engine) &&
preferredPipeline.stt_engine &&
sttEntityIds.includes(preferredPipeline.stt_engine)
) {
await this.hass.callService(
"select",
"select_option",
{ option: "preferred" },
{ entity_id: this.assistConfiguration?.pipeline_entity_id }
);
this._nextStep();
return;
}
}
let localPipeline = pipelines.pipelines.find(
(pipeline) =>
pipeline.tts_engine &&
ttsEntityIds.includes(pipeline.tts_engine) &&
pipeline.stt_engine &&
sttEntityIds.includes(pipeline.stt_engine)
);
if (!localPipeline) {
localPipeline = await this._createPipeline(
this._localTts[0].entity_id,
this._localStt[0].entity_id
);
}
await this.hass.callService(
"select",
"select_option",
{ option: localPipeline.name },
{ entity_id: this.assistConfiguration?.pipeline_entity_id }
);
this._nextStep();
}
private async _createPipeline(ttsEntityId: string, sttEntityId: string) {
// Create a pipeline with local TTS and STT
const pipelines = await listAssistPipelines(this.hass);
const ttsEngine = (
await listTTSEngines(
this.hass,
this.hass.config.language,
this.hass.config.country || undefined
)
).providers.find((provider) => provider.engine_id === ttsEntityId);
const ttsVoices = await listTTSVoices(
this.hass,
ttsEntityId,
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 === sttEntityId);
let pipelineName = "Local Assistant";
let i = 1;
while (
pipelines.pipelines.find(
// eslint-disable-next-line @typescript-eslint/no-loop-func
(pipeline) => pipeline.name === pipelineName
)
) {
pipelineName = `Local Assistant ${i}`;
i++;
}
return createAssistPipeline(this.hass, {
name: pipelineName,
language: this.hass.config.language.split("-")[0],
conversation_engine: "conversation.home_assistant",
conversation_language: this.hass.config.language.split("-")[0],
stt_engine: sttEntityId,
stt_language: sttEngine!.supported_languages![0],
tts_engine: ttsEntityId,
tts_language: ttsEngine!.supported_languages![0],
tts_voice: ttsVoices.voices![0].voice_id,
wake_word_entity: null,
wake_word_id: null,
});
}
private async _findEntitiesAndCreatePipeline(tryNo: number = 0) {
this._findLocalEntities();
if (!this._localTts?.length || !this._localStt?.length) {
if (tryNo > 3) {
throw new Error("Timeout searching for local TTS and STT entities");
}
setTimeout(() => this._findEntitiesAndCreatePipeline(tryNo + 1), 2000);
return;
}
const localPipeline = await this._createPipeline(
this._localTts[0].entity_id,
this._localStt[0].entity_id
);
await this.hass.callService(
"select",
"select_option",
{ option: localPipeline.name },
{ entity_id: this.assistConfiguration?.pipeline_entity_id }
);
this._nextStep();
}
static styles = [
AssistantSetupStyles,
css`
ha-circular-progress {
margin-top: 24px;
margin-bottom: 24px;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-voice-assistant-setup-step-local": HaVoiceAssistantSetupStepLocal;
}
}

View File

@ -14,9 +14,9 @@ import { fetchCloudStatus } from "../../data/cloud";
import { listSTTEngines } from "../../data/stt";
import { listTTSEngines, listTTSVoices } from "../../data/tts";
import type { HomeAssistant } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import { AssistantSetupStyles } from "./styles";
import { STEP } from "./voice-assistant-setup-dialog";
import { documentationUrl } from "../../util/documentation-url";
@customElement("ha-voice-assistant-setup-step-pipeline")
export class HaVoiceAssistantSetupStepPipeline extends LitElement {
@ -93,7 +93,7 @@ export class HaVoiceAssistantSetupStepPipeline extends LitElement {
</div>
<h2>Home Assistant Cloud</h2>
<p>Ideal if you don't have a powerful system at home.</p>
<ha-button @click=${this._setupCloud}>Learn more</ha-button>
<ha-button @click=${this._setupCloud} unelevated>Learn more</ha-button>
</div>
<div class="container">
<div class="messages-container rpi">
@ -117,19 +117,24 @@ export class HaVoiceAssistantSetupStepPipeline extends LitElement {
Install add-ons or containers to run it on your own system. Powerful
hardware is needed for fast responses.
</p>
<div class="row">
<a
href=${documentationUrl(
this.hass,
"/voice_control/voice_remote_local_assistant/"
)}
target="_blank"
rel="noreferrer noopenner"
rel="noreferrer noopener"
>
<ha-button @click=${this._skip}>
<ha-button>
<ha-svg-icon .path=${mdiOpenInNew} slot="icon"></ha-svg-icon>
Learn more</ha-button
>
</a>
<ha-button @click=${this._setupLocal} unelevated
>Setup with add-ons</ha-button
>
</div>
</div>
</div>`;
}
@ -218,15 +223,15 @@ export class HaVoiceAssistantSetupStepPipeline extends LitElement {
(pipeline) => pipeline.name === pipelineName
)
) {
pipelineName = `${pipelineName} ${i}`;
pipelineName = `Home Assistant Cloud ${i}`;
i++;
}
cloudPipeline = await createAssistPipeline(this.hass, {
name: pipelineName,
language: this.hass.config.language,
language: this.hass.config.language.split("-")[0],
conversation_engine: "conversation.home_assistant",
conversation_language: this.hass.config.language,
conversation_language: this.hass.config.language.split("-")[0],
stt_engine: cloudSttEntityId,
stt_language: sttEngine!.supported_languages![0],
tts_engine: cloudTtsEntityId,
@ -250,8 +255,8 @@ export class HaVoiceAssistantSetupStepPipeline extends LitElement {
this._nextStep(STEP.CLOUD);
}
private _skip() {
this._nextStep(STEP.SUCCESS);
private async _setupLocal() {
this._nextStep(STEP.LOCAL);
}
private _nextStep(step?: STEP) {
@ -334,6 +339,11 @@ export class HaVoiceAssistantSetupStepPipeline extends LitElement {
.message.hass.show {
width: 184px;
}
.row {
display: flex;
justify-content: space-between;
margin: 0 16px;
}
`,
];
}