Compare commits

...
Author SHA1 Message Date
9472d8ad12 Add device context to energy individual device names (#53603)
Add device context to energy individual device labels

Co-authored-by: Simon Lamon <[email protected]>
2026-08-13 18:52:28 +02:00
Paul BotteinandGitHub 94cbe4467b Lay out inline features in two columns (#53631)
* Lay out inline features in two columns

* Detect stacked features from the slot

* Rename tile container layout classes

* Rename the below features in the layout helper

* Keep area card features below at full width

* Stretch the area card features below the inline one

* Keep area card features compact outside the compact type

* Revert "Keep area card features compact outside the compact type"

This reverts commit 48268cb4e2.

* Reapply "Keep area card features compact outside the compact type"

This reverts commit e10bb27e2f.

* Keep the inline feature compact on condensed cards
2026-08-13 18:00:47 +03:00
Paul BotteinandGitHub 043abb69af Keep control select in the tab order without a selection (#53633) 2026-08-13 15:54:59 +01:00
Paul BotteinandGitHub b6b8fe641f Fix clipped focus ring on media player playback buttons (#53632) 2026-08-13 15:54:49 +01:00
70bb08b101 Child devices UI (#53619)
* Add child devices UI

Surface child devices throughout the config UI so they read as first-class
devices nested under their parent:

- Device page: a "Sub-devices" card lists a device's children, and a child's
  page shows a "Part of <parent>" link (hardware/model/config-entry are
  already inherited from the parent by the registry resolver).
- Integration page: children are nested and indented under their parent
  device in the config-entry and subentry device lists.
- Device picker: children are ordered and indented under their parent with a
  tree connector, mirroring the area/floor picker.
- Naming: the device picker's secondary label and search now include the
  parent device name for children, so they stay identifiable in flat views.

Follow-up to the child devices data layer (#53617).

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Add child devices to demo and gallery mock data

A power strip parent with two outlet children in the demo device stubs and
the ha-selector gallery demo, so child device rendering (nesting, tree
indentation, parent-context naming, inherited area) can be exercised without
a running backend.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Refine child devices UI and target resolution

Follow-up polish so child devices behave like a normal device everywhere:

- Targets & filters: a parent device qualifies for and resolves to its
  children's entities (getDevices filter, deviceMeetsFilter,
  deviceMeetsTargetSelector, resolveEntityIDs and the target-chip "split into
  entities" expand), matching core's server-side target resolution. Selecting a
  parent excludes its children from the picker.
- Pickers: keep a parent visible when a child matches the search (device and
  target pickers), fix the target picker's nested order (unsorted search +
  recomputed last-child flag), and render the sub-device tree in the target
  picker's device group.
- Area/naming: the area field shows only the (inherited) area again; the parent
  name remains a search term.
- Integration page: correct the tree end connector and align it in narrow mode.
- A parent-disabled child can no longer be enabled from the settings dialog.
- Devices dashboard: show "Part of <parent>" under a sub-device's name and add a
  hidden-by-default, searchable and groupable Parent device column that groups a
  parent together with its children.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Address review on child devices UI

Align target resolution with core and fix picker/table details:

- Only a directly targeted device expands to its child devices. Labels are
  never inherited by children (core's target helper is explicit about this) and
  areas resolve by effective-area membership, so deviceMeetsFilter and
  deviceMeetsTargetSelector evaluate a device's own entities again.
- Add devicesInEffectiveArea, mirroring core's dr.async_entries_for_area: an
  area contains its devices plus children that inherit the area, but not a child
  with a different explicit area. Used for area expansion, area matching and the
  area chip's split action.
- Device picker: add a searchFn that restores the nested parent/child order
  after the fuzzy search and recomputes the last visible child, so a child can
  no longer be ranked above its parent and connectors stay correct.
- Devices dashboard: derive the family group name from the family's parent
  device with the same fallback for parents and children, so an unnamed parent
  cannot end up in a different group than its children.
- Child devices card: pass the device registry so a child shows its inherited
  area.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Fix formatting in device picker row renderer

Reindent the row renderer template after it gained a block body, so Prettier
is satisfied.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Make the child device tree connector span the full row

The connector was a fixed 48px box centred in a taller two-line row, and its
SVG was letterboxed by the default preserveAspectRatio, so the dashed line
stopped short of the row edges and consecutive children never visually
connected.

Let the indicator stretch (preserveAspectRatio="none") and give it the full row
height, so the line runs edge to edge with the elbow at the vertical centre.
non-scaling-stroke keeps the line width and dash pattern identical however far
it is stretched; existing 48x48 usages render unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-13 17:45:31 +03:00
4259ddee24 Show remaining tile card features below when position is inline (#53310)
* Show remaining tile card features below when position is inline

Previously the tile card silently dropped every feature after the first
one when "features_position" was set to "inline". The first feature is
now rendered next to the name as before, and any additional features are
stacked underneath, the same way they are in "bottom" position.

The tile container gained a "features-bottom" slot for this, and the
card size and grid options now account for the extra rows.

* Add tests for hui-tile-card size and grid options calculations

* Enhance hui-tile-card to support inline feature layout and improve feature counting logic

* reduce vertical gap for inline features

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-13 15:45:00 +02:00
42 changed files with 1886 additions and 253 deletions
+35
View File
@@ -51,4 +51,39 @@ export const demoDevices: DeviceRegistryEntry[] = [
primary_config_entry: "mock-sonos",
entry_type: null,
},
{
...baseDevice,
id: "power-strip",
name: "Power strip",
manufacturer: "Acme",
model: "Smart Power Strip",
config_entries: ["mock-hue"],
primary_config_entry: "mock-hue",
entry_type: null,
},
// Child devices (logical parts of the power strip). They carry the parent's
// inherited hardware fields, mirroring how resolveChildDevices fills them in
// from the WebSocket, and reference the parent via parent_device_id.
{
...baseDevice,
id: "power-strip-outlet-1",
name: "Outlet 1",
manufacturer: "Acme",
model: "Smart Power Strip",
config_entries: ["mock-hue"],
primary_config_entry: "mock-hue",
entry_type: null,
parent_device_id: "power-strip",
},
{
...baseDevice,
id: "power-strip-outlet-2",
name: "Outlet 2",
manufacturer: "Acme",
model: "Smart Power Strip",
config_entries: ["mock-hue"],
primary_config_entry: "mock-hue",
entry_type: null,
parent_device_id: "power-strip",
},
];
@@ -152,6 +152,84 @@ const DEVICES: DeviceRegistryEntry[] = [
primary_config_entry: null,
parent_device_id: null,
},
{
area_id: "livingroom",
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_power_strip",
identifiers: [["demo", "strip1"] as [string, string]],
manufacturer: "Acme",
model: "Smart Power Strip",
model_id: null,
name_by_user: null,
name: "Power strip",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
// Child devices of the power strip. They have no area of their own and
// inherit the parent's area ("Livingroom"); the picker renders them indented
// under the parent with a tree connector.
{
area_id: null,
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_outlet_1",
identifiers: [["demo", "outlet1"] as [string, string]],
manufacturer: "Acme",
model: "Smart Power Strip",
model_id: null,
name_by_user: null,
name: "Outlet 1",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: "device_power_strip",
},
{
area_id: null,
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_outlet_2",
identifiers: [["demo", "outlet2"] as [string, string]],
manufacturer: "Acme",
model: "Smart Power Strip",
model_id: null,
name_by_user: null,
name: "Outlet 2",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: "device_power_strip",
},
];
const AREAS: DemoArea[] = [
+79 -1
View File
@@ -132,12 +132,12 @@ const ENTITIES = [
fan_modes: ["on_low", "on_high", "auto_low", "auto_high", "off"],
preset_modes: ["home", "eco", "away"],
swing_modes: ["auto", "1", "2", "3", "off"],
switch_horizontal_modes: ["auto", "4", "5", "6", "off"],
current_temperature: 23,
target_temp_high: 24,
target_temp_low: 21,
fan_mode: "auto_low",
preset_mode: "home",
swing_horizontal_modes: ["auto", "4", "5", "6", "off"],
swing_mode: "auto",
swing_horizontal_mode: "off",
supported_features:
@@ -340,6 +340,84 @@ const CONFIGS = [
features: [{ type: "fan-oscillate" }],
},
},
{
heading: "Inline features: one feature",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [{ type: "climate-hvac-modes", style: "dropdown" }],
},
},
{
heading: "Inline features: two features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
],
},
},
{
heading: "Inline features: three features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
{ type: "climate-fan-modes", style: "dropdown" },
],
},
},
{
heading: "Inline features: four features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
{ type: "climate-fan-modes", style: "dropdown" },
{ type: "climate-swing-modes", style: "dropdown" },
],
},
},
{
heading: "Inline features: five features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
{ type: "climate-fan-modes", style: "dropdown" },
{ type: "climate-swing-modes", style: "dropdown" },
{ type: "climate-swing-horizontal-modes", style: "dropdown" },
],
},
},
{
heading: "Bottom features: five features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "bottom",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
{ type: "climate-fan-modes", style: "dropdown" },
{ type: "climate-swing-modes", style: "dropdown" },
{ type: "climate-swing-horizontal-modes", style: "dropdown" },
],
},
},
] satisfies DemoCardConfig<TileCardConfig>[];
@customElement("demo-lovelace-tile-card")
+102 -38
View File
@@ -3,11 +3,13 @@ import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
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 { computeRTL } from "../../common/util/compute_rtl";
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
import {
deviceComboBoxKeys,
@@ -26,7 +28,9 @@ import "../ha-alert";
import "../ha-button";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import type { PickerComboBoxSearchFn } from "../ha-picker-combo-box";
import "../ha-svg-icon";
import "../ha-tree-indicator";
import { showDeviceReplacedDialog } from "./show-dialog-device-replaced";
export type HaDevicePickerDeviceFilterFunc = (
@@ -128,6 +132,7 @@ export class HaDevicePicker extends LitElement {
entityFilter,
excludeDevices,
value,
nested: true,
})
);
@@ -216,6 +221,34 @@ export class HaDevicePicker extends LitElement {
this.value
);
// The fuzzy search ranks matches by relevance, which would pull a child device
// above its parent (the parent often only matches through the lower-weighted
// child names). Restore the nested order from the full item list and recompute
// which child is last, so the tree connectors stay correct while searching.
private _searchFn: PickerComboBoxSearchFn<DevicePickerItem> = (
_search,
filteredItems,
allItems
) => {
const matchedIds = new Set(filteredItems.map((item) => item.id));
const ordered = allItems.filter((item) => matchedIds.has(item.id));
// Keep any items the search added that are not part of the nested list
// (for example the "no items available" placeholder or additional items).
const orderedIds = new Set(ordered.map((item) => item.id));
const extras = filteredItems.filter((item) => !orderedIds.has(item.id));
return [
...ordered.map((item, index) => {
if (!item.is_child) {
return item;
}
const nextItem = ordered[index + 1];
return { ...item, last: !nextItem || !nextItem.is_child };
}),
...extras,
];
};
private _valueRenderer = memoizeOne(
(
configEntriesLookup: Record<string, ConfigEntry>,
@@ -279,46 +312,75 @@ export class HaDevicePicker extends LitElement {
}
);
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => html`
<ha-combo-box-item type="button">
${
item.domain
? html`
<img
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => {
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
return html`
<ha-combo-box-item
type="button"
style=${
item.is_child
? "--md-list-item-leading-space: var(--ha-space-12);"
: ""
}
>
${
item.is_child
? html`<ha-tree-indicator
style=${styleMap({
width: "var(--ha-space-12)",
position: "absolute",
top: "0",
left: rtl ? undefined : "var(--ha-space-1)",
right: rtl ? "var(--ha-space-1)" : undefined,
transform: rtl ? "scaleX(-1)" : "",
})}
.end=${item.last}
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: item.domain,
type: "icon",
darkOptimized: this.hass.themes.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>
`
: nothing
}
></ha-tree-indicator>`
: nothing
}
${
item.domain
? html`
<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: item.domain,
type: "icon",
darkOptimized: this.hass.themes.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>
`
: nothing
}
<span slot="headline">${item.primary}</span>
${
item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing
}
${
item.domain_name
? html`
<div slot="trailing-supporting-text" class="domain">
${item.domain_name}
</div>
`
: nothing
}
</ha-combo-box-item>
`;
<span slot="headline">${item.primary}</span>
${
item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing
}
${
item.domain_name
? html`
<div slot="trailing-supporting-text" class="domain">
${item.domain_name}
</div>
`
: nothing
}
</ha-combo-box-item>
`;
};
protected render() {
const placeholder =
@@ -375,6 +437,8 @@ export class HaDevicePicker extends LitElement {
.value=${this.value}
.rowRenderer=${this._rowRenderer}
.getItems=${this._getItems}
.searchFn=${this._searchFn}
no-sort
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${valueRenderer}
.searchKeys=${deviceComboBoxKeys}
+11 -3
View File
@@ -143,7 +143,8 @@ export class HaControlSelect extends LitElement {
? repeat(
this.options,
(option) => option.value,
(option) => this._renderOption(option)
(option, index) =>
this._renderOption(option, index === this._tabbableIndex)
)
: nothing
}
@@ -151,7 +152,14 @@ export class HaControlSelect extends LitElement {
`;
}
private _renderOption(option: ControlSelectOption) {
/* a radio group with no selection puts its first option in the tab sequence */
private get _tabbableIndex() {
const selectedIndex =
this.options?.findIndex((option) => option.value === this.value) ?? -1;
return selectedIndex === -1 ? 0 : selectedIndex;
}
private _renderOption(option: ControlSelectOption, tabbable: boolean) {
const isSelected = this.value === option.value;
return html`
@@ -162,7 +170,7 @@ export class HaControlSelect extends LitElement {
selected: isSelected,
})}
role="radio"
tabindex=${isSelected ? "0" : "-1"}
tabindex=${tabbable ? "0" : "-1"}
.value=${option.value}
aria-checked=${isSelected ? "true" : "false"}
aria-label=${ifDefined(option.ariaLabel ?? option.label)}
+69 -19
View File
@@ -28,6 +28,7 @@ import {
type DevicePickerItem,
} from "../data/device/device_picker";
import {
devicesInEffectiveArea,
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
} from "../data/device/device_registry";
@@ -158,6 +159,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
excludeDevices,
value,
idPrefix,
nested: true,
})
);
@@ -682,9 +684,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
});
} else if (type === "area") {
Object.values(this.hass.devices).forEach((device) => {
// Splitting an area yields its effective-area devices, so a child device
// that belongs to a different area is not pulled into this area.
devicesInEffectiveArea(this.hass.devices, itemId).forEach((device) => {
if (
device.area_id === itemId &&
!this.value!.device_id?.includes(device.id) &&
deviceMeetsFilter(
device,
@@ -716,9 +719,18 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
});
} else if (type === "device") {
// Splitting a device into entities includes its child devices' entities,
// since targeting the device would target its children too.
const deviceIds = new Set([
itemId,
...Object.values(this.hass.devices)
.filter((device) => device.parent_device_id === itemId)
.map((device) => device.id),
]);
Object.values(this.hass.entities).forEach((entity) => {
if (
entity.device_id === itemId &&
entity.device_id &&
deviceIds.has(entity.device_id) &&
!this.value!.entity_id?.includes(entity.entity_id) &&
entityRegMeetsFilter(
entity,
@@ -1001,6 +1013,28 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
if (!filterType || filterType === "device") {
const selectedDeviceIds = targetValue?.device_id
? replacingDeviceId
? ensureArray(targetValue.device_id).filter(
(deviceId) => deviceId !== replacingDeviceId
)
: ensureArray(targetValue.device_id)
: undefined;
// A selected parent device already targets its children, so exclude
// those children from the picker too (mirrors selecting a floor
// removing its areas from the list).
const excludeDeviceIds = selectedDeviceIds
? [
...selectedDeviceIds,
...Object.values(this.hass.devices)
.filter(
(device) =>
device.parent_device_id !== null &&
selectedDeviceIds.includes(device.parent_device_id)
)
.map((device) => device.id),
]
: undefined;
let deviceItems = this._getDevicesMemoized(
this.hass,
configEntryLookup,
@@ -1008,26 +1042,41 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
includeDeviceClasses,
deviceFilter,
entityFilter,
targetValue?.device_id
? replacingDeviceId
? ensureArray(targetValue.device_id).filter(
(deviceId) => deviceId !== replacingDeviceId
)
: ensureArray(targetValue.device_id)
: undefined,
excludeDeviceIds,
replacingDeviceId,
`device${SEPARATOR}`
).sort(this._sortBySortingLabel);
);
// getDevices already returns child devices nested under their parent
// with the top-level devices sorted; keep that order rather than
// re-sorting by label, which would separate children from their parent.
if (searchTerm) {
// Keep the nested parent-then-children order (sort=false), matching
// the areas group; the default sorted search would reorder matches by
// relevance and pull children above their parent.
deviceItems = this._filterGroup(
"device",
deviceItems,
searchTerm,
deviceComboBoxKeys
deviceComboBoxKeys,
false
);
}
// Recompute the tree "last child" flag over the (possibly filtered)
// list so the last visible child of each parent draws its end connector.
deviceItems = deviceItems.map((item, index) => {
if (!(item as DevicePickerItem).is_child) {
return item;
}
const nextItem = deviceItems[index + 1] as
DevicePickerItem | undefined;
return {
...item,
last: !nextItem || !nextItem.is_child,
};
});
if (!filterType && deviceItems.length) {
// show group title
items.push(localize("ui.components.target-picker.type.devices"));
@@ -1245,7 +1294,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
let hasFloor = false;
let rtl = false;
let showEntityId = false;
if (type === "area" || type === "floor") {
const isChildDeviceRow =
type === "device" && !!(item as DevicePickerItem).is_child;
if (type === "area" || type === "floor" || isChildDeviceRow) {
rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
@@ -1265,13 +1316,15 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.type=${type === "empty" ? "text" : "button"}
class=${type === "empty" ? "empty" : ""}
style=${
(item as FloorComboBoxItem).type === "area" && hasFloor
((item as FloorComboBoxItem).type === "area" && hasFloor) ||
isChildDeviceRow
? "--md-list-item-leading-space: var(--ha-space-12);"
: ""
}
>
${
(item as FloorComboBoxItem).type === "area" && hasFloor
((item as FloorComboBoxItem).type === "area" && hasFloor) ||
isChildDeviceRow
? html`
<ha-tree-indicator
style=${styleMap({
@@ -1282,10 +1335,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
right: rtl ? "var(--ha-space-1)" : undefined,
transform: rtl ? "scaleX(-1)" : "",
})}
.end=${
(item as FloorComboBoxItem & { last?: boolean | undefined })
.last
}
.end=${(item as { last?: boolean }).last}
slot="start"
></ha-tree-indicator>
`
+24 -3
View File
@@ -8,10 +8,31 @@ export class HaTreeIndicator extends LitElement {
public end?: boolean = false;
protected render(): TemplateResult {
// preserveAspectRatio="none" lets the connector stretch to the host box, so
// it can span the full height of a taller row instead of being letterboxed
// to a square in the middle. non-scaling-stroke keeps the line width and
// dash pattern identical no matter how far it is stretched.
return html`
<svg width="100%" height="100%" viewBox="0 0 48 48">
<line x1="24" y1="0" x2="24" y2=${this.end ? "24" : "48"}></line>
<line x1="24" y1="24" x2="36" y2="24"></line>
<svg
width="100%"
height="100%"
viewBox="0 0 48 48"
preserveAspectRatio="none"
>
<line
x1="24"
y1="0"
x2="24"
y2=${this.end ? "24" : "48"}
vector-effect="non-scaling-stroke"
></line>
<line
x1="24"
y1="24"
x2="36"
y2="24"
vector-effect="non-scaling-stroke"
></line>
</svg>
`;
}
+51 -11
View File
@@ -1,5 +1,5 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { stopPropagation } from "../../common/dom/stop_propagation";
@@ -25,6 +25,13 @@ export class HaTileContainer extends LitElement {
@property({ attribute: false })
public actionHandlerOptions?: ActionHandlerOptions;
@state() private _hasFeatures = false;
private _handleFeaturesSlotChange(ev: Event) {
this._hasFeatures =
(ev.target as HTMLSlotElement).assignedElements().length > 0;
}
private _handleFocus(ev: FocusEvent) {
if ((ev.target as HTMLElement).matches(":focus-visible")) {
this.setAttribute("focused", "");
@@ -36,8 +43,12 @@ export class HaTileContainer extends LitElement {
}
protected render() {
const containerOrientationClass =
this.featurePosition === "inline" ? "horizontal" : "";
const isInline = this.featurePosition === "inline";
const containerClasses = {
inline: isInline,
"has-features-below": isInline && this._hasFeatures,
"fixed-height": this.fixedInfoHeight,
};
const contentClasses = {
vertical: this.vertical,
"fixed-info-height": this.fixedInfoHeight,
@@ -56,15 +67,21 @@ export class HaTileContainer extends LitElement {
<ha-ripple .disabled=${!this.interactive}></ha-ripple>
</div>
<div
class="container ${containerOrientationClass}"
class="container ${classMap(containerClasses)}"
@action=${stopPropagation}
@click=${stopPropagation}
>
<div class="content ${classMap(contentClasses)}">
<slot name="icon"></slot>
<slot name="info" id="info"></slot>
<div class="row">
<div class="content ${classMap(contentClasses)}">
<slot name="icon"></slot>
<slot name="info" id="info"></slot>
</div>
<slot name="features-inline"></slot>
</div>
<slot name="features"></slot>
<slot
name="features"
@slotchange=${this._handleFeaturesSlotChange}
></slot>
</div>
`;
}
@@ -97,7 +114,13 @@ export class HaTileContainer extends LitElement {
flex-direction: column;
flex: 1;
}
.container.horizontal {
.row {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.container.inline .row {
flex-direction: row;
}
@@ -153,13 +176,30 @@ export class HaTileContainer extends LitElement {
padding: 0 var(--ha-space-3) var(--ha-space-3) var(--ha-space-3);
}
.container.horizontal ::slotted([slot="features"]) {
.container.inline ::slotted([slot="features-inline"]) {
/* size the feature on the 6 column grid track, so it lines up with neighbouring tiles */
width: calc(50% - var(--column-gap, 0px) / 2 - var(--ha-space-3));
flex: none;
--feature-height: var(--ha-space-9);
padding: 0 var(--ha-space-3);
padding-inline-start: 0;
}
/* the inline feature keeps the icon height, unless the card reserves a row it can fill */
.container.inline:not(.has-features-below)
::slotted([slot="features-inline"]),
.container.inline:not(.fixed-height) ::slotted([slot="features-inline"]) {
--feature-height: var(--ha-space-9);
}
.container.inline.has-features-below ::slotted([slot="features"]) {
/* keep both columns under the inline feature, which sits on the grid track */
--ha-card-feature-column-gap: calc(
var(--column-gap, 0px) + var(--ha-space-3) * 2
);
--ha-card-feature-divider: 1px solid var(--ha-color-border-neutral-quiet);
--ha-card-feature-divider-inset: calc(
var(--ha-space-3) + var(--column-gap, 0px) / 2
);
}
[role="button"] {
cursor: pointer;
pointer-events: auto;
+152 -20
View File
@@ -3,6 +3,7 @@ import { computeDeviceNameDisplay } from "../../common/entity/compute_device_nam
import { computeDomain } from "../../common/entity/compute_domain";
import { getDeviceArea } from "../../common/entity/context/get_device_context";
import type { LocalizeFunc } from "../../common/translations/localize";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
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";
@@ -24,12 +25,17 @@ import {
export interface DevicePickerItem extends PickerComboBoxItem {
domain?: string;
domain_name?: string;
// Set when this device is a child rendered indented under its parent.
is_child?: boolean;
// Set on the last child of a parent so the tree connector draws its end.
last?: boolean;
}
export interface DeviceAreaLabel {
areaName?: string;
viaDeviceName?: string;
viaDeviceAreaName?: string;
parentDeviceName?: string;
}
export interface GetDevicesOptions {
@@ -41,6 +47,10 @@ export interface GetDevicesOptions {
excludeDevices?: string[];
value?: string;
idPrefix?: string;
// When set, order the result so children directly follow their parent and
// flag them for indented rendering. Requires the picker to disable its own
// sorting (no-sort) so this order is preserved.
nested?: boolean;
}
export const computeDeviceAreaLabel = (
@@ -69,6 +79,16 @@ export const computeDeviceAreaLabel = (
? computeAreaName(viaDeviceArea)
: undefined;
// A child device is a logical part of its parent. We surface the parent name
// only as a search term (below) — not in the area label, which stays the pure
// (inherited) area. The nested tree rendering communicates the relationship.
const parentDevice = device.parent_device_id
? devices[device.parent_device_id]
: undefined;
const parentDeviceName = parentDevice
? computeDeviceNameDisplay(parentDevice, localize, states)
: undefined;
const isRTL = computeRTL(language, translationMetadata.translations);
const areaName = area
@@ -77,7 +97,7 @@ export const computeDeviceAreaLabel = (
? `${viaDeviceAreaName}${isRTL ? " ◂ " : " ▸ "}${viaDeviceName}`
: viaDeviceName || undefined;
return { areaName, viaDeviceName, viaDeviceAreaName };
return { areaName, viaDeviceName, viaDeviceAreaName, parentDeviceName };
};
export const deviceComboBoxKeys: FuseWeightedKey[] = [
@@ -105,6 +125,14 @@ export const deviceComboBoxKeys: FuseWeightedKey[] = [
name: "search_labels.viaDeviceArea",
weight: 3,
},
{
name: "search_labels.parentDeviceName",
weight: 3,
},
{
name: "search_labels.childDeviceNames",
weight: 3,
},
];
export const getDevices = (
@@ -121,6 +149,7 @@ export const getDevices = (
excludeDevices,
value,
idPrefix = "",
nested,
} = options ?? {};
const devices = Object.values(hass.devices);
@@ -128,26 +157,60 @@ export const getDevices = (
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
if (
includeDomains ||
excludeDomains ||
includeDeviceClasses ||
entityFilter
) {
const filtersEntities =
includeDomains || excludeDomains || includeDeviceClasses || entityFilter;
if (filtersEntities) {
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
}
// Targeting a device also targets its child devices (a parent inherits its
// children's entities), so a device should match an entity-based filter when
// it OR any of its children has a matching entity. Build a parent -> children
// map and resolve each device's effective entity set accordingly. Nesting is
// single-level, so one hop covers it.
const filterChildrenByParent = new Map<string, DeviceRegistryEntry[]>();
if (filtersEntities) {
for (const device of devices) {
if (device.parent_device_id) {
const siblings = filterChildrenByParent.get(device.parent_device_id);
if (siblings) {
siblings.push(device);
} else {
filterChildrenByParent.set(device.parent_device_id, [device]);
}
}
}
}
const effectiveEntities = (
deviceId: string
): EntityRegistryDisplayEntry[] => {
const own = deviceEntityLookup[deviceId] ?? [];
const children = filterChildrenByParent.get(deviceId);
if (!children) {
return own;
}
const combined = [...own];
for (const child of children) {
const childEntities = deviceEntityLookup[child.id];
if (childEntities) {
combined.push(...childEntities);
}
}
return combined;
};
let inputDevices = devices.filter(
(device) => device.id === value || !device.disabled_by
);
if (includeDomains) {
inputDevices = inputDevices.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
const devEntities = effectiveEntities(device.id);
if (!devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) =>
return devEntities.some((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
});
@@ -155,11 +218,11 @@ export const getDevices = (
if (excludeDomains) {
inputDevices = inputDevices.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
const devEntities = effectiveEntities(device.id);
if (!devEntities.length) {
return true;
}
return entities.every(
return devEntities.every(
(entity) => !excludeDomains.includes(computeDomain(entity.entity_id))
);
});
@@ -173,11 +236,11 @@ export const getDevices = (
if (includeDeviceClasses) {
inputDevices = inputDevices.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
const devEntities = effectiveEntities(device.id);
if (!devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
return devEntities.some((entity) => {
const stateObj = hass.states[entity.entity_id];
if (!stateObj) {
return false;
@@ -192,8 +255,8 @@ export const getDevices = (
if (entityFilter) {
inputDevices = inputDevices.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
const devEntities = effectiveEntities(device.id);
if (!devEntities.length) {
return false;
}
return devEntities.some((entity) => {
@@ -222,7 +285,7 @@ export const getDevices = (
deviceEntityLookup[device.id]
);
const { areaName, viaDeviceName, viaDeviceAreaName } =
const { areaName, viaDeviceName, viaDeviceAreaName, parentDeviceName } =
computeDeviceAreaLabel(
device,
hass.areas,
@@ -259,10 +322,79 @@ export const getDevices = (
domainName: domainName || null,
viaDeviceName: viaDeviceName || null,
viaDeviceArea: viaDeviceAreaName || null,
parentDeviceName: parentDeviceName || null,
},
sorting_label: [primary, areaName, domainName].filter(Boolean).join("_"),
};
});
return outputDevices;
if (!nested) {
return outputDevices;
}
// Order children directly after their parent and flag them for indented
// rendering. outputDevices is 1:1 with inputDevices, so we can pair them up.
const itemByDeviceId = new Map<string, DevicePickerItem>();
inputDevices.forEach((device, index) => {
itemByDeviceId.set(device.id, outputDevices[index]);
});
const presentIds = new Set(inputDevices.map((device) => device.id));
const childrenByParent = new Map<string, DeviceRegistryEntry[]>();
const topLevel: DeviceRegistryEntry[] = [];
for (const device of inputDevices) {
const parentId = device.parent_device_id;
// A child whose parent was filtered out is shown as a top-level row.
if (parentId && presentIds.has(parentId)) {
const siblings = childrenByParent.get(parentId);
if (siblings) {
siblings.push(device);
} else {
childrenByParent.set(parentId, [device]);
}
} else {
topLevel.push(device);
}
}
const compareByName = (a: DeviceRegistryEntry, b: DeviceRegistryEntry) =>
caseInsensitiveStringCompare(
itemByDeviceId.get(a.id)!.primary,
itemByDeviceId.get(b.id)!.primary,
hass.locale.language
);
topLevel.sort(compareByName);
const ordered: DevicePickerItem[] = [];
for (const device of topLevel) {
const parentItem = itemByDeviceId.get(device.id)!;
const children = childrenByParent.get(device.id);
if (children) {
children.sort(compareByName);
// Add the children's names to the parent's search terms so a search that
// matches a child keeps the parent visible (mirrors how a floor stays
// visible when one of its areas matches).
ordered.push({
...parentItem,
search_labels: {
...parentItem.search_labels,
childDeviceNames: children
.map((child) => itemByDeviceId.get(child.id)!.primary)
.join(" "),
},
});
children.forEach((child, index) => {
ordered.push({
...itemByDeviceId.get(child.id)!,
is_child: true,
last: index === children.length - 1,
});
});
} else {
ordered.push(parentItem);
}
}
return ordered;
};
+69
View File
@@ -81,6 +81,75 @@ export type DeviceRegistryListEntry =
export const isChildDevice = (device: DeviceRegistryEntry): boolean =>
device.parent_device_id !== null;
/**
* Devices whose effective area is the given area: devices with that area, and
* child devices that inherit it because they have no area of their own. Mirrors
* core's dr.async_entries_for_area, so a child device with a different explicit
* area is not part of its parent's area.
*/
export const devicesInEffectiveArea = (
devices: Record<string, DeviceRegistryEntry>,
areaId: string
): DeviceRegistryEntry[] =>
Object.values(devices).filter((device) => {
if (device.area_id) {
return device.area_id === areaId;
}
if (device.parent_device_id) {
return devices[device.parent_device_id]?.area_id === areaId;
}
return false;
});
export interface DeviceRowItem {
device: DeviceRegistryEntry;
isChild: boolean;
// True for the last child of a parent, so the tree connector draws its end.
isLastChild: boolean;
}
/**
* Order a flat device list so each child directly follows its parent, flagging
* children for indented rendering. The incoming order of the top-level devices
* (and of the children within each parent) is preserved. A child whose parent
* is not in the list is treated as a top-level device.
*/
export const groupDevicesByParent = (
devices: DeviceRegistryEntry[]
): DeviceRowItem[] => {
const presentIds = new Set(devices.map((device) => device.id));
const childrenByParent = new Map<string, DeviceRegistryEntry[]>();
const topLevel: DeviceRegistryEntry[] = [];
for (const device of devices) {
const parentId = device.parent_device_id;
if (parentId && presentIds.has(parentId)) {
const siblings = childrenByParent.get(parentId);
if (siblings) {
siblings.push(device);
} else {
childrenByParent.set(parentId, [device]);
}
} else {
topLevel.push(device);
}
}
const result: DeviceRowItem[] = [];
for (const device of topLevel) {
result.push({ device, isChild: false, isLastChild: false });
const children = childrenByParent.get(device.id) ?? [];
children.forEach((child, index) => {
result.push({
device: child,
isChild: true,
isLastChild: index === children.length - 1,
});
});
}
return result;
};
export type DeviceEntityDisplayLookup = Record<
string,
EntityRegistryDisplayEntry[]
+55
View File
@@ -22,6 +22,7 @@ import {
import type { DateRange } from "../common/datetime/calc_date_range";
import { calcDateRange } from "../common/datetime/calc_date_range";
import { formatTime24h } from "../common/datetime/format_time";
import { DEFAULT_ENTITY_NAME } from "../common/entity/compute_entity_name_display";
import { formatNumber } from "../common/number/format_number";
import { normalizeValueBySIPrefix } from "../common/number/normalize-by-si-prefix";
import { groupBy } from "../common/util/group-by";
@@ -36,6 +37,7 @@ import type {
import {
fetchStatistics,
getDisplayUnit,
getStatisticLabel,
getStatisticMetadata,
VOLUME_UNITS,
} from "./recorder";
@@ -311,6 +313,59 @@ export interface EnergySourceByType {
export const energySourcesByType = (prefs: EnergyPreferences) =>
groupBy(prefs.energy_sources, (item) => item.type) as EnergySourceByType;
/**
* Display name of a configured statistic. A name set by the user always wins;
* otherwise the entity is named the same way the rest of the UI names
* entities, so devices sharing an entity name stay distinguishable.
* Statistics without an entity (external or removed) keep the statistic label.
*/
export const computeEnergyLabel = (
hass: HomeAssistant,
statisticId: string,
statisticsMetaData?: StatisticsMetaData,
customName?: string
): string => {
if (customName) {
return customName;
}
const stateObj = hass.states[statisticId];
if (stateObj) {
return hass.formatEntityName(stateObj, DEFAULT_ENTITY_NAME);
}
return getStatisticLabel(hass, statisticId, statisticsMetaData);
};
/**
* Device labels keyed by statistic id. Cards that show live power or flow
* key their nodes by `stat_rate` instead of `stat_consumption`; devices
* without the requested statistic are left out.
*/
export const computeEnergyDeviceLabels = (
hass: HomeAssistant,
devices: DeviceConsumptionEnergyPreference[],
statsMetadata?: Record<string, StatisticsMetaData>,
statisticKey: "stat_consumption" | "stat_rate" = "stat_consumption"
): Record<string, string> => {
const labels: Record<string, string> = {};
for (const device of devices) {
const statisticId = device[statisticKey];
if (statisticId) {
labels[statisticId] = computeEnergyLabel(
hass,
statisticId,
statsMetadata?.[statisticId],
device.name
);
}
}
return labels;
};
export interface EnergyData {
start: Date;
end?: Date;
+23 -10
View File
@@ -14,6 +14,7 @@ import type {
import type { HomeAssistant } from "../types";
import {
type DeviceRegistryEntry,
devicesInEffectiveArea,
getDeviceIntegrationLookup,
} from "./device/device_registry";
import type {
@@ -726,9 +727,10 @@ export const expandAreaTarget = (
) => {
const newEntities: string[] = [];
const newDevices: string[] = [];
Object.values(devices).forEach((device) => {
// Devices of an area are its effective-area members: a child device inheriting
// this area counts, a child with a different explicit area does not.
devicesInEffectiveArea(devices, areaId).forEach((device) => {
if (
device.area_id === areaId &&
deviceMeetsTargetSelector(
hass.states,
Object.values(entities),
@@ -790,9 +792,8 @@ export const areaMeetsTargetSelector = (
targetSelector: TargetSelector,
entitySources?: EntitySources
): boolean => {
const hasMatchingdevice = Object.values(devices).some((device) => {
if (
device.area_id === areaId &&
const hasMatchingdevice = devicesInEffectiveArea(devices, areaId).some(
(device) =>
deviceMeetsTargetSelector(
hass.states,
Object.values(entities),
@@ -800,11 +801,7 @@ export const areaMeetsTargetSelector = (
targetSelector,
entitySources
)
) {
return true;
}
return false;
});
);
if (hasMatchingdevice) {
return true;
}
@@ -846,6 +843,8 @@ export const deviceMeetsTargetSelector = (
}
}
if (targetSelector.target?.entity) {
// Only the device's own entities: a child device is reached through the
// device target itself, so a parent must not match on a child's behalf.
const entities = entityRegistry.filter(
(reg) => reg.device_id === device.id
);
@@ -1112,6 +1111,11 @@ export const resolveEntityIDs = (
const targetFloors = new Set(ensureArray(targetPickerValue.floor_id));
const targetLabels = new Set(ensureArray(targetPickerValue.label_id));
// Only a directly targeted device pulls in its child devices. Devices that are
// only reached through a label or an area must not, because core does not
// inherit labels to children and resolves areas by effective area membership.
const directDevices = new Set(targetDevices);
targetLabels.forEach((labelId) => {
const expanded = expandLabelTarget(
hass,
@@ -1143,6 +1147,15 @@ export const resolveEntityIDs = (
expanded.entities.forEach((id) => targetEntities.add(id));
});
// Targeting a device also targets its child devices, matching core's
// server-side target resolution. Only direct device targets expand this way;
// nesting is single-level, so one pass is enough.
Object.values(devices).forEach((device) => {
if (device.parent_device_id && directDevices.has(device.parent_device_id)) {
targetDevices.add(device.id);
}
});
targetDevices.forEach((deviceId) => {
const expanded = expandDeviceTarget(
hass,
+8 -4
View File
@@ -7,7 +7,10 @@ import type { CallWS, HomeAssistant } from "../types";
import type { AreaRegistryEntry } from "./area/area_registry";
import type { FloorComboBoxItem } from "./area_floor_picker";
import type { DevicePickerItem } from "./device/device_picker";
import type { DeviceRegistryEntry } from "./device/device_registry";
import {
devicesInEffectiveArea,
type DeviceRegistryEntry,
} from "./device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "./entity/entity";
import type { EntityComboBoxItem } from "./entity/entity_picker";
import type { EntityRegistryDisplayEntry } from "./entity/entity_registry";
@@ -125,9 +128,7 @@ export const areaMeetsFilter = (
entityFilter?: HaEntityPickerEntityFilterFunc,
includeSecondary = false
): boolean => {
const areaDevices = Object.values(devices).filter(
(device) => device.area_id === area.area_id
);
const areaDevices = devicesInEffectiveArea(devices, area.area_id);
if (
areaDevices.some((device) =>
@@ -178,6 +179,9 @@ export const deviceMeetsFilter = (
entityFilter?: HaEntityPickerEntityFilterFunc,
includeSecondary = false
): boolean => {
// Only the device's own entities: child devices are targeted through the
// device itself (see core's target resolution), not by making a parent match
// on behalf of a child.
const devEntities = Object.values(entities).filter(
(entity) => entity.device_id === device.id
);
@@ -0,0 +1,168 @@
import { consume } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { computeDeviceNameDisplay } from "../../../../common/entity/compute_device_name";
import { getDeviceArea } from "../../../../common/entity/context/get_device_context";
import { caseInsensitiveStringCompare } from "../../../../common/string/compare";
import "../../../../components/ha-card";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-list-item";
import { fullEntitiesContext } from "../../../../data/context";
import type { DeviceRegistryEntry } from "../../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../types";
const MAX_VISIBLE_CHILD_DEVICES = 10;
@customElement("ha-device-child-devices-card")
export class HaDeviceChildDevicesCard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public deviceId!: string;
@state() public _showAll = false;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
private _entityCounts = memoizeOne(
(entities: EntityRegistryEntry[]): Record<string, number> => {
const counts: Record<string, number> = {};
for (const entity of entities) {
if (entity.device_id) {
counts[entity.device_id] = (counts[entity.device_id] ?? 0) + 1;
}
}
return counts;
}
);
private _childDevices = memoizeOne(
(
deviceId: string,
devices: Record<string, DeviceRegistryEntry>
): DeviceRegistryEntry[] =>
Object.values(devices)
.filter((device) => device.parent_device_id === deviceId)
.sort((d1, d2) =>
caseInsensitiveStringCompare(
computeDeviceNameDisplay(d1, this.hass.localize, this.hass.states),
computeDeviceNameDisplay(d2, this.hass.localize, this.hass.states),
this.hass.locale.language
)
)
);
protected render() {
const childDevices = this._childDevices(this.deviceId, this.hass.devices);
if (childDevices.length === 0) {
return nothing;
}
const entityCounts = this._entityCounts(this._entityReg);
return html`
<ha-card>
<h1 class="card-header">
${this.hass.localize("ui.panel.config.devices.child_devices.heading")}
</h1>
${(this._showAll
? childDevices
: childDevices.slice(0, MAX_VISIBLE_CHILD_DEVICES)
).map((childDevice) => {
const area = getDeviceArea(
childDevice,
this.hass.areas,
this.hass.devices
);
const entityCount = entityCounts[childDevice.id] ?? 0;
const secondary = [
area?.name,
entityCount
? this.hass.localize(
"ui.panel.config.common.quick_links.entities",
{ count: entityCount }
)
: undefined,
]
.filter(Boolean)
.join(" • ");
return html`
<a href=${`/config/devices/device/${childDevice.id}`}>
<ha-list-item hasMeta .twoline=${!!secondary}>
${computeDeviceNameDisplay(
childDevice,
this.hass.localize,
this.hass.states
)}
${
secondary
? html`<span slot="secondary">${secondary}</span>`
: nothing
}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>
`;
})}
${
!this._showAll && childDevices.length > MAX_VISIBLE_CHILD_DEVICES
? html`
<button class="show-more" @click=${this._toggleShowAll}>
${this.hass.localize(
"ui.panel.config.devices.child_devices.show_more",
{ count: childDevices.length - MAX_VISIBLE_CHILD_DEVICES }
)}
</button>
`
: ""
}
</ha-card>
`;
}
private _toggleShowAll() {
this._showAll = !this._showAll;
}
static styles = css`
:host {
display: block;
}
.card-header {
padding-bottom: 0;
}
a {
text-decoration: none;
color: var(--primary-text-color);
}
button.show-more {
color: var(--primary-color);
text-align: left;
cursor: pointer;
background: none;
border-width: initial;
border-style: none;
border-color: initial;
border-image: initial;
padding: 16px;
font: inherit;
}
button.show-more:focus {
outline: none;
text-decoration: underline;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-device-child-devices-card": HaDeviceChildDevicesCard;
}
}
@@ -93,6 +93,27 @@ export class HaDeviceCard extends LitElement {
`
: ""
}
${
this.device.parent_device_id
? html`
<div class="extra-info">
${this.hass.localize(
"ui.panel.config.integrations.config_entry.part_of"
)}
<span class="hub"
><a
href="/config/devices/device/${
this.device.parent_device_id
}"
>${this._computeDeviceNameDisplay(
this.device.parent_device_id
)}</a
></span
>
</div>
`
: ""
}
${
this.device.via_device_id
? html`
@@ -107,6 +107,7 @@ import { createSearchParam } from "../../../common/url/search-params";
import { brandsUrl } from "../../../util/brands-url";
import { fileDownload } from "../../../util/file_download";
import "../../logbook/ha-logbook";
import "./device-detail/ha-device-child-devices-card";
import "./device-detail/ha-device-entities-card";
import "./device-detail/ha-device-info-card";
import "./device-detail/ha-device-linked-devices-card";
@@ -900,6 +901,10 @@ export class HaConfigDevicePage extends LitElement {
: ""
}
</ha-device-info-card>
<ha-device-child-devices-card
.hass=${this.hass}
.deviceId=${this.deviceId}
></ha-device-child-devices-card>
<ha-device-linked-devices-card
.hass=${this.hass}
.deviceId=${this.deviceId}
@@ -459,6 +459,15 @@ export class HaConfigDeviceDashboard extends LitElement {
? new Map(labelReg.map((label) => [label.label_id, label]))
: undefined;
// Ids of devices that have at least one child device, so a parent can be
// grouped together with its children.
const deviceIdsWithChildren = new Set<string>();
for (const dev of Object.values(devices)) {
if (dev.parent_device_id) {
deviceIdsWithChildren.add(dev.parent_device_id);
}
}
const formattedOutputDevices = outputDevices.map((device) => {
const deviceEntries = sortConfigEntries(
device.config_entries
@@ -472,6 +481,15 @@ export class HaConfigDeviceDashboard extends LitElement {
.map((lbl) => labelLookup!.get(lbl))
.filter((entry): entry is LabelRegistryEntry => entry !== undefined);
const parentDevice = device.parent_device_id
? this.hass.devices[device.parent_device_id]
: undefined;
// The device that identifies this device's family: its parent for a
// child device, itself for a device that has children.
const familyParentDevice =
parentDevice ??
(deviceIdsWithChildren.has(device.id) ? device : undefined);
const { areaName } = computeDeviceAreaLabel(
device,
this.hass.areas,
@@ -527,6 +545,29 @@ export class HaConfigDeviceDashboard extends LitElement {
"ui.panel.config.devices.data_table.no_integration"
),
domains: deviceEntries.map((entry) => entry.domain),
parent_device_name: parentDevice
? computeDeviceNameDisplay(
parentDevice,
this.hass.localize,
this.hass.states,
deviceEntityLookup[parentDevice.id]
)
: "",
// Grouping key that keeps a device with its family: children group
// under their parent's name, a parent groups under its own name, and
// standalone devices stay ungrouped. The name is always computed from
// the family's parent device with the same arguments, so a parent and
// its children can never end up in different groups. Like the area and
// floor columns, this groups on the display name rather than the id,
// because the data table renders the raw group value as its header.
device_family_name: familyParentDevice
? computeDeviceNameDisplay(
familyParentDevice,
this.hass.localize,
this.hass.states,
deviceEntityLookup[familyParentDevice.id]
)
: undefined,
firmware_version: device.sw_version || undefined,
battery_entity: [
this._batteryEntity(device.id, deviceEntityLookup),
@@ -587,6 +628,16 @@ export class HaConfigDeviceDashboard extends LitElement {
flex: 2,
minWidth: "150px",
extraTemplate: (device) => html`
${
device.parent_device_name
? html`<div style="color: var(--secondary-text-color);">
${localize(
"ui.panel.config.devices.data_table.part_of_device",
{ name: device.parent_device_name }
)}
</div>`
: nothing
}
${
device.label_entries.length
? html`
@@ -607,6 +658,19 @@ export class HaConfigDeviceDashboard extends LitElement {
groupable: true,
minWidth: "120px",
},
device_family_name: {
title: localize("ui.panel.config.devices.data_table.parent_device"),
// Keyed on the family name so grouping/sorting keeps a parent together
// with its children (grouping uses the column key directly). The cell
// only shows the parent name for child devices. Filterable stays on
// even when hidden, so searching a parent's name surfaces its children.
sortable: true,
filterable: true,
groupable: true,
defaultHidden: true,
minWidth: "120px",
template: (device) => device.parent_device_name || "",
},
manufacturer: {
title: localize("ui.panel.config.devices.data_table.manufacturer"),
sortable: true,
@@ -11,6 +11,8 @@ import { css, html, LitElement, nothing } from "lit";
import { repeat } from "lit/directives/repeat";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeAreaName } from "../../../../common/entity/compute_area_name";
import { getEntityAreaId } from "../../../../common/entity/context/get_entity_context";
import "../../../../components/ha-card";
import "../../../../components/ha-button";
import "../../../../components/ha-icon-button";
@@ -23,9 +25,11 @@ import type {
EnergyPreferencesValidation,
EnergyValidationIssue,
} from "../../../../data/energy";
import { saveEnergyPreferences } from "../../../../data/energy";
import {
computeEnergyLabel,
saveEnergyPreferences,
} from "../../../../data/energy";
import type { StatisticsMetaData } from "../../../../data/recorder";
import { getStatisticLabel } from "../../../../data/recorder";
import {
showAlertDialog,
showConfirmationDialog,
@@ -104,18 +108,7 @@ export class EnergyDeviceSettingsWater extends LitElement {
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</div>
<span class="content"
>${
device.name ||
getStatisticLabel(
this.hass,
device.stat_consumption,
this.statsMetadata?.[
device.stat_consumption
]
)
}</span
>
${this._renderName(device)}
${this._renderIssueIndicator(
this.validationResult?.device_consumption_water[
index
@@ -155,6 +148,32 @@ export class EnergyDeviceSettingsWater extends LitElement {
`;
}
private _renderName(device: DeviceConsumptionEnergyPreference) {
const name = computeEnergyLabel(
this.hass,
device.stat_consumption,
this.statsMetadata?.[device.stat_consumption],
device.name
);
const areaId = getEntityAreaId(
device.stat_consumption,
this.hass.entities,
this.hass.devices
);
const area = areaId ? this.hass.areas[areaId] : undefined;
const areaName = area ? computeAreaName(area) : undefined;
return html`
<div class="content">
<span class="label">${name}</span>
${
areaName
? html`<span class="label secondary">${areaName}</span>`
: nothing
}
</div>
`;
}
private _renderIssueIndicator(
issues: EnergyValidationIssue[] | undefined,
index: number
@@ -280,6 +299,22 @@ export class EnergyDeviceSettingsWater extends LitElement {
haStyle,
energyCardStyles,
css`
.row {
height: 58px;
}
.content {
display: flex;
flex-direction: column;
}
.label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.label.secondary {
color: var(--secondary-text-color);
font-size: 0.9em;
}
.handle {
cursor: move; /* fallback if grab cursor is unsupported */
cursor: grab;
@@ -11,6 +11,8 @@ import { css, html, LitElement, nothing } from "lit";
import { repeat } from "lit/directives/repeat";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeAreaName } from "../../../../common/entity/compute_area_name";
import { getEntityAreaId } from "../../../../common/entity/context/get_entity_context";
import "../../../../components/ha-card";
import "../../../../components/ha-button";
import "../../../../components/ha-icon-button";
@@ -23,9 +25,11 @@ import type {
EnergyPreferencesValidation,
EnergyValidationIssue,
} from "../../../../data/energy";
import { saveEnergyPreferences } from "../../../../data/energy";
import {
computeEnergyLabel,
saveEnergyPreferences,
} from "../../../../data/energy";
import type { StatisticsMetaData } from "../../../../data/recorder";
import { getStatisticLabel } from "../../../../data/recorder";
import {
showAlertDialog,
showConfirmationDialog,
@@ -104,18 +108,7 @@ export class EnergyDeviceSettings extends LitElement {
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</div>
<span class="content"
>${
device.name ||
getStatisticLabel(
this.hass,
device.stat_consumption,
this.statsMetadata?.[
device.stat_consumption
]
)
}</span
>
${this._renderName(device)}
${this._renderIssueIndicator(
this.validationResult?.device_consumption[
index
@@ -155,6 +148,32 @@ export class EnergyDeviceSettings extends LitElement {
`;
}
private _renderName(device: DeviceConsumptionEnergyPreference) {
const name = computeEnergyLabel(
this.hass,
device.stat_consumption,
this.statsMetadata?.[device.stat_consumption],
device.name
);
const areaId = getEntityAreaId(
device.stat_consumption,
this.hass.entities,
this.hass.devices
);
const area = areaId ? this.hass.areas[areaId] : undefined;
const areaName = area ? computeAreaName(area) : undefined;
return html`
<div class="content">
<span class="label">${name}</span>
${
areaName
? html`<span class="label secondary">${areaName}</span>`
: nothing
}
</div>
`;
}
private _renderIssueIndicator(
issues: EnergyValidationIssue[] | undefined,
index: number
@@ -276,6 +295,22 @@ export class EnergyDeviceSettings extends LitElement {
haStyle,
energyCardStyles,
css`
.row {
height: 58px;
}
.content {
display: flex;
flex-direction: column;
}
.label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.label.secondary {
color: var(--secondary-text-color);
font-size: 0.9em;
}
.handle {
cursor: move; /* fallback if grab cursor is unsupported */
cursor: grab;
@@ -11,9 +11,11 @@ import "../../../../components/input/ha-input";
import "./ha-energy-upstream-device-picker";
import type { HaInput } from "../../../../components/input/ha-input";
import type { DeviceConsumptionEnergyPreference } from "../../../../data/energy";
import { energyStatisticHelpUrl } from "../../../../data/energy";
import {
getStatisticLabel,
computeEnergyLabel,
energyStatisticHelpUrl,
} from "../../../../data/energy";
import {
getStatisticMetadata,
isExternalStatistic,
} from "../../../../data/recorder";
@@ -174,7 +176,7 @@ export class DialogEnergyDeviceSettingsWater
.value=${this._device?.name || ""}
.placeholder=${
this._device
? getStatisticLabel(
? computeEnergyLabel(
this.hass,
this._device.stat_consumption,
this._params?.statsMetadata?.[this._device.stat_consumption]
@@ -11,9 +11,11 @@ import "../../../../components/input/ha-input";
import "./ha-energy-upstream-device-picker";
import type { HaInput } from "../../../../components/input/ha-input";
import type { DeviceConsumptionEnergyPreference } from "../../../../data/energy";
import { energyStatisticHelpUrl } from "../../../../data/energy";
import {
getStatisticLabel,
computeEnergyLabel,
energyStatisticHelpUrl,
} from "../../../../data/energy";
import {
getStatisticMetadata,
isExternalStatistic,
} from "../../../../data/recorder";
@@ -170,7 +172,7 @@ export class DialogEnergyDeviceSettings
.value=${this._device?.name || ""}
.placeholder=${
this._device
? getStatisticLabel(
? computeEnergyLabel(
this.hass,
this._device.stat_consumption,
this._params?.statsMetadata?.[this._device.stat_consumption]
@@ -7,7 +7,6 @@ import memoizeOne from "memoize-one";
import { computeEntityNameList } from "../../../../common/entity/compute_entity_name_display";
import { computeStateName } from "../../../../common/entity/compute_state_name";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeRTL } from "../../../../common/util/compute_rtl";
import "../../../../components/entity/state-badge";
import "../../../../components/ha-combo-box-item";
import "../../../../components/ha-generic-picker";
@@ -15,6 +14,7 @@ import type { PickerComboBoxItem } from "../../../../components/ha-picker-combo-
import type { PickerValueRenderer } from "../../../../components/ha-picker-field";
import "../../../../components/ha-svg-icon";
import type { DeviceConsumptionEnergyPreference } from "../../../../data/energy";
import { computeEnergyLabel } from "../../../../data/energy";
import { domainToName } from "../../../../data/integration";
import {
getStatisticLabel,
@@ -73,20 +73,18 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
this.hass.floors
);
const isRTL = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const friendlyName = computeStateName(stateObj); // Keep this for search
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
return {
id: statisticId,
primary: name || entityName || deviceName || statisticId,
secondary,
// Match the label shown in the device list and the graphs.
primary: computeEnergyLabel(
this.hass,
statisticId,
this.statsMetadata?.[statisticId],
name
),
secondary: areaName,
stateObj,
search_labels: {
entityName: entityName || null,
@@ -10,12 +10,15 @@ import {
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { stopPropagation } from "../../../common/dom/stop_propagation";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
import { getDeviceArea } from "../../../common/entity/context/get_device_context";
import { navigate } from "../../../common/navigate";
import { computeRTL } from "../../../common/util/compute_rtl";
import "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-tree-indicator";
import {
disableConfigEntry,
type ConfigEntry,
@@ -48,6 +51,13 @@ class HaConfigEntryDeviceRow extends LitElement {
@property({ attribute: false }) public entities!: EntityRegistryEntry[];
// Rendered indented under its parent device.
@property({ type: Boolean, reflect: true, attribute: "is-child" })
public isChild = false;
// The last child of its parent, so the tree connector draws its end.
@property({ attribute: false }) public isLastChild = false;
protected render() {
const device = this.device;
@@ -55,6 +65,11 @@ class HaConfigEntryDeviceRow extends LitElement {
const area = getDeviceArea(device, this.hass.areas, this.hass.devices);
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const supportingText = [
device.model || device.sw_version || device.manufacturer,
area ? area.name : undefined,
@@ -65,6 +80,25 @@ class HaConfigEntryDeviceRow extends LitElement {
@click=${this._handleNavigateToDevice}
class=${classMap({ disabled: Boolean(device.disabled_by) })}
>
${
this.isChild
? html`<ha-tree-indicator
style=${styleMap({
position: "absolute",
// Span the full row height so consecutive children form one
// continuous line; the elbow sits at the vertical centre.
top: "0",
// Align the connector under the parent device icon; the leading
// space (and thus the icon column) is smaller in narrow mode.
left: rtl ? undefined : this.narrow ? "4px" : "44px",
right: rtl ? (this.narrow ? "4px" : "44px") : undefined,
transform: rtl ? "scaleX(-1)" : "",
})}
.end=${this.isLastChild}
slot="start"
></ha-tree-indicator>`
: nothing
}
<ha-svg-icon
.path=${
device.entry_type === "service"
@@ -351,12 +385,22 @@ class HaConfigEntryDeviceRow extends LitElement {
--md-ripple-hover-color: transparent;
--md-ripple-pressed-color: transparent;
}
:host([is-child]) ha-md-list-item {
--md-list-item-leading-space: 88px;
}
.disabled {
opacity: 0.5;
}
:host([narrow]) ha-md-list-item {
--md-list-item-leading-space: 16px;
}
:host([narrow][is-child]) ha-md-list-item {
--md-list-item-leading-space: 48px;
}
ha-tree-indicator {
width: 48px;
height: 100%;
}
.vertical-divider {
height: 100%;
width: 1px;
@@ -45,6 +45,7 @@ import {
} from "../../../data/config_entries";
import type { DiagnosticInfo } from "../../../data/diagnostics";
import { getConfigEntryDiagnosticsDownloadUrl } from "../../../data/diagnostics";
import { groupDevicesByParent } from "../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import type { IntegrationManifest } from "../../../data/integration";
import {
@@ -479,14 +480,16 @@ export class HaConfigEntryRow extends LitElement {
</ha-md-list-item>
${
this._devicesExpanded
? ownDevices.map(
(device) =>
? groupDevicesByParent(ownDevices).map(
({ device, isChild, isLastChild }) =>
html`<ha-config-entry-device-row
.hass=${this.hass}
.narrow=${this.narrow}
.entry=${item}
.device=${device}
.entities=${entities}
.isChild=${isChild}
.isLastChild=${isLastChild}
></ha-config-entry-device-row>`
)
: nothing
@@ -509,14 +512,16 @@ export class HaConfigEntryRow extends LitElement {
`
)}`
: html`
${ownDevices.map(
(device) =>
${groupDevicesByParent(ownDevices).map(
({ device, isChild, isLastChild }) =>
html`<ha-config-entry-device-row
.hass=${this.hass}
.narrow=${this.narrow}
.entry=${item}
.device=${device}
.entities=${entities}
.isChild=${isChild}
.isLastChild=${isLastChild}
></ha-config-entry-device-row>`
)}
`
@@ -15,6 +15,7 @@ import "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import type { ConfigEntry } from "../../../data/config_entries";
import { deleteSubEntry, updateSubEntry } from "../../../data/config_entries";
import { groupDevicesByParent } from "../../../data/device/device_registry";
import type { DiagnosticInfo } from "../../../data/diagnostics";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import type { IntegrationManifest } from "../../../data/integration";
@@ -191,14 +192,16 @@ class HaConfigSubEntryRow extends LitElement {
${
this._expanded
? html`
${devices.map(
(device) =>
${groupDevicesByParent(devices).map(
({ device, isChild, isLastChild }) =>
html`<ha-config-entry-device-row
.hass=${this.hass}
.narrow=${this.narrow}
.entry=${this.entry}
.device=${device}
.entities=${this.entities}
.isChild=${isChild}
.isLastChild=${isLastChild}
></ha-config-entry-device-row>`
)}
${services.map(
@@ -0,0 +1,33 @@
import type {
LovelaceCardFeatureConfig,
LovelaceCardFeaturePosition,
} from "../types";
export interface CardFeatureLayout {
inline: LovelaceCardFeatureConfig[];
below: LovelaceCardFeatureConfig[];
/** Columns filled by the below features, 0 when there are none */
columns: number;
}
const INLINE_COLUMNS = 2;
export const computeCardFeatureLayout = (
features: LovelaceCardFeatureConfig[] | undefined,
position: LovelaceCardFeaturePosition
): CardFeatureLayout => {
if (position !== "inline") {
return { inline: [], below: features ?? [], columns: 1 };
}
const inline = features?.slice(0, 1) ?? [];
const below = features?.slice(1) ?? [];
return { inline, below, columns: Math.min(below.length, INLINE_COLUMNS) };
};
export const computeCardFeatureRows = (
features: LovelaceCardFeatureConfig[] | undefined,
position: LovelaceCardFeaturePosition
): number => {
const { below, columns } = computeCardFeatureLayout(features, position);
return Math.ceil(below.length / Math.max(columns, 1));
};
@@ -1,5 +1,6 @@
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { HomeAssistant } from "../../../types";
import "./hui-card-feature";
import type {
@@ -32,22 +33,32 @@ export class HuiCardFeatures extends LitElement {
@property({ attribute: false })
public position?: LovelaceCardFeaturePosition;
@property({ type: Number, reflect: true })
public columns = 1;
protected render() {
if (!this.features) {
return nothing;
}
const lastIndex = this.features.length - 1;
const columns = Math.max(this.columns, 1);
return html`
${this.features.map(
(feature) => html`
${this.features.map((feature, index) => {
const column = index % columns;
return html`
<hui-card-feature
class=${classMap({
divided: column > 0,
wide: column === 0 && index === lastIndex,
})}
.hass=${this.hass}
.context=${this.context}
.color=${this.color}
.feature=${feature}
.position=${this.position}
></hui-card-feature>
`
)}
`;
})}
`;
}
@@ -55,20 +66,38 @@ export class HuiCardFeatures extends LitElement {
:host {
--feature-color: var(--state-icon-color);
--feature-height: 42px;
--feature-columns: 1;
--feature-border-radius: var(
--ha-card-features-border-radius,
var(--ha-border-radius-lg)
);
--feature-button-spacing: 12px;
--feature-column-gap: var(
--ha-card-feature-column-gap,
var(--ha-card-feature-gap, 12px)
);
--feature-divider-inset: var(--ha-card-feature-divider-inset, 0px);
pointer-events: none;
position: relative;
width: 100%;
display: flex;
flex-direction: column;
gap: var(--ha-card-feature-gap, 12px);
width: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--ha-card-feature-gap, 12px) var(--feature-column-gap);
box-sizing: border-box;
justify-content: space-evenly;
align-content: space-evenly;
}
:host([columns="2"]) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.wide {
grid-column: 1 / -1;
}
/* pull the divider out of the column and into the middle of the gutter */
.divided {
box-sizing: border-box;
margin-inline-start: calc(-1 * var(--feature-divider-inset));
padding-inline-start: var(--feature-divider-inset);
border-inline-start: var(--ha-card-feature-divider, none);
}
`;
}
@@ -1,7 +1,7 @@
import { consume } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import {
consumeEntityState,
@@ -202,14 +202,7 @@ class HuiMediaPlayerPlaybackCardFeature
});
}
static styles = [
cardFeatureStyles,
css`
ha-control-button-group {
overflow: hidden;
}
`,
];
static styles = cardFeatureStyles;
}
declare global {
@@ -8,13 +8,13 @@ import type {
} from "../../../../data/energy";
import {
computeConsumptionData,
computeEnergyDeviceLabels,
getSuggestedPeriod,
getSummedData,
} from "../../../../data/energy";
import type { Statistics, StatisticsMetaData } from "../../../../data/recorder";
import type { Statistics } from "../../../../data/recorder";
import {
calculateStatisticSumGrowth,
getStatisticLabel,
isExternalStatistic,
} from "../../../../data/recorder";
import type { HomeAssistant } from "../../../../types";
@@ -69,13 +69,13 @@ interface ProcessContext {
end: Date;
compareStart?: Date;
untrackedOrder: number;
deviceLabels: Record<string, string>;
}
function processDataSet(
ctx: ProcessContext,
computedStyle: CSSStyleDeclaration,
statistics: Statistics,
statisticsMetaData: Record<string, StatisticsMetaData>,
devices: DeviceConsumptionEnergyPreference[],
sorted_devices: string[],
childMap: Record<string, string[]>,
@@ -167,12 +167,7 @@ function processDataSet(
}
const name =
(source.name ||
getStatisticLabel(
ctx.hass,
source.stat_consumption,
statisticsMetaData[source.stat_consumption]
)) +
ctx.deviceLabels[source.stat_consumption] +
(source.stat_consumption in childMap
? ` (${ctx.hass.localize("ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked")})`
: "");
@@ -351,6 +346,8 @@ export function generateEnergyDevicesDetailGraphData(
const data = energyData.stats;
const compareData = energyData.statsCompare;
const devices = energyData.prefs.device_consumption;
const ctx: ProcessContext = {
hass,
config,
@@ -358,10 +355,13 @@ export function generateEnergyDevicesDetailGraphData(
end,
compareStart,
untrackedOrder,
deviceLabels: computeEnergyDeviceLabels(
hass,
devices,
energyData.statsMetadata
),
};
const devices = energyData.prefs.device_consumption;
const childMap: Record<string, string[]> = {};
devices.forEach((d) => {
if (d.included_in_stat) {
@@ -425,7 +425,6 @@ export function generateEnergyDevicesDetailGraphData(
ctx,
computedStyles,
compareData,
energyData.statsMetadata,
energyData.prefs.device_consumption,
sorted_devices,
childMap,
@@ -468,7 +467,6 @@ export function generateEnergyDevicesDetailGraphData(
ctx,
computedStyles,
data,
energyData.statsMetadata,
energyData.prefs.device_consumption,
sorted_devices,
childMap,
@@ -16,6 +16,7 @@ import "../../../../components/chart/ha-chart-tooltip-marker";
import type { EnergyData } from "../../../../data/energy";
import {
computeConsumptionData,
computeEnergyDeviceLabels,
getEnergyDataCollection,
getSummedData,
validateEnergyCollectionKey,
@@ -91,6 +92,8 @@ export class HuiEnergyDevicesGraphCard
private _compoundStats: string[] = [];
private _deviceLabels: Record<string, string> = {};
protected hassSubscribeRequiredHostProps = ["_config"];
public hassSubscribe(): UnsubscribeFunc[] {
@@ -295,9 +298,8 @@ export class HuiEnergyDevicesGraphCard
? ` (${this.hass.localize("ui.panel.lovelace.cards.energy.energy_devices_graph.untracked")})`
: "";
return (
(this._data?.prefs.device_consumption.find(
(d) => d.stat_consumption === statisticId
)?.name ||
// The untracked slice is not a statistic, so it has no label.
(this._deviceLabels[statisticId] ||
getStatisticLabel(
this.hass,
statisticId,
@@ -377,6 +379,12 @@ export class HuiEnergyDevicesGraphCard
.map((d) => d.included_in_stat)
.filter(Boolean) as string[];
this._deviceLabels = computeEnergyDeviceLabels(
this.hass,
energyData.prefs.device_consumption,
energyData.statsMetadata
);
const devices = energyData.prefs.device_consumption;
const devicesTotals: Record<string, number> = {};
devices.forEach((device) => {
@@ -8,6 +8,7 @@ import "../../../../components/ha-svg-icon";
import type { EnergyData } from "../../../../data/energy";
import {
computeConsumptionData,
computeEnergyDeviceLabels,
energySourcesByType,
getEnergyDataCollection,
getSummedData,
@@ -272,8 +273,14 @@ class HuiEnergySankeyCard
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
: 0;
const deviceLabel = (statConsumption: string, name?: string) =>
name ||
const deviceLabels = computeEnergyDeviceLabels(
this.hass,
prefs.device_consumption,
this._data.statsMetadata
);
const deviceLabel = (statConsumption: string) =>
deviceLabels[statConsumption] ||
getStatisticLabel(
this.hass,
statConsumption,
@@ -7,6 +7,7 @@ import "../../../../components/ha-card";
import "../../../../components/ha-svg-icon";
import type { EnergyData, EnergyPreferences } from "../../../../data/energy";
import {
computeEnergyDeviceLabels,
formatPowerShort,
getEnergyDataCollection,
getPowerFromState,
@@ -278,6 +279,13 @@ class HuiPowerSankeyCard
}
}
const deviceLabels = computeEnergyDeviceLabels(
this.hass,
prefs.device_consumption,
this._data.statsMetadata,
"stat_rate"
);
const {
deviceNodes,
parentLinks,
@@ -294,7 +302,7 @@ class HuiPowerSankeyCard
initialUntracked: homeNode.value,
getId: (device) => device.stat_rate,
getValue: (id) => this._getCurrentPower(id),
getLabel: (id, name) => name || this._getEntityLabel(id),
getLabel: (id) => deviceLabels[id] || this._getEntityLabel(id),
getEntityId: (id) => id,
});
links.push(...deviceLinks);
+37 -20
View File
@@ -36,6 +36,10 @@ import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import type { HomeAssistant } from "../../../types";
import "../card-features/hui-card-features";
import {
computeCardFeatureLayout,
computeCardFeatureRows,
} from "../card-features/common/feature-layout";
import type { LovelaceCardFeatureContext } from "../card-features/types";
import { actionHandler } from "../common/directives/action-handler-directive";
import { handleAction } from "../common/handle-action";
@@ -151,14 +155,12 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
}
public getCardSize(): number {
const featuresPosition =
this._config && this._featurePosition(this._config);
const displayType = this._config?.display_type || "picture";
const featuresCount = this._config?.features?.length || 0;
const featureRows = this._config ? this._featureRows(this._config) : 0;
return (
1 +
(displayType === "compact" ? (this._config?.vertical ? 1 : 0) : 2) +
(featuresPosition === "inline" ? 0 : featuresCount)
featureRows
);
}
@@ -170,13 +172,12 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
? this._featurePosition(this._config)
: "bottom";
const featuresCount = this._config?.features?.length || 0;
if (featuresCount) {
if (this._config && featuresCount) {
if (featurePosition === "inline") {
min_columns = 12;
columns = 12;
} else {
rows += featuresCount;
}
rows += this._featureRows(this._config);
}
const displayType = this._config?.display_type || "picture";
@@ -555,15 +556,13 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
return config.features_position || "bottom";
});
private _displayedFeatures = memoizeOne((config: AreaCardConfig) => {
const features = config.features || [];
const featurePosition = this._featurePosition(config);
private _featureLayout = memoizeOne((config: AreaCardConfig) =>
computeCardFeatureLayout(config.features, this._featurePosition(config))
);
if (featurePosition === "inline") {
return features.slice(0, 1);
}
return features;
});
private _featureRows = memoizeOne((config: AreaCardConfig) =>
computeCardFeatureRows(config.features, this._featurePosition(config))
);
public willUpdate(changedProps: PropertyValues) {
if (changedProps.has("_config") || this._ratio === null) {
@@ -601,7 +600,7 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
const secondary = this._computeSensorsDisplay();
const featurePosition = this._featurePosition(this._config);
const features = this._displayedFeatures(this._config);
const features = this._featureLayout(this._config);
const displayType = this._config.display_type || "picture";
@@ -620,8 +619,11 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
"--tile-color": color,
};
/* the picture takes the extra height, so only the compact type reserves a row */
const fixedInfoHeight =
this.layout === "grid" && this._config.grid_options?.rows !== "auto";
displayType === "compact" &&
this.layout === "grid" &&
this._config.grid_options?.rows !== "auto";
return html`
<ha-card style=${styleMap(style)}>
@@ -710,19 +712,34 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
.secondary=${secondary}
></ha-tile-info>
${
features.length > 0
features.inline.length > 0
? html`
<hui-card-features
slot="features"
slot="features-inline"
.hass=${this.hass}
.context=${this._featureContext}
.color=${this._config.color}
.features=${features}
.features=${features.inline}
.position=${featurePosition}
></hui-card-features>
`
: nothing
}
${
features.below.length > 0
? html`
<hui-card-features
slot="features"
.columns=${features.columns}
.hass=${this.hass}
.context=${this._featureContext}
.color=${this._config.color}
.features=${features.below}
.position=${"bottom"}
></hui-card-features>
`
: nothing
}
</ha-tile-container>
</ha-card>
`;
+32 -23
View File
@@ -22,6 +22,10 @@ import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import "../../../state-display/state-display";
import type { HomeAssistant } from "../../../types";
import "../card-features/hui-card-features";
import {
computeCardFeatureLayout,
computeCardFeatureRows,
} from "../card-features/common/feature-layout";
import type { LovelaceCardFeatureContext } from "../card-features/types";
import { findEntities } from "../common/find-entities";
import { handleAction } from "../common/handle-action";
@@ -107,14 +111,8 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
}
public getCardSize(): number {
const featuresPosition =
this._config && this._featurePosition(this._config);
const featuresCount = this._config?.features?.length || 0;
return (
1 +
(this._config?.vertical ? 1 : 0) +
(featuresPosition === "inline" ? 0 : featuresCount)
);
const featureRows = this._config ? this._featureRows(this._config) : 0;
return 1 + (this._config?.vertical ? 1 : 0) + featureRows;
}
public getGridOptions(): LovelaceGridOptions {
@@ -123,12 +121,11 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
let rows = 1;
const featurePosition = this._config && this._featurePosition(this._config);
const featuresCount = this._config?.features?.length || 0;
if (featuresCount) {
if (this._config && featuresCount) {
if (featurePosition === "inline") {
min_columns = 12;
} else {
rows += featuresCount;
}
rows += this._featureRows(this._config);
}
if (this._config?.vertical) {
@@ -234,15 +231,13 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
return config.features_position || "bottom";
});
private _displayedFeatures = memoizeOne((config: TileCardConfig) => {
const features = config.features || [];
const featurePosition = this._featurePosition(config);
private _featureLayout = memoizeOne((config: TileCardConfig) =>
computeCardFeatureLayout(config.features, this._featurePosition(config))
);
if (featurePosition === "inline") {
return features.slice(0, 1);
}
return features;
});
private _featureRows = memoizeOne((config: TileCardConfig) =>
computeCardFeatureRows(config.features, this._featurePosition(config))
);
protected render() {
if (!this._config || !this.hass) {
@@ -286,7 +281,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
: undefined;
const featurePosition = this._featurePosition(this._config);
const features = this._displayedFeatures(this._config);
const features = this._featureLayout(this._config);
const hasImage = Boolean(imageUrl);
@@ -341,14 +336,28 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
}
</ha-tile-info>
${
features.length > 0
features.inline.length > 0
? html`
<hui-card-features
slot="features"
slot="features-inline"
.hass=${this.hass}
.context=${this._featureContext}
.color=${this._config.color}
.features=${features}
.features=${features.inline}
></hui-card-features>
`
: nothing
}
${
features.below.length > 0
? html`
<hui-card-features
slot="features"
.columns=${features.columns}
.hass=${this.hass}
.context=${this._featureContext}
.color=${this._config.color}
.features=${features.below}
></hui-card-features>
`
: nothing
@@ -6,6 +6,7 @@ import { classMap } from "lit/directives/class-map";
import "../../../../components/ha-card";
import type { EnergyData } from "../../../../data/energy";
import {
computeEnergyDeviceLabels,
formatFlowRateShort,
getEnergyDataCollection,
getFlowRateFromState,
@@ -241,6 +242,13 @@ class HuiWaterFlowSankeyCard
}
}
const deviceLabels = computeEnergyDeviceLabels(
this.hass,
prefs.device_consumption_water,
this._data.statsMetadata,
"stat_rate"
);
const {
deviceNodes,
parentLinks,
@@ -257,7 +265,7 @@ class HuiWaterFlowSankeyCard
initialUntracked: effectiveTotalInflow,
getId: (device) => device.stat_rate,
getValue: (id) => this._getCurrentFlowRate(id),
getLabel: (id, name) => name || this._getEntityLabel(id),
getLabel: (id) => deviceLabels[id] || this._getEntityLabel(id),
getEntityId: (id) => id,
});
links.push(...deviceLinks);
@@ -7,6 +7,7 @@ import "../../../../components/ha-card";
import "../../../../components/ha-svg-icon";
import type { EnergyData } from "../../../../data/energy";
import {
computeEnergyDeviceLabels,
getEnergyDataCollection,
validateEnergyCollectionKey,
} from "../../../../data/energy";
@@ -215,8 +216,14 @@ class HuiWaterSankeyCard
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
: 0;
const deviceLabel = (statConsumption: string, name?: string) =>
name ||
const deviceLabels = computeEnergyDeviceLabels(
this.hass,
prefs.device_consumption_water,
this._data!.statsMetadata
);
const deviceLabel = (statConsumption: string) =>
deviceLabels[statConsumption] ||
getStatisticLabel(
this.hass,
statConsumption,
+8 -1
View File
@@ -6814,6 +6814,10 @@
"heading": "Connected devices",
"show_more": "+{count} devices not shown"
},
"child_devices": {
"heading": "Sub-devices",
"show_more": "+{count} devices not shown"
},
"linked_devices": {
"heading": "Linked devices",
"description": "These devices share hardware with this device and are managed by other integrations."
@@ -6895,6 +6899,8 @@
"manufacturer": "Manufacturer",
"model": "Model",
"integration": "Integration",
"parent_device": "Parent device",
"part_of_device": "Part of {name}",
"firmware_version": "Firmware",
"battery": "Battery",
"disabled_by": "Disabled",
@@ -7187,6 +7193,7 @@
"disable_error": "Enabling or disabling of the integration failed",
"manuf": "by {manufacturer}",
"via": "Connected via",
"part_of": "Part of",
"firmware": "Firmware: {version}",
"hardware": "Hardware: {version}",
"version": "Version {version}",
@@ -10465,7 +10472,7 @@
"bottom": "Bottom",
"bottom_description": "Displays all features stacked",
"inline": "Inline",
"inline_description": "Displays only the first feature"
"inline_description": "Displays features in two columns, starting next to the name"
},
"features_position_helper_vertical": "Always displayed at the bottom if the content layout is vertical",
"content_layout": "Content layout",
@@ -0,0 +1,66 @@
import { assert, describe, it } from "vitest";
import { devicesInEffectiveArea } from "../../src/data/device/device_registry";
import type { DeviceRegistryEntry } from "../../src/data/device/device_registry";
const device = (
partial: Partial<DeviceRegistryEntry> & { id: string }
): DeviceRegistryEntry =>
({
area_id: null,
parent_device_id: null,
...partial,
}) as DeviceRegistryEntry;
describe("devicesInEffectiveArea", () => {
it("includes devices with the area set", () => {
const devices = {
a: device({ id: "a", area_id: "kitchen" }),
b: device({ id: "b", area_id: "bedroom" }),
};
assert.deepEqual(
devicesInEffectiveArea(devices, "kitchen").map((d) => d.id),
["a"]
);
});
it("includes a child device inheriting its parent's area", () => {
const devices = {
parent: device({ id: "parent", area_id: "kitchen" }),
child: device({ id: "child", parent_device_id: "parent" }),
};
assert.deepEqual(
devicesInEffectiveArea(devices, "kitchen")
.map((d) => d.id)
.sort(),
["child", "parent"]
);
});
it("excludes a child device with a different explicit area", () => {
const devices = {
parent: device({ id: "parent", area_id: "kitchen" }),
child: device({
id: "child",
area_id: "bedroom",
parent_device_id: "parent",
}),
};
assert.deepEqual(
devicesInEffectiveArea(devices, "kitchen").map((d) => d.id),
["parent"]
);
// ...and the child belongs to its own area instead.
assert.deepEqual(
devicesInEffectiveArea(devices, "bedroom").map((d) => d.id),
["child"]
);
});
it("excludes a child whose parent has no area", () => {
const devices = {
parent: device({ id: "parent" }),
child: device({ id: "child", parent_device_id: "parent" }),
};
assert.deepEqual(devicesInEffectiveArea(devices, "kitchen"), []);
});
});
+154
View File
@@ -13,13 +13,19 @@ import {
} from "../../src/data/translation";
import {
computeConsumptionSingle,
computeEnergyLabel,
computeEnergyDeviceLabels,
formatConsumptionShort,
calculateSolarConsumedGauge,
formatPowerShort,
getNextEnergyPeriodStart,
getEnergyDefaultPeriodStorageKey,
} from "../../src/data/energy";
import type { DeviceRegistryEntry } from "../../src/data/device/device_registry";
import type { EntityRegistryDisplayEntry } from "../../src/data/entity/entity_registry";
import type { StatisticsMetaData } from "../../src/data/recorder";
import type { HomeAssistant } from "../../src/types";
import { createMockEntityState, createMockHass } from "../fixtures/hass";
const checkConsumptionResult = (
input: {
@@ -944,3 +950,151 @@ describe("getEnergyDefaultPeriodStorageKey", () => {
);
});
});
describe("computeEnergyLabel", () => {
const ENTITY_ID = "sensor.washer_energy";
const createEntry = (
entry: Partial<EntityRegistryDisplayEntry>
): EntityRegistryDisplayEntry =>
({
entity_id: ENTITY_ID,
labels: [],
...entry,
}) as EntityRegistryDisplayEntry;
const createDevice = (
device: Partial<DeviceRegistryEntry>
): DeviceRegistryEntry =>
({ id: "device1", name_by_user: null, ...device }) as DeviceRegistryEntry;
const createHass = (
friendlyName: string,
entry?: Partial<EntityRegistryDisplayEntry>,
device?: Partial<DeviceRegistryEntry>
) =>
createMockHass(
{
[ENTITY_ID]: createMockEntityState(ENTITY_ID, "1", {
friendly_name: friendlyName,
}),
},
{
entities: entry ? { [ENTITY_ID]: createEntry(entry) } : {},
devices: device ? { device1: createDevice(device) } : {},
}
);
it("composes the device and entity name", () => {
const hass = createHass(
"Washer Energy",
{ name: "Energy", device_id: "device1" },
{ name: "Washer" }
);
assert.equal(computeEnergyLabel(hass, ENTITY_ID), "Washer Energy");
});
it("uses the device name alone when the entity has no name of its own", () => {
const hass = createHass(
"Washer",
{ name: "Washer", device_id: "device1" },
{ name: "Washer" }
);
assert.equal(computeEnergyLabel(hass, ENTITY_ID), "Washer");
});
it("distinguishes entities sharing a name by their device", () => {
const hass = createHass(
"Energy",
{ name: "Energy", device_id: "device1" },
{ name: "Dishwasher" }
);
assert.equal(computeEnergyLabel(hass, ENTITY_ID), "Dishwasher Energy");
});
it("keeps a name set by the user", () => {
const hass = createHass(
"Washer Energy",
{ name: "Energy", device_id: "device1" },
{ name: "Washer" }
);
assert.equal(
computeEnergyLabel(hass, ENTITY_ID, undefined, "Laundry"),
"Laundry"
);
});
it("ignores an empty name", () => {
const hass = createHass(
"Washer Energy",
{ name: "Energy", device_id: "device1" },
{ name: "Washer" }
);
assert.equal(
computeEnergyLabel(hass, ENTITY_ID, undefined, ""),
"Washer Energy"
);
});
it("falls back to the friendly name for an entity outside the registry", () => {
const hass = createHass("Washer Energy");
assert.equal(computeEnergyLabel(hass, ENTITY_ID), "Washer Energy");
});
it("uses the statistic metadata name when there is no entity", () => {
const hass = createMockHass();
assert.equal(
computeEnergyLabel(hass, "external:solar", {
statistic_id: "external:solar",
name: "Solar production",
} as StatisticsMetaData),
"Solar production"
);
});
it("falls back to the statistic id when there is nothing to name it with", () => {
const hass = createMockHass();
assert.equal(computeEnergyLabel(hass, "external:solar"), "external:solar");
});
});
describe("computeEnergyDeviceLabels", () => {
const DEVICES = [
{
stat_consumption: "sensor.washer_energy",
stat_rate: "sensor.washer_power",
},
{ stat_consumption: "sensor.heater_energy", name: "Heater" },
];
const hass = createMockHass({
"sensor.washer_energy": createMockEntityState("sensor.washer_energy", "1", {
friendly_name: "Washer Energy",
}),
"sensor.washer_power": createMockEntityState("sensor.washer_power", "5", {
friendly_name: "Washer Power",
}),
});
it("keys labels by the consumption statistic", () => {
assert.deepEqual(computeEnergyDeviceLabels(hass, DEVICES), {
"sensor.washer_energy": "Washer Energy",
"sensor.heater_energy": "Heater",
});
});
it("keys labels by the rate statistic, skipping devices without one", () => {
assert.deepEqual(
computeEnergyDeviceLabels(hass, DEVICES, undefined, "stat_rate"),
{ "sensor.washer_power": "Washer Power" }
);
});
});
+32 -9
View File
@@ -1,9 +1,10 @@
/**
* Deterministic `HomeAssistant` stub covering exactly what the chart data
* transforms read: states, entities, locale, config, localize, and entity
* state formatting. Everything is stable across runs.
* transforms read: states, registries, locale, config, localize, and entity
* state/name formatting. Everything is stable across runs.
*/
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
import { computeEntityNameDisplay } from "../../src/common/entity/compute_entity_name_display";
import type { LocalizeFunc } from "../../src/common/translations/localize";
import {
DateFormat,
@@ -43,21 +44,43 @@ export const createMockEntityState = (
context: { id: "fixture", parent_id: null, user_id: null },
});
export const createMockHass = (states: HassEntities = {}): HomeAssistant =>
({
export const createMockHass = (
states: HassEntities = {},
registries: Partial<
Pick<HomeAssistant, "entities" | "devices" | "areas" | "floors">
> = {}
): HomeAssistant => {
const entities = registries.entities ?? {};
const devices = registries.devices ?? {};
const areas = registries.areas ?? {};
const floors = registries.floors ?? {};
return {
states,
entities: {},
devices: {},
areas: {},
floors: {},
entities,
devices,
areas,
floors,
config: demoConfig,
locale: mockLocale,
language: "en",
localize: mockLocalize,
translationMetadata: { translations: {} },
formatEntityState: (stateObj: HassEntity, state?: string) =>
state ?? stateObj.state,
formatEntityAttributeValue: (stateObj: HassEntity, attribute: string) =>
String(stateObj.attributes[attribute]),
formatEntityAttributeName: (_stateObj: HassEntity, attribute: string) =>
attribute,
}) as unknown as HomeAssistant;
formatEntityName: ((stateObj, name, options) =>
computeEntityNameDisplay(
stateObj,
name,
entities,
devices,
areas,
floors,
options
)) satisfies HomeAssistant["formatEntityName"],
} as unknown as HomeAssistant;
};
@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import {
computeCardFeatureLayout,
computeCardFeatureRows,
} from "../../../../../src/panels/lovelace/card-features/common/feature-layout";
import type { LovelaceCardFeatureConfig } from "../../../../../src/panels/lovelace/card-features/types";
const features = (count: number): LovelaceCardFeatureConfig[] =>
Array.from({ length: count }, () => ({ type: "toggle" }) as const);
describe("computeCardFeatureLayout", () => {
it("stacks every feature in bottom position", () => {
const layout = computeCardFeatureLayout(features(3), "bottom");
expect(layout.inline).toHaveLength(0);
expect(layout.below).toHaveLength(3);
expect(layout.columns).toBe(1);
});
it("handles a missing feature list", () => {
expect(computeCardFeatureLayout(undefined, "inline")).toEqual({
inline: [],
below: [],
columns: 0,
});
});
it("puts the first feature inline and stacks the rest", () => {
const configs: LovelaceCardFeatureConfig[] = [
{ type: "toggle" },
{ type: "cover-open-close" },
{ type: "cover-position" },
];
const layout = computeCardFeatureLayout(configs, "inline");
expect(layout.inline).toEqual([configs[0]]);
expect(layout.below).toEqual([configs[1], configs[2]]);
});
it("fills no column when the inline feature is alone", () => {
expect(computeCardFeatureLayout(features(1), "inline").columns).toBe(0);
});
it("fills one column for a single feature below", () => {
expect(computeCardFeatureLayout(features(2), "inline").columns).toBe(1);
});
it("caps the features below at two columns", () => {
expect(computeCardFeatureLayout(features(5), "inline").columns).toBe(2);
});
});
describe("computeCardFeatureRows", () => {
it("counts one row per feature in bottom position", () => {
const rows = [0, 1, 2, 3].map((count) =>
computeCardFeatureRows(features(count), "bottom")
);
expect(rows).toEqual([0, 1, 2, 3]);
});
it("pairs the features below the inline one", () => {
const rows = [0, 1, 2, 3, 4, 5, 6].map((count) =>
computeCardFeatureRows(features(count), "inline")
);
expect(rows).toEqual([0, 0, 1, 1, 2, 2, 3]);
});
});
@@ -0,0 +1,172 @@
import { describe, expect, it, vi } from "vitest";
import "../../../../src/panels/lovelace/cards/hui-tile-card";
import type { LovelaceCardFeatureConfig } from "../../../../src/panels/lovelace/card-features/types";
import type {
LovelaceCard,
LovelaceGridOptions,
} from "../../../../src/panels/lovelace/types";
import type { TileCardConfig } from "../../../../src/panels/lovelace/cards/types";
// getCardSize() and getGridOptions() drive how much space the tile card claims
// in masonry and sections views. In "inline" mode the first feature shares the
// name row and the remaining features are laid out two per row below it, so the
// counting differs from "bottom" mode; these tests pin that arithmetic down.
// Bundler-defined globals the card's import graph reads at eval time.
vi.hoisted(() => {
Object.assign(globalThis, {
__STATIC_PATH__: "/",
__HASS_URL__: "",
__BUILD__: "modern",
__VERSION__: "test",
__BACKWARDS_COMPAT__: false,
__SUPERVISOR__: false,
__NAMESPACE__: "frontend",
});
});
const features = (count: number): LovelaceCardFeatureConfig[] =>
Array.from(
{ length: count },
() => ({ type: "toggle" }) as LovelaceCardFeatureConfig
);
const makeCard = (config: Partial<TileCardConfig>): LovelaceCard => {
const card = document.createElement("hui-tile-card") as LovelaceCard;
card.setConfig({
type: "tile",
entity: "light.test",
...config,
} as TileCardConfig);
return card;
};
describe("hui-tile-card getCardSize", () => {
it("is 1 for a bare tile", () => {
expect(makeCard({}).getCardSize()).toBe(1);
});
it("adds a row for the vertical layout", () => {
expect(makeCard({ vertical: true }).getCardSize()).toBe(2);
});
it("counts every feature in bottom mode", () => {
expect(
makeCard({
features_position: "bottom",
features: features(3),
}).getCardSize()
).toBe(4);
});
it("pairs the features below the inline one", () => {
const sizes = [1, 2, 3, 4, 5, 6].map((count) =>
makeCard({
features_position: "inline",
features: features(count),
}).getCardSize()
);
expect(sizes).toEqual([1, 2, 2, 3, 3, 4]);
});
it("does not add rows for a single inline feature", () => {
expect(
makeCard({
features_position: "inline",
features: features(1),
}).getCardSize()
).toBe(1);
});
it("does not add rows for inline mode with no features", () => {
expect(
makeCard({
features_position: "inline",
features: features(0),
}).getCardSize()
).toBe(1);
});
it("ignores inline mode when the layout is vertical", () => {
// vertical forces bottom positioning, so all features are stacked
expect(
makeCard({
vertical: true,
features_position: "inline",
features: features(2),
}).getCardSize()
).toBe(4);
});
});
describe("hui-tile-card getGridOptions", () => {
const gridOptions = (config: Partial<TileCardConfig>): LovelaceGridOptions =>
makeCard(config).getGridOptions!();
it("is a single 6-wide row for a bare tile", () => {
expect(gridOptions({})).toEqual({
columns: 6,
rows: 1,
min_columns: 6,
min_rows: 1,
});
});
it("adds one row per feature in bottom mode", () => {
expect(
gridOptions({ features_position: "bottom", features: features(3) })
).toEqual({
columns: 6,
rows: 4,
min_columns: 6,
min_rows: 4,
});
});
it("widens to 12 columns and pairs the extra features in inline mode", () => {
expect(
gridOptions({ features_position: "inline", features: features(3) })
).toEqual({
columns: 6,
rows: 2,
min_columns: 12,
min_rows: 2,
});
});
it("adds a row per pair of features below the inline one", () => {
const rows = [1, 2, 3, 4, 5, 6].map(
(count) =>
gridOptions({ features_position: "inline", features: features(count) })
.rows
);
expect(rows).toEqual([1, 2, 2, 3, 3, 4]);
});
it("keeps a single row for one inline feature", () => {
expect(
gridOptions({ features_position: "inline", features: features(1) })
).toEqual({
columns: 6,
rows: 1,
min_columns: 12,
min_rows: 1,
});
});
it("stacks all features and narrows columns in vertical mode", () => {
// vertical forces bottom positioning and adds its own row
expect(
gridOptions({
vertical: true,
features_position: "inline",
features: features(2),
})
).toEqual({
columns: 6,
rows: 4,
min_columns: 3,
min_rows: 4,
});
});
});