Compare commits

..
Author SHA1 Message Date
Petar Petrov e4f54540c2 Add local disk mounts to storage settings
Support the new disk mount type from the Supervisor mount API:

- Model disk mounts in the mounts data layer. Only network mounts have a
  server and a port, so those move out of the shared base, and a disk
  mount carries the uuid and filesystem Supervisor resolves it to.
- Offer "Local disk" when adding storage, with the disk picked from the
  devices Supervisor reports as mountable. A device the host reports as
  write-protected forces the read-only option on, and is not offered for
  backups at all, as Supervisor refuses a read-only backup mount.
- Hide the option when Supervisor does not have the candidates endpoint,
  and explain it rather than error when no suitable disk is connected.
- Show the disk a mount already uses as fixed text when editing it,
  since Supervisor excludes a mounted device from the candidates.
- Describe disk mounts in the storage panel and the mount picker. Both
  built their secondary line from server, share and path, which a disk
  mount does not have, so it rendered as "undefined".
- Retitle the card to "Additional storage", as it is no longer limited
  to storage reached over the network.

Also fixes the mount picker dropping its mount type filter whenever a
usage was set, which reapplied the filter to the unfiltered list.
2026-08-14 16:12:13 +03:00
Bram KragtenandGitHub f23c8f0e23 Fix sub device tree indicator (#53638) 2026-08-14 09:18:06 +01:00
Petar PetrovandGitHub f578cb0526 Guard registry lookups in the target picker item row (#53637) 2026-08-14 09:55:54 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
78878897cc Update dependency typescript-eslint to v8.67.0 (#53634)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-14 06:46:42 +02:00
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
6c5ac1ea84 Add background editor to dashboard detail editor (#51644)
* Setup

* Use same slot layout

* Fill tabs

* Move content rendering to dedicated function

* Self review

* Save dashboard details before background

Co-authored-by: timmo001 <[email protected]>

* Protect strategy dashboard config

* Apply dashboard background review fixes

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-08-13 16:27:34 +03:00
Petar PetrovandGitHub 5224fa2048 Seed selector values when enabling optional service fields (#53629) 2026-08-13 15:27:07 +02:00
96 changed files with 3211 additions and 4438 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")
+1 -1
View File
@@ -210,7 +210,7 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.66.0",
"typescript-eslint": "8.67.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.10",
"webpack-stats-plugin": "1.1.3",
+103 -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,76 @@ 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",
height: "100%",
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 +438,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}
-172
View File
@@ -1,172 +0,0 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type {
LocalizeFunc,
LocalizeKeys,
} from "../common/translations/localize";
import type { YamlFieldSchema } from "../resources/yaml_field_schema";
/**
* Tooltip element rendered inside a CodeMirror hoverTooltip for YAML field
* keys in the automation / script / card YAML editors.
*
* Shows:
* - Field name (monospace)
* - "required" badge when applicable
* - Description paragraph
* - Selector type hint
* - Example value
* - Default value
*/
@customElement("ha-code-editor-yaml-hover")
export class HaCodeEditorYamlHover extends LitElement {
@property({ attribute: false }) public fieldName = "";
@property({ attribute: false }) public fieldSchema!: YamlFieldSchema;
/**
* Optional localize callback forwarded from the editor so translated
* descriptions can be rendered. When absent, strings are shown verbatim.
*/
@property({ attribute: false }) public localize?: LocalizeFunc;
render() {
const schema = this.fieldSchema;
if (!schema) return nothing;
// Built-in field descriptions are translation keys; descriptions coming
// from the backend are already translated and fall through unchanged.
const description = schema.description
? this.localize?.(schema.description as LocalizeKeys) ||
schema.description
: undefined;
const selectorType = schema.selector
? Object.keys(schema.selector)[0]
: undefined;
return html`
<div class="header">
<code class="key">${this.fieldName}</code>
${
schema.required
? html`<span class="badge required"
>${this._label("required", "required")}</span
>`
: nothing
}
${
selectorType
? html`<span class="badge type">${selectorType}</span>`
: nothing
}
</div>
${description ? html`<div class="desc">${description}</div>` : nothing}
${
schema.example != null
? html`<div class="meta">
<span class="meta-label"
>${this._label("example", "Example:")}</span
>
<code>${String(schema.example)}</code>
</div>`
: nothing
}
${
schema.default != null
? html`<div class="meta">
<span class="meta-label"
>${this._label("default", "Default:")}</span
>
<code>${String(schema.default)}</code>
</div>`
: nothing
}
`;
}
private _label(
key: "required" | "example" | "default",
fallback: string
): string {
return (
this.localize?.(`ui.components.yaml-editor.schema.${key}`) || fallback
);
}
static styles = css`
:host {
display: block;
padding: 6px 10px;
max-width: 320px;
line-height: 1.5;
font-size: 0.9em;
}
.header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
flex-wrap: wrap;
}
code.key {
font-family: var(--ha-font-family-code);
font-size: 1em;
font-weight: bold;
}
.badge {
display: inline-block;
border-radius: 4px;
padding: 0 5px;
font-size: 0.78em;
line-height: 1.6;
font-family: var(--ha-font-family-body);
}
.badge.required {
background: color-mix(
in srgb,
var(--error-color, #db4437) 15%,
transparent
);
color: var(--error-color, #db4437);
}
.badge.type {
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
color: var(--primary-text-color);
}
.desc {
color: var(--secondary-text-color);
margin-bottom: 4px;
}
.meta {
display: flex;
gap: 6px;
align-items: baseline;
color: var(--secondary-text-color);
}
.meta-label {
font-size: 0.85em;
opacity: 0.75;
flex-shrink: 0;
}
.meta code {
font-family: var(--ha-font-family-code);
font-size: 0.9em;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-code-editor-yaml-hover": HaCodeEditorYamlHover;
}
}
+94 -140
View File
@@ -32,21 +32,15 @@ import { consume } from "@lit/context";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import { getEntityContext } from "../common/entity/context/get_entity_context";
import { computeDeviceName } from "../common/entity/compute_device_name";
import { computeAreaName } from "../common/entity/compute_area_name";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { copyToClipboard } from "../common/util/copy-clipboard";
import { haStyleScrollbar } from "../resources/styles";
import {
buildEntityCompletions,
buildDeviceCompletions,
buildAreaCompletions,
buildFloorCompletions,
buildLabelCompletions,
} from "../resources/ha_completion_items";
import type {
JinjaArgType,
HassArgHoverContext,
} from "../resources/jinja_ha_completions";
import type { YamlFieldSchemaMap } from "../resources/yaml_field_schema";
import "./ha-code-editor-yaml-hover";
import type { HomeAssistant } from "../types";
import { showToast } from "../util/toast";
import { documentationUrl } from "../util/documentation-url";
@@ -95,14 +89,6 @@ export class HaCodeEditor extends ReactiveElement {
@property() public mode = "yaml";
/**
* Optional field schema for YAML mode. When set, the editor will provide
* field-aware key/value completions, hover tooltips, and linting for the
* known fields described by this map.
*/
@property({ attribute: false })
public yamlFieldSchema?: YamlFieldSchemaMap;
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@@ -182,12 +168,6 @@ export class HaCodeEditor extends ReactiveElement {
private _completionInfoDestroy?: () => void;
// Stored YAML syntax error set by setYamlError(); consumed by _yamlSyntaxLinter.
private _yamlSyntaxError: {
mark?: { position: number; line: number; column: number };
reason?: string;
} | null = null;
private _completionInfoRequest = 0;
private _completionInfoKey?: string;
@@ -222,10 +202,6 @@ export class HaCodeEditor extends ReactiveElement {
* Push a YAML parse error (or null to clear) into the lint gutter as a
* diagnostic. Avoids re-parsing the document — the caller (ha-yaml-editor)
* already has the error from its own js-yaml load() call.
*
* Stores the error and triggers forceLinting() so the yamlLintCompartment
* linter re-runs and returns it as a diagnostic — rather than calling
* setDiagnostics() which would wipe diagnostics from other linters.
*/
public setYamlError(
err: {
@@ -233,10 +209,27 @@ export class HaCodeEditor extends ReactiveElement {
reason?: string;
} | null
): void {
this._yamlSyntaxError = err;
if (this.codemirror && this._loadedCodeMirror) {
this._loadedCodeMirror.forceLinting(this.codemirror);
if (!this.codemirror || !this._loadedCodeMirror) return;
let diagnostics: {
from: number;
to: number;
severity: "error";
message: string;
}[] = [];
if (err) {
const doc = this.codemirror.state.doc;
const pos = err.mark ? Math.min(err.mark.position, doc.length) : 0;
const line = doc.lineAt(pos);
const message = `${
err.reason ||
this._i18n?.localize("ui.components.yaml-editor.error") ||
"YAML syntax error"
}${err.mark ? ` (${this._i18n?.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
diagnostics = [{ from: pos, to: line.to, severity: "error", message }];
}
this.codemirror.dispatch(
this._loadedCodeMirror.setDiagnostics(this.codemirror.state, diagnostics)
);
}
public connectedCallback() {
@@ -297,7 +290,9 @@ export class HaCodeEditor extends ReactiveElement {
effects: [
this._loadedCodeMirror!.langCompartment!.reconfigure(this._mode),
this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
this._buildYamlSyntaxLinter()
this.lint && !this.readOnly
? [this._loadedCodeMirror!.lintGutter()]
: []
),
],
});
@@ -309,23 +304,20 @@ export class HaCodeEditor extends ReactiveElement {
this._loadedCodeMirror!.EditorView!.editable.of(!this.readOnly)
),
this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
this._buildYamlSyntaxLinter()
this.lint && !this.readOnly
? [this._loadedCodeMirror!.lintGutter()]
: []
),
],
});
this._updateToolbarButtons();
}
if (changedProps.has("lint") || changedProps.has("yamlFieldSchema")) {
if (changedProps.has("lint")) {
transactions.push({
effects: this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
this._buildYamlSyntaxLinter()
),
});
}
if (changedProps.has("yamlFieldSchema") || changedProps.has("readOnly")) {
transactions.push({
effects: this._loadedCodeMirror!.yamlSchemaCompartment!.reconfigure(
this._buildSchemaLinter()
this.lint && !this.readOnly
? [this._loadedCodeMirror!.lintGutter()]
: []
),
});
}
@@ -379,65 +371,6 @@ export class HaCodeEditor extends ReactiveElement {
return this._loadedCodeMirror!.langs[this.mode];
}
private _buildSchemaLinter() {
if (!this._loadedCodeMirror || !this.yamlFieldSchema || this.readOnly) {
return [];
}
const schema = this.yamlFieldSchema;
return [
this._loadedCodeMirror.linter(
(view) =>
this._loadedCodeMirror!.haYamlLintSource(
view,
schema,
this._i18n?.localize
),
{ delay: 500 }
),
];
}
/**
* Builds the yamlLintCompartment extensions: a linter that surfaces the
* stored _yamlSyntaxError (set by setYamlError), plus the lint gutter when
* either syntax linting or schema linting is active.
*
* Using a linter() instead of setDiagnostics() means this linter's
* diagnostics are managed independently of the schema linter's diagnostics —
* they don't overwrite each other.
*/
private _buildYamlSyntaxLinter() {
if (this.readOnly) return [];
const showGutter = this.lint || !!this.yamlFieldSchema;
const extensions: Extension[] = [];
if (showGutter) {
extensions.push(this._loadedCodeMirror!.lintGutter());
}
if (this.lint) {
extensions.push(
this._loadedCodeMirror!.linter(
(view) => {
const err = this._yamlSyntaxError;
if (!err) return [];
const doc = view.state.doc;
const pos = err.mark ? Math.min(err.mark.position, doc.length) : 0;
const line = doc.lineAt(pos);
const message = `${
err.reason ||
this._i18n?.localize("ui.components.yaml-editor.error") ||
"YAML syntax error"
}${err.mark ? ` (${this._i18n?.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
return [
{ from: pos, to: line.to, severity: "error" as const, message },
];
},
{ delay: 0 }
)
);
}
return extensions;
}
private _createCodeMirror() {
if (!this._loadedCodeMirror) {
throw new Error("Cannot create editor before CodeMirror is loaded");
@@ -486,10 +419,7 @@ export class HaCodeEditor extends ReactiveElement {
this.linewrap ? this._loadedCodeMirror.EditorView.lineWrapping : []
),
this._loadedCodeMirror.yamlLintCompartment.of(
this._buildYamlSyntaxLinter()
),
this._loadedCodeMirror.yamlSchemaCompartment.of(
this._buildSchemaLinter()
this.lint && !this.readOnly ? [this._loadedCodeMirror.lintGutter()] : []
),
this._loadedCodeMirror.EditorView.updateListener.of(this._onUpdate),
this._loadedCodeMirror.tooltips({
@@ -505,21 +435,6 @@ export class HaCodeEditor extends ReactiveElement {
),
{ hoverTime: 300 }
),
...(this.mode === "yaml"
? [
this._loadedCodeMirror.hoverTooltip(
(view, pos) =>
this.yamlFieldSchema
? this._loadedCodeMirror!.haYamlHoverSource(view, pos, {
schema: this.yamlFieldSchema,
localize: this._i18n?.localize,
hassContext: this._hassArgHoverContext(),
})
: null,
{ hoverTime: 300 }
),
]
: []),
...(this.placeholder ? [placeholder(this.placeholder)] : []),
];
@@ -527,19 +442,6 @@ export class HaCodeEditor extends ReactiveElement {
const completionSources: CompletionSource[] = [
this._loadedCodeMirror.haJinjaCompletionSource,
];
if (this.mode === "yaml") {
completionSources.push(
this._loadedCodeMirror.haYamlCompletionSource(() => ({
schema: this.yamlFieldSchema,
localize: this._i18n?.localize,
states: this._states,
devices: this._registries?.devices,
areas: this._registries?.areas,
floors: this._registries?.floors,
labels: this._labels,
}))
);
}
if (this.autocompleteEntities) {
completionSources.push(this._entityCompletions.bind(this));
}
@@ -550,7 +452,6 @@ export class HaCodeEditor extends ReactiveElement {
this._loadedCodeMirror.autocompletion({
override: completionSources,
maxRenderedOptions: 10,
activateOnCompletion: (completion) => completion.type === "yaml-key",
}),
this._loadedCodeMirror.closeBrackets(),
this._loadedCodeMirror.closeBracketsOverride,
@@ -1106,9 +1007,23 @@ export class HaCodeEditor extends ReactiveElement {
});
};
private _getStates = memoizeOne((states: HassEntities): Completion[] =>
buildEntityCompletions(states)
);
private _getStates = memoizeOne((states: HassEntities): Completion[] => {
if (!states) {
return [];
}
const options = Object.keys(states).map((key) => ({
type: "variable",
label: states[key].attributes.friendly_name
? `${states[key].attributes.friendly_name} ${key}` // label is used for searching, so include both name and entity_id here
: key,
displayLabel: key,
detail: states[key].attributes.friendly_name,
apply: key,
}));
return options;
});
// Map of HA Jinja function name → (arg index → JinjaArgType).
// Derived from the snippet definitions in jinja_ha_completions.ts.
@@ -1503,7 +1418,18 @@ export class HaCodeEditor extends ReactiveElement {
private _getDevices = memoizeOne(
(devices: HomeAssistant["devices"]): Completion[] =>
buildDeviceCompletions(devices)
Object.values(devices)
.filter((device) => !device.disabled_by)
.map((device) => {
const name = computeDeviceName(device);
return {
type: "variable",
label: `${name} ${device.id}`,
displayLabel: name ?? device.id,
detail: device.id,
apply: device.id,
};
})
);
/** Build a CompletionResult for device IDs, with `from` set inside the quotes. */
@@ -1522,7 +1448,17 @@ export class HaCodeEditor extends ReactiveElement {
}
private _getAreas = memoizeOne(
(areas: HomeAssistant["areas"]): Completion[] => buildAreaCompletions(areas)
(areas: HomeAssistant["areas"]): Completion[] =>
Object.values(areas).map((area) => {
const name = computeAreaName(area) ?? area.area_id;
return {
type: "variable",
label: `${name} ${area.area_id}`, // label is used for searching, so include both name and ID here
displayLabel: name,
detail: area.area_id,
apply: area.area_id,
};
})
);
/** Build a CompletionResult for area IDs, with `from` set inside the quotes. */
@@ -1542,7 +1478,16 @@ export class HaCodeEditor extends ReactiveElement {
private _getFloors = memoizeOne(
(floors: HomeAssistant["floors"]): Completion[] =>
buildFloorCompletions(floors)
Object.values(floors).map((floor) => {
const name = computeFloorName(floor) ?? floor.floor_id;
return {
type: "variable",
label: `${name} ${floor.floor_id}`, // label is used for searching, so include both name and ID here
displayLabel: name,
detail: floor.floor_id,
apply: floor.floor_id,
};
})
);
/** Build a CompletionResult for floor IDs, with `from` set inside the quotes. */
@@ -1562,7 +1507,16 @@ export class HaCodeEditor extends ReactiveElement {
private _getLabels = memoizeOne(
(labels: LabelRegistryEntry[]): Completion[] =>
buildLabelCompletions(labels)
labels.map((label) => {
const name = label.name.trim() || label.label_id;
return {
type: "variable",
label: `${name} ${label.label_id}`, // label is used for searching, so include both name and ID here
displayLabel: name,
detail: label.label_id,
apply: label.label_id,
};
})
);
/** Build a CompletionResult for label IDs, with `from` set inside the quotes. */
+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)}
@@ -0,0 +1,25 @@
import { DEFAULT_MIN_KELVIN } from "../../common/color/convert-light-color";
import type { Selector } from "../../data/selector";
/**
* Value a selector already displays when no field value is set.
* Used when enabling an optional service/trigger/condition field.
*/
export const getSelectorFallbackValue = (selector: Selector): unknown => {
if ("constant" in selector) {
return selector.constant?.value;
}
if ("boolean" in selector) {
return false;
}
if ("number" in selector) {
return selector.number?.min ?? 0;
}
if ("color_temp" in selector) {
if (selector.color_temp?.unit === "kelvin") {
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
}
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
}
return undefined;
};
+10 -5
View File
@@ -11,6 +11,7 @@ import {
fetchSupervisorMounts,
SupervisorMountType,
SupervisorMountUsage,
supervisorMountDescription,
} from "../data/supervisor/mounts";
import type { HomeAssistant } from "../types";
import "./ha-alert";
@@ -58,9 +59,7 @@ class HaMountPicker extends LitElement {
).map((mount) => ({
value: mount.name,
label: mount.name,
secondary: `${mount.server}${mount.port ? `:${mount.port}` : ""}${
mount.type === SupervisorMountType.NFS ? mount.path : `:${mount.share}`
}`,
secondary: supervisorMountDescription(mount),
iconPath:
mount.usage === SupervisorMountUsage.MEDIA
? mdiPlayBox
@@ -108,10 +107,16 @@ class HaMountPicker extends LitElement {
private _filterMounts = memoizeOne(
(mounts: SupervisorMounts, usage: this["usage"]) => {
let filteredMounts = mounts.mounts.filter((mount) =>
[SupervisorMountType.CIFS, SupervisorMountType.NFS].includes(mount.type)
[
SupervisorMountType.CIFS,
SupervisorMountType.DISK,
SupervisorMountType.NFS,
].includes(mount.type)
);
if (usage) {
filteredMounts = mounts.mounts.filter((mount) => mount.usage === usage);
filteredMounts = filteredMounts.filter(
(mount) => mount.usage === usage
);
}
return filteredMounts.sort((mountA, mountB) => {
if (mountA.name === mounts.default_backup_mount) {
@@ -16,16 +16,13 @@ import "../ha-input-helper-text";
import "../ha-select-box";
import type { SelectBoxOption } from "../ha-select-box";
export const TRIGGER_BEHAVIORS: AutomationBehaviorTriggerMode[] = [
const TRIGGER_BEHAVIORS: AutomationBehaviorTriggerMode[] = [
"each",
"first",
"all",
];
export const CONDITION_BEHAVIORS: AutomationBehaviorConditionMode[] = [
"any",
"all",
];
const CONDITION_BEHAVIORS: AutomationBehaviorConditionMode[] = ["any", "all"];
@customElement("ha-selector-automation_behavior")
export class HaSelectorAutomationBehavior extends LitElement {
+3 -14
View File
@@ -35,6 +35,7 @@ import {
} from "../data/selector";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import { documentationUrl } from "../util/documentation-url";
import { getSelectorFallbackValue } from "./ha-form/get-selector-fallback-value";
import "./ha-checkbox";
import type { HaCheckbox } from "./ha-checkbox";
import "./ha-icon-button";
@@ -799,20 +800,8 @@ export class HaServiceControl extends LitElement {
let defaultValue = field?.default;
if (
defaultValue == null &&
field?.selector &&
"constant" in field.selector
) {
defaultValue = field.selector.constant?.value;
}
if (
defaultValue == null &&
field?.selector &&
"boolean" in field.selector
) {
defaultValue = false;
if (defaultValue == null && field?.selector) {
defaultValue = getSelectorFallbackValue(field.selector);
}
if (defaultValue != null) {
+70 -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,27 +1316,27 @@ 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({
width: "var(--ha-space-12)",
position: "absolute",
top: "0",
height: "100%",
left: rtl ? undefined : "var(--ha-space-1)",
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>
`;
}
+1 -11
View File
@@ -8,7 +8,6 @@ import { consume } from "@lit/context";
import { fireEvent } from "../common/dom/fire_event";
import { copyToClipboard } from "../common/util/copy-clipboard";
import { haStyle } from "../resources/styles";
import type { YamlFieldSchemaMap } from "../resources/yaml_field_schema";
import { showToast } from "../util/toast";
import "./ha-button";
import "./ha-code-editor";
@@ -33,14 +32,6 @@ export class HaYamlEditor extends LitElement {
@property({ attribute: false }) public yamlSchema: Schema = YAML11_SCHEMA;
/**
* Optional field schema for YAML mode. When provided, the code editor will
* offer field-aware key/value completions, hover tooltips, and linting.
* This is forwarded directly to ha-code-editor.
*/
@property({ attribute: false })
public yamlFieldSchema?: YamlFieldSchemaMap;
@property({ attribute: false }) public defaultValue?: any;
@property({ attribute: "is-valid", type: Boolean }) public isValid = true;
@@ -132,9 +123,8 @@ export class HaYamlEditor extends LitElement {
.inDialog=${this.inDialog}
mode="yaml"
lint
.autocompleteEntities=${!this.yamlFieldSchema}
autocomplete-entities
autocomplete-icons
.yamlFieldSchema=${this.yamlFieldSchema}
.error=${this.isValid === false}
@value-changed=${this._onChange}
@editor-save=${this._onEditorSave}
@@ -393,6 +393,9 @@ export class HaTargetPickerItemRow extends LitElement {
nextEntries.referenced_entities =
entries?.referenced_entities.filter((entity_id) => {
const entity = this.hass.entities[entity_id];
if (!entity) {
return false;
}
return (
entity.area_id === rowItem ||
!entity.device_id ||
@@ -416,6 +419,9 @@ export class HaTargetPickerItemRow extends LitElement {
this.type === "label" && entries
? entries.referenced_entities.filter((entity_id) => {
const entity = this.hass.entities[entity_id];
if (!entity) {
return false;
}
return (
entity.labels.includes(this.itemId) &&
!entries.referenced_devices.includes(entity.device_id || "")
@@ -424,7 +430,7 @@ export class HaTargetPickerItemRow extends LitElement {
: nextType === "device" && entries
? entries.referenced_entities.filter(
(entity_id) =>
this.hass.entities[entity_id].area_id === this.itemId
this.hass.entities[entity_id]?.area_id === this.itemId
)
: [];
@@ -433,7 +439,7 @@ export class HaTargetPickerItemRow extends LitElement {
? entries.referenced_devices.filter(
(device_id) =>
!devicesInAreas.includes(device_id) &&
this.hass.devices[device_id].labels.includes(this.itemId)
this.hass.devices[device_id]?.labels.includes(this.itemId)
)
: [];
@@ -528,6 +534,12 @@ export class HaTargetPickerItemRow extends LitElement {
entries.referenced_areas = entries.referenced_areas.filter(
(area_id) => {
const area = this.hass.areas[area_id];
// Absent from the registry is not a filter decision: drop the id
// without marking it hidden, so entities targeted through their
// own area or label are not dropped along with it.
if (!area) {
return false;
}
if (
(this.type === "floor" || area.labels.includes(this.itemId)) &&
areaMeetsFilter(
@@ -560,6 +572,9 @@ export class HaTargetPickerItemRow extends LitElement {
entries.referenced_devices = entries.referenced_devices.filter(
(device_id) => {
const device = this.hass.devices[device_id];
if (!device) {
return false;
}
if (
!hiddenAreaIds.includes(device.area_id || "") &&
deviceMeetsFilter(
@@ -585,6 +600,11 @@ export class HaTargetPickerItemRow extends LitElement {
entries.referenced_entities = entries.referenced_entities.filter(
(entity_id) => {
const entity = this.hass.entities[entity_id];
// Core can reference entities that are absent from the display
// registry (e.g. disabled ones expanded from an area).
if (!entity) {
return false;
}
if (hiddenDeviceIds.includes(entity.device_id || "")) {
return false;
}
+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;
+5 -2
View File
@@ -2,12 +2,15 @@ import type { Connection } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../../../types";
import type { LovelaceResource } from "../resource";
import type { LovelaceStrategyConfig } from "./strategy";
import type { LovelaceViewRawConfig } from "./view";
import type {
LovelaceDashboardBackgroundConfig,
LovelaceViewRawConfig,
} from "./view";
export interface LovelaceDashboardBaseConfig {}
export interface LovelaceConfig extends LovelaceDashboardBaseConfig {
background?: string;
background?: LovelaceDashboardBackgroundConfig;
views: LovelaceViewRawConfig[];
}
+4 -1
View File
@@ -31,6 +31,9 @@ export interface LovelaceViewBackgroundConfig {
attachment?: "scroll" | "fixed";
}
export type LovelaceDashboardBackgroundConfig =
string | LovelaceViewBackgroundConfig;
export interface LovelaceViewHeaderConfig {
card?: LovelaceCardConfig;
layout?: "start" | "center" | "responsive";
@@ -60,7 +63,7 @@ export interface LovelaceBaseViewConfig {
show_icon_and_title?: boolean;
theme?: string;
panel?: boolean;
background?: string | LovelaceViewBackgroundConfig;
background?: LovelaceDashboardBackgroundConfig;
visible?: boolean | ShowViewConfig[];
subview?: boolean;
back_path?: string;
+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,
+86 -6
View File
@@ -3,6 +3,7 @@ import type { HomeAssistant } from "../../types";
export enum SupervisorMountType {
BIND = "bind",
CIFS = "cifs",
DISK = "disk",
NFS = "nfs",
}
@@ -28,26 +29,40 @@ interface SupervisorMountBase {
name: string;
usage: SupervisorMountUsage;
type: SupervisorMountType;
server: string;
port: number;
read_only?: boolean;
}
export interface SupervisorMountResponse extends SupervisorMountBase {
state: SupervisorMountState | null;
}
export interface SupervisorNFSMount extends SupervisorMountResponse {
// Supervisor omits port when the mount uses the protocol default.
interface SupervisorNetworkMount extends SupervisorMountResponse {
server: string;
port?: number;
}
export interface SupervisorNFSMount extends SupervisorNetworkMount {
type: SupervisorMountType.NFS;
path: string;
}
export interface SupervisorCIFSMount extends SupervisorMountResponse {
export interface SupervisorCIFSMount extends SupervisorNetworkMount {
type: SupervisorMountType.CIFS;
share: string;
version?: CIFSVersion;
}
export type SupervisorMount = SupervisorNFSMount | SupervisorCIFSMount;
// A disk mount is identified by device on the way in, but Supervisor resolves
// that to a UUID and only ever reports uuid and filesystem back.
export interface SupervisorDiskMount extends SupervisorMountResponse {
type: SupervisorMountType.DISK;
uuid: string;
filesystem?: string;
}
export type SupervisorMount =
SupervisorNFSMount | SupervisorCIFSMount | SupervisorDiskMount;
export type SupervisorNFSMountRequestParams = SupervisorNFSMount;
@@ -57,14 +72,67 @@ export interface SupervisorCIFSMountRequestParams extends SupervisorCIFSMount {
version?: CIFSVersion;
}
interface SupervisorDiskMountRequestParamsBase {
name: string;
usage: SupervisorMountUsage;
type: SupervisorMountType.DISK;
read_only?: boolean;
}
// Supervisor accepts exactly one identifier: device when creating from a
// candidate, uuid when round-tripping a mount it already resolved.
export type SupervisorDiskMountRequestParams =
| (SupervisorDiskMountRequestParamsBase & { device: string; uuid?: never })
| (SupervisorDiskMountRequestParamsBase & { uuid: string; device?: never });
export type SupervisorMountRequestParams =
SupervisorNFSMountRequestParams | SupervisorCIFSMountRequestParams;
| SupervisorNFSMountRequestParams
| SupervisorCIFSMountRequestParams
| SupervisorDiskMountRequestParams;
export interface SupervisorMounts {
default_backup_mount: string | null;
mounts: SupervisorMount[];
}
// Null when UDisks2 cannot attribute the device to a drive, which also happens
// when the drive is unplugged between enumeration and lookup.
export interface SupervisorMountCandidateDrive {
vendor: string;
model: string;
serial: string;
id: string;
size: number;
connection_bus: string;
removable: boolean;
ejectable: boolean;
}
export interface SupervisorMountCandidate {
device: string;
uuid: string;
label: string;
filesystem: string;
size: number;
read_only: boolean;
drive: SupervisorMountCandidateDrive | null;
}
export interface SupervisorMountCandidates {
candidates: SupervisorMountCandidate[];
}
// Identifies a mount in a list row. A disk mount has no server, share or path,
// so it is described by what Supervisor does report for it.
export const supervisorMountDescription = (mount: SupervisorMount): string => {
if (mount.type === SupervisorMountType.DISK) {
return [mount.filesystem, mount.uuid].filter(Boolean).join(" • ");
}
return `${mount.server}${mount.port ? `:${mount.port}` : ""}${
mount.type === SupervisorMountType.NFS ? mount.path : `:${mount.share}`
}`;
};
export const fetchSupervisorMounts = async (
hass: HomeAssistant
): Promise<SupervisorMounts> =>
@@ -75,6 +143,18 @@ export const fetchSupervisorMounts = async (
timeout: null,
});
// Returns an empty list on a host without UDisks2. A Supervisor predating disk
// mounts answers 404, which callers use to hide the feature.
export const fetchSupervisorMountCandidates = async (
hass: HomeAssistant
): Promise<SupervisorMountCandidates> =>
hass.callWS({
type: "supervisor/api",
endpoint: `/mounts/candidates`,
method: "get",
timeout: null,
});
export const createSupervisorMount = async (
hass: HomeAssistant,
data: SupervisorMountRequestParams
+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
);
@@ -1,7 +1,6 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-yaml-editor";
@@ -9,7 +8,6 @@ import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import { COLLAPSIBLE_ACTION_ELEMENTS } from "../../../../data/action";
import { migrateAutomationAction, type Action } from "../../../../data/script";
import type { HomeAssistant } from "../../../../types";
import { actionSchemaKey, actionToYamlSchema } from "../yaml_schema_helpers";
import "../ha-automation-editor-warning";
import { editorStyles, indentStyle } from "../styles";
import {
@@ -43,14 +41,6 @@ export default class HaAutomationActionEditor extends LitElement {
@query(COLLAPSIBLE_ACTION_ELEMENTS.join(", "))
private _collapsibleElement?: ActionElement;
private _actionYamlSchema = memoizeOne(
(
actionKey: string | undefined,
services: HomeAssistant["services"],
localize: HomeAssistant["localize"]
) => actionToYamlSchema(actionKey, services, localize)
);
protected render() {
const yamlMode = this.yamlMode || !this.uiSupported;
const type = getAutomationActionType(this.action);
@@ -84,11 +74,6 @@ export default class HaAutomationActionEditor extends LitElement {
.defaultValue=${this.action}
@value-changed=${this._onYamlChange}
.readOnly=${this.disabled}
.yamlFieldSchema=${this._actionYamlSchema(
actionSchemaKey(this.action),
this.hass.services,
this.hass.localize
)}
></ha-yaml-editor>
`
: html`
@@ -370,6 +370,7 @@ export class HaAutomationAddSearch extends LitElement {
width: "var(--ha-space-12)",
position: "absolute",
top: "0",
height: "100%",
left: rtl ? undefined : "var(--ha-space-1)",
right: rtl ? "var(--ha-space-1)" : undefined,
transform: rtl ? "scaleX(-1)" : "",
@@ -11,10 +11,6 @@ import { expandConditionWithShorthand } from "../../../../data/automation";
import type { ConditionDescription } from "../../../../data/condition";
import { COLLAPSIBLE_CONDITION_ELEMENTS } from "../../../../data/condition";
import type { HomeAssistant } from "../../../../types";
import {
builtInConditionSchema,
conditionDescriptionToSchema,
} from "../yaml_schema_helpers";
import "../ha-automation-editor-warning";
import { editorStyles, indentStyle } from "../styles";
import type { ConditionElement } from "./ha-automation-condition-row";
@@ -48,22 +44,6 @@ export default class HaAutomationConditionEditor extends LitElement {
@query(COLLAPSIBLE_CONDITION_ELEMENTS.join(", "))
private _collapsibleElement?: ConditionElement;
// Memoized on the condition type rather than the condition itself: the
// condition object gets a new identity on every keystroke, and the schema
// only depends on its type.
private _conditionYamlSchema = memoizeOne(
(
conditionType: string,
description: ConditionDescription | undefined,
localize: HomeAssistant["localize"]
) => {
if (!description) {
return builtInConditionSchema(conditionType, localize);
}
return conditionDescriptionToSchema(conditionType, description, localize);
}
);
private _processedCondition = memoizeOne((condition) =>
expandConditionWithShorthand(condition)
);
@@ -102,11 +82,6 @@ export default class HaAutomationConditionEditor extends LitElement {
.defaultValue=${this.condition}
@value-changed=${this._onYamlChange}
.readOnly=${this.disabled}
.yamlFieldSchema=${this._conditionYamlSchema(
condition.condition,
this.description,
this.hass.localize
)}
></ha-yaml-editor>
`
: html`
@@ -32,54 +32,6 @@ const numericStateConditionStruct = object({
enabled: optional(boolean()),
});
export const YAML_SCHEMA = [
{ name: "entity_id", required: true, selector: { entity: {} } },
{
name: "attribute",
selector: { attribute: { hide_attributes: NON_NUMERIC_ATTRIBUTES } },
context: { filter_entity: "entity_id" },
},
{
name: "above",
selector: {
number: {
mode: "box",
min: Number.MIN_SAFE_INTEGER,
max: Number.MAX_SAFE_INTEGER,
step: 0.1,
},
},
},
{
name: "below",
selector: {
number: {
mode: "box",
min: Number.MIN_SAFE_INTEGER,
max: Number.MAX_SAFE_INTEGER,
step: 0.1,
},
},
},
{ name: "value_template", selector: { template: {} } },
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string => {
switch (fieldName) {
case "entity_id":
return localize("ui.components.entity.entity-picker.entity");
case "attribute":
return localize("ui.components.entity.entity-attribute-picker.attribute");
default:
return localize(
`ui.panel.config.automation.editor.triggers.type.numeric_state.${fieldName}` as any
);
}
};
@customElement("ha-automation-condition-numeric_state")
export default class HaNumericStateCondition extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -290,7 +242,20 @@ export default class HaNumericStateCondition extends LitElement {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string => computeLabel(schema.name, this.hass.localize);
): string => {
switch (schema.name) {
case "entity_id":
return this.hass.localize("ui.components.entity.entity-picker.entity");
case "attribute":
return this.hass.localize(
"ui.components.entity.entity-attribute-picker.attribute"
);
default:
return this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.numeric_state.${schema.name}`
);
}
};
static styles = css`
:host {
@@ -7,6 +7,7 @@ import { createDurationData } from "../../../../../common/datetime/create_durati
import { durationDataToSeconds } from "../../../../../common/datetime/duration_to_seconds";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../../common/dom/stop_propagation";
import { getSelectorFallbackValue } from "../../../../../components/ha-form/get-selector-fallback-value";
import "../../../../../components/ha-checkbox";
import "../../../../../components/ha-selector/ha-selector";
import "../../../../../components/ha-settings-row";
@@ -429,20 +430,8 @@ export class HaPlatformCondition extends LitElement {
Object.entries(this.description).find(([k, _value]) => k === key)?.[1];
let defaultValue = field?.default;
if (
defaultValue == null &&
field?.selector &&
"constant" in field.selector
) {
defaultValue = field.selector.constant?.value;
}
if (
defaultValue == null &&
field?.selector &&
"boolean" in field.selector
) {
defaultValue = false;
if (defaultValue == null && field?.selector) {
defaultValue = getSelectorFallbackValue(field.selector);
}
if (defaultValue != null) {
@@ -19,7 +19,6 @@ import "../../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
import type { StateCondition } from "../../../../../data/automation";
import { STATE_CONDITION_HIDDEN_ATTRIBUTES } from "../../../../../data/entity/entity_attributes";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { HomeAssistant } from "../../../../../types";
import { forDictStruct } from "../../structs";
import type { ConditionElement } from "../ha-automation-condition-row";
@@ -35,7 +34,7 @@ const stateConditionStruct = object({
enabled: optional(boolean()),
});
export const SCHEMA = [
const SCHEMA = [
{ name: "entity_id", required: true, selector: { entity: {} } },
{
name: "attribute",
@@ -62,26 +61,6 @@ export const SCHEMA = [
{ name: "for", selector: { duration: {} } },
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string => {
switch (fieldName) {
case "entity_id":
return localize("ui.components.entity.entity-picker.entity");
case "attribute":
return localize("ui.components.entity.entity-attribute-picker.attribute");
case "for":
return localize(
"ui.panel.config.automation.editor.triggers.type.state.for"
);
default:
return localize(
`ui.panel.config.automation.editor.conditions.type.state.${fieldName}` as any
);
}
};
@customElement("ha-automation-condition-state")
export class HaStateCondition extends LitElement implements ConditionElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -146,7 +125,24 @@ export class HaStateCondition extends LitElement implements ConditionElement {
private _computeLabelCallback = (
schema: SchemaUnion<typeof SCHEMA>
): string => computeLabel(schema.name, this.hass.localize);
): string => {
switch (schema.name) {
case "entity_id":
return this.hass.localize("ui.components.entity.entity-picker.entity");
case "attribute":
return this.hass.localize(
"ui.components.entity.entity-attribute-picker.attribute"
);
case "for":
return this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.state.for`
);
default:
return this.hass.localize(
`ui.panel.config.automation.editor.conditions.type.state.${schema.name}`
);
}
};
static styles = css`
:host {
@@ -14,29 +14,6 @@ type FormType = "before" | "after" | "between";
const BEFORE_DEFAULT = "sunrise";
const AFTER_DEFAULT = "sunset";
export const YAML_SCHEMA = [
{
name: "before",
type: "select" as const,
options: [["sunrise", "sunrise"] as const, ["sunset", "sunset"] as const],
},
{ name: "before_offset", selector: { duration: { allow_negative: true } } },
{
name: "after",
type: "select" as const,
options: [["sunrise", "sunrise"] as const, ["sunset", "sunset"] as const],
},
{ name: "after_offset", selector: { duration: { allow_negative: true } } },
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.conditions.type.sun.${fieldName}` as any
);
@customElement("ha-automation-condition-sun")
export class HaSunCondition extends LitElement implements ConditionElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -177,7 +154,10 @@ export class HaSunCondition extends LitElement implements ConditionElement {
private _computeLabelCallback = (schema: {
name: "before" | "after";
}): string => computeLabel(schema.name, this.hass.localize);
}): string =>
this.hass.localize(
`ui.panel.config.automation.editor.conditions.type.sun.${schema.name}`
);
private _typeSelected(ev: HaSelectSelectEvent): void {
const value = ev.detail.value as FormType;
@@ -5,20 +5,11 @@ import type { HomeAssistant } from "../../../../../types";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
import "../../../../../components/ha-form/ha-form";
import { fireEvent } from "../../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
export const SCHEMA = [
const SCHEMA = [
{ name: "value_template", required: true, selector: { template: {} } },
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.conditions.type.template.${fieldName}` as any
);
@customElement("ha-automation-condition-template")
export class HaTemplateCondition extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -52,7 +43,10 @@ export class HaTemplateCondition extends LitElement {
private _computeLabelCallback = (
schema: SchemaUnion<typeof SCHEMA>
): string => computeLabel(schema.name, this.hass.localize);
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.conditions.type.template.${schema.name}`
);
}
declare global {
@@ -13,24 +13,6 @@ import type { ConditionElement } from "../ha-automation-condition-row";
const DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
export const YAML_SCHEMA = [
{ name: "after", selector: { time: {} } },
{ name: "before", selector: { time: {} } },
{
name: "weekday",
type: "multi_select" as const,
options: DAYS.map((d) => [d, d] as const),
},
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.conditions.type.time.${fieldName}` as any
);
@customElement("ha-automation-condition-time")
export class HaTimeCondition extends LitElement implements ConditionElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -202,7 +184,10 @@ export class HaTimeCondition extends LitElement implements ConditionElement {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string => computeLabel(schema.name, this.hass.localize);
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.conditions.type.time.${schema.name}`
);
}
declare global {
@@ -13,7 +13,6 @@ import {
type Trigger,
type TriggerCondition,
} from "../../../../../data/automation";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { HomeAssistant } from "../../../../../types";
const getTriggersIds = (triggers: Trigger[]): string[] => {
@@ -23,20 +22,6 @@ const getTriggersIds = (triggers: Trigger[]): string[] => {
return Array.from(new Set(triggerIds));
};
// Static YAML schema — trigger IDs are dynamic at runtime, so we use a
// plain text selector here to at least provide key completion.
export const YAML_SCHEMA = [
{ name: "id", required: true, selector: { text: { multiple: true } } },
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.conditions.type.trigger.${fieldName}` as any
);
@customElement("ha-automation-condition-trigger")
export class HaTriggerCondition extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -109,7 +94,10 @@ export class HaTriggerCondition extends LitElement {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string => computeLabel(schema.name, this.hass.localize);
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.conditions.type.trigger.${schema.name}`
);
private _automationUpdated(config?: AutomationConfig) {
this._triggerIds = config?.triggers
@@ -2,7 +2,6 @@ import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-yaml-editor";
@@ -14,10 +13,6 @@ import type { TriggerDescription } from "../../../../data/trigger";
import { isTriggerList } from "../../../../data/trigger";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import {
builtInTriggerSchema,
triggerDescriptionToSchema,
} from "../yaml_schema_helpers";
import "../ha-automation-editor-warning";
import "./types/ha-automation-trigger-platform";
@@ -42,23 +37,6 @@ export default class HaAutomationTriggerEditor extends LitElement {
@query("ha-yaml-editor") public yamlEditor?: HaYamlEditor;
// Memoized on the trigger type rather than the trigger itself: the trigger
// object gets a new identity on every keystroke, and the schema only depends
// on its type.
private _triggerYamlSchema = memoizeOne(
(
triggerType: string | undefined,
description: TriggerDescription | undefined,
localize: HomeAssistant["localize"]
) => {
if (triggerType === undefined) return undefined;
if (!description) {
return builtInTriggerSchema(triggerType, localize);
}
return triggerDescriptionToSchema(triggerType, description, localize);
}
);
protected render() {
const type = isTriggerList(this.trigger) ? "list" : this.trigger.trigger;
@@ -94,13 +72,6 @@ export default class HaAutomationTriggerEditor extends LitElement {
<ha-yaml-editor
.defaultValue=${this.trigger}
.readOnly=${this.disabled}
.yamlFieldSchema=${this._triggerYamlSchema(
isTriggerList(this.trigger)
? undefined
: this.trigger.trigger,
this.description,
this.hass.localize
)}
@value-changed=${this._onYamlChange}
></ha-yaml-editor>
`
@@ -11,37 +11,6 @@ import { createDurationData } from "../../../../../common/datetime/create_durati
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string => {
switch (fieldName) {
case "entity_id":
return localize("ui.components.entity.entity-picker.entity");
case "event":
return localize(
"ui.panel.config.automation.editor.triggers.type.calendar.event"
);
default:
return "";
}
};
export const YAML_SCHEMA = [
{
name: "entity_id",
required: true,
selector: { entity: { domain: "calendar" } },
},
{
name: "event",
type: "select" as const,
required: true,
options: [["start", "start"] as const, ["end", "end"] as const],
},
{ name: "offset", selector: { text: {} } },
] as const;
@customElement("ha-automation-trigger-calendar")
export class HaCalendarTrigger extends LitElement implements TriggerElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -156,7 +125,17 @@ export class HaCalendarTrigger extends LitElement implements TriggerElement {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string => computeLabel(schema.name, this.hass.localize);
): string => {
switch (schema.name) {
case "entity_id":
return this.hass.localize("ui.components.entity.entity-picker.entity");
case "event":
return this.hass.localize(
"ui.panel.config.automation.editor.triggers.type.calendar.event"
);
}
return "";
};
}
declare global {
@@ -8,25 +8,6 @@ import type { HomeAssistant } from "../../../../../types";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.triggers.type.geo_location.${fieldName}` as any
);
export const YAML_SCHEMA = [
{ name: "source", selector: { text: {} } },
{ name: "zone", selector: { entity: { domain: "zone" } } },
{
name: "event",
type: "select" as const,
required: true,
options: [["enter", "enter"] as const, ["leave", "leave"] as const],
},
] as const;
@customElement("ha-automation-trigger-geo_location")
export class HaGeolocationTrigger extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -92,7 +73,10 @@ export class HaGeolocationTrigger extends LitElement {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string => computeLabel(schema.name, this.hass.localize);
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.geo_location.${schema.name}`
);
}
declare global {
@@ -8,23 +8,6 @@ import type { HomeAssistant } from "../../../../../types";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.triggers.type.homeassistant.${fieldName}` as any
);
export const YAML_SCHEMA = [
{
name: "event",
type: "select" as const,
required: true,
options: [["start", "start"] as const, ["shutdown", "shutdown"] as const],
},
] as const;
@customElement("ha-automation-trigger-homeassistant")
export class HaHassTrigger extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -86,7 +69,10 @@ export class HaHassTrigger extends LitElement {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string => computeLabel(schema.name, this.hass.localize);
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.homeassistant.${schema.name}`
);
static styles = css`
label {
@@ -8,7 +8,6 @@ import { hasTemplate } from "../../../../../common/string/has-template";
import "../../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
import type { NumericStateTrigger } from "../../../../../data/automation";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { HomeAssistant } from "../../../../../types";
const SCHEMA = [
@@ -184,63 +183,6 @@ const SCHEMA = [
},
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string => {
switch (fieldName) {
case "entity_id":
return localize("ui.components.entity.entity-picker.entity");
case "attribute":
return localize("ui.components.entity.entity-attribute-picker.attribute");
case "for":
return localize(
"ui.panel.config.automation.editor.triggers.type.state.for"
);
default:
return localize(
`ui.panel.config.automation.editor.triggers.type.numeric_state.${fieldName}` as any
);
}
};
export const YAML_SCHEMA = [
{
name: "entity_id",
required: true,
selector: { entity: { multiple: true } },
},
{
name: "attribute",
selector: { attribute: {} },
context: { filter_entity: "entity_id" },
},
{
name: "above",
selector: {
number: {
mode: "box",
min: Number.MIN_SAFE_INTEGER,
max: Number.MAX_SAFE_INTEGER,
step: 0.1,
},
},
},
{
name: "below",
selector: {
number: {
mode: "box",
min: Number.MIN_SAFE_INTEGER,
max: Number.MAX_SAFE_INTEGER,
step: 0.1,
},
},
},
{ name: "value_template", selector: { template: {} } },
{ name: "for", selector: { duration: {} } },
] as const;
@customElement("ha-automation-trigger-numeric_state")
export class HaNumericStateTrigger extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -334,7 +276,24 @@ export class HaNumericStateTrigger extends LitElement {
private _computeLabelCallback = (
schema: SchemaUnion<typeof SCHEMA>
): string => computeLabel(schema.name, this.hass.localize);
): string => {
switch (schema.name) {
case "entity_id":
return this.hass.localize("ui.components.entity.entity-picker.entity");
case "attribute":
return this.hass.localize(
"ui.components.entity.entity-attribute-picker.attribute"
);
case "for":
return this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.state.for`
);
default:
return this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.numeric_state.${schema.name}`
);
}
};
static styles = css`
:host {
@@ -12,31 +12,9 @@ import type { PersistentNotificationTrigger } from "../../../../../data/automati
import type { HomeAssistant } from "../../../../../types";
import type { TriggerElement } from "../ha-automation-trigger-row";
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.triggers.type.persistent_notification.${fieldName}` as any
);
const DEFAULT_UPDATE_TYPES = ["added", "removed"];
const DEFAULT_NOTIFICATION_ID = "";
export const YAML_SCHEMA = [
{ name: "notification_id", selector: { text: {} } },
{
name: "update_type",
type: "multi_select" as const,
options: [
["added", "added"] as const,
["removed", "removed"] as const,
["current", "current"] as const,
["updated", "updated"] as const,
],
},
] as const;
@customElement("ha-automation-trigger-persistent_notification")
export class HaPersistentNotificationTrigger
extends LitElement
@@ -121,7 +99,10 @@ export class HaPersistentNotificationTrigger
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string => computeLabel(schema.name, this.hass.localize);
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.persistent_notification.${schema.name}`
);
}
declare global {
@@ -4,6 +4,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { getSelectorFallbackValue } from "../../../../../components/ha-form/get-selector-fallback-value";
import "../../../../../components/ha-checkbox";
import "../../../../../components/ha-selector/ha-selector";
import "../../../../../components/ha-settings-row";
@@ -422,20 +423,8 @@ export class HaPlatformTrigger extends LitElement {
Object.entries(this.description).find(([k, _value]) => k === key)?.[1];
let defaultValue = field?.default;
if (
defaultValue == null &&
field?.selector &&
"constant" in field.selector
) {
defaultValue = field.selector.constant?.value;
}
if (
defaultValue == null &&
field?.selector &&
"boolean" in field.selector
) {
defaultValue = false;
if (defaultValue == null && field?.selector) {
defaultValue = getSelectorFallbackValue(field.selector);
}
if (defaultValue != null) {
@@ -43,40 +43,6 @@ const stateTriggerStruct = assign(
})
);
export const YAML_SCHEMA = [
{
name: "entity_id",
required: true,
selector: { entity: { multiple: true } },
},
{
name: "attribute",
selector: { attribute: {} },
context: { filter_entity: "entity_id" },
},
{
name: "from",
selector: { state: { multiple: true } },
context: { filter_entity: "entity_id" },
},
{
name: "to",
selector: { state: { multiple: true } },
context: { filter_entity: "entity_id" },
},
{ name: "for", selector: { duration: {} } },
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
fieldName === "entity_id"
? "ui.components.entity.entity-picker.entity"
: (`ui.panel.config.automation.editor.triggers.type.state.${fieldName}` as any)
);
@customElement("ha-automation-trigger-state")
export class HaStateTrigger extends LitElement implements TriggerElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -372,7 +338,12 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string => computeLabel(schema.name, this.hass.localize);
): string =>
this.hass.localize(
schema.name === "entity_id"
? "ui.components.entity.entity-picker.entity"
: `ui.panel.config.automation.editor.triggers.type.state.${schema.name}`
);
static styles = css`
:host {
@@ -9,24 +9,6 @@ import "../../../../../components/ha-form/ha-form";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.triggers.type.sun.${fieldName}` as any
);
export const YAML_SCHEMA = [
{
name: "event",
type: "select" as const,
required: true,
options: [["sunrise", "sunrise"] as const, ["sunset", "sunset"] as const],
},
{ name: "offset", selector: { text: {} } },
] as const;
@customElement("ha-automation-trigger-sun")
export class HaSunTrigger extends LitElement implements TriggerElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -91,7 +73,10 @@ export class HaSunTrigger extends LitElement implements TriggerElement {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string => computeLabel(schema.name, this.hass.localize);
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.sun.${schema.name}`
);
}
declare global {
@@ -7,9 +7,8 @@ import { createDurationData } from "../../../../../common/datetime/create_durati
import { fireEvent } from "../../../../../common/dom/fire_event";
import { hasTemplate } from "../../../../../common/string/has-template";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
export const SCHEMA = [
const SCHEMA = [
{ name: "value_template", required: true, selector: { template: {} } },
{
name: "for",
@@ -26,14 +25,6 @@ export const SCHEMA = [
},
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.triggers.type.template.${fieldName}` as any
);
@customElement("ha-automation-trigger-template")
export class HaTemplateTrigger extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -111,7 +102,10 @@ export class HaTemplateTrigger extends LitElement {
private _computeLabelCallback = (
schema: SchemaUnion<typeof SCHEMA>
): string => computeLabel(schema.name, this.hass.localize);
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.template.${schema.name}`
);
}
declare global {
@@ -18,37 +18,6 @@ const MODE_ENTITY = "entity";
const VALID_DOMAINS = ["sensor", "input_datetime"];
const DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
// Real YAML keys for the time trigger (the UI uses time/entity/mode/offset
// as an abstraction, but the actual YAML uses `at` and `weekday`).
export const YAML_SCHEMA = [
{ name: "at", required: true, selector: { text: {} } },
{
name: "weekday",
type: "multi_select" as const,
options: DAYS.map((d) => [d, d] as const),
},
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string => {
switch (fieldName) {
case "time":
return localize(
"ui.panel.config.automation.editor.triggers.type.time.at"
);
case "weekday":
return localize(
"ui.panel.config.automation.editor.triggers.type.time.weekday"
);
default:
return localize(
`ui.panel.config.automation.editor.triggers.type.time.${fieldName}` as any
);
}
};
@customElement("ha-automation-trigger-time")
export class HaTimeTrigger extends LitElement implements TriggerElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -238,7 +207,21 @@ export class HaTimeTrigger extends LitElement implements TriggerElement {
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string => computeLabel(schema.name, this.hass.localize);
): string => {
switch (schema.name) {
case "time":
return this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.time.at`
);
case "weekday":
return this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.time.weekday`
);
}
return this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.time.${schema.name}`
);
};
static styles = css`
:host {
@@ -6,28 +6,13 @@ import type { SchemaUnion } from "../../../../../components/ha-form/types";
import type { TimePatternTrigger } from "../../../../../data/automation";
import type { HomeAssistant } from "../../../../../types";
import type { TriggerElement } from "../ha-automation-trigger-row";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
export const SCHEMA = [
const SCHEMA = [
{ name: "hours", selector: { text: {} } },
{ name: "minutes", selector: { text: {} } },
{ name: "seconds", selector: { text: {} } },
] as const;
export const computeLabel = (
fieldName: string,
localize: LocalizeFunc
): string =>
localize(
`ui.panel.config.automation.editor.triggers.type.time_pattern.${fieldName}` as any
);
export const computeHelper = (
_fieldName: string,
localize: LocalizeFunc
): string =>
localize("ui.panel.config.automation.editor.triggers.type.time_pattern.help");
@customElement("ha-automation-trigger-time_pattern")
export class HaTimePatternTrigger extends LitElement implements TriggerElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -62,11 +47,17 @@ export class HaTimePatternTrigger extends LitElement implements TriggerElement {
private _computeLabelCallback = (
schema: SchemaUnion<typeof SCHEMA>
): string => computeLabel(schema.name, this.hass.localize);
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.time_pattern.${schema.name}`
);
private _computeHelperCallback = (
schema: SchemaUnion<typeof SCHEMA>
): string => computeHelper(schema.name, this.hass.localize);
_schema: SchemaUnion<typeof SCHEMA>
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.time_pattern.help`
);
}
declare global {
File diff suppressed because it is too large Load Diff
@@ -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(
@@ -1,29 +1,46 @@
import type { CSSResultGroup } from "lit";
import { mdiClose } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import {
fireEvent,
type HASSDomEvent,
} from "../../../../common/dom/fire_event";
import { slugify } from "../../../../common/string/slugify";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-form/ha-form";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-tab-group";
import "../../../../components/ha-tab-group-tab";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type {
LovelaceDashboard,
LovelaceDashboardCreateParams,
LovelaceDashboardMutableParams,
} from "../../../../data/lovelace/dashboard";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { LovelaceDashboard } from "../../../../data/lovelace/dashboard";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../../../resources/styles";
import {
haStyleDialog,
haStyleDialogFixedTop,
} from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../../../lovelace/editor/view-editor/hui-view-background-editor";
import type { LovelaceDashboardDetailsDialogParams } from "./show-dialog-lovelace-dashboard-detail";
import { pickAvailableDashboardUrlPath } from "./pick-available-dashboard-url-path";
const TABS = ["tab-settings", "tab-background"] as const;
interface DashboardDetailState {
dashboard: Partial<LovelaceDashboard>;
background?: LovelaceConfig["background"];
}
@customElement("dialog-lovelace-dashboard-detail")
export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
Partial<LovelaceDashboard>
>()(LitElement) {
export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<DashboardDetailState>()(
LitElement
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: LovelaceDashboardDetailsDialogParams;
@@ -38,39 +55,59 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
@state() private _submitting = false;
public showDialog(params: LovelaceDashboardDetailsDialogParams): void {
@state() private _currTab: (typeof TABS)[number] = TABS[0];
@state() private _backgroundConfig?: LovelaceConfig;
public showDialog(params: LovelaceDashboardDetailsDialogParams) {
this._params = params;
this._error = undefined;
this._urlPathChanged = false;
this._currTab = TABS[0];
this._backgroundConfig = params.lovelaceConfig;
this._open = true;
if (this._params.dashboard) {
this._data = this._params.dashboard;
this._initDirtyTracking({ type: "deep" }, this._data);
this._initDirtyTracking(
{ type: "deep" },
{
dashboard: this._params.dashboard,
background: this._params.lovelaceConfig?.background,
}
);
} else {
const suggestions = this._params.suggestions;
this._data = {
show_in_sidebar: true,
icon: suggestions?.icon,
title: suggestions?.title ?? "",
icon: this._params.suggestions?.icon,
title: this._params.suggestions?.title ?? "",
require_admin: false,
mode: "storage",
};
// New dashboards have no saved baseline, so track against an emptyobject to mark them dirty from the outset (keeps Create enabled).
this._initDirtyTracking({ type: "deep" }, {});
if (suggestions?.title) {
this._fillUrlPath(suggestions.title);
this._initDirtyTracking(
{ type: "deep" },
{
dashboard: {},
background: this._params.lovelaceConfig?.background,
}
);
if (this._params.suggestions?.title) {
this._fillUrlPath(this._params.suggestions.title);
}
this._updateDirtyState(this._data!);
this._updateDirtyState({
dashboard: this._data,
background: this._backgroundConfig?.background,
});
}
}
public closeDialog(): void {
public closeDialog() {
this._open = false;
}
private _dialogClosed(): void {
private _dialogClosed() {
this._params = undefined;
this._data = undefined;
this._backgroundConfig = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -82,6 +119,18 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
const yamlMode = this._params.dashboard?.mode === "yaml";
const titleInvalid = !this._data.title || !this._data.title.trim();
const dialogTitle = this._params.urlPath
? this._data.title ||
this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.edit_dashboard"
)
: this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.new_dashboard"
);
const showBackgroundTab =
this._params.dashboard?.mode !== "yaml" &&
Boolean(this._params.lovelaceConfig) &&
Boolean(this._params.saveConfig);
const cancelButton = html`
<ha-button
@@ -96,41 +145,43 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
return html`
<ha-dialog
.open=${this._open}
header-title=${
this._params.urlPath
? this._data.title ||
this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.edit_dashboard"
)
: this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.new_dashboard"
)
}
header-title=${showBackgroundTab ? nothing : dialogTitle}
width=${showBackgroundTab ? "large" : "medium"}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
<div>
${
yamlMode
? this.hass.localize(
"ui.panel.config.lovelace.dashboards.cant_edit_yaml"
)
: html`
<ha-form
autofocus
.schema=${this._schema(
this._params,
this._data?.require_admin
${
showBackgroundTab
? html`
<ha-dialog-header show-border slot="header">
<ha-icon-button
slot="navigationIcon"
@click=${this.closeDialog}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<h2 slot="title">${dialogTitle}</h2>
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
${TABS.map(
(tab) => html`
<ha-tab-group-tab
slot="nav"
.panel=${tab}
.active=${this._currTab === tab}
>
${this.hass.localize(
`ui.panel.lovelace.editor.edit_view.${tab.replace("-", "_")}`
)}
</ha-tab-group-tab>
`
)}
.data=${this._data}
.hass=${this.hass}
.error=${this._error}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._valueChanged}
></ha-form>
`
}
</ha-tab-group>
</ha-dialog-header>
`
: nothing
}
<div>
${this._renderContent(this._params, this._data, showBackgroundTab)}
</div>
<ha-dialog-footer slot="footer">
${
@@ -185,6 +236,46 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
`;
}
private _renderContent(
params: LovelaceDashboardDetailsDialogParams,
data: Partial<LovelaceDashboard>,
showBackgroundTab: boolean
): string | TemplateResult<1> | typeof nothing {
if (params.dashboard?.mode === "yaml") {
return this.hass.localize(
"ui.panel.config.lovelace.dashboards.cant_edit_yaml"
);
}
if (this._currTab === "tab-background" && showBackgroundTab) {
return html`
${
this._error?.base
? html`<ha-alert alert-type="error">${this._error.base}</ha-alert>`
: nothing
}
<hui-view-background-editor
.hass=${this.hass}
.config=${this._backgroundConfig}
@background-config-changed=${this._backgroundConfigChanged}
></hui-view-background-editor>
`;
}
return html`
<ha-form
autofocus
.schema=${this._schema(params, data.require_admin)}
.data=${data}
.hass=${this.hass}
.error=${this._error}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _schema = memoizeOne(
(params: LovelaceDashboardDetailsDialogParams, requireAdmin?: boolean) =>
[
@@ -251,15 +342,16 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
)
: "";
private _valueChanged(ev: CustomEvent) {
private _valueChanged(
ev: HASSDomEvent<{ value: Partial<LovelaceDashboard> }>
) {
this._error = undefined;
const value = ev.detail.value;
if (value.url_path !== this._data?.url_path) {
if (ev.detail.value.url_path !== this._data?.url_path) {
this._urlPathChanged = true;
if (
!value.url_path ||
value.url_path === "lovelace" ||
!/^[a-zA-Z0-9_-]+-[a-zA-Z0-9_-]+$/.test(value.url_path)
!ev.detail.value.url_path ||
ev.detail.value.url_path === "lovelace" ||
!/^[a-zA-Z0-9_-]+-[a-zA-Z0-9_-]+$/.test(ev.detail.value.url_path)
) {
this._error = {
url_path: this.hass.localize(
@@ -268,13 +360,30 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
};
}
}
if (value.title !== this._data?.title) {
this._data = value;
this._fillUrlPath(value.title);
if (ev.detail.value.title !== this._data?.title) {
this._data = ev.detail.value;
if (ev.detail.value.title) {
this._fillUrlPath(ev.detail.value.title);
}
} else {
this._data = value;
this._data = ev.detail.value;
}
this._updateDirtyState({
dashboard: this._data,
background: this._backgroundConfig?.background,
});
}
private _backgroundConfigChanged(
ev: HASSDomEvent<{ config: LovelaceConfig }>
) {
this._backgroundConfig = ev.detail.config;
if (this._data) {
this._updateDirtyState({
dashboard: this._data,
background: this._backgroundConfig.background,
});
}
this._updateDirtyState(this._data!);
}
private _fillUrlPath(title: string) {
@@ -286,36 +395,55 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
const baseSlug = slugifyTitle.includes("-")
? slugifyTitle
: `dashboard-${slugifyTitle}`;
const taken = this._params?.takenUrlPaths;
this._data = {
...this._data,
url_path:
taken !== undefined
? pickAvailableDashboardUrlPath(baseSlug, taken)
this._params?.takenUrlPaths !== undefined
? pickAvailableDashboardUrlPath(baseSlug, this._params.takenUrlPaths)
: baseSlug,
};
this._updateDirtyState(this._data!);
this._updateDirtyState({
dashboard: this._data,
background: this._backgroundConfig?.background,
});
}
private async _updateDashboard() {
if (this._params?.urlPath && this._params.dashboard?.mode === "yaml") {
if (!this._params || !this._data) {
return;
}
if (this._params.urlPath && this._params.dashboard?.mode === "yaml") {
this.closeDialog();
return;
}
this._submitting = true;
try {
if (this._params!.dashboard) {
const values: Partial<LovelaceDashboardMutableParams> = {
require_admin: this._data!.require_admin,
show_in_sidebar: this._data!.show_in_sidebar,
icon: this._data!.icon || undefined,
title: this._data!.title,
};
await this._params!.updateDashboard(values);
} else if (this._params!.createDashboard) {
await this._params!.createDashboard(
this._data as LovelaceDashboardCreateParams
);
if (this._params.dashboard) {
await this._params.updateDashboard({
require_admin: this._data.require_admin ?? false,
show_in_sidebar: this._data.show_in_sidebar ?? true,
icon: this._data.icon || undefined,
title: this._data.title ?? "",
});
} else if (this._params.createDashboard) {
await this._params.createDashboard({
require_admin: this._data.require_admin ?? false,
show_in_sidebar: this._data.show_in_sidebar ?? true,
icon: this._data.icon || undefined,
title: this._data.title ?? "",
url_path: this._data.url_path ?? "",
mode: "storage",
});
}
if (
this._backgroundConfig &&
this._params.saveConfig &&
this._params.lovelaceConfig &&
this._backgroundConfig.background !==
this._params.lovelaceConfig.background
) {
await this._params.saveConfig(this._backgroundConfig);
}
this._markDirtyStateClean();
this.closeDialog();
@@ -339,10 +467,25 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
}
}
private _handleTabChanged(
ev: HASSDomEvent<{
name: (typeof TABS)[number];
}>
) {
if (ev.detail.name === this._currTab) {
return;
}
this._currTab = ev.detail.name;
}
private async _deleteDashboard() {
if (!this._params) {
return;
}
this._submitting = true;
try {
if (await this._params!.removeDashboard()) {
if (await this._params.removeDashboard()) {
this.closeDialog();
}
} finally {
@@ -351,7 +494,30 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
}
static get styles(): CSSResultGroup {
return [haStyleDialog, css``];
return [
haStyleDialog,
haStyleDialogFixedTop,
css`
ha-dialog {
--dialog-content-padding: var(--ha-space-6);
}
h2 {
margin: 0;
font-size: inherit;
font-weight: inherit;
}
ha-tab-group-tab {
flex: 1;
}
ha-tab-group-tab::part(base) {
width: 100%;
justify-content: center;
}
`,
];
}
}
@@ -1,4 +1,5 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type {
LovelaceDashboard,
LovelaceDashboardCreateParams,
@@ -17,6 +18,8 @@ export interface LovelaceDashboardDetailsDialogParams {
* auto-generated paths avoid collisions by appending -2, -3, and so on.
*/
takenUrlPaths?: ReadonlySet<string>;
lovelaceConfig?: LovelaceConfig;
saveConfig?: (config: LovelaceConfig) => Promise<void>;
createDashboard?: (values: LovelaceDashboardCreateParams) => Promise<unknown>;
updateDashboard: (
updates: Partial<LovelaceDashboardMutableParams>
+203 -44
View File
@@ -14,28 +14,92 @@ import type { SchemaUnion } from "../../../components/ha-form/types";
import "../../../components/ha-icon-button";
import "../../../components/ha-dialog";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import type { SupervisorMountRequestParams } from "../../../data/supervisor/mounts";
import type {
SupervisorMountCandidate,
SupervisorMountRequestParams,
} from "../../../data/supervisor/mounts";
import {
createSupervisorMount,
fetchSupervisorMountCandidates,
removeSupervisorMount,
SupervisorMountType,
SupervisorMountUsage,
updateSupervisorMount,
} from "../../../data/supervisor/mounts";
import { bytesToString } from "../../../util/bytes-to-string";
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
import { haStyle, haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import type { MountViewDialogParams } from "./show-dialog-view-mount";
// Describes a device by the drive it belongs to, falling back to what UDisks2
// did report: an unattributed device has no drive, and an unformatted-label
// partition has no label.
const mountCandidateLabel = (candidate: SupervisorMountCandidate): string => {
const drive = [candidate.drive?.vendor, candidate.drive?.model]
.filter(Boolean)
.join(" ");
const identity = candidate.label || candidate.device;
const size = bytesToString(candidate.size);
return drive ? `${drive}${identity}, ${size}` : `${identity}, ${size}`;
};
const mountSchema = memoizeOne(
(
localize: LocalizeFunc,
existing?: boolean,
mountType?: SupervisorMountType,
showCIFSVersion?: boolean
) =>
[
showCIFSVersion?: boolean,
showDisk?: boolean,
candidates?: SupervisorMountCandidate[],
diskIdentity?: string,
readOnlyForced?: boolean,
allowBackupUsage = true
) => {
// Supervisor rejects a read-only mount used for backups, so a device that
// can only be mounted read-only is not offered for one.
const usageOptions: [string, string][] = allowBackupUsage
? [
[
SupervisorMountUsage.BACKUP,
localize(
"ui.panel.config.storage.network_mounts.mount_usage.backup"
),
],
]
: [];
usageOptions.push(
[
SupervisorMountUsage.MEDIA,
localize("ui.panel.config.storage.network_mounts.mount_usage.media"),
],
[
SupervisorMountUsage.SHARE,
localize("ui.panel.config.storage.network_mounts.mount_usage.share"),
]
);
const typeOptions: [string, string][] = [
[
SupervisorMountType.CIFS,
localize("ui.panel.config.storage.network_mounts.mount_type.cifs"),
],
[
SupervisorMountType.NFS,
localize("ui.panel.config.storage.network_mounts.mount_type.nfs"),
],
];
// Hidden on a Supervisor that does not support disk mounts, but always
// offered when editing one that already exists.
if (showDisk || mountType === SupervisorMountType.DISK) {
typeOptions.push([
SupervisorMountType.DISK,
localize("ui.panel.config.storage.network_mounts.mount_type.disk"),
]);
}
return [
{
name: "name",
required: true,
@@ -46,57 +110,34 @@ const mountSchema = memoizeOne(
name: "usage",
required: true,
type: "select",
options: [
[
SupervisorMountUsage.BACKUP,
localize(
"ui.panel.config.storage.network_mounts.mount_usage.backup"
),
],
[
SupervisorMountUsage.MEDIA,
localize(
"ui.panel.config.storage.network_mounts.mount_usage.media"
),
],
[
SupervisorMountUsage.SHARE,
localize(
"ui.panel.config.storage.network_mounts.mount_usage.share"
),
],
] as const,
},
{
name: "server",
required: true,
selector: { text: {} },
options: usageOptions,
},
{
name: "type",
required: true,
type: "select",
options: [
[
SupervisorMountType.CIFS,
localize("ui.panel.config.storage.network_mounts.mount_type.cifs"),
],
[
SupervisorMountType.NFS,
localize("ui.panel.config.storage.network_mounts.mount_type.nfs"),
],
],
options: typeOptions,
},
...(mountType === "nfs"
...(mountType === SupervisorMountType.NFS
? ([
{
name: "server",
required: true,
selector: { text: {} },
},
{
name: "path",
required: true,
selector: { text: {} },
},
] as const)
: mountType === "cifs"
: mountType === SupervisorMountType.CIFS
? ([
{
name: "server",
required: true,
selector: { text: {} },
},
...(showCIFSVersion
? ([
{
@@ -148,8 +189,44 @@ const mountSchema = memoizeOne(
selector: { text: { type: "password" } },
},
] as const)
: ([] as const)),
] as const
: mountType === SupervisorMountType.DISK
? existing
? // Supervisor excludes a mounted device from the candidates, so
// an existing mount can only show what it resolved to.
([
{
name: "device_identity",
type: "constant",
value: diskIdentity,
},
{
name: "read_only",
selector: { boolean: {} },
},
] as const)
: ([
{
name: "device",
required: true,
selector: {
select: {
options: (candidates ?? []).map((candidate) => ({
value: candidate.device,
label: mountCandidateLabel(candidate),
})),
mode: "dropdown",
},
},
},
{
name: "read_only",
disabled: readOnlyForced,
selector: { boolean: {} },
},
] as const)
: ([] as const)),
] as const;
}
);
@customElement("dialog-mount-view")
@@ -172,6 +249,12 @@ class ViewMountDialog extends DirtyStateProviderMixin<
@state() private _showCIFSVersion?: boolean;
@state() private _candidates?: SupervisorMountCandidate[];
@state() private _diskSupported = false;
@state() private _diskIdentity?: string;
@state() private _reloadMounts?: () => void;
@state() private _open = false;
@@ -190,13 +273,36 @@ class ViewMountDialog extends DirtyStateProviderMixin<
) {
this._showCIFSVersion = true;
}
if (dialogParams.mount?.type === SupervisorMountType.DISK) {
this._diskIdentity = [
dialogParams.mount.filesystem,
dialogParams.mount.uuid,
]
.filter(Boolean)
.join(" • ");
}
this._initDirtyTracking({ type: "deep" }, this._data ?? {});
this._loadCandidates();
}
public closeDialog(): void {
this._open = false;
}
private async _loadCandidates(): Promise<void> {
try {
const { candidates } = await fetchSupervisorMountCandidates(this.hass);
this._candidates = candidates;
this._diskSupported = true;
} catch (_err: any) {
// A Supervisor predating disk mounts answers 404. Any other failure
// leaves us unable to offer a device either, so in both cases the option
// is hidden rather than shown as broken.
this._candidates = [];
this._diskSupported = false;
}
}
private _dialogClosed(): void {
this._data = undefined;
this._waiting = undefined;
@@ -205,6 +311,9 @@ class ViewMountDialog extends DirtyStateProviderMixin<
this._validationWarning = undefined;
this._existing = undefined;
this._showCIFSVersion = undefined;
this._candidates = undefined;
this._diskSupported = false;
this._diskIdentity = undefined;
this._reloadMounts = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -249,6 +358,15 @@ class ViewMountDialog extends DirtyStateProviderMixin<
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing
}
${
this._showNoCandidates
? html`<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.config.storage.network_mounts.no_disk_candidates"
)}
</ha-alert>`
: nothing
}
<ha-form
autofocus
.data=${this._data}
@@ -256,7 +374,12 @@ class ViewMountDialog extends DirtyStateProviderMixin<
this.hass.localize,
this._existing,
this._data?.type,
this._showCIFSVersion
this._showCIFSVersion,
this._diskSupported,
this._candidates,
this._diskIdentity,
this._readOnlyForced,
this._allowBackupUsage
)}
.error=${this._validationError}
.warning=${this._validationWarning}
@@ -308,6 +431,33 @@ class ViewMountDialog extends DirtyStateProviderMixin<
`;
}
// The device the mount already uses is excluded from candidates, so an empty
// list is only worth mentioning while creating one.
private get _showNoCandidates(): boolean {
return (
!this._existing &&
this._data?.type === SupervisorMountType.DISK &&
this._candidates?.length === 0
);
}
private get _readOnlyForced(): boolean {
if (this._existing || this._data?.type !== SupervisorMountType.DISK) {
return false;
}
const { device } = this._data;
return !!this._candidates?.find((candidate) => candidate.device === device)
?.read_only;
}
// Backup usage is impossible for a read-only mount, whether the device forced
// that or the user chose it.
private get _allowBackupUsage(): boolean {
return !(
this._data?.type === SupervisorMountType.DISK && this._data.read_only
);
}
private _computeLabelCallback = (
// @ts-ignore
schema: SchemaUnion<ReturnType<typeof mountSchema>>
@@ -353,6 +503,15 @@ class ViewMountDialog extends DirtyStateProviderMixin<
) {
this._validationWarning.version = "not_recomeded_cifs_version";
}
// A device the host reports as read-only cannot be mounted writable.
if (this._readOnlyForced) {
this._data!.read_only = true;
}
// Picking such a device while backup was selected leaves a combination
// Supervisor refuses, so drop the usage and make the user choose again.
if (!this._allowBackupUsage && this._data?.usage === "backup") {
delete (this._data as Partial<SupervisorMountRequestParams>).usage;
}
this._updateDirtyState(this._data ?? {});
}
@@ -37,6 +37,7 @@ import {
SupervisorMountUsage,
fetchSupervisorMounts,
reloadSupervisorMount,
supervisorMountDescription,
} from "../../../data/supervisor/mounts";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
@@ -74,7 +75,11 @@ class HaConfigSectionStorage extends LitElement {
return nothing;
}
const validMounts = this._mountsInfo?.mounts.filter((mount) =>
[SupervisorMountType.CIFS, SupervisorMountType.NFS].includes(mount.type)
[
SupervisorMountType.CIFS,
SupervisorMountType.DISK,
SupervisorMountType.NFS,
].includes(mount.type)
);
const isHAOS = this._hostInfo?.features.includes("haos");
return html`
@@ -193,13 +198,7 @@ class HaConfigSectionStorage extends LitElement {
${mount.name}
</span>
<span slot="secondary">
${mount.server}${
mount.port ? `:${mount.port}` : nothing
}${
mount.type === SupervisorMountType.NFS
? mount.path
: `:${mount.share}`
}
${supervisorMountDescription(mount)}
</span>
${
mount.state !== SupervisorMountState.ACTIVE
@@ -50,7 +50,6 @@ import { documentationUrl } from "../../../../util/documentation-url";
import { resolveMediaSource } from "../../../../data/media_source";
import { MatchMinHeightMixin } from "../../../../mixins/match-min-height-mixin";
import { withViewTransition } from "../../../../common/util/view-transition";
import { serviceActionSchema } from "../../automation/yaml_schema_helpers";
@customElement("tools-action")
class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
@@ -198,11 +197,6 @@ class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
<ha-yaml-editor
id="yaml-editor"
.defaultValue=${this._serviceData}
.yamlFieldSchema=${this._yamlFieldSchema(
this._serviceData?.action,
this.hass.services,
this.hass.localize
)}
@value-changed=${this._yamlChanged}
></ha-yaml-editor>
</div>`
@@ -403,20 +397,6 @@ class HaPanelDevAction extends MatchMinHeightMixin(LitElement) {
fields.filter((field) => !field.selector)
);
private _yamlFieldSchema = memoizeOne(
(
action: string | undefined,
services: HomeAssistant["services"],
localize: HomeAssistant["localize"]
) => {
if (!action) return undefined;
const domain = computeDomain(action);
const service = computeObjectId(action);
if (!domain || !service) return undefined;
return serviceActionSchema(domain, service, services, localize);
}
);
private _validateServiceData = (
serviceData: ServiceAction | undefined,
fields,
@@ -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,
@@ -181,7 +181,7 @@ export class HuiDialogEditView extends DirtyStateProviderMixin<LovelaceViewConfi
<hui-view-background-editor
.hass=${this.hass}
.config=${this._config}
@view-config-changed=${this._viewConfigChanged}
@background-config-changed=${this._viewConfigChanged}
></hui-view-background-editor>
`;
break;
@@ -2,35 +2,41 @@ import memoizeOne from "memoize-one";
import { LitElement, css, html, nothing } from "lit";
import type { PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import {
fireEvent,
type HASSDomEvent,
} from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import type {
LovelaceDashboardBackgroundConfig,
LovelaceViewBackgroundConfig,
} from "../../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../../types";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import {
isMediaSourceContentId,
resolveMediaSource,
} from "../../../../data/media_source";
export interface BackgroundConfigTarget {
background?: LovelaceDashboardBackgroundConfig;
}
@customElement("hui-view-background-editor")
export class HuiViewBackgroundEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config!: LovelaceViewConfig;
@property({ attribute: false }) public config?: BackgroundConfigTarget;
@state({ attribute: false }) private _resolvedImage?: string;
set config(config: LovelaceViewConfig) {
this._config = config;
}
private _localizeValueCallback = (key: string) =>
this.hass.localize(key as Parameters<LocalizeFunc>[0]);
private _schema = memoizeOne(
(localize: LocalizeFunc, showSettings: boolean) =>
(showSettings: boolean) =>
[
{
name: "image",
@@ -40,7 +46,7 @@ export class HuiViewBackgroundEditor extends LitElement {
clearable: true,
image_upload: true,
hide_content_type: true,
content_id_helper: localize(
content_id_helper: this.hass.localize(
"ui.panel.lovelace.editor.card.picture.content_id_helper"
),
},
@@ -123,14 +129,14 @@ export class HuiViewBackgroundEditor extends LitElement {
] as const
);
protected updated(changedProps: PropertyValues) {
protected updated(changedProps: PropertyValues<this>) {
if (
this._config &&
this.config &&
this.hass &&
(changedProps.has("_config") ||
(changedProps.has("config") ||
(changedProps.has("hass") && !changedProps.get("hass")))
) {
const background = this._backgroundData(this._config);
const background = this._backgroundData(this.config);
this.style.setProperty(
"--picture-opacity",
`${(background.opacity ?? 100) / 100}`
@@ -156,7 +162,7 @@ export class HuiViewBackgroundEditor extends LitElement {
return nothing;
}
const background = this._backgroundData(this._config);
const background = this._backgroundData(this.config);
return html`
${
@@ -174,7 +180,7 @@ export class HuiViewBackgroundEditor extends LitElement {
<ha-form
.hass=${this.hass}
.data=${background}
.schema=${this._schema(this.hass.localize, true)}
.schema=${this._schema(true)}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
.localizeValue=${this._localizeValueCallback}
@@ -183,7 +189,7 @@ export class HuiViewBackgroundEditor extends LitElement {
}
private _backgroundData = memoizeOne(
(backgroundConfig?: LovelaceViewConfig) => {
(backgroundConfig?: BackgroundConfigTarget) => {
let background = backgroundConfig?.background;
if (typeof background === "string") {
const backgroundUrl = background.match(
@@ -220,12 +226,15 @@ export class HuiViewBackgroundEditor extends LitElement {
}
);
private _valueChanged(ev: CustomEvent): void {
const config = {
...this._config,
background: ev.detail.value,
};
fireEvent(this, "view-config-changed", { config });
private _valueChanged(
ev: HASSDomEvent<{ value: LovelaceViewBackgroundConfig }>
) {
fireEvent(this, "background-config-changed", {
config: {
...(this.config || {}),
background: ev.detail.value,
},
});
}
private _computeLabelCallback = (
@@ -292,4 +301,10 @@ declare global {
interface HTMLElementTagNameMap {
"hui-view-background-editor": HuiViewBackgroundEditor;
}
interface HASSDomEvents {
"background-config-changed": {
config: BackgroundConfigTarget;
};
}
}
+8
View File
@@ -1081,10 +1081,18 @@ class HUIRoot extends LitElement {
await this.hass.loadFragmentTranslation("config");
const dashboards = await fetchDashboards(this.hass);
const dashboard = dashboards.find((d) => d.url_path === urlPath);
const lovelace = this.lovelace;
const lovelaceConfig =
lovelace && !isStrategyDashboard(lovelace.rawConfig)
? lovelace.rawConfig
: undefined;
showDashboardDetailDialog(this, {
dashboard,
urlPath,
...(lovelace && lovelaceConfig
? { lovelaceConfig, saveConfig: lovelace.saveConfig }
: {}),
updateDashboard: async (values) => {
await updateDashboard(this.hass!, dashboard!.id, values);
},
+1 -18
View File
@@ -37,13 +37,7 @@ export {
search,
searchKeymap,
} from "@codemirror/search";
export {
lintGutter,
lintKeymap,
setDiagnostics,
linter,
forceLinting,
} from "@codemirror/lint";
export { lintGutter, lintKeymap, setDiagnostics } from "@codemirror/lint";
export { EditorState } from "@codemirror/state";
export {
crosshairCursor,
@@ -92,23 +86,12 @@ export {
JINJA_FUNCTION_ARG_TYPES,
} from "./jinja_ha_completions";
export type { HassArgHoverContext, JinjaArgType } from "./jinja_ha_completions";
export {
haYamlCompletionSource,
haYamlHoverSource,
haYamlLintSource,
} from "./yaml_ha_completions";
export type {
HaYamlCompletionContext,
HaYamlHoverContext,
} from "./yaml_ha_completions";
export type { YamlFieldSchemaMap, YamlFieldSchema } from "./yaml_field_schema";
export { closePercentBrace };
export const langCompartment = new Compartment();
export const readonlyCompartment = new Compartment();
export const linewrapCompartment = new Compartment();
export const yamlLintCompartment = new Compartment();
export const yamlSchemaCompartment = new Compartment();
// ---------------------------------------------------------------------------
// YAML scalar type highlighter
-122
View File
@@ -1,122 +0,0 @@
/**
* Shared CodeMirror completion-item builders for HA entity / device / area
* selectors.
*
* Used by both the Jinja template editor (`ha-code-editor.ts`) and the YAML
* field-schema editor (`yaml_ha_completions.ts`) so the two always produce
* identical completion items for the same HA registry data.
*
* Each builder follows the same convention:
* label "friendly name + ID" concatenated so filtering works on both
* displayLabel only the friendly name (what the user actually sees)
* detail the raw ID (shown as secondary text)
* apply the raw ID (what gets inserted)
*/
import type { Completion } from "@codemirror/autocomplete";
import type { HassEntities } from "home-assistant-js-websocket";
import { computeAreaName } from "../common/entity/compute_area_name";
import { computeDeviceName } from "../common/entity/compute_device_name";
import { computeFloorName } from "../common/entity/compute_floor_name";
import type { AreaRegistryEntry } from "../data/area/area_registry";
import type { DeviceRegistryEntry } from "../data/device/device_registry";
import type { FloorRegistryEntry } from "../data/floor_registry";
import type { LabelRegistryEntry } from "../data/label/label_registry";
/**
* Build completion items for entity IDs.
* `label` is "friendly name + entity_id" for search; `displayLabel` shows only
* the entity_id; `detail` shows the friendly name.
*/
export function buildEntityCompletions(states: HassEntities): Completion[] {
return Object.keys(states).map((entityId) => {
const friendlyName = states[entityId].attributes.friendly_name as
string | undefined;
return {
type: "variable",
label: friendlyName ? `${friendlyName} ${entityId}` : entityId,
displayLabel: entityId,
detail: friendlyName,
apply: entityId,
};
});
}
/**
* Build completion items for device IDs.
* `label` is "name + id" for search; `displayLabel` shows only the name;
* `detail` shows the device ID.
*/
export function buildDeviceCompletions(
devices: Record<string, DeviceRegistryEntry>
): Completion[] {
return Object.values(devices)
.filter((device) => !device.disabled_by)
.map((device) => {
const name = computeDeviceName(device) ?? device.id;
return {
type: "variable",
label: `${name} ${device.id}`,
displayLabel: name,
detail: device.id,
apply: device.id,
};
});
}
/**
* Build completion items for area IDs.
* `label` is "name + area_id" for search; `displayLabel` shows only the name;
* `detail` shows the area ID.
*/
export function buildAreaCompletions(
areas: Record<string, AreaRegistryEntry>
): Completion[] {
return Object.values(areas).map((area) => {
const name = computeAreaName(area) ?? area.area_id;
return {
type: "variable",
label: `${name} ${area.area_id}`,
displayLabel: name,
detail: area.area_id,
apply: area.area_id,
};
});
}
/**
* Build completion items for floor IDs.
* `label` is "name + floor_id" for search; `displayLabel` shows only the name;
* `detail` shows the floor ID.
*/
export function buildFloorCompletions(
floors: Record<string, FloorRegistryEntry>
): Completion[] {
return Object.values(floors).map((floor) => {
const name = computeFloorName(floor) ?? floor.floor_id;
return {
type: "variable",
label: `${name} ${floor.floor_id}`,
displayLabel: name,
detail: floor.floor_id,
apply: floor.floor_id,
};
});
}
/**
* Build completion items for label IDs.
* `label` is "name + label_id" for search; `displayLabel` shows only the name;
* `detail` shows the label ID.
*/
export function buildLabelCompletions(
labels: LabelRegistryEntry[]
): Completion[] {
return labels.map((label) => ({
type: "variable",
label: `${label.name} ${label.label_id}`,
displayLabel: label.name,
detail: label.label_id,
apply: label.label_id,
}));
}
+1 -1
View File
@@ -1952,7 +1952,7 @@ function buildTooltipDom(
* Returns null when no hass context is available or the value can't be resolved.
* `siblingEntityId` is only used for `attribute` arg types.
*/
export function buildArgTooltipDom(
function buildArgTooltipDom(
argType: JinjaArgType,
value: string,
ctx: HassArgHoverContext,
-67
View File
@@ -1,67 +0,0 @@
import type { Selector } from "../data/selector";
/**
* Describes a single field in a YAML schema used for editor assistance
* (completions, hover tooltips, and linting) in ha-yaml-editor / ha-code-editor.
*
* This is intentionally kept separate from superstruct structs (which are
* runtime-validation only) and from ha-form schemas (which drive form UI).
* It maps closely to the shape of TriggerDescription.fields,
* ConditionDescription.fields, and HassService.fields so the automation editor
* can forward those descriptions into the YAML editor with minimal conversion.
*/
export interface YamlFieldSchema {
/** Human-readable description shown in the hover tooltip. */
description?: string;
/**
* Selector driving value completions and type hints. When present,
* the completion source will offer relevant value suggestions based on
* the selector type (boolean true/false, select option list, etc.).
*/
selector?: Selector;
/** Whether the field is required (shown in the hover tooltip). */
required?: boolean;
/** Example value shown in the hover tooltip. */
example?: unknown;
/** Default value shown in the hover tooltip. */
default?: unknown;
/**
* Nested field schema for object/mapping values. When set, drilling
* into this key's value will offer the nested fields as completions.
*/
fields?: YamlFieldSchemaMap;
}
/**
* A map of YAML key field schema. Passed to ha-yaml-editor / ha-code-editor.
*
* Keys are the YAML keys valid at this mapping level; see
* `allowUnknownFields()` for levels where that set is not statically known.
*/
export type YamlFieldSchemaMap = Record<string, YamlFieldSchema>;
/**
* Marks a map as accepting keys beyond the ones it lists. Kept on a symbol so
* it can never collide with a real YAML key, and so it stays out of the
* Object.keys() / Object.entries() iteration used for completions and
* required-field checks.
*/
const ALLOW_UNKNOWN_FIELDS = Symbol("allowUnknownFields");
/**
* Mark a `YamlFieldSchemaMap` so that the linter does not warn about unknown
* keys at this mapping level. Use it for schemas where the full set of valid
* keys is not statically known (e.g. device triggers/conditions/actions that
* accept integration-specific fields). Returns the same object for convenience.
*/
export function allowUnknownFields(
map: YamlFieldSchemaMap
): YamlFieldSchemaMap {
(map as Record<symbol, boolean>)[ALLOW_UNKNOWN_FIELDS] = true;
return map;
}
/** Return true if the map was marked via `allowUnknownFields()`. */
export function hasAllowUnknownFields(map: YamlFieldSchemaMap): boolean {
return (map as Record<symbol, boolean>)[ALLOW_UNKNOWN_FIELDS] === true;
}
-925
View File
@@ -1,925 +0,0 @@
/**
* CodeMirror completion source and hover tooltip source for field-aware YAML
* editing in the automation/script/card YAML editors.
*
* Given a `YamlFieldSchemaMap` describing the valid keys (and their selectors,
* descriptions, etc.), this module provides:
*
* - `haYamlCompletionSource` key completions at the current indent level
* plus value completions driven by the selector.
* - `haYamlHoverSource` a `hoverTooltip` callback that shows field
* description, required status and example on hover.
*
* The module is intentionally free of Lit/HA runtime imports so it can be
* consumed in the same lazy code-split chunk as ha-code-editor.
*/
import type {
Completion,
CompletionContext,
CompletionResult,
} from "@codemirror/autocomplete";
import { syntaxTree } from "@codemirror/language";
import type { EditorView, Tooltip } from "@codemirror/view";
import type { SyntaxNode } from "@lezer/common";
import { NodeProp } from "@lezer/common";
import type { HassEntities } from "home-assistant-js-websocket";
import memoizeOne from "memoize-one";
import type { AreaRegistryEntry } from "../data/area/area_registry";
import type { DeviceRegistryEntry } from "../data/device/device_registry";
import type { FloorRegistryEntry } from "../data/floor_registry";
import type { LabelRegistryEntry } from "../data/label/label_registry";
import type { SelectSelector } from "../data/selector";
import type { LocalizeFunc } from "../common/translations/localize";
import {
buildAreaCompletions,
buildDeviceCompletions,
buildEntityCompletions,
buildFloorCompletions,
buildLabelCompletions,
} from "./ha_completion_items";
import {
buildArgTooltipDom,
type HassArgHoverContext,
type JinjaArgType,
} from "./jinja_ha_completions";
import "../components/ha-code-editor-jinja-arg-hover";
import "../components/ha-code-editor-yaml-hover";
import type { YamlFieldSchema, YamlFieldSchemaMap } from "./yaml_field_schema";
import { hasAllowUnknownFields } from "./yaml_field_schema";
// ---------------------------------------------------------------------------
// Helpers YAML syntax tree traversal
// ---------------------------------------------------------------------------
/**
* Returns the text content of a syntax node.
*/
function nodeText(node: SyntaxNode, doc: string): string {
return doc.slice(node.from, node.to);
}
/**
* Resolve the field schema for a given key path through a nested schema map.
* Returns `undefined` if the path is not found.
*/
function resolveFieldSchema(
schema: YamlFieldSchemaMap,
path: string[]
): YamlFieldSchema | undefined {
if (path.length === 0) return undefined;
const [head, ...rest] = path;
const field = schema[head];
if (!field) return undefined;
if (rest.length === 0) return field;
if (field.fields) return resolveFieldSchema(field.fields, rest);
return undefined;
}
// ---------------------------------------------------------------------------
// Value completions driven by selector type
// ---------------------------------------------------------------------------
/** validFor pattern for value completions — matches any typed text. */
const VALUE_VALID_FOR = /^.*$/;
const BOOLEAN_COMPLETIONS: Completion[] = [
{ label: "true", type: "keyword" },
{ label: "false", type: "keyword" },
];
/** "Required" marker shown as the `detail` of a required key completion. */
function requiredLabel(ctx: HaYamlCompletionContext): string | undefined {
return ctx.localize?.("ui.components.yaml-editor.schema.required");
}
/**
* A field's description, translated when it is a translation key. Descriptions
* coming from the backend (service/trigger/condition descriptions) are already
* translated plain text and pass through unchanged.
*/
function describe(
field: YamlFieldSchema,
ctx: { localize?: LocalizeFunc }
): string | undefined {
if (!field.description) return undefined;
return (
ctx.localize?.(field.description as Parameters<LocalizeFunc>[0]) ||
field.description
);
}
// Registry-derived completion lists are rebuilt for every completion request,
// and there can be thousands of entities — memoize on the registry identity so
// typing doesn't remap the whole registry on each keystroke.
const memoEntityCompletions = memoizeOne(buildEntityCompletions);
const memoDeviceCompletions = memoizeOne(buildDeviceCompletions);
const memoAreaCompletions = memoizeOne(buildAreaCompletions);
const memoFloorCompletions = memoizeOne(buildFloorCompletions);
const memoLabelCompletions = memoizeOne(buildLabelCompletions);
function valueCompletionsForSelector(
field: YamlFieldSchema,
ctx: HaYamlCompletionContext
): Completion[] | null {
const { selector } = field;
if (!selector) return null;
const type = Object.keys(selector)[0];
if (type === "boolean") {
return BOOLEAN_COMPLETIONS;
}
if (type === "select") {
const opts = (selector as SelectSelector).select?.options;
if (Array.isArray(opts)) {
return opts.map((option) =>
typeof option === "object"
? { label: option.value, type: "enum", detail: option.label }
: { label: option, type: "enum" }
);
}
}
if (type === "entity" && ctx.states) {
return memoEntityCompletions(ctx.states);
}
if (type === "device" && ctx.devices) {
return memoDeviceCompletions(ctx.devices);
}
if (type === "area" && ctx.areas) {
return memoAreaCompletions(ctx.areas);
}
if (type === "floor" && ctx.floors) {
return memoFloorCompletions(ctx.floors);
}
if (type === "label" && ctx.labels) {
return memoLabelCompletions(ctx.labels);
}
if (type === "template") {
return ctx.localize
? [
{
label: "{{ }}",
type: "text",
detail: ctx.localize(
"ui.components.yaml-editor.schema.jinja_template"
),
},
{
label: "{% %}",
type: "text",
detail: ctx.localize(
"ui.components.yaml-editor.schema.jinja_block"
),
},
]
: [
{ label: "{{ }}", type: "text" },
{ label: "{% %}", type: "text" },
];
}
return null;
}
// ---------------------------------------------------------------------------
// Completion source
// ---------------------------------------------------------------------------
export interface HaYamlCompletionContext {
/** The current field schema map, or undefined when no schema is active. */
schema?: YamlFieldSchemaMap;
/** Localize callback for the completion chrome (badges, type hints). */
localize?: LocalizeFunc;
/** Optional entity states for EntitySelector completions. */
states?: HassEntities;
/** Optional device registry for DeviceSelector completions. */
devices?: Record<string, DeviceRegistryEntry>;
/** Optional area registry for AreaSelector completions. */
areas?: Record<string, AreaRegistryEntry>;
/** Optional floor registry for FloorSelector completions. */
floors?: Record<string, FloorRegistryEntry>;
/** Optional label registry for LabelSelector completions. */
labels?: LabelRegistryEntry[];
}
/**
* Returns a CodeMirror `CompletionSource` for field-aware YAML completions.
*
* `getContext` is called on every completion request rather than captured once,
* so a schema or registry that changes after the editor was created is picked
* up without recreating the editor. Register the result once per editor via
* `autocompletion({ override: [..., haYamlCompletionSource(getContext)] })`.
*/
export function haYamlCompletionSource(
getContext: () => HaYamlCompletionContext
): (context: CompletionContext) => CompletionResult | null {
return (context: CompletionContext): CompletionResult | null => {
const ctx = getContext();
const rootSchema = ctx.schema;
if (!rootSchema) return null;
const { state, pos } = context;
const doc = state.doc.toString();
const tree = syntaxTree(state);
const node = tree.resolveInner(pos, -1);
// ---- SEQUENCE ITEM position: cursor is inside a list item ---------------
// Lezer YAML: Pair → BlockSequence → Item → Literal
if (
node.name === "Literal" &&
node.parent?.name === "Item" &&
node.parent?.parent?.name === "BlockSequence"
) {
const seqNode = node.parent.parent; // BlockSequence
const pairNode = seqNode.parent; // Pair
if (pairNode?.name === "Pair") {
const keyNode = pairNode.getChild("Key");
const keyLit = keyNode?.firstChild ?? keyNode;
if (keyLit) {
const keyText = nodeText(keyLit, doc);
const ancestorPath = getAncestorKeyPath(pairNode.parent, doc);
const fullPath = [...ancestorPath, keyText];
const field = resolveFieldSchema(rootSchema, fullPath);
if (field) {
const completions = valueCompletionsForSelector(field, ctx);
if (completions) {
return {
options: completions,
from: node.from,
validFor: VALUE_VALID_FOR,
};
}
}
}
}
return null;
}
// ---- EMPTY SEQUENCE ITEM: cursor after "- " with no Literal yet ----------
if (node.name === "-" && node.parent?.name === "BlockSequence") {
// Only fire when cursor is strictly past the dash (space has been typed).
if (pos <= node.to) return null;
const seqNode = node.parent;
const pairNode = seqNode.parent;
if (pairNode?.name === "Pair") {
const keyNode = pairNode.getChild("Key");
const keyLit = keyNode?.firstChild ?? keyNode;
if (keyLit) {
const keyText = nodeText(keyLit, doc);
const ancestorPath = getAncestorKeyPath(pairNode.parent, doc);
const fullPath = [...ancestorPath, keyText];
const field = resolveFieldSchema(rootSchema, fullPath);
if (field) {
const completions = valueCompletionsForSelector(field, ctx);
if (completions) {
return {
options: completions,
from: pos,
validFor: VALUE_VALID_FOR,
};
}
}
}
}
return null;
}
// ---- VALUE position: cursor is in a Literal value of a Pair --------------
// Lezer YAML: Pair → Key, ":", Literal(value)
if (node.name === "Literal" && node.parent?.name === "Pair") {
const pair = node.parent;
const keyNode = pair.getChild("Key");
if (keyNode) {
const keyLit = keyNode.firstChild ?? keyNode;
const keyText = nodeText(keyLit, doc);
const ancestorPath = getAncestorKeyPath(pair.parent, doc);
const fullPath = [...ancestorPath, keyText];
const field = resolveFieldSchema(rootSchema, fullPath);
if (field) {
// If this field has sub-fields (nested mapping), the Literal is
// actually the first key being typed — offer key completions from
// the sub-schema rather than value completions.
if (field.fields && Object.keys(field.fields).length > 0) {
const word = context.matchBefore(/[\w_-]*/);
const fromPos = word ? word.from : pos;
const completions: Completion[] = Object.entries(field.fields).map(
([key, subField]) => ({
label: key,
type: "yaml-key",
detail: subField.required ? requiredLabel(ctx) : undefined,
info: describe(subField, ctx),
apply: buildKeyApply(key, subField),
boost: subField.required ? 10 : 0,
})
);
if (completions.length === 0) return null;
return {
options: completions,
from: fromPos,
validFor: /^[\w_-]*$/,
};
}
const completions = valueCompletionsForSelector(field, ctx);
if (completions) {
const valueLiteral =
pair
.getChildren("Literal")
.find((n) => n !== keyNode.firstChild && n !== keyNode) ?? null;
const from = valueLiteral ? valueLiteral.from : pos;
return { options: completions, from, validFor: VALUE_VALID_FOR };
}
}
}
return null;
}
// ---- EMPTY VALUE: cursor is right after "key: " with no value yet --------
// When there's no value Literal, lezer puts the cursor on the ":" node
// inside the Pair, or on the Pair itself just after the colon.
if (node.name === ":" && node.parent?.name === "Pair") {
const pair = node.parent;
// Only fire when cursor is strictly past the colon (i.e. at least one
// space has been typed), so we don't insert right after "key:".
if (pos > node.to) {
const keyNode = pair.getChild("Key");
if (keyNode) {
const keyLit = keyNode.firstChild ?? keyNode;
const keyText = nodeText(keyLit, doc);
const ancestorPath = getAncestorKeyPath(pair.parent, doc);
const fullPath = [...ancestorPath, keyText];
const field = resolveFieldSchema(rootSchema, fullPath);
if (field) {
// Nested mapping field — offer key completions from sub-schema.
if (field.fields && Object.keys(field.fields).length > 0) {
const completions: Completion[] = Object.entries(
field.fields
).map(([key, subField]) => ({
label: key,
type: "yaml-key",
detail: subField.required ? requiredLabel(ctx) : undefined,
info: describe(subField, ctx),
apply: buildKeyApply(key, subField),
boost: subField.required ? 10 : 0,
}));
if (completions.length > 0) {
return { options: completions, from: pos };
}
}
const completions = valueCompletionsForSelector(field, ctx);
if (completions) {
return {
options: completions,
from: pos,
validFor: VALUE_VALID_FOR,
};
}
}
}
}
return null;
}
// ---- KEY position: cursor is on a Key Literal or start of a new Pair ----
// Determine which schema level to offer completions at.
let schemaLevel: YamlFieldSchemaMap = rootSchema;
// Find the BlockMapping we are inside.
let keyLiteralNode: SyntaxNode | null = null;
// Are we inside a Key node?
if (node.name === "Literal" && node.parent?.name === "Key") {
keyLiteralNode = node;
} else if (node.name === "Key") {
// cursor is directly on a Key node, no literal node to pin
}
// Guard: walk up from the cursor node. If we pass through a node that is
// a non-BlockMapping value child of a Pair (scalar Literal, FlowSequence,
// FlowMapping, or a "," / "[" / "]" inside a flow node), the cursor is in
// a value position — do not offer key completions.
{
let n: SyntaxNode | null = keyLiteralNode ?? node;
while (n) {
const p = n.parent;
if (p?.name === "Pair") {
// n is a direct child of a Pair. If it is NOT a Key, it is value-side.
if (n.name !== "Key" && n.name !== ":") {
// BlockMapping / BlockSequence as value means nested keys — OK.
if (n.name !== "BlockMapping" && n.name !== "BlockSequence") {
return null;
}
}
}
// Inside any flow node (FlowSequence, FlowMapping) → value position.
if (
n.name === "FlowSequence" ||
n.name === "FlowMapping" ||
n.name === "," ||
n.name === "[" ||
n.name === "]" ||
n.name === "{" ||
n.name === "}"
) {
return null;
}
n = p;
}
}
// Also guard: cursor is past end of an inner BlockMapping (e.g. "brightness: |"
// where the inner BM ends at the colon). Find the deepest Pair whose ":"
// is on the cursor line and which has no block value — that means cursor is
// in a scalar value gap.
// Also handles: empty sequence item "- |" where cursor is past the BlockSequence
// end — find the "-" on the cursor line by text-scanning, then locate it in
// the syntax tree to determine the field and return value completions.
{
const curLine = state.doc.lineAt(pos);
const lineText = state.doc.sliceString(curLine.from, curLine.to);
// Only proceed if the line looks like a sequence item (optional spaces + "- ")
const dashMatch = /^(\s*)-(\s*)$/.exec(lineText);
if (dashMatch && pos > curLine.from + dashMatch[1].length) {
// Find the "-" node in the tree by resolving at its text position.
const dashPos = curLine.from + dashMatch[1].length;
const dashNode = syntaxTree(state).resolveInner(dashPos, 1);
// Walk up to find the BlockSequence → Pair → field schema.
let n: SyntaxNode | null = dashNode;
while (n) {
if (n.name === "BlockSequence") {
const pairNode2 = n.parent;
if (pairNode2?.name === "Pair") {
const keyNode2 = pairNode2.getChild("Key");
const keyLit2 = keyNode2?.firstChild ?? keyNode2;
if (keyLit2) {
const keyText2 = nodeText(keyLit2, doc);
const ancestorPath2 = getAncestorKeyPath(pairNode2.parent, doc);
const field2 = resolveFieldSchema(rootSchema, [
...ancestorPath2,
keyText2,
]);
if (field2) {
const completions = valueCompletionsForSelector(field2, ctx);
if (completions) {
return {
options: completions,
from: pos,
validFor: VALUE_VALID_FOR,
};
}
}
}
}
return null;
}
n = n.parent;
}
}
}
// ---- SCALAR VALUE GAP: cursor is after "key: " but lezer has no Literal ----
// Resolve from the start of the cursor line to find the Pair whose key
// is on this line. Walking up from `pos` can land inside a sibling node's
// BlockSequence when the previous field has list items, causing us to
// miss the current Pair entirely.
{
const curLine2 = state.doc.lineAt(pos);
const lineText2 = state.doc.sliceString(curLine2.from, curLine2.to);
// Only proceed if line looks like " key: " (optional spaces, a key, colon, optional space)
// and NOT a sequence item ("- ").
const keyColonMatch = /^(\s*)[\w_-]+\s*:\s*$/.test(lineText2);
if (keyColonMatch) {
// Resolve a node at the line start to find the Pair for this key.
const lineStartNode = syntaxTree(state).resolveInner(
curLine2.from + lineText2.search(/\S/),
1
);
let n: SyntaxNode | null = lineStartNode;
while (n) {
if (n.name === "Pair") {
const colon = n.getChild(":");
if (
colon &&
state.doc.lineAt(colon.from).number === curLine2.number &&
pos > colon.to
) {
const hasBlockValue =
n.getChild("BlockMapping") !== null ||
n.getChild("BlockSequence") !== null ||
n.getChild("FlowSequence") !== null ||
n.getChild("FlowMapping") !== null;
const hasScalarValue = n.getChildren("Literal").length > 1;
if (!hasBlockValue && !hasScalarValue) {
// Cursor is in scalar value gap (e.g. "area_id: |").
const keyNode2 = n.getChild("Key");
const keyLit2 = keyNode2?.firstChild ?? keyNode2;
if (keyLit2) {
const keyText2 = nodeText(keyLit2, doc);
const ancestorPath2 = getAncestorKeyPath(n.parent, doc);
const field2 = resolveFieldSchema(rootSchema, [
...ancestorPath2,
keyText2,
]);
if (field2) {
const completions2 = valueCompletionsForSelector(
field2,
ctx
);
if (completions2) {
return {
options: completions2,
from: pos,
validFor: VALUE_VALID_FOR,
};
}
}
}
return null;
}
}
break; // found the Pair for this line, stop searching
}
n = n.parent;
}
}
}
// Walk up to find which BlockMapping this key belongs to.
// When the cursor is on an empty/blank line, resolveInner returns a parent
// BlockMapping rather than the inner one we're actually inside.
// Strategy: find the indentation of the cursor line, then look backwards
// for the nearest non-empty line at a *greater* indent — that line's first
// char resolves into the inner BlockMapping we want.
let bmNode: SyntaxNode | null = null;
let pairNode: SyntaxNode | null = null;
{
const curLine = state.doc.lineAt(pos);
const lineText = state.doc.sliceString(curLine.from, curLine.to);
const curIndent = lineText.search(/\S/);
let resolvePos: number;
if (curIndent >= 0) {
// Line has content — resolve from its first non-space char.
resolvePos = curLine.from + curIndent;
} else {
// Empty/blank line — scan backwards for a line with greater indent
// (i.e. a sibling or child line that's already inside the same block).
resolvePos = pos; // fallback
for (let ln = curLine.number - 1; ln >= 1; ln--) {
const prevLine = state.doc.line(ln);
const prevText = state.doc.sliceString(prevLine.from, prevLine.to);
const prevIndent = prevText.search(/\S/);
if (prevIndent < 0) continue; // skip blank lines
// A line with more indentation is inside the same or deeper block.
if (prevIndent > 0) {
resolvePos = prevLine.from + prevIndent;
break;
}
// Hit a root-level line — we're at root level.
break;
}
}
const resolveNode = syntaxTree(state).resolveInner(resolvePos, -1);
let n: SyntaxNode | null = keyLiteralNode ?? resolveNode;
while (n) {
if (n.name === "Pair") pairNode = n;
if (n.name === "BlockMapping") {
bmNode = n;
break;
}
n = n.parent;
}
}
if (bmNode) {
// Build the path of ancestor keys above this BlockMapping.
const ancestorPath = getAncestorKeyPath(bmNode, doc);
if (ancestorPath.length > 0) {
const parentField = resolveFieldSchema(rootSchema, ancestorPath);
schemaLevel = parentField?.fields ?? {};
}
}
if (Object.keys(schemaLevel).length === 0) return null;
// Determine what has already been typed for this key.
const word = context.matchBefore(/[\w_-]*/);
if (!word && !context.explicit) return null;
const fromPos = word ? word.from : pos;
// Exclude keys already present in the current mapping.
const alreadyUsed = new Set<string>();
if (bmNode) {
let c = bmNode.firstChild;
while (c) {
if (c.name === "Pair" && c !== pairNode) {
const k = c.getChild("Key");
const lit = k?.firstChild ?? k;
if (lit) alreadyUsed.add(nodeText(lit, doc));
}
c = c.nextSibling;
}
}
const completions: Completion[] = Object.entries(schemaLevel)
.filter(([key]) => !alreadyUsed.has(key))
.map(([key, field]) => ({
label: key,
type: "yaml-key",
detail: field.required ? requiredLabel(ctx) : undefined,
info: describe(field, ctx),
// Insert "key: " or "key:\n " depending on selector type
apply: buildKeyApply(key, field),
boost: field.required ? 10 : 0,
}));
if (completions.length === 0) return null;
return { options: completions, from: fromPos, validFor: /^[\w_-]*$/ };
};
}
/**
* Build the text to insert when a key completion is accepted.
* For object/sequence selectors we add a newline; for simple values "key: ".
*/
function buildKeyApply(key: string, field: YamlFieldSchema): string {
const type = field.selector ? Object.keys(field.selector)[0] : null;
if (type === "object" || type === "action" || type === "condition") {
return `${key}:\n `;
}
if (field.fields && Object.keys(field.fields).length > 0) {
return `${key}:\n `;
}
return `${key}: `;
}
/**
* Walk up from a node (expected to be a BlockMapping) and collect the
* sequence of Pair keys that enclose it.
*/
function getAncestorKeyPath(bm: SyntaxNode | null, doc: string): string[] {
const path: string[] = [];
let cur: SyntaxNode | null = bm;
while (cur) {
if (cur.name === "Pair") {
const keyNode = cur.getChild("Key");
const lit = keyNode?.firstChild ?? keyNode;
if (lit) path.unshift(nodeText(lit, doc));
}
cur = cur.parent;
}
return path.filter(Boolean);
}
// ---------------------------------------------------------------------------
// Hover tooltip source
// ---------------------------------------------------------------------------
/**
* Selector types whose values are HA IDs, mapped to the Jinja arg type that
* knows how to render a rich tooltip for them.
*/
const SELECTOR_ARG_TYPES: Record<string, JinjaArgType | undefined> = {
entity: "entity_id",
device: "device_id",
area: "area_id",
floor: "floor_id",
label: "label_id",
};
export interface HaYamlHoverContext {
/** The current field schema map. */
schema: YamlFieldSchemaMap;
/**
* Optional localize callback used to translate field descriptions that are
* i18n keys, and the tooltip's own labels. When absent, the raw string is
* displayed and the labels fall back to English.
*/
localize?: LocalizeFunc;
/** Optional HA context for rich entity/device/area value tooltips. */
hassContext?: HassArgHoverContext;
}
/**
* Returns a `hoverTooltip` callback. Register it via:
*
* hoverTooltip((view, pos) => haYamlHoverSource(view, pos, ctx))
*/
export function haYamlHoverSource(
view: EditorView,
pos: number,
ctx: HaYamlHoverContext
): Tooltip | null {
const doc = view.state.doc.toString();
const tree = syntaxTree(view.state);
const node = tree.resolveInner(pos, -1);
// ---- Value hover: entity/device/area Literal in a Pair value or list item --
if (ctx.hassContext && node.name === "Literal") {
// Resolve the Pair that owns this value — either directly (scalar value)
// or via BlockSequence → Item (list item).
let pair: SyntaxNode | null = null;
if (node.parent?.name === "Pair") {
// scalar value: Literal is a direct child of Pair (not the Key)
const keyNode = node.parent.getChild("Key");
const keyLit2 = keyNode?.firstChild ?? keyNode;
if (keyLit2 && node !== keyLit2 && node.from !== keyLit2.from) {
pair = node.parent;
}
} else if (
node.parent?.name === "Item" &&
node.parent.parent?.name === "BlockSequence" &&
node.parent.parent.parent?.name === "Pair"
) {
// list item: Literal → Item → BlockSequence → Pair
pair = node.parent.parent.parent;
}
if (pair) {
const keyNode = pair.getChild("Key");
const keyLit2 = keyNode?.firstChild ?? keyNode;
if (keyLit2) {
const keyText2 = nodeText(keyLit2, doc);
const ancestorPath2 = getAncestorKeyPath(pair.parent, doc);
const field2 = resolveFieldSchema(ctx.schema, [
...ancestorPath2,
keyText2,
]);
if (field2?.selector) {
const selectorType = Object.keys(field2.selector)[0];
const argType = SELECTOR_ARG_TYPES[selectorType];
if (argType) {
const value = nodeText(node, doc);
const dom = buildArgTooltipDom(argType, value, ctx.hassContext);
if (dom) {
return {
pos: node.from,
end: node.to,
above: true,
create: () => ({ dom }),
};
}
}
}
}
}
}
// ---- Key hover: show field name, description, type, example, default ----
let keyLit: SyntaxNode | null = null;
if (node.name === "Literal" && node.parent?.name === "Key") {
keyLit = node;
} else if (node.name === "Key") {
keyLit = node.firstChild;
}
if (!keyLit) return null;
const keyText = nodeText(keyLit, doc);
if (!keyText) return null;
// Build the path from ancestor BlockMappings.
const pairNode = keyLit.parent?.parent; // Literal → Key → Pair
const bmNode = pairNode?.parent; // Pair → BlockMapping
const ancestorPath = getAncestorKeyPath(bmNode ?? null, doc);
const fullPath = [...ancestorPath, keyText];
const field = resolveFieldSchema(ctx.schema, fullPath);
if (!field) return null;
return {
pos: keyLit.from,
end: keyLit.to,
above: true,
create() {
const dom = document.createElement("ha-code-editor-yaml-hover");
dom.fieldName = keyText;
dom.fieldSchema = field;
dom.localize = ctx.localize;
return { dom };
},
};
}
// ---------------------------------------------------------------------------
// Linting
// ---------------------------------------------------------------------------
export interface Diagnostic {
from: number;
to: number;
severity: "error" | "warning" | "info";
message: string;
}
/**
* Produces lint diagnostics for a YAML document given a field schema.
*
* Checks, at every mapping level the schema describes:
* - Keys the schema doesn't know (warning), unless the level was marked with
* `allowUnknownFields()`
* - Required keys that are missing (error)
*/
export function haYamlLintSource(
view: EditorView,
schema: YamlFieldSchemaMap,
localize?: LocalizeFunc
): Diagnostic[] {
const diagnostics: Diagnostic[] = [];
const doc = view.state.doc.toString();
function lintMapping(
bmNode: SyntaxNode,
schemaLevel: YamlFieldSchemaMap
): void {
const presentKeys = new Set<string>();
let child = bmNode.firstChild;
while (child) {
if (child.name === "Pair") {
const keyNode = child.getChild("Key");
const lit = keyNode?.firstChild ?? keyNode;
if (lit) {
const key = nodeText(lit, doc);
presentKeys.add(key);
if (!(key in schemaLevel)) {
if (!hasAllowUnknownFields(schemaLevel)) {
diagnostics.push({
from: lit.from,
to: lit.to,
severity: "warning",
message:
localize?.("ui.components.yaml-editor.schema.unknown_field", {
field: key,
}) || `Unknown field: "${key}"`,
});
}
} else {
// Recurse into nested mappings.
const fieldDef = schemaLevel[key];
if (fieldDef.fields) {
const valueNode = child.getChildren("BlockMapping").find(Boolean);
if (valueNode) lintMapping(valueNode, fieldDef.fields);
}
}
}
}
child = child.nextSibling;
}
// Report required fields that are missing from this mapping. Anchored to
// the start of the mapping, since there is no key to point at.
for (const [key, fieldDef] of Object.entries(schemaLevel)) {
if (fieldDef.required && !presentKeys.has(key)) {
const from = bmNode.parent?.from ?? 0;
diagnostics.push({
from,
to: from + 1,
severity: "error",
message:
localize?.("ui.components.yaml-editor.schema.missing_field", {
field: key,
}) || `Required field missing: "${key}"`,
});
}
}
}
// Find the root BlockMapping.
// With jinja({ base: yaml() }) the outer tree is Template → Text, and the
// YAML parse is mounted on the Text node via NodeProp.mounted. With plain
// yaml() the tree is Stream → Document → BlockMapping directly.
let bm: SyntaxNode | null = null;
const outerTree = syntaxTree(view.state);
// Try plain yaml() path first: walk down until BlockMapping.
let cur: SyntaxNode | null = outerTree.topNode;
while (cur && cur.name !== "BlockMapping") {
// If this node has a mounted subtree (jinja wrapper), use that tree instead.
const mounted = cur.node?.tree
? cur.node.tree.prop(NodeProp.mounted)
: null;
if (mounted) {
// The mounted tree root — walk down into it.
cur = mounted.tree.topNode;
continue;
}
cur = cur.firstChild;
}
if (cur?.name === "BlockMapping") {
bm = cur;
}
if (bm) {
lintMapping(bm, schema);
}
return diagnostics;
}
+37 -168
View File
@@ -1435,16 +1435,7 @@
"exit_fullscreen": "Exit fullscreen",
"find_and_replace": "Find and replace",
"test_on": "Turn on testing",
"test_off": "Turn off testing",
"schema": {
"required": "Required",
"example": "Example:",
"default": "Default:",
"unknown_field": "Unknown field: \"{field}\"",
"missing_field": "Required field missing: \"{field}\"",
"jinja_template": "Jinja2 template",
"jinja_block": "Jinja2 block"
}
"test_off": "Turn off testing"
},
"state-content-picker": {
"state": "State",
@@ -3454,7 +3445,7 @@
"agents": {
"cloud_agent_description": "Note: It stores only the most recent backup, regardless of your retention settings, with a maximum size of 5 GB.",
"cloud_agent_no_subcription": "You currently do not have an active Home Assistant Cloud subscription.",
"network_mount_agent_description": "Network storage",
"network_mount_agent_description": "Additional storage",
"unavailable_agents": "Unavailable locations",
"no_agents": "No locations configured",
"encryption_turned_off": "Encryption turned off",
@@ -3739,7 +3730,7 @@
"no_location": "No location selected",
"no_location_description": "You have to select at least one location to create a backup.",
"more_locations": "Explore more locations",
"manage_network_storage": "Manage network storage",
"manage_network_storage": "Manage storage",
"ha_cloud_backup": "{home_assistant_cloud} backup",
"ha_cloud_description": "Stores an encrypted backup offsite. This is ideal in case your local backup becomes inaccessible. The cloud stores one backup with a maximum size of 5 GB, and older backups are automatically deleted."
},
@@ -6050,150 +6041,7 @@
},
"paste_toast_message": "Pasted automation from clipboard",
"paste_invalid_yaml": "Pasted value is not valid YAML",
"paste_invalid_config": "Pasted automation is not editable in the visual editor",
"yaml_schema": {
"trigger_base": {
"trigger": "The trigger type (platform).",
"id": "An optional ID for the trigger, used to identify it in conditions or templates.",
"alias": "A friendly name for this trigger.",
"enabled": "Whether this trigger is enabled. Defaults to true.",
"variables": "Variables to set when this trigger fires."
},
"condition_base": {
"condition": "The condition type.",
"alias": "A friendly name for this condition.",
"enabled": "Whether this condition is enabled. Defaults to true."
},
"action_base": {
"alias": "A friendly name for this action.",
"enabled": "Whether this action is enabled. Defaults to true.",
"continue_on_error": "If true, the automation continues even if this action fails."
},
"actions": {
"delay": {
"delay": "Duration to wait. Can be a number (seconds), a time string (HH:MM:SS), or a mapping with hours/minutes/seconds/milliseconds."
},
"wait_template": {
"wait_template": "A Jinja2 template that must evaluate to true before continuing.",
"timeout": "Maximum time to wait. After this the action continues (or stops if continue_on_timeout is false).",
"continue_on_timeout": "Whether to continue when the timeout is reached. Defaults to true."
},
"wait_for_trigger": {
"wait_for_trigger": "One or more triggers to wait for before continuing.",
"timeout": "Maximum time to wait.",
"continue_on_timeout": "[%key:ui::panel::config::automation::editor::yaml_schema::actions::wait_template::continue_on_timeout%]"
},
"event": {
"event": "The event type to fire.",
"event_data": "Data to include with the event.",
"event_data_template": "Templated data to include with the event."
},
"condition": {
"condition": "The condition type to check. The automation stops if the condition is false."
},
"stop": {
"stop": "Message to log when stopping the automation.",
"error": "If true, this is logged as an error. Defaults to false.",
"response_variable": "Variable name to store when stopping and returning a response."
},
"repeat": {
"repeat": "Repeat configuration — use count, while, until, or for_each.",
"count": "Number of times to repeat.",
"while": "Repeat while these conditions are true.",
"until": "Repeat until these conditions are true.",
"for_each": "List of items to iterate over.",
"sequence": "Actions to perform on each iteration."
},
"choose": {
"choose": "List of options; the first matching one is executed.",
"conditions": "Conditions that must be met for this option to run.",
"sequence": "Actions to run if the conditions match.",
"alias": "A friendly name for this option.",
"default": "Actions to run when no option matched."
},
"if": {
"if": "Conditions to check.",
"then": "Actions to run when the condition is true.",
"else": "Actions to run when the condition is false."
},
"sequence": {
"sequence": "A list of actions to run in order."
},
"parallel": {
"parallel": "A list of actions (or scripts) to run in parallel."
},
"variables": {
"variables": "Key/value pairs to set as variables in the automation context."
},
"set_conversation_response": {
"set_conversation_response": "The text response to return to the conversation agent."
},
"service": {
"data": "Service call data (field values).",
"response_variable": "Variable name to store the action response in.",
"action": "The action to call."
}
},
"conditions": {
"zone": {
"zone": "The zone the entity must be in.",
"entity_id": "The person or device_tracker entity to check."
},
"and": {
"conditions": "All of these conditions must be true."
},
"or": {
"conditions": "At least one of these conditions must be true."
},
"not": {
"conditions": "None of these conditions must be true."
},
"options": "Condition options."
},
"triggers": {
"event": {
"event_type": "The event type to listen for.",
"event_data": "Optional event data to match.",
"context": "Optional context to match (e.g. user_id)."
},
"zone": {
"entity_id": "The person or device_tracker entity to watch.",
"zone": "The zone to watch.",
"event": "Whether to trigger on zone entry or exit."
},
"tag": {
"tag_id": "The NFC/QR tag ID(s) to watch."
},
"webhook": {
"webhook_id": "The webhook ID. Will be part of the webhook URL.",
"allowed_methods": "HTTP methods that are accepted (default: POST, PUT).",
"local_only": "Only allow requests from the local network. Defaults to true."
},
"conversation": {
"command": "The voice command phrase(s) to match."
},
"options": "Trigger options."
},
"target": {
"target": "The target entities, devices, areas, floors, or labels.",
"entity_id": "One or more entity IDs to target.",
"device_id": "One or more device IDs to target.",
"area_id": "One or more area IDs to target.",
"floor_id": "One or more floor IDs to target.",
"label_id": "One or more label IDs to target."
},
"numeric_threshold": {
"active_choice": "Whether to use a fixed number or an entity state.",
"number": "Fixed numeric value.",
"entity": "Entity whose state provides the value.",
"unit_of_measurement": "Unit of measurement.",
"value_entry": "Threshold value entry.",
"type": "Comparison type.",
"value": "Threshold value (for above / below types).",
"value_min": "Lower bound (for between / outside types).",
"value_max": "Upper bound (for between / outside types)."
}
}
"paste_invalid_config": "Pasted automation is not editable in the visual editor"
},
"trace": {
"refresh": "[%key:ui::common::refresh%]",
@@ -6966,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."
@@ -7047,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",
@@ -7339,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}",
@@ -8783,7 +8638,7 @@
"join_chat": "Chat",
"join_blog": "Blog",
"join_newsletter": "Newsletter",
"media_storage": "You can add network storage to your Home Assistant instance in the {storage} panel."
"media_storage": "You can add additional storage to your Home Assistant instance in the {storage} panel."
},
"analytics": {
"caption": "Analytics",
@@ -9046,15 +8901,16 @@
"move": "Move"
},
"network_mounts": {
"title": "Network storage",
"add_title": "Add network storage",
"update_title": "Update network storage",
"no_mounts": "No connected network storage",
"title": "Additional storage",
"add_title": "Add storage",
"update_title": "Update storage",
"no_mounts": "No additional storage configured",
"no_disk_candidates": "No suitable disks were found. Connect a disk with a supported file system, then reopen this dialog.",
"documentation": "Documentation",
"not_supported": {
"title": "The operating system does not support network storage",
"supervised": "Network storage is not supported on this host",
"os": "To use network storage you need to run at least Home Assistant OS {version}",
"title": "The operating system does not support additional storage",
"supervised": "Additional storage is not supported on this host",
"os": "To use additional storage you need to run at least Home Assistant OS {version}",
"navigate_to_updates": "Go to updates"
},
"mount_usage": {
@@ -9064,7 +8920,8 @@
},
"mount_type": {
"nfs": "Network File System (NFS)",
"cifs": "Samba/Windows (CIFS)"
"cifs": "Samba/Windows (CIFS)",
"disk": "Local disk"
},
"cifs_versions": {
"auto": "Auto (2.1+)",
@@ -9092,13 +8949,25 @@
"description": "This is the path of the remote share on your storage server"
},
"type": {
"title": "Protocol",
"description": "This determines how to communicate with the storage server"
"title": "Type",
"description": "This determines the kind of storage you want to add"
},
"usage": {
"title": "Usage",
"description": "This determines how the share is intended to be used"
},
"device": {
"title": "Disk",
"description": "This is the disk connected to your system that you want to use"
},
"device_identity": {
"title": "Disk",
"description": "This is the disk this storage was set up with, which cannot be changed"
},
"read_only": {
"title": "Read-only",
"description": "Home Assistant will not be able to write to this storage. Backup storage cannot be read-only"
},
"version": {
"title": "Samba/Windows (CIFS) version",
"description": "This chooses the version of the protocol to use"
@@ -10617,7 +10486,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,55 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_MIN_KELVIN } from "../../../src/common/color/convert-light-color";
import { getSelectorFallbackValue } from "../../../src/components/ha-form/get-selector-fallback-value";
describe("getSelectorFallbackValue", () => {
it("returns the constant selector value", () => {
expect(getSelectorFallbackValue({ constant: { value: "fixed" } })).toBe(
"fixed"
);
expect(getSelectorFallbackValue({ constant: { value: 0 } })).toBe(0);
expect(getSelectorFallbackValue({ constant: { value: false } })).toBe(
false
);
});
it("returns false for boolean selectors", () => {
expect(getSelectorFallbackValue({ boolean: null })).toBe(false);
expect(getSelectorFallbackValue({ boolean: {} })).toBe(false);
});
it("returns number min, or 0 when min is omitted", () => {
expect(getSelectorFallbackValue({ number: { min: 2000 } })).toBe(2000);
expect(getSelectorFallbackValue({ number: { min: 0, max: 100 } })).toBe(0);
expect(getSelectorFallbackValue({ number: null })).toBe(0);
});
it("returns kelvin min for color_temp, falling back to DEFAULT_MIN_KELVIN", () => {
expect(
getSelectorFallbackValue({
color_temp: { unit: "kelvin", min: 2000, max: 6500 },
})
).toBe(2000);
expect(getSelectorFallbackValue({ color_temp: { unit: "kelvin" } })).toBe(
DEFAULT_MIN_KELVIN
);
});
it("returns mired min for color_temp, including legacy min_mireds", () => {
expect(
getSelectorFallbackValue({
color_temp: { unit: "mired", min: 154, max: 500 },
})
).toBe(154);
expect(getSelectorFallbackValue({ color_temp: { min_mireds: 160 } })).toBe(
160
);
expect(getSelectorFallbackValue({ color_temp: null })).toBe(153);
});
it("returns undefined when the selector has no displayed fallback", () => {
expect(getSelectorFallbackValue({ text: null })).toBeUndefined();
expect(getSelectorFallbackValue({ entity: null })).toBeUndefined();
expect(getSelectorFallbackValue({ target: {} })).toBeUndefined();
});
});
@@ -0,0 +1,176 @@
import { describe, expect, it } from "vitest";
import "../../../src/components/target-picker/ha-target-picker-item-row";
import type { HaTargetPickerItemRow } from "../../../src/components/target-picker/ha-target-picker-item-row";
import type { AreaRegistryEntry } from "../../../src/data/area/area_registry";
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
import type { EntityRegistryDisplayEntry } from "../../../src/data/entity/entity_registry";
import type {
ExtractFromTargetResult,
TargetType,
} from "../../../src/data/target";
import type { HomeAssistant } from "../../../src/types";
const extractResult = (
referenced: Partial<ExtractFromTargetResult>
): ExtractFromTargetResult => ({
missing_areas: [],
missing_devices: [],
missing_floors: [],
missing_labels: [],
referenced_areas: [],
referenced_devices: [],
referenced_entities: [],
...referenced,
});
const mkEntity = (
entity_id: string,
rest: Partial<EntityRegistryDisplayEntry> = {}
): EntityRegistryDisplayEntry => ({ entity_id, labels: [], ...rest });
const mkDevice = (
id: string,
rest: Partial<DeviceRegistryEntry> = {}
): DeviceRegistryEntry =>
({ id, area_id: null, labels: [], ...rest }) as DeviceRegistryEntry;
interface Registries {
entities?: Record<string, EntityRegistryDisplayEntry>;
devices?: Record<string, DeviceRegistryEntry>;
areas?: Record<string, AreaRegistryEntry>;
}
// Runs the row's extraction against `result`, with only the registry entries in
// `registries` available to filter it.
const extractedBy = async (
result: ExtractFromTargetResult,
registries: Registries,
{
type = "area",
itemId = "area_1",
}: { type?: TargetType; itemId?: string } = {}
) => {
const el = document.createElement(
"ha-target-picker-item-row"
) as HaTargetPickerItemRow;
el.type = type;
el.itemId = itemId;
el.hass = {
areas: {},
devices: {},
entities: {},
states: {},
callWS: async () => result,
...registries,
} as unknown as HomeAssistant;
await (el as any)._updateItemData();
return (el as any)._entries as ExtractFromTargetResult | undefined;
};
describe("ha-target-picker-item-row target extraction", () => {
it("drops entities that are missing from the entity registry", async () => {
// Disabled entities are referenced by core but never reach the display
// registry, which is the crash in #52964.
const entries = await extractedBy(
extractResult({
referenced_entities: ["light.known", "light.disabled"],
}),
{
entities: {
"light.known": mkEntity("light.known", { area_id: "area_1" }),
},
}
);
expect(entries).toBeDefined();
expect(entries!.referenced_entities).toEqual(["light.known"]);
});
it("keeps every entity when all registry entries resolve", async () => {
const entries = await extractedBy(
extractResult({
referenced_entities: ["light.one", "light.two"],
}),
{
entities: {
"light.one": mkEntity("light.one", { area_id: "area_1" }),
"light.two": mkEntity("light.two", { area_id: "area_1" }),
},
}
);
expect(entries).toBeDefined();
expect(entries!.referenced_entities).toEqual(["light.one", "light.two"]);
});
it("drops a device missing from the registry, and entities linked only through it", async () => {
const entries = await extractedBy(
extractResult({
referenced_devices: ["dev_known", "dev_missing"],
referenced_entities: ["light.on_known_dev", "light.on_missing_dev"],
}),
{
devices: { dev_known: mkDevice("dev_known") },
entities: {
"light.on_known_dev": mkEntity("light.on_known_dev", {
device_id: "dev_known",
}),
"light.on_missing_dev": mkEntity("light.on_missing_dev", {
device_id: "dev_missing",
}),
},
}
);
expect(entries).toBeDefined();
expect(entries!.referenced_devices).toEqual(["dev_known"]);
expect(entries!.referenced_entities).toEqual(["light.on_known_dev"]);
});
it("keeps an entity targeted through its own area when its device is missing", async () => {
// A device we do not know about is not a filter decision, so it must not
// take an entity that the area targets directly down with it.
const entries = await extractedBy(
extractResult({
referenced_devices: ["dev_missing"],
referenced_entities: ["light.explicit_area"],
}),
{
entities: {
"light.explicit_area": mkEntity("light.explicit_area", {
area_id: "area_1",
device_id: "dev_missing",
}),
},
}
);
expect(entries).toBeDefined();
expect(entries!.referenced_entities).toEqual(["light.explicit_area"]);
});
it("keeps devices of a floor whose area is missing from the registry", async () => {
// Same rule for areas: an area we do not know about must not mark itself
// hidden and drop the devices the floor references through it.
const entries = await extractedBy(
extractResult({
referenced_areas: ["area_missing"],
referenced_devices: ["dev_1"],
referenced_entities: ["light.on_dev1"],
}),
{
areas: {},
devices: { dev_1: mkDevice("dev_1", { area_id: "area_missing" }) },
entities: {
"light.on_dev1": mkEntity("light.on_dev1", { device_id: "dev_1" }),
},
},
{ type: "floor", itemId: "floor_1" }
);
expect(entries).toBeDefined();
expect(entries!.referenced_devices).toEqual(["dev_1"]);
expect(entries!.referenced_entities).toEqual(["light.on_dev1"]);
});
});
@@ -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" }
);
});
});
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import type {
SupervisorCIFSMount,
SupervisorDiskMount,
SupervisorNFSMount,
} from "../../src/data/supervisor/mounts";
import {
SupervisorMountState,
SupervisorMountType,
SupervisorMountUsage,
supervisorMountDescription,
} from "../../src/data/supervisor/mounts";
const nfsMount = (
overrides: Partial<SupervisorNFSMount> = {}
): SupervisorNFSMount => ({
name: "my_nfs",
type: SupervisorMountType.NFS,
usage: SupervisorMountUsage.MEDIA,
state: SupervisorMountState.ACTIVE,
server: "nas.local",
path: "/export/media",
...overrides,
});
const cifsMount = (
overrides: Partial<SupervisorCIFSMount> = {}
): SupervisorCIFSMount => ({
name: "my_share",
type: SupervisorMountType.CIFS,
usage: SupervisorMountUsage.MEDIA,
state: SupervisorMountState.ACTIVE,
server: "nas.local",
share: "media",
...overrides,
});
const diskMount = (
overrides: Partial<SupervisorDiskMount> = {}
): SupervisorDiskMount => ({
name: "media_disk",
type: SupervisorMountType.DISK,
usage: SupervisorMountUsage.MEDIA,
state: SupervisorMountState.ACTIVE,
uuid: "e3f1a2b4-5c6d-7e8f-9a0b-1c2d3e4f5a6b",
filesystem: "ext4",
...overrides,
});
describe("supervisorMountDescription", () => {
it("describes an NFS mount by server and path", () => {
expect(supervisorMountDescription(nfsMount())).toBe(
"nas.local/export/media"
);
});
it("describes a CIFS mount by server and share", () => {
expect(supervisorMountDescription(cifsMount())).toBe("nas.local:media");
});
it("includes the port only when Supervisor reports one", () => {
expect(supervisorMountDescription(nfsMount({ port: 2049 }))).toBe(
"nas.local:2049/export/media"
);
expect(supervisorMountDescription(nfsMount({ port: undefined }))).toBe(
"nas.local/export/media"
);
});
it("describes a disk mount by filesystem and uuid", () => {
expect(supervisorMountDescription(diskMount())).toBe(
"ext4 • e3f1a2b4-5c6d-7e8f-9a0b-1c2d3e4f5a6b"
);
});
it("omits the filesystem when Supervisor has not resolved one", () => {
expect(
supervisorMountDescription(diskMount({ filesystem: undefined }))
).toBe("e3f1a2b4-5c6d-7e8f-9a0b-1c2d3e4f5a6b");
});
it("never describes a disk mount using network fields", () => {
// A disk mount has no server, share or path, which previously rendered as
// "undefined" in the storage panel and the mount picker.
expect(supervisorMountDescription(diskMount())).not.toContain("undefined");
});
});
+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;
};
@@ -1,364 +0,0 @@
import { describe, expect, test } from "vitest";
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
import type { Action } from "../../../../src/data/script";
import type { HomeAssistant } from "../../../../src/types";
import {
actionSchemaKey,
actionToYamlSchema,
builtInActionSchema,
builtInConditionSchema,
builtInTriggerSchema,
haFormSchemaToYamlFieldSchemaMap,
serviceActionSchema,
} from "../../../../src/panels/config/automation/yaml_schema_helpers";
import type { YamlFieldSchemaMap } from "../../../../src/resources/yaml_field_schema";
import { hasAllowUnknownFields } from "../../../../src/resources/yaml_field_schema";
import en from "../../../../src/translations/en.json";
const localize = ((key: string) => key) as HomeAssistant["localize"];
const BUILT_IN_ACTION_TYPES = [
"delay",
"wait_template",
"wait_for_trigger",
"event",
"condition",
"stop",
"repeat",
"choose",
"if",
"sequence",
"parallel",
"variables",
"set_conversation_response",
];
const BUILT_IN_TRIGGER_TYPES = [
"state",
"numeric_state",
"event",
"homeassistant",
"template",
"time",
"time_pattern",
"sun",
"zone",
"tag",
"webhook",
"geo_location",
"calendar",
"persistent_notification",
"conversation",
"device",
];
const BUILT_IN_CONDITION_TYPES = [
"state",
"numeric_state",
"template",
"time",
"sun",
"zone",
"trigger",
"and",
"or",
"not",
"device",
];
describe("haFormSchemaToYamlFieldSchemaMap", () => {
test("maps selector entries straight through", () => {
const schema = [
{ name: "entity_id", required: true, selector: { entity: {} } },
] as const satisfies readonly HaFormSchema[];
expect(haFormSchemaToYamlFieldSchemaMap(schema)).toEqual({
entity_id: {
required: true,
default: undefined,
description: undefined,
selector: { entity: {} },
},
});
});
test("converts select entries into a select selector", () => {
const schema = [
{
name: "behavior",
type: "select",
options: [
["first", "First"],
["all", "All"],
],
},
] as const satisfies readonly HaFormSchema[];
expect(haFormSchemaToYamlFieldSchemaMap(schema).behavior.selector).toEqual({
select: {
options: [
{ value: "first", label: "First" },
{ value: "all", label: "All" },
],
},
});
});
test("flattens grid and expandable containers into the parent map", () => {
const schema = [
{
name: "grid",
type: "grid",
schema: [{ name: "above", selector: { number: {} } }],
},
{
name: "advanced",
type: "expandable",
schema: [{ name: "for", selector: { duration: {} } }],
},
] as const satisfies readonly HaFormSchema[];
expect(Object.keys(haFormSchemaToYamlFieldSchemaMap(schema))).toEqual([
"above",
"for",
]);
});
test("uses the description callback when given", () => {
const schema = [
{ name: "entity_id", selector: { entity: {} } },
] as const satisfies readonly HaFormSchema[];
expect(
haFormSchemaToYamlFieldSchemaMap(schema, (field) => `desc:${field}`)
.entity_id.description
).toBe("desc:entity_id");
});
test("skips entries without a name and unsupported types", () => {
const schema = [
{ type: "constant", name: "info", value: "x" },
] as const satisfies readonly HaFormSchema[];
expect(haFormSchemaToYamlFieldSchemaMap(schema)).toEqual({});
});
});
describe("actionSchemaKey", () => {
test("returns the service name for service calls", () => {
expect(
actionSchemaKey({ action: "light.turn_on" } as unknown as Action)
).toBe("light.turn_on");
});
test("returns the built-in key for built-in actions", () => {
expect(actionSchemaKey({ delay: "00:00:05" } as unknown as Action)).toBe(
"delay"
);
expect(
actionSchemaKey({ choose: [], default: [] } as unknown as Action)
).toBe("choose");
});
test("is stable while the action's values change", () => {
expect(
actionSchemaKey({
action: "light.turn_on",
target: { entity_id: "light.kitchen" },
} as unknown as Action)
).toBe(
actionSchemaKey({
action: "light.turn_on",
target: { entity_id: "light.bedroom" },
data: { brightness: 5 },
} as unknown as Action)
);
});
test("returns undefined for an unrecognized action", () => {
expect(actionSchemaKey({ unknown_thing: 1 } as unknown as Action)).toBe(
undefined
);
});
});
describe("actionToYamlSchema", () => {
const services = {
light: {
turn_on: {
name: "Turn on",
description: "",
fields: {
brightness: {
selector: { number: { min: 0, max: 255 } },
description: "Brightness",
},
},
target: { entity: [{ domain: ["light"] }] },
},
},
} as unknown as HomeAssistant["services"];
test("builds a service schema for service calls", () => {
const schema = actionToYamlSchema("light.turn_on", services, localize)!;
expect(schema.action.required).toBe(true);
expect(schema.data.fields?.brightness.selector).toEqual({
number: { min: 0, max: 255 },
});
// The service's target filter is forwarded into the entity_id selector.
expect(schema.target.fields?.entity_id.selector).toEqual({
entity: { multiple: true, filter: [{ domain: ["light"] }] },
});
});
test("builds a built-in schema for built-in actions", () => {
expect(actionToYamlSchema("delay", services, localize)).toBe(
builtInActionSchema("delay")
);
});
test("falls back to base fields that allow unknown keys", () => {
const schema = actionToYamlSchema(undefined, services, localize)!;
expect(Object.keys(schema)).toEqual([
"alias",
"enabled",
"continue_on_error",
]);
expect(hasAllowUnknownFields(schema)).toBe(true);
});
test("does not leak the allow-unknown marker into other schemas", () => {
actionToYamlSchema(undefined, services, localize);
expect(hasAllowUnknownFields(builtInActionSchema("delay")!)).toBe(false);
});
});
describe("serviceActionSchema", () => {
test("hoists grouped advanced fields to the top level of data", () => {
const services = {
light: {
turn_on: {
name: "Turn on",
description: "",
fields: {
brightness: { selector: { number: {} } },
advanced_fields: {
collapsed: true,
fields: { transition: { selector: { number: {} } } },
},
},
},
},
} as unknown as HomeAssistant["services"];
const schema = serviceActionSchema("light", "turn_on", services, localize);
expect(Object.keys(schema.data.fields!)).toEqual([
"brightness",
"transition",
]);
});
test("does not mark a field required when it has a default", () => {
const services = {
light: {
turn_on: {
name: "Turn on",
description: "",
fields: {
brightness: { required: true, default: 255, selector: {} },
color_name: { required: true, selector: {} },
},
},
},
} as unknown as HomeAssistant["services"];
const fields = serviceActionSchema("light", "turn_on", services, localize)
.data.fields!;
expect(fields.brightness.required).toBe(false);
expect(fields.color_name.required).toBe(true);
});
});
describe("builtInTriggerSchema / builtInConditionSchema", () => {
test("always includes the shared base fields", () => {
expect(Object.keys(builtInTriggerSchema("state", localize)!)).toEqual(
expect.arrayContaining(["trigger", "id", "alias", "enabled", "variables"])
);
expect(Object.keys(builtInConditionSchema("state", localize)!)).toEqual(
expect.arrayContaining(["condition", "alias", "enabled"])
);
});
test("device triggers and conditions accept integration-specific keys", () => {
expect(
hasAllowUnknownFields(builtInTriggerSchema("device", localize)!)
).toBe(true);
expect(
hasAllowUnknownFields(builtInConditionSchema("device", localize)!)
).toBe(true);
});
test("known trigger types do not accept unknown keys", () => {
expect(
hasAllowUnknownFields(builtInTriggerSchema("state", localize)!)
).toBe(false);
});
});
describe("built-in field descriptions", () => {
// Built-in schemas carry translation keys as their description; the hover
// tooltip and completion info resolve them through localize(). A key that
// isn't in en.json would silently render as the raw key.
const resolve = (key: string): unknown =>
key
.split(".")
.reduce<any>((node, part) => (node ? node[part] : undefined), en);
const collect = (map: YamlFieldSchemaMap | undefined, into: string[]) => {
for (const field of Object.values(map ?? {})) {
if (field.description) into.push(field.description);
collect(field.fields, into);
}
return into;
};
const descriptionsOf = (map: YamlFieldSchemaMap | undefined) =>
collect(map, []);
test.each(BUILT_IN_ACTION_TYPES)("action %s", (type) => {
const keys = descriptionsOf(builtInActionSchema(type));
expect(keys.length).toBeGreaterThan(0);
keys.forEach((key) => expect(resolve(key), key).toBeTypeOf("string"));
});
test.each(BUILT_IN_TRIGGER_TYPES)("trigger %s", (type) => {
const keys = descriptionsOf(builtInTriggerSchema(type, localize));
expect(keys.length).toBeGreaterThan(0);
keys.forEach((key) => expect(resolve(key), key).toBeTypeOf("string"));
});
test.each(BUILT_IN_CONDITION_TYPES)("condition %s", (type) => {
const keys = descriptionsOf(builtInConditionSchema(type, localize));
expect(keys.length).toBeGreaterThan(0);
keys.forEach((key) => expect(resolve(key), key).toBeTypeOf("string"));
});
test("service action schema", () => {
const keys = descriptionsOf(
serviceActionSchema(
"light",
"turn_on",
{} as HomeAssistant["services"],
localize
)
);
expect(keys.length).toBeGreaterThan(0);
keys.forEach((key) => expect(resolve(key), key).toBeTypeOf("string"));
});
});
@@ -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,
});
});
});
-119
View File
@@ -1,119 +0,0 @@
import { jinja } from "@codemirror/lang-jinja";
import { yaml } from "@codemirror/lang-yaml";
import { EditorState } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import { describe, expect, test } from "vitest";
import { haYamlLintSource } from "../../src/resources/yaml_ha_completions";
import type { YamlFieldSchemaMap } from "../../src/resources/yaml_field_schema";
import { allowUnknownFields } from "../../src/resources/yaml_field_schema";
// haYamlLintSource only reads `view.state`, so a state-only stub is enough.
const viewFor = (doc: string, language = jinja({ base: yaml() })) =>
({
state: EditorState.create({ doc, extensions: [language] }),
}) as EditorView;
const SCHEMA: YamlFieldSchemaMap = {
trigger: { required: true, selector: { text: null } },
entity_id: { selector: { entity: null } },
options: {
selector: { object: null },
fields: {
above: { required: true, selector: { number: {} } },
below: { selector: { number: {} } },
},
},
};
describe("haYamlLintSource", () => {
test("accepts a document that matches the schema", () => {
expect(
haYamlLintSource(
viewFor("trigger: state\nentity_id: light.kitchen\n"),
SCHEMA
)
).toEqual([]);
});
test("warns about a key the schema doesn't know", () => {
const diagnostics = haYamlLintSource(
viewFor("trigger: state\nnot_a_field: 1\n"),
SCHEMA
);
expect(diagnostics).toHaveLength(1);
expect(diagnostics[0].severity).toBe("warning");
expect(diagnostics[0].message).toContain("not_a_field");
});
test("errors when a required key is missing", () => {
const diagnostics = haYamlLintSource(
viewFor("entity_id: light.kitchen\n"),
SCHEMA
);
expect(diagnostics).toHaveLength(1);
expect(diagnostics[0].severity).toBe("error");
expect(diagnostics[0].message).toContain("trigger");
});
test("checks nested mappings against the nested schema", () => {
const diagnostics = haYamlLintSource(
viewFor("trigger: numeric_state\noptions:\n bogus: 1\n"),
SCHEMA
);
expect(diagnostics.map((d) => d.severity).sort()).toEqual([
"error",
"warning",
]);
expect(
diagnostics.find((d) => d.severity === "warning")!.message
).toContain("bogus");
// "above" is required inside options.
expect(diagnostics.find((d) => d.severity === "error")!.message).toContain(
"above"
);
});
test("stays quiet about unknown keys on a map marked allowUnknownFields", () => {
const schema = allowUnknownFields({
trigger: { required: true, selector: { text: null } },
});
expect(
haYamlLintSource(
viewFor("trigger: device\ndomain: zha\nintegration_specific: 1\n"),
schema
)
).toEqual([]);
});
test("localizes diagnostics when a localize callback is given", () => {
const diagnostics = haYamlLintSource(
viewFor("trigger: state\nnot_a_field: 1\n"),
SCHEMA,
((key: string, values?: Record<string, string>) =>
`${key}|${values?.field}`) as any
);
expect(diagnostics[0].message).toBe(
"ui.components.yaml-editor.schema.unknown_field|not_a_field"
);
});
test("finds the mapping through a plain yaml() tree too", () => {
const diagnostics = haYamlLintSource(
viewFor("trigger: state\nnot_a_field: 1\n", yaml()),
SCHEMA
);
expect(diagnostics).toHaveLength(1);
expect(diagnostics[0].message).toContain("not_a_field");
});
test("reports nothing for an empty document", () => {
expect(haYamlLintSource(viewFor(""), SCHEMA)).toEqual([]);
});
});
+73 -73
View File
@@ -5771,105 +5771,105 @@ __metadata:
languageName: node
linkType: hard
"@typescript-eslint/eslint-plugin@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.66.0"
"@typescript-eslint/eslint-plugin@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.67.0"
dependencies:
"@eslint-community/regexpp": "npm:^4.12.2"
"@typescript-eslint/scope-manager": "npm:8.66.0"
"@typescript-eslint/type-utils": "npm:8.66.0"
"@typescript-eslint/utils": "npm:8.66.0"
"@typescript-eslint/visitor-keys": "npm:8.66.0"
"@typescript-eslint/scope-manager": "npm:8.67.0"
"@typescript-eslint/type-utils": "npm:8.67.0"
"@typescript-eslint/utils": "npm:8.67.0"
"@typescript-eslint/visitor-keys": "npm:8.67.0"
ignore: "npm:^7.0.5"
natural-compare: "npm:^1.4.0"
ts-api-utils: "npm:^2.5.0"
peerDependencies:
"@typescript-eslint/parser": ^8.66.0
"@typescript-eslint/parser": ^8.67.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/bb144ff0c27592b6a53d964fe1e2145dd0737f5fe50fe7dcd88217ca5ac4e470d7965d97745a63de16252ac2880d00732a472b9cec14d7d6929ebda97e7bcbb1
checksum: 10/d53f9e51c6be98ff4cdcb94de24e2951f5e2b7a1dfc56c4f8a2d81e61346c511c0478117626d9cfd5d5d63f5e7a0e0653dc1abe1e7d926a3f9806aaf1fa11bd2
languageName: node
linkType: hard
"@typescript-eslint/parser@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/parser@npm:8.66.0"
"@typescript-eslint/parser@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/parser@npm:8.67.0"
dependencies:
"@typescript-eslint/scope-manager": "npm:8.66.0"
"@typescript-eslint/types": "npm:8.66.0"
"@typescript-eslint/typescript-estree": "npm:8.66.0"
"@typescript-eslint/visitor-keys": "npm:8.66.0"
"@typescript-eslint/scope-manager": "npm:8.67.0"
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/typescript-estree": "npm:8.67.0"
"@typescript-eslint/visitor-keys": "npm:8.67.0"
debug: "npm:^4.4.3"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/a0451abe17d2eeff6ff2e1bf637d5ea19b400378d98f4511672e0f30dc5a7971d03352e32764927176c3632f245d5f85e8b28a0a3e66d020effcf0c61624e9b6
checksum: 10/fa156ade066d0886ea0daa0f872940cdb450a2b5dbd1687e0df64ec19aac86711dde51e17e6b20e72387aa38daec811736821e9db950e8557affa7faea63d65c
languageName: node
linkType: hard
"@typescript-eslint/project-service@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/project-service@npm:8.66.0"
"@typescript-eslint/project-service@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/project-service@npm:8.67.0"
dependencies:
"@typescript-eslint/tsconfig-utils": "npm:^8.66.0"
"@typescript-eslint/types": "npm:^8.66.0"
"@typescript-eslint/tsconfig-utils": "npm:^8.67.0"
"@typescript-eslint/types": "npm:^8.67.0"
debug: "npm:^4.4.3"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/e8a71f69ee7f4bd9ca1c6a0a5110361b9927d17df2f41cdf6d042203b49aabfbddece79dec7882fde8dc016b6278671a2ba3f7bf08e5ae241582627b68be3a8b
checksum: 10/3beccdee1e74060eac4e6c5e7edd14a800c8c98e3313dbfe5838a357e83babc22c41c3ebe637c331c4b93db2da50f5479ccf4677db7b6b1a760f3cd27795b36f
languageName: node
linkType: hard
"@typescript-eslint/scope-manager@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/scope-manager@npm:8.66.0"
"@typescript-eslint/scope-manager@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/scope-manager@npm:8.67.0"
dependencies:
"@typescript-eslint/types": "npm:8.66.0"
"@typescript-eslint/visitor-keys": "npm:8.66.0"
checksum: 10/f2024cd2819dc3b967f32089ea1a0b9099acabf12860699432f0ba802d75b01ed35ea488a50576cae544813d5cbe9bd9ee5815bc4f11cefc89fe2fb24559238f
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/visitor-keys": "npm:8.67.0"
checksum: 10/a0ca8d52e6c5b2538f7df860acd5a36437cce8e6d5bcededdfde04c02489f4b523cb178b502c266d138cff6758ea375ebe311b2b6af373b127ded4876a697ffb
languageName: node
linkType: hard
"@typescript-eslint/tsconfig-utils@npm:8.66.0, @typescript-eslint/tsconfig-utils@npm:^8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/tsconfig-utils@npm:8.66.0"
"@typescript-eslint/tsconfig-utils@npm:8.67.0, @typescript-eslint/tsconfig-utils@npm:^8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/tsconfig-utils@npm:8.67.0"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/271b28660edc9e6272ae07a91f8da2c69e8a6b009e8588f1009c485c11fe4e66e09356d50dbeabf8e6a44c95c8adeb630f1b6089bdd68aec2512d0036cef7704
checksum: 10/736d0ab8a273b4e4e17d412b390386eec0171cd3f7ba451bacd92c4f931051bba2049e040fdae84603ea0e07430afb05b49c6d8b6d429f9012d3c6b03eb69f54
languageName: node
linkType: hard
"@typescript-eslint/type-utils@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/type-utils@npm:8.66.0"
"@typescript-eslint/type-utils@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/type-utils@npm:8.67.0"
dependencies:
"@typescript-eslint/types": "npm:8.66.0"
"@typescript-eslint/typescript-estree": "npm:8.66.0"
"@typescript-eslint/utils": "npm:8.66.0"
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/typescript-estree": "npm:8.67.0"
"@typescript-eslint/utils": "npm:8.67.0"
debug: "npm:^4.4.3"
ts-api-utils: "npm:^2.5.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/6d3c55a591b96cbac4ac845ad1d281bac54df12a631ca550370d266aed30760fa9d498e44b938e454941930e3decda820c6e544e0748254c5089425d120e1ed1
checksum: 10/a0b943e5a8f3c2e8490ec28599eb94561d7ad029fcf42f2e9a0e8d2685ce625898371cd0a7d3a906fe8b4077d72fee8329cad512a5b0521bc2167f7d88a984fb
languageName: node
linkType: hard
"@typescript-eslint/types@npm:8.66.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/types@npm:8.66.0"
checksum: 10/de1ea79e7056c11e38e4001a899b88511793a0aa1702508af42ee179bc7303bed2eb02f177a7ae438f20eab00aee9407c7e842bec6fc5b7bb1fdcab4a7cd5c4b
"@typescript-eslint/types@npm:8.67.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/types@npm:8.67.0"
checksum: 10/8edc1a14a52c7566409c19b34fb72ba3a62a3b409c98b1d496de9fcd83179fb128c8df2fde391c199316a54cdc5d314ba70ce7b440161dd850b73a44f3e54e6d
languageName: node
linkType: hard
"@typescript-eslint/typescript-estree@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/typescript-estree@npm:8.66.0"
"@typescript-eslint/typescript-estree@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/typescript-estree@npm:8.67.0"
dependencies:
"@typescript-eslint/project-service": "npm:8.66.0"
"@typescript-eslint/tsconfig-utils": "npm:8.66.0"
"@typescript-eslint/types": "npm:8.66.0"
"@typescript-eslint/visitor-keys": "npm:8.66.0"
"@typescript-eslint/project-service": "npm:8.67.0"
"@typescript-eslint/tsconfig-utils": "npm:8.67.0"
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/visitor-keys": "npm:8.67.0"
debug: "npm:^4.4.3"
minimatch: "npm:^10.2.2"
semver: "npm:^7.7.3"
@@ -5877,32 +5877,32 @@ __metadata:
ts-api-utils: "npm:^2.5.0"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/0834efcdd4468c0dd954089daf26b40175e204402bd4075b06cd910786bb6de7a15aec4e79e40f0980f39fce5bfd9c871ec8fcc6871a8895a34a273bb187fd26
checksum: 10/31c5be812f67c997b9d1e9324c4289b6268a12e9948492403fdd9c9b5df12dcc00f845ae93ae37c78f717583519060175f2147f165b65112eef1e40acf44ad1d
languageName: node
linkType: hard
"@typescript-eslint/utils@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/utils@npm:8.66.0"
"@typescript-eslint/utils@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/utils@npm:8.67.0"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.9.1"
"@typescript-eslint/scope-manager": "npm:8.66.0"
"@typescript-eslint/types": "npm:8.66.0"
"@typescript-eslint/typescript-estree": "npm:8.66.0"
"@typescript-eslint/scope-manager": "npm:8.67.0"
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/typescript-estree": "npm:8.67.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/e7a24ee0f35d58b4392daa03fb1ea37ab7e4519949a034be89151df1b0e9fc416831f50cb69f54150272e252cba350964632ee9bfbdb7e62ac599cc731de56e1
checksum: 10/8703468cdea3630275c6faacc031e7e4bd7fb8f8988e80ab987811ae3c63a9a9677496cee2b4811f49cb9bc9e6f6c0bdb91cb213353019ce31df0a2b351cea51
languageName: node
linkType: hard
"@typescript-eslint/visitor-keys@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/visitor-keys@npm:8.66.0"
"@typescript-eslint/visitor-keys@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/visitor-keys@npm:8.67.0"
dependencies:
"@typescript-eslint/types": "npm:8.66.0"
"@typescript-eslint/types": "npm:8.67.0"
eslint-visitor-keys: "npm:^5.0.0"
checksum: 10/d47e936b10ec94a96f69f77a62463f4634fe72d12d42dd302d7777f7b98689468e0b67c538a52232b723695208f1b3fdeae5745eb3e71a70a90818fdd827a0bc
checksum: 10/c92c9b202105a8f398ab2ad4be58c8c8fd9af6b5ddd28cdcd57d75f7f04f69561a56a05aac9a008668c64faa7222ebb3fefe19097923a18eb2b9f7a90809a5be
languageName: node
linkType: hard
@@ -10040,7 +10040,7 @@ __metadata:
tinykeys: "patch:tinykeys@npm%3A4.0.0#~/.yarn/patches/tinykeys-npm-4.0.0-a6ca3fd771.patch"
ts-lit-plugin: "npm:2.0.2"
typescript: "npm:6.0.3"
typescript-eslint: "npm:8.66.0"
typescript-eslint: "npm:8.67.0"
vite-tsconfig-paths: "npm:6.1.1"
vitest: "npm:4.1.10"
webpack-stats-plugin: "npm:1.1.3"
@@ -14892,18 +14892,18 @@ __metadata:
languageName: node
linkType: hard
"typescript-eslint@npm:8.66.0":
version: 8.66.0
resolution: "typescript-eslint@npm:8.66.0"
"typescript-eslint@npm:8.67.0":
version: 8.67.0
resolution: "typescript-eslint@npm:8.67.0"
dependencies:
"@typescript-eslint/eslint-plugin": "npm:8.66.0"
"@typescript-eslint/parser": "npm:8.66.0"
"@typescript-eslint/typescript-estree": "npm:8.66.0"
"@typescript-eslint/utils": "npm:8.66.0"
"@typescript-eslint/eslint-plugin": "npm:8.67.0"
"@typescript-eslint/parser": "npm:8.67.0"
"@typescript-eslint/typescript-estree": "npm:8.67.0"
"@typescript-eslint/utils": "npm:8.67.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/e76da9c684e4fe012b49a5120dd57553df0788cf1cffcbd5dc72c98de33e549f306495e710292f111ee058366b1e5d8d603c2bb14385b12bb35edc7bbf13ad55
checksum: 10/4efc2cea5b067f65d58e010fda3888ec94f2e67ebfa289be78c3a02458787b7125cf8f6095776b912005fb69e8748ad4018777b16436a0658957c163ef213bf9
languageName: node
linkType: hard