Compare commits

...

2 Commits

Author SHA1 Message Date
Aidan Timson 4bbcd0f00e Move device pickers to context data 2026-06-05 08:39:59 +01:00
Aidan Timson e493920f88 Narrow picker helper data dependencies 2026-06-05 08:37:29 +01:00
13 changed files with 189 additions and 107 deletions
+127 -77
View File
@@ -1,25 +1,40 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { consume, type ContextType } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { getDeviceArea } from "../../common/entity/context/get_device_context";
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
import {
configEntriesToLookup,
type ConfigEntry,
} from "../../data/config_entries";
import {
configContext,
configEntriesContext,
internationalizationContext,
registriesContext,
statesContext,
uiContext,
} from "../../data/context";
import {
deviceComboBoxKeys,
getDevices,
type DevicePickerData,
type DevicePickerItem,
} from "../../data/device/device_picker";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
const EMPTY_CONFIG_ENTRY_LOOKUP: Record<string, ConfigEntry> = {};
export type HaDevicePickerDeviceFilterFunc = (
device: DeviceRegistryEntry
) => boolean;
@@ -28,8 +43,6 @@ export type HaDevicePickerEntityFilterFunc = (entity: HassEntity) => boolean;
@customElement("ha-device-picker")
export class HaDevicePicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@@ -93,11 +106,36 @@ export class HaDevicePicker extends LitElement {
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
@consume({ context: registriesContext, subscribe: true })
private _registries!: ContextType<typeof registriesContext>;
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: uiContext, subscribe: true })
private _ui!: ContextType<typeof uiContext>;
@consume({ context: configContext, subscribe: true })
private _config!: ContextType<typeof configContext>;
@state()
@consume({ context: configEntriesContext, subscribe: true })
@transform<
ContextType<typeof configEntriesContext>,
Record<string, ConfigEntry>
>({
transformer: configEntriesToLookup,
})
private _configEntryLookup?: Record<string, ConfigEntry>;
private _getDevicesMemoized = memoizeOne(
(
_devices: HomeAssistant["devices"],
states: ContextType<typeof statesContext>,
registries: ContextType<typeof registriesContext>,
i18n: ContextType<typeof internationalizationContext>,
configEntryLookup: Record<string, ConfigEntry>,
includeDomains?: string[],
excludeDomains?: string[],
@@ -107,33 +145,33 @@ export class HaDevicePicker extends LitElement {
excludeDevices?: string[],
value?: string
) =>
getDevices(this.hass, configEntryLookup, {
includeDomains,
excludeDomains,
includeDeviceClasses,
deviceFilter,
entityFilter,
excludeDevices,
value,
})
getDevices(
{
...registries,
states,
localize: i18n.localize,
language: i18n.language,
translationMetadata: i18n.translationMetadata,
} satisfies DevicePickerData,
configEntryLookup,
{
includeDomains,
excludeDomains,
includeDeviceClasses,
deviceFilter,
entityFilter,
excludeDevices,
value,
}
)
);
protected firstUpdated(_changedProperties: PropertyValues<this>): void {
super.firstUpdated(_changedProperties);
this._loadConfigEntries();
}
private async _loadConfigEntries() {
const configEntries = await getConfigEntries(this.hass);
this._configEntryLookup = Object.fromEntries(
configEntries.map((entry) => [entry.entry_id, entry])
);
}
private _getItems = () =>
this._getDevicesMemoized(
this.hass.devices,
this._configEntryLookup,
this._states,
this._registries,
this._i18n,
this._configEntryLookup ?? EMPTY_CONFIG_ENTRY_LOOKUP,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
@@ -144,47 +182,54 @@ export class HaDevicePicker extends LitElement {
);
private _valueRenderer = memoizeOne(
(configEntriesLookup: Record<string, ConfigEntry>) => (value: string) => {
const deviceId = value;
const device = this.hass.devices[deviceId];
(
devices: ContextType<typeof registriesContext>["devices"],
areas: ContextType<typeof registriesContext>["areas"],
darkMode: boolean,
hassUrl: string,
configEntriesLookup: Record<string, ConfigEntry>
) =>
(value: string) => {
const deviceId = value;
const device = devices[deviceId];
if (!device) {
return html`<span slot="headline">${deviceId}</span>`;
if (!device) {
return html`<span slot="headline">${deviceId}</span>`;
}
const area = getDeviceArea(device, areas);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const primary = deviceName;
const secondary = areaName;
const configEntry = device.primary_config_entry
? configEntriesLookup[device.primary_config_entry]
: undefined;
return html`
${configEntry
? html`<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: configEntry.domain,
type: "icon",
darkOptimized: darkMode,
},
hassUrl
)}
/>`
: nothing}
<span slot="headline">${primary}</span>
<span slot="supporting-text">${secondary}</span>
`;
}
const area = getDeviceArea(device, this.hass.areas);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const primary = deviceName;
const secondary = areaName;
const configEntry = device.primary_config_entry
? configEntriesLookup[device.primary_config_entry]
: undefined;
return html`
${configEntry
? html`<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: configEntry.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>`
: nothing}
<span slot="headline">${primary}</span>
<span slot="supporting-text">${secondary}</span>
`;
}
);
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => html`
@@ -200,9 +245,9 @@ export class HaDevicePicker extends LitElement {
{
domain: item.domain,
type: "icon",
darkOptimized: this.hass.themes.darkMode,
darkOptimized: this._ui.themes.darkMode,
},
this.hass.auth.data.hassUrl
this._config.auth.data.hassUrl
)}
/>
`
@@ -225,20 +270,25 @@ export class HaDevicePicker extends LitElement {
protected render() {
const placeholder =
this.placeholder ??
this.hass.localize("ui.components.device-picker.placeholder");
this._i18n.localize("ui.components.device-picker.placeholder");
const valueRenderer = this._valueRenderer(this._configEntryLookup);
const valueRenderer = this._valueRenderer(
this._registries.devices,
this._registries.areas,
this._ui.themes.darkMode,
this._config.auth.data.hassUrl,
this._configEntryLookup ?? EMPTY_CONFIG_ENTRY_LOOKUP
);
return html`
<ha-generic-picker
.hass=${this.hass}
.autofocus=${this.autofocus}
.disabled=${this.disabled}
.helper=${this.helper}
.label=${this.label}
.searchLabel=${this.searchLabel}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass.localize(
.emptyLabel=${this._i18n.localize(
"ui.components.device-picker.no_devices"
)}
.placeholder=${placeholder}
@@ -248,7 +298,7 @@ export class HaDevicePicker extends LitElement {
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${valueRenderer}
.searchKeys=${deviceComboBoxKeys}
.unknownItemText=${this.hass.localize(
.unknownItemText=${this._i18n.localize(
"ui.components.device-picker.unknown"
)}
@value-changed=${this._valueChanged}
@@ -270,7 +320,7 @@ export class HaDevicePicker extends LitElement {
}
private _notFoundLabel = (search: string) =>
this.hass.localize("ui.components.device-picker.no_match", {
this._i18n.localize("ui.components.device-picker.no_match", {
term: html`<b>${search}</b>`,
});
}
+2 -10
View File
@@ -1,7 +1,7 @@
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import type { ValueChangedEvent } from "../../types";
import "./ha-device-picker";
import type {
HaDevicePickerDeviceFilterFunc,
@@ -10,8 +10,6 @@ import type {
@customElement("ha-devices-picker")
class HaDevicesPicker extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ type: Array }) public value?: string[];
@property() public helper?: string;
@@ -51,10 +49,6 @@ class HaDevicesPicker extends LitElement {
public entityFilter?: HaDevicePickerEntityFilterFunc;
protected render() {
if (!this.hass) {
return nothing;
}
const currentDevices = this._currentDevices;
return html`
${currentDevices.map(
@@ -62,7 +56,6 @@ class HaDevicesPicker extends LitElement {
<div>
<ha-device-picker
.curValue=${entityId}
.hass=${this.hass}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDomains=${this.includeDomains}
@@ -78,7 +71,6 @@ class HaDevicesPicker extends LitElement {
)}
<div>
<ha-device-picker
.hass=${this.hass}
.helper=${this.helper}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
+8 -4
View File
@@ -21,6 +21,8 @@ import "../ha-state-icon";
export class StateBadge extends LitElement {
public hass?: HomeAssistant;
@property({ attribute: false }) public hassUrl?: HomeAssistant["hassUrl"];
@property({ attribute: false }) public stateObj?: HassEntity;
@property({ attribute: false }) public overrideIcon?: string;
@@ -136,8 +138,9 @@ export class StateBadge extends LitElement {
let imageUrl =
stateObj.attributes.entity_picture_local ||
stateObj.attributes.entity_picture;
if (this.hass) {
imageUrl = this.hass.hassUrl(imageUrl);
const hassUrl = this.hass?.hassUrl ?? this.hassUrl;
if (hassUrl) {
imageUrl = hassUrl(imageUrl);
}
if (domain === "camera") {
imageUrl = cameraUrlWithWidthHeight(imageUrl, 80, 80);
@@ -180,8 +183,9 @@ export class StateBadge extends LitElement {
}
} else if (this.overrideImage) {
let imageUrl = this.overrideImage;
if (this.hass) {
imageUrl = this.hass.hassUrl(imageUrl);
const hassUrl = this.hass?.hassUrl ?? this.hassUrl;
if (hassUrl) {
imageUrl = hassUrl(imageUrl);
}
backgroundImage = `url(${imageUrl})`;
this.icon = false;
@@ -96,7 +96,6 @@ export class HaDeviceSelector extends LitElement {
if (!this.selector.device?.multiple) {
return html`
<ha-device-picker
.hass=${this.hass}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
@@ -114,7 +113,6 @@ export class HaDeviceSelector extends LitElement {
return html`
${this.label ? html`<label>${this.label}</label>` : ""}
<ha-devices-picker
.hass=${this.hass}
.value=${this.value}
.helper=${this.helper}
.deviceFilter=${this._filterDevices}
+8 -3
View File
@@ -28,6 +28,11 @@ import {
import type { LocalizeKeys } from "../common/translations/localize";
import type { HomeAssistant } from "../types";
type NavigationPathInfoData = Pick<
HomeAssistant,
"areas" | "devices" | "localize" | "panels"
>;
export interface NavigationPathInfo {
label: string;
icon?: string;
@@ -81,7 +86,7 @@ export const CONFIG_SUB_ROUTES: Record<
* For lovelace views, pass the dashboard config to resolve view title/icon.
*/
export const computeNavigationPathInfo = (
hass: HomeAssistant,
hass: NavigationPathInfoData,
path: string,
lovelaceConfig?: LovelaceRawConfig,
ingressPanels?: IngressPanelInfoMap
@@ -161,7 +166,7 @@ export const computeNavigationPathInfo = (
};
const computeAreaNavigationPathInfo = (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "areas">,
areaId: string
): NavigationPathInfo => {
const area = hass.areas[areaId];
@@ -173,7 +178,7 @@ const computeAreaNavigationPathInfo = (
};
const computeDeviceNavigationPathInfo = (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "devices">,
deviceId: string
): NavigationPathInfo => {
const device = hass.devices[deviceId];
+9 -1
View File
@@ -137,7 +137,15 @@ export const getConfigEntries = (
});
};
export const getConfigEntry = (hass: HomeAssistant, configEntryId: string) =>
export const configEntriesToLookup = (
entries: ConfigEntry[]
): Record<string, ConfigEntry> =>
Object.fromEntries(entries.map((entry) => [entry.entry_id, entry]));
export const getConfigEntry = (
hass: Pick<HomeAssistant, "callWS">,
configEntryId: string
) =>
hass.callWS<{ config_entry: ConfigEntry }>({
type: "config_entries/get_single",
entry_id: configEntryId,
+17 -2
View File
@@ -7,7 +7,11 @@ import { computeRTL } from "../../common/util/compute_rtl";
import type { HaDevicePickerDeviceFilterFunc } from "../../components/device/ha-device-picker";
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
import type { FuseWeightedKey } from "../../resources/fuseMultiTerm";
import type { HomeAssistant } from "../../types";
import type {
HomeAssistant,
HomeAssistantInternationalization,
HomeAssistantRegistries,
} from "../../types";
import type { ConfigEntry } from "../config_entries";
import type { HaEntityPickerEntityFilterFunc } from "../entity/entity";
import type {
@@ -43,6 +47,17 @@ export interface GetDevicesOptions {
idPrefix?: string;
}
export type DevicePickerData = Pick<
HomeAssistantRegistries,
"areas" | "devices" | "entities"
> &
Pick<
HomeAssistantInternationalization,
"language" | "localize" | "translationMetadata"
> & {
states: HomeAssistant["states"];
};
export const computeDeviceAreaLabel = (
device: DeviceRegistryEntry,
areas: HomeAssistant["areas"],
@@ -105,7 +120,7 @@ export const deviceComboBoxKeys: FuseWeightedKey[] = [
];
export const getDevices = (
hass: HomeAssistant,
hass: DevicePickerData,
configEntryLookup: Record<string, ConfigEntry>,
options?: GetDevicesOptions
): DevicePickerItem[] => {
+14 -2
View File
@@ -5,7 +5,11 @@ import { computeStateName } from "../../common/entity/compute_state_name";
import { computeRTL } from "../../common/util/compute_rtl";
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
import type { FuseWeightedKey } from "../../resources/fuseMultiTerm";
import type { HomeAssistant } from "../../types";
import type {
HomeAssistant,
HomeAssistantInternationalization,
HomeAssistantRegistries,
} from "../../types";
import { domainToName } from "../integration";
import type { HaEntityPickerEntityFilterFunc } from "./entity";
@@ -53,8 +57,16 @@ export interface GetEntitiesOptions {
idPrefix?: string;
}
export type EntityPickerData = HomeAssistantRegistries &
Pick<
HomeAssistantInternationalization,
"language" | "localize" | "translationMetadata"
> & {
states: HomeAssistant["states"];
};
export const getEntities = (
hass: HomeAssistant,
hass: EntityPickerData,
options?: GetEntitiesOptions
): EntityComboBoxItem[] => {
const {
+4 -2
View File
@@ -12,6 +12,8 @@ import type { LocalizeKeys } from "../common/translations/localize";
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
import type { HomeAssistant, PanelInfo } from "../types";
type PanelTitleData = Pick<HomeAssistant, "localize" | "panels">;
export const APP_PANEL = "app";
export const HOME_PANEL = "home";
export const MY_REDIRECT_PANEL = "_my_redirect";
@@ -65,7 +67,7 @@ export const getPanelNameTranslationKey = (panel: PanelInfo) => {
};
export const getPanelTitle = (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "localize">,
panel: PanelInfo
): string | undefined => {
const translationKey = getPanelNameTranslationKey(panel);
@@ -74,7 +76,7 @@ export const getPanelTitle = (
};
export const getPanelTitleFromUrlPath = (
hass: HomeAssistant,
hass: PanelTitleData,
urlPath: string
): string | undefined => {
if (!hass.panels) {
@@ -89,7 +89,6 @@ export class HaDeviceAction extends LitElement {
.value=${deviceId}
.disabled=${this.disabled}
@value-changed=${this._devicePicked}
.hass=${this.hass}
label=${this.hass.localize(
"ui.panel.config.automation.editor.actions.type.device_id.label"
)}
@@ -89,7 +89,6 @@ export class HaDeviceCondition extends LitElement {
<ha-device-picker
.value=${deviceId}
@value-changed=${this._devicePicked}
.hass=${this.hass}
.disabled=${this.disabled}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.device.label"
@@ -93,7 +93,6 @@ export class HaDeviceTrigger extends LitElement {
<ha-device-picker
.value=${deviceId}
@value-changed=${this._devicePicked}
.hass=${this.hass}
.disabled=${this.disabled}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.type.device.label"
@@ -463,7 +463,6 @@ export class HaSceneEditor extends PreventUnsavedMixin(
<div class="card-content">
<ha-device-picker
@value-changed=${this._devicePicked}
.hass=${this.hass}
.label=${this.hass.localize(
"ui.panel.config.scene.editor.devices.add"
)}