Compare commits

..

2 Commits

Author SHA1 Message Date
Bram Kragten c8243e6bd0 Add time and sun category to target view 2026-06-23 14:29:33 +02:00
Bram Kragten 93a8d296a8 Move purpose-specific triggers and conditions out of labs (#52801) 2026-06-23 14:11:32 +02:00
33 changed files with 945 additions and 1598 deletions
+1 -5
View File
@@ -6,15 +6,11 @@ export interface AlexaEntity {
interfaces: string[];
}
export interface AlexaEntityConfig {
name?: string | null;
}
export const fetchCloudAlexaEntities = (hass: HomeAssistant) =>
hass.callWS<AlexaEntity[]>({ type: "cloud/alexa/entities" });
export const fetchCloudAlexaEntity = (hass: HomeAssistant, entity_id: string) =>
hass.callWS<AlexaEntityConfig>({
hass.callWS<AlexaEntity>({
type: "cloud/alexa/entities/get",
entity_id,
});
+8 -1
View File
@@ -331,7 +331,14 @@ export interface AutomationElementGroupCollection {
export type AutomationElementGroup = Record<
string,
{ icon?: string; members?: AutomationElementGroup }
{
icon?: string;
members?: AutomationElementGroup;
// Backend element domains (e.g. "calendar", "sun") whose triggers/conditions
// are bundled into this group instead of appearing as their own dynamic
// domain group.
domains?: string[];
}
>;
export type LegacyCondition =
+2 -13
View File
@@ -172,23 +172,12 @@ export const removeCloudData = (hass: HomeAssistant) =>
export const updateCloudGoogleEntityConfig = (
hass: HomeAssistant,
entity_id: string,
values: { disable_2fa?: boolean; name?: string | null; aliases?: string[] }
disable_2fa: boolean
) =>
hass.callWS({
type: "cloud/google_assistant/entities/update",
entity_id,
...values,
});
export const updateCloudAlexaEntityConfig = (
hass: HomeAssistant,
entity_id: string,
name: string | null
) =>
hass.callWS({
type: "cloud/alexa/entities/update",
entity_id,
name,
disable_2fa,
});
export const cloudSyncGoogleAssistant = (hass: HomeAssistant) =>
+12 -7
View File
@@ -1,7 +1,7 @@
import { mdiMapClock, mdiShape } from "@mdi/js";
import { mdiClockOutline, mdiShape, mdiWeatherSunny } from "@mdi/js";
import type { Connection } from "home-assistant-js-websocket";
import { computeDomain } from "../common/entity/compute_domain";
import { computeObjectId } from "../common/entity/compute_object_id";
import type { HomeAssistant } from "../types";
import type { AutomationElementGroupCollection } from "./automation";
import type { Selector, TargetSelector } from "./selector";
@@ -9,9 +9,14 @@ export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [
{
groups: {
dynamicGroups: {},
time_location: {
icon: mdiMapClock,
members: { sun: {}, time: {}, zone: {} },
time: {
icon: mdiClockOutline,
members: { time: {} },
domains: ["calendar", "schedule"],
},
sun: {
icon: mdiWeatherSunny,
domains: ["sun"],
},
helpers: {},
template: {},
@@ -68,10 +73,10 @@ export interface ConditionDescription {
export type ConditionDescriptions = Record<string, ConditionDescription>;
export const subscribeConditions = (
hass: HomeAssistant,
connection: Connection,
callback: (conditions: ConditionDescriptions) => void
) =>
hass.connection.subscribeMessage<ConditionDescriptions>(callback, {
connection.subscribeMessage<ConditionDescriptions>(callback, {
type: "condition_platforms/subscribe",
});
+15
View File
@@ -12,11 +12,13 @@ import type {
HomeAssistantUI,
} from "../../types";
import type { RelatedIdSets } from "../../common/search/related-context";
import type { ConditionDescriptions } from "../condition";
import type { ConfigEntry } from "../config_entries";
import type { EntityRegistryEntry } from "../entity/entity_registry";
import type { DomainManifestLookup } from "../integration";
import type { LabelRegistryEntry } from "../label/label_registry";
import type { ItemType } from "../search";
import type { TriggerDescriptions } from "../trigger";
/**
* Entity, device, area, and floor registries
@@ -131,6 +133,19 @@ export const configEntriesContext =
export const manifestsContext =
createContext<DomainManifestLookup>("manifests");
/**
* Lazy loaded trigger platform descriptions, keyed by trigger key.
*/
export const triggerDescriptionsContext = createContext<TriggerDescriptions>(
"triggerDescriptions"
);
/**
* Lazy loaded condition platform descriptions, keyed by condition key.
*/
export const conditionDescriptionsContext =
createContext<ConditionDescriptions>("conditionDescriptions");
// #endregion lazy-contexts
// #region deprecated-contexts
-2
View File
@@ -5,8 +5,6 @@ export interface GoogleEntity {
traits: string[];
might_2fa: boolean;
disable_2fa?: boolean;
name?: string | null;
aliases?: string[] | null;
}
export const fetchCloudGoogleEntities = (hass: HomeAssistant) =>
+11 -9
View File
@@ -1,8 +1,8 @@
import { mdiMapClock, mdiShape } from "@mdi/js";
import { mdiClockOutline, mdiShape, mdiWeatherSunny } from "@mdi/js";
import type { Connection } from "home-assistant-js-websocket";
import { computeDomain } from "../common/entity/compute_domain";
import { computeObjectId } from "../common/entity/compute_object_id";
import type { HomeAssistant } from "../types";
import type {
AutomationElementGroupCollection,
Trigger,
@@ -14,15 +14,17 @@ export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
{
groups: {
dynamicGroups: {},
time_location: {
icon: mdiMapClock,
time: {
icon: mdiClockOutline,
members: {
calendar: {},
sun: {},
time: {},
time_pattern: {},
zone: {},
},
domains: ["calendar", "schedule"],
},
sun: {
icon: mdiWeatherSunny,
domains: ["sun"],
},
event: {},
geo_location: {},
@@ -73,10 +75,10 @@ export interface TriggerDescription {
export type TriggerDescriptions = Record<string, TriggerDescription>;
export const subscribeTriggers = (
hass: HomeAssistant,
connection: Connection,
callback: (triggers: TriggerDescriptions) => void
) =>
hass.connection.subscribeMessage<TriggerDescriptions>(callback, {
connection.subscribeMessage<TriggerDescriptions>(callback, {
type: "trigger_platforms/subscribe",
});
@@ -1,33 +0,0 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { ExtEntityRegistryEntry } from "../../../../data/entity/entity_registry";
import "../../../../panels/config/voice-assistants/voice-assistant-settings";
import type { HomeAssistant } from "../../../../types";
@customElement("ha-more-info-view-voice-assistant-settings")
class MoreInfoViewVoiceAssistantSettings extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entry!: ExtEntityRegistryEntry;
@property({ attribute: false }) public params?: { assistant: string };
protected render() {
if (!this.params || !this.entry) {
return nothing;
}
return html`<voice-assistant-settings
.hass=${this.hass}
.entityId=${this.entry.entity_id}
.assistant=${this.params.assistant}
.entry=${this.entry}
></voice-assistant-settings>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-more-info-view-voice-assistant-settings": MoreInfoViewVoiceAssistantSettings;
}
}
@@ -7,7 +7,6 @@ import type { ExposeEntitySettings } from "../../../../data/expose";
import { voiceAssistants } from "../../../../data/expose";
import "../../../../panels/config/voice-assistants/entity-voice-settings";
import type { HomeAssistant } from "../../../../types";
import { showVoiceAssistantSettingsView } from "./show-view-voice-assistant-settings";
@customElement("ha-more-info-view-voice-assistants")
class MoreInfoViewVoiceAssistants extends LitElement {
@@ -34,19 +33,9 @@ class MoreInfoViewVoiceAssistants extends LitElement {
.entityId=${this.entry.entity_id}
.entry=${this.entry}
.exposed=${this._calculateExposed(this.entry)}
@edit-assistant=${this._editAssistant}
></entity-voice-settings>`;
}
private _editAssistant(ev: CustomEvent) {
const assistant = ev.detail.assistant;
showVoiceAssistantSettingsView(
this,
voiceAssistants[assistant].name,
assistant
);
}
static get styles(): CSSResultGroup {
return [
css`
@@ -1,17 +0,0 @@
import { fireEvent } from "../../../../common/dom/fire_event";
export const loadVoiceAssistantSettingsView = () =>
import("./ha-more-info-view-voice-assistant-settings");
export const showVoiceAssistantSettingsView = (
element: HTMLElement,
title: string,
assistant: string
): void => {
fireEvent(element, "show-child-view", {
viewTag: "ha-more-info-view-voice-assistant-settings",
viewImport: loadVoiceAssistantSettingsView,
viewTitle: title,
viewParams: { assistant },
});
};
+2 -19
View File
@@ -59,14 +59,12 @@ import {
getExtendedEntityRegistryEntry,
updateEntityRegistryEntry,
} from "../../data/entity/entity_registry";
import { subscribeLabFeature } from "../../data/labs";
import type { ItemType } from "../../data/search";
import { SearchableDomains } from "../../data/search";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import type { EntitySettingsState } from "../../panels/config/entities/entity-registry-settings-editor";
import type { Helper } from "../../panels/config/helpers/const";
import { ScrollableFadeMixin } from "../../mixins/scrollable-fade-mixin";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import {
haStyleDialog,
haStyleDialogFixedTop,
@@ -126,7 +124,7 @@ const DEFAULT_VIEW: MoreInfoView = "info";
export class MoreInfoDialog extends DirtyStateProviderMixin<
EntitySettingsState | Helper | Record<string, string[]> | null,
"entity-registry" | "helper" | "vacuum-segment-mapping"
>()(SubscribeMixin(ScrollableFadeMixin(LitElement))) {
>()(ScrollableFadeMixin(LitElement)) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public large = false;
@@ -163,8 +161,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
@state() private _isEscapeEnabled = true;
@state() private _newTriggersAndConditions = false;
protected scrollFadeThreshold = 24;
protected get scrollableElement(): HTMLElement | null {
@@ -260,24 +256,11 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
private _shouldShowAddEntityTo(): boolean {
return (
(this._newTriggersAndConditions && !!this.hass.user?.is_admin) ||
!!this.hass.user?.is_admin ||
!!this.hass.auth.external?.config.hasEntityAddTo
);
}
protected hassSubscribe() {
return [
subscribeLabFeature(
this.hass.connection,
"automation",
"new_triggers_conditions",
(feature) => {
this._newTriggersAndConditions = feature.enabled;
}
),
];
}
private _getDeviceId(): string | null {
const entity = this.hass.entities[this._entityId!] as
| EntityRegistryEntry
+22 -52
View File
@@ -14,10 +14,7 @@ import {
mdiShape,
mdiTools,
} from "@mdi/js";
import type {
HassEntity,
UnsubscribeFunc,
} from "home-assistant-js-websocket/dist/types";
import type { HassEntity } from "home-assistant-js-websocket/dist/types";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -59,7 +56,6 @@ import {
computeEntityRegistryName,
sortEntityRegistryByName,
} from "../../../data/entity/entity_registry";
import { subscribeLabFeature } from "../../../data/labs";
import type { SceneEntity } from "../../../data/scene";
import type { ScriptEntity } from "../../../data/script";
import type { RelatedResult } from "../../../data/search";
@@ -69,7 +65,6 @@ import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
import "../../../layouts/hass-error-screen";
import "../../../layouts/hass-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { isHelperDomain } from "../helpers/const";
@@ -143,7 +138,7 @@ const NAVIGATION_ACTIONS: {
const MAX_COLUMNS = 3;
@customElement("ha-config-area-page")
class HaConfigAreaPage extends SubscribeMixin(LitElement) {
class HaConfigAreaPage extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public areaId!: string;
@@ -158,8 +153,6 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
@state() private _related?: RelatedResult;
@state() private _newTriggersConditions = false;
private _logbookTime = { recent: 86400 };
private _columnsController = createColumnsController(this, MAX_COLUMNS);
@@ -255,23 +248,6 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
}
}
// When new_triggers_conditions labs feature is promoted, this whole method can be removed.
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
if (!isComponentLoaded(this.hass!.config, "automation")) {
return [];
}
return [
subscribeLabFeature(
this.hass!.connection,
"automation",
"new_triggers_conditions",
(feature) => {
this._newTriggersConditions = feature.enabled;
}
),
];
}
protected render() {
if (!this.hass.areas || !this.hass.devices || !this.hass.entities) {
return nothing;
@@ -377,32 +353,26 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
></ha-icon-button>
</div>`
: nothing}
${area.picture && !this._newTriggersConditions
? nothing
: html`<div class="action-buttons">
${area.picture
? nothing
: html`<ha-button
appearance="filled"
.entry=${area}
@click=${this._showSettings}
>
<ha-svg-icon .path=${mdiImagePlus} slot="start"></ha-svg-icon>
${this.hass.localize("ui.panel.config.areas.add_picture")}
</ha-button>`}
${this._newTriggersConditions
? html`<ha-button
appearance="filled"
variant="brand"
@click=${this._showAddToDialog}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.add_to.item"
)}
</ha-button>`
: nothing}
</div>`}
<div class="action-buttons">
${area.picture
? nothing
: html`<ha-button
appearance="filled"
.entry=${area}
@click=${this._showSettings}
>
<ha-svg-icon .path=${mdiImagePlus} slot="start"></ha-svg-icon>
${this.hass.localize("ui.panel.config.areas.add_picture")}
</ha-button>`}
<ha-button
appearance="filled"
variant="brand"
@click=${this._showAddToDialog}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.dialogs.more_info_control.add_to.item")}
</ha-button>
</div>
<ha-card
outlined
.header=${this.hass.localize("ui.panel.config.devices.caption")}
@@ -1,3 +1,4 @@
import { consume } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -21,10 +22,9 @@ import {
CONDITION_BUILDING_BLOCKS,
getConditionDomain,
getConditionObjectId,
subscribeConditions,
} from "../../../../../data/condition";
import { conditionDescriptionsContext } from "../../../../../data/context";
import { domainToName } from "../../../../../data/integration";
import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin";
import type { HomeAssistant, ValueChangedEvent } from "../../../../../types";
import "../../condition/ha-automation-condition-editor";
import type HaAutomationConditionEditor from "../../condition/ha-automation-condition-editor";
@@ -42,10 +42,7 @@ import "../../condition/types/ha-automation-condition-zone";
import type { ActionElement } from "../ha-automation-action-row";
@customElement("ha-automation-action-condition")
export class HaConditionAction
extends SubscribeMixin(LitElement)
implements ActionElement
{
export class HaConditionAction extends LitElement implements ActionElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public disabled = false;
@@ -58,7 +55,9 @@ export class HaConditionAction
@property({ type: Boolean, attribute: "indent" }) public indent = false;
@state() private _conditionDescriptions: ConditionDescriptions = {};
@state()
@consume({ context: conditionDescriptionsContext, subscribe: true })
private _conditionDescriptions: ConditionDescriptions = {};
@query("ha-automation-condition-editor")
private _conditionEditor?: HaAutomationConditionEditor;
@@ -67,21 +66,6 @@ export class HaConditionAction
return { condition: "state" };
}
protected hassSubscribe() {
return [
subscribeConditions(this.hass, (conditions) =>
this._addConditions(conditions)
),
];
}
private _addConditions(conditions: ConditionDescriptions) {
this._conditionDescriptions = {
...this._conditionDescriptions,
...conditions,
};
}
protected render() {
const buildingBlock = CONDITION_BUILDING_BLOCKS.includes(
this.action.condition
@@ -7,10 +7,7 @@ import {
mdiHelpCircleOutline,
mdiPlus,
} from "@mdi/js";
import type {
HassServiceTarget,
UnsubscribeFunc,
} from "home-assistant-js-websocket";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -80,13 +77,16 @@ import {
CONDITION_COLLECTIONS,
getConditionDomain,
getConditionObjectId,
subscribeConditions,
} from "../../../data/condition";
import {
getConfigEntries,
type ConfigEntry,
} from "../../../data/config_entries";
import { labelsContext } from "../../../data/context";
import {
conditionDescriptionsContext,
labelsContext,
triggerDescriptionsContext,
} from "../../../data/context";
import { getDeviceEntityLookup } from "../../../data/device/device_registry";
import type { EntityComboBoxItem } from "../../../data/entity/entity_picker";
import { getFloorAreaLookup } from "../../../data/floor_registry";
@@ -101,7 +101,6 @@ import {
fetchIntegrationManifests,
} from "../../../data/integration";
import type { LabelRegistryEntry } from "../../../data/label/label_registry";
import { subscribeLabFeature } from "../../../data/labs";
import { filterSelectorEntities } from "../../../data/selector";
import {
TARGET_SEPARATOR,
@@ -116,7 +115,6 @@ import {
TRIGGER_COLLECTIONS,
getTriggerDomain,
getTriggerObjectId,
subscribeTriggers,
} from "../../../data/trigger";
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
@@ -194,6 +192,11 @@ const DYNAMIC_KEYWORDS = [
const DYNAMIC_TO_GENERIC = new Set([`${DYNAMIC_PREFIX}event`]);
// Group keys surfaced as their own section in the "by target" tab because
// their elements have no target (time/calendar/schedule, sun). Picking one
// drills into its items, like selecting the matching group in the "by type" tab.
const TIME_LOCATION_GROUPS = ["time", "sun"];
type CollectionGroupType = "helper" | "other" | "dynamic" | "customDynamic";
@customElement("add-automation-element-dialog")
@@ -227,7 +230,13 @@ class DialogAddAutomationElement
@state() private _narrow = false;
@state() private _triggerDescriptions: TriggerDescriptions = {};
@state()
@consume({ context: triggerDescriptionsContext, subscribe: true })
private _triggerDescriptions: TriggerDescriptions = {};
@state()
@consume({ context: conditionDescriptionsContext, subscribe: true })
private _conditionDescriptions: ConditionDescriptions = {};
@state() private _targetItems?: {
title: string;
@@ -236,12 +245,8 @@ class DialogAddAutomationElement
@state() private _loadItemsError = false;
@state() private _newTriggersAndConditions = false;
@state() private _openedFromQuery = false;
@state() private _conditionDescriptions: ConditionDescriptions = {};
@state()
@consume({ context: labelsContext, subscribe: true })
private _labelRegistry!: LabelRegistryEntry[];
@@ -259,10 +264,6 @@ class DialogAddAutomationElement
// #region variables
private _unsub?: Promise<UnsubscribeFunc>;
private _unsubscribeLabFeatures?: Promise<UnsubscribeFunc>;
private _configEntryLookup: Record<string, ConfigEntry> = {};
private _closing = false;
@@ -278,31 +279,6 @@ class DialogAddAutomationElement
) {
this._calculateUsedDomains();
}
if (changedProps.has("_newTriggersAndConditions")) {
this._subscribeDescriptions();
}
}
private _subscribeDescriptions() {
this._unsubscribe();
if (this._params?.type === "trigger") {
this._triggerDescriptions = {};
this._unsub = subscribeTriggers(this.hass, (triggers) => {
this._triggerDescriptions = {
...this._triggerDescriptions,
...triggers,
};
});
} else if (this._params?.type === "condition") {
this._conditionDescriptions = {};
this._unsub = subscribeConditions(this.hass, (conditions) => {
this._conditionDescriptions = {
...this._conditionDescriptions,
...conditions,
};
});
}
}
public showDialog(params: AddAutomationElementDialogParams): void {
@@ -334,28 +310,9 @@ class DialogAddAutomationElement
this._loadConfigEntries();
this._unsubscribe();
this._fetchManifests();
this._calculateUsedDomains();
this._unsubscribeLabFeatures = subscribeLabFeature(
this.hass.connection,
"automation",
"new_triggers_conditions",
(feature) => {
this._newTriggersAndConditions = feature.enabled;
this._tab = this._newTriggersAndConditions ? "targets" : "groups";
if (
queryTarget &&
this._newTriggersAndConditions &&
!this._selectedTarget
) {
this._selectedTarget = queryTarget;
this._getItemsByTarget();
}
}
);
if (!queryTarget) {
// add initial dialog view state to history
mainWindow.history.pushState(
@@ -372,11 +329,9 @@ class DialogAddAutomationElement
} else if (this._params?.type === "trigger") {
this.hass.loadBackendTranslation("triggers");
getTriggerIcons(this.hass.connection, this.hass.config);
this._subscribeDescriptions();
} else if (this._params?.type === "condition") {
this.hass.loadBackendTranslation("conditions");
getConditionIcons(this.hass.connection, this.hass.config);
this._subscribeDescriptions();
}
window.addEventListener("resize", this._updateNarrow);
@@ -385,11 +340,7 @@ class DialogAddAutomationElement
// prevent view mode switch when resizing window
this._bottomSheetMode = this._narrow;
if (
queryTarget &&
this._newTriggersAndConditions &&
!this._selectedTarget
) {
if (queryTarget && !this._selectedTarget) {
this._selectedTarget = queryTarget;
this._tab = "targets";
this._getItemsByTarget();
@@ -434,7 +385,6 @@ class DialogAddAutomationElement
}
this.removeKeyboardShortcuts();
this._unsubscribe();
if (this._params) {
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -450,7 +400,7 @@ class DialogAddAutomationElement
this._selectedCollectionIndex = undefined;
this._selectedGroup = undefined;
this._selectedTarget = undefined;
this._tab = this._newTriggersAndConditions ? "targets" : "groups";
this._tab = "targets";
this._filter = "";
this._manifests = undefined;
this._domains = undefined;
@@ -589,7 +539,6 @@ class DialogAddAutomationElement
public disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener("resize", this._updateNarrow);
this._unsubscribe();
}
protected supportedShortcuts(): SupportedShortcuts {
@@ -598,39 +547,10 @@ class DialogAddAutomationElement
};
}
private _unsubscribe() {
if (this._unsub) {
this._unsub.then((unsub) => unsub());
this._unsub = undefined;
}
if (this._unsubscribeLabFeatures) {
this._unsubscribeLabFeatures.then((unsub) => unsub());
this._unsubscribeLabFeatures = undefined;
}
}
// #endregion lifecycle
// #region render
private _getEmptyNote(automationElementType: string) {
if (
automationElementType !== "trigger" &&
automationElementType !== "condition"
) {
return undefined;
}
return this.hass.localize(
`ui.panel.config.automation.editor.${automationElementType}s.no_items_for_target_note`,
{
labs_link: html`<a href="/config/labs" @click=${this._close}
>${this.hass.localize("ui.panel.config.labs.caption")}</a
>`,
}
);
}
protected render() {
if (!this._params) {
return nothing;
@@ -664,6 +584,12 @@ class DialogAddAutomationElement
const automationElementType = this._params!.type;
const tabButtons = [
{
label: this.hass.localize(
"ui.panel.config.automation.editor.tabs.target"
),
value: "targets",
},
{
label: this.hass.localize(
"ui.panel.config.automation.editor.tabs.type"
@@ -672,15 +598,6 @@ class DialogAddAutomationElement
},
];
if (this._newTriggersAndConditions) {
tabButtons.unshift({
label: this.hass.localize(
"ui.panel.config.automation.editor.tabs.target"
),
value: "targets",
});
}
if (this._params?.type !== "trigger") {
tabButtons.push({
label: this.hass.localize("ui.panel.config.automation.editor.blocks"),
@@ -763,7 +680,6 @@ class DialogAddAutomationElement
this._manifests
)}
.convertToItem=${this._convertToItem}
.newTriggersAndConditions=${this._newTriggersAndConditions}
@search-element-picked=${this._searchItemSelected}
>
</ha-automation-add-search>`
@@ -772,13 +688,28 @@ class DialogAddAutomationElement
.hass=${this.hass}
.value=${this._selectedTarget}
@value-changed=${this._handleTargetSelected}
@time-location-group-selected=${this
._handleTimeLocationGroupSelected}
.narrow=${this._narrow}
.timeLocationLabel=${this._getTimeLocationLabel(
automationElementType
)}
.timeLocationGroups=${this._getTimeLocationGroups(
automationElementType,
this.hass.localize,
automationElementType === "condition"
? this._conditionDescriptions
: this._triggerDescriptions
)}
.selectedGroup=${this._selectedGroup}
class=${classMap({
"ha-scrollbar": true,
hidden: !!this._getAddFromTargetHidden(
this._narrow,
this._selectedTarget
),
hidden:
!!this._getAddFromTargetHidden(
this._narrow,
this._selectedTarget
) ||
(this._narrow && !!this._selectedGroup),
})}
.manifests=${this._manifests}
></ha-automation-add-from-target>`
@@ -884,13 +815,13 @@ class DialogAddAutomationElement
)
: undefined}
.selectLabel=${this.hass.localize(
`ui.panel.config.automation.editor.${this._tab === "groups" ? `${automationElementType}s.select` : "select_target"}` as LocalizeKeys
`ui.panel.config.automation.editor.${this._tab === "groups" || this._selectedGroup ? `${automationElementType}s.select` : "select_target"}` as LocalizeKeys
)}
.emptyLabel=${this.hass.localize(
`ui.panel.config.automation.editor.${automationElementType}s.no_items_for_target`
)}
.emptyNote=${this._getEmptyNote(automationElementType)}
.tooltipDescription=${this._tab === "targets"}
.tooltipDescription=${this._tab === "targets" &&
!this._selectedGroup}
.target=${(this._tab === "targets" &&
this._selectedTarget &&
([
@@ -1050,7 +981,7 @@ class DialogAddAutomationElement
items: this._getBlockItems(this._params!.type, this.hass.localize),
},
]
: !this._filter && this._tab === "groups" && this._selectedGroup
: !this._filter && this._selectedGroup
? [
{
title: this.hass.localize(
@@ -1108,7 +1039,10 @@ class DialogAddAutomationElement
Object.entries(grp).map(([key, options]) =>
options.members
? flattenGroups(options.members)
: this._convertToItem(key, options, type, localize)
: options.domains
? // domain elements are appended below from the backend descriptions
[]
: this._convertToItem(key, options, type, localize)
);
const items = flattenGroups(groups).flat();
@@ -1149,6 +1083,8 @@ class DialogAddAutomationElement
let genericCollectionIndex = -1;
let dynamicCollectionIndex = -1;
const exclusiveDomains = this._getExclusiveDomains(type);
collections.forEach((collection, index) => {
let collectionGroups = Object.entries(collection.groups);
const groups: AddAutomationElementListItem[] = [];
@@ -1179,7 +1115,8 @@ class DialogAddAutomationElement
triggerDescriptions,
manifests,
domains,
types
types,
exclusiveDomains
)
);
@@ -1198,7 +1135,8 @@ class DialogAddAutomationElement
conditionDescriptions,
manifests,
domains,
types
types,
exclusiveDomains
)
);
@@ -1231,9 +1169,19 @@ class DialogAddAutomationElement
}
groups.push(
...collectionGroups.map(([key, options]) =>
this._convertToItem(key, options, type, localize)
)
...collectionGroups
.filter(([, options]) =>
this._groupHasItems(
type,
options,
type === "condition"
? conditionDescriptions
: triggerDescriptions
)
)
.map(([key, options]) =>
this._convertToItem(key, options, type, localize)
)
);
if (groups.length) {
@@ -1330,11 +1278,28 @@ class DialogAddAutomationElement
return this._services(localize, services, manifests, group);
}
const groups = this._getGroups(type, group, collectionIndex);
const groupDef =
TYPES[type].collections[collectionIndex]?.groups[group] ??
TYPES[type].collections.find((collection) => group in collection.groups)
?.groups[group];
const result = Object.entries(groups).map(([key, options]) =>
this._convertToItem(key, options, type, localize)
);
let result: AddAutomationElementListItem[];
if (groupDef?.domains && !groupDef.members) {
// Curated group whose items come solely from backend domains (e.g. Sun).
result = this._getDomainElementItems(type, groupDef.domains, localize);
} else {
const groups = this._getGroups(type, group, collectionIndex);
result = Object.entries(groups).map(([key, options]) =>
this._convertToItem(key, options, type, localize)
);
if (groupDef?.domains) {
// Curated group with both static members and backend domains (Time).
result.push(
...this._getDomainElementItems(type, groupDef.domains, localize)
);
}
}
if (type === "action") {
if (!this._selectedGroup) {
@@ -1454,7 +1419,8 @@ class DialogAddAutomationElement
triggers: TriggerDescriptions,
manifests: DomainManifestLookup | undefined,
domains: Set<string> | undefined,
types: CollectionGroupType[]
types: CollectionGroupType[],
exclusiveDomains: Set<string>
): AddAutomationElementListItem[] => {
if (!triggers || !manifests) {
return [];
@@ -1464,7 +1430,7 @@ class DialogAddAutomationElement
Object.keys(triggers).forEach((trigger) => {
const domain = getTriggerDomain(trigger);
if (addedDomains.has(domain)) {
if (addedDomains.has(domain) || exclusiveDomains.has(domain)) {
return;
}
addedDomains.add(domain);
@@ -1526,7 +1492,8 @@ class DialogAddAutomationElement
conditions: ConditionDescriptions,
manifests: DomainManifestLookup | undefined,
domains: Set<string> | undefined,
types: CollectionGroupType[]
types: CollectionGroupType[],
exclusiveDomains: Set<string>
): AddAutomationElementListItem[] => {
if (!conditions || !manifests) {
return [];
@@ -1536,7 +1503,7 @@ class DialogAddAutomationElement
Object.keys(conditions).forEach((condition) => {
const domain = getConditionDomain(condition);
if (addedDomains.has(domain)) {
if (addedDomains.has(domain) || exclusiveDomains.has(domain)) {
return;
}
addedDomains.add(domain);
@@ -1796,22 +1763,93 @@ class DialogAddAutomationElement
options,
type: AddAutomationElementDialogParams["type"],
localize: LocalizeFunc
): AddAutomationElementListItem => ({
key,
name: localize(
// @ts-ignore
`ui.panel.config.automation.editor.${type}s.${
options.members ? "groups" : "type"
}.${key}.label`
),
description: localize(
// @ts-ignore
`ui.panel.config.automation.editor.${type}s.${
options.members ? "groups" : "type"
}.${key}.description${options.members ? "" : ".picker"}`
),
iconPath: options.icon || TYPES[type].icons[key],
});
): AddAutomationElementListItem => {
// A group either lists explicit members or bundles backend element domains.
const isGroup = !!(options.members || options.domains);
return {
key,
name: localize(
// @ts-ignore
`ui.panel.config.automation.editor.${type}s.${
isGroup ? "groups" : "type"
}.${key}.label`
),
description: localize(
// @ts-ignore
`ui.panel.config.automation.editor.${type}s.${
isGroup ? "groups" : "type"
}.${key}.description${isGroup ? "" : ".picker"}`
),
iconPath: options.icon || TYPES[type].icons[key],
};
};
// Domains owned exclusively by a curated group, i.e. a group that bundles
// only domains and no static members (e.g. "sun" under the Sun group). Those
// are hidden from the generic dynamic domain grouping so they don't appear
// both standalone and inside the curated group. Domains of a mixed group
// (static members + domains, e.g. "calendar"/"schedule" under Time) are NOT
// hidden — they still surface as their own domain group as well.
private _getExclusiveDomains = memoizeOne(
(type: AddAutomationElementDialogParams["type"]): Set<string> => {
const domains = new Set<string>();
TYPES[type].collections.forEach((collection) =>
Object.values(collection.groups).forEach((group) => {
if (group.domains && !group.members) {
group.domains.forEach((domain) => domains.add(domain));
}
})
);
return domains;
}
);
private _getDomainElementItems(
type: AddAutomationElementDialogParams["type"],
domains: string[],
localize: LocalizeFunc
): AddAutomationElementListItem[] {
const domainSet = new Set(domains);
if (type === "trigger") {
return Object.keys(this._triggerDescriptions)
.filter((trigger) => domainSet.has(getTriggerDomain(trigger)))
.map((trigger) =>
this._getTriggerListItem(localize, getTriggerDomain(trigger), trigger)
);
}
if (type === "condition") {
return Object.keys(this._conditionDescriptions)
.filter((condition) => domainSet.has(getConditionDomain(condition)))
.map((condition) =>
this._getConditionListItem(
localize,
getConditionDomain(condition),
condition
)
);
}
return [];
}
private _groupHasItems(
type: AddAutomationElementDialogParams["type"],
options: { members?: object; domains?: string[] },
descriptions: TriggerDescriptions | ConditionDescriptions
): boolean {
if (options.members && Object.keys(options.members).length) {
return true;
}
if (options.domains) {
const domainSet = new Set(options.domains);
const getDomain =
type === "condition" ? getConditionDomain : getTriggerDomain;
return Object.keys(descriptions).some((key) =>
domainSet.has(getDomain(key))
);
}
// plain single-element group
return true;
}
private _getDomainGroupedListItems(
localize: LocalizeFunc,
@@ -2055,6 +2093,8 @@ class DialogAddAutomationElement
) => {
this._targetItems = undefined;
this._loadItemsError = false;
this._selectedGroup = undefined;
this._selectedCollectionIndex = undefined;
this._selectedTarget = ev.detail.value;
mainWindow.history.pushState(
{
@@ -2076,6 +2116,67 @@ class DialogAddAutomationElement
this._getItemsByTarget();
};
// Time & location groups have no target; picking one drills into its items
// (the same list as the matching group in the "by type" tab).
private _handleTimeLocationGroupSelected = (
ev: ValueChangedEvent<string>
) => {
this._targetItems = undefined;
this._loadItemsError = false;
this._selectedTarget = undefined;
this._selectedGroup = ev.detail.value;
this._selectedCollectionIndex = 0;
mainWindow.history.pushState(
{
dialogData: {
group: this._selectedGroup,
collectionIndex: this._selectedCollectionIndex,
},
},
""
);
requestAnimationFrame(() => {
if (this._narrow) {
this._contentElement?.scrollTo(0, 0);
} else {
this._itemsListElement?.scrollTo(0, 0);
}
});
};
private _getTimeLocationLabel(
type: AddAutomationElementDialogParams["type"]
): string | undefined {
if (type !== "trigger" && type !== "condition") {
return undefined;
}
return this.hass.localize("ui.panel.config.automation.editor.time_sun");
}
private _getTimeLocationGroups = memoizeOne(
(
type: AddAutomationElementDialogParams["type"],
localize: LocalizeFunc,
descriptions: TriggerDescriptions | ConditionDescriptions
): AddAutomationElementListItem[] => {
if (type !== "trigger" && type !== "condition") {
return [];
}
return TIME_LOCATION_GROUPS.map(
(group) => [group, TYPES[type].collections[0].groups[group]] as const
)
.filter(
([, options]) =>
options && this._groupHasItems(type, options, descriptions)
)
.map(([group, options]) =>
this._convertToItem(group, options, type, localize)
)
.filter((item) => item.name);
}
);
private _getDefaultStateItems(
type: "trigger" | "condition"
): AddAutomationElementListItem[] {
@@ -63,6 +63,7 @@ import {
} from "../../../../data/target";
import type { HomeAssistant } from "../../../../types";
import { brandsUrl } from "../../../../util/brands-url";
import type { AddAutomationElementListItem } from "../add-automation-element-dialog";
interface Level1Entries {
open: boolean;
@@ -93,6 +94,16 @@ export default class HaAutomationAddFromTarget extends LitElement {
@property({ attribute: false }) public manifests?: DomainManifestLookup;
// Section title + group rows (Time, Location) for the targetless element
// groups. Picking a row drills into that group's items, just like selecting
// the matching group in the "by type" tab.
@property({ attribute: false }) public timeLocationLabel?: string;
@property({ attribute: false })
public timeLocationGroups?: AddAutomationElementListItem[];
@property({ attribute: false }) public selectedGroup?: string;
// #endregion properties
// #region context
@@ -182,8 +193,20 @@ export default class HaAutomationAddFromTarget extends LitElement {
? this._renderNarrow(this._entries, this.value)
: html`
${this._renderFloors(this.narrow, this._entries, this.value)}
${this._renderTimeLocation(
this.narrow,
this.timeLocationLabel,
this.timeLocationGroups,
this.selectedGroup
)}
${this._renderUnassigned(this.narrow, this._entries, this.value)}
${this._renderLabels(this.narrow, this.value)}
${this._renderLabels(
this.narrow,
this.states,
this._registries,
this._labelRegistry,
this.value
)}
`}
${this.narrow && this._showShowMoreButton && !this._fullHeight
? html`
@@ -343,14 +366,58 @@ export default class HaAutomationAddFromTarget extends LitElement {
}
);
private _renderTimeLocation = memoizeOne(
(
narrow: boolean,
label?: string,
groups?: AddAutomationElementListItem[],
selectedGroup?: string
) => {
if (!label || !groups?.length) {
return nothing;
}
return html`<ha-section-title>${label}</ha-section-title>
<ha-list-base>
${groups.map(
(group) =>
html`<ha-list-item-button
.value=${group.key}
@click=${this._selectTimeLocationGroup}
class=${group.key === selectedGroup ? "selected" : ""}
>
${group.icon
? html`<span slot="start">${group.icon}</span>`
: group.iconPath
? html`<ha-svg-icon
slot="start"
.path=${group.iconPath}
></ha-svg-icon>`
: nothing}
<div slot="headline">${group.name}</div>
${narrow
? html`<ha-icon-next slot="end"></ha-icon-next>`
: nothing}
</ha-list-item-button>`
)}
</ha-list-base>`;
}
);
private _renderLabels = memoizeOne(
(narrow: boolean, value?: SingleHassServiceTarget) => {
(
narrow: boolean,
states: ContextType<typeof statesContext>,
registries: ContextType<typeof registriesContext>,
labelRegistry: LabelRegistryEntry[],
value?: SingleHassServiceTarget
) => {
const labels = this._getLabelsMemoized(
this.states,
this._registries.areas,
this._registries.devices,
this._registries.entities,
this._labelRegistry,
states,
registries.areas,
registries.devices,
registries.entities,
labelRegistry,
undefined,
undefined,
undefined,
@@ -1173,6 +1240,13 @@ export default class HaAutomationAddFromTarget extends LitElement {
}
}
private _selectTimeLocationGroup(ev: CustomEvent) {
const value = (ev.currentTarget as any).value;
if (value) {
fireEvent(this, "time-location-group-selected", { value });
}
}
private async _valueChanged(itemId: string, expand = false) {
const [type, id] = itemId.split(TARGET_SEPARATOR, 2);
@@ -1512,4 +1586,7 @@ declare global {
interface HTMLElementTagNameMap {
"ha-automation-add-from-target": HaAutomationAddFromTarget;
}
interface HASSDomEvents {
"time-location-group-selected": { value: string };
}
}
@@ -1,5 +1,5 @@
import { mdiInformationOutline, mdiPlus } from "@mdi/js";
import { LitElement, css, html, nothing, type TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import {
customElement,
eventOptions,
@@ -40,8 +40,6 @@ export class HaAutomationAddItems extends LitElement {
@property({ attribute: "empty-label" }) public emptyLabel!: string;
@property({ attribute: false }) public emptyNote?: string | TemplateResult;
@property({ attribute: false }) public target?: Target;
@property({ attribute: false }) public getLabel!: (
@@ -83,9 +81,6 @@ export class HaAutomationAddItems extends LitElement {
? html`${this.emptyLabel}
${this.target
? html`<div>${this._renderTarget(this.target)}</div>`
: nothing}
${this.emptyNote
? html`<div class="empty-note">${this.emptyNote}</div>`
: nothing}`
: repeat(
this.items,
@@ -232,17 +227,6 @@ export class HaAutomationAddItems extends LitElement {
justify-content: center;
}
.empty-note {
color: var(--ha-color-text-secondary);
margin-top: var(--ha-space-2);
text-align: center;
}
.empty-note a {
color: currentColor;
text-decoration: underline;
}
.items.error {
background-color: var(--ha-color-fill-danger-quiet-resting);
color: var(--ha-color-on-danger-normal);
@@ -117,9 +117,6 @@ export class HaAutomationAddSearch extends LitElement {
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, attribute: "new-triggers-and-conditions" })
public newTriggersAndConditions = false;
@property({ attribute: false })
public convertToItem!: (
key: string,
@@ -209,7 +206,6 @@ export class HaAutomationAddSearch extends LitElement {
this.filter,
this.configEntryLookup,
this.items,
this.newTriggersAndConditions,
this._selectedSearchSection,
this._relatedIdSets
);
@@ -260,19 +256,13 @@ export class HaAutomationAddSearch extends LitElement {
}
private _renderSections() {
if (this.addElementType === "trigger" && !this.newTriggersAndConditions) {
return nothing;
}
const searchSections: ("separator" | SearchSection)[] = ["item"];
if (this.addElementType !== "trigger") {
searchSections.push("block");
}
if (this.newTriggersAndConditions) {
searchSections.push(...TARGET_SEARCH_SECTIONS);
}
searchSections.push(...TARGET_SEARCH_SECTIONS);
return html`
<ha-chip-set class="sections">
${searchSections.map((section) =>
@@ -502,7 +492,6 @@ export class HaAutomationAddSearch extends LitElement {
searchTerm: string,
configEntryLookup: Record<string, ConfigEntry>,
automationItems: AddAutomationElementListItem[],
newTriggersAndConditions: boolean,
selectedSection?: SearchSection,
relatedIdSets?: RelatedIdSets
) => {
@@ -570,191 +559,185 @@ export class HaAutomationAddSearch extends LitElement {
resultItems.push(...blocks);
}
if (newTriggersAndConditions) {
if (!selectedSection || selectedSection === "entity") {
let entityItems = this._getEntitiesMemoized(
this.hass,
`entity${TARGET_SEPARATOR}`
);
if (!selectedSection || selectedSection === "entity") {
let entityItems = this._getEntitiesMemoized(
this.hass,
`entity${TARGET_SEPARATOR}`
);
if (relatedIdSets?.entities.size) {
entityItems = entityItems.map((item) => ({
...item,
isRelated: relatedIdSets.entities.has(
(item as EntityComboBoxItem).stateObj?.entity_id || ""
),
})) as EntityComboBoxItem[];
}
if (searchTerm) {
entityItems = sortRelatedFirst(
this._filterGroup(
"entity",
entityItems,
searchTerm,
entityComboBoxKeys
)
) as EntityComboBoxItem[];
} else if (relatedIdSets?.entities.size) {
entityItems = sortRelatedFirst(entityItems) as EntityComboBoxItem[];
}
if (!selectedSection && entityItems.length) {
// show group title
resultItems.push(
localize("ui.components.target-picker.type.entities")
);
}
resultItems.push(...entityItems);
}
if (!selectedSection || selectedSection === "device") {
let deviceItems = this._getDevicesMemoized(
this.hass,
configEntryLookup,
`device${TARGET_SEPARATOR}`
);
if (relatedIdSets?.devices.size) {
deviceItems = deviceItems.map((item) => ({
...item,
isRelated: relatedIdSets.devices.has(
item.id.split(TARGET_SEPARATOR)[1] || ""
),
}));
}
if (searchTerm) {
deviceItems = sortRelatedFirst(
this._filterGroup(
"device",
deviceItems,
searchTerm,
deviceComboBoxKeys
)
);
} else if (relatedIdSets?.devices.size) {
deviceItems = sortRelatedFirst(deviceItems);
}
if (!selectedSection && deviceItems.length) {
// show group title
resultItems.push(
localize("ui.components.target-picker.type.devices")
);
}
resultItems.push(...deviceItems);
}
if (!selectedSection || selectedSection === "area") {
let areasAndFloors = this._getAreasAndFloorsMemoized(
this.hass.states,
this.hass.floors,
this.hass.areas,
this.hass.devices,
this.hass.entities,
memoizeOne((value: AreaFloorValue): string =>
[value.type, value.id].join(TARGET_SEPARATOR)
if (relatedIdSets?.entities.size) {
entityItems = entityItems.map((item) => ({
...item,
isRelated: relatedIdSets.entities.has(
(item as EntityComboBoxItem).stateObj?.entity_id || ""
),
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined
);
if (relatedIdSets?.areas.size) {
areasAndFloors = areasAndFloors.map((item) => ({
...item,
isRelated:
item.type === "area"
? relatedIdSets.areas.has(
item.id.split(TARGET_SEPARATOR)[1] || ""
)
: false,
})) as FloorComboBoxItem[];
}
if (searchTerm) {
areasAndFloors = sortRelatedFirst(
this._filterGroup(
"area",
areasAndFloors,
searchTerm,
areaFloorComboBoxKeys,
false
)
) as FloorComboBoxItem[];
} else if (relatedIdSets?.areas.size) {
areasAndFloors = sortRelatedFirst(
areasAndFloors
) as FloorComboBoxItem[];
}
if (!selectedSection && areasAndFloors.length) {
// show group title
resultItems.push(
localize("ui.components.target-picker.type.areas")
);
}
resultItems.push(
...areasAndFloors.map((item, index) => {
const nextItem = areasAndFloors[index + 1];
if (
!nextItem ||
(item.type === "area" && nextItem.type === "floor")
) {
return {
...item,
last: true,
};
}
return item;
})
);
})) as EntityComboBoxItem[];
}
if (!selectedSection || selectedSection === "label") {
let labels = this._getLabelsMemoized(
this.hass.states,
this.hass.areas,
this.hass.devices,
this.hass.entities,
this._labelRegistry,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
`label${TARGET_SEPARATOR}`
);
if (searchTerm) {
labels = this._filterGroup(
"label",
labels,
if (searchTerm) {
entityItems = sortRelatedFirst(
this._filterGroup(
"entity",
entityItems,
searchTerm,
labelComboBoxKeys
);
}
if (!selectedSection && labels.length) {
// show group title
resultItems.push(
localize("ui.components.target-picker.type.labels")
);
}
resultItems.push(...labels);
entityComboBoxKeys
)
) as EntityComboBoxItem[];
} else if (relatedIdSets?.entities.size) {
entityItems = sortRelatedFirst(entityItems) as EntityComboBoxItem[];
}
if (!selectedSection && entityItems.length) {
// show group title
resultItems.push(
localize("ui.components.target-picker.type.entities")
);
}
resultItems.push(...entityItems);
}
if (!selectedSection || selectedSection === "device") {
let deviceItems = this._getDevicesMemoized(
this.hass,
configEntryLookup,
`device${TARGET_SEPARATOR}`
);
if (relatedIdSets?.devices.size) {
deviceItems = deviceItems.map((item) => ({
...item,
isRelated: relatedIdSets.devices.has(
item.id.split(TARGET_SEPARATOR)[1] || ""
),
}));
}
if (searchTerm) {
deviceItems = sortRelatedFirst(
this._filterGroup(
"device",
deviceItems,
searchTerm,
deviceComboBoxKeys
)
);
} else if (relatedIdSets?.devices.size) {
deviceItems = sortRelatedFirst(deviceItems);
}
if (!selectedSection && deviceItems.length) {
// show group title
resultItems.push(
localize("ui.components.target-picker.type.devices")
);
}
resultItems.push(...deviceItems);
}
if (!selectedSection || selectedSection === "area") {
let areasAndFloors = this._getAreasAndFloorsMemoized(
this.hass.states,
this.hass.floors,
this.hass.areas,
this.hass.devices,
this.hass.entities,
memoizeOne((value: AreaFloorValue): string =>
[value.type, value.id].join(TARGET_SEPARATOR)
),
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined
);
if (relatedIdSets?.areas.size) {
areasAndFloors = areasAndFloors.map((item) => ({
...item,
isRelated:
item.type === "area"
? relatedIdSets.areas.has(
item.id.split(TARGET_SEPARATOR)[1] || ""
)
: false,
})) as FloorComboBoxItem[];
}
if (searchTerm) {
areasAndFloors = sortRelatedFirst(
this._filterGroup(
"area",
areasAndFloors,
searchTerm,
areaFloorComboBoxKeys,
false
)
) as FloorComboBoxItem[];
} else if (relatedIdSets?.areas.size) {
areasAndFloors = sortRelatedFirst(
areasAndFloors
) as FloorComboBoxItem[];
}
if (!selectedSection && areasAndFloors.length) {
// show group title
resultItems.push(localize("ui.components.target-picker.type.areas"));
}
resultItems.push(
...areasAndFloors.map((item, index) => {
const nextItem = areasAndFloors[index + 1];
if (
!nextItem ||
(item.type === "area" && nextItem.type === "floor")
) {
return {
...item,
last: true,
};
}
return item;
})
);
}
if (!selectedSection || selectedSection === "label") {
let labels = this._getLabelsMemoized(
this.hass.states,
this.hass.areas,
this.hass.devices,
this.hass.entities,
this._labelRegistry,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
`label${TARGET_SEPARATOR}`
);
if (searchTerm) {
labels = this._filterGroup(
"label",
labels,
searchTerm,
labelComboBoxKeys
);
}
if (!selectedSection && labels.length) {
// show group title
resultItems.push(localize("ui.components.target-picker.type.labels"));
}
resultItems.push(...labels);
}
return resultItems;
@@ -1,9 +1,7 @@
import { consume } from "@lit/context";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import deepClone from "deep-clone-simple";
import type {
HassServiceTarget,
UnsubscribeFunc,
} from "home-assistant-js-websocket";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, queryAll, state } from "lit/decorators";
@@ -19,12 +17,8 @@ import {
type Condition,
} from "../../../../data/automation";
import type { ConditionDescriptions } from "../../../../data/condition";
import {
CONDITION_BUILDING_BLOCKS,
subscribeConditions,
} from "../../../../data/condition";
import { subscribeLabFeature } from "../../../../data/labs";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition";
import { conditionDescriptionsContext } from "../../../../data/context";
import { EDITOR_SAVE_FAB_TOAST_BOTTOM_OFFSET } from "../editor-toast";
import {
getAddAutomationElementTargetFromQuery,
@@ -38,7 +32,7 @@ import type HaAutomationConditionRow from "./ha-automation-condition-row";
@customElement("ha-automation-condition")
export default class HaAutomationCondition extends AutomationSortableListMixin<Condition>(
SubscribeMixin(LitElement)
LitElement
) {
@property({ attribute: false }) public conditions!: Condition[];
@@ -48,16 +42,13 @@ export default class HaAutomationCondition extends AutomationSortableListMixin<C
@property({ type: Boolean, attribute: false }) public editorDirty = false;
@state() private _conditionDescriptions: ConditionDescriptions = {};
@state()
@consume({ context: conditionDescriptionsContext, subscribe: true })
private _conditionDescriptions: ConditionDescriptions = {};
@queryAll("ha-automation-condition-row")
private _conditionRowElements?: HaAutomationConditionRow[];
// @ts-ignore
@state() private _newTriggersAndConditions = false;
private _unsub?: Promise<UnsubscribeFunc>;
private _openedAddDialogFromQuery = false;
protected get items(): Condition[] {
@@ -72,49 +63,6 @@ export default class HaAutomationCondition extends AutomationSortableListMixin<C
this.highlightedConditions = items;
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribe();
}
protected hassSubscribe() {
return [
subscribeLabFeature(
this.hass!.connection,
"automation",
"new_triggers_conditions",
(feature) => {
this._newTriggersAndConditions = feature.enabled;
}
),
];
}
private _subscribeDescriptions() {
this._unsubscribe();
this._conditionDescriptions = {};
this._unsub = subscribeConditions(this.hass, (descriptions) => {
this._conditionDescriptions = {
...this._conditionDescriptions,
...descriptions,
};
});
}
private _unsubscribe() {
if (this._unsub) {
this._unsub.then((unsub) => unsub());
this._unsub = undefined;
}
}
protected willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties);
if (changedProperties.has("_newTriggersAndConditions")) {
this._subscribeDescriptions();
}
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.hass.loadBackendTranslation("conditions");
@@ -1,9 +1,7 @@
import { consume } from "@lit/context";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import deepClone from "deep-clone-simple";
import type {
HassServiceTarget,
UnsubscribeFunc,
} from "home-assistant-js-websocket";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -19,10 +17,9 @@ import {
type Trigger,
type TriggerList,
} from "../../../../data/automation";
import { subscribeLabFeature } from "../../../../data/labs";
import { triggerDescriptionsContext } from "../../../../data/context";
import type { TriggerDescriptions } from "../../../../data/trigger";
import { isTriggerList, subscribeTriggers } from "../../../../data/trigger";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import { isTriggerList } from "../../../../data/trigger";
import { EDITOR_SAVE_FAB_TOAST_BOTTOM_OFFSET } from "../editor-toast";
import {
getAddAutomationElementTargetFromQuery,
@@ -36,7 +33,7 @@ import type HaAutomationTriggerRow from "./ha-automation-trigger-row";
@customElement("ha-automation-trigger")
export default class HaAutomationTrigger extends AutomationSortableListMixin<Trigger>(
SubscribeMixin(LitElement)
LitElement
) {
@property({ attribute: false }) public triggers!: Trigger[];
@@ -46,12 +43,9 @@ export default class HaAutomationTrigger extends AutomationSortableListMixin<Tri
@property({ type: Boolean, attribute: false }) public editorDirty = false;
@state() private _triggerDescriptions: TriggerDescriptions = {};
// @ts-ignore
@state() private _newTriggersAndConditions = false;
private _unsub?: Promise<UnsubscribeFunc>;
@state()
@consume({ context: triggerDescriptionsContext, subscribe: true })
private _triggerDescriptions: TriggerDescriptions = {};
private _openedAddDialogFromQuery = false;
@@ -67,49 +61,6 @@ export default class HaAutomationTrigger extends AutomationSortableListMixin<Tri
this.highlightedTriggers = items;
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribe();
}
protected hassSubscribe() {
return [
subscribeLabFeature(
this.hass!.connection,
"automation",
"new_triggers_conditions",
(feature) => {
this._newTriggersAndConditions = feature.enabled;
}
),
];
}
private _subscribeDescriptions() {
this._unsubscribe();
this._triggerDescriptions = {};
this._unsub = subscribeTriggers(this.hass, (descriptions) => {
this._triggerDescriptions = {
...this._triggerDescriptions,
...descriptions,
};
});
}
private _unsubscribe() {
if (this._unsub) {
this._unsub.then((unsub) => unsub());
this._unsub = undefined;
}
}
protected willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties);
if (changedProperties.has("_newTriggersAndConditions")) {
this._subscribeDescriptions();
}
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.hass.loadBackendTranslation("triggers");
@@ -1,5 +1,5 @@
import { css, html, LitElement, nothing } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import type { CSSResultGroup } from "lit";
import { consume, type ContextType } from "@lit/context";
import { customElement, state } from "lit/decorators";
import {
@@ -12,27 +12,10 @@ import {
import { computeDeviceNameDisplay } from "../../../../common/entity/compute_device_name";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-adaptive-dialog";
import "../../../../components/ha-spinner";
import type { AutomationConfig } from "../../../../data/automation";
import { showAutomationEditor } from "../../../../data/automation";
import {
apiContext,
internationalizationContext,
statesContext,
} from "../../../../data/context";
import type {
DeviceAction,
DeviceCondition,
DeviceTrigger,
} from "../../../../data/device/device_automation";
import {
fetchDeviceActions,
fetchDeviceConditions,
fetchDeviceTriggers,
sortDeviceAutomations,
} from "../../../../data/device/device_automation";
import type { ScriptConfig } from "../../../../data/script";
import { showScriptEditor } from "../../../../data/script";
import { showSceneEditor } from "../../../../data/scene";
import "../../../../dialogs/add-to/ha-add-to-action-list";
import type {
@@ -48,21 +31,11 @@ import {
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { DeviceAddToDialogParams } from "./show-dialog-device-add-to";
type DeviceLegacyAddToActionType =
| "trigger"
| "condition"
| "automation_action"
| "script_action";
type DeviceAddToAction =
| (AddToActionListItem & {
kind: "add-to";
key: AddToAutomationScriptActionKey;
})
| (AddToActionListItem & {
kind: "legacy";
legacyType: DeviceLegacyAddToActionType;
})
| (AddToActionListItem & { kind: "scene" });
@customElement("dialog-device-add-to")
@@ -75,76 +48,25 @@ export class DialogDeviceAddTo extends LitElement {
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@state() private _params?: DeviceAddToDialogParams;
@state() private _open = false;
@state() private _triggers?: DeviceTrigger[];
@state() private _conditions?: DeviceCondition[];
@state() private _actions?: DeviceAction[];
public showDialog(params: DeviceAddToDialogParams): void {
this._params = params;
this._open = true;
// When new_triggers_conditions labs feature is promoted, this whole check can be removed.
if (!params.newTriggersConditions && this._api) {
this._fetchDeviceAutomations(params);
}
}
public closeDialog(): void {
this._open = false;
}
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
// When new_triggers_conditions labs feature is promoted, this whole check can be removed.
if (
changedProps.has("_api") &&
this._api &&
this._params &&
!this._params.newTriggersConditions &&
!this._triggers
) {
this._fetchDeviceAutomations(this._params);
}
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
protected firstUpdated() {
this._i18n.loadBackendTranslation("device_automation");
}
// When new_triggers_conditions labs feature is promoted, this whole method can be removed.
private async _fetchDeviceAutomations(
params: DeviceAddToDialogParams
): Promise<void> {
const deviceId = params.device.id;
const [triggers, conditions, actions] = await Promise.all([
fetchDeviceTriggers(this._api.callWS, deviceId),
fetchDeviceConditions(this._api.callWS, deviceId),
fetchDeviceActions(this._api.callWS, deviceId),
]);
this._triggers = triggers.sort(sortDeviceAutomations);
this._conditions = conditions.sort(sortDeviceAutomations);
this._actions = actions.sort(sortDeviceAutomations);
}
private _dialogClosed(): void {
this._params = undefined;
this._triggers = undefined;
this._conditions = undefined;
this._actions = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -168,14 +90,12 @@ export class DialogDeviceAddTo extends LitElement {
)}
@closed=${this._dialogClosed}
>
${this._params.newTriggersConditions
? this._renderNewOptions()
: this._renderLegacyOptions()}
${this._renderOptions()}
</ha-adaptive-dialog>
`;
}
private _renderNewOptions() {
private _renderOptions() {
if (!this._params) {
return nothing;
}
@@ -238,112 +158,6 @@ export class DialogDeviceAddTo extends LitElement {
`;
}
// When new_triggers_conditions labs feature is promoted, this whole method can be removed.
private _renderLegacyOptions() {
if (!this._triggers && !this._conditions && !this._actions) {
return html`
<div class="loading">
<ha-spinner></ha-spinner>
</div>
`;
}
if (!this._params) {
return nothing;
}
const hasTriggers = Boolean(this._triggers?.length);
const hasConditions = Boolean(this._conditions?.length);
const hasActions = Boolean(this._actions?.length);
const hasScenes = Boolean(this._params.entityIds.length);
if (!hasTriggers && !hasConditions && !hasActions && !hasScenes) {
return html`
<div class="empty">
${this._i18n.localize(
"ui.panel.config.devices.automation.no_device_automations"
)}
</div>
`;
}
const automationActions: DeviceAddToAction[] = [];
if (hasTriggers) {
automationActions.push({
kind: "legacy",
legacyType: "trigger",
iconPath: mdiRobotOutline,
name: this._i18n.localize(
"ui.dialogs.more_info_control.add_to.action_options.automation_trigger"
),
});
}
if (hasConditions) {
automationActions.push({
kind: "legacy",
legacyType: "condition",
iconPath: mdiPlaylistCheck,
name: this._i18n.localize(
"ui.dialogs.more_info_control.add_to.action_options.automation_condition"
),
});
}
if (hasActions) {
automationActions.push({
kind: "legacy",
legacyType: "automation_action",
iconPath: mdiPlayCircleOutline,
name: this._i18n.localize(
"ui.dialogs.more_info_control.add_to.action_options.automation_action"
),
});
}
const scriptActions: DeviceAddToAction[] = hasActions
? [
{
kind: "legacy",
legacyType: "script_action",
iconPath: mdiScriptTextOutline,
name: this._i18n.localize(
"ui.dialogs.more_info_control.add_to.action_options.script_action"
),
},
]
: [];
const sections: AddToActionListSection<DeviceAddToAction>[] = [
{
title: this._i18n.localize(
"ui.panel.config.devices.automation.automations_heading"
),
actions: automationActions,
empty: automationActions.length
? undefined
: this._i18n.localize(
"ui.panel.config.devices.automation.no_automations"
),
},
{
title: this._i18n.localize(
"ui.panel.config.devices.script.scripts_heading"
),
actions: scriptActions,
empty: scriptActions.length
? undefined
: this._i18n.localize("ui.panel.config.devices.script.no_scripts"),
},
];
this._addSceneSection(sections);
return html`
<ha-add-to-action-list
.sections=${sections}
@add-to-list-action-selected=${this._handleActionSelected}
></ha-add-to-action-list>
`;
}
private _addSceneSection(
sections: AddToActionListSection<DeviceAddToAction>[]
): void {
@@ -380,12 +194,7 @@ export class DialogDeviceAddTo extends LitElement {
return;
}
if (action.kind === "add-to") {
this._handleAddToAction(action.key);
return;
}
this._handleLegacyAction(action.legacyType);
this._handleAddToAction(action.key);
}
private _handleAddToAction(key: AddToAutomationScriptActionKey) {
@@ -397,30 +206,6 @@ export class DialogDeviceAddTo extends LitElement {
addToActionHandler(key, { device_id: this._params.device.id });
}
// When new_triggers_conditions labs feature is promoted, this whole method can be removed.
private _handleLegacyAction(type: DeviceLegacyAddToActionType) {
this.closeDialog();
if (type === "script_action") {
const newScript = {} as ScriptConfig;
if (this._actions?.length) {
newScript.sequence = [this._actions[0]];
}
showScriptEditor(newScript, true);
return;
}
const newAutomation = {} as AutomationConfig;
if (type === "trigger" && this._triggers?.length) {
newAutomation.triggers = [this._triggers[0]];
} else if (type === "condition" && this._conditions?.length) {
newAutomation.conditions = [this._conditions[0]];
} else if (type === "automation_action" && this._actions?.length) {
newAutomation.actions = [this._actions[0]];
}
showAutomationEditor(newAutomation, true);
}
private _handleCreateScene() {
if (!this._params) {
return;
@@ -439,12 +224,6 @@ export class DialogDeviceAddTo extends LitElement {
ha-adaptive-dialog {
--dialog-content-padding: 0;
}
.loading,
.empty {
padding: var(--ha-space-4);
text-align: center;
}
`,
];
}
@@ -3,7 +3,6 @@ import type { DeviceRegistryEntry } from "../../../../data/device/device_registr
export interface DeviceAddToDialogParams {
device: DeviceRegistryEntry;
newTriggersConditions: boolean;
entityIds: string[];
canCreateScene: boolean;
}
@@ -68,7 +68,6 @@ import {
removeConfigEntryFromDevice,
updateDeviceRegistryEntry,
} from "../../../data/device/device_registry";
import { subscribeLabFeature } from "../../../data/labs";
import type { DiagnosticInfo } from "../../../data/diagnostics";
import {
fetchDiagnosticHandler,
@@ -204,10 +203,6 @@ export class HaConfigDevicePage extends LitElement {
private _deviceAlertsActionsTimeout?: number;
@state() private _newTriggersConditions = false;
private _unsubLabFeature?: (() => void) | undefined;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
@@ -377,7 +372,6 @@ export class HaConfigDevicePage extends LitElement {
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
loadDeviceRegistryDetailDialog();
this._subscribeLabFeature();
}
protected updated(changedProps: PropertyValues<this>) {
@@ -394,7 +388,6 @@ export class HaConfigDevicePage extends LitElement {
public disconnectedCallback() {
super.disconnectedCallback();
clearTimeout(this._deviceAlertsActionsTimeout);
this._unsubLabFeature?.();
}
protected render() {
@@ -1404,7 +1397,6 @@ export class HaConfigDevicePage extends LitElement {
);
showDeviceAddToDialog(this, {
device,
newTriggersConditions: this._newTriggersConditions,
entityIds: sceneEntityIds,
canCreateScene:
isComponentLoaded(this.hass.config, "scene") &&
@@ -1412,23 +1404,6 @@ export class HaConfigDevicePage extends LitElement {
});
}
// When new_triggers_conditions labs feature is promoted, this whole method can be removed.
private _subscribeLabFeature() {
if (!isComponentLoaded(this.hass.config, "automation")) {
return;
}
subscribeLabFeature(
this.hass.connection,
"automation",
"new_triggers_conditions",
(feature) => {
this._newTriggersConditions = feature.enabled;
}
).then((unsub) => {
this._unsubLabFeature = unsub;
});
}
private _renderIntegrationInfo(
device: DeviceRegistryEntry,
integrations: ConfigEntry[],
@@ -1011,9 +1011,13 @@ export class EntityRegistrySettingsEditor extends LitElement {
)}</span
>
<span slot="secondary">
${this.hass.localize(
"ui.dialogs.entity_registry.editor.voice_assistants_description"
)}
${this.entry.aliases.filter((a) => a !== null).length
? this.entry.aliases
.filter((a): a is string => a !== null)
.join(", ")
: this.hass.localize(
"ui.dialogs.entity_registry.editor.no_aliases"
)}
</span>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
@@ -1,95 +0,0 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { computeStateName } from "../../../common/entity/compute_state_name";
import "../../../components/input/ha-input";
import type { AlexaEntityConfig } from "../../../data/alexa";
import { fetchCloudAlexaEntity } from "../../../data/alexa";
import { updateCloudAlexaEntityConfig } from "../../../data/cloud";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
@customElement("alexa-entity-voice-settings")
export class AlexaEntityVoiceSettings extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId!: string;
@state() private _entity?: AlexaEntityConfig;
protected willUpdate(changedProps: PropertyValues<this>) {
if (changedProps.has("entityId") && this.entityId) {
this._fetchEntity();
}
}
private async _fetchEntity() {
try {
this._entity = await fetchCloudAlexaEntity(this.hass, this.entityId);
} catch (_err) {
this._entity = undefined;
}
}
protected render() {
if (!this._entity) {
return nothing;
}
const defaultName = this.hass.states[this.entityId]
? computeStateName(this.hass.states[this.entityId])
: this.entityId;
return html`
<ha-input
.label=${this.hass.localize("ui.dialogs.voice-settings.name")}
.hint=${this.hass.localize(
"ui.dialogs.voice-settings.name_description"
)}
with-clear
.value=${this._entity.name ?? ""}
.placeholder=${defaultName}
@change=${this._nameChanged}
></ha-input>
`;
}
private async _nameChanged(ev) {
if (!this._entity) {
return;
}
const value = ev.target.value?.trim() || null;
if ((this._entity.name ?? null) === value) {
return;
}
const previous = this._entity.name ?? null;
this._entity = { ...this._entity, name: value };
try {
await updateCloudAlexaEntityConfig(this.hass, this.entityId, value);
} catch (_err) {
this._entity = { ...this._entity, name: previous };
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
display: block;
margin: 0 var(--ha-space-8) var(--ha-space-8);
}
ha-input {
display: block;
width: 100%;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"alexa-entity-voice-settings": AlexaEntityVoiceSettings;
}
}
@@ -1,146 +0,0 @@
import type { CSSResultGroup } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import { computeStateName } from "../../../common/entity/compute_state_name";
import "../../../components/ha-alert";
import "../../../components/ha-aliases-editor";
import "../../../components/ha-md-list-item";
import "../../../components/ha-switch";
import type { ExtEntityRegistryEntry } from "../../../data/entity/entity_registry";
import { updateEntityRegistryEntry } from "../../../data/entity/entity_registry";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
@customElement("assist-entity-voice-settings")
export class AssistEntityVoiceSettings extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId!: string;
@property({ attribute: false }) public entry?: ExtEntityRegistryEntry;
@state() private _aliases?: (string | null)[];
protected render() {
if (!this.entry) {
return html`<ha-alert alert-type="warning">
${this.hass.localize("ui.dialogs.voice-settings.aliases_no_unique_id", {
faq_link: html`<a
href=${documentationUrl(this.hass, "/faq/unique_id")}
target="_blank"
rel="noreferrer"
>${this.hass.localize("ui.dialogs.entity_registry.faq")}</a
>`,
})}
</ha-alert>`;
}
return html`
<ha-md-list-item>
<span slot="headline">
${this.hass.states[this.entityId]
? computeStateName(this.hass.states[this.entityId])
: this.entityId}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.dialogs.voice-settings.entity_name_alias_description"
)}
</span>
<ha-switch
slot="end"
.checked=${(this._aliases ?? this.entry.aliases).includes(null)}
@change=${this._toggleEntityNameAlias}
></ha-switch>
</ha-md-list-item>
<h4 class="header">
${this.hass.localize("ui.dialogs.voice-settings.aliases")}
</h4>
<ha-aliases-editor
.aliases=${(this._aliases ?? this.entry.aliases).filter(
(a): a is string => a !== null
)}
sortable
@value-changed=${this._aliasesChanged}
></ha-aliases-editor>
`;
}
private async _toggleEntityNameAlias(ev) {
const previous = this._aliases;
const enabled = ev.target.checked;
const currentAliases = this._aliases ?? this.entry?.aliases ?? [];
if (enabled) {
this._aliases = [null, ...currentAliases.filter((a) => a !== null)];
} else {
this._aliases = currentAliases.filter((a): a is string => a !== null);
}
await this._saveAliases(previous);
}
private _aliasesChanged(ev) {
const previous = this._aliases;
const currentAliases = this._aliases ?? this.entry?.aliases ?? [];
const hasNull = currentAliases.includes(null);
const nullAliases: (string | null)[] = hasNull ? [null] : [];
const newStringAliases: string[] = ev.detail.value;
this._aliases = [...nullAliases, ...newStringAliases];
this._saveAliases(previous);
}
private async _saveAliases(previous?: (string | null)[]) {
if (!this._aliases) {
return;
}
const hasNull = this._aliases.includes(null);
const nullAliases: null[] = hasNull ? [null] : [];
const stringAliases = this._aliases
.filter((a): a is string => a !== null)
.map((alias) => alias.trim())
.filter((alias) => alias);
try {
const result = await updateEntityRegistryEntry(this.hass, this.entityId, {
aliases: [...nullAliases, ...stringAliases],
});
fireEvent(this, "entity-entry-updated", result.entity_entry);
} catch (_err) {
this._aliases = previous;
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
display: block;
margin: 0 var(--ha-space-8) var(--ha-space-8);
}
ha-md-list-item {
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
--md-item-overflow: visible;
}
ha-aliases-editor {
display: block;
}
.header {
margin-top: var(--ha-space-2);
margin-bottom: var(--ha-space-1);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"assist-entity-voice-settings": AssistEntityVoiceSettings;
}
interface HASSDomEvents {
"entity-entry-updated": ExtEntityRegistryEntry;
}
}
@@ -1,4 +1,4 @@
import { mdiChevronLeft, mdiTuneVertical } from "@mdi/js";
import { mdiTuneVertical } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -6,17 +6,10 @@ import { fireEvent } from "../../../common/dom/fire_event";
import { computeStateName } from "../../../common/entity/compute_state_name";
import "../../../components/ha-icon-button";
import "../../../components/ha-dialog";
import type { ExposeEntitySettings } from "../../../data/expose";
import { voiceAssistants } from "../../../data/expose";
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
import {
haStyle,
haStyleDialog,
haStyleDialogFixedTop,
} from "../../../resources/styles";
import { haStyle, haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import "./entity-voice-settings";
import "./voice-assistant-settings";
import type { VoiceSettingsDialogParams } from "./show-dialog-voice-settings";
@customElement("dialog-voice-settings")
@@ -27,14 +20,8 @@ class DialogVoiceSettings extends LitElement {
@state() private _open = false;
@state() private _editingAssistant?: string;
@state() private _exposed?: ExposeEntitySettings;
public showDialog(params: VoiceSettingsDialogParams): void {
this._params = params;
this._exposed = params.exposed;
this._editingAssistant = undefined;
this._open = true;
}
@@ -44,8 +31,6 @@ class DialogVoiceSettings extends LitElement {
private _dialogClosed(): void {
this._params = undefined;
this._exposed = undefined;
this._editingAssistant = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -56,23 +41,14 @@ class DialogVoiceSettings extends LitElement {
this.closeDialog();
}
private _editAssistant(ev: CustomEvent): void {
this._editingAssistant = ev.detail.assistant;
}
private _backToList(): void {
this._editingAssistant = undefined;
}
protected render() {
if (!this._params) {
return nothing;
}
const title = this._editingAssistant
? voiceAssistants[this._editingAssistant].name
: computeStateName(this.hass.states[this._params.entityId]) ||
this.hass.localize("ui.panel.config.entities.picker.unnamed_entity");
const title =
computeStateName(this.hass.states[this._params.entityId]) ||
this.hass.localize("ui.panel.config.entities.picker.unnamed_entity");
return html`
<ha-dialog
@@ -80,58 +56,28 @@ class DialogVoiceSettings extends LitElement {
header-title=${title}
@closed=${this._dialogClosed}
>
${this._editingAssistant
? html`<ha-icon-button
slot="headerNavigationIcon"
.label=${this.hass.localize("ui.common.back")}
.path=${mdiChevronLeft}
@click=${this._backToList}
></ha-icon-button>`
: html`<ha-icon-button
slot="headerActionItems"
.label=${this.hass.localize(
"ui.dialogs.voice-settings.view_entity"
)}
.path=${mdiTuneVertical}
@click=${this._viewMoreInfo}
></ha-icon-button>`}
<div>${this._renderContent()}</div>
<ha-icon-button
slot="headerActionItems"
.label=${this.hass.localize("ui.dialogs.voice-settings.view_entity")}
.path=${mdiTuneVertical}
@click=${this._viewMoreInfo}
></ha-icon-button>
<div>
<entity-voice-settings
.hass=${this.hass}
.entityId=${this._params.entityId}
.entry=${this._params.extEntityReg}
.exposed=${this._params.exposed}
@entity-entry-updated=${this._entityEntryUpdated}
@exposed-entities-changed=${this._exposedEntitiesChanged}
></entity-voice-settings>
</div>
</ha-dialog>
`;
}
private _renderContent() {
const entityId = this._params!.entityId;
if (this._editingAssistant) {
return html`<voice-assistant-settings
.hass=${this.hass}
.entityId=${entityId}
.assistant=${this._editingAssistant}
.entry=${this._params!.extEntityReg}
@entity-entry-updated=${this._entityEntryUpdated}
></voice-assistant-settings>`;
}
return html`<entity-voice-settings
.hass=${this.hass}
.entityId=${entityId}
.entry=${this._params!.extEntityReg}
.exposed=${this._exposed!}
@edit-assistant=${this._editAssistant}
@exposed-changed=${this._exposedChanged}
@entity-entry-updated=${this._entityEntryUpdated}
@exposed-entities-changed=${this._exposedEntitiesChanged}
></entity-voice-settings>`;
}
private _exposedChanged(ev: CustomEvent): void {
this._exposed = ev.detail.value;
}
private _entityEntryUpdated(ev: CustomEvent) {
this._params!.extEntityReg = ev.detail;
this._params!.entityEntryUpdated?.(ev.detail);
}
private _exposedEntitiesChanged() {
@@ -142,7 +88,6 @@ class DialogVoiceSettings extends LitElement {
return [
haStyle,
haStyleDialog,
haStyleDialogFixedTop,
css`
ha-dialog {
--dialog-content-padding: 0;
@@ -1,10 +1,11 @@
import { mdiAlertCircle, mdiCog } from "@mdi/js";
import { mdiAlertCircle } from "@mdi/js";
import type { CSSResultGroup, 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 { computeStateName } from "../../../common/entity/compute_state_name";
import type {
EntityDomainFilter,
EntityDomainFilterFunc,
@@ -13,24 +14,35 @@ import {
generateEntityDomainFilter,
isEmptyEntityDomainFilter,
} from "../../../common/entity/entity_domain_filter";
import "../../../components/ha-icon-button";
import "../../../components/ha-alert";
import "../../../components/ha-aliases-editor";
import "../../../components/ha-checkbox";
import "../../../components/ha-md-list-item";
import "../../../components/ha-switch";
import "../../../components/voice-assistant-brand-icon";
import { fetchCloudAlexaEntity } from "../../../data/alexa";
import type { CloudStatus, CloudStatusLoggedIn } from "../../../data/cloud";
import { fetchCloudStatus } from "../../../data/cloud";
import {
fetchCloudStatus,
updateCloudGoogleEntityConfig,
} from "../../../data/cloud";
import type { ExtEntityRegistryEntry } from "../../../data/entity/entity_registry";
import { getExtendedEntityRegistryEntry } from "../../../data/entity/entity_registry";
import {
getExtendedEntityRegistryEntry,
updateEntityRegistryEntry,
} from "../../../data/entity/entity_registry";
import type { ExposeEntitySettings } from "../../../data/expose";
import { exposeEntities, voiceAssistants } from "../../../data/expose";
import type { GoogleEntity } from "../../../data/google_assistant";
import { fetchCloudGoogleEntity } from "../../../data/google_assistant";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import type { EntityRegistrySettings } from "../entities/entity-registry-settings";
@customElement("entity-voice-settings")
export class EntityVoiceSettings extends LitElement {
export class EntityVoiceSettings extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId!: string;
@@ -41,6 +53,8 @@ export class EntityVoiceSettings extends LitElement {
@state() private _cloudStatus?: CloudStatus;
@state() private _aliases?: (string | null)[];
@state() private _googleEntity?: GoogleEntity;
@state() private _unsupported: Partial<
@@ -63,16 +77,16 @@ export class EntityVoiceSettings extends LitElement {
private async _fetchEntities() {
try {
this._googleEntity = await fetchCloudGoogleEntity(
const googleEntity = await fetchCloudGoogleEntity(
this.hass,
this.entityId
);
this._googleEntity = googleEntity;
this.requestUpdate("_googleEntity");
} catch (err: any) {
if (err.code === "not_supported") {
this._unsupported = {
...this._unsupported,
"cloud.google_assistant": true,
};
this._unsupported["cloud.google_assistant"] = true;
this.requestUpdate("_unsupported");
}
}
@@ -80,7 +94,8 @@ export class EntityVoiceSettings extends LitElement {
await fetchCloudAlexaEntity(this.hass, this.entityId);
} catch (err: any) {
if (err.code === "not_supported") {
this._unsupported = { ...this._unsupported, "cloud.alexa": true };
this._unsupported["cloud.alexa"] = true;
this.requestUpdate("_unsupported");
}
}
}
@@ -112,6 +127,7 @@ export class EntityVoiceSettings extends LitElement {
this._cloudStatus.prefs.alexa_enabled === true;
const showAssistants = [...Object.keys(voiceAssistants)];
const uiAssistants = [...showAssistants];
const alexaManual =
alexaEnabled &&
@@ -129,12 +145,20 @@ export class EntityVoiceSettings extends LitElement {
showAssistants.indexOf("cloud.google_assistant"),
1
);
uiAssistants.splice(showAssistants.indexOf("cloud.google_assistant"), 1);
} else if (googleManual) {
uiAssistants.splice(uiAssistants.indexOf("cloud.google_assistant"), 1);
}
if (!alexaEnabled) {
showAssistants.splice(showAssistants.indexOf("cloud.alexa"), 1);
uiAssistants.splice(uiAssistants.indexOf("cloud.alexa"), 1);
} else if (alexaManual) {
uiAssistants.splice(uiAssistants.indexOf("cloud.alexa"), 1);
}
const uiExposed = uiAssistants.some((key) => this.exposed[key]);
let manFilterFuncs:
| {
google: EntityDomainFilterFunc;
@@ -153,97 +177,216 @@ export class EntityVoiceSettings extends LitElement {
const manExposedGoogle =
googleManual && manFilterFuncs!.google(this.entityId);
const anyExposed = uiExposed || manExposedAlexa || manExposedGoogle;
return html`
${showAssistants.map((key) => {
const supported = !this._unsupported[key];
<ha-md-list-item>
<h3 slot="headline">
${this.hass.localize("ui.dialogs.voice-settings.expose_header")}
</h3>
<ha-switch
slot="end"
@change=${this._toggleAll}
.assistants=${uiAssistants}
.checked=${anyExposed}
></ha-switch>
</ha-md-list-item>
${anyExposed
? showAssistants.map((key) => {
const supported = !this._unsupported[key];
const exposed =
alexaManual && key === "cloud.alexa"
? manExposedAlexa
: googleManual && key === "cloud.google_assistant"
? manExposedGoogle
: this.exposed[key];
const exposed =
alexaManual && key === "cloud.alexa"
? manExposedAlexa
: googleManual && key === "cloud.google_assistant"
? manExposedGoogle
: this.exposed[key];
const manualConfig =
(alexaManual && key === "cloud.alexa") ||
(googleManual && key === "cloud.google_assistant");
const manualConfig =
(alexaManual && key === "cloud.alexa") ||
(googleManual && key === "cloud.google_assistant");
const hasSettings = supported && !manualConfig;
const support2fa =
key === "cloud.google_assistant" &&
!googleManual &&
supported &&
this._googleEntity?.might_2fa;
const aliasCount =
key === "conversation"
? this.entry
? this.entry.aliases.filter(Boolean).length
: undefined
: key === "cloud.google_assistant"
? (this._googleEntity?.aliases?.filter(Boolean).length ?? 0)
: undefined;
return html`
<ha-md-list-item>
<voice-assistant-brand-icon slot="start" .voiceAssistantId=${key}>
</voice-assistant-brand-icon>
<span slot="headline">${voiceAssistants[key].name}</span>
${!supported
? html`<div slot="supporting-text" class="unsupported">
<ha-svg-icon .path=${mdiAlertCircle}></ha-svg-icon>
${this.hass.localize("ui.dialogs.voice-settings.unsupported")}
</div>`
: manualConfig
? html`
<div slot="supporting-text">
return html`
<ha-md-list-item>
<voice-assistant-brand-icon
slot="start"
.voiceAssistantId=${key}
>
</voice-assistant-brand-icon>
<span slot="headline">${voiceAssistants[key].name}</span>
${!supported
? html`<div slot="supporting-text" class="unsupported">
<ha-svg-icon .path=${mdiAlertCircle}></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.voice-settings.manual_config"
)}
</div>
`
: aliasCount
? html`<div slot="supporting-text">
${this.hass.localize(
"ui.dialogs.voice-settings.aliases_count",
{ count: aliasCount }
"ui.dialogs.voice-settings.unsupported"
)}
</div>`
: nothing}
<div slot="end" class="trailing">
${hasSettings
? html`<ha-icon-button
.path=${mdiCog}
.label=${this.hass.localize(
"ui.dialogs.voice-settings.edit_settings",
{ assistant: voiceAssistants[key].name }
)}
.assistant=${key}
@click=${this._editAssistant}
></ha-icon-button>`
: nothing}
${manualConfig
? html`
<div slot="supporting-text">
${this.hass.localize(
"ui.dialogs.voice-settings.manual_config"
)}
</div>
`
: nothing}
${support2fa
? html`
<ha-checkbox
slot="supporting-text"
.checked=${!this._googleEntity!.disable_2fa}
@change=${this._2faChanged}
>
${this.hass.localize(
"ui.dialogs.voice-settings.ask_pin"
)}
</ha-checkbox>
`
: nothing}
<ha-switch
slot="end"
.assistant=${key}
@change=${this._toggleAssistant}
.disabled=${manualConfig || (!exposed && !supported)}
.checked=${exposed}
></ha-switch>
</ha-md-list-item>
`;
})
: nothing}
<h3 class="header">
${this.hass.localize("ui.dialogs.voice-settings.aliases_header")}
</h3>
<p class="description">
${this.hass.localize("ui.dialogs.voice-settings.aliases_description")}
</p>
${!this.entry
? html`<ha-alert alert-type="warning">
${this.hass.localize(
"ui.dialogs.voice-settings.aliases_no_unique_id",
{
faq_link: html`<a
href=${documentationUrl(this.hass, "/faq/unique_id")}
target="_blank"
rel="noreferrer"
>${this.hass.localize("ui.dialogs.entity_registry.faq")}</a
>`,
}
)}
</ha-alert>`
: html`
<ha-md-list-item>
<span slot="headline">
${this.hass.states[this.entityId]
? computeStateName(this.hass.states[this.entityId])
: this.entityId}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.dialogs.voice-settings.entity_name_alias_description"
)}
</span>
<ha-switch
.assistant=${key}
@change=${this._toggleAssistant}
.disabled=${manualConfig || (!exposed && !supported)}
.checked=${exposed}
slot="end"
.checked=${(this._aliases ?? this.entry.aliases).includes(null)}
@change=${this._toggleEntityNameAlias}
></ha-switch>
</div>
</ha-md-list-item>
`;
})}
</ha-md-list-item>
<ha-aliases-editor
.aliases=${(this._aliases ?? this.entry.aliases).filter(
(a): a is string => a !== null
)}
sortable
@value-changed=${this._aliasesChanged}
></ha-aliases-editor>
`}
`;
}
private _editAssistant(ev) {
fireEvent(this, "edit-assistant", { assistant: ev.target.assistant });
private async _toggleEntityNameAlias(ev) {
const enabled = ev.target.checked;
const currentAliases = this._aliases ?? this.entry?.aliases ?? [];
if (enabled) {
this._aliases = [null, ...currentAliases.filter((a) => a !== null)];
} else {
this._aliases = currentAliases.filter((a): a is string => a !== null);
}
await this._saveAliases();
}
private _aliasesChanged(ev) {
const currentAliases = this._aliases ?? this.entry?.aliases ?? [];
const hasNull = currentAliases.includes(null);
const nullAliases: (string | null)[] = hasNull ? [null] : [];
const newStringAliases: string[] = ev.detail.value;
this._aliases = [...nullAliases, ...newStringAliases];
this._saveAliases();
}
private async _2faChanged(ev) {
try {
await updateCloudGoogleEntityConfig(
this.hass,
this.entityId,
!ev.target.checked
);
} catch (_err) {
ev.target.checked = !ev.target.checked;
}
}
private async _saveAliases() {
if (!this._aliases) {
return;
}
const hasNull = this._aliases.includes(null);
const nullAliases: null[] = hasNull ? [null] : [];
const stringAliases = this._aliases
.filter((a): a is string => a !== null)
.map((alias) => alias.trim())
.filter((alias) => alias);
const result = await updateEntityRegistryEntry(this.hass, this.entityId, {
aliases: [...nullAliases, ...stringAliases],
});
fireEvent(this, "entity-entry-updated", result.entity_entry);
}
private async _toggleAssistant(ev) {
ev.stopPropagation();
const assistant: string = ev.target.assistant;
const checked: boolean = ev.target.checked;
exposeEntities(
this.hass,
[ev.target.assistant],
[this.entityId],
ev.target.checked
);
if (this.entry) {
const entry = await getExtendedEntityRegistryEntry(
this.hass,
this.entityId
);
fireEvent(this, "entity-entry-updated", entry);
}
fireEvent(this, "exposed-entities-changed");
}
exposeEntities(this.hass, [assistant], [this.entityId], checked);
fireEvent(this, "exposed-changed", {
value: { ...this.exposed, [assistant]: checked },
});
private async _toggleAll(ev) {
const expose = ev.target.checked;
const assistants = expose
? ev.target.assistants.filter((key) => !this._unsupported[key])
: ev.target.assistants;
exposeEntities(this.hass, assistants, [this.entityId], ev.target.checked);
if (this.entry) {
const entry = await getExtendedEntityRegistryEntry(
this.hass,
@@ -260,7 +403,7 @@ export class EntityVoiceSettings extends LitElement {
css`
:host {
display: block;
margin: var(--ha-space-8);
margin: 32px;
margin-top: 0;
}
ha-md-list-item {
@@ -268,10 +411,19 @@ export class EntityVoiceSettings extends LitElement {
--md-list-item-trailing-space: 0;
--md-item-overflow: visible;
}
.trailing {
display: flex;
align-items: center;
gap: var(--ha-space-2);
img {
height: 32px;
width: 32px;
margin-right: 16px;
margin-inline-end: 16px;
margin-inline-start: initial;
}
ha-aliases-editor {
display: block;
}
ha-alert {
display: block;
margin-top: 16px;
}
.unsupported {
display: flex;
@@ -280,10 +432,21 @@ export class EntityVoiceSettings extends LitElement {
.unsupported ha-svg-icon {
color: var(--error-color);
--mdc-icon-size: 16px;
margin-right: var(--ha-space-1);
margin-inline-end: var(--ha-space-1);
margin-right: 4px;
margin-inline-end: 4px;
margin-inline-start: initial;
}
.header {
margin-top: 8px;
margin-bottom: 4px;
}
.description {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-m);
line-height: var(--ha-line-height-condensed);
margin-top: 0;
margin-bottom: 16px;
}
`,
];
}
@@ -291,11 +454,15 @@ export class EntityVoiceSettings extends LitElement {
declare global {
interface HTMLElementTagNameMap {
"entity-voice-settings": EntityVoiceSettings;
"entity-registry-settings": EntityRegistrySettings;
}
interface HASSDomEvents {
"entity-entry-updated": ExtEntityRegistryEntry;
"edit-assistant": { assistant: string };
"exposed-changed": { value: ExposeEntitySettings };
}
}
declare global {
interface HTMLElementTagNameMap {
"entity-voice-settings": EntityVoiceSettings;
}
}
@@ -1,176 +0,0 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { computeStateName } from "../../../common/entity/compute_state_name";
import "../../../components/ha-aliases-editor";
import "../../../components/ha-md-list-item";
import "../../../components/ha-switch";
import "../../../components/input/ha-input";
import { updateCloudGoogleEntityConfig } from "../../../data/cloud";
import type { GoogleEntity } from "../../../data/google_assistant";
import { fetchCloudGoogleEntity } from "../../../data/google_assistant";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
@customElement("google-entity-voice-settings")
export class GoogleEntityVoiceSettings extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId!: string;
@state() private _entity?: GoogleEntity;
protected willUpdate(changedProps: PropertyValues<this>) {
if (changedProps.has("entityId") && this.entityId) {
this._fetchEntity();
}
}
private async _fetchEntity() {
try {
const entity = await fetchCloudGoogleEntity(this.hass, this.entityId);
if (entity.aliases) {
entity.aliases = entity.aliases.filter(Boolean);
}
this._entity = entity;
} catch (_err) {
this._entity = undefined;
}
}
protected render() {
if (!this._entity) {
return nothing;
}
const defaultName = this.hass.states[this.entityId]
? computeStateName(this.hass.states[this.entityId])
: this.entityId;
return html`
<ha-input
.label=${this.hass.localize("ui.dialogs.voice-settings.name")}
.hint=${this.hass.localize(
"ui.dialogs.voice-settings.name_description"
)}
with-clear
.value=${this._entity.name ?? ""}
.placeholder=${defaultName}
@change=${this._nameChanged}
></ha-input>
<h4 class="header">
${this.hass.localize("ui.dialogs.voice-settings.aliases")}
</h4>
<ha-aliases-editor
.aliases=${this._entity.aliases ?? []}
@value-changed=${this._aliasesChanged}
></ha-aliases-editor>
${this._entity.might_2fa
? html`
<wa-divider></wa-divider>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize("ui.dialogs.voice-settings.ask_pin")}
</span>
<ha-switch
slot="end"
.checked=${!this._entity.disable_2fa}
@change=${this._2faChanged}
></ha-switch>
</ha-md-list-item>
`
: nothing}
`;
}
private async _nameChanged(ev) {
if (!this._entity) {
return;
}
const value = ev.target.value?.trim() || null;
if ((this._entity.name ?? null) === value) {
return;
}
const previous = this._entity.name ?? null;
this._entity = { ...this._entity, name: value };
try {
await updateCloudGoogleEntityConfig(this.hass, this.entityId, {
name: value,
});
} catch (_err) {
this._entity = { ...this._entity, name: previous };
}
}
private async _aliasesChanged(ev) {
if (!this._entity) {
return;
}
const aliases = ev.detail.value as string[];
const previous = this._entity.aliases ?? null;
this._entity = { ...this._entity, aliases };
const stringAliases = aliases
.map((alias) => alias.trim())
.filter((alias) => alias);
try {
await updateCloudGoogleEntityConfig(this.hass, this.entityId, {
aliases: stringAliases,
});
} catch (_err) {
this._entity = { ...this._entity, aliases: previous };
}
}
private async _2faChanged(ev) {
if (!this._entity) {
return;
}
const disable_2fa = !ev.target.checked;
this._entity = { ...this._entity, disable_2fa };
try {
await updateCloudGoogleEntityConfig(this.hass, this.entityId, {
disable_2fa,
});
} catch (_err) {
this._entity = { ...this._entity, disable_2fa: !disable_2fa };
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host {
display: block;
margin: 0 var(--ha-space-8) var(--ha-space-8);
}
ha-input {
display: block;
width: 100%;
}
ha-aliases-editor {
display: block;
}
.header {
margin-top: var(--ha-space-2);
margin-bottom: var(--ha-space-1);
}
ha-md-list-item {
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
--md-item-overflow: visible;
}
wa-divider {
margin: var(--ha-space-2) 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"google-entity-voice-settings": GoogleEntityVoiceSettings;
}
}
@@ -104,8 +104,6 @@ export class VoiceAssistantsExpose extends LitElement {
string[] | undefined
>;
@state() private _googleAliases?: Record<string, string[]>;
@storage({
key: "voice-expose-table-sort",
state: false,
@@ -160,8 +158,7 @@ export class VoiceAssistantsExpose extends LitElement {
| undefined,
_language: string,
localize: LocalizeFunc,
entitiesToCheck?: any[],
googleAliases?: Record<string, string[]>
entitiesToCheck?: any[]
): DataTableColumnContainer => ({
icon: {
title: "",
@@ -202,15 +199,9 @@ export class VoiceAssistantsExpose extends LitElement {
sortable: true,
filterable: true,
template: (entry) => {
const registryAliases = entry.aliases.filter(
const aliases = entry.aliases.filter(
(a: string | null) => a !== null
);
const aliases = [
...new Set([
...registryAliases,
...(googleAliases?.[entry.entity_id] ?? []),
]),
];
return aliases.length === 0
? "-"
: aliases.length === 1
@@ -466,14 +457,6 @@ export class VoiceAssistantsExpose extends LitElement {
// TODO add supported entity for assist
conversation: undefined,
};
this._googleAliases = googleEntities
? Object.fromEntries(
googleEntities.map((entity) => [
entity.entity_id,
(entity.aliases ?? []).filter(Boolean),
])
)
: undefined;
}
public willUpdate(changedProperties: PropertyValues): void {
@@ -520,8 +503,7 @@ export class VoiceAssistantsExpose extends LitElement {
this._supportedEntities,
this.hass.language,
this.hass.localize,
filteredEntities,
this._googleAliases
filteredEntities
)}
.data=${filteredEntities}
.searchLabel=${this.hass.localize(
@@ -726,9 +708,6 @@ export class VoiceAssistantsExpose extends LitElement {
exposedEntitiesChanged: () => {
fireEvent(this, "exposed-entities-changed");
},
entityEntryUpdated: (entry) => {
this._extEntities = { ...this._extEntities, [entityId]: entry };
},
});
}
@@ -7,7 +7,6 @@ export interface VoiceSettingsDialogParams {
exposed: ExposeEntitySettings;
extEntityReg?: ExtEntityRegistryEntry;
exposedEntitiesChanged?: () => void;
entityEntryUpdated?: (entry: ExtEntityRegistryEntry) => void;
}
export const loadVoiceSettingsDialog = () => import("./dialog-voice-settings");
@@ -1,47 +0,0 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { ExtEntityRegistryEntry } from "../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../types";
import "./alexa-entity-voice-settings";
import "./assist-entity-voice-settings";
import "./google-entity-voice-settings";
@customElement("voice-assistant-settings")
export class VoiceAssistantSettings extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId!: string;
@property({ attribute: false }) public assistant!: string;
@property({ attribute: false }) public entry?: ExtEntityRegistryEntry;
protected render() {
switch (this.assistant) {
case "cloud.google_assistant":
return html`<google-entity-voice-settings
.hass=${this.hass}
.entityId=${this.entityId}
></google-entity-voice-settings>`;
case "cloud.alexa":
return html`<alexa-entity-voice-settings
.hass=${this.hass}
.entityId=${this.entityId}
></alexa-entity-voice-settings>`;
case "conversation":
return html`<assist-entity-voice-settings
.hass=${this.hass}
.entityId=${this.entityId}
.entry=${this.entry}
></assist-entity-voice-settings>`;
default:
return nothing;
}
}
}
declare global {
interface HTMLElementTagNameMap {
"voice-assistant-settings": VoiceAssistantSettings;
}
}
+30
View File
@@ -7,6 +7,7 @@ import {
apiContext,
areasContext,
authContext,
conditionDescriptionsContext,
configContext,
configEntriesContext,
configSingleContext,
@@ -28,14 +29,19 @@ import {
servicesContext,
statesContext,
themesContext,
triggerDescriptionsContext,
uiContext,
userContext,
userDataContext,
} from "../data/context";
import type { ConditionDescriptions } from "../data/condition";
import { subscribeConditions } from "../data/condition";
import { updateHassGroups } from "../data/context/updateContext";
import { subscribeEntityRegistry } from "../data/entity/entity_registry";
import { fetchIntegrationManifestsCollection } from "../data/integration";
import { subscribeLabelRegistry } from "../data/label/label_registry";
import type { TriggerDescriptions } from "../data/trigger";
import { subscribeTriggers } from "../data/trigger";
import type { Constructor, HomeAssistant } from "../types";
import type { HassBaseEl } from "./hass-base-mixin";
import { LazyContextProvider } from "./lazy-context-provider";
@@ -200,6 +206,30 @@ export const contextMixin = <T extends Constructor<HassBaseEl>>(
context: manifestsContext,
subscribeFn: fetchIntegrationManifestsCollection,
}),
triggerDescriptions: new LazyContextProvider(this, {
context: triggerDescriptionsContext,
subscribeFn: (connection, setValue) => {
// The backend streams trigger platforms in batches, so accumulate
// them into a single descriptions map.
let descriptions: TriggerDescriptions = {};
return subscribeTriggers(connection, (update) => {
descriptions = { ...descriptions, ...update };
setValue(descriptions);
});
},
}),
conditionDescriptions: new LazyContextProvider(this, {
context: conditionDescriptionsContext,
subscribeFn: (connection, setValue) => {
// The backend streams condition platforms in batches, so accumulate
// them into a single descriptions map.
let descriptions: ConditionDescriptions = {};
return subscribeConditions(connection, (update) => {
descriptions = { ...descriptions, ...update };
setValue(descriptions);
});
},
}),
};
private __relatedContextProvider = new RelatedContextProvider(this);
+16 -14
View File
@@ -1962,7 +1962,7 @@
"stream_orientation_8": "Rotate right"
},
"voice_assistants": "[%key:ui::panel::config::dashboard::voice_assistants::main%]",
"voice_assistants_description": "Configure aliases and expose settings for voice assistants"
"no_aliases": "Configure aliases and expose settings for voice assistants"
}
},
"recreate_entity_ids": {
@@ -1999,11 +1999,9 @@
"required_error_msg": "[%key:ui::panel::config::zone::detail::required_error_msg%]"
},
"voice-settings": {
"edit_settings": "Edit {assistant} settings",
"name": "Name",
"name_description": "Leave empty to use the entity's default name.",
"aliases": "Aliases",
"aliases_count": "{count} {count, plural,\n one {alias}\n other {aliases}\n}",
"expose_header": "Expose",
"aliases_header": "Aliases",
"aliases_description": "Aliases are alternative names to call your entity. Only supported by Assist and Google Assistant.",
"aliases_no_unique_id": "Aliases are not supported for entities without a unique ID. See the {faq_link} for more detail.",
"entity_name_alias_description": "Default name. Disable it if you want your voice assistants to ignore it and just use aliases.",
"ask_pin": "Ask for PIN",
@@ -5109,6 +5107,7 @@
"select_target": "Select a target",
"home": "Home",
"unassigned": "Unassigned",
"time_sun": "Time and sun",
"blocks": "Blocks",
"tabs": {
"target": "By target",
@@ -5177,7 +5176,6 @@
"cut_to_clipboard": "Trigger cut to clipboard",
"select": "Select a trigger",
"no_items_for_target": "No triggers available for",
"no_items_for_target_note": "This is a {labs_link} feature. More triggers will be added in future updates.",
"groups": {
"device": {
"label": "Device"
@@ -5185,8 +5183,11 @@
"entity": {
"label": "Entity"
},
"time_location": {
"label": "Time and location"
"time": {
"label": "Time"
},
"sun": {
"label": "Sun"
},
"generic": {
"label": "Generic"
@@ -5456,7 +5457,6 @@
"cut_to_clipboard": "Condition cut to clipboard",
"select": "Select a condition",
"no_items_for_target": "No conditions available for",
"no_items_for_target_note": "This is a {labs_link} feature. More conditions will be added in future updates.",
"groups": {
"device": {
"label": "Device"
@@ -5464,8 +5464,11 @@
"entity": {
"label": "Entity"
},
"time_location": {
"label": "Time and location"
"time": {
"label": "Time"
},
"sun": {
"label": "Sun"
},
"generic": {
"label": "Generic"
@@ -6526,8 +6529,7 @@
"actions": {
"no_actions": "No actions",
"unknown_action": "Unknown action"
},
"no_device_automations": "There are no automations, scripts or scenes available for this device."
}
},
"script": {
"scripts_heading": "Scripts",