mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-14 18:37:37 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4f54540c2 | ||
|
|
f23c8f0e23 | ||
|
|
f578cb0526 | ||
|
|
78878897cc | ||
|
|
9472d8ad12 | ||
|
|
94cbe4467b | ||
|
|
043abb69af | ||
|
|
b6b8fe641f | ||
|
|
70bb08b101 | ||
|
|
4259ddee24 | ||
|
|
6c5ac1ea84 | ||
|
|
5224fa2048 |
@@ -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[] = [
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
+14
-49
@@ -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 {
|
||||
|
||||
+4
-23
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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"), []);
|
||||
});
|
||||
});
|
||||
@@ -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" }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Vendored
+32
-9
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user