Update voice wizard (#24750)

* Update pipeline step, add option for speech-to-phrase addon

* adjust to core changes

* Update conversation.ts

* Update voice-assistant-setup-step-pipeline.ts

* Update voice-assistant-setup-dialog.ts

* review
pull/24599/head^2
Bram Kragten 2025-03-26 14:05:51 +01:00 committed by GitHub
parent 7cc6397324
commit f6467a35db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 776 additions and 394 deletions

View File

@ -13,6 +13,49 @@ import "./ha-list-item";
import "./ha-select";
import type { HaSelect } from "./ha-select";
export const getLanguageOptions = (
languages: string[],
nativeName: boolean,
noSort: boolean,
locale?: FrontendLocaleData
) => {
let options: { label: string; value: string }[] = [];
if (nativeName) {
const translations = translationMetadata.translations;
options = languages.map((lang) => {
let label = translations[lang]?.nativeName;
if (!label) {
try {
// this will not work if Intl.DisplayNames is polyfilled, it will return in the language of the user
label = new Intl.DisplayNames(lang, {
type: "language",
fallback: "code",
}).of(lang)!;
} catch (_err) {
label = lang;
}
}
return {
value: lang,
label,
};
});
} else if (locale) {
options = languages.map((lang) => ({
value: lang,
label: formatLanguageCode(lang, locale),
}));
}
if (!noSort && locale) {
options.sort((a, b) =>
caseInsensitiveStringCompare(a.label, b.label, locale.language)
);
}
return options;
};
@customElement("ha-language-picker")
export class HaLanguagePicker extends LitElement {
@property() public value?: string;
@ -68,6 +111,7 @@ export class HaLanguagePicker extends LitElement {
const languageOptions = this._getLanguagesOptions(
this.languages ?? this._defaultLanguages,
this.nativeName,
this.noSort,
this.hass?.locale
);
const selectedItemIndex = languageOptions.findIndex(
@ -82,45 +126,7 @@ export class HaLanguagePicker extends LitElement {
}
}
private _getLanguagesOptions = memoizeOne(
(languages: string[], nativeName: boolean, locale?: FrontendLocaleData) => {
let options: { label: string; value: string }[] = [];
if (nativeName) {
const translations = translationMetadata.translations;
options = languages.map((lang) => {
let label = translations[lang]?.nativeName;
if (!label) {
try {
// this will not work if Intl.DisplayNames is polyfilled, it will return in the language of the user
label = new Intl.DisplayNames(lang, {
type: "language",
fallback: "code",
}).of(lang)!;
} catch (_err) {
label = lang;
}
}
return {
value: lang,
label,
};
});
} else if (locale) {
options = languages.map((lang) => ({
value: lang,
label: formatLanguageCode(lang, locale),
}));
}
if (!this.noSort && locale) {
options.sort((a, b) =>
caseInsensitiveStringCompare(a.label, b.label, locale.language)
);
}
return options;
}
);
private _getLanguagesOptions = memoizeOne(getLanguageOptions);
private _computeDefaultLanguageOptions() {
this._defaultLanguages = Object.keys(translationMetadata.translations);
@ -130,6 +136,7 @@ export class HaLanguagePicker extends LitElement {
const languageOptions = this._getLanguagesOptions(
this.languages ?? this._defaultLanguages,
this.nativeName,
this.noSort,
this.hass?.locale
);

View File

@ -124,3 +124,22 @@ export const debugAgent = (
language,
device_id,
});
export interface LanguageScore {
cloud: number;
focused_local: number;
full_local: number;
}
export type LanguageScores = Record<string, LanguageScore>;
export const getLanguageScores = (
hass: HomeAssistant,
language?: string,
country?: string
): Promise<{ languages: LanguageScores; preferred_language: string | null }> =>
hass.callWS({
type: "conversation/agent/homeassistant/language_scores",
language,
country,
});

22
src/data/wyoming.ts Normal file
View File

@ -0,0 +1,22 @@
import type { HomeAssistant } from "../types";
export interface WyomingInfo {
asr: WyomingAsrInfo[];
handle: [];
intent: [];
tts: WyomingTtsInfo[];
wake: [];
}
interface WyomingBaseInfo {
name: string;
version: string;
attribution: Record<string, string>;
}
interface WyomingTtsInfo extends WyomingBaseInfo {}
interface WyomingAsrInfo extends WyomingBaseInfo {}
export const fetchWyomingInfo = (hass: HomeAssistant) =>
hass.callWS<{ info: Record<string, WyomingInfo> }>({ type: "wyoming/info" });

View File

@ -1,14 +1,19 @@
import "@material/mwc-button/mwc-button";
import { mdiChevronLeft, mdiClose } from "@mdi/js";
import { mdiChevronLeft, mdiClose, mdiMenuDown } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, 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 { formatLanguageCode } from "../../common/language/format_language";
import "../../components/chips/ha-assist-chip";
import "../../components/ha-dialog";
import { getLanguageOptions } from "../../components/ha-language-picker";
import "../../components/ha-md-button-menu";
import type { AssistSatelliteConfiguration } from "../../data/assist_satellite";
import { fetchAssistSatelliteConfiguration } from "../../data/assist_satellite";
import { getLanguageScores } from "../../data/conversation";
import { UNAVAILABLE } from "../../data/entity";
import type { EntityRegistryDisplayEntry } from "../../data/entity_registry";
import { haStyleDialog } from "../../resources/styles";
@ -18,11 +23,11 @@ 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-local";
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,
@ -49,6 +54,12 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
@state() private _error?: string;
@state() private _language?: string;
@state() private _languages: string[] = [];
@state() private _localOption?: string;
private _previousSteps: STEP[] = [];
private _nextStep?: STEP;
@ -67,6 +78,12 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
this.renderRoot.querySelector("ha-dialog")?.close();
}
protected willUpdate(changedProps) {
if (changedProps.has("_step") && this._step === STEP.PIPELINE) {
this._getLanguages();
}
}
private _dialogClosed() {
this._params = undefined;
this._assistConfiguration = undefined;
@ -139,9 +156,7 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
@click=${this.closeDialog}
></ha-icon-button>`
: nothing}
${this._step === STEP.WAKEWORD ||
this._step === STEP.AREA ||
this._step === STEP.PIPELINE
${this._step === STEP.WAKEWORD || this._step === STEP.AREA
? html`<ha-button
@click=${this._goToNextStep}
class="skip-btn"
@ -150,7 +165,43 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
"ui.panel.config.voice_assistants.satellite_wizard.skip"
)}</ha-button
>`
: nothing}
: this._step === STEP.PIPELINE
? this._language
? html`<ha-md-button-menu
slot="actionItems"
positioning="fixed"
>
<ha-assist-chip
.label=${formatLanguageCode(
this._language,
this.hass.locale
)}
slot="trigger"
>
<ha-svg-icon
slot="trailing-icon"
.path=${mdiMenuDown}
></ha-svg-icon
></ha-assist-chip>
${getLanguageOptions(
this._languages,
false,
false,
this.hass.locale
).map(
(lang) =>
html`<ha-md-menu-item
.value=${lang.value}
@click=${this._handlePickLanguage}
@keydown=${this._handlePickLanguage}
.selected=${this._language === lang.value}
>
${lang.label}
</ha-md-menu-item>`
)}
</ha-md-button-menu>`
: nothing
: nothing}
</ha-dialog-header>
<div
class="content"
@ -207,8 +258,11 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
: this._step === STEP.PIPELINE
? html`<ha-voice-assistant-setup-step-pipeline
.hass=${this.hass}
.languages=${this._languages}
.language=${this._language}
.assistConfiguration=${this._assistConfiguration}
.assistEntityId=${assistSatelliteEntityId}
@language-changed=${this._languageChanged}
></ha-voice-assistant-setup-step-pipeline>`
: this._step === STEP.CLOUD
? html`<ha-voice-assistant-setup-step-cloud
@ -217,6 +271,8 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
: this._step === STEP.LOCAL
? html`<ha-voice-assistant-setup-step-local
.hass=${this.hass}
.language=${this._language}
.localOption=${this._localOption}
.assistConfiguration=${this
._assistConfiguration}
></ha-voice-assistant-setup-step-local>`
@ -233,6 +289,27 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
`;
}
private async _getLanguages() {
if (this._languages.length) {
return;
}
const scores = await getLanguageScores(this.hass);
this._languages = Object.entries(scores.languages)
.filter(
([_lang, score]) =>
score.cloud > 0 || score.full_local > 0 || score.focused_local > 0
)
.map(([lang, _score]) => lang);
this._language =
scores.preferred_language &&
this._languages.includes(scores.preferred_language)
? scores.preferred_language
: undefined;
}
private async _fetchAssistConfiguration() {
try {
this._assistConfiguration = await fetchAssistSatelliteConfiguration(
@ -248,6 +325,19 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
}
}
private _handlePickLanguage(ev) {
if (ev.type === "keydown" && ev.key !== "Enter" && ev.key !== " ") return;
this._language = ev.target.value;
}
private _languageChanged(ev: CustomEvent) {
if (!ev.detail.value) {
return;
}
this._language = ev.detail.value;
}
private _goToPreviousStep() {
if (!this._previousSteps.length) {
return;
@ -267,6 +357,9 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
}
if (ev?.detail?.step) {
this._step = ev.detail.step;
if (ev.detail.step === STEP.LOCAL) {
this._localOption = ev.detail.option;
}
} else if (this._nextStep) {
this._step = this._nextStep;
this._nextStep = undefined;
@ -305,6 +398,13 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
margin: 24px;
display: block;
}
ha-md-button-menu {
height: 48px;
display: flex;
align-items: center;
margin-right: 12px;
margin-inline-end: 12px;
}
`,
];
}
@ -322,8 +422,10 @@ declare global {
updateConfig?: boolean;
noPrevious?: boolean;
nextStep?: STEP;
option?: string;
}
| undefined;
"prev-step": undefined;
"language-changed": { value: string };
}
}

View File

@ -16,7 +16,10 @@ import {
fetchConfigFlowInProgress,
handleConfigFlowStep,
} from "../../data/config_flow";
import type { EntityRegistryDisplayEntry } from "../../data/entity_registry";
import {
type ExtEntityRegistryEntry,
getExtendedEntityRegistryEntries,
} from "../../data/entity_registry";
import {
fetchHassioAddonsInfo,
installHassioAddon,
@ -24,10 +27,12 @@ import {
} from "../../data/hassio/addon";
import { listSTTEngines } from "../../data/stt";
import { listTTSEngines, listTTSVoices } from "../../data/tts";
import { fetchWyomingInfo } from "../../data/wyoming";
import type { HomeAssistant } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import { AssistantSetupStyles } from "./styles";
import { STEP } from "./voice-assistant-setup-dialog";
import { listAgents } from "../../data/conversation";
@customElement("ha-voice-assistant-setup-step-local")
export class HaVoiceAssistantSetupStepLocal extends LitElement {
@ -36,6 +41,12 @@ export class HaVoiceAssistantSetupStepLocal extends LitElement {
@property({ attribute: false })
public assistConfiguration?: AssistSatelliteConfiguration;
@property({ attribute: false }) public localOption!:
| "focused_local"
| "full_local";
@property({ attribute: false }) public language!: string;
@state() private _state: "INSTALLING" | "NOT_SUPPORTED" | "ERROR" | "INTRO" =
"INTRO";
@ -43,9 +54,9 @@ export class HaVoiceAssistantSetupStepLocal extends LitElement {
@state() private _error?: string;
@state() private _localTts?: EntityRegistryDisplayEntry[];
@state() private _localTts?: ExtEntityRegistryEntry[];
@state() private _localStt?: EntityRegistryDisplayEntry[];
@state() private _localStt?: ExtEntityRegistryEntry[];
protected override render() {
return html`<div class="content">
@ -159,58 +170,62 @@ export class HaVoiceAssistantSetupStepLocal extends LitElement {
}
private async _checkLocal() {
this._findLocalEntities();
await this._findLocalEntities();
if (!this._localTts || !this._localStt) {
return;
}
if (this._localTts.length && this._localStt.length) {
this._pickOrCreatePipelineExists();
return;
}
if (!isComponentLoaded(this.hass, "hassio")) {
this._state = "NOT_SUPPORTED";
return;
}
this._state = "INSTALLING";
try {
if (this._localTts.length && this._localStt.length) {
await this._pickOrCreatePipelineExists();
return;
}
if (!isComponentLoaded(this.hass, "hassio")) {
this._state = "NOT_SUPPORTED";
return;
}
this._state = "INSTALLING";
const { addons } = await fetchHassioAddonsInfo(this.hass);
const whisper = addons.find((addon) => addon.slug === "core_whisper");
const piper = addons.find((addon) => addon.slug === "core_piper");
const ttsAddon = addons.find(
(addon) => addon.slug === this._ttsAddonName
);
const sttAddon = addons.find(
(addon) => addon.slug === this._sttAddonName
);
if (!this._localTts.length) {
if (!piper) {
if (!ttsAddon) {
this._detailState = this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.local.state.installing_piper"
`ui.panel.config.voice_assistants.satellite_wizard.local.state.installing_${this._ttsProviderName}`
);
await installHassioAddon(this.hass, "core_piper");
await installHassioAddon(this.hass, this._ttsAddonName);
}
if (!piper || piper.state !== "started") {
if (!ttsAddon || ttsAddon.state !== "started") {
this._detailState = this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.local.state.starting_piper"
`ui.panel.config.voice_assistants.satellite_wizard.local.state.starting_${this._ttsProviderName}`
);
await startHassioAddon(this.hass, "core_piper");
await startHassioAddon(this.hass, this._ttsAddonName);
}
this._detailState = this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.local.state.setup_piper"
`ui.panel.config.voice_assistants.satellite_wizard.local.state.setup_${this._ttsProviderName}`
);
await this._setupConfigEntry("piper");
await this._setupConfigEntry("tts");
}
if (!this._localStt.length) {
if (!whisper) {
if (!sttAddon) {
this._detailState = this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.local.state.installing_whisper"
`ui.panel.config.voice_assistants.satellite_wizard.local.state.installing_${this._sttProviderName}`
);
await installHassioAddon(this.hass, "core_whisper");
await installHassioAddon(this.hass, this._sttAddonName);
}
if (!whisper || whisper.state !== "started") {
if (!sttAddon || sttAddon.state !== "started") {
this._detailState = this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.local.state.starting_whisper"
`ui.panel.config.voice_assistants.satellite_wizard.local.state.starting_${this._sttProviderName}`
);
await startHassioAddon(this.hass, "core_whisper");
await startHassioAddon(this.hass, this._sttAddonName);
}
this._detailState = this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.local.state.setup_whisper"
`ui.panel.config.voice_assistants.satellite_wizard.local.state.setup_${this._sttProviderName}`
);
await this._setupConfigEntry("whisper");
await this._setupConfigEntry("stt");
}
this._detailState = this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.local.state.creating_pipeline"
@ -222,20 +237,72 @@ export class HaVoiceAssistantSetupStepLocal extends LitElement {
}
}
private _findLocalEntities() {
private readonly _ttsProviderName = "piper";
private readonly _ttsAddonName = "core_piper";
private readonly _ttsHostName = "core-piper";
private readonly _ttsPort = "10200";
private get _sttProviderName() {
return this.localOption === "focused_local"
? "speech-to-phrase"
: "faster-whisper";
}
private get _sttAddonName() {
return this.localOption === "focused_local"
? "core_speech-to-phrase"
: "core_whisper";
}
private get _sttHostName() {
return this.localOption === "focused_local"
? "core-speech-to-phrase"
: "core-whisper";
}
private readonly _sttPort = "10300";
private async _findLocalEntities() {
const wyomingEntities = Object.values(this.hass.entities).filter(
(entity) => entity.platform === "wyoming"
);
this._localTts = wyomingEntities.filter(
(ent) => computeDomain(ent.entity_id) === "tts"
if (!wyomingEntities.length) {
this._localStt = [];
this._localTts = [];
return;
}
const wyomingInfo = await fetchWyomingInfo(this.hass);
const entityRegs = Object.values(
await getExtendedEntityRegistryEntries(
this.hass,
wyomingEntities.map((ent) => ent.entity_id)
)
);
this._localStt = wyomingEntities.filter(
(ent) => computeDomain(ent.entity_id) === "stt"
this._localTts = entityRegs.filter(
(ent) =>
computeDomain(ent.entity_id) === "tts" &&
ent.config_entry_id &&
wyomingInfo.info[ent.config_entry_id]?.tts.some(
(provider) => provider.name === this._ttsProviderName
)
);
this._localStt = entityRegs.filter(
(ent) =>
computeDomain(ent.entity_id) === "stt" &&
ent.config_entry_id &&
wyomingInfo.info[ent.config_entry_id]?.asr.some(
(provider) => provider.name === this._sttProviderName
)
);
}
private async _setupConfigEntry(addon: string) {
const configFlow = await this._findConfigFlowInProgress(addon);
private async _setupConfigEntry(type: "tts" | "stt") {
const configFlow = await this._findConfigFlowInProgress(type);
if (configFlow) {
const step = await handleConfigFlowStep(
@ -248,30 +315,36 @@ export class HaVoiceAssistantSetupStepLocal extends LitElement {
}
}
return this._createConfigEntry(addon);
return this._createConfigEntry(type);
}
private async _findConfigFlowInProgress(addon: string) {
private async _findConfigFlowInProgress(type: "tts" | "stt") {
const configFlows = await fetchConfigFlowInProgress(this.hass.connection);
return configFlows.find(
(flow) =>
flow.handler === "wyoming" &&
flow.context.source === "hassio" &&
(flow.context.configuration_url.includes(`core_${addon}`) ||
flow.context.title_placeholders.title.toLowerCase().includes(addon))
(flow.context.configuration_url.includes(
type === "tts" ? this._ttsHostName : this._sttHostName
) ||
flow.context.title_placeholders.title
.toLowerCase()
.includes(
type === "tts" ? this._ttsProviderName : this._sttProviderName
))
);
}
private async _createConfigEntry(addon: string) {
private async _createConfigEntry(type: "tts" | "stt") {
const configFlow = await createConfigFlow(this.hass, "wyoming");
const step = await handleConfigFlowStep(this.hass, configFlow.flow_id, {
host: `core-${addon}`,
port: addon === "piper" ? 10200 : 10300,
host: type === "tts" ? this._ttsHostName : this._sttHostName,
port: type === "tts" ? this._ttsPort : this._sttPort,
});
if (step.type !== "create_entry") {
throw new Error(
`${this.hass.localize("ui.panel.config.voice_assistants.satellite_wizard.local.errors.failed_create_entry", { addon })}${"errors" in step ? `: ${step.errors.base}` : ""}`
`${this.hass.localize("ui.panel.config.voice_assistants.satellite_wizard.local.errors.failed_create_entry", { addon: type === "tts" ? this._ttsProviderName : this._sttProviderName })}${"errors" in step ? `: ${step.errors.base}` : ""}`
);
}
}
@ -341,27 +414,54 @@ export class HaVoiceAssistantSetupStepLocal extends LitElement {
const pipelines = await listAssistPipelines(this.hass);
const agent = (
await listAgents(
this.hass,
this.language || this.hass.config.language,
this.hass.config.country || undefined
)
).agents.find((agnt) => agnt.id === "conversation.home_assistant");
if (!agent?.supported_languages.length) {
throw new Error(
"Conversation agent does not support requested language."
);
}
const ttsEngine = (
await listTTSEngines(
this.hass,
this.hass.config.language,
this.language,
this.hass.config.country || undefined
)
).providers.find((provider) => provider.engine_id === ttsEntityId);
if (!ttsEngine?.supported_languages?.length) {
throw new Error("TTS engine does not support requested language.");
}
const ttsVoices = await listTTSVoices(
this.hass,
ttsEntityId,
ttsEngine?.supported_languages![0] || this.hass.config.language
ttsEngine.supported_languages[0]
);
if (!ttsVoices.voices?.length) {
throw new Error("No voice available for requested language.");
}
const sttEngine = (
await listSTTEngines(
this.hass,
this.hass.config.language,
this.language,
this.hass.config.country || undefined
)
).providers.find((provider) => provider.engine_id === sttEntityId);
if (!sttEngine?.supported_languages?.length) {
throw new Error("STT engine does not support requested language.");
}
let pipelineName = this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.local.local_pipeline"
);
@ -378,21 +478,21 @@ export class HaVoiceAssistantSetupStepLocal extends LitElement {
return createAssistPipeline(this.hass, {
name: pipelineName,
language: this.hass.config.language.split("-")[0],
language: this.language.split("-")[0],
conversation_engine: "conversation.home_assistant",
conversation_language: this.hass.config.language.split("-")[0],
conversation_language: agent.supported_languages[0],
stt_engine: sttEntityId,
stt_language: sttEngine!.supported_languages![0],
stt_language: sttEngine.supported_languages[0],
tts_engine: ttsEntityId,
tts_language: ttsEngine!.supported_languages![0],
tts_voice: ttsVoices.voices![0].voice_id,
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 = 0) {
this._findLocalEntities();
await this._findLocalEntities();
if (!this._localTts?.length || !this._localStt?.length) {
if (tryNo > 3) {
throw new Error(

View File

@ -1,22 +1,30 @@
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 memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event";
import { computeDomain } from "../../common/entity/compute_domain";
import { formatLanguageCode } from "../../common/language/format_language";
import type { LocalizeFunc } from "../../common/translations/localize";
import "../../components/ha-select-box";
import type { SelectBoxOption } from "../../components/ha-select-box";
import {
createAssistPipeline,
listAssistPipelines,
} from "../../data/assist_pipeline";
import type { AssistSatelliteConfiguration } from "../../data/assist_satellite";
import { fetchCloudStatus } from "../../data/cloud";
import type { LanguageScores } from "../../data/conversation";
import { getLanguageScores, listAgents } from "../../data/conversation";
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";
const OPTIONS = ["cloud", "focused_local", "full_local"] as const;
@customElement("ha-voice-assistant-setup-step-pipeline")
export class HaVoiceAssistantSetupStepPipeline extends LitElement {
@ -29,166 +37,231 @@ export class HaVoiceAssistantSetupStepPipeline extends LitElement {
@property({ attribute: false }) public assistEntityId?: string;
@property({ attribute: false }) public language?: string;
@property({ attribute: false }) public languages: string[] = [];
@state() private _cloudChecked = false;
@state() private _showFirst = false;
@state() private _value?: (typeof OPTIONS)[number];
@state() private _showSecond = false;
@state() private _showThird = false;
@state() private _showFourth = false;
@state() private _languageScores?: LanguageScores;
protected override willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties);
if (!this.hasUpdated) {
this._checkCloud();
this._fetchData();
}
if (
(changedProperties.has("language") ||
changedProperties.has("_languageScores")) &&
this.language &&
this._languageScores
) {
const lang = this.language;
if (this._value && this._languageScores[lang][this._value] === 0) {
this._value = undefined;
}
if (!this._value) {
this._value = this._getOptions(
this._languageScores[lang],
this.hass.localize
).supportedOptions[0]?.value as
| "cloud"
| "focused_local"
| "full_local"
| undefined;
}
}
}
protected override firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
setTimeout(() => {
this._showFirst = true;
}, 200);
setTimeout(() => {
this._showSecond = true;
}, 600);
setTimeout(() => {
this._showThird = true;
}, 2000);
setTimeout(() => {
this._showFourth = true;
}, 3000);
}
private _getOptions = memoizeOne((score, localize: LocalizeFunc) => {
const supportedOptions: SelectBoxOption[] = [];
const unsupportedOptions: SelectBoxOption[] = [];
OPTIONS.forEach((option) => {
if (score[option] > 0) {
supportedOptions.push({
label: localize(
`ui.panel.config.voice_assistants.satellite_wizard.pipeline.options.${option}.label`
),
description: localize(
`ui.panel.config.voice_assistants.satellite_wizard.pipeline.options.${option}.description`
),
value: option,
});
} else {
unsupportedOptions.push({
label: localize(
`ui.panel.config.voice_assistants.satellite_wizard.pipeline.options.${option}.label`
),
value: option,
});
}
});
return { supportedOptions, unsupportedOptions };
});
protected override render() {
if (!this._cloudChecked) {
if (!this._cloudChecked || !this._languageScores) {
return nothing;
}
return html`<div class="content">
<h1>
${this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.pipeline.title"
)}
</h1>
<p class="secondary">
${this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.pipeline.secondary"
)}
</p>
<div class="container">
<div class="messages-container cloud">
<div class="message user ${this._showFirst ? "show" : ""}">
${!this._showFirst ? "…" : "Turn on the lights in the bedroom"}
</div>
${this._showFirst
? html`<div class="timing user">
0.2
${this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.pipeline.seconds"
)}
</div>`
: nothing}
${this._showFirst
? html` <div class="message hass ${this._showSecond ? "show" : ""}">
${!this._showSecond ? "…" : "Turned on the lights"}
</div>`
: nothing}
${this._showSecond
? html`<div class="timing hass">
0.4
${this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.pipeline.seconds"
)}
</div>`
: nothing}
</div>
<h2>Home Assistant Cloud</h2>
<p>
if (!this.language) {
const language = formatLanguageCode(
this.hass.config.language,
this.hass.locale
);
return html`<div class="content">
<h1>
${this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.pipeline.cloud.description"
"ui.panel.config.voice_assistants.satellite_wizard.pipeline.unsupported_language.header"
)}
</p>
<ha-button @click=${this._setupCloud} unelevated
>${this.hass.localize("ui.panel.config.common.learn_more")}</ha-button
</h1>
${this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.pipeline.unsupported_language.secondary",
{ language }
)}
<ha-language-picker
.hass=${this.hass}
.label=${this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.pipeline.unsupported_language.language_picker"
)}
.languages=${this.languages}
@value-changed=${this._languageChanged}
></ha-language-picker>
<a
href=${documentationUrl(
this.hass,
"/voice_control/contribute-voice/"
)}
>${this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.pipeline.unsupported_language.contribute",
{ language }
)}</a
>
</div>
<div class="container">
<div class="messages-container rpi">
<div class="message user ${this._showThird ? "show" : ""}">
${!this._showThird ? "…" : "Turn on the lights in the bedroom"}
</div>
${this._showThird
? html`<div class="timing user">
2
${this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.pipeline.seconds"
)}
</div>`
: nothing}
${this._showThird
? html`<div class="message hass ${this._showFourth ? "show" : ""}">
${!this._showFourth ? "…" : "Turned on the lights"}
</div>`
: nothing}
${this._showFourth
? html`<div class="timing hass">
1
${this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.pipeline.seconds"
)}
</div>`
: nothing}
</div>
<h2>
</div>`;
}
const score = this._languageScores[this.language];
const options = this._getOptions(
score || { cloud: 3, focused_local: 0, full_local: 0 },
this.hass.localize
);
const performance = !this._value
? ""
: this._value === "full_local"
? "low"
: "high";
const commands = !this._value
? ""
: score?.[this._value] > 2
? "high"
: score?.[this._value] > 1
? "ready"
: score?.[this._value] > 0
? "low"
: "";
return html`<div class="content">
<h1>
${this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.pipeline.local.title"
"ui.panel.config.voice_assistants.satellite_wizard.pipeline.title"
)}
</h2>
<p>
${this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.pipeline.local.description"
)}
</p>
<div class="row">
<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>
${this.hass.localize(
"ui.panel.config.common.learn_more"
)}</ha-button
>
</a>
<ha-button @click=${this._setupLocal} unelevated
</h1>
<div class="bar-header">
<span
>${this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.pipeline.local.setup"
)}</ha-button
"ui.panel.config.voice_assistants.satellite_wizard.pipeline.performance.header"
)}</span
><span
>${!performance
? ""
: this.hass.localize(
`ui.panel.config.voice_assistants.satellite_wizard.pipeline.performance.${performance}`
)}</span
>
</div>
<div class="perf-bar ${performance}">
<div class="segment"></div>
<div class="segment"></div>
<div class="segment"></div>
</div>
<div class="bar-header">
<span
>${this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.pipeline.commands.header"
)}</span
><span
>${!commands
? ""
: this.hass.localize(
`ui.panel.config.voice_assistants.satellite_wizard.pipeline.commands.${commands}`
)}</span
>
</div>
<div class="perf-bar ${commands}">
<div class="segment"></div>
<div class="segment"></div>
<div class="segment"></div>
</div>
<ha-select-box
max_columns="1"
.options=${options.supportedOptions}
.value=${this._value}
@value-changed=${this._valueChanged}
></ha-select-box>
${options.unsupportedOptions.length
? html`<h3>
${this.hass.localize(
"ui.panel.config.voice_assistants.satellite_wizard.pipeline.unsupported"
)}
</h3>
<ha-select-box
max_columns="1"
.options=${options.unsupportedOptions}
disabled
></ha-select-box>`
: nothing}
</div>
</div>`;
<div class="footer">
<ha-button
@click=${this._createPipeline}
unelevated
.disabled=${!this._value}
>${this.hass.localize("ui.common.next")}</ha-button
>
</div>`;
}
private async _checkCloud() {
if (!isComponentLoaded(this.hass, "cloud")) {
private async _fetchData() {
const cloud =
(await this._hasCloud()) && (await this._createCloudPipeline());
if (!cloud) {
this._cloudChecked = true;
return;
this._languageScores = (await getLanguageScores(this.hass)).languages;
}
}
private async _hasCloud(): Promise<boolean> {
if (!isComponentLoaded(this.hass, "cloud")) {
return false;
}
const cloudStatus = await fetchCloudStatus(this.hass);
if (!cloudStatus.logged_in || !cloudStatus.active_subscription) {
this._cloudChecked = true;
return;
return false;
}
return true;
}
private async _createCloudPipeline(): Promise<boolean> {
let cloudTtsEntityId;
let cloudSttEntityId;
for (const entity of Object.values(this.hass.entities)) {
@ -206,186 +279,212 @@ export class HaVoiceAssistantSetupStepPipeline extends LitElement {
}
}
}
const pipelines = await listAssistPipelines(this.hass);
const preferredPipeline = pipelines.pipelines.find(
(pipeline) => pipeline.id === pipelines.preferred_pipeline
);
if (preferredPipeline) {
if (
preferredPipeline.conversation_engine ===
"conversation.home_assistant" &&
preferredPipeline.tts_engine === cloudTtsEntityId &&
preferredPipeline.stt_engine === cloudSttEntityId
) {
await this.hass.callService(
"select",
"select_option",
{ option: "preferred" },
{ entity_id: this.assistConfiguration?.pipeline_entity_id }
);
fireEvent(this, "next-step", { step: STEP.SUCCESS, noPrevious: true });
return;
}
}
let cloudPipeline = pipelines.pipelines.find(
(pipeline) =>
pipeline.conversation_engine === "conversation.home_assistant" &&
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
try {
const pipelines = await listAssistPipelines(this.hass);
const preferredPipeline = pipelines.pipelines.find(
(pipeline) => pipeline.id === pipelines.preferred_pipeline
);
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 no-loop-func
(pipeline) => pipeline.name === pipelineName
)
) {
pipelineName = `Home Assistant Cloud ${i}`;
i++;
if (preferredPipeline) {
if (
preferredPipeline.conversation_engine ===
"conversation.home_assistant" &&
preferredPipeline.tts_engine === cloudTtsEntityId &&
preferredPipeline.stt_engine === cloudSttEntityId
) {
await this.hass.callService(
"select",
"select_option",
{ option: "preferred" },
{ entity_id: this.assistConfiguration?.pipeline_entity_id }
);
fireEvent(this, "next-step", {
step: STEP.SUCCESS,
noPrevious: true,
});
return true;
}
}
cloudPipeline = await 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: 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,
});
}
let cloudPipeline = pipelines.pipelines.find(
(pipeline) =>
pipeline.conversation_engine === "conversation.home_assistant" &&
pipeline.tts_engine === cloudTtsEntityId &&
pipeline.stt_engine === cloudSttEntityId
);
await this.hass.callService(
"select",
"select_option",
{ option: cloudPipeline.name },
{ entity_id: this.assistConfiguration?.pipeline_entity_id }
);
fireEvent(this, "next-step", { step: STEP.SUCCESS, noPrevious: true });
if (!cloudPipeline) {
const agent = (
await listAgents(
this.hass,
this.language || this.hass.config.language,
this.hass.config.country || undefined
)
).agents.find((agnt) => agnt.id === "conversation.home_assistant");
if (!agent?.supported_languages.length) {
return false;
}
const ttsEngine = (
await listTTSEngines(
this.hass,
this.language || this.hass.config.language,
this.hass.config.country || undefined
)
).providers.find((provider) => provider.engine_id === cloudTtsEntityId);
if (!ttsEngine?.supported_languages?.length) {
return false;
}
const ttsVoices = await listTTSVoices(
this.hass,
cloudTtsEntityId,
ttsEngine.supported_languages[0]
);
const sttEngine = (
await listSTTEngines(
this.hass,
this.language || this.hass.config.language,
this.hass.config.country || undefined
)
).providers.find((provider) => provider.engine_id === cloudSttEntityId);
if (!sttEngine?.supported_languages?.length) {
return false;
}
let pipelineName = "Home Assistant Cloud";
let i = 1;
while (
pipelines.pipelines.find(
// eslint-disable-next-line no-loop-func
(pipeline) => pipeline.name === pipelineName
)
) {
pipelineName = `Home Assistant Cloud ${i}`;
i++;
}
cloudPipeline = await createAssistPipeline(this.hass, {
name: pipelineName,
language: (this.language || this.hass.config.language).split("-")[0],
conversation_engine: "conversation.home_assistant",
conversation_language: agent.supported_languages[0],
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 }
);
fireEvent(this, "next-step", { step: STEP.SUCCESS, noPrevious: true });
return true;
} catch (_e) {
return false;
}
}
private _valueChanged(ev: CustomEvent) {
this._value = ev.detail.value;
}
private async _setupCloud() {
this._nextStep(STEP.CLOUD);
if (await this._hasCloud()) {
this._createCloudPipeline();
return;
}
fireEvent(this, "next-step", { step: STEP.CLOUD });
}
private async _setupLocal() {
this._nextStep(STEP.LOCAL);
private _createPipeline() {
if (this._value === "cloud") {
this._setupCloud();
} else if (this._value === "focused_local") {
this._setupLocalFocused();
} else {
this._setupLocalFull();
}
}
private _nextStep(step?: STEP) {
fireEvent(this, "next-step", { step });
private _setupLocalFocused() {
fireEvent(this, "next-step", { step: STEP.LOCAL, option: this._value });
}
private _setupLocalFull() {
fireEvent(this, "next-step", { step: STEP.LOCAL, option: this._value });
}
private _languageChanged(ev: CustomEvent) {
if (!ev.detail.value) {
return;
}
fireEvent(this, "language-changed", { value: ev.detail.value });
}
static styles = [
AssistantSetupStyles,
css`
.container {
border-radius: 16px;
border: 1px solid var(--divider-color);
overflow: hidden;
padding-bottom: 16px;
:host {
text-align: left;
}
.container:last-child {
margin-top: 16px;
}
.messages-container {
padding: 24px;
box-sizing: border-box;
height: 195px;
background: var(--input-fill-color);
.perf-bar {
width: 100%;
height: 10px;
display: flex;
flex-direction: column;
}
.message {
white-space: nowrap;
font-size: 18px;
clear: both;
gap: 4px;
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;
.segment {
flex-grow: 1;
background-color: var(--disabled-color);
transition: background-color 0.3s;
}
.cloud .message {
transition: width 0.5s;
.segment:first-child {
border-radius: 4px 0 0 4px;
}
.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);
.segment:last-child {
border-radius: 0 4px 4px 0;
}
.timing.user {
align-self: self-end;
.perf-bar.high .segment {
background-color: var(--success-color);
}
.message.user.show {
width: 295px;
.perf-bar.ready .segment:nth-child(-n + 2) {
background-color: var(--warning-color);
}
.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);
.perf-bar.low .segment:nth-child(1) {
background-color: var(--error-color);
}
.timing.hass {
align-self: self-start;
}
.message.hass.show {
width: 184px;
}
.row {
.bar-header {
display: flex;
justify-content: space-between;
margin: 0 16px;
margin: 8px 0;
margin-top: 16px;
}
ha-select-box {
display: block;
}
ha-select-box:first-of-type {
margin-top: 32px;
}
.footer {
margin-top: 16px;
}
ha-language-picker {
display: block;
margin-top: 16px;
margin-bottom: 16px;
}
`,
];

View File

@ -187,7 +187,15 @@ export default <T extends Constructor<HassElement>>(superClass: T) =>
}
private _canOverrideAlphanumericInput(e: KeyboardEvent) {
const el = e.composedPath()[0] as Element;
const composedPath = e.composedPath();
if (
composedPath.some((el) => "tagName" in el && el.tagName === "HA-MENU")
) {
return false;
}
const el = composedPath[0] as Element;
if (el.tagName === "TEXTAREA") {
return false;

View File

@ -3388,16 +3388,38 @@
"no_selection": "Please select an area"
},
"pipeline": {
"title": "What hardware do you want to use?",
"secondary": "How quickly your assistant responds depends on the power of the hardware.",
"seconds": "seconds",
"cloud": {
"description": "Ideal if you don't have a powerful system at home."
"title": "How do you want your voice to be processed?",
"performance": {
"header": "Performance on low-powered system",
"low": "Low",
"high": "High"
},
"local": {
"title": "Do-it-yourself",
"description": "Install add-ons or containers to run it on your own system. Powerful hardware is needed for fast responses.",
"setup": "Set up"
"commands": {
"header": "Supported commands",
"low": "Needs work",
"ready": "Ready to be used",
"high": "Fully supported"
},
"options": {
"cloud": {
"label": "Home Assistant Cloud",
"description": "Offloads speech processing to a fast, private cloud. Offering the highest accuracy and widest language support. Home Assistant Cloud is a subscription service that includes voice processing."
},
"focused_local": {
"label": "Focused local processing",
"description": "Limited to a set list of common home control phrases, this allows any system to process speech locally and offline."
},
"full_local": {
"label": "Full local processing",
"description": "Full speech processing is done locally, requiring high processing power for adequate speed and accuracy."
}
},
"unsupported": "Unsupported",
"unsupported_language": {
"header": "Your language is not supported",
"secondary": "Your language {language} is not supported yet. You can select a different language to continue.",
"language_picker": "Language",
"contribute": "Contribute to the voice initiative for {language}"
}
},
"cloud": {
@ -3418,9 +3440,12 @@
"installing_piper": "Installing Piper add-on",
"starting_piper": "Starting Piper add-on",
"setup_piper": "Setting up Piper",
"installing_whisper": "Installing Whisper add-on",
"starting_whisper": "Starting Whisper add-on",
"setup_whisper": "Setting up Whisper",
"installing_faster-whisper": "Installing Whisper add-on",
"starting_faster-whisper": "Starting Whisper add-on",
"setup_faster-whisper": "Setting up Whisper",
"installing_speech-to-phrase": "Installing Speech-to-Phrase add-on",
"starting_speech-to-phrase": "Starting Speech-to-Phrase add-on",
"setup_speech-to-phrase": "Setting up Speech-to-Phrase",
"creating_pipeline": "Creating assistant"
},
"errors": {