diff --git a/src/components/ha-conversation-agent-picker.ts b/src/components/ha-conversation-agent-picker.ts index 858d6f687b..8e3fdc4aeb 100644 --- a/src/components/ha-conversation-agent-picker.ts +++ b/src/components/ha-conversation-agent-picker.ts @@ -94,6 +94,10 @@ export class HaConversationAgentPicker extends LitElement { const selectedAgent = agents.find((agent) => agent.id === this.value); + fireEvent(this, "supported-languages-changed", { + value: selectedAgent?.supported_languages, + }); + if (!selectedAgent || selectedAgent.supported_languages?.length === 0) { this.value = undefined; fireEvent(this, "value-changed", { value: this.value }); @@ -120,6 +124,10 @@ export class HaConversationAgentPicker extends LitElement { } this.value = target.value === NONE ? undefined : target.value; fireEvent(this, "value-changed", { value: this.value }); + fireEvent(this, "supported-languages-changed", { + value: this._agents!.find((agent) => agent.id === this.value) + ?.supported_languages, + }); } } @@ -127,4 +135,7 @@ declare global { interface HTMLElementTagNameMap { "ha-conversation-agent-picker": HaConversationAgentPicker; } + interface HASSDomEvents { + "supported-languages-changed": { value: string[] | undefined }; + } } diff --git a/src/components/ha-language-picker.ts b/src/components/ha-language-picker.ts index bbd53e7c8e..2bda976cd4 100644 --- a/src/components/ha-language-picker.ts +++ b/src/components/ha-language-picker.ts @@ -3,8 +3,8 @@ import { CSSResultGroup, html, LitElement, + nothing, PropertyValues, - TemplateResult, } from "lit"; import { customElement, property, state } from "lit/decorators"; import memoizeOne from "memoize-one"; @@ -74,7 +74,7 @@ export class HaLanguagePicker extends LitElement { ); } - protected render(): TemplateResult { + protected render() { const value = this.value; const languageOptions = this._getLanguagesOptions( @@ -83,6 +83,10 @@ export class HaLanguagePicker extends LitElement { this.nativeName ); + if (languageOptions.length === 0) { + return nothing; + } + return html` `; + } + + static styles = css` + ha-tts-picker { + width: 100%; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-selector-tts-voice": HaTTSVoiceSelector; + } +} diff --git a/src/components/ha-selector/ha-selector.ts b/src/components/ha-selector/ha-selector.ts index 77d880daf6..5066271770 100644 --- a/src/components/ha-selector/ha-selector.ts +++ b/src/components/ha-selector/ha-selector.ts @@ -42,6 +42,7 @@ const LOAD_ELEMENTS = { media: () => import("./ha-selector-media"), theme: () => import("./ha-selector-theme"), tts: () => import("./ha-selector-tts"), + tts_voice: () => import("./ha-selector-tts-voice"), location: () => import("./ha-selector-location"), color_temp: () => import("./ha-selector-color-temp"), ui_action: () => import("./ha-selector-ui-action"), diff --git a/src/components/ha-stt-picker.ts b/src/components/ha-stt-picker.ts index c506adcb92..d01f637a24 100644 --- a/src/components/ha-stt-picker.ts +++ b/src/components/ha-stt-picker.ts @@ -19,6 +19,8 @@ import type { HaSelect } from "./ha-select"; const NONE = "__NONE_OPTION__"; +const NAME_MAP = { cloud: "Home Assistant Cloud" }; + @customElement("ha-stt-picker") export class HaSTTPicker extends LitElement { @property() public value?: string; @@ -64,12 +66,18 @@ export class HaSTTPicker extends LitElement { ` : nothing} ${this._engines.map((engine) => { - const stateObj = this.hass!.states[engine.engine_id]; + let label = engine.engine_id; + if (engine.engine_id.includes(".")) { + const stateObj = this.hass!.states[engine.engine_id]; + label = stateObj ? computeStateName(stateObj) : engine.engine_id; + } else if (engine.engine_id in NAME_MAP) { + label = NAME_MAP[engine.engine_id]; + } return html` - ${stateObj ? computeStateName(stateObj) : engine.engine_id} + ${label} `; })} @@ -98,6 +106,10 @@ export class HaSTTPicker extends LitElement { (engine) => engine.engine_id === this.value ); + fireEvent(this, "supported-languages-changed", { + value: selectedEngine?.supported_languages, + }); + if (!selectedEngine || selectedEngine.supported_languages?.length === 0) { this.value = undefined; fireEvent(this, "value-changed", { value: this.value }); @@ -124,6 +136,10 @@ export class HaSTTPicker extends LitElement { } this.value = target.value === NONE ? undefined : target.value; fireEvent(this, "value-changed", { value: this.value }); + fireEvent(this, "supported-languages-changed", { + value: this._engines!.find((engine) => engine.engine_id === this.value) + ?.supported_languages, + }); } } diff --git a/src/components/ha-tts-picker.ts b/src/components/ha-tts-picker.ts index 35853b6e26..bde9196e49 100644 --- a/src/components/ha-tts-picker.ts +++ b/src/components/ha-tts-picker.ts @@ -19,6 +19,8 @@ import type { HaSelect } from "./ha-select"; const NONE = "__NONE_OPTION__"; +const NAME_MAP = { cloud: "Home Assistant Cloud" }; + @customElement("ha-tts-picker") export class HaTTSPicker extends LitElement { @property() public value?: string; @@ -64,12 +66,18 @@ export class HaTTSPicker extends LitElement { ` : nothing} ${this._engines.map((engine) => { - const stateObj = this.hass!.states[engine.engine_id]; + let label = engine.engine_id; + if (engine.engine_id.includes(".")) { + const stateObj = this.hass!.states[engine.engine_id]; + label = stateObj ? computeStateName(stateObj) : engine.engine_id; + } else if (engine.engine_id in NAME_MAP) { + label = NAME_MAP[engine.engine_id]; + } return html` - ${stateObj ? computeStateName(stateObj) : engine.engine_id} + ${label} `; })} @@ -98,6 +106,10 @@ export class HaTTSPicker extends LitElement { (engine) => engine.engine_id === this.value ); + fireEvent(this, "supported-languages-changed", { + value: selectedEngine?.supported_languages, + }); + if (!selectedEngine || selectedEngine.supported_languages?.length === 0) { this.value = undefined; fireEvent(this, "value-changed", { value: this.value }); @@ -124,6 +136,10 @@ export class HaTTSPicker extends LitElement { } this.value = target.value === NONE ? undefined : target.value; fireEvent(this, "value-changed", { value: this.value }); + fireEvent(this, "supported-languages-changed", { + value: this._engines!.find((engine) => engine.engine_id === this.value) + ?.supported_languages, + }); } } diff --git a/src/components/ha-tts-voice-picker.ts b/src/components/ha-tts-voice-picker.ts new file mode 100644 index 0000000000..7afc737c17 --- /dev/null +++ b/src/components/ha-tts-voice-picker.ts @@ -0,0 +1,130 @@ +import { + css, + CSSResultGroup, + html, + LitElement, + nothing, + PropertyValues, +} from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../common/dom/fire_event"; +import { stopPropagation } from "../common/dom/stop_propagation"; +import { debounce } from "../common/util/debounce"; +import { listTTSVoices } from "../data/tts"; +import { HomeAssistant } from "../types"; +import "./ha-list-item"; +import "./ha-select"; +import type { HaSelect } from "./ha-select"; + +const NONE = "__NONE_OPTION__"; + +@customElement("ha-tts-voice-picker") +export class HaTTSVoicePicker extends LitElement { + @property() public value?: string; + + @property() public label?: string; + + @property() public engineId?: string; + + @property() public language?: string; + + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean, reflect: true }) public disabled = false; + + @property({ type: Boolean }) public required = false; + + @state() _voices?: string[] | null; + + protected render() { + if (!this._voices) { + return nothing; + } + const value = this.value ?? (this.required ? this._voices[0] : NONE); + return html` + + ${!this.required + ? html` + ${this.hass!.localize("ui.components.tts-voice-picker.none")} + ` + : nothing} + ${this._voices.map( + (voice) => html` + ${voice} + ` + )} + + `; + } + + protected willUpdate(changedProperties: PropertyValues): void { + super.willUpdate(changedProperties); + if (!this.hasUpdated) { + this._updateVoices(); + } else if ( + changedProperties.has("language") || + changedProperties.has("engineId") + ) { + this._debouncedUpdateVoices(); + } + } + + private _debouncedUpdateVoices = debounce(() => this._updateVoices(), 500); + + private async _updateVoices() { + if (!this.engineId || !this.language) { + this._voices = undefined; + return; + } + this._voices = ( + await listTTSVoices(this.hass, this.engineId, this.language) + ).voices; + + if (!this.value) { + return; + } + + if (!this._voices || !this._voices.includes(this.value)) { + this.value = undefined; + fireEvent(this, "value-changed", { value: this.value }); + } + } + + static get styles(): CSSResultGroup { + return css` + ha-select { + width: 100%; + } + `; + } + + private _changed(ev): void { + const target = ev.target as HaSelect; + if ( + !this.hass || + target.value === "" || + target.value === this.value || + (this.value === undefined && target.value === NONE) + ) { + return; + } + this.value = target.value === NONE ? undefined : target.value; + fireEvent(this, "value-changed", { value: this.value }); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-tts-voice-picker": HaTTSVoicePicker; + } +} diff --git a/src/data/selector.ts b/src/data/selector.ts index 4f07b479cb..3376191bed 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -43,6 +43,7 @@ export type Selector = | ThemeSelector | TimeSelector | TTSSelector + | TTSVoiceSelector | UiActionSelector | UiColorSelector; @@ -344,6 +345,10 @@ export interface TTSSelector { tts: { language?: string } | null; } +export interface TTSVoiceSelector { + tts_voice: { engineId?: string; language?: string } | null; +} + export interface UiActionSelector { ui_action: { actions?: UiAction[]; diff --git a/src/data/tts.ts b/src/data/tts.ts index 47dd2c7266..5f2175c135 100644 --- a/src/data/tts.ts +++ b/src/data/tts.ts @@ -42,7 +42,7 @@ export const listTTSVoices = ( hass: HomeAssistant, engine_id: string, language: string -): Promise<{ voices: TTSVoice[] }> => +): Promise<{ voices: string[] | null }> => hass.callWS({ type: "tts/engine/voices", engine_id, diff --git a/src/panels/config/voice-assistants/assist-pipeline-detail/assist-pipeline-detail-conversation.ts b/src/panels/config/voice-assistants/assist-pipeline-detail/assist-pipeline-detail-conversation.ts index 902bdf1eac..5257e234cd 100644 --- a/src/panels/config/voice-assistants/assist-pipeline-detail/assist-pipeline-detail-conversation.ts +++ b/src/panels/config/voice-assistants/assist-pipeline-detail/assist-pipeline-detail-conversation.ts @@ -1,5 +1,5 @@ import { css, CSSResultGroup, html, LitElement } from "lit"; -import { customElement, property } from "lit/decorators"; +import { customElement, property, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import { SchemaUnion } from "../../../../components/ha-form/types"; import { AssistPipeline } from "../../../../data/assist_pipeline"; @@ -11,8 +11,10 @@ export class AssistPipelineDetailConversation extends LitElement { @property() public data?: Partial; + @state() private _supportedLanguages?: string[]; + private _schema = memoizeOne( - (language?: string) => + (language?: string, supportedLanguages?: string[]) => [ { name: "", @@ -29,8 +31,9 @@ export class AssistPipelineDetailConversation extends LitElement { }, { name: "conversation_language", + required: true, selector: { - text: {}, + language: { languages: supportedLanguages ?? [] }, }, }, ] as const, @@ -56,15 +59,20 @@ export class AssistPipelineDetailConversation extends LitElement {

`; } + private _supportedLanguagesChanged(ev) { + this._supportedLanguages = ev.detail.value; + } + static get styles(): CSSResultGroup { return css` .section { diff --git a/src/panels/config/voice-assistants/assist-pipeline-detail/assist-pipeline-detail-stt.ts b/src/panels/config/voice-assistants/assist-pipeline-detail/assist-pipeline-detail-stt.ts index cde84b698f..0675af69ff 100644 --- a/src/panels/config/voice-assistants/assist-pipeline-detail/assist-pipeline-detail-stt.ts +++ b/src/panels/config/voice-assistants/assist-pipeline-detail/assist-pipeline-detail-stt.ts @@ -1,5 +1,5 @@ import { css, CSSResultGroup, html, LitElement } from "lit"; -import { customElement, property } from "lit/decorators"; +import { customElement, property, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import { SchemaUnion } from "../../../../components/ha-form/types"; import { AssistPipeline } from "../../../../data/assist_pipeline"; @@ -11,8 +11,10 @@ export class AssistPipelineDetailSTT extends LitElement { @property() public data?: Partial; + @state() private _supportedLanguages?: string[]; + private _schema = memoizeOne( - (language?: string) => + (language?: string, supportedLanguages?: string[]) => [ { name: "", @@ -28,8 +30,9 @@ export class AssistPipelineDetailSTT extends LitElement { }, { name: "stt_language", + required: true, selector: { - text: {}, + language: { languages: supportedLanguages ?? [] }, }, }, ] as const, @@ -55,15 +58,20 @@ export class AssistPipelineDetailSTT extends LitElement {

`; } + private _supportedLanguagesChanged(ev) { + this._supportedLanguages = ev.detail.value; + } + static get styles(): CSSResultGroup { return css` .section { diff --git a/src/panels/config/voice-assistants/assist-pipeline-detail/assist-pipeline-detail-tts.ts b/src/panels/config/voice-assistants/assist-pipeline-detail/assist-pipeline-detail-tts.ts index 09bd3c0296..abb567289e 100644 --- a/src/panels/config/voice-assistants/assist-pipeline-detail/assist-pipeline-detail-tts.ts +++ b/src/panels/config/voice-assistants/assist-pipeline-detail/assist-pipeline-detail-tts.ts @@ -1,5 +1,5 @@ import { css, CSSResultGroup, html, LitElement } from "lit"; -import { customElement, property } from "lit/decorators"; +import { customElement, property, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import { SchemaUnion } from "../../../../components/ha-form/types"; import { AssistPipeline } from "../../../../data/assist_pipeline"; @@ -11,8 +11,10 @@ export class AssistPipelineDetailTTS extends LitElement { @property() public data?: Partial; + @state() private _supportedLanguages?: string[]; + private _schema = memoizeOne( - (language?: string) => + (language?: string, supportedLanguages?: string[]) => [ { name: "", @@ -26,18 +28,20 @@ export class AssistPipelineDetailTTS extends LitElement { }, }, }, - { name: "tts_language", selector: { - text: {}, + language: { languages: supportedLanguages ?? [] }, }, + required: true, }, { name: "tts_voice", selector: { - text: {}, + tts_voice: {}, }, + context: { language: "tts_language", engineId: "tts_engine" }, + required: true, }, ] as const, }, @@ -63,15 +67,20 @@ export class AssistPipelineDetailTTS extends LitElement {

`; } + private _supportedLanguagesChanged(ev) { + this._supportedLanguages = ev.detail.value; + } + static get styles(): CSSResultGroup { return css` .section { diff --git a/src/translations/en.json b/src/translations/en.json index 32c5eaa2f5..651faa3f21 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -417,6 +417,10 @@ "tts": "Text to Speech", "none": "None" }, + "tts-voice-picker": { + "voice": "Voice", + "none": "None" + }, "user-picker": { "no_user": "No user", "add_user": "Add user",