mirror of
https://github.com/home-assistant/frontend.git
synced 2026-04-22 18:42:52 +00:00
Compare commits
81 Commits
retro-east
...
20260325.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7e92b484a | ||
|
|
9fab7bafdb | ||
|
|
0dabb02007 | ||
|
|
5b73e86786 | ||
|
|
144d7c5c3f | ||
|
|
8b396dc640 | ||
|
|
9bf48d30ab | ||
|
|
35fee46f5b | ||
|
|
9ac6636029 | ||
|
|
136462114d | ||
|
|
cf542197e0 | ||
|
|
5c2627624a | ||
|
|
698ded9d85 | ||
|
|
9a7fb96873 | ||
|
|
204c5b5e14 | ||
|
|
8ea3acfa98 | ||
|
|
306739773e | ||
|
|
8b3fa3adac | ||
|
|
37a1d59a24 | ||
|
|
6812884e00 | ||
|
|
bf7ef1f7ae | ||
|
|
fe57f601ba | ||
|
|
c89d478440 | ||
|
|
fa27d26e5f | ||
|
|
18f411ef53 | ||
|
|
24826e92f0 | ||
|
|
ea9d369d88 | ||
|
|
a9b026d0ef | ||
|
|
35339906ec | ||
|
|
ce23f716cc | ||
|
|
aaf8fa199f | ||
|
|
fba430d507 | ||
|
|
59361cbd38 | ||
|
|
b558117d8c | ||
|
|
a7c8347751 | ||
|
|
31ca9c849a | ||
|
|
6252d7e8f5 | ||
|
|
f42986adf6 | ||
|
|
9e70ea3723 | ||
|
|
de3b7bf513 | ||
|
|
2c5f491c9e | ||
|
|
1ef13c5100 | ||
|
|
c166335aca | ||
|
|
c64ec21eca | ||
|
|
8d62056f4a | ||
|
|
62e73608b6 | ||
|
|
aa66d8891c | ||
|
|
494a96c635 | ||
|
|
36d77f54ce | ||
|
|
12fec9f580 | ||
|
|
5f1f55448a | ||
|
|
837e345ecf | ||
|
|
0929d7d18a | ||
|
|
70991d3c1e | ||
|
|
82e5bd62a1 | ||
|
|
b8adf4e866 | ||
|
|
111be984e0 | ||
|
|
78a2cbb532 | ||
|
|
34b09b140b | ||
|
|
f173f901c5 | ||
|
|
ebb6ac8d8b | ||
|
|
abe214a33a | ||
|
|
248332ae27 | ||
|
|
82fc2fccdc | ||
|
|
c8f30a7ee4 | ||
|
|
77f48d91cd | ||
|
|
caa707a7b1 | ||
|
|
0bed0fa37e | ||
|
|
5b6309d984 | ||
|
|
264818bc70 | ||
|
|
d664ab6836 | ||
|
|
a6c4184054 | ||
|
|
cb6985eb7c | ||
|
|
d466ab63bd | ||
|
|
1132cdb364 | ||
|
|
0f9d48a03d | ||
|
|
7e085d9b08 | ||
|
|
1a62c7296c | ||
|
|
be1921229c | ||
|
|
640558ad35 | ||
|
|
99636c9719 |
@@ -99,6 +99,41 @@ const lokaliseProjects = {
|
||||
frontend: "3420425759f6d6d241f598.13594006",
|
||||
};
|
||||
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
|
||||
/* eslint-disable no-await-in-loop */
|
||||
async function pollProcess(lokaliseApi, projectId, processId) {
|
||||
while (true) {
|
||||
const process = await lokaliseApi
|
||||
.queuedProcesses()
|
||||
.get(processId, { project_id: projectId });
|
||||
|
||||
const project =
|
||||
projectId === lokaliseProjects.backend ? "backend" : "frontend";
|
||||
|
||||
if (process.status === "finished") {
|
||||
console.log(`Lokalise export process for ${project} finished`);
|
||||
return process;
|
||||
}
|
||||
|
||||
if (process.status === "failed" || process.status === "cancelled") {
|
||||
throw new Error(
|
||||
`Lokalise export process for ${project} ${process.status}: ${process.message}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Lokalise export process for ${project} in progress...`,
|
||||
process.status
|
||||
);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, POLL_INTERVAL_MS);
|
||||
});
|
||||
}
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
|
||||
gulp.task("fetch-lokalise", async function () {
|
||||
let apiKey;
|
||||
try {
|
||||
@@ -118,55 +153,60 @@ gulp.task("fetch-lokalise", async function () {
|
||||
]);
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(lokaliseProjects).map(([project, projectId]) =>
|
||||
lokaliseApi
|
||||
.files()
|
||||
.download(projectId, {
|
||||
format: "json",
|
||||
original_filenames: false,
|
||||
replace_breaks: false,
|
||||
json_unescaped_slashes: true,
|
||||
export_empty_as: "skip",
|
||||
filter_data: ["verified"],
|
||||
})
|
||||
.then((download) => fetch(download.bundle_url))
|
||||
.then((response) => {
|
||||
if (response.status === 200 || response.status === 0) {
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
Object.entries(lokaliseProjects).map(async ([project, projectId]) => {
|
||||
try {
|
||||
const exportProcess = await lokaliseApi
|
||||
.files()
|
||||
.async_download(projectId, {
|
||||
format: "json",
|
||||
original_filenames: false,
|
||||
replace_breaks: false,
|
||||
json_unescaped_slashes: true,
|
||||
export_empty_as: "skip",
|
||||
filter_data: ["verified"],
|
||||
});
|
||||
|
||||
const finishedProcess = await pollProcess(
|
||||
lokaliseApi,
|
||||
projectId,
|
||||
exportProcess.process_id
|
||||
);
|
||||
|
||||
const bundleUrl = finishedProcess.details.download_url;
|
||||
|
||||
console.log(`Downloading translations from: ${bundleUrl}`);
|
||||
|
||||
const response = await fetch(bundleUrl);
|
||||
|
||||
if (response.status !== 200 && response.status !== 0) {
|
||||
throw new Error(response.statusText);
|
||||
})
|
||||
.then(JSZip.loadAsync)
|
||||
.then(async (contents) => {
|
||||
await mkdirPromise;
|
||||
return Promise.all(
|
||||
Object.keys(contents.files).map(async (filename) => {
|
||||
const file = contents.file(filename);
|
||||
if (!file) {
|
||||
// no file, probably a directory
|
||||
return Promise.resolve();
|
||||
}
|
||||
return file
|
||||
.async("nodebuffer")
|
||||
.then((content) =>
|
||||
fs.writeFile(
|
||||
path.join(
|
||||
inDir,
|
||||
project,
|
||||
filename.split("/").splice(-1)[0]
|
||||
),
|
||||
content,
|
||||
{ flag: "w", encoding }
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
throw err;
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`Extracting translations...`);
|
||||
|
||||
const contents = await JSZip.loadAsync(await response.arrayBuffer());
|
||||
|
||||
await mkdirPromise;
|
||||
await Promise.all(
|
||||
Object.keys(contents.files).map(async (filename) => {
|
||||
const file = contents.file(filename);
|
||||
if (!file) {
|
||||
// no file, probably a directory
|
||||
return;
|
||||
}
|
||||
const content = await file.async("nodebuffer");
|
||||
await fs.writeFile(
|
||||
path.join(inDir, project, filename.split("/").splice(-1)[0]),
|
||||
content,
|
||||
{ flag: "w", encoding }
|
||||
);
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
|
||||
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
|
||||
@@ -692,7 +692,11 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
|
||||
([key, value]) => html`
|
||||
<ha-settings-row narrow slot=${slot}>
|
||||
<span slot="heading">${value?.name || key}</span>
|
||||
<span slot="description">${value?.description}</span>
|
||||
${value?.description
|
||||
? html`<span slot="description"
|
||||
>${value?.description}</span
|
||||
>`
|
||||
: nothing}
|
||||
<ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${value!.selector}
|
||||
|
||||
@@ -134,6 +134,21 @@ const CONFIGS = [
|
||||
entity: sensor.not_working
|
||||
`,
|
||||
},
|
||||
{
|
||||
heading: "Lower minimum",
|
||||
config: `
|
||||
- type: gauge
|
||||
entity: sensor.brightness_high
|
||||
needle: true
|
||||
severity:
|
||||
green: 0
|
||||
yellow: 0.45
|
||||
red: 0.9
|
||||
min: -0.05
|
||||
name: " "
|
||||
max: 1.9
|
||||
unit: GBP/h`,
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("demo-lovelace-gauge-card")
|
||||
|
||||
@@ -129,7 +129,6 @@
|
||||
"stacktrace-js": "2.0.2",
|
||||
"superstruct": "2.0.2",
|
||||
"tinykeys": "3.0.0",
|
||||
"ua-parser-js": "2.0.9",
|
||||
"weekstart": "2.0.0",
|
||||
"workbox-cacheable-response": "7.4.0",
|
||||
"workbox-core": "7.4.0",
|
||||
@@ -169,7 +168,6 @@
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@types/ua-parser-js": "0.7.39",
|
||||
"@types/webspeechapi": "0.0.29",
|
||||
"@vitest/coverage-v8": "4.1.0",
|
||||
"babel-loader": "10.1.1",
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20260325.0"
|
||||
version = "20260325.7"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
@@ -21,6 +21,9 @@ export const filterNavigationPages = (
|
||||
if (page.path === "#external-app-configuration") {
|
||||
return hass.auth.external?.config.hasSettingsScreen;
|
||||
}
|
||||
if (page.adminOnly && !hass.user?.is_admin) {
|
||||
return false;
|
||||
}
|
||||
// Only show Bluetooth page if there are Bluetooth config entries
|
||||
if (page.component === "bluetooth") {
|
||||
return options.hasBluetoothConfigEntries ?? false;
|
||||
|
||||
@@ -4,11 +4,14 @@ import type {
|
||||
EntityRegistryEntry,
|
||||
} from "../../data/entity/entity_registry";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { computeDeviceName } from "./compute_device_name";
|
||||
import { computeStateName } from "./compute_state_name";
|
||||
import { stripPrefixFromEntityName } from "./strip_prefix_from_entity_name";
|
||||
|
||||
export const computeEntityName = (
|
||||
stateObj: HassEntity,
|
||||
entities: HomeAssistant["entities"]
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"]
|
||||
): string | undefined => {
|
||||
const entry = entities[stateObj.entity_id] as
|
||||
| EntityRegistryDisplayEntry
|
||||
@@ -18,22 +21,49 @@ export const computeEntityName = (
|
||||
// Fall back to state name if not in the entity registry (friendly name)
|
||||
return computeStateName(stateObj);
|
||||
}
|
||||
return computeEntityEntryName(entry);
|
||||
return computeEntityEntryName(entry, devices);
|
||||
};
|
||||
|
||||
export const computeEntityEntryName = (
|
||||
entry: EntityRegistryDisplayEntry | EntityRegistryEntry
|
||||
entry: EntityRegistryDisplayEntry | EntityRegistryEntry,
|
||||
devices: HomeAssistant["devices"],
|
||||
fallbackStateObj?: HassEntity
|
||||
): string | undefined => {
|
||||
if (entry.name != null) {
|
||||
return entry.name;
|
||||
const name =
|
||||
entry.name ||
|
||||
("original_name" in entry && entry.original_name != null
|
||||
? String(entry.original_name)
|
||||
: undefined);
|
||||
|
||||
const device = entry.device_id ? devices[entry.device_id] : undefined;
|
||||
|
||||
if (!device) {
|
||||
if (name) {
|
||||
return name;
|
||||
}
|
||||
if (fallbackStateObj) {
|
||||
return computeStateName(fallbackStateObj);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if ("original_name" in entry && entry.original_name != null) {
|
||||
return String(entry.original_name);
|
||||
|
||||
const deviceName = computeDeviceName(device);
|
||||
|
||||
// If the device name is the same as the entity name, consider empty entity name
|
||||
if (deviceName === name) {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
|
||||
// Remove the device name from the entity name if it starts with it
|
||||
if (deviceName && name) {
|
||||
return stripPrefixFromEntityName(name, deviceName) || name;
|
||||
}
|
||||
|
||||
return name;
|
||||
};
|
||||
|
||||
export const entityUseDeviceName = (
|
||||
stateObj: HassEntity,
|
||||
entities: HomeAssistant["entities"]
|
||||
): boolean => !computeEntityName(stateObj, entities);
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"]
|
||||
): boolean => !computeEntityName(stateObj, entities, devices);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { computeAreaName } from "./compute_area_name";
|
||||
import { computeDeviceName } from "./compute_device_name";
|
||||
import { computeEntityName, entityUseDeviceName } from "./compute_entity_name";
|
||||
import { computeFloorName } from "./compute_floor_name";
|
||||
import { computeStateName } from "./compute_state_name";
|
||||
import { getEntityContext } from "./context/get_entity_context";
|
||||
|
||||
const DEFAULT_SEPARATOR = " ";
|
||||
@@ -29,14 +30,23 @@ export interface EntityNameOptions {
|
||||
|
||||
export const computeEntityNameDisplay = (
|
||||
stateObj: HassEntity,
|
||||
name: EntityNameItem | EntityNameItem[] | undefined,
|
||||
name: string | EntityNameItem | EntityNameItem[] | undefined,
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"],
|
||||
areas: HomeAssistant["areas"],
|
||||
floors: HomeAssistant["floors"],
|
||||
options?: EntityNameOptions
|
||||
) => {
|
||||
let items = ensureArray(name || DEFAULT_ENTITY_NAME);
|
||||
if (typeof name === "string") {
|
||||
return name;
|
||||
}
|
||||
|
||||
// If no name config is provided, fall back to the friendly name
|
||||
if (!name) {
|
||||
return computeStateName(stateObj);
|
||||
}
|
||||
|
||||
let items = ensureArray(name);
|
||||
|
||||
const separator = options?.separator ?? DEFAULT_SEPARATOR;
|
||||
|
||||
@@ -45,7 +55,7 @@ export const computeEntityNameDisplay = (
|
||||
return items.map((item) => item.text).join(separator);
|
||||
}
|
||||
|
||||
const useDeviceName = entityUseDeviceName(stateObj, entities);
|
||||
const useDeviceName = entityUseDeviceName(stateObj, entities, devices);
|
||||
|
||||
// If entity uses device name, and device is not already included, replace it with device name
|
||||
if (useDeviceName) {
|
||||
@@ -91,7 +101,7 @@ export const computeEntityNameList = (
|
||||
const names = name.map((item) => {
|
||||
switch (item.type) {
|
||||
case "entity":
|
||||
return computeEntityName(stateObj, entities);
|
||||
return computeEntityName(stateObj, entities, devices);
|
||||
case "device":
|
||||
return device ? computeDeviceName(device) : undefined;
|
||||
case "area":
|
||||
|
||||
@@ -142,9 +142,10 @@ const computeStateToPartsFromEntityAttributes = (
|
||||
group: "value",
|
||||
decimal: "value",
|
||||
fraction: "value",
|
||||
minusSign: "value",
|
||||
plusSign: "value",
|
||||
literal: "literal",
|
||||
currency: "unit",
|
||||
minusSign: "value",
|
||||
};
|
||||
|
||||
const valueParts: ValuePart[] = [];
|
||||
@@ -153,7 +154,7 @@ const computeStateToPartsFromEntityAttributes = (
|
||||
const type = TYPE_MAP[part.type];
|
||||
if (!type) continue;
|
||||
const last = valueParts[valueParts.length - 1];
|
||||
// Merge consecutive numeric parts (e.g. "1" + "," + "234" + "." + "56" → "1,234.56")
|
||||
// Merge consecutive value parts (e.g. "-" + "12" + "." + "00" → "-12.00")
|
||||
if (type === "value" && last?.type === "value") {
|
||||
last.value += part.value;
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Indicates whether the current browser has native ElementInternals support.
|
||||
*/
|
||||
export const nativeElementInternalsSupported =
|
||||
Boolean(globalThis.ElementInternals) &&
|
||||
globalThis.HTMLElement?.prototype.attachInternals
|
||||
?.toString()
|
||||
.includes("[native code]");
|
||||
11
src/common/feature-detect/support-popover.ts
Normal file
11
src/common/feature-detect/support-popover.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Indicates whether the current browser supports the Popover API.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/Popover_API
|
||||
*/
|
||||
export const popoverSupported = globalThis?.HTMLElement?.prototype
|
||||
? Object.prototype.hasOwnProperty.call(
|
||||
globalThis.HTMLElement.prototype,
|
||||
"popover"
|
||||
)
|
||||
: false;
|
||||
@@ -5,12 +5,41 @@ import {
|
||||
formatDateMonthYear,
|
||||
formatDateVeryShort,
|
||||
formatDateWeekdayShort,
|
||||
formatDateYear,
|
||||
} from "../../common/datetime/format_date";
|
||||
import {
|
||||
formatTime,
|
||||
formatTimeWithSeconds,
|
||||
} from "../../common/datetime/format_time";
|
||||
|
||||
export function getPeriodicAxisLabelConfig(
|
||||
period: string,
|
||||
locale: FrontendLocaleData,
|
||||
config: HassConfig
|
||||
):
|
||||
| {
|
||||
formatter: (value: number) => string;
|
||||
}
|
||||
| undefined {
|
||||
if (period === "month") {
|
||||
return {
|
||||
formatter: (value: number) => {
|
||||
const date = new Date(value);
|
||||
return date.getMonth() === 0
|
||||
? `{bold|${formatDateMonthYear(date, locale, config)}}`
|
||||
: formatDateMonth(date, locale, config);
|
||||
},
|
||||
};
|
||||
}
|
||||
if (period === "year") {
|
||||
return {
|
||||
formatter: (value: number) =>
|
||||
formatDateYear(new Date(value), locale, config),
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function formatTimeLabel(
|
||||
value: number | Date,
|
||||
locale: FrontendLocaleData,
|
||||
|
||||
@@ -587,10 +587,7 @@ export class HaChartBase extends LitElement {
|
||||
id: "dataZoom",
|
||||
type: "inside",
|
||||
orient: "horizontal",
|
||||
// "boundaryFilter" is a custom mode added via axis-proxy-patch.ts.
|
||||
// It rescales the Y-axis to the visible data while keeping one point
|
||||
// just outside each boundary to avoid line gaps at the zoom edges.
|
||||
filterMode: "boundaryFilter" as any,
|
||||
filterMode: this._getDataZoomFilterMode() as any,
|
||||
xAxisIndex: 0,
|
||||
moveOnMouseMove: !this._isTouchDevice || this._isZoomed,
|
||||
preventDefaultMouseMove: !this._isTouchDevice || this._isZoomed,
|
||||
@@ -598,6 +595,23 @@ export class HaChartBase extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
// "boundaryFilter" is a custom mode added via axis-proxy-patch.ts.
|
||||
// It rescales the Y-axis to the visible data while keeping one point
|
||||
// just outside each boundary to avoid line gaps at the zoom edges.
|
||||
// Use "filter" for bar charts since boundaryFilter causes rendering issues.
|
||||
// Use "weakFilter" for other types (e.g. custom/timeline) so bars
|
||||
// spanning the visible range boundary are kept.
|
||||
private _getDataZoomFilterMode(): string {
|
||||
const series = ensureArray(this.data);
|
||||
if (series.every((s) => s.type === "line")) {
|
||||
return "boundaryFilter";
|
||||
}
|
||||
if (series.some((s) => s.type === "bar")) {
|
||||
return "filter";
|
||||
}
|
||||
return "weakFilter";
|
||||
}
|
||||
|
||||
private _createOptions(): ECOption {
|
||||
let xAxis = this.options?.xAxis;
|
||||
if (xAxis) {
|
||||
@@ -632,7 +646,7 @@ export class HaChartBase extends LitElement {
|
||||
hideOverlap: true,
|
||||
...axis.axisLabel,
|
||||
},
|
||||
minInterval,
|
||||
minInterval: axis.minInterval ?? minInterval,
|
||||
} as XAXisOption;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -65,6 +65,8 @@ export interface NetworkData {
|
||||
categories?: { name: string; symbol: string }[];
|
||||
}
|
||||
|
||||
const PHYSICS_DISABLE_THRESHOLD = 512;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports
|
||||
let GraphChart: typeof import("echarts/lib/chart/graph/install");
|
||||
|
||||
@@ -94,7 +96,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _reducedMotion = false;
|
||||
|
||||
@state() private _physicsEnabled = true;
|
||||
@state() private _physicsEnabled?: boolean;
|
||||
|
||||
@state() private _showLabels = true;
|
||||
|
||||
@@ -122,6 +124,14 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
|
||||
];
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues): void {
|
||||
super.willUpdate(changedProperties);
|
||||
if (this._physicsEnabled === undefined && this.data?.nodes?.length > 1) {
|
||||
this._physicsEnabled =
|
||||
this.data.nodes.length <= PHYSICS_DISABLE_THRESHOLD;
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!GraphChart || !this.data.nodes?.length) {
|
||||
return nothing;
|
||||
@@ -138,7 +148,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
|
||||
.hass=${this.hass}
|
||||
.data=${this._getSeries(
|
||||
this.data,
|
||||
this._physicsEnabled,
|
||||
this._physicsEnabled ?? false,
|
||||
this._reducedMotion,
|
||||
this._showLabels,
|
||||
isMobile,
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from "../../data/recorder";
|
||||
import type { ECOption } from "../../resources/echarts/echarts";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { getPeriodicAxisLabelConfig } from "./axis-label";
|
||||
import type { CustomLegendOption } from "./ha-chart-base";
|
||||
import "./ha-chart-base";
|
||||
|
||||
@@ -293,6 +294,22 @@ export class StatisticsChart extends LitElement {
|
||||
type: "time",
|
||||
min: startTime,
|
||||
max: this.endTime,
|
||||
...(this.period === "month" && {
|
||||
minInterval: 28 * 24 * 3600 * 1000,
|
||||
axisLabel: getPeriodicAxisLabelConfig(
|
||||
"month",
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
),
|
||||
}),
|
||||
...(this.period === "year" && {
|
||||
minInterval: 365 * 24 * 3600 * 1000,
|
||||
axisLabel: getPeriodicAxisLabelConfig(
|
||||
"year",
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
),
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: "hiddenAxis",
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ActionDetail } from "@material/mwc-list";
|
||||
import { mdiCalendarToday } from "@mdi/js";
|
||||
import "cally";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, queryAll, state } from "lit/decorators";
|
||||
import { firstWeekdayIndex } from "../../common/datetime/first_weekday";
|
||||
import {
|
||||
formatCallyDateRange,
|
||||
@@ -19,7 +19,12 @@ import {
|
||||
localizeContext,
|
||||
} from "../../data/context";
|
||||
import { TimeZone } from "../../data/translation";
|
||||
import { MobileAwareMixin } from "../../mixins/mobile-aware-mixin";
|
||||
import { haStyleScrollbar } from "../../resources/styles";
|
||||
import type { ValueChangedEvent } from "../../types";
|
||||
import "../chips/ha-chip-set";
|
||||
import "../chips/ha-filter-chip";
|
||||
import type { HaFilterChip } from "../chips/ha-filter-chip";
|
||||
import type { HaBaseTimeInput } from "../ha-base-time-input";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-icon-button-next";
|
||||
@@ -27,11 +32,12 @@ import "../ha-icon-button-prev";
|
||||
import "../ha-list";
|
||||
import "../ha-list-item";
|
||||
import "../ha-time-input";
|
||||
import type { HaTimeInput } from "../ha-time-input";
|
||||
import type { DateRangePickerRanges } from "./ha-date-range-picker";
|
||||
import { datePickerStyles, dateRangePickerStyles } from "./styles";
|
||||
|
||||
@customElement("date-range-picker")
|
||||
export class DateRangePicker extends LitElement {
|
||||
export class DateRangePicker extends MobileAwareMixin(LitElement) {
|
||||
@property({ attribute: false }) public ranges?: DateRangePickerRanges | false;
|
||||
|
||||
@property({ attribute: false }) public startDate?: Date;
|
||||
@@ -69,6 +75,8 @@ export class DateRangePicker extends LitElement {
|
||||
to: { hours: 23, minutes: 59 },
|
||||
};
|
||||
|
||||
@queryAll("ha-time-input") private _timeInputs?: NodeListOf<HaTimeInput>;
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
@@ -100,16 +108,38 @@ export class DateRangePicker extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _renderRanges() {
|
||||
if (this._isMobileSize) {
|
||||
return html`
|
||||
<ha-chip-set class="ha-scrollbar">
|
||||
${Object.entries(this.ranges!).map(
|
||||
([name, range], index) => html`
|
||||
<ha-filter-chip
|
||||
.index=${index}
|
||||
.range=${range}
|
||||
@click=${this._clickDateRangeChip}
|
||||
>
|
||||
${name}
|
||||
</ha-filter-chip>
|
||||
`
|
||||
)}
|
||||
</ha-chip-set>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-list @action=${this._setDateRange} activatable>
|
||||
${Object.keys(this.ranges!).map(
|
||||
(name) => html`<ha-list-item>${name}</ha-list-item>`
|
||||
)}
|
||||
</ha-list>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<div class="picker">
|
||||
${this.ranges !== false && this.ranges
|
||||
? html`<div class="date-range-ranges">
|
||||
<ha-list @action=${this._setDateRange} activatable>
|
||||
${Object.keys(this.ranges).map(
|
||||
(name) => html`<ha-list-item>${name}</ha-list-item>`
|
||||
)}
|
||||
</ha-list>
|
||||
</div>`
|
||||
? html`<div class="date-range-ranges">${this._renderRanges()}</div>`
|
||||
: nothing}
|
||||
<div class="range">
|
||||
<calendar-range
|
||||
@@ -153,6 +183,7 @@ export class DateRangePicker extends LitElement {
|
||||
)}
|
||||
id="from"
|
||||
placeholder-labels
|
||||
auto-validate
|
||||
></ha-time-input>
|
||||
<ha-time-input
|
||||
.value=${`${this._timeValue.to.hours}:${this._timeValue.to.minutes}`}
|
||||
@@ -163,6 +194,7 @@ export class DateRangePicker extends LitElement {
|
||||
)}
|
||||
id="to"
|
||||
placeholder-labels
|
||||
auto-validate
|
||||
></ha-time-input>
|
||||
</div>
|
||||
`
|
||||
@@ -200,6 +232,14 @@ export class DateRangePicker extends LitElement {
|
||||
let endDate = new Date(`${dates[1]}T23:59:00`);
|
||||
|
||||
if (this.timePicker) {
|
||||
const timeInputs = this._timeInputs;
|
||||
if (
|
||||
timeInputs &&
|
||||
![...timeInputs].every((input) => input.reportValidity())
|
||||
) {
|
||||
// If we have time inputs, and they don't all report valid, don't save
|
||||
return;
|
||||
}
|
||||
startDate.setHours(this._timeValue.from.hours);
|
||||
startDate.setMinutes(this._timeValue.from.minutes);
|
||||
endDate.setHours(this._timeValue.to.hours);
|
||||
@@ -257,31 +297,38 @@ export class DateRangePicker extends LitElement {
|
||||
this._focusDate = undefined;
|
||||
}
|
||||
|
||||
private _clickDateRangeChip(ev: Event) {
|
||||
const chip = ev.target as HaFilterChip & {
|
||||
index: number;
|
||||
range: [Date, Date];
|
||||
};
|
||||
this._saveDateRangePreset(chip.range, chip.index);
|
||||
}
|
||||
|
||||
private _setDateRange(ev: CustomEvent<ActionDetail>) {
|
||||
const dateRange: [Date, Date] = Object.values(this.ranges!)[
|
||||
ev.detail.index
|
||||
];
|
||||
this._dateValue = formatCallyDateRange(
|
||||
dateRange[0],
|
||||
dateRange[1],
|
||||
this.locale,
|
||||
this.hassConfig
|
||||
);
|
||||
this._saveDateRangePreset(dateRange, ev.detail.index);
|
||||
}
|
||||
|
||||
private _saveDateRangePreset(range: [Date, Date], index: number) {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
startDate: dateRange[0],
|
||||
endDate: dateRange[1],
|
||||
startDate: range[0],
|
||||
endDate: range[1],
|
||||
},
|
||||
});
|
||||
fireEvent(this, "preset-selected", {
|
||||
index: ev.detail.index,
|
||||
index,
|
||||
});
|
||||
}
|
||||
|
||||
private _handleChangeTime(ev: ValueChangedEvent<string>) {
|
||||
ev.stopPropagation();
|
||||
const time = ev.detail.value;
|
||||
const type = (ev.target as HaBaseTimeInput).id;
|
||||
const target = ev.target as HaBaseTimeInput;
|
||||
const type = target.id;
|
||||
if (time) {
|
||||
if (!this._timeValue) {
|
||||
this._timeValue = {
|
||||
@@ -298,20 +345,48 @@ export class DateRangePicker extends LitElement {
|
||||
static styles = [
|
||||
datePickerStyles,
|
||||
dateRangePickerStyles,
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.date-range-ranges {
|
||||
border-right: 1px solid var(--divider-color);
|
||||
border-right: var(--ha-border-width-sm) solid var(--divider-color);
|
||||
min-width: 140px;
|
||||
flex: 0 1 30%;
|
||||
}
|
||||
|
||||
.range {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
padding: var(--ha-space-3);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||
.picker {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.date-range-ranges {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
margin-top: var(--ha-space-5);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
ha-chip-set {
|
||||
padding: var(--ha-space-3);
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.range {
|
||||
flex-basis: fit-content;
|
||||
}
|
||||
}
|
||||
|
||||
.times {
|
||||
@@ -326,12 +401,6 @@ export class DateRangePicker extends LitElement {
|
||||
padding: var(--ha-space-2);
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 500px) {
|
||||
.date-range-ranges {
|
||||
max-width: 30%;
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -80,33 +80,6 @@ export const datePickerStyles = css`
|
||||
text-align: center;
|
||||
margin-left: 48px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 500px) {
|
||||
calendar-month {
|
||||
min-height: calc(34px * 7);
|
||||
}
|
||||
calendar-month::part(day) {
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
calendar-month::part(button) {
|
||||
height: 26px;
|
||||
width: 26px;
|
||||
}
|
||||
calendar-month::part(range-inner),
|
||||
calendar-month::part(range-start),
|
||||
calendar-month::part(range-end),
|
||||
calendar-month::part(selected),
|
||||
calendar-month::part(selected):hover {
|
||||
height: 34px;
|
||||
width: 34px;
|
||||
}
|
||||
.heading {
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
.month-year {
|
||||
margin-left: 40px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const dateRangePickerStyles = css`
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import {
|
||||
DEFAULT_ENTITY_NAME,
|
||||
type EntityNameItem,
|
||||
} from "../../common/entity/compute_entity_name_display";
|
||||
import type { EntityNameItem } from "../../common/entity/compute_entity_name_display";
|
||||
import { getEntityContext } from "../../common/entity/context/get_entity_context";
|
||||
import type { EntityNameType } from "../../common/translations/entity-state";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
@@ -17,12 +15,14 @@ import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import "../chips/ha-assist-chip";
|
||||
import "../chips/ha-chip-set";
|
||||
import "../chips/ha-input-chip";
|
||||
import "../ha-button-toggle-group";
|
||||
import "../ha-combo-box-item";
|
||||
import "../ha-generic-picker";
|
||||
import type { HaGenericPicker } from "../ha-generic-picker";
|
||||
import "../ha-input-helper-text";
|
||||
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
|
||||
import "../ha-sortable";
|
||||
import "../input/ha-input";
|
||||
|
||||
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
|
||||
<ha-combo-box-item type="button" compact>
|
||||
@@ -73,10 +73,291 @@ export class HaEntityNamePicker extends LitElement {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||||
|
||||
@query("ha-generic-picker", true) private _picker?: HaGenericPicker;
|
||||
@state() private _mode?: "composed" | "custom";
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
|
||||
private _editIndex?: number;
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (this.hasUpdated) {
|
||||
const items = this._toItems(this.value);
|
||||
this._mode =
|
||||
items.length === 1 && items[0].type === "text" ? "custom" : "composed";
|
||||
}
|
||||
}
|
||||
|
||||
protected willUpdate(_changedProperties: PropertyValues): void {
|
||||
if (this._mode === undefined) {
|
||||
const items = this._toItems(this.value);
|
||||
this._mode =
|
||||
items.length === 1 && items[0].type === "text" ? "custom" : "composed";
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const modeButtons = [
|
||||
{
|
||||
label: this.hass.localize(
|
||||
"ui.components.entity.entity-name-picker.mode_composed"
|
||||
),
|
||||
value: "composed",
|
||||
},
|
||||
{
|
||||
label: this.hass.localize(
|
||||
"ui.components.entity.entity-name-picker.mode_custom"
|
||||
),
|
||||
value: "custom",
|
||||
},
|
||||
];
|
||||
|
||||
return html`
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-button-toggle-group
|
||||
size="small"
|
||||
.buttons=${modeButtons}
|
||||
.active=${this._mode}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._modeChanged}
|
||||
></ha-button-toggle-group>
|
||||
</div>
|
||||
<div class="content">
|
||||
${this._mode === "custom"
|
||||
? this._renderTextInput()
|
||||
: this._renderPicker()}
|
||||
</div>
|
||||
</div>
|
||||
${this.helper
|
||||
? html`
|
||||
<ha-input-helper-text .disabled=${this.disabled}>
|
||||
${this.helper}
|
||||
</ha-input-helper-text>
|
||||
`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderTextInput() {
|
||||
const items = this._items;
|
||||
const value =
|
||||
items.length === 1 && items[0].type === "text" ? items[0].text || "" : "";
|
||||
return html`
|
||||
<ha-input
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.value=${value}
|
||||
@input=${this._textInputChanged}
|
||||
></ha-input>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderPicker() {
|
||||
const value = this._items;
|
||||
const validTypes = this._validTypes(this.entityId);
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required && !value.length}
|
||||
.getItems=${this._getFilteredItems}
|
||||
.rowRenderer=${rowRenderer}
|
||||
.value=${this._getPickerValue()}
|
||||
allow-custom-value
|
||||
.customValueLabel=${this.hass.localize(
|
||||
"ui.components.entity.entity-name-picker.custom_name"
|
||||
)}
|
||||
@value-changed=${this._pickerValueChanged}
|
||||
.searchFn=${this._searchFn}
|
||||
.searchLabel=${this.hass.localize(
|
||||
"ui.components.entity.entity-name-picker.search"
|
||||
)}
|
||||
>
|
||||
<div slot="field" class="field">
|
||||
<ha-sortable
|
||||
no-style
|
||||
@item-moved=${this._moveItem}
|
||||
.disabled=${this.disabled}
|
||||
handle-selector="button.primary.action"
|
||||
filter=".add"
|
||||
>
|
||||
<ha-chip-set>
|
||||
${repeat(
|
||||
this._items,
|
||||
(item) => item,
|
||||
(item: EntityNameItem, idx) => {
|
||||
const label = this._formatItem(item);
|
||||
const isValid = validTypes.has(item.type);
|
||||
return html`
|
||||
<ha-input-chip
|
||||
data-idx=${idx}
|
||||
@remove=${this._removeItem}
|
||||
@click=${this._editItem}
|
||||
.label=${label}
|
||||
.selected=${!this.disabled}
|
||||
.disabled=${this.disabled}
|
||||
class=${!isValid ? "invalid" : ""}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
></ha-svg-icon>
|
||||
<span>${label}</span>
|
||||
</ha-input-chip>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
${this.disabled
|
||||
? nothing
|
||||
: html`
|
||||
<ha-assist-chip
|
||||
@click=${this._addItem}
|
||||
.disabled=${this.disabled}
|
||||
label=${this.hass.localize(
|
||||
"ui.components.entity.entity-name-picker.add"
|
||||
)}
|
||||
class="add"
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
|
||||
</ha-assist-chip>
|
||||
`}
|
||||
</ha-chip-set>
|
||||
</ha-sortable>
|
||||
</div>
|
||||
</ha-generic-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
private _modeChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
this._mode = ev.detail.value as "composed" | "custom";
|
||||
}
|
||||
|
||||
private _textInputChanged(ev: Event) {
|
||||
const value = (ev.target as HTMLInputElement).value;
|
||||
const newValue: EntityNameItem[] = value
|
||||
? [{ type: "text", text: value }]
|
||||
: [];
|
||||
this._setValue(newValue);
|
||||
}
|
||||
|
||||
private async _addItem(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
this._editIndex = undefined;
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
}
|
||||
|
||||
private async _editItem(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
const idx = parseInt(
|
||||
(ev.currentTarget as HTMLElement).dataset.idx || "",
|
||||
10
|
||||
);
|
||||
this._editIndex = idx;
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
const value = this._items[idx];
|
||||
// Pre-fill the field value when editing a text item
|
||||
if (value.type === "text" && value.text) {
|
||||
this._picker?.setFieldValue(value.text);
|
||||
}
|
||||
}
|
||||
|
||||
private async _moveItem(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
const { oldIndex, newIndex } = ev.detail;
|
||||
const value = this._items;
|
||||
const newValue = value.concat();
|
||||
const element = newValue.splice(oldIndex, 1)[0];
|
||||
newValue.splice(newIndex, 0, element);
|
||||
this._setValue(newValue);
|
||||
}
|
||||
|
||||
private async _removeItem(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
const value = [...this._items];
|
||||
const idx = parseInt((ev.target as HTMLElement).dataset.idx || "", 10);
|
||||
value.splice(idx, 1);
|
||||
this._setValue(value);
|
||||
}
|
||||
|
||||
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value;
|
||||
|
||||
if (this.disabled || !value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item: EntityNameItem = parseOptionValue(value);
|
||||
|
||||
const newValue = [...this._items];
|
||||
|
||||
if (this._editIndex != null) {
|
||||
newValue[this._editIndex] = item;
|
||||
this._editIndex = undefined;
|
||||
} else {
|
||||
newValue.push(item);
|
||||
}
|
||||
|
||||
this._setValue(newValue);
|
||||
|
||||
if (this._picker) {
|
||||
this._picker.value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _setValue(value: EntityNameItem[]) {
|
||||
const newValue = this._toValue(value);
|
||||
this.value = newValue;
|
||||
fireEvent(this, "value-changed", {
|
||||
value: newValue,
|
||||
});
|
||||
}
|
||||
|
||||
private get _items(): EntityNameItem[] {
|
||||
return this._toItems(this.value);
|
||||
}
|
||||
|
||||
private _toItems = memoizeOne((value?: typeof this.value) => {
|
||||
if (typeof value === "string") {
|
||||
if (value === "") {
|
||||
return [];
|
||||
}
|
||||
return [{ type: "text", text: value } satisfies EntityNameItem];
|
||||
}
|
||||
return value ? ensureArray(value) : [];
|
||||
});
|
||||
|
||||
private _toValue = memoizeOne(
|
||||
(items: EntityNameItem[]): typeof this.value => {
|
||||
if (items.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (items.length === 1) {
|
||||
const item = items[0];
|
||||
return item.type === "text" ? item.text : item;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
);
|
||||
|
||||
private _formatItem = (item: EntityNameItem) => {
|
||||
if (item.type === "text") {
|
||||
return `"${item.text}"`;
|
||||
}
|
||||
if (KNOWN_TYPES.has(item.type)) {
|
||||
return this.hass.localize(
|
||||
`ui.components.entity.entity-name-picker.types.${item.type as EntityNameType}`
|
||||
);
|
||||
}
|
||||
return item.type;
|
||||
};
|
||||
|
||||
private _validTypes = memoizeOne((entityId?: string) => {
|
||||
const options = new Set<string>(["text"]);
|
||||
if (!entityId) {
|
||||
@@ -161,157 +442,6 @@ export class HaEntityNamePicker extends LitElement {
|
||||
})
|
||||
);
|
||||
|
||||
private _formatItem = (item: EntityNameItem) => {
|
||||
if (item.type === "text") {
|
||||
return `"${item.text}"`;
|
||||
}
|
||||
if (KNOWN_TYPES.has(item.type)) {
|
||||
return this.hass.localize(
|
||||
`ui.components.entity.entity-name-picker.types.${item.type as EntityNameType}`
|
||||
);
|
||||
}
|
||||
return item.type;
|
||||
};
|
||||
|
||||
protected render() {
|
||||
const value = this._items;
|
||||
const validTypes = this._validTypes(this.entityId);
|
||||
|
||||
return html`
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required && !value.length}
|
||||
.getItems=${this._getFilteredItems}
|
||||
.rowRenderer=${rowRenderer}
|
||||
.value=${this._getPickerValue()}
|
||||
allow-custom-value
|
||||
.customValueLabel=${this.hass.localize(
|
||||
"ui.components.entity.entity-name-picker.custom_name"
|
||||
)}
|
||||
@value-changed=${this._pickerValueChanged}
|
||||
.searchFn=${this._searchFn}
|
||||
.searchLabel=${this.hass.localize(
|
||||
"ui.components.entity.entity-name-picker.search"
|
||||
)}
|
||||
>
|
||||
<div slot="field" class="container">
|
||||
<ha-sortable
|
||||
no-style
|
||||
@item-moved=${this._moveItem}
|
||||
.disabled=${this.disabled}
|
||||
handle-selector="button.primary.action"
|
||||
filter=".add"
|
||||
>
|
||||
<ha-chip-set>
|
||||
${repeat(
|
||||
this._items,
|
||||
(item) => item,
|
||||
(item: EntityNameItem, idx) => {
|
||||
const label = this._formatItem(item);
|
||||
const isValid = validTypes.has(item.type);
|
||||
return html`
|
||||
<ha-input-chip
|
||||
data-idx=${idx}
|
||||
@remove=${this._removeItem}
|
||||
@click=${this._editItem}
|
||||
.label=${label}
|
||||
.selected=${!this.disabled}
|
||||
.disabled=${this.disabled}
|
||||
class=${!isValid ? "invalid" : ""}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
></ha-svg-icon>
|
||||
<span>${label}</span>
|
||||
</ha-input-chip>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
${this.disabled
|
||||
? nothing
|
||||
: html`
|
||||
<ha-assist-chip
|
||||
@click=${this._addItem}
|
||||
.disabled=${this.disabled}
|
||||
label=${this.hass.localize(
|
||||
"ui.components.entity.entity-name-picker.add"
|
||||
)}
|
||||
class="add"
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
|
||||
</ha-assist-chip>
|
||||
`}
|
||||
</ha-chip-set>
|
||||
</ha-sortable>
|
||||
</div>
|
||||
</ha-generic-picker>
|
||||
${this._renderHelper()}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderHelper() {
|
||||
return this.helper
|
||||
? html`
|
||||
<ha-input-helper-text .disabled=${this.disabled}>
|
||||
${this.helper}
|
||||
</ha-input-helper-text>
|
||||
`
|
||||
: nothing;
|
||||
}
|
||||
|
||||
private async _addItem(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
this._editIndex = undefined;
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
}
|
||||
|
||||
private async _editItem(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
const idx = parseInt(
|
||||
(ev.currentTarget as HTMLElement).dataset.idx || "",
|
||||
10
|
||||
);
|
||||
this._editIndex = idx;
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
const value = this._items[idx];
|
||||
// Pre-fill the field value when editing a text item
|
||||
if (value.type === "text" && value.text) {
|
||||
this._picker?.setFieldValue(value.text);
|
||||
}
|
||||
}
|
||||
|
||||
private get _items(): EntityNameItem[] {
|
||||
return this._toItems(this.value);
|
||||
}
|
||||
|
||||
private _toItems = memoizeOne((value?: typeof this.value) => {
|
||||
if (typeof value === "string") {
|
||||
if (value === "") {
|
||||
return [];
|
||||
}
|
||||
return [{ type: "text", text: value } satisfies EntityNameItem];
|
||||
}
|
||||
return value ? ensureArray(value) : [...DEFAULT_ENTITY_NAME];
|
||||
});
|
||||
|
||||
private _toValue = memoizeOne(
|
||||
(items: EntityNameItem[]): typeof this.value => {
|
||||
if (items.length === 0) {
|
||||
return "";
|
||||
}
|
||||
if (items.length === 1) {
|
||||
const item = items[0];
|
||||
return item.type === "text" ? item.text : item;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
);
|
||||
|
||||
private _getPickerValue(): string | undefined {
|
||||
if (this._editIndex != null) {
|
||||
const item = this._items[this._editIndex];
|
||||
@@ -362,58 +492,6 @@ export class HaEntityNamePicker extends LitElement {
|
||||
return filteredItems;
|
||||
};
|
||||
|
||||
private async _moveItem(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
const { oldIndex, newIndex } = ev.detail;
|
||||
const value = this._items;
|
||||
const newValue = value.concat();
|
||||
const element = newValue.splice(oldIndex, 1)[0];
|
||||
newValue.splice(newIndex, 0, element);
|
||||
this._setValue(newValue);
|
||||
}
|
||||
|
||||
private async _removeItem(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
const value = [...this._items];
|
||||
const idx = parseInt((ev.target as HTMLElement).dataset.idx || "", 10);
|
||||
value.splice(idx, 1);
|
||||
this._setValue(value);
|
||||
}
|
||||
|
||||
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value;
|
||||
|
||||
if (this.disabled || !value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item: EntityNameItem = parseOptionValue(value);
|
||||
|
||||
const newValue = [...this._items];
|
||||
|
||||
if (this._editIndex != null) {
|
||||
newValue[this._editIndex] = item;
|
||||
this._editIndex = undefined;
|
||||
} else {
|
||||
newValue.push(item);
|
||||
}
|
||||
|
||||
this._setValue(newValue);
|
||||
|
||||
if (this._picker) {
|
||||
this._picker.value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _setValue(value: EntityNameItem[]) {
|
||||
const newValue = this._toValue(value);
|
||||
this.value = newValue;
|
||||
fireEvent(this, "value-changed", {
|
||||
value: newValue,
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: relative;
|
||||
@@ -421,13 +499,42 @@ export class HaEntityNamePicker extends LitElement {
|
||||
}
|
||||
|
||||
.container {
|
||||
--ha-input-padding-bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
gap: var(--ha-space-2);
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
ha-generic-picker,
|
||||
ha-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field {
|
||||
position: relative;
|
||||
background-color: var(--mdc-text-field-fill-color, whitesmoke);
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
border-end-end-radius: var(--ha-border-radius-square);
|
||||
border-end-start-radius: var(--ha-border-radius-square);
|
||||
}
|
||||
.container:after {
|
||||
.field:after {
|
||||
display: block;
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -445,30 +552,25 @@ export class HaEntityNamePicker extends LitElement {
|
||||
height 180ms ease-in-out,
|
||||
background-color 180ms ease-in-out;
|
||||
}
|
||||
:host([disabled]) .container:after {
|
||||
:host([disabled]) .field:after {
|
||||
background-color: var(
|
||||
--mdc-text-field-disabled-line-color,
|
||||
rgba(0, 0, 0, 0.42)
|
||||
);
|
||||
}
|
||||
.container:focus-within:after {
|
||||
.field:focus-within:after {
|
||||
height: 2px;
|
||||
background-color: var(--mdc-theme-primary);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin: 0 0 var(--ha-space-2);
|
||||
ha-chip-set {
|
||||
padding: var(--ha-space-3);
|
||||
}
|
||||
|
||||
.add {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
ha-chip-set {
|
||||
padding: var(--ha-space-2) var(--ha-space-2);
|
||||
}
|
||||
|
||||
.invalid {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-relative-time";
|
||||
import "./state-badge";
|
||||
import "../ha-tooltip";
|
||||
import "./state-badge";
|
||||
|
||||
@customElement("state-info")
|
||||
class StateInfo extends LitElement {
|
||||
@@ -22,7 +21,7 @@ class StateInfo extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const name = computeStateName(this.stateObj);
|
||||
const name = this.hass.formatEntityName(this.stateObj, { type: "entity" });
|
||||
|
||||
return html`<state-badge
|
||||
.hass=${this.hass}
|
||||
|
||||
@@ -56,7 +56,10 @@ class HaAttributeValue extends LitElement {
|
||||
this.stateObj!,
|
||||
this.attribute
|
||||
);
|
||||
return parts.find((part) => part.type === "value")?.value;
|
||||
return parts
|
||||
.filter((part) => part.type === "value")
|
||||
.map((part) => part.value)
|
||||
.join("");
|
||||
}
|
||||
|
||||
return this.hass.formatEntityAttributeValue(this.stateObj!, this.attribute);
|
||||
|
||||
@@ -137,7 +137,7 @@ export class HaBaseTimeInput extends LitElement {
|
||||
@property({ attribute: "placeholder-labels", type: Boolean })
|
||||
public placeholderLabels = false;
|
||||
|
||||
@queryAll("ha-input") private _inputs?: HaInput[];
|
||||
@queryAll("ha-input") private _inputs?: NodeListOf<HaInput>;
|
||||
|
||||
static shadowRootOptions = {
|
||||
...LitElement.shadowRootOptions,
|
||||
@@ -145,7 +145,9 @@ export class HaBaseTimeInput extends LitElement {
|
||||
};
|
||||
|
||||
public reportValidity(): boolean {
|
||||
return this._inputs?.every((input) => input.reportValidity()) ?? true;
|
||||
const inputs = this._inputs;
|
||||
if (!inputs) return true;
|
||||
return [...inputs].every((input) => input.reportValidity());
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
@@ -399,7 +401,7 @@ export class HaBaseTimeInput extends LitElement {
|
||||
|
||||
.time-separator,
|
||||
ha-icon-button {
|
||||
background-color: var(--ha-color-fill-neutral-quiet-resting);
|
||||
background-color: var(--ha-color-form-background);
|
||||
color: var(--ha-color-text-secondary);
|
||||
border-bottom: 1px solid var(--ha-color-border-neutral-loud);
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -57,6 +57,7 @@ export class HaButton extends Button {
|
||||
.button {
|
||||
font-size: var(--ha-font-size-m);
|
||||
line-height: 1;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
transition: background-color var(--ha-animation-duration-fast)
|
||||
ease-out;
|
||||
|
||||
@@ -123,6 +123,9 @@ export class HaDateInput extends LitElement {
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
min-width: 0px;
|
||||
}
|
||||
ha-svg-icon {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
@@ -100,6 +100,9 @@ export class HaDropdown extends Dropdown {
|
||||
#menu {
|
||||
padding: var(--ha-space-1);
|
||||
}
|
||||
wa-popup::part(popup) {
|
||||
z-index: 200;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -142,6 +142,19 @@ export const computeInitialHaFormData = (
|
||||
])[firstChoice],
|
||||
};
|
||||
}
|
||||
} else if ("numeric_threshold" in selector) {
|
||||
const mode = selector.numeric_threshold?.mode ?? "crossed";
|
||||
const type = mode === "changed" ? "any" : "above";
|
||||
data[field.name] =
|
||||
type === "any"
|
||||
? { type }
|
||||
: {
|
||||
type,
|
||||
value: {
|
||||
number: selector.numeric_threshold?.number?.min ?? 0,
|
||||
active_choice: "number",
|
||||
},
|
||||
};
|
||||
} else {
|
||||
throw new Error(
|
||||
`Selector ${Object.keys(selector)[0]} not supported in initial form data`
|
||||
|
||||
@@ -100,7 +100,7 @@ export class HaFormInteger extends LitElement implements HaFormElement {
|
||||
inputMode="numeric"
|
||||
.label=${this.label}
|
||||
.hint=${this.helper}
|
||||
.value=${this.data !== undefined ? this.data.toString() : ""}
|
||||
.value=${this.data?.toString() ?? ""}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.schema.required}
|
||||
.autoValidate=${this.schema.required}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, LitElement, svg } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
@@ -54,6 +54,7 @@ export class HaGauge extends LitElement {
|
||||
this._angle = getAngle(this.value, this.min, this.max);
|
||||
}
|
||||
this._segment_label = this._getSegmentLabel();
|
||||
this._rescaleSvg();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,6 +71,7 @@ export class HaGauge extends LitElement {
|
||||
}
|
||||
this._angle = getAngle(this.value, this.min, this.max);
|
||||
this._segment_label = this._getSegmentLabel();
|
||||
this._rescaleSvg();
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -88,87 +90,91 @@ export class HaGauge extends LitElement {
|
||||
/>
|
||||
|
||||
|
||||
${
|
||||
this.levels
|
||||
? [...this.levels]
|
||||
.sort((a, b) => a.level - b.level)
|
||||
.map((level, i, arr) => {
|
||||
const startLevel = i === 0 ? this.min : arr[i].level;
|
||||
const endLevel = i + 1 < arr.length ? arr[i + 1].level : this.max;
|
||||
${
|
||||
this.levels
|
||||
? (() => {
|
||||
const sortedLevels = [...this.levels].sort(
|
||||
(a, b) => a.level - b.level
|
||||
);
|
||||
|
||||
const startAngle = getAngle(startLevel, this.min, this.max);
|
||||
const endAngle = getAngle(endLevel, this.min, this.max);
|
||||
const largeArc = endAngle - startAngle > 180 ? 1 : 0;
|
||||
if (
|
||||
sortedLevels.length > 0 &&
|
||||
sortedLevels[0].level !== this.min
|
||||
) {
|
||||
sortedLevels.unshift({
|
||||
level: this.min,
|
||||
stroke: "var(--info-color)",
|
||||
});
|
||||
}
|
||||
|
||||
const x1 = -arcRadius * Math.cos((startAngle * Math.PI) / 180);
|
||||
const y1 = -arcRadius * Math.sin((startAngle * Math.PI) / 180);
|
||||
const x2 = -arcRadius * Math.cos((endAngle * Math.PI) / 180);
|
||||
const y2 = -arcRadius * Math.sin((endAngle * Math.PI) / 180);
|
||||
return sortedLevels.map((level, i, arr) => {
|
||||
const startLevel = level.level;
|
||||
const endLevel =
|
||||
i + 1 < arr.length ? arr[i + 1].level : this.max;
|
||||
|
||||
const firstSegment = i === 0;
|
||||
const lastSegment = i === arr.length - 1;
|
||||
const startAngle = getAngle(startLevel, this.min, this.max);
|
||||
const endAngle = getAngle(endLevel, this.min, this.max);
|
||||
const largeArc = endAngle - startAngle > 180 ? 1 : 0;
|
||||
|
||||
const paths: TemplateResult[] = [];
|
||||
const x1 =
|
||||
-arcRadius * Math.cos((startAngle * Math.PI) / 180);
|
||||
const y1 =
|
||||
-arcRadius * Math.sin((startAngle * Math.PI) / 180);
|
||||
const x2 = -arcRadius * Math.cos((endAngle * Math.PI) / 180);
|
||||
const y2 = -arcRadius * Math.sin((endAngle * Math.PI) / 180);
|
||||
|
||||
if (firstSegment) {
|
||||
paths.push(svg`
|
||||
<path
|
||||
class="level"
|
||||
stroke="${level.stroke}"
|
||||
style="stroke-linecap: round"
|
||||
d="M ${x1} ${y1} A ${arcRadius} ${arcRadius} 0 ${largeArc} 1 ${x2} ${y2}"
|
||||
/>
|
||||
`);
|
||||
} else if (lastSegment) {
|
||||
const offsetAngle = 0.5;
|
||||
const midAngle = endAngle - offsetAngle;
|
||||
const xm = -arcRadius * Math.cos((midAngle * Math.PI) / 180);
|
||||
const ym = -arcRadius * Math.sin((midAngle * Math.PI) / 180);
|
||||
const isFirst = i === 0;
|
||||
const isLast = i === arr.length - 1;
|
||||
|
||||
paths.push(svg`
|
||||
<path
|
||||
class="level"
|
||||
stroke="${level.stroke}"
|
||||
style="stroke-linecap: butt"
|
||||
d="M ${x1} ${y1} A ${arcRadius} ${arcRadius} 0 ${largeArc} 1 ${xm} ${ym}"
|
||||
/>
|
||||
`);
|
||||
if (isFirst) {
|
||||
return svg`
|
||||
<path
|
||||
class="level"
|
||||
stroke="${level.stroke}"
|
||||
style="stroke-linecap: butt"
|
||||
d="M ${x1} ${y1} A ${arcRadius} ${arcRadius} 0 ${largeArc} 1 ${x2} ${y2}"
|
||||
/>
|
||||
`;
|
||||
}
|
||||
|
||||
paths.push(svg`
|
||||
<path
|
||||
class="level"
|
||||
stroke="${level.stroke}"
|
||||
style="stroke-linecap: round"
|
||||
d="M ${xm} ${ym} A ${arcRadius} ${arcRadius} 0 0 1 ${x2} ${y2}"
|
||||
/>
|
||||
`);
|
||||
} else {
|
||||
paths.push(svg`
|
||||
<path
|
||||
class="level"
|
||||
stroke="${level.stroke}"
|
||||
style="stroke-linecap: butt"
|
||||
d="M ${x1} ${y1} A ${arcRadius} ${arcRadius} 0 ${largeArc} 1 ${x2} ${y2}"
|
||||
/>
|
||||
`);
|
||||
}
|
||||
if (isLast) {
|
||||
const offsetAngle = 0.5;
|
||||
const midAngle = endAngle - offsetAngle;
|
||||
const xm =
|
||||
-arcRadius * Math.cos((midAngle * Math.PI) / 180);
|
||||
const ym =
|
||||
-arcRadius * Math.sin((midAngle * Math.PI) / 180);
|
||||
|
||||
return paths;
|
||||
})
|
||||
: ""
|
||||
}
|
||||
return svg`
|
||||
<path class="level" stroke="${level.stroke}" style="stroke-linecap: butt"
|
||||
d="M ${x1} ${y1} A ${arcRadius} ${arcRadius} 0 ${largeArc} 1 ${xm} ${ym}" />
|
||||
<path class="level" stroke="${level.stroke}" style="stroke-linecap: butt"
|
||||
d="M ${xm} ${ym} A ${arcRadius} ${arcRadius} 0 0 1 ${x2} ${y2}" />
|
||||
`;
|
||||
}
|
||||
|
||||
return svg`
|
||||
<path
|
||||
class="level"
|
||||
stroke="${level.stroke}"
|
||||
style="stroke-linecap: butt"
|
||||
d="M ${x1} ${y1} A ${arcRadius} ${arcRadius} 0 ${largeArc} 1 ${x2} ${y2}"
|
||||
></path>
|
||||
`;
|
||||
});
|
||||
})()
|
||||
: ""
|
||||
}
|
||||
|
||||
${
|
||||
this.needle
|
||||
? svg`
|
||||
<line
|
||||
class="needle"
|
||||
x1="-35.0"
|
||||
y1="0"
|
||||
x2="-45.0"
|
||||
y2="0"
|
||||
style=${styleMap({ transform: `rotate(${this._angle}deg)` })}
|
||||
/>
|
||||
<path
|
||||
class="needle"
|
||||
d="M -34,-3 L -48,-1 A 1,1,0,0,0,-48,1 L -34,3 A 2,2,0,0,0,-34,-3 Z"
|
||||
|
||||
style=${styleMap({ transform: `rotate(${this._angle}deg)` })}
|
||||
/>
|
||||
`
|
||||
: svg`
|
||||
<path
|
||||
@@ -179,7 +185,8 @@ export class HaGauge extends LitElement {
|
||||
/>
|
||||
`
|
||||
}
|
||||
|
||||
</svg>
|
||||
<svg class="text">
|
||||
<text
|
||||
class="value-text"
|
||||
x="0"
|
||||
@@ -204,6 +211,18 @@ export class HaGauge extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _rescaleSvg() {
|
||||
// Set the viewbox of the SVG containing the value to perfectly
|
||||
// fit the text
|
||||
// That way it will auto-scale correctly
|
||||
const svgRoot = this.shadowRoot!.querySelector(".text")!;
|
||||
const box = svgRoot.querySelector("text")!.getBBox()!;
|
||||
svgRoot.setAttribute(
|
||||
"viewBox",
|
||||
`${box.x} ${box.y} ${box.width} ${box.height}`
|
||||
);
|
||||
}
|
||||
|
||||
private _getSegmentLabel() {
|
||||
if (this.levels) {
|
||||
[...this.levels].sort((a, b) => a.level - b.level);
|
||||
@@ -224,32 +243,43 @@ export class HaGauge extends LitElement {
|
||||
.levels-base {
|
||||
fill: none;
|
||||
stroke: var(--primary-background-color);
|
||||
stroke-width: 8;
|
||||
stroke-linecap: round;
|
||||
stroke-width: 12;
|
||||
stroke-linecap: butt;
|
||||
}
|
||||
|
||||
.level {
|
||||
fill: none;
|
||||
stroke-width: 8;
|
||||
stroke-width: 12;
|
||||
stroke-linecap: butt;
|
||||
}
|
||||
|
||||
.value {
|
||||
fill: none;
|
||||
stroke-width: 8;
|
||||
stroke-width: 12;
|
||||
stroke: var(--gauge-color);
|
||||
stroke-linecap: round;
|
||||
stroke-linecap: butt;
|
||||
transition: stroke-dashoffset 1s ease 0s;
|
||||
}
|
||||
|
||||
.needle {
|
||||
stroke: var(--primary-text-color);
|
||||
stroke-width: 2;
|
||||
fill: var(--primary-text-color);
|
||||
stroke: var(--card-background-color);
|
||||
color: var(--primary-text-color);
|
||||
stroke-width: 1;
|
||||
stroke-linecap: round;
|
||||
transform-origin: 0 0;
|
||||
transition: all 1s ease 0s;
|
||||
}
|
||||
|
||||
.text {
|
||||
position: absolute;
|
||||
max-height: 40%;
|
||||
max-width: 55%;
|
||||
left: 50%;
|
||||
bottom: 10%;
|
||||
transform: translate(-50%, 0%);
|
||||
}
|
||||
|
||||
.value-text {
|
||||
font-size: var(--ha-font-size-l);
|
||||
fill: var(--primary-text-color);
|
||||
|
||||
@@ -798,11 +798,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
}
|
||||
|
||||
ha-input-search {
|
||||
padding: 0 var(--ha-space-3);
|
||||
padding: 0 var(--ha-space-3) var(--ha-space-3);
|
||||
}
|
||||
|
||||
:host([mode="dialog"]) ha-input-search {
|
||||
padding: 0 var(--ha-space-4);
|
||||
padding: 0 var(--ha-space-4) var(--ha-space-3);
|
||||
}
|
||||
|
||||
ha-combo-box-item {
|
||||
@@ -873,12 +873,12 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: var(--ha-space-2);
|
||||
padding: var(--ha-space-3) var(--ha-space-3);
|
||||
padding: 0 var(--ha-space-3) var(--ha-space-3);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
:host([mode="dialog"]) .sections {
|
||||
padding: var(--ha-space-3) var(--ha-space-4);
|
||||
padding: 0 var(--ha-space-4) var(--ha-space-3);
|
||||
}
|
||||
|
||||
.sections ha-filter-chip {
|
||||
@@ -915,10 +915,6 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
padding: var(--ha-space-1) var(--ha-space-4);
|
||||
}
|
||||
|
||||
:host([mode="dialog"]) ha-input-search {
|
||||
padding: 0 var(--ha-space-4);
|
||||
}
|
||||
|
||||
.section-title-wrapper {
|
||||
height: 0;
|
||||
position: relative;
|
||||
|
||||
@@ -121,6 +121,8 @@ export class HaPickerField extends PickerMixin(LitElement) {
|
||||
css`
|
||||
ha-combo-box-item[disabled] {
|
||||
background-color: var(--ha-color-form-background-disabled);
|
||||
--md-list-item-disabled-opacity: 0.5;
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
ha-combo-box-item {
|
||||
@@ -141,13 +143,6 @@ export class HaPickerField extends PickerMixin(LitElement) {
|
||||
--md-focus-ring-duration: 0s;
|
||||
}
|
||||
|
||||
/* Add Similar focus style as the text field */
|
||||
ha-combo-box-item[disabled]:after {
|
||||
background-color: var(
|
||||
--mdc-text-field-disabled-line-color,
|
||||
rgba(0, 0, 0, 0.42)
|
||||
);
|
||||
}
|
||||
ha-combo-box-item:after {
|
||||
display: block;
|
||||
content: "";
|
||||
@@ -158,10 +153,7 @@ export class HaPickerField extends PickerMixin(LitElement) {
|
||||
right: 0;
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background-color: var(
|
||||
--mdc-text-field-idle-line-color,
|
||||
rgba(0, 0, 0, 0.42)
|
||||
);
|
||||
background-color: var(--ha-color-border-neutral-loud);
|
||||
transform:
|
||||
height 180ms ease-in-out,
|
||||
background-color 180ms ease-in-out;
|
||||
|
||||
@@ -2,6 +2,7 @@ import memoizeOne from "memoize-one";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { mdiChartBellCurveCumulative } from "@mdi/js";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type {
|
||||
NumericThresholdSelector,
|
||||
@@ -76,6 +77,18 @@ export class HaNumericThresholdSelector extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues): void {
|
||||
super.updated(changedProperties);
|
||||
if (
|
||||
(changedProperties.has("value") || changedProperties.has("selector")) &&
|
||||
!this.value
|
||||
) {
|
||||
const mode = this._getMode();
|
||||
const type = DEFAULT_TYPE[mode];
|
||||
fireEvent(this, "value-changed", { value: { type } });
|
||||
}
|
||||
}
|
||||
|
||||
private _getUnitOptions() {
|
||||
return this.selector.numeric_threshold?.unit_of_measurement;
|
||||
}
|
||||
@@ -220,6 +233,7 @@ export class HaNumericThresholdSelector extends LitElement {
|
||||
return [
|
||||
{
|
||||
value: "any",
|
||||
iconPath: mdiChartBellCurveCumulative,
|
||||
label: localize(
|
||||
"ui.components.selectors.numeric_threshold.changed.any"
|
||||
),
|
||||
@@ -273,7 +287,9 @@ export class HaNumericThresholdSelector extends LitElement {
|
||||
const numberSelector = {
|
||||
number: {
|
||||
...this.selector.numeric_threshold?.number,
|
||||
...(effectiveUnit ? { unit_of_measurement: effectiveUnit } : {}),
|
||||
...(!showUnit && effectiveUnit
|
||||
? { unit_of_measurement: effectiveUnit }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
const entitySelector = {
|
||||
@@ -481,7 +497,7 @@ export class HaNumericThresholdSelector extends LitElement {
|
||||
.value-inputs {
|
||||
display: flex;
|
||||
gap: var(--ha-space-2);
|
||||
align-items: flex-end;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.value-selector {
|
||||
|
||||
@@ -516,17 +516,10 @@ export class HaServiceControl extends LitElement {
|
||||
`}
|
||||
${serviceData && "target" in serviceData
|
||||
? html`<ha-settings-row .narrow=${this.narrow}>
|
||||
${hasOptional
|
||||
? html`<div slot="prefix" class="checkbox-spacer"></div>`
|
||||
: ""}
|
||||
<span slot="heading"
|
||||
>${this.hass.localize("ui.components.service-control.target")}</span
|
||||
>
|
||||
<span slot="description"
|
||||
>${this.hass.localize(
|
||||
"ui.components.service-control.target_secondary"
|
||||
)}</span
|
||||
><ha-selector
|
||||
<ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${this._targetSelector(
|
||||
serviceData.target as TargetSelector,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { HasSlotController } from "@home-assistant/webawesome/dist/internal/slot";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
|
||||
@customElement("ha-settings-row")
|
||||
@@ -16,17 +17,28 @@ export class HaSettingsRow extends LitElement {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public empty = false;
|
||||
|
||||
private readonly _hasSlotController = new HasSlotController(
|
||||
this,
|
||||
"description"
|
||||
);
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const hasDescription = this._hasSlotController.test("description");
|
||||
|
||||
return html`
|
||||
<div class="prefix-wrap">
|
||||
<slot name="prefix"></slot>
|
||||
<div
|
||||
class="body"
|
||||
?two-line=${!this.threeLine}
|
||||
?two-line=${!this.threeLine && hasDescription}
|
||||
?three-line=${this.threeLine}
|
||||
>
|
||||
<slot name="heading"></slot>
|
||||
<div class="secondary"><slot name="description"></slot></div>
|
||||
${hasDescription
|
||||
? html`<span class="secondary"
|
||||
><slot name="description"></slot
|
||||
></span>`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
|
||||
@@ -13,6 +13,24 @@ export class HaTabGroup extends TabGroup {
|
||||
|
||||
@property({ attribute: "tab-only", type: Boolean }) tabOnly = true;
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
// Prevent the tab group from consuming Alt+Arrow and Cmd+Arrow keys,
|
||||
// which browsers use for back/forward navigation.
|
||||
this.addEventListener("keydown", this._handleKeyDown, true);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener("keydown", this._handleKeyDown, true);
|
||||
}
|
||||
|
||||
private _handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.altKey || event.metaKey) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
protected override handleClick(event: MouseEvent) {
|
||||
if (this._dragScrollController.scrolled) {
|
||||
return;
|
||||
|
||||
@@ -21,6 +21,8 @@ export class HaTimeInput extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public required = false;
|
||||
|
||||
@property({ attribute: "auto-validate", type: Boolean }) autoValidate = false;
|
||||
|
||||
@property({ type: Boolean, attribute: "enable-second" })
|
||||
public enableSecond = false;
|
||||
|
||||
@@ -71,6 +73,7 @@ export class HaTimeInput extends LitElement {
|
||||
.clearable=${this.clearable && this.value !== undefined}
|
||||
.helper=${this.helper}
|
||||
.placeholderLabels=${this.placeholderLabels}
|
||||
.autoValidate=${this.autoValidate}
|
||||
day-label="dd"
|
||||
hour-label="hh"
|
||||
min-label="mm"
|
||||
@@ -86,6 +89,7 @@ export class HaTimeInput extends LitElement {
|
||||
|
||||
const useAMPM = useAmPm(this.locale);
|
||||
let value: string | undefined;
|
||||
let updateHours = 0;
|
||||
|
||||
// An undefined eventValue means the time selector is being cleared,
|
||||
// the `value` variable will (intentionally) be left undefined.
|
||||
@@ -97,6 +101,8 @@ export class HaTimeInput extends LitElement {
|
||||
) {
|
||||
let hours = eventValue.hours || 0;
|
||||
if (eventValue && useAMPM) {
|
||||
updateHours =
|
||||
hours >= 12 && hours < 24 ? hours - 12 : hours === 0 ? 12 : 0;
|
||||
if (eventValue.amPm === "PM" && hours < 12) {
|
||||
hours += 12;
|
||||
}
|
||||
@@ -115,6 +121,17 @@ export class HaTimeInput extends LitElement {
|
||||
}`;
|
||||
}
|
||||
|
||||
if (updateHours) {
|
||||
// If the user entered a 24hr time in a 12hr input, we need to refresh the
|
||||
// input to ensure it resets back to the 12hr equivalent.
|
||||
this.updateComplete.then(() => {
|
||||
const input = this._input;
|
||||
if (input) {
|
||||
input.hours = updateHours;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (value === this.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import "@home-assistant/webawesome/dist/components/popup/popup";
|
||||
import type WaPopup from "@home-assistant/webawesome/dist/components/popup/popup";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import {
|
||||
customElement,
|
||||
property,
|
||||
query,
|
||||
queryAssignedElements,
|
||||
state,
|
||||
} from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { popoverSupported } from "../common/feature-detect/support-popover";
|
||||
import { nextRender } from "../common/util/render-status";
|
||||
|
||||
export type ToastCloseReason =
|
||||
@@ -22,12 +28,15 @@ export class HaToast extends LitElement {
|
||||
|
||||
@property({ type: Number, attribute: "timeout-ms" }) public timeoutMs = 4000;
|
||||
|
||||
@query("wa-popup")
|
||||
private _popup?: WaPopup;
|
||||
|
||||
@query(".toast")
|
||||
private _toast?: HTMLDivElement;
|
||||
|
||||
@queryAssignedElements({ slot: "action", flatten: true })
|
||||
private _actionElements?: Element[];
|
||||
|
||||
@queryAssignedElements({ slot: "dismiss", flatten: true })
|
||||
private _dismissElements?: Element[];
|
||||
|
||||
@state() private _active = false;
|
||||
|
||||
@state() private _visible = false;
|
||||
@@ -48,7 +57,6 @@ export class HaToast extends LitElement {
|
||||
clearTimeout(this._dismissTimer);
|
||||
|
||||
if (this._active && this._visible) {
|
||||
this._popup?.reposition();
|
||||
this._setDismissTimer();
|
||||
return;
|
||||
}
|
||||
@@ -62,7 +70,7 @@ export class HaToast extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
this._popup?.reposition();
|
||||
this._showToastPopover();
|
||||
await nextRender();
|
||||
|
||||
if (transitionId !== this._transitionId) {
|
||||
@@ -102,6 +110,7 @@ export class HaToast extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
this._hideToastPopover();
|
||||
this._active = false;
|
||||
await this.updateComplete;
|
||||
|
||||
@@ -123,6 +132,34 @@ export class HaToast extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _isPopoverOpen(): boolean {
|
||||
if (!this._toast || !popoverSupported) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return this._toast.matches(":popover-open");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private _showToastPopover(): void {
|
||||
if (!this._toast || !popoverSupported || this._isPopoverOpen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._toast.showPopover?.();
|
||||
}
|
||||
|
||||
private _hideToastPopover(): void {
|
||||
if (!this._toast || !popoverSupported || !this._isPopoverOpen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._toast.hidePopover?.();
|
||||
}
|
||||
|
||||
private async _waitForTransitionEnd(): Promise<void> {
|
||||
const toastEl = this._toast;
|
||||
if (!toastEl) {
|
||||
@@ -138,82 +175,70 @@ export class HaToast extends LitElement {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const hasAction =
|
||||
(this._actionElements?.length ?? 0) > 0 ||
|
||||
(this._dismissElements?.length ?? 0) > 0;
|
||||
|
||||
return html`
|
||||
<wa-popup
|
||||
placement="top"
|
||||
.active=${this._active}
|
||||
.distance=${16}
|
||||
skidding="0"
|
||||
flip
|
||||
shift
|
||||
<div
|
||||
class=${classMap({
|
||||
toast: true,
|
||||
active: this._active,
|
||||
visible: this._visible,
|
||||
})}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
|
||||
>
|
||||
<div id="toast-anchor" slot="anchor" aria-hidden="true"></div>
|
||||
<div
|
||||
class=${classMap({
|
||||
toast: true,
|
||||
visible: this._visible,
|
||||
})}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="message">${this.labelText}</span>
|
||||
<div class="actions">
|
||||
<slot name="action"></slot>
|
||||
<slot name="dismiss"></slot>
|
||||
</div>
|
||||
<span class="message">${this.labelText}</span>
|
||||
<div class=${classMap({ actions: true, "has-action": hasAction })}>
|
||||
<slot name="action"></slot>
|
||||
<slot name="dismiss"></slot>
|
||||
</div>
|
||||
</wa-popup>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static override styles = css`
|
||||
#toast-anchor {
|
||||
position: fixed;
|
||||
bottom: calc(var(--ha-space-2) + var(--safe-area-inset-bottom));
|
||||
inset-inline-start: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
wa-popup::part(popup) {
|
||||
padding: 0;
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
box-shadow: var(--wa-shadow-l);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toast {
|
||||
box-sizing: border-box;
|
||||
min-width: min(
|
||||
350px,
|
||||
calc(
|
||||
100vw - var(--ha-space-4) - var(--safe-area-inset-left) - var(
|
||||
--safe-area-inset-right
|
||||
)
|
||||
)
|
||||
position: fixed;
|
||||
inset-block-start: auto;
|
||||
inset-inline-end: auto;
|
||||
inset-block-end: calc(
|
||||
var(--safe-area-inset-bottom, 0px) + var(--ha-space-4)
|
||||
);
|
||||
max-width: 650px;
|
||||
inset-inline-start: 50%;
|
||||
margin: 0;
|
||||
width: max-content;
|
||||
height: auto;
|
||||
border: none;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
min-width: min(350px, calc(var(--safe-width) - var(--ha-space-4)));
|
||||
max-width: min(650px, var(--safe-width));
|
||||
min-height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
padding: var(--ha-space-2) var(--ha-space-3);
|
||||
padding: var(--ha-space-3) var(--ha-space-4);
|
||||
color: var(--ha-color-on-neutral-loud);
|
||||
background-color: var(--ha-color-neutral-10);
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
box-shadow: var(--wa-shadow-l);
|
||||
opacity: 0;
|
||||
transform: translateY(var(--ha-space-2));
|
||||
transform: translate(-50%, var(--ha-space-2));
|
||||
transition:
|
||||
opacity var(--ha-animation-duration-fast, 150ms) ease,
|
||||
transform var(--ha-animation-duration-fast, 150ms) ease;
|
||||
}
|
||||
|
||||
.toast:not(.active) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toast.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.message {
|
||||
@@ -228,15 +253,14 @@ export class HaToast extends LitElement {
|
||||
color: var(--ha-color-on-neutral-loud);
|
||||
}
|
||||
|
||||
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||
wa-popup::part(popup) {
|
||||
border-radius: var(--ha-border-radius-square);
|
||||
}
|
||||
.actions:not(.has-action) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||
.toast {
|
||||
min-width: calc(
|
||||
100vw - var(--safe-area-inset-left) - var(--safe-area-inset-right)
|
||||
);
|
||||
min-width: var(--safe-width);
|
||||
max-width: var(--safe-width);
|
||||
border-radius: var(--ha-border-radius-square);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { stopPropagation } from "../../common/dom/stop_propagation";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-svg-icon";
|
||||
import "../ha-tooltip";
|
||||
import { nativeElementInternalsSupported } from "../../common/feature-detect/support-native-element-internals";
|
||||
|
||||
export type InputType =
|
||||
| "date"
|
||||
@@ -287,7 +288,9 @@ export class HaInput extends LitElement {
|
||||
}
|
||||
|
||||
public checkValidity(): boolean {
|
||||
return this._input?.checkValidity() ?? true;
|
||||
return nativeElementInternalsSupported
|
||||
? (this._input?.checkValidity() ?? true)
|
||||
: true;
|
||||
}
|
||||
|
||||
public reportValidity(): boolean {
|
||||
@@ -620,8 +623,12 @@ export class HaInput extends LitElement {
|
||||
background-color: var(--ha-color-form-background-disabled);
|
||||
}
|
||||
|
||||
wa-input:disabled::part(label) {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
wa-input::part(hint) {
|
||||
height: var(--ha-space-5);
|
||||
min-height: var(--ha-space-5);
|
||||
margin-block-start: 0;
|
||||
margin-inline-start: var(--ha-space-3);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
@@ -632,6 +639,7 @@ export class HaInput extends LitElement {
|
||||
|
||||
wa-input.hint-hidden::part(hint) {
|
||||
height: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
|
||||
@@ -79,6 +79,7 @@ class DialogMediaPlayerBrowse extends LitElement {
|
||||
<ha-dialog
|
||||
.hass=${this.hass}
|
||||
.open=${this._open}
|
||||
width="large"
|
||||
flexcontent
|
||||
@closed=${this.closeDialog}
|
||||
@opened=${this._dialogOpened}
|
||||
@@ -230,6 +231,8 @@ class DialogMediaPlayerBrowse extends LitElement {
|
||||
--media-browser-max-height: calc(
|
||||
100vh - 65px - var(--safe-area-inset-y)
|
||||
);
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
:host(.opened) ha-media-player-browse {
|
||||
@@ -248,7 +251,6 @@ class DialogMediaPlayerBrowse extends LitElement {
|
||||
--media-browser-max-height: calc(
|
||||
100vh - 145px - var(--safe-area-inset-y)
|
||||
);
|
||||
width: 700px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -535,7 +535,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
|
||||
const stateObject: HassEntity | undefined = this.hass.states[item];
|
||||
const entityName = stateObject
|
||||
? computeEntityName(stateObject, this.hass.entities)
|
||||
? computeEntityName(stateObject, this.hass.entities, this.hass.devices)
|
||||
: item;
|
||||
const { area, device } = stateObject
|
||||
? getEntityContext(
|
||||
|
||||
@@ -78,6 +78,19 @@ const localizeTimeString = (
|
||||
}
|
||||
};
|
||||
|
||||
const formatNumericLimitValue = (
|
||||
hass: HomeAssistant,
|
||||
value?: number | string
|
||||
) => {
|
||||
if (typeof value !== "string" || !isValidEntityId(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return hass.states[value]
|
||||
? computeStateName(hass.states[value]) || value
|
||||
: value;
|
||||
};
|
||||
|
||||
export const describeTrigger = (
|
||||
trigger: Trigger,
|
||||
hass: HomeAssistant,
|
||||
@@ -233,8 +246,8 @@ const describeLegacyTrigger = (
|
||||
attribute: attribute,
|
||||
entity: formatListWithOrs(hass.locale, entities),
|
||||
numberOfEntities: entities.length,
|
||||
above: trigger.above,
|
||||
below: trigger.below,
|
||||
above: formatNumericLimitValue(hass, trigger.above),
|
||||
below: formatNumericLimitValue(hass, trigger.below),
|
||||
duration: duration,
|
||||
}
|
||||
);
|
||||
@@ -246,7 +259,7 @@ const describeLegacyTrigger = (
|
||||
attribute: attribute,
|
||||
entity: formatListWithOrs(hass.locale, entities),
|
||||
numberOfEntities: entities.length,
|
||||
above: trigger.above,
|
||||
above: formatNumericLimitValue(hass, trigger.above),
|
||||
duration: duration,
|
||||
}
|
||||
);
|
||||
@@ -258,7 +271,7 @@ const describeLegacyTrigger = (
|
||||
attribute: attribute,
|
||||
entity: formatListWithOrs(hass.locale, entities),
|
||||
numberOfEntities: entities.length,
|
||||
below: trigger.below,
|
||||
below: formatNumericLimitValue(hass, trigger.below),
|
||||
duration: duration,
|
||||
}
|
||||
);
|
||||
@@ -1116,8 +1129,8 @@ const describeLegacyCondition = (
|
||||
attribute,
|
||||
entity,
|
||||
numberOfEntities: entity_ids.length,
|
||||
above: condition.above,
|
||||
below: condition.below,
|
||||
above: formatNumericLimitValue(hass, condition.above),
|
||||
below: formatNumericLimitValue(hass, condition.below),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1128,7 +1141,7 @@ const describeLegacyCondition = (
|
||||
attribute,
|
||||
entity,
|
||||
numberOfEntities: entity_ids.length,
|
||||
above: condition.above,
|
||||
above: formatNumericLimitValue(hass, condition.above),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1139,7 +1152,7 @@ const describeLegacyCondition = (
|
||||
attribute,
|
||||
entity,
|
||||
numberOfEntities: entity_ids.length,
|
||||
below: condition.below,
|
||||
below: formatNumericLimitValue(hass, condition.below),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export const isExternal =
|
||||
window.externalAppV2 ||
|
||||
window.externalApp ||
|
||||
window.webkit?.messageHandlers?.getExternalAuth ||
|
||||
location.search.includes("external_auth=1");
|
||||
export const isExternalAndroid = window.externalApp || window.externalAppV2;
|
||||
|
||||
@@ -3,8 +3,10 @@ import {
|
||||
mdiAirFilter,
|
||||
mdiAlert,
|
||||
mdiAppleSafari,
|
||||
mdiBattery,
|
||||
mdiBell,
|
||||
mdiBookmark,
|
||||
mdiBrightness6,
|
||||
mdiBullhorn,
|
||||
mdiButtonPointer,
|
||||
mdiCalendar,
|
||||
@@ -16,13 +18,18 @@ import {
|
||||
mdiCog,
|
||||
mdiCommentAlert,
|
||||
mdiCounter,
|
||||
mdiDoorOpen,
|
||||
mdiEye,
|
||||
mdiFlash,
|
||||
mdiFlower,
|
||||
mdiFormatListBulleted,
|
||||
mdiFormTextbox,
|
||||
mdiForumOutline,
|
||||
mdiGarageOpen,
|
||||
mdiGate,
|
||||
mdiGoogleAssistant,
|
||||
mdiGoogleCirclesCommunities,
|
||||
mdiHomeAccount,
|
||||
mdiHomeAutomation,
|
||||
mdiImage,
|
||||
mdiImageFilterFrames,
|
||||
@@ -30,6 +37,7 @@ import {
|
||||
mdiLightbulb,
|
||||
mdiMapMarkerRadius,
|
||||
mdiMicrophoneMessage,
|
||||
mdiMotionSensor,
|
||||
mdiPalette,
|
||||
mdiRayVertex,
|
||||
mdiRemote,
|
||||
@@ -41,10 +49,14 @@ import {
|
||||
mdiSpeakerMessage,
|
||||
mdiStarFourPoints,
|
||||
mdiThermostat,
|
||||
mdiThermometer,
|
||||
mdiTimerOutline,
|
||||
mdiToggleSwitch,
|
||||
mdiWater,
|
||||
mdiWaterPercent,
|
||||
mdiWeatherPartlyCloudy,
|
||||
mdiWhiteBalanceSunny,
|
||||
mdiWindowClosed,
|
||||
} from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { isComponentLoaded } from "../common/config/is_component_loaded";
|
||||
@@ -75,6 +87,7 @@ export const FALLBACK_DOMAIN_ICONS = {
|
||||
air_quality: mdiAirFilter,
|
||||
alert: mdiAlert,
|
||||
automation: mdiRobot,
|
||||
battery: mdiBattery,
|
||||
calendar: mdiCalendar,
|
||||
climate: mdiThermostat,
|
||||
configurator: mdiCog,
|
||||
@@ -84,10 +97,15 @@ export const FALLBACK_DOMAIN_ICONS = {
|
||||
datetime: mdiCalendarClock,
|
||||
demo: mdiHomeAssistant,
|
||||
device_tracker: mdiAccount,
|
||||
door: mdiDoorOpen,
|
||||
garage_door: mdiGarageOpen,
|
||||
gate: mdiGate,
|
||||
google_assistant: mdiGoogleAssistant,
|
||||
group: mdiGoogleCirclesCommunities,
|
||||
homeassistant: mdiHomeAssistant,
|
||||
homekit: mdiHomeAutomation,
|
||||
humidity: mdiWaterPercent,
|
||||
illuminance: mdiBrightness6,
|
||||
image_processing: mdiImageFilterFrames,
|
||||
image: mdiImage,
|
||||
infrared: mdiLedOn,
|
||||
@@ -99,11 +117,15 @@ export const FALLBACK_DOMAIN_ICONS = {
|
||||
input_text: mdiFormTextbox,
|
||||
lawn_mower: mdiRobotMower,
|
||||
light: mdiLightbulb,
|
||||
moisture: mdiWater,
|
||||
motion: mdiMotionSensor,
|
||||
notify: mdiCommentAlert,
|
||||
number: mdiRayVertex,
|
||||
occupancy: mdiHomeAccount,
|
||||
persistent_notification: mdiBell,
|
||||
person: mdiAccount,
|
||||
plant: mdiFlower,
|
||||
power: mdiFlash,
|
||||
proximity: mdiAppleSafari,
|
||||
remote: mdiRemote,
|
||||
scene: mdiPalette,
|
||||
@@ -115,6 +137,7 @@ export const FALLBACK_DOMAIN_ICONS = {
|
||||
siren: mdiBullhorn,
|
||||
stt: mdiMicrophoneMessage,
|
||||
sun: mdiWhiteBalanceSunny,
|
||||
temperature: mdiThermometer,
|
||||
text: mdiFormTextbox,
|
||||
time: mdiClock,
|
||||
timer: mdiTimerOutline,
|
||||
@@ -124,6 +147,7 @@ export const FALLBACK_DOMAIN_ICONS = {
|
||||
vacuum: mdiRobotVacuum,
|
||||
wake_word: mdiChatSleep,
|
||||
weather: mdiWeatherPartlyCloudy,
|
||||
window: mdiWindowClosed,
|
||||
zone: mdiMapMarkerRadius,
|
||||
};
|
||||
|
||||
|
||||
@@ -105,10 +105,6 @@ const generateNavigationConfigSectionCommands = (
|
||||
hass: HomeAssistant,
|
||||
filterOptions: NavigationFilterOptions = {}
|
||||
): BaseNavigationCommand[] => {
|
||||
if (!hass.user?.is_admin) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const items: NavigationInfo[] = [];
|
||||
const allPages = Object.values(configSections).flat();
|
||||
const visiblePages = filterNavigationPages(hass, allPages, filterOptions);
|
||||
|
||||
@@ -11,6 +11,8 @@ export const DialogMixin = <
|
||||
superClass: T
|
||||
) =>
|
||||
class extends superClass implements HassDialogNext<P> {
|
||||
public dialogNext = true as const;
|
||||
|
||||
declare public params?: P;
|
||||
|
||||
private _closePromise?: Promise<boolean>;
|
||||
|
||||
@@ -87,7 +87,7 @@ export class DialogEnterCode
|
||||
|
||||
private _numberClick(e: MouseEvent): void {
|
||||
const val = (e.currentTarget! as any).value;
|
||||
this._input!.value = this._input!.value + val;
|
||||
this._input!.value = (this._input!.value ?? "") + val;
|
||||
this._showClearButton = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface HassDialog<T = unknown> extends HTMLElement {
|
||||
}
|
||||
|
||||
export interface HassDialogNext<T = unknown> extends HTMLElement {
|
||||
dialogNext: true;
|
||||
params?: T;
|
||||
closeDialog?: (historyState?: any) => Promise<boolean> | boolean;
|
||||
}
|
||||
@@ -168,13 +169,18 @@ export const showDialog = async (
|
||||
dialogElement = await LOADED[dialogTag].element;
|
||||
}
|
||||
|
||||
if ("showDialog" in dialogElement!) {
|
||||
if ("dialogNext" in dialogElement! && dialogElement.dialogNext) {
|
||||
dialogElement!.params = dialogParams;
|
||||
} else if ("showDialog" in dialogElement!) {
|
||||
dialogElement.showDialog(dialogParams);
|
||||
} else {
|
||||
dialogElement!.params = dialogParams;
|
||||
throw new Error("Unknown dialog type loaded");
|
||||
}
|
||||
|
||||
(parentElement || element).shadowRoot!.appendChild(dialogElement!);
|
||||
const targetParent = (parentElement || element).shadowRoot!;
|
||||
if (dialogElement!.parentNode !== targetParent) {
|
||||
targetParent.appendChild(dialogElement!);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -82,6 +82,11 @@ class MoreInfoInputDatetime extends LitElement {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
--ha-input-padding-bottom: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
ha-date-input {
|
||||
flex: 1 1 160px;
|
||||
}
|
||||
ha-date-input + ha-time-input {
|
||||
margin-left: var(--ha-space-1);
|
||||
|
||||
@@ -536,9 +536,9 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
|
||||
: undefined;
|
||||
|
||||
const entityName = stateObj
|
||||
? computeEntityName(stateObj, this.hass.entities)
|
||||
? computeEntityName(stateObj, this.hass.entities, this.hass.devices)
|
||||
: this._entry
|
||||
? computeEntityEntryName(this._entry)
|
||||
? computeEntityEntryName(this._entry, this.hass.devices)
|
||||
: entityId;
|
||||
|
||||
const deviceName = context?.device
|
||||
|
||||
@@ -107,7 +107,11 @@ class MoreInfoContent extends LitElement {
|
||||
if (!stateObj) {
|
||||
return null;
|
||||
}
|
||||
const entityName = computeEntityName(stateObj, hass.entities);
|
||||
const entityName = computeEntityName(
|
||||
stateObj,
|
||||
hass.entities,
|
||||
hass.devices
|
||||
);
|
||||
const { area } = getEntityContext(
|
||||
stateObj,
|
||||
hass.entities,
|
||||
|
||||
@@ -57,7 +57,11 @@ import type { HomeAssistant } from "../../types";
|
||||
import { isMac } from "../../util/is_mac";
|
||||
import { showConfirmationDialog } from "../generic/show-dialog-box";
|
||||
import { showShortcutsDialog } from "../shortcuts/show-shortcuts-dialog";
|
||||
import type { QuickBarParams, QuickBarSection } from "./show-dialog-quick-bar";
|
||||
import {
|
||||
effectiveQuickBarMode,
|
||||
type QuickBarParams,
|
||||
type QuickBarSection,
|
||||
} from "./show-dialog-quick-bar";
|
||||
|
||||
const SEPARATOR = "________";
|
||||
|
||||
@@ -100,7 +104,7 @@ export class QuickBar extends LitElement {
|
||||
this._translationsLoaded = true;
|
||||
}
|
||||
this._initialize();
|
||||
this._selectedSection = params.mode;
|
||||
this._selectedSection = effectiveQuickBarMode(this.hass.user, params.mode);
|
||||
this._showHint = params.showHint ?? false;
|
||||
|
||||
this._relatedResult = params.contextItem ? params.related : undefined;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { ItemType, RelatedResult } from "../../data/search";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { closeDialog } from "../make-dialog-manager";
|
||||
|
||||
export type QuickBarSection =
|
||||
@@ -22,6 +23,20 @@ export interface QuickBarParams {
|
||||
related?: RelatedResult;
|
||||
}
|
||||
|
||||
/** Non-admin users cannot scope the bar to command, device, or area (those sections are admin-only). */
|
||||
export const effectiveQuickBarMode = (
|
||||
user: HomeAssistant["user"],
|
||||
mode?: QuickBarSection
|
||||
): QuickBarSection | undefined => {
|
||||
if (mode && user?.is_admin) {
|
||||
return mode;
|
||||
}
|
||||
if (mode === "command" || mode === "device" || mode === "area") {
|
||||
return undefined;
|
||||
}
|
||||
return mode;
|
||||
};
|
||||
|
||||
export const loadQuickBar = () => import("./ha-quick-bar");
|
||||
|
||||
export const showQuickBar = (
|
||||
|
||||
@@ -5,6 +5,11 @@ import { Auth } from "home-assistant-js-websocket";
|
||||
import type { EMMessage } from "./external_messaging";
|
||||
import { ExternalMessaging } from "./external_messaging";
|
||||
|
||||
/**
|
||||
* WARNING: These constants should not be changed, as the native app relies on
|
||||
* these exact string values to know which callback to call.
|
||||
* This happens after getting a response from the native app.
|
||||
*/
|
||||
const CALLBACK_SET_TOKEN = "externalAuthSetToken";
|
||||
const CALLBACK_REVOKE_TOKEN = "externalAuthRevokeToken";
|
||||
|
||||
@@ -28,6 +33,9 @@ declare global {
|
||||
revokeExternalAuth(payload: string);
|
||||
externalBus(payload: string);
|
||||
};
|
||||
externalAppV2?: {
|
||||
postMessage(payload: string): void;
|
||||
};
|
||||
webkit?: {
|
||||
messageHandlers: {
|
||||
getExternalAuth: {
|
||||
@@ -44,9 +52,9 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
if (!window.externalApp && !window.webkit) {
|
||||
if (!window.externalApp && !window.webkit && !window.externalAppV2) {
|
||||
throw new Error(
|
||||
"External auth requires either externalApp or webkit defined on Window object."
|
||||
"External auth requires either externalApp, externalAppV2, or webkit defined on Window object."
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,7 +103,11 @@ export class ExternalAuth extends Auth {
|
||||
// we sleep 1 microtask to get the promise to actually set it on the window object.
|
||||
await Promise.resolve();
|
||||
|
||||
if (window.externalApp) {
|
||||
if (window.externalAppV2) {
|
||||
window.externalAppV2.postMessage(
|
||||
JSON.stringify({ type: "getExternalAuth", payload })
|
||||
);
|
||||
} else if (window.externalApp) {
|
||||
window.externalApp.getExternalAuth(JSON.stringify(payload));
|
||||
} else {
|
||||
window.webkit!.messageHandlers.getExternalAuth.postMessage(payload);
|
||||
@@ -119,7 +131,11 @@ export class ExternalAuth extends Auth {
|
||||
// we sleep 1 microtask to get the promise to actually set it on the window object.
|
||||
await Promise.resolve();
|
||||
|
||||
if (window.externalApp) {
|
||||
if (window.externalAppV2) {
|
||||
window.externalAppV2.postMessage(
|
||||
JSON.stringify({ type: "revokeExternalAuth", payload })
|
||||
);
|
||||
} else if (window.externalApp) {
|
||||
window.externalApp.revokeExternalAuth(JSON.stringify(payload));
|
||||
} else {
|
||||
window.webkit!.messageHandlers.revokeExternalAuth.postMessage(payload);
|
||||
@@ -132,6 +148,7 @@ export class ExternalAuth extends Auth {
|
||||
export const createExternalAuth = async (hassUrl: string) => {
|
||||
const auth = new ExternalAuth(hassUrl);
|
||||
if (
|
||||
window.externalAppV2 ||
|
||||
window.externalApp?.externalBus ||
|
||||
(window.webkit && window.webkit.messageHandlers.externalBus)
|
||||
) {
|
||||
|
||||
@@ -187,6 +187,11 @@ interface EMOutgoingMessageFocusElement extends EMMessage {
|
||||
};
|
||||
}
|
||||
|
||||
// These types are handled internally by the Android app via postMessage.
|
||||
// They are not sent by the frontend and should not be used directly.
|
||||
// They are intentionally listed here to prevent anyone from using them unintentionally.
|
||||
type RejectedEMMessageType = "onHomeAssistantSetTheme" | "handleBlob";
|
||||
|
||||
type EMOutgoingMessageWithoutAnswer =
|
||||
| EMMessageResultError
|
||||
| EMMessageResultSuccess
|
||||
@@ -393,8 +398,16 @@ export class ExternalMessaging {
|
||||
* Send message to external app that expects a response.
|
||||
* @param msg message to send
|
||||
*/
|
||||
public sendMessage<T extends keyof EMOutgoingMessageWithAnswer>(
|
||||
msg: EMOutgoingMessageWithAnswer[T]["request"]
|
||||
public sendMessage<
|
||||
T extends keyof EMOutgoingMessageWithAnswer,
|
||||
TType extends string = EMOutgoingMessageWithAnswer[T]["request"]["type"],
|
||||
>(
|
||||
msg: EMOutgoingMessageWithAnswer[T]["request"] & {
|
||||
type: TType &
|
||||
(TType extends RejectedEMMessageType
|
||||
? "ERROR: message type is rejected"
|
||||
: {});
|
||||
}
|
||||
): Promise<EMOutgoingMessageWithAnswer[T]["response"]> {
|
||||
const msgId = ++this.msgId;
|
||||
msg.id = msgId;
|
||||
@@ -412,7 +425,14 @@ export class ExternalMessaging {
|
||||
* Send message to external app without expecting a response.
|
||||
* @param msg message to send
|
||||
*/
|
||||
public fireMessage(msg: EMOutgoingMessageWithoutAnswer) {
|
||||
public fireMessage<T extends string>(
|
||||
msg: EMOutgoingMessageWithoutAnswer & {
|
||||
type: T &
|
||||
(T extends RejectedEMMessageType
|
||||
? "ERROR: message type is rejected"
|
||||
: {});
|
||||
}
|
||||
) {
|
||||
if (!msg.id) {
|
||||
msg.id = ++this.msgId;
|
||||
}
|
||||
@@ -473,7 +493,11 @@ export class ExternalMessaging {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("Sending message to external app", msg);
|
||||
}
|
||||
if (window.externalApp) {
|
||||
if (window.externalAppV2) {
|
||||
window.externalAppV2.postMessage(
|
||||
JSON.stringify({ type: "externalBus", payload: msg })
|
||||
);
|
||||
} else if (window.externalApp) {
|
||||
window.externalApp.externalBus(JSON.stringify(msg));
|
||||
} else {
|
||||
window.webkit!.messageHandlers.externalBus.postMessage(msg);
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface PageNavigation {
|
||||
not_component?: string | string[];
|
||||
core?: boolean;
|
||||
advancedOnly?: boolean;
|
||||
/** Hide from non-admin users in filtered navigation and quick bar. */
|
||||
adminOnly?: boolean;
|
||||
iconPath?: string;
|
||||
iconSecondaryPath?: string;
|
||||
iconViewBox?: string;
|
||||
|
||||
@@ -36,11 +36,19 @@ class NotificationManager extends LitElement {
|
||||
@query("ha-toast")
|
||||
private _toast!: HTMLElementTagNameMap["ha-toast"] | undefined;
|
||||
|
||||
private _showDialogId = 0;
|
||||
|
||||
public async showDialog(parameters: ShowToastParams) {
|
||||
const showId = ++this._showDialogId;
|
||||
|
||||
if (!parameters.id || this._parameters?.id !== parameters.id) {
|
||||
await this._toast?.hide();
|
||||
}
|
||||
|
||||
if (showId !== this._showDialogId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parameters.duration === 0) {
|
||||
this._parameters = undefined;
|
||||
return;
|
||||
@@ -56,6 +64,11 @@ class NotificationManager extends LitElement {
|
||||
}
|
||||
|
||||
await this.updateComplete;
|
||||
|
||||
if (showId !== this._showDialogId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._toast?.show();
|
||||
}
|
||||
|
||||
|
||||
@@ -271,6 +271,7 @@ class DialogCalendarEventDetail extends LitElement {
|
||||
color: var(--secondary-text-color);
|
||||
max-width: 300px;
|
||||
overflow-wrap: break-word;
|
||||
white-space: pre-line;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -65,6 +65,9 @@ const processAreasForClimate = (
|
||||
if (temperatureEntityId && hass.states[temperatureEntityId]) {
|
||||
areaCards.push({
|
||||
...computeTileCard(temperatureEntityId),
|
||||
name:
|
||||
hass.localize("component.sensor.entity_component.temperature.name") ||
|
||||
"Temperature",
|
||||
features: [{ type: "trend-graph" }],
|
||||
});
|
||||
}
|
||||
@@ -73,6 +76,9 @@ const processAreasForClimate = (
|
||||
if (humidityEntityId && hass.states[humidityEntityId]) {
|
||||
areaCards.push({
|
||||
...computeTileCard(humidityEntityId),
|
||||
name:
|
||||
hass.localize("component.sensor.entity_component.humidity.name") ||
|
||||
"Humidity",
|
||||
features: [{ type: "trend-graph" }],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -95,6 +95,8 @@ import "./types/ha-automation-action-set_conversation_response";
|
||||
import "./types/ha-automation-action-stop";
|
||||
import "./types/ha-automation-action-wait_for_trigger";
|
||||
import "./types/ha-automation-action-wait_template";
|
||||
import { computeDomain } from "../../../../common/entity/compute_domain";
|
||||
import { computeObjectId } from "../../../../common/entity/compute_object_id";
|
||||
|
||||
export const getAutomationActionType = memoizeOne(
|
||||
(action: Action | undefined) => {
|
||||
@@ -185,6 +187,8 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
|
||||
@state() private _collapsed = true;
|
||||
|
||||
@state() private _isNew = false;
|
||||
|
||||
@state() private _warnings?: string[];
|
||||
|
||||
@query("ha-automation-action-editor")
|
||||
@@ -237,12 +241,20 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
private _renderRow() {
|
||||
const type = getAutomationActionType(this.action);
|
||||
|
||||
const target =
|
||||
type === "service" && "target" in this.action
|
||||
? (this.action as ServiceAction).target
|
||||
: type === "device_id" && (this.action as DeviceAction).device_id
|
||||
? { device_id: (this.action as DeviceAction).device_id }
|
||||
: undefined;
|
||||
const action = type === "service" && (this.action as ServiceAction).action;
|
||||
|
||||
const actionHasTarget =
|
||||
action &&
|
||||
"target" in
|
||||
(this.hass.services?.[computeDomain(action)]?.[
|
||||
computeObjectId(action)
|
||||
] || {});
|
||||
|
||||
const target = actionHasTarget
|
||||
? (this.action as ServiceAction).target
|
||||
: type === "device_id" && (this.action as DeviceAction).device_id
|
||||
? { device_id: (this.action as DeviceAction).device_id }
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
${type === "service" && "action" in this.action && this.action.action
|
||||
@@ -265,7 +277,9 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
${capitalizeFirstLetter(
|
||||
describeAction(this.hass, this._entityReg, this.action)
|
||||
)}
|
||||
${target ? this._renderTargets(target) : nothing}
|
||||
${target !== undefined || (actionHasTarget && !this._isNew)
|
||||
? this._renderTargets(target, actionHasTarget && !this._isNew)
|
||||
: nothing}
|
||||
${type !== "condition" &&
|
||||
(this.action as NonConditionAction).continue_on_error === true
|
||||
? html`<ha-svg-icon
|
||||
@@ -545,7 +559,10 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
>${this._renderRow()}</ha-automation-row
|
||||
>`
|
||||
: html`
|
||||
<ha-expansion-panel left-chevron>
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
@expanded-changed=${this._expansionPanelChanged}
|
||||
>
|
||||
${this._renderRow()}
|
||||
</ha-expansion-panel>
|
||||
`}
|
||||
@@ -575,10 +592,11 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
}
|
||||
|
||||
private _renderTargets = memoizeOne(
|
||||
(target?: HassServiceTarget) =>
|
||||
(target?: HassServiceTarget, targetRequired = false) =>
|
||||
html`<ha-automation-row-targets
|
||||
.hass=${this.hass}
|
||||
.target=${target}
|
||||
.targetRequired=${targetRequired}
|
||||
></ha-automation-row-targets>`
|
||||
);
|
||||
|
||||
@@ -802,6 +820,12 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _expansionPanelChanged(ev: CustomEvent) {
|
||||
if (!ev.detail.expanded) {
|
||||
this._isNew = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _toggleSidebar(ev: Event) {
|
||||
ev?.stopPropagation();
|
||||
|
||||
@@ -812,6 +836,10 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
this.openSidebar();
|
||||
}
|
||||
|
||||
public markAsNew(): void {
|
||||
this._isNew = true;
|
||||
}
|
||||
|
||||
public openSidebar(action?: Action): void {
|
||||
const sidebarAction = action ?? this.action;
|
||||
const actionType = getAutomationActionType(sidebarAction);
|
||||
@@ -822,6 +850,7 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
},
|
||||
close: (focus?: boolean) => {
|
||||
this._selected = false;
|
||||
this._isNew = false;
|
||||
fireEvent(this, "close-sidebar");
|
||||
if (focus) {
|
||||
this.focus();
|
||||
|
||||
@@ -161,6 +161,7 @@ export default class HaAutomationAction extends AutomationSortableListMixin<Acti
|
||||
|
||||
if (mode === "new") {
|
||||
row.expand();
|
||||
row.markAsNew();
|
||||
}
|
||||
|
||||
if (!this.optionsInSidebar) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../../../common/array/ensure-array";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { mainWindow } from "../../../common/dom/get_main_window";
|
||||
import { computeAreaName } from "../../../common/entity/compute_area_name";
|
||||
@@ -98,6 +99,7 @@ import {
|
||||
domainToName,
|
||||
fetchIntegrationManifests,
|
||||
} from "../../../data/integration";
|
||||
import { filterSelectorEntities } from "../../../data/selector";
|
||||
import type { LabelRegistryEntry } from "../../../data/label/label_registry";
|
||||
import { subscribeLabFeature } from "../../../data/labs";
|
||||
import {
|
||||
@@ -287,6 +289,7 @@ class DialogAddAutomationElement
|
||||
|
||||
public showDialog(params): void {
|
||||
this._params = params;
|
||||
this._resetVariables();
|
||||
|
||||
this.addKeyboardShortcuts();
|
||||
|
||||
@@ -376,9 +379,15 @@ class DialogAddAutomationElement
|
||||
if (this._params) {
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
this._params = undefined;
|
||||
this._resetVariables();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private _resetVariables() {
|
||||
this._open = true;
|
||||
this._closing = false;
|
||||
this._params = undefined;
|
||||
this._selectedCollectionIndex = undefined;
|
||||
this._selectedGroup = undefined;
|
||||
this._selectedTarget = undefined;
|
||||
@@ -390,7 +399,6 @@ class DialogAddAutomationElement
|
||||
this._narrow = false;
|
||||
this._targetItems = undefined;
|
||||
this._loadItemsError = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private _updateNarrow = () => {
|
||||
@@ -406,6 +414,86 @@ class DialogAddAutomationElement
|
||||
}
|
||||
}
|
||||
|
||||
private _calculateActiveSystemDomains = memoizeOne(
|
||||
(
|
||||
descriptions: TriggerDescriptions | ConditionDescriptions,
|
||||
manifests: DomainManifestLookup,
|
||||
getDomain: (key: string) => string
|
||||
): { active: Set<string>; byEntityDomain: Map<string, Set<string>> } => {
|
||||
const active = new Set<string>();
|
||||
// Group all entity filters by system domain
|
||||
const domainFilters: Record<
|
||||
string,
|
||||
Parameters<typeof filterSelectorEntities>[0][]
|
||||
> = {};
|
||||
// Also collect which entity domains each system domain targets
|
||||
const entityDomainsPerSystemDomain: Record<string, Set<string>> = {};
|
||||
for (const [key, desc] of Object.entries(descriptions)) {
|
||||
const domain = getDomain(key);
|
||||
if (manifests[domain]?.integration_type !== "system") {
|
||||
continue;
|
||||
}
|
||||
if (!domainFilters[domain]) {
|
||||
domainFilters[domain] = [];
|
||||
entityDomainsPerSystemDomain[domain] = new Set();
|
||||
}
|
||||
const entityFilters = ensureArray(desc.target?.entity);
|
||||
if (entityFilters) {
|
||||
// target.entity can be EntitySelectorFilter | readonly EntitySelectorFilter[]
|
||||
// ensureArray wraps it but each element may still be an array, so flatten
|
||||
for (const filterOrArray of entityFilters) {
|
||||
const filters = ensureArray(filterOrArray);
|
||||
domainFilters[domain].push(...filters);
|
||||
for (const filter of filters) {
|
||||
for (const entityDomain of ensureArray(filter.domain) ?? []) {
|
||||
entityDomainsPerSystemDomain[domain].add(entityDomain);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check each entity in hass.states against the filters
|
||||
for (const entity of Object.values(this.hass.states)) {
|
||||
for (const [domain, filters] of Object.entries(domainFilters)) {
|
||||
if (active.has(domain)) {
|
||||
continue;
|
||||
}
|
||||
if (filters.some((f) => filterSelectorEntities(f, entity))) {
|
||||
active.add(domain);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Build reverse map: entity domain → set of system domains that cover it
|
||||
const byEntityDomain = new Map<string, Set<string>>();
|
||||
for (const [systemDomain, entityDomains] of Object.entries(
|
||||
entityDomainsPerSystemDomain
|
||||
)) {
|
||||
for (const entityDomain of entityDomains) {
|
||||
if (!byEntityDomain.has(entityDomain)) {
|
||||
byEntityDomain.set(entityDomain, new Set());
|
||||
}
|
||||
byEntityDomain.get(entityDomain)!.add(systemDomain);
|
||||
}
|
||||
}
|
||||
return { active, byEntityDomain };
|
||||
}
|
||||
);
|
||||
|
||||
private get _systemDomains() {
|
||||
if (!this._manifests) {
|
||||
return undefined;
|
||||
}
|
||||
const descriptions =
|
||||
this._params?.type === "trigger"
|
||||
? this._triggerDescriptions
|
||||
: this._conditionDescriptions;
|
||||
return this._calculateActiveSystemDomains(
|
||||
descriptions,
|
||||
this._manifests,
|
||||
this._params?.type === "trigger" ? getTriggerDomain : getConditionDomain
|
||||
);
|
||||
}
|
||||
|
||||
private async _loadConfigEntries() {
|
||||
const configEntries = await getConfigEntries(this.hass);
|
||||
this._configEntryLookup = Object.fromEntries(
|
||||
@@ -420,6 +508,11 @@ class DialogAddAutomationElement
|
||||
manifests[manifest.domain] = manifest;
|
||||
}
|
||||
this._manifests = manifests;
|
||||
// If a target was already selected and items computed before manifests
|
||||
// loaded, recompute so system domain grouping applies correctly.
|
||||
if (this._selectedTarget && this._targetItems) {
|
||||
this._getItemsByTarget();
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
@@ -503,7 +596,7 @@ class DialogAddAutomationElement
|
||||
const tabButtons = [
|
||||
{
|
||||
label: this.hass.localize(
|
||||
`ui.panel.config.automation.editor.${automationElementType}s.name`
|
||||
"ui.panel.config.automation.editor.tabs.type"
|
||||
),
|
||||
value: "groups",
|
||||
},
|
||||
@@ -511,7 +604,9 @@ class DialogAddAutomationElement
|
||||
|
||||
if (this._newTriggersAndConditions) {
|
||||
tabButtons.unshift({
|
||||
label: this.hass.localize(`ui.panel.config.automation.editor.targets`),
|
||||
label: this.hass.localize(
|
||||
"ui.panel.config.automation.editor.tabs.target"
|
||||
),
|
||||
value: "targets",
|
||||
});
|
||||
}
|
||||
@@ -610,10 +705,10 @@ class DialogAddAutomationElement
|
||||
.narrow=${this._narrow}
|
||||
class=${classMap({
|
||||
"ha-scrollbar": true,
|
||||
[this._getAddFromTargetHidden(
|
||||
hidden: !!this._getAddFromTargetHidden(
|
||||
this._narrow,
|
||||
this._selectedTarget
|
||||
)]: true,
|
||||
),
|
||||
})}
|
||||
.manifests=${this._manifests}
|
||||
></ha-automation-add-from-target>`
|
||||
@@ -897,7 +992,8 @@ class DialogAddAutomationElement
|
||||
this._domains,
|
||||
this.hass.localize,
|
||||
this.hass.services,
|
||||
this._manifests
|
||||
this._manifests,
|
||||
this._systemDomains?.byEntityDomain
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -946,10 +1042,17 @@ class DialogAddAutomationElement
|
||||
|
||||
const items = flattenGroups(groups).flat();
|
||||
if (type === "trigger") {
|
||||
items.push(...this._triggers(localize, this._triggerDescriptions));
|
||||
items.push(
|
||||
...this._triggers(localize, this._triggerDescriptions, undefined)
|
||||
);
|
||||
} else if (type === "condition") {
|
||||
items.push(
|
||||
...this._conditions(localize, this._conditionDescriptions, manifests)
|
||||
...this._conditions(
|
||||
localize,
|
||||
this._conditionDescriptions,
|
||||
manifests,
|
||||
undefined
|
||||
)
|
||||
);
|
||||
} else if (type === "action") {
|
||||
items.push(...this._services(localize, services, manifests));
|
||||
@@ -1133,16 +1236,23 @@ class DialogAddAutomationElement
|
||||
domains: Set<string> | undefined,
|
||||
localize: LocalizeFunc,
|
||||
services: HomeAssistant["services"],
|
||||
manifests?: DomainManifestLookup
|
||||
manifests?: DomainManifestLookup,
|
||||
systemDomainsByEntityDomain?: Map<string, Set<string>>
|
||||
): AddAutomationElementListItem[] => {
|
||||
if (type === "trigger" && isDynamic(group)) {
|
||||
return this._triggers(localize, this._triggerDescriptions, group);
|
||||
return this._triggers(
|
||||
localize,
|
||||
this._triggerDescriptions,
|
||||
systemDomainsByEntityDomain,
|
||||
group
|
||||
);
|
||||
}
|
||||
if (type === "condition" && isDynamic(group)) {
|
||||
return this._conditions(
|
||||
localize,
|
||||
this._conditionDescriptions,
|
||||
manifests,
|
||||
systemDomainsByEntityDomain,
|
||||
group
|
||||
);
|
||||
}
|
||||
@@ -1242,6 +1352,38 @@ class DialogAddAutomationElement
|
||||
);
|
||||
};
|
||||
|
||||
private _domainMatchesGroupType(
|
||||
domain: string,
|
||||
manifest: DomainManifestLookup[string] | undefined,
|
||||
domainUsed: boolean,
|
||||
type: "helper" | "other" | undefined
|
||||
): boolean {
|
||||
if (type === undefined) {
|
||||
return (
|
||||
ENTITY_DOMAINS_MAIN.has(domain) ||
|
||||
(manifest?.integration_type === "entity" &&
|
||||
domainUsed &&
|
||||
!ENTITY_DOMAINS_OTHER.has(domain)) ||
|
||||
(manifest?.integration_type === "system" &&
|
||||
(this._systemDomains?.active.has(domain) ?? false))
|
||||
);
|
||||
}
|
||||
if (type === "helper") {
|
||||
return manifest?.integration_type === "helper";
|
||||
}
|
||||
// type === "other"
|
||||
return (
|
||||
!ENTITY_DOMAINS_MAIN.has(domain) &&
|
||||
(ENTITY_DOMAINS_OTHER.has(domain) ||
|
||||
(!domainUsed && manifest?.integration_type === "entity") ||
|
||||
(manifest?.integration_type === "system" &&
|
||||
!(this._systemDomains?.active.has(domain) ?? false)) ||
|
||||
!["helper", "entity", "system"].includes(
|
||||
manifest?.integration_type || ""
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
private _triggerGroups = (
|
||||
localize: LocalizeFunc,
|
||||
triggers: TriggerDescriptions,
|
||||
@@ -1265,19 +1407,7 @@ class DialogAddAutomationElement
|
||||
const manifest = manifests[domain];
|
||||
const domainUsed = !domains ? true : domains.has(domain);
|
||||
|
||||
if (
|
||||
(type === undefined &&
|
||||
(ENTITY_DOMAINS_MAIN.has(domain) ||
|
||||
(manifest?.integration_type === "entity" &&
|
||||
domainUsed &&
|
||||
!ENTITY_DOMAINS_OTHER.has(domain)))) ||
|
||||
(type === "helper" && manifest?.integration_type === "helper") ||
|
||||
(type === "other" &&
|
||||
!ENTITY_DOMAINS_MAIN.has(domain) &&
|
||||
(ENTITY_DOMAINS_OTHER.has(domain) ||
|
||||
(!domainUsed && manifest?.integration_type === "entity") ||
|
||||
!["helper", "entity"].includes(manifest?.integration_type || "")))
|
||||
) {
|
||||
if (this._domainMatchesGroupType(domain, manifest, domainUsed, type)) {
|
||||
result.push({
|
||||
icon: html`
|
||||
<ha-domain-icon
|
||||
@@ -1301,17 +1431,30 @@ class DialogAddAutomationElement
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
triggers: TriggerDescriptions,
|
||||
systemDomainsByEntityDomain: Map<string, Set<string>> | undefined,
|
||||
group?: string
|
||||
): AddAutomationElementListItem[] => {
|
||||
if (!triggers) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const browsedEntityDomain =
|
||||
group && isDynamic(group) ? getValueFromDynamic(group) : undefined;
|
||||
|
||||
// System domains that should be merged into this entity domain group
|
||||
const systemDomainsForGroup = browsedEntityDomain
|
||||
? systemDomainsByEntityDomain?.get(browsedEntityDomain)
|
||||
: undefined;
|
||||
|
||||
return this._getTriggerListItems(
|
||||
localize,
|
||||
Object.keys(triggers).filter((trigger) => {
|
||||
const domain = getTriggerDomain(trigger);
|
||||
return !group || group === `${DYNAMIC_PREFIX}${domain}`;
|
||||
if (!group || group === `${DYNAMIC_PREFIX}${domain}`) {
|
||||
return true;
|
||||
}
|
||||
// Also include system domain triggers that cover the browsed entity domain
|
||||
return systemDomainsForGroup?.has(domain) ?? false;
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -1340,19 +1483,7 @@ class DialogAddAutomationElement
|
||||
const manifest = manifests[domain];
|
||||
const domainUsed = !domains ? true : domains.has(domain);
|
||||
|
||||
if (
|
||||
(type === undefined &&
|
||||
(ENTITY_DOMAINS_MAIN.has(domain) ||
|
||||
(manifest?.integration_type === "entity" &&
|
||||
domainUsed &&
|
||||
!ENTITY_DOMAINS_OTHER.has(domain)))) ||
|
||||
(type === "helper" && manifest?.integration_type === "helper") ||
|
||||
(type === "other" &&
|
||||
!ENTITY_DOMAINS_MAIN.has(domain) &&
|
||||
(ENTITY_DOMAINS_OTHER.has(domain) ||
|
||||
(!domainUsed && manifest?.integration_type === "entity") ||
|
||||
!["helper", "entity"].includes(manifest?.integration_type || "")))
|
||||
) {
|
||||
if (this._domainMatchesGroupType(domain, manifest, domainUsed, type)) {
|
||||
result.push({
|
||||
icon: html`
|
||||
<ha-domain-icon
|
||||
@@ -1377,6 +1508,7 @@ class DialogAddAutomationElement
|
||||
localize: LocalizeFunc,
|
||||
conditions: ConditionDescriptions,
|
||||
_manifests: DomainManifestLookup | undefined,
|
||||
systemDomainsByEntityDomain: Map<string, Set<string>> | undefined,
|
||||
group?: string
|
||||
): AddAutomationElementListItem[] => {
|
||||
if (!conditions) {
|
||||
@@ -1384,10 +1516,21 @@ class DialogAddAutomationElement
|
||||
}
|
||||
const result: AddAutomationElementListItem[] = [];
|
||||
|
||||
const browsedEntityDomain =
|
||||
group && isDynamic(group) ? getValueFromDynamic(group) : undefined;
|
||||
|
||||
const systemDomainsForGroup = browsedEntityDomain
|
||||
? systemDomainsByEntityDomain?.get(browsedEntityDomain)
|
||||
: undefined;
|
||||
|
||||
for (const condition of Object.keys(conditions)) {
|
||||
const domain = getConditionDomain(condition);
|
||||
|
||||
if (group && group !== `${DYNAMIC_PREFIX}${domain}`) {
|
||||
if (
|
||||
group &&
|
||||
group !== `${DYNAMIC_PREFIX}${domain}` &&
|
||||
!(systemDomainsForGroup?.has(domain) ?? false)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1483,13 +1626,23 @@ class DialogAddAutomationElement
|
||||
);
|
||||
|
||||
private _getDomainType(domain: string) {
|
||||
return ENTITY_DOMAINS_MAIN.has(domain) ||
|
||||
(this._manifests?.[domain].integration_type === "entity" &&
|
||||
if (
|
||||
ENTITY_DOMAINS_MAIN.has(domain) ||
|
||||
(this._manifests?.[domain]?.integration_type === "entity" &&
|
||||
!ENTITY_DOMAINS_OTHER.has(domain))
|
||||
? "dynamicGroups"
|
||||
: this._manifests?.[domain].integration_type === "helper"
|
||||
? "helpers"
|
||||
: "other";
|
||||
) {
|
||||
return "dynamicGroups";
|
||||
}
|
||||
if (this._manifests?.[domain]?.integration_type === "helper") {
|
||||
return "helpers";
|
||||
}
|
||||
if (
|
||||
this._manifests?.[domain]?.integration_type === "system" &&
|
||||
this._systemDomains?.active.has(domain)
|
||||
) {
|
||||
return "dynamicGroups";
|
||||
}
|
||||
return "other";
|
||||
}
|
||||
|
||||
private _sortDomainsByCollection(
|
||||
@@ -1594,30 +1747,56 @@ class DialogAddAutomationElement
|
||||
iconPath: options.icon || TYPES[type].icons[key],
|
||||
});
|
||||
|
||||
private _getDomainGroupedTriggerListItems(
|
||||
private _getDomainGroupedListItems(
|
||||
localize: LocalizeFunc,
|
||||
triggerIds: string[]
|
||||
ids: string[],
|
||||
getDomain: (id: string) => string,
|
||||
getListItem: (
|
||||
localize: LocalizeFunc,
|
||||
domain: string,
|
||||
id: string
|
||||
) => AddAutomationElementListItem
|
||||
): { title: string; items: AddAutomationElementListItem[] }[] {
|
||||
const items: Record<
|
||||
string,
|
||||
{ title: string; items: AddAutomationElementListItem[] }
|
||||
> = {};
|
||||
|
||||
triggerIds.forEach((trigger) => {
|
||||
const domain = getTriggerDomain(trigger);
|
||||
// When a specific entity is selected, system domain items are merged
|
||||
// under the entity's real domain rather than under their system domain name.
|
||||
const targetEntityId = this._selectedTarget?.entity_id;
|
||||
const targetEntityDomain =
|
||||
targetEntityId &&
|
||||
this._manifests?.[computeDomain(targetEntityId)]?.integration_type !==
|
||||
"system"
|
||||
? computeDomain(targetEntityId)
|
||||
: undefined;
|
||||
|
||||
if (!items[domain]) {
|
||||
items[domain] = {
|
||||
title: domainToName(localize, domain, this._manifests?.[domain]),
|
||||
ids.forEach((id) => {
|
||||
const itemDomain = getDomain(id);
|
||||
const isSystemDomain =
|
||||
this._manifests?.[itemDomain]?.integration_type === "system";
|
||||
|
||||
// System domain items are grouped under the entity's real domain (if
|
||||
// a specific entity is selected), so they appear alongside that domain's
|
||||
// own items rather than in a separate section.
|
||||
const groupDomain =
|
||||
isSystemDomain && targetEntityDomain ? targetEntityDomain : itemDomain;
|
||||
|
||||
if (!items[groupDomain]) {
|
||||
items[groupDomain] = {
|
||||
title: domainToName(
|
||||
localize,
|
||||
groupDomain,
|
||||
this._manifests?.[groupDomain]
|
||||
),
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
items[domain].items.push(
|
||||
this._getTriggerListItem(localize, domain, trigger)
|
||||
);
|
||||
items[groupDomain].items.push(getListItem(localize, itemDomain, id));
|
||||
|
||||
items[domain].items.sort((a, b) =>
|
||||
items[groupDomain].items.sort((a, b) =>
|
||||
stringCompare(a.name, b.name, this.hass.locale.language)
|
||||
);
|
||||
});
|
||||
@@ -1739,39 +1918,6 @@ class DialogAddAutomationElement
|
||||
);
|
||||
}
|
||||
|
||||
private _getDomainGroupedConditionListItems(
|
||||
localize: LocalizeFunc,
|
||||
conditionIds: string[]
|
||||
): { title: string; items: AddAutomationElementListItem[] }[] {
|
||||
const items: Record<
|
||||
string,
|
||||
{ title: string; items: AddAutomationElementListItem[] }
|
||||
> = {};
|
||||
|
||||
conditionIds.forEach((condition) => {
|
||||
const domain = getConditionDomain(condition);
|
||||
if (!items[domain]) {
|
||||
items[domain] = {
|
||||
title: domainToName(localize, domain, this._manifests?.[domain]),
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
items[domain].items.push(
|
||||
this._getConditionListItem(localize, domain, condition)
|
||||
);
|
||||
|
||||
items[domain].items.sort((a, b) =>
|
||||
stringCompare(a.name, b.name, this.hass.locale.language)
|
||||
);
|
||||
});
|
||||
|
||||
return this._sortDomainsByCollection(
|
||||
this._params!.type,
|
||||
Object.entries(items)
|
||||
);
|
||||
}
|
||||
|
||||
// #endregion render prepare
|
||||
|
||||
// #region interaction
|
||||
@@ -1901,9 +2047,12 @@ class DialogAddAutomationElement
|
||||
this._selectedTarget
|
||||
);
|
||||
|
||||
const grouped = this._getDomainGroupedTriggerListItems(
|
||||
const grouped = this._getDomainGroupedListItems(
|
||||
this.hass.localize,
|
||||
items
|
||||
items,
|
||||
getTriggerDomain,
|
||||
(localize, domain, trigger) =>
|
||||
this._getTriggerListItem(localize, domain, trigger)
|
||||
);
|
||||
if (this._selectedTarget.entity_id) {
|
||||
grouped.push({
|
||||
@@ -1922,9 +2071,12 @@ class DialogAddAutomationElement
|
||||
this._selectedTarget
|
||||
);
|
||||
|
||||
const grouped = this._getDomainGroupedConditionListItems(
|
||||
const grouped = this._getDomainGroupedListItems(
|
||||
this.hass.localize,
|
||||
items
|
||||
items,
|
||||
getConditionDomain,
|
||||
(localize, domain, condition) =>
|
||||
this._getConditionListItem(localize, domain, condition)
|
||||
);
|
||||
if (this._selectedTarget.entity_id) {
|
||||
grouped.push({
|
||||
|
||||
@@ -107,6 +107,8 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
|
||||
@state() private _collapsed = true;
|
||||
|
||||
@state() private _isNew = false;
|
||||
|
||||
@state() private _warnings?: string[];
|
||||
|
||||
@property({ attribute: false })
|
||||
@@ -160,13 +162,15 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
}
|
||||
|
||||
private _renderRow() {
|
||||
const target =
|
||||
"target" in (this.conditionDescriptions[this.condition.condition] || {})
|
||||
? (this.condition as PlatformCondition).target
|
||||
: "device_id" in this.condition &&
|
||||
(this.condition as DeviceCondition).device_id
|
||||
? { device_id: [(this.condition as DeviceCondition).device_id] }
|
||||
: undefined;
|
||||
const descriptionHasTarget =
|
||||
"target" in (this.conditionDescriptions[this.condition.condition] || {});
|
||||
|
||||
const target = descriptionHasTarget
|
||||
? (this.condition as PlatformCondition).target
|
||||
: "device_id" in this.condition &&
|
||||
(this.condition as DeviceCondition).device_id
|
||||
? { device_id: [(this.condition as DeviceCondition).device_id] }
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
<ha-condition-icon
|
||||
@@ -178,7 +182,9 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
${capitalizeFirstLetter(
|
||||
describeCondition(this.condition, this.hass, this._entityReg)
|
||||
)}
|
||||
${target ? this._renderTargets(target) : nothing}
|
||||
${target !== undefined || (descriptionHasTarget && !this._isNew)
|
||||
? this._renderTargets(target, descriptionHasTarget && !this._isNew)
|
||||
: nothing}
|
||||
</h3>
|
||||
|
||||
<slot name="icons" slot="icons"></slot>
|
||||
@@ -423,7 +429,10 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
>${this._renderRow()}</ha-automation-row
|
||||
>`
|
||||
: html`
|
||||
<ha-expansion-panel left-chevron>
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
@expanded-changed=${this._expansionPanelChanged}
|
||||
>
|
||||
${this._renderRow()}
|
||||
</ha-expansion-panel>
|
||||
`}
|
||||
@@ -464,10 +473,11 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
}
|
||||
|
||||
private _renderTargets = memoizeOne(
|
||||
(target?: HassServiceTarget) =>
|
||||
(target?: HassServiceTarget, targetRequired = false) =>
|
||||
html`<ha-automation-row-targets
|
||||
.hass=${this.hass}
|
||||
.target=${target}
|
||||
.targetRequired=${targetRequired}
|
||||
></ha-automation-row-targets>`
|
||||
);
|
||||
|
||||
@@ -743,6 +753,16 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
this.openSidebar();
|
||||
}
|
||||
|
||||
public markAsNew(): void {
|
||||
this._isNew = true;
|
||||
}
|
||||
|
||||
private _expansionPanelChanged(ev: CustomEvent) {
|
||||
if (!ev.detail.expanded) {
|
||||
this._isNew = false;
|
||||
}
|
||||
}
|
||||
|
||||
public openSidebar(condition?: Condition): void {
|
||||
const sidebarCondition = condition || this.condition;
|
||||
fireEvent(this, "open-sidebar", {
|
||||
@@ -751,6 +771,7 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
},
|
||||
close: (focus?: boolean) => {
|
||||
this._selected = false;
|
||||
this._isNew = false;
|
||||
fireEvent(this, "close-sidebar");
|
||||
if (focus) {
|
||||
this.focus();
|
||||
|
||||
@@ -169,6 +169,7 @@ export default class HaAutomationCondition extends AutomationSortableListMixin<C
|
||||
|
||||
if (mode === "new") {
|
||||
row.expand();
|
||||
row.markAsNew();
|
||||
}
|
||||
|
||||
if (!this.optionsInSidebar) {
|
||||
|
||||
@@ -73,13 +73,16 @@ export class HaPlatformCondition extends LitElement {
|
||||
}
|
||||
|
||||
if (
|
||||
oldValue?.condition !== this.condition?.condition &&
|
||||
this.condition &&
|
||||
oldValue?.condition !== this.condition.condition &&
|
||||
this.description?.fields
|
||||
) {
|
||||
const hadOptions = "options" in this.condition;
|
||||
const updatedOptions = this.condition.options
|
||||
? { ...this.condition.options }
|
||||
: {};
|
||||
const loadDefaults = !hadOptions;
|
||||
let updatedDefaultValue = false;
|
||||
const updatedOptions = {};
|
||||
const loadDefaults = !("options" in this.condition);
|
||||
// Set mandatory bools without a default value to false
|
||||
Object.entries(this.description.fields).forEach(([key, field]) => {
|
||||
if (
|
||||
@@ -106,7 +109,7 @@ export class HaPlatformCondition extends LitElement {
|
||||
updatedOptions[key] = field.default;
|
||||
}
|
||||
});
|
||||
if (updatedDefaultValue) {
|
||||
if (!hadOptions || updatedDefaultValue) {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.condition,
|
||||
@@ -166,19 +169,12 @@ export class HaPlatformCondition extends LitElement {
|
||||
</div>
|
||||
${conditionDesc && "target" in conditionDesc
|
||||
? html`<ha-settings-row narrow>
|
||||
${hasOptional
|
||||
? html`<div slot="prefix" class="checkbox-spacer"></div>`
|
||||
: nothing}
|
||||
<span slot="heading"
|
||||
>${this.hass.localize(
|
||||
"ui.components.service-control.target"
|
||||
)}</span
|
||||
>
|
||||
<span slot="description"
|
||||
>${this.hass.localize(
|
||||
"ui.components.service-control.target_secondary"
|
||||
)}</span
|
||||
><ha-selector
|
||||
<ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${this._targetSelector(conditionDesc.target)}
|
||||
.disabled=${this.disabled}
|
||||
@@ -240,6 +236,10 @@ export class HaPlatformCondition extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const description = this.hass.localize(
|
||||
`component.${domain}.conditions.${conditionName}.fields.${fieldName}.description`
|
||||
);
|
||||
|
||||
return html`<ha-settings-row narrow>
|
||||
${!showOptional
|
||||
? hasOptional
|
||||
@@ -259,11 +259,9 @@ export class HaPlatformCondition extends LitElement {
|
||||
`component.${domain}.conditions.${conditionName}.fields.${fieldName}.name`
|
||||
) || conditionName}</span
|
||||
>
|
||||
<span slot="description"
|
||||
>${this.hass.localize(
|
||||
`component.${domain}.conditions.${conditionName}.fields.${fieldName}.description`
|
||||
)}</span
|
||||
>
|
||||
${description
|
||||
? html`<span slot="description">${description}</span>`
|
||||
: nothing}
|
||||
<ha-selector
|
||||
.disabled=${this.disabled ||
|
||||
(showOptional &&
|
||||
|
||||
@@ -148,7 +148,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
this._newAutomationId &&
|
||||
changedProps.has("entityRegistry")
|
||||
) {
|
||||
const automation = this.entityRegistry.find(
|
||||
const automation = this.entityRegistry?.find(
|
||||
(entity: EntityRegistryEntry) =>
|
||||
entity.platform === "automation" &&
|
||||
entity.unique_id === this._newAutomationId
|
||||
|
||||
@@ -93,7 +93,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
|
||||
@state()
|
||||
@consume({ context: fullEntitiesContext, subscribe: true })
|
||||
entityRegistry!: EntityRegistryEntry[];
|
||||
entityRegistry?: EntityRegistryEntry[];
|
||||
|
||||
@state() protected dirty = false;
|
||||
|
||||
@@ -234,7 +234,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
goBack("/config");
|
||||
return;
|
||||
}
|
||||
const entity = this.entityRegistry.find(
|
||||
const entity = this.entityRegistry?.find(
|
||||
(ent) => ent.platform === domain && ent.unique_id === id
|
||||
);
|
||||
if (entity) {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { consume } from "@lit/context";
|
||||
import {
|
||||
mdiAlert,
|
||||
mdiAlertOctagon,
|
||||
mdiCodeBraces,
|
||||
mdiFormatListBulleted,
|
||||
mdiShape,
|
||||
} from "@mdi/js";
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, type nothing, type TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing, type TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ensureArray } from "../../../../common/array/ensure-array";
|
||||
import { transform } from "../../../../common/decorators/transform";
|
||||
import { isTemplate } from "../../../../common/string/has-template";
|
||||
@@ -35,6 +37,9 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public target?: HassServiceTarget;
|
||||
|
||||
@property({ attribute: false })
|
||||
public targetRequired = false;
|
||||
|
||||
@state()
|
||||
@consume({ context: localizeContext, subscribe: true })
|
||||
private localize!: HomeAssistant["localize"];
|
||||
@@ -73,13 +78,16 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
protected render() {
|
||||
const length = Object.keys(this.target || {}).length;
|
||||
if (!length) {
|
||||
return html`<span class="target">
|
||||
<div class="label">
|
||||
${this.localize(
|
||||
"ui.panel.config.automation.editor.target_summary.no_target"
|
||||
)}
|
||||
</div>
|
||||
</span>`;
|
||||
return this._renderTargetBadge(
|
||||
this.targetRequired
|
||||
? html`<ha-svg-icon .path=${mdiAlertOctagon}></ha-svg-icon>`
|
||||
: nothing,
|
||||
this.localize(
|
||||
"ui.panel.config.automation.editor.target_summary.no_target"
|
||||
),
|
||||
false,
|
||||
this.targetRequired
|
||||
);
|
||||
}
|
||||
const totalLength = Object.values(this.target || {}).reduce(
|
||||
(acc, val) => acc + ensureArray(val).length,
|
||||
@@ -154,9 +162,10 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
private _renderTargetBadge(
|
||||
icon: TemplateResult | typeof nothing,
|
||||
label: string,
|
||||
alert = false
|
||||
warning = false,
|
||||
error = false
|
||||
) {
|
||||
return html`<div class="target ${alert ? "alert" : ""}">
|
||||
return html`<div class=${classMap({ target: true, warning, error })}>
|
||||
${icon}
|
||||
<div class="label">${label}</div>
|
||||
</div>`;
|
||||
@@ -230,10 +239,14 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
overflow: hidden;
|
||||
height: 32px;
|
||||
}
|
||||
.target.alert {
|
||||
.target.warning {
|
||||
background: var(--ha-color-fill-warning-normal-resting);
|
||||
color: var(--ha-color-on-warning-normal);
|
||||
}
|
||||
.target.error {
|
||||
background: var(--ha-color-fill-danger-normal-resting);
|
||||
color: var(--ha-color-on-danger-normal);
|
||||
}
|
||||
.target .label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -145,6 +145,8 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
|
||||
@state() private _selected = false;
|
||||
|
||||
@state() private _isNew = false;
|
||||
|
||||
@state() private _warnings?: string[];
|
||||
|
||||
@property({ attribute: false })
|
||||
@@ -197,14 +199,16 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
|
||||
const yamlMode = this._yamlMode || !supported;
|
||||
|
||||
const target =
|
||||
const descriptionHasTarget =
|
||||
type === "platform" &&
|
||||
"target" in
|
||||
this.triggerDescriptions[(this.trigger as PlatformTrigger).trigger]
|
||||
? (this.trigger as PlatformTrigger).target
|
||||
: type === "device" && (this.trigger as DeviceTrigger).device_id
|
||||
? { device_id: (this.trigger as DeviceTrigger).device_id }
|
||||
: undefined;
|
||||
this.triggerDescriptions[(this.trigger as PlatformTrigger).trigger];
|
||||
|
||||
const target = descriptionHasTarget
|
||||
? (this.trigger as PlatformTrigger).target
|
||||
: type === "device" && (this.trigger as DeviceTrigger).device_id
|
||||
? { device_id: (this.trigger as DeviceTrigger).device_id }
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
${type === "list"
|
||||
@@ -220,7 +224,9 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
></ha-trigger-icon>`}
|
||||
<h3 slot="header">
|
||||
${describeTrigger(this.trigger, this.hass, this._entityReg)}
|
||||
${target ? this._renderTargets(target) : nothing}
|
||||
${target !== undefined || (descriptionHasTarget && !this._isNew)
|
||||
? this._renderTargets(target, descriptionHasTarget && !this._isNew)
|
||||
: nothing}
|
||||
</h3>
|
||||
|
||||
<slot name="icons" slot="icons"></slot>
|
||||
@@ -446,7 +452,10 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
: nothing}${this._renderRow()}</ha-automation-row
|
||||
>`
|
||||
: html`
|
||||
<ha-expansion-panel left-chevron>
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
@expanded-changed=${this._expansionPanelChanged}
|
||||
>
|
||||
${this._renderRow()}
|
||||
</ha-expansion-panel>
|
||||
`}
|
||||
@@ -467,10 +476,11 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
}
|
||||
|
||||
private _renderTargets = memoizeOne(
|
||||
(target?: HassServiceTarget) =>
|
||||
(target?: HassServiceTarget, targetRequired = false) =>
|
||||
html`<ha-automation-row-targets
|
||||
.hass=${this.hass}
|
||||
.target=${target}
|
||||
.targetRequired=${targetRequired}
|
||||
></ha-automation-row-targets>`
|
||||
);
|
||||
|
||||
@@ -576,6 +586,16 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
this.openSidebar();
|
||||
}
|
||||
|
||||
public markAsNew(): void {
|
||||
this._isNew = true;
|
||||
}
|
||||
|
||||
private _expansionPanelChanged(ev: CustomEvent) {
|
||||
if (!ev.detail.expanded) {
|
||||
this._isNew = false;
|
||||
}
|
||||
}
|
||||
|
||||
public openSidebar(trigger?: Trigger): void {
|
||||
trigger = trigger || this.trigger;
|
||||
fireEvent(this, "open-sidebar", {
|
||||
@@ -584,6 +604,7 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
},
|
||||
close: (focus?: boolean) => {
|
||||
this._selected = false;
|
||||
this._isNew = false;
|
||||
fireEvent(this, "close-sidebar");
|
||||
if (focus) {
|
||||
this.focus();
|
||||
|
||||
@@ -252,6 +252,7 @@ export default class HaAutomationTrigger extends AutomationSortableListMixin<Tri
|
||||
row.expand();
|
||||
row.focus();
|
||||
}
|
||||
row.markAsNew();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,13 +108,16 @@ export class HaPlatformTrigger extends LitElement {
|
||||
}
|
||||
|
||||
if (
|
||||
oldValue?.trigger !== this.trigger?.trigger &&
|
||||
this.trigger &&
|
||||
oldValue?.trigger !== this.trigger.trigger &&
|
||||
this.description?.fields
|
||||
) {
|
||||
const hadOptions = "options" in this.trigger;
|
||||
const updatedOptions = this.trigger.options
|
||||
? { ...this.trigger.options }
|
||||
: {};
|
||||
const loadDefaults = !hadOptions;
|
||||
let updatedDefaultValue = false;
|
||||
const updatedOptions = {};
|
||||
const loadDefaults = !("options" in this.trigger);
|
||||
// Set mandatory bools without a default value to false
|
||||
Object.entries(this.description.fields).forEach(([key, field]) => {
|
||||
if (
|
||||
@@ -142,7 +145,7 @@ export class HaPlatformTrigger extends LitElement {
|
||||
}
|
||||
});
|
||||
|
||||
if (updatedDefaultValue) {
|
||||
if (!hadOptions || updatedDefaultValue) {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.trigger,
|
||||
@@ -202,18 +205,10 @@ export class HaPlatformTrigger extends LitElement {
|
||||
</div>
|
||||
${triggerDesc && "target" in triggerDesc
|
||||
? html`<ha-settings-row narrow>
|
||||
${hasOptional
|
||||
? html`<div slot="prefix" class="checkbox-spacer"></div>`
|
||||
: nothing}
|
||||
<span slot="heading"
|
||||
>${this.hass.localize(
|
||||
"ui.components.service-control.target"
|
||||
)}</span
|
||||
>
|
||||
<span slot="description"
|
||||
>${this.hass.localize(
|
||||
"ui.components.service-control.target_secondary"
|
||||
)}</span
|
||||
><ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${this._targetSelector(triggerDesc.target)}
|
||||
@@ -276,6 +271,10 @@ export class HaPlatformTrigger extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const description = this.hass.localize(
|
||||
`component.${domain}.triggers.${triggerName}.fields.${fieldName}.description`
|
||||
);
|
||||
|
||||
return html`<ha-settings-row narrow>
|
||||
${!showOptional
|
||||
? hasOptional
|
||||
@@ -295,11 +294,9 @@ export class HaPlatformTrigger extends LitElement {
|
||||
`component.${domain}.triggers.${triggerName}.fields.${fieldName}.name`
|
||||
) || triggerName}</span
|
||||
>
|
||||
<span slot="description"
|
||||
>${this.hass.localize(
|
||||
`component.${domain}.triggers.${triggerName}.fields.${fieldName}.description`
|
||||
)}</span
|
||||
>
|
||||
${description
|
||||
? html`<span slot="description">${description}</span>`
|
||||
: nothing}
|
||||
<ha-selector
|
||||
.disabled=${this.disabled ||
|
||||
(showOptional &&
|
||||
|
||||
@@ -111,12 +111,14 @@ const randomTip = (openFn: any, hass: HomeAssistant, narrow: boolean) => {
|
||||
>`,
|
||||
};
|
||||
|
||||
tips.push(
|
||||
{
|
||||
if (hass.user?.is_admin) {
|
||||
tips.push({
|
||||
content: hass.localize("ui.tips.key_c_tip", localizeParam),
|
||||
weight: 1,
|
||||
narrow: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
tips.push(
|
||||
{
|
||||
content: hass.localize("ui.tips.key_m_tip", localizeParam),
|
||||
weight: 1,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mdiHelpCircleOutline } from "@mdi/js";
|
||||
import type { HassService } from "home-assistant-js-websocket";
|
||||
import { ERR_CONNECTION_LOST } from "home-assistant-js-websocket";
|
||||
import { dump, load } from "js-yaml";
|
||||
import { dump, JSON_SCHEMA, load } from "js-yaml";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
@@ -652,7 +652,7 @@ class HaPanelDevAction extends LitElement {
|
||||
if (field.example) {
|
||||
let value: any = "";
|
||||
try {
|
||||
value = load(field.example);
|
||||
value = load(field.example, { schema: JSON_SCHEMA });
|
||||
} catch (_err: any) {
|
||||
value =
|
||||
this.hass.localize(
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
computeSection,
|
||||
} from "../../../lovelace/common/generate-lovelace-config";
|
||||
import { addEntitiesToLovelaceView } from "../../../lovelace/editor/add-entities-to-view";
|
||||
import type { EntityRegistryEntryWithDisplayName } from "../ha-config-device-page";
|
||||
import type { EntityRegistryStateEntry } from "../ha-config-device-page";
|
||||
import { entityRowElement } from "../../../lovelace/entity-rows/entity-row-element-directive";
|
||||
|
||||
@customElement("ha-device-entities-card")
|
||||
@@ -30,7 +30,7 @@ export class HaDeviceEntitiesCard extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
public entities!: EntityRegistryEntryWithDisplayName[];
|
||||
public entities!: EntityRegistryStateEntry[];
|
||||
|
||||
@property({ attribute: "show-hidden", type: Boolean })
|
||||
public showHidden = false;
|
||||
@@ -115,10 +115,8 @@ export class HaDeviceEntitiesCard extends LitElement {
|
||||
this.showHidden = !this.showHidden;
|
||||
}
|
||||
|
||||
private _renderEntity(
|
||||
entry: EntityRegistryEntryWithDisplayName
|
||||
): TemplateResult {
|
||||
let name = entry.display_name || this.deviceName;
|
||||
private _renderEntity(entry: EntityRegistryStateEntry): TemplateResult {
|
||||
let name = entry.stateName || this.deviceName;
|
||||
if (entry.hidden_by) {
|
||||
name += ` (${this.hass.localize(
|
||||
"ui.panel.config.devices.entities.hidden"
|
||||
@@ -130,9 +128,9 @@ export class HaDeviceEntitiesCard extends LitElement {
|
||||
}
|
||||
|
||||
private _renderUnavailableEntity(
|
||||
entry: EntityRegistryEntryWithDisplayName
|
||||
entry: EntityRegistryStateEntry
|
||||
): TemplateResult {
|
||||
const name = entry.display_name || this.deviceName;
|
||||
const name = entry.stateName || this.deviceName;
|
||||
|
||||
const icon = until(entryIcon(this.hass, entry));
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ import "../../../components/entity/ha-battery-icon";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-icon-next";
|
||||
@@ -66,6 +65,7 @@ import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
|
||||
import {
|
||||
findBatteryChargingEntity,
|
||||
findBatteryEntity,
|
||||
updateEntityRegistryEntry,
|
||||
} from "../../../data/entity/entity_registry";
|
||||
import type { IntegrationManifest } from "../../../data/integration";
|
||||
import { domainToName } from "../../../data/integration";
|
||||
@@ -94,9 +94,10 @@ import {
|
||||
loadDeviceRegistryDetailDialog,
|
||||
showDeviceRegistryDetailDialog,
|
||||
} from "./device-registry-detail/show-dialog-device-registry-detail";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
|
||||
export interface EntityRegistryEntryWithDisplayName extends EntityRegistryEntry {
|
||||
display_name?: string | null;
|
||||
export interface EntityRegistryStateEntry extends EntityRegistryEntry {
|
||||
stateName?: string | null;
|
||||
}
|
||||
|
||||
export interface DeviceAction {
|
||||
@@ -175,18 +176,19 @@ export class HaConfigDevicePage extends LitElement {
|
||||
private _entities = memoizeOne(
|
||||
(
|
||||
deviceId: string,
|
||||
entities: EntityRegistryEntry[]
|
||||
): EntityRegistryEntry[] =>
|
||||
entities: EntityRegistryEntry[],
|
||||
devices: HomeAssistant["devices"]
|
||||
): EntityRegistryStateEntry[] =>
|
||||
entities
|
||||
.filter((entity) => entity.device_id === deviceId)
|
||||
.map((entity) => ({
|
||||
...entity,
|
||||
display_name: computeEntityEntryName(entity),
|
||||
stateName: this._computeEntityName(entity, devices),
|
||||
}))
|
||||
.sort((ent1, ent2) =>
|
||||
stringCompare(
|
||||
ent1.display_name || "",
|
||||
ent2.display_name || "",
|
||||
ent1.stateName || `zzz${ent1.entity_id}`,
|
||||
ent2.stateName || `zzz${ent2.entity_id}`,
|
||||
this.hass.locale.language
|
||||
)
|
||||
)
|
||||
@@ -216,7 +218,7 @@ export class HaConfigDevicePage extends LitElement {
|
||||
private _deviceIdInList = memoizeOne((deviceId: string) => [deviceId]);
|
||||
|
||||
private _entityIds = memoizeOne(
|
||||
(entries: EntityRegistryEntryWithDisplayName[]): string[] =>
|
||||
(entries: EntityRegistryStateEntry[]): string[] =>
|
||||
entries.map((entry) => entry.entity_id)
|
||||
);
|
||||
|
||||
@@ -249,7 +251,7 @@ export class HaConfigDevicePage extends LitElement {
|
||||
| "assist"
|
||||
| "notify"
|
||||
| NonNullable<EntityRegistryEntry["entity_category"]>,
|
||||
EntityRegistryEntryWithDisplayName[]
|
||||
EntityRegistryStateEntry[]
|
||||
>;
|
||||
for (const key of [
|
||||
"assist",
|
||||
@@ -339,7 +341,11 @@ export class HaConfigDevicePage extends LitElement {
|
||||
this.entries,
|
||||
this.manifests
|
||||
);
|
||||
const entities = this._entities(this.deviceId, this._entityReg);
|
||||
const entities = this._entities(
|
||||
this.deviceId,
|
||||
this._entityReg,
|
||||
this.hass.devices
|
||||
);
|
||||
const entitiesByCategory = this._entitiesByCategory(entities);
|
||||
const batteryEntity = this._batteryEntity(entities);
|
||||
const batteryChargingEntity = this._batteryChargingEntity(entities);
|
||||
@@ -1143,7 +1149,11 @@ export class HaConfigDevicePage extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
const entities = this._entities(this.deviceId, this._entityReg);
|
||||
const entities = this._entities(
|
||||
this.deviceId,
|
||||
this._entityReg,
|
||||
this.hass.devices
|
||||
);
|
||||
|
||||
const assistSatellite = entities.find(
|
||||
(ent) => computeDomain(ent.entity_id) === "assist_satellite"
|
||||
@@ -1270,6 +1280,17 @@ export class HaConfigDevicePage extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _computeEntityName(
|
||||
entity: EntityRegistryEntry,
|
||||
devices: HomeAssistant["devices"]
|
||||
) {
|
||||
const device = devices[this.deviceId];
|
||||
return (
|
||||
computeEntityEntryName(entity, devices) ||
|
||||
computeDeviceNameDisplay(device, this.hass)
|
||||
);
|
||||
}
|
||||
|
||||
private _onImageLoad(ev) {
|
||||
ev.target.style.display = "inline-block";
|
||||
}
|
||||
@@ -1284,9 +1305,11 @@ export class HaConfigDevicePage extends LitElement {
|
||||
|
||||
private _createScene() {
|
||||
const entities: SceneEntities = {};
|
||||
this._entities(this.deviceId, this._entityReg).forEach((entity) => {
|
||||
entities[entity.entity_id] = "";
|
||||
});
|
||||
this._entities(this.deviceId, this._entityReg, this.hass.devices).forEach(
|
||||
(entity) => {
|
||||
entities[entity.entity_id] = "";
|
||||
}
|
||||
);
|
||||
showSceneEditor({
|
||||
entities,
|
||||
});
|
||||
@@ -1351,9 +1374,11 @@ export class HaConfigDevicePage extends LitElement {
|
||||
}
|
||||
|
||||
private _resetEntityIds = () => {
|
||||
const entities = this._entities(this.deviceId, this._entityReg).map(
|
||||
(e) => e.entity_id
|
||||
);
|
||||
const entities = this._entities(
|
||||
this.deviceId,
|
||||
this._entityReg,
|
||||
this.hass.devices
|
||||
).map((e) => e.entity_id);
|
||||
regenerateEntityIds(this, this.hass, entities);
|
||||
};
|
||||
|
||||
@@ -1362,6 +1387,8 @@ export class HaConfigDevicePage extends LitElement {
|
||||
showDeviceRegistryDetailDialog(this, {
|
||||
device,
|
||||
updateEntry: async (updates) => {
|
||||
const oldDeviceName = device.name_by_user || device.name;
|
||||
const newDeviceName = updates.name_by_user;
|
||||
const disabled =
|
||||
updates.disabled_by === "user" && device.disabled_by !== "user";
|
||||
|
||||
@@ -1433,7 +1460,47 @@ export class HaConfigDevicePage extends LitElement {
|
||||
),
|
||||
text: err.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!oldDeviceName ||
|
||||
!newDeviceName ||
|
||||
oldDeviceName === newDeviceName
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const entities = this._entities(
|
||||
this.deviceId,
|
||||
this._entityReg,
|
||||
this.hass.devices
|
||||
);
|
||||
|
||||
const updateProms = entities.map((entity) => {
|
||||
const name = entity.name || entity.stateName;
|
||||
let newName: string | null | undefined;
|
||||
|
||||
if (entity.has_entity_name && !entity.name) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
entity.has_entity_name &&
|
||||
(entity.name === oldDeviceName || entity.name === newDeviceName)
|
||||
) {
|
||||
// clear name if it matches the device name and it uses the device name (entity naming)
|
||||
newName = null;
|
||||
} else if (name && name.includes(oldDeviceName)) {
|
||||
newName = name.replace(oldDeviceName, newDeviceName);
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return updateEntityRegistryEntry(this.hass!, entity.entity_id, {
|
||||
name: newName,
|
||||
});
|
||||
});
|
||||
await Promise.all(updateProms);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -448,9 +448,9 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
);
|
||||
|
||||
const labels = labelReg && device?.labels;
|
||||
const labelsEntries = (labels || []).map(
|
||||
(lbl) => labelReg!.find((label) => label.label_id === lbl)!
|
||||
);
|
||||
const labelsEntries = (labels || [])
|
||||
.map((lbl) => labelReg!.find((label) => label.label_id === lbl))
|
||||
.filter((entry): entry is LabelRegistryEntry => entry !== undefined);
|
||||
|
||||
let floorName;
|
||||
if (
|
||||
|
||||
@@ -140,9 +140,9 @@ export class DialogVacuumSegmentMapping
|
||||
: undefined;
|
||||
|
||||
const entityName = stateObj
|
||||
? computeEntityName(stateObj, this.hass.entities)
|
||||
? computeEntityName(stateObj, this.hass.entities, this.hass.devices)
|
||||
: this._entry
|
||||
? computeEntityEntryName(this._entry)
|
||||
? computeEntityEntryName(this._entry, this.hass.devices)
|
||||
: this._params.entityId;
|
||||
|
||||
const deviceName = context?.device
|
||||
|
||||
@@ -164,7 +164,7 @@ export class EntitySettingsHelperTab extends LitElement {
|
||||
}
|
||||
|
||||
private async _confirmDeleteItem(): Promise<void> {
|
||||
const name = computeEntityEntryName(this.entry);
|
||||
const name = computeEntityEntryName(this.entry, this.hass.devices);
|
||||
const confirmationText = await getDeleteConfirmationText(
|
||||
this.hass,
|
||||
this.entry,
|
||||
|
||||
@@ -153,7 +153,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public helperConfigEntry?: ConfigEntry;
|
||||
|
||||
@state() private _name!: string | null;
|
||||
@state() private _name!: string;
|
||||
|
||||
@state() private _icon!: string;
|
||||
|
||||
@@ -218,7 +218,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
this._name = this.entry.name;
|
||||
this._name = this.entry.name || "";
|
||||
this._icon = this.entry.icon || "";
|
||||
this._deviceClass =
|
||||
this.entry.device_class || this.entry.original_device_class;
|
||||
@@ -388,27 +388,14 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
${this.hideName
|
||||
? nothing
|
||||
: html`<ha-textfield
|
||||
class="name"
|
||||
.value=${this._name ?? this.entry.original_name ?? ""}
|
||||
.value=${this._name}
|
||||
.label=${this.hass.localize(
|
||||
"ui.dialogs.entity_registry.editor.name"
|
||||
)}
|
||||
.disabled=${this.disabled}
|
||||
.placeholder=${this.entry.original_name}
|
||||
@input=${this._nameChanged}
|
||||
.iconTrailing=${this._name !== null}
|
||||
>
|
||||
${this._name !== null
|
||||
? html`<div class="layout horizontal" slot="trailingIcon">
|
||||
<ha-icon-button
|
||||
@click=${this._restoreName}
|
||||
.path=${mdiRestore}
|
||||
.label=${this.hass.localize(
|
||||
"ui.dialogs.entity_registry.editor.restore_name"
|
||||
)}
|
||||
></ha-icon-button>
|
||||
</div>`
|
||||
: nothing}
|
||||
</ha-textfield>`}
|
||||
></ha-textfield>`}
|
||||
${this.hideIcon
|
||||
? nothing
|
||||
: html`
|
||||
@@ -1065,7 +1052,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
}
|
||||
|
||||
const params: Partial<EntityRegistryEntryUpdateParams> = {
|
||||
name: this._name?.trim() ?? null,
|
||||
name: this._name.trim() || null,
|
||||
icon: this._icon.trim() || null,
|
||||
area_id: this._areaId || null,
|
||||
labels: this._labels || [],
|
||||
@@ -1331,11 +1318,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
this._name = ev.target.value;
|
||||
}
|
||||
|
||||
private _restoreName(): void {
|
||||
fireEvent(this, "change");
|
||||
this._name = null;
|
||||
}
|
||||
|
||||
private _iconChanged(ev: CustomEvent): void {
|
||||
fireEvent(this, "change");
|
||||
this._icon = ev.detail.value;
|
||||
@@ -1610,13 +1592,9 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
}
|
||||
ha-textfield.entityId {
|
||||
--text-field-prefix-padding-right: 0;
|
||||
}
|
||||
ha-textfield.entityId,
|
||||
ha-textfield.name {
|
||||
--textfield-icon-trailing-padding: 0;
|
||||
}
|
||||
ha-textfield.entityId ha-icon-button,
|
||||
ha-textfield.name ha-icon-button {
|
||||
ha-textfield.entityId ha-icon-button {
|
||||
position: relative;
|
||||
right: calc(var(--ha-space-2) * -1);
|
||||
--ha-icon-button-size: 36px;
|
||||
|
||||
@@ -213,7 +213,7 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
private async _confirmDeleteEntry(): Promise<void> {
|
||||
let name = computeEntityEntryName(this.entry);
|
||||
let name = computeEntityEntryName(this.entry, this.hass.devices);
|
||||
if (!name) {
|
||||
const { device } = getEntityEntryContext(
|
||||
this.entry,
|
||||
|
||||
@@ -32,10 +32,7 @@ import {
|
||||
getDuplicatedDeviceNames,
|
||||
} from "../../../common/entity/compute_device_name";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import {
|
||||
computeEntityEntryName,
|
||||
computeEntityName,
|
||||
} from "../../../common/entity/compute_entity_name";
|
||||
import { computeEntityEntryName } from "../../../common/entity/compute_entity_name";
|
||||
import { computeStateName } from "../../../common/entity/compute_state_name";
|
||||
import {
|
||||
deleteEntity,
|
||||
@@ -693,9 +690,11 @@ export class HaConfigEntities extends LitElement {
|
||||
(lbl) => labelReg!.find((label) => label.label_id === lbl)!
|
||||
);
|
||||
|
||||
const entityName = entity
|
||||
? computeEntityName(entity, this.hass.entities)
|
||||
: computeEntityEntryName(entry as EntityRegistryEntry);
|
||||
const entityName = computeEntityEntryName(
|
||||
entry as EntityRegistryEntry,
|
||||
this.hass.devices,
|
||||
entity
|
||||
);
|
||||
|
||||
const deviceName = device ? computeDeviceName(device) : undefined;
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
|
||||
@@ -63,6 +63,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconPath: mdiDevices,
|
||||
iconColor: "#0D47A1",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/automation",
|
||||
@@ -70,6 +71,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconPath: mdiRobot,
|
||||
iconColor: "#518C43",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/areas",
|
||||
@@ -77,6 +79,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconPath: mdiSofa,
|
||||
iconColor: "#E48629",
|
||||
component: "zone",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/apps",
|
||||
@@ -84,6 +87,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconPath: mdiPuzzle,
|
||||
iconColor: "#F1C447",
|
||||
component: "hassio",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/lovelace/dashboards",
|
||||
@@ -91,12 +95,14 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconPath: mdiViewDashboard,
|
||||
iconColor: "#B1345C",
|
||||
component: "lovelace",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/voice-assistants",
|
||||
translationKey: "voice_assistants",
|
||||
iconPath: mdiMicrophone,
|
||||
iconColor: "#3263C3",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
dashboard_external_settings: [
|
||||
@@ -116,6 +122,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconColor: "#2458B3",
|
||||
component: "matter",
|
||||
translationKey: "matter",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/zha",
|
||||
@@ -123,6 +130,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconColor: "#E74011",
|
||||
component: "zha",
|
||||
translationKey: "zha",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/zwave_js",
|
||||
@@ -130,6 +138,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconColor: "#153163",
|
||||
component: "zwave_js",
|
||||
translationKey: "zwave_js",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/knx",
|
||||
@@ -138,6 +147,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconColor: "#4EAA66",
|
||||
component: "knx",
|
||||
translationKey: "knx",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/thread",
|
||||
@@ -146,6 +156,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconColor: "#ED7744",
|
||||
component: "thread",
|
||||
translationKey: "thread",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/bluetooth",
|
||||
@@ -153,6 +164,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconColor: "#0082FC",
|
||||
component: "bluetooth",
|
||||
translationKey: "bluetooth",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/insteon",
|
||||
@@ -161,6 +173,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconColor: "#E4002C",
|
||||
component: "insteon",
|
||||
translationKey: "insteon",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/tags",
|
||||
@@ -168,6 +181,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconPath: mdiNfcVariant,
|
||||
iconColor: "#616161",
|
||||
component: "tag",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
dashboard_3: [
|
||||
@@ -177,6 +191,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconPath: mdiAccount,
|
||||
iconColor: "#5A87FA",
|
||||
component: ["person", "users"],
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/system",
|
||||
@@ -184,6 +199,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconPath: mdiCog,
|
||||
iconColor: "#301ABE",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/developer-tools",
|
||||
@@ -191,6 +207,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconPath: mdiHammer,
|
||||
iconColor: "#7A5AA6",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/info",
|
||||
@@ -198,6 +215,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconPath: mdiInformationOutline,
|
||||
iconColor: "#4A5963",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
backup: [
|
||||
@@ -207,6 +225,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconPath: mdiBackupRestore,
|
||||
iconColor: "#4084CD",
|
||||
component: "backup",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
devices: [
|
||||
@@ -217,6 +236,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconPath: mdiPuzzle,
|
||||
iconColor: "#2D338F",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "devices",
|
||||
@@ -225,6 +245,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconPath: mdiDevices,
|
||||
iconColor: "#2D338F",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "entities",
|
||||
@@ -233,6 +254,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconPath: mdiShape,
|
||||
iconColor: "#2D338F",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "helpers",
|
||||
@@ -241,6 +263,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconPath: mdiTools,
|
||||
iconColor: "#4D2EA4",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
automations: [
|
||||
@@ -250,6 +273,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
translationKey: "ui.panel.config.automation.caption",
|
||||
iconPath: mdiRobot,
|
||||
iconColor: "#518C43",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "scene",
|
||||
@@ -257,6 +281,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
translationKey: "ui.panel.config.scene.caption",
|
||||
iconPath: mdiPalette,
|
||||
iconColor: "#518C43",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "script",
|
||||
@@ -264,6 +289,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
translationKey: "ui.panel.config.script.caption",
|
||||
iconPath: mdiScriptText,
|
||||
iconColor: "#518C43",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "blueprint",
|
||||
@@ -271,6 +297,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
translationKey: "ui.panel.config.blueprint.caption",
|
||||
iconPath: mdiPaletteSwatch,
|
||||
iconColor: "#518C43",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
tags: [
|
||||
@@ -280,6 +307,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
translationKey: "ui.panel.config.tag.caption",
|
||||
iconPath: mdiNfcVariant,
|
||||
iconColor: "#616161",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
voice_assistants: [
|
||||
@@ -288,6 +316,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
translationKey: "ui.panel.config.dashboard.voice_assistants.main",
|
||||
iconPath: mdiMicrophone,
|
||||
iconColor: "#3263C3",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
developer_tools: [
|
||||
@@ -297,6 +326,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconPath: mdiHammer,
|
||||
iconColor: "#7A5AA6",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
// Not used as a tab, but this way it will stay in the quick bar
|
||||
@@ -307,6 +337,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
translationKey: "ui.panel.config.energy.caption",
|
||||
iconPath: mdiLightningBolt,
|
||||
iconColor: "#F1C447",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
// Not used as a tab, but this way it will stay in the quick bar
|
||||
@@ -317,6 +348,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
translationKey: "ui.panel.config.network.discovery.dhcp",
|
||||
iconPath: mdiNetwork,
|
||||
iconColor: "#B1345C",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "ssdp",
|
||||
@@ -324,6 +356,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
translationKey: "ui.panel.config.network.discovery.ssdp",
|
||||
iconPath: mdiNetwork,
|
||||
iconColor: "#B1345C",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "zeroconf",
|
||||
@@ -331,6 +364,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
translationKey: "ui.panel.config.network.discovery.zeroconf",
|
||||
iconPath: mdiNetwork,
|
||||
iconColor: "#B1345C",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
// Not used as a tab, but this way it will stay in the quick bar
|
||||
@@ -340,6 +374,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
translationKey: "ui.panel.config.application_credentials.caption",
|
||||
iconPath: mdiPuzzle,
|
||||
iconColor: "#2D338F",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
// Not used as a tab, but this way it will stay in the quick bar
|
||||
@@ -350,6 +385,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
translationKey: "ui.panel.config.mqtt.title",
|
||||
iconPath: mdiPuzzle,
|
||||
iconColor: "#2D338F",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
lovelace: [
|
||||
@@ -359,6 +395,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
translationKey: "ui.panel.config.lovelace.caption",
|
||||
iconPath: mdiViewDashboard,
|
||||
iconColor: "#B1345C",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
persons: [
|
||||
@@ -368,6 +405,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
translationKey: "ui.panel.config.person.caption",
|
||||
iconPath: mdiAccount,
|
||||
iconColor: "#5A87FA",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "users",
|
||||
@@ -377,6 +415,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconColor: "#5A87FA",
|
||||
core: true,
|
||||
advancedOnly: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
areas: [
|
||||
@@ -387,6 +426,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconPath: mdiSofa,
|
||||
iconColor: "#2D338F",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "labels",
|
||||
@@ -395,6 +435,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconPath: mdiLabel,
|
||||
iconColor: "#2D338F",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "zone",
|
||||
@@ -402,6 +443,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
translationKey: "ui.panel.config.zone.caption",
|
||||
iconPath: mdiMapMarkerRadius,
|
||||
iconColor: "#E48629",
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
general: [
|
||||
@@ -411,18 +453,21 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconPath: mdiCog,
|
||||
iconColor: "#653249",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/updates",
|
||||
translationKey: "updates",
|
||||
iconPath: mdiUpdate,
|
||||
iconColor: "#3B808E",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/repairs",
|
||||
translationKey: "repairs",
|
||||
iconPath: mdiScrewdriver,
|
||||
iconColor: "#5c995c",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
component: "logs",
|
||||
@@ -431,6 +476,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconPath: mdiTextBoxOutline,
|
||||
iconColor: "#C65326",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/backup",
|
||||
@@ -438,12 +484,14 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconPath: mdiBackupRestore,
|
||||
iconColor: "#0D47A1",
|
||||
component: "backup",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/analytics",
|
||||
translationKey: "analytics",
|
||||
iconPath: mdiShape,
|
||||
iconColor: "#f1c447",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/ai-tasks",
|
||||
@@ -451,6 +499,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconPath: mdiStarFourPoints,
|
||||
iconColor: "#8B69E3",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/labs",
|
||||
@@ -458,12 +507,14 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconPath: mdiFlask,
|
||||
iconColor: "#b1b134",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/network",
|
||||
translationKey: "network",
|
||||
iconPath: mdiNetwork,
|
||||
iconColor: "#B1345C",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/storage",
|
||||
@@ -471,6 +522,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconPath: mdiDatabase,
|
||||
iconColor: "#518C43",
|
||||
component: "hassio",
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
path: "/config/hardware",
|
||||
@@ -478,6 +530,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconPath: mdiMemory,
|
||||
iconColor: "#301A8E",
|
||||
component: ["hassio", "hardware"],
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
about: [
|
||||
@@ -488,6 +541,7 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconPath: mdiInformationOutline,
|
||||
iconColor: "#4A5963",
|
||||
core: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -292,7 +292,6 @@ export class HaIntegrationCard extends LitElement {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
--state-color: var(--divider-color, #e0e0e0);
|
||||
--ha-card-border-color: var(--state-color);
|
||||
--state-message-color: var(--state-color);
|
||||
}
|
||||
.ripple-anchor {
|
||||
@@ -318,19 +317,23 @@ export class HaIntegrationCard extends LitElement {
|
||||
}
|
||||
.debug-logging {
|
||||
--state-color: var(--warning-color);
|
||||
--ha-card-border-color: var(--state-color);
|
||||
--text-on-state-color: var(--primary-text-color);
|
||||
}
|
||||
.state-error {
|
||||
--state-color: var(--error-color);
|
||||
--ha-card-border-color: var(--state-color);
|
||||
--text-on-state-color: var(--text-primary-color);
|
||||
}
|
||||
.state-failed-unload {
|
||||
--state-color: var(--warning-color);
|
||||
--ha-card-border-color: var(--state-color);
|
||||
--text-on-state-color: var(--primary-text-color);
|
||||
}
|
||||
.state-not-loaded {
|
||||
opacity: 0.8;
|
||||
--state-color: var(--warning-color);
|
||||
--ha-card-border-color: var(--state-color);
|
||||
--state-message-color: var(--primary-text-color);
|
||||
}
|
||||
.state-setup {
|
||||
@@ -339,6 +342,7 @@ export class HaIntegrationCard extends LitElement {
|
||||
}
|
||||
:host(.highlight) ha-card {
|
||||
--state-color: var(--primary-color);
|
||||
--ha-card-border-color: var(--state-color);
|
||||
--text-on-state-color: var(--text-primary-color);
|
||||
}
|
||||
.content {
|
||||
|
||||
@@ -75,18 +75,21 @@ class ZHAConfigDashboard extends LitElement {
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const deviceIds = new Set<string>();
|
||||
const devices = this._configEntry
|
||||
? Object.values(this.hass.devices).filter((device) =>
|
||||
device.config_entries.includes(this._configEntry!.entry_id)
|
||||
)
|
||||
: [];
|
||||
const deviceCount = devices.length;
|
||||
|
||||
let entityCount = 0;
|
||||
for (const entity of Object.values(this.hass.entities)) {
|
||||
if (entity.platform === "zha") {
|
||||
entityCount++;
|
||||
if (entity.device_id) {
|
||||
deviceIds.add(entity.device_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
const deviceOnline =
|
||||
this._offlineDevices < deviceIds.size || deviceIds.size === 0;
|
||||
this._offlineDevices < deviceCount || deviceCount === 0;
|
||||
return html`
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
@@ -96,8 +99,8 @@ class ZHAConfigDashboard extends LitElement {
|
||||
has-fab
|
||||
>
|
||||
<div class="container">
|
||||
${this._renderNetworkStatus(deviceOnline, deviceIds.size)}
|
||||
${this._renderMyNetworkCard(deviceIds, entityCount)}
|
||||
${this._renderNetworkStatus(deviceOnline, deviceCount)}
|
||||
${this._renderMyNetworkCard(deviceCount, entityCount)}
|
||||
${this._renderNavigationCard()} ${this._renderBackupCard()}
|
||||
</div>
|
||||
|
||||
@@ -165,7 +168,7 @@ class ZHAConfigDashboard extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderMyNetworkCard(deviceIds: Set<string>, entityCount: number) {
|
||||
private _renderMyNetworkCard(deviceCount: number, entityCount: number) {
|
||||
return html`
|
||||
<ha-card class="nav-card">
|
||||
<div class="card-header">
|
||||
@@ -189,7 +192,7 @@ class ZHAConfigDashboard extends LitElement {
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.zha.configuration_page.device_count",
|
||||
{ count: deviceIds.size }
|
||||
{ count: deviceCount }
|
||||
)}
|
||||
</div>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
|
||||
@@ -4,7 +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 { computeEntityEntryName } from "../../../../../common/entity/compute_entity_name";
|
||||
import { computeStateName } from "../../../../../common/entity/compute_state_name";
|
||||
import { stringCompare } from "../../../../../common/string/compare";
|
||||
import "../../../../../components/entity/state-badge";
|
||||
import "../../../../../components/ha-area-picker";
|
||||
@@ -12,13 +12,17 @@ import "../../../../../components/ha-card";
|
||||
import "../../../../../components/ha-textfield";
|
||||
import { updateDeviceRegistryEntry } from "../../../../../data/device/device_registry";
|
||||
import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry";
|
||||
import { subscribeEntityRegistry } from "../../../../../data/entity/entity_registry";
|
||||
import {
|
||||
getAutomaticEntityIds,
|
||||
subscribeEntityRegistry,
|
||||
updateEntityRegistryEntry,
|
||||
} from "../../../../../data/entity/entity_registry";
|
||||
import type { ZHADevice } from "../../../../../data/zha";
|
||||
import { showAlertDialog } from "../../../../../dialogs/generic/show-dialog-box";
|
||||
import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin";
|
||||
import { haStyle } from "../../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import type { EntityRegistryEntryWithDisplayName } from "../../../devices/ha-config-device-page";
|
||||
import type { EntityRegistryStateEntry } from "../../../devices/ha-config-device-page";
|
||||
|
||||
@customElement("zha-device-card")
|
||||
class ZHADeviceCard extends SubscribeMixin(LitElement) {
|
||||
@@ -34,17 +38,17 @@ class ZHADeviceCard extends SubscribeMixin(LitElement) {
|
||||
(
|
||||
deviceId: string,
|
||||
entities: EntityRegistryEntry[]
|
||||
): EntityRegistryEntryWithDisplayName[] =>
|
||||
): EntityRegistryStateEntry[] =>
|
||||
entities
|
||||
.filter((entity) => entity.device_id === deviceId)
|
||||
.map((entity) => ({
|
||||
...entity,
|
||||
display_name: computeEntityEntryName(entity),
|
||||
stateName: this._computeEntityName(entity),
|
||||
}))
|
||||
.sort((ent1, ent2) =>
|
||||
stringCompare(
|
||||
ent1.display_name || "",
|
||||
ent2.display_name || "",
|
||||
ent1.stateName || `zzz${ent1.entity_id}`,
|
||||
ent2.stateName || `zzz${ent2.entity_id}`,
|
||||
this.hass.locale.language
|
||||
)
|
||||
)
|
||||
@@ -85,7 +89,7 @@ class ZHADeviceCard extends SubscribeMixin(LitElement) {
|
||||
? html`
|
||||
<state-badge
|
||||
@click=${this._openMoreInfo}
|
||||
.title=${entity.display_name || ""}
|
||||
.title=${entity.stateName!}
|
||||
.hass=${this.hass}
|
||||
.stateObj=${this.hass!.states[entity.entity_id]}
|
||||
slot="item-icon"
|
||||
@@ -116,11 +120,52 @@ class ZHADeviceCard extends SubscribeMixin(LitElement) {
|
||||
if (!this.hass || !this.device) {
|
||||
return;
|
||||
}
|
||||
const device = this.device;
|
||||
|
||||
const oldDeviceName = device.user_given_name || device.name;
|
||||
const newDeviceName = event.target.value;
|
||||
this.device.user_given_name = newDeviceName;
|
||||
await updateDeviceRegistryEntry(this.hass, this.device.device_reg_id, {
|
||||
await updateDeviceRegistryEntry(this.hass, device.device_reg_id, {
|
||||
name_by_user: newDeviceName,
|
||||
});
|
||||
|
||||
if (!oldDeviceName || !newDeviceName || oldDeviceName === newDeviceName) {
|
||||
return;
|
||||
}
|
||||
const entities = this._deviceEntities(device.device_reg_id, this._entities);
|
||||
|
||||
const entityIdsMapping = await getAutomaticEntityIds(
|
||||
this.hass,
|
||||
entities.map((entity) => entity.entity_id)
|
||||
);
|
||||
|
||||
const updateProms = entities.map((entity) => {
|
||||
const name = entity.name;
|
||||
const newEntityId = entityIdsMapping[entity.entity_id];
|
||||
let newName: string | null | undefined;
|
||||
|
||||
if (entity.has_entity_name && !entity.name) {
|
||||
newName = undefined;
|
||||
} else if (
|
||||
entity.has_entity_name &&
|
||||
(entity.name === oldDeviceName || entity.name === newDeviceName)
|
||||
) {
|
||||
// clear name if it matches the device name and it uses the device name (entity naming)
|
||||
newName = null;
|
||||
} else if (name && name.includes(oldDeviceName)) {
|
||||
newName = name.replace(oldDeviceName, newDeviceName);
|
||||
}
|
||||
|
||||
if (newName !== undefined && !newEntityId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return updateEntityRegistryEntry(this.hass!, entity.entity_id, {
|
||||
name: newName,
|
||||
new_entity_id: newEntityId || undefined,
|
||||
});
|
||||
});
|
||||
await Promise.all(updateProms);
|
||||
}
|
||||
|
||||
private _openMoreInfo(ev: MouseEvent): void {
|
||||
@@ -129,6 +174,13 @@ class ZHADeviceCard extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _computeEntityName(entity: EntityRegistryEntry): string | null {
|
||||
if (this.hass.states[entity.entity_id]) {
|
||||
return computeStateName(this.hass.states[entity.entity_id]);
|
||||
}
|
||||
return entity.name;
|
||||
}
|
||||
|
||||
private async _areaPicked(ev: CustomEvent) {
|
||||
const picker = ev.currentTarget as any;
|
||||
|
||||
|
||||
@@ -890,6 +890,8 @@ class DialogZWaveJSAddNode extends LitElement {
|
||||
this._step = "rename_device";
|
||||
const nameChanged = this._device.name !== this._deviceOptions?.name;
|
||||
if (nameChanged || this._deviceOptions?.area) {
|
||||
const oldDeviceName = this._device.name;
|
||||
const newDeviceName = this._deviceOptions!.name;
|
||||
try {
|
||||
await updateDeviceRegistryEntry(this.hass, this._device.id, {
|
||||
name_by_user: this._deviceOptions!.name,
|
||||
@@ -897,6 +899,8 @@ class DialogZWaveJSAddNode extends LitElement {
|
||||
});
|
||||
|
||||
if (nameChanged) {
|
||||
// rename entities
|
||||
|
||||
const entities = this._entities.filter(
|
||||
(entity) => entity.device_id === this._device!.id
|
||||
);
|
||||
@@ -908,14 +912,33 @@ class DialogZWaveJSAddNode extends LitElement {
|
||||
|
||||
await Promise.all(
|
||||
entities.map((entity) => {
|
||||
const name = entity.name;
|
||||
let newName: string | null | undefined;
|
||||
const newEntityId = entityIdsMapping[entity.entity_id];
|
||||
|
||||
if (!newEntityId || newEntityId === entity.entity_id) {
|
||||
if (entity.has_entity_name && !entity.name) {
|
||||
newName = undefined;
|
||||
} else if (
|
||||
entity.has_entity_name &&
|
||||
(entity.name === oldDeviceName ||
|
||||
entity.name === newDeviceName)
|
||||
) {
|
||||
// clear name if it matches the device name and it uses the device name (entity naming)
|
||||
newName = null;
|
||||
} else if (name && name.includes(oldDeviceName)) {
|
||||
newName = name.replace(oldDeviceName, newDeviceName);
|
||||
}
|
||||
|
||||
if (
|
||||
(newName === undefined && !newEntityId) ||
|
||||
newEntityId === entity.entity_id
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return updateEntityRegistryEntry(this.hass!, entity.entity_id, {
|
||||
new_entity_id: newEntityId,
|
||||
name: newName || name,
|
||||
new_entity_id: newEntityId || undefined,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
@@ -113,7 +113,7 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
this._newScriptId &&
|
||||
changedProps.has("entityRegistry")
|
||||
) {
|
||||
const script = this.entityRegistry.find(
|
||||
const script = this.entityRegistry?.find(
|
||||
(entity: EntityRegistryEntry) =>
|
||||
entity.platform === "script" && entity.unique_id === this._newScriptId
|
||||
);
|
||||
@@ -487,6 +487,9 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
}
|
||||
|
||||
private _setEntityId() {
|
||||
if (!this.entityRegistry) {
|
||||
return;
|
||||
}
|
||||
const entity = this.entityRegistry.find(
|
||||
(ent) => ent.platform === "script" && ent.unique_id === this.scriptId
|
||||
);
|
||||
@@ -536,7 +539,7 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
this.config = normalizeScriptConfig(c.config);
|
||||
this._checkValidation();
|
||||
});
|
||||
const regEntry = this.entityRegistry.find(
|
||||
const regEntry = this.entityRegistry?.find(
|
||||
(ent) => ent.entity_id === this.entityId
|
||||
);
|
||||
if (regEntry?.unique_id) {
|
||||
@@ -630,7 +633,7 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
private _idIsUsed(id: string): boolean {
|
||||
return (
|
||||
`script.${id}` in this.hass.states ||
|
||||
this.entityRegistry.some((ent) => ent.unique_id === id)
|
||||
(this.entityRegistry?.some((ent) => ent.unique_id === id) ?? false)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -638,7 +641,7 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
if (!this.scriptId) {
|
||||
return;
|
||||
}
|
||||
const entity = this.entityRegistry.find(
|
||||
const entity = this.entityRegistry?.find(
|
||||
(entry) => entry.unique_id === this.scriptId
|
||||
);
|
||||
if (!entity) {
|
||||
@@ -818,7 +821,7 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
},
|
||||
onClose: () => resolve(false),
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
entityRegistryEntry: this.entityRegistry.find(
|
||||
entityRegistryEntry: this.entityRegistry?.find(
|
||||
(entry) => entry.unique_id === this.scriptId
|
||||
),
|
||||
});
|
||||
|
||||
@@ -322,7 +322,7 @@ export class VoiceAssistantsExpose extends LitElement {
|
||||
(entry?.device_id ? devices[entry.device_id!]?.area_id : undefined);
|
||||
const area = areaId ? areas[areaId] : undefined;
|
||||
const _assistants = Object.keys(
|
||||
exposedEntities?.[entityState.entity_id]
|
||||
exposedEntities?.[entityState.entity_id] ?? {}
|
||||
).filter(
|
||||
(key) =>
|
||||
showAssistants.includes(key) &&
|
||||
@@ -384,7 +384,7 @@ export class VoiceAssistantsExpose extends LitElement {
|
||||
assistants: [
|
||||
...(exposedEntities
|
||||
? Object.keys(
|
||||
exposedEntities?.[entityState.entity_id]
|
||||
exposedEntities?.[entityState.entity_id] ?? {}
|
||||
).filter(
|
||||
(key) =>
|
||||
showAssistants.includes(key) &&
|
||||
|
||||
@@ -39,8 +39,7 @@ class PanelEnergy extends LitElement {
|
||||
super.willUpdate(changedProps);
|
||||
// Initial setup
|
||||
if (!this.hasUpdated) {
|
||||
this.hass.loadFragmentTranslation("lovelace");
|
||||
this._loadConfig();
|
||||
this._setup();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -54,6 +53,14 @@ class PanelEnergy extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private async _setup() {
|
||||
await Promise.all([
|
||||
this.hass.loadFragmentTranslation("lovelace"),
|
||||
this.hass.loadFragmentTranslation("energy"),
|
||||
]);
|
||||
this._loadConfig();
|
||||
}
|
||||
|
||||
private async _loadConfig() {
|
||||
try {
|
||||
this._error = undefined;
|
||||
|
||||
@@ -52,6 +52,9 @@ export class GasViewStrategy extends ReactiveElement {
|
||||
section.cards!.push({
|
||||
type: "energy-compare",
|
||||
collection_key: collectionKey,
|
||||
grid_options: {
|
||||
columns: 36,
|
||||
},
|
||||
});
|
||||
|
||||
section.cards!.push({
|
||||
|
||||
@@ -54,6 +54,9 @@ export class WaterViewStrategy extends ReactiveElement {
|
||||
section.cards!.push({
|
||||
type: "energy-compare",
|
||||
collection_key: collectionKey,
|
||||
grid_options: {
|
||||
columns: 36,
|
||||
},
|
||||
});
|
||||
|
||||
if (hasWaterSources) {
|
||||
|
||||
@@ -19,7 +19,6 @@ import { cameraUrlWithWidthHeight } from "../../../data/camera";
|
||||
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { actionHandler } from "../common/directives/action-handler-directive";
|
||||
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
|
||||
import { findEntities } from "../common/find-entities";
|
||||
import { handleAction } from "../common/handle-action";
|
||||
import { hasAction } from "../common/has-action";
|
||||
@@ -175,11 +174,7 @@ export class HuiEntityBadge extends LitElement implements LovelaceBadge {
|
||||
"--badge-color": color,
|
||||
};
|
||||
|
||||
const name = computeLovelaceEntityName(
|
||||
this.hass,
|
||||
stateObj,
|
||||
this._config.name
|
||||
);
|
||||
const name = this.hass.formatEntityName(stateObj, this._config.name);
|
||||
|
||||
const stateDisplay = html`
|
||||
<state-display
|
||||
|
||||
@@ -35,6 +35,7 @@ import { formatTime } from "../../../../../common/datetime/format_time";
|
||||
import type { ECOption } from "../../../../../resources/echarts/echarts";
|
||||
import { filterXSS } from "../../../../../common/util/xss";
|
||||
import type { StatisticPeriod } from "../../../../../data/recorder";
|
||||
import { getPeriodicAxisLabelConfig } from "../../../../../components/chart/axis-label";
|
||||
import { getSuggestedPeriod } from "../../../../../data/energy";
|
||||
|
||||
// Number of days of padding when showing time axis in months
|
||||
@@ -109,17 +110,7 @@ export function getCommonOptions(
|
||||
type: "time",
|
||||
min: subDays(start, MONTH_TIME_AXIS_PADDING),
|
||||
max: addDays(suggestedMax, MONTH_TIME_AXIS_PADDING),
|
||||
axisLabel: {
|
||||
formatter: {
|
||||
year: "{yearStyle|{MMMM} {yyyy}}",
|
||||
month: "{MMMM}",
|
||||
},
|
||||
rich: {
|
||||
yearStyle: {
|
||||
fontWeight: "bold",
|
||||
},
|
||||
},
|
||||
},
|
||||
axisLabel: getPeriodicAxisLabelConfig("month", locale, config),
|
||||
// For shorter month ranges, force splitting to ensure time axis renders
|
||||
// as whole month intervals. Limit the number of forced ticks to 6 months
|
||||
// (so a max calendar difference of 5) to reduce clutter.
|
||||
|
||||
@@ -542,6 +542,7 @@ export class HuiEnergyDevicesGraphCard
|
||||
this._legendData = chartData.map((d) => ({
|
||||
...d,
|
||||
name: this._getDeviceName(d.name),
|
||||
value: `${formatNumber(d.value[0], this.hass.locale)} kWh`,
|
||||
}));
|
||||
// filter out hidden stats in place
|
||||
for (let i = chartData.length - 1; i >= 0; i--) {
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
subscribeEntityRegistry,
|
||||
} from "../../../data/entity/entity_registry";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
|
||||
import { findEntities } from "../common/find-entities";
|
||||
import { createEntityNotFoundWarning } from "../components/hui-warning";
|
||||
import type { LovelaceCard } from "../types";
|
||||
@@ -233,11 +232,7 @@ class HuiAlarmPanelCard extends LitElement implements LovelaceCard {
|
||||
|
||||
const defaultCode = this._entry?.options?.alarm_control_panel?.default_code;
|
||||
|
||||
const name = computeLovelaceEntityName(
|
||||
this.hass,
|
||||
stateObj,
|
||||
this._config.name
|
||||
);
|
||||
const name = this.hass.formatEntityName(stateObj, this._config.name);
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
|
||||
@@ -41,7 +41,6 @@ import type { FrontendLocaleData } from "../../../data/translation";
|
||||
import type { Themes } from "../../../data/ws-themes";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { actionHandler } from "../common/directives/action-handler-directive";
|
||||
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
|
||||
import { findEntities } from "../common/find-entities";
|
||||
import { hasAction } from "../common/has-action";
|
||||
import { createEntityNotFoundWarning } from "../components/hui-warning";
|
||||
@@ -52,6 +51,21 @@ import type {
|
||||
} from "../types";
|
||||
import type { ButtonCardConfig } from "./types";
|
||||
|
||||
const EMPTY_STATE_OBJ = {
|
||||
state: "unavailable",
|
||||
attributes: {
|
||||
friendly_name: "",
|
||||
},
|
||||
entity_id: "___.empty",
|
||||
context: {
|
||||
id: "",
|
||||
parent_id: null,
|
||||
user_id: null,
|
||||
},
|
||||
last_changed: "",
|
||||
last_updated: "",
|
||||
} satisfies HassEntity;
|
||||
|
||||
export const getEntityDefaultButtonAction = (entityId?: string) =>
|
||||
entityId && DOMAINS_TOGGLE.has(computeDomain(entityId))
|
||||
? "toggle"
|
||||
@@ -183,9 +197,8 @@ export class HuiButtonCard extends LitElement implements LovelaceCard {
|
||||
`;
|
||||
}
|
||||
|
||||
const name = computeLovelaceEntityName(
|
||||
this.hass,
|
||||
stateObj,
|
||||
const name = this.hass.formatEntityName(
|
||||
stateObj || EMPTY_STATE_OBJ,
|
||||
this._config.name
|
||||
);
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { normalizeValueBySIPrefix } from "../../../common/number/normalize-by-si-prefix";
|
||||
import { MobileAwareMixin } from "../../../mixins/mobile-aware-mixin";
|
||||
import type { EntityNameItem } from "../../../common/entity/compute_entity_name_display";
|
||||
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
|
||||
import "../../../components/chips/ha-assist-chip";
|
||||
import "../../../components/ha-card";
|
||||
import "../../../components/ha-segmented-bar";
|
||||
@@ -240,7 +239,7 @@ export class HuiDistributionCard
|
||||
const color = entity.color
|
||||
? computeCssColor(entity.color)
|
||||
: getGraphColorByIndex(entity.originalIndex, computedStyles);
|
||||
const name = computeLovelaceEntityName(this.hass!, stateObj, entity.name);
|
||||
const name = this.hass!.formatEntityName(stateObj, entity.name);
|
||||
const formattedValue = this.hass!.formatEntityState(stateObj);
|
||||
|
||||
segments.push({
|
||||
@@ -276,7 +275,7 @@ export class HuiDistributionCard
|
||||
const isZeroOrNegative = !stateObj || value <= 0 || isNaN(value);
|
||||
|
||||
const name = stateObj
|
||||
? computeLovelaceEntityName(this.hass!, stateObj, entity.name)
|
||||
? this.hass!.formatEntityName(stateObj, entity.name)
|
||||
: entity.entity;
|
||||
|
||||
const formattedValue = stateObj
|
||||
|
||||
@@ -25,7 +25,6 @@ import { handleAction } from "../common/handle-action";
|
||||
import { hasAction, hasAnyAction } from "../common/has-action";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { computeCardSize } from "../common/compute-card-size";
|
||||
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
|
||||
import { findEntities } from "../common/find-entities";
|
||||
import { hasConfigOrEntityChanged } from "../common/has-changed";
|
||||
import { createEntityNotFoundWarning } from "../components/hui-warning";
|
||||
@@ -143,14 +142,13 @@ export class HuiEntityCard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
|
||||
const indexUnit = stateParts.findIndex((part) => part.type === "unit");
|
||||
const indexValue = stateParts.findIndex((part) => part.type === "value");
|
||||
const indexValue = stateParts.reduceRight(
|
||||
(acc, part, i) => (acc === -1 && part.type === "value" ? i : acc),
|
||||
-1
|
||||
);
|
||||
const reversedOrder = indexUnit !== -1 && indexUnit < indexValue;
|
||||
|
||||
const name = computeLovelaceEntityName(
|
||||
this.hass,
|
||||
stateObj,
|
||||
this._config.name
|
||||
);
|
||||
const name = this.hass.formatEntityName(stateObj, this._config.name);
|
||||
|
||||
const colored = stateObj && this._getStateColor(stateObj, this._config);
|
||||
|
||||
@@ -209,7 +207,10 @@ export class HuiEntityCard extends LitElement implements LovelaceCard {
|
||||
>
|
||||
</ha-attribute-value>`
|
||||
: this.hass.localize("state.default.unknown")
|
||||
: stateParts.find((part) => part.type === "value")?.value}</span
|
||||
: stateParts
|
||||
.filter((part) => part.type === "value")
|
||||
.map((part) => part.value)
|
||||
.join("")}</span
|
||||
>${unit
|
||||
? html`<span
|
||||
class=${classMap({
|
||||
|
||||
@@ -14,7 +14,6 @@ import { UNAVAILABLE } from "../../../data/entity/entity";
|
||||
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { actionHandler } from "../common/directives/action-handler-directive";
|
||||
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
|
||||
import { findEntities } from "../common/find-entities";
|
||||
import { handleAction } from "../common/handle-action";
|
||||
import { hasAction, hasAnyAction } from "../common/has-action";
|
||||
@@ -124,11 +123,7 @@ class HuiGaugeCard extends LitElement implements LovelaceCard {
|
||||
`;
|
||||
}
|
||||
|
||||
const name = computeLovelaceEntityName(
|
||||
this.hass,
|
||||
stateObj,
|
||||
this._config.name
|
||||
);
|
||||
const name = this.hass.formatEntityName(stateObj, this._config.name);
|
||||
|
||||
// Use `stateObj.state` as value to keep formatting (e.g trailing zeros)
|
||||
// for consistent value display across gauge, entity, entity-row, etc.
|
||||
@@ -308,9 +303,8 @@ class HuiGaugeCard extends LitElement implements LovelaceCard {
|
||||
|
||||
.title {
|
||||
width: 100%;
|
||||
font-size: var(--ha-font-size-l);
|
||||
font-size: var(--ha-font-size-m);
|
||||
line-height: var(--ha-line-height-expanded);
|
||||
padding: 0px 0px var(--ha-space-2) 0px;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
@@ -323,6 +317,7 @@ class HuiGaugeCard extends LitElement implements LovelaceCard {
|
||||
|
||||
ha-gauge {
|
||||
width: 100%;
|
||||
max-width: 250px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import type {
|
||||
import { SENSOR_DEVICE_CLASS_TIMESTAMP } from "../../../data/sensor";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { actionHandler } from "../common/directives/action-handler-directive";
|
||||
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
|
||||
import { findEntities } from "../common/find-entities";
|
||||
import { handleAction } from "../common/handle-action";
|
||||
import { hasAction, hasAnyAction } from "../common/has-action";
|
||||
@@ -252,11 +251,7 @@ export class HuiGlanceCard extends LitElement implements LovelaceCard {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const name = computeLovelaceEntityName(
|
||||
this.hass!,
|
||||
stateObj,
|
||||
entityConf.name
|
||||
);
|
||||
const name = this.hass!.formatEntityName(stateObj, entityConf.name);
|
||||
|
||||
return html`
|
||||
<div
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
import { fetchStatistics } from "../../../data/recorder";
|
||||
import { getSensorNumericDeviceClasses } from "../../../data/sensor";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
|
||||
import { hasConfigOrEntitiesChanged } from "../common/has-changed";
|
||||
import { processConfigEntities } from "../common/process-config-entities";
|
||||
import type { EntityConfig } from "../entity-rows/types";
|
||||
@@ -106,7 +105,7 @@ export class HuiHistoryGraphCard extends LitElement implements LovelaceCard {
|
||||
this._entities.forEach((entity) => {
|
||||
const stateObj = this.hass!.states[entity.entity];
|
||||
this._names[entity.entity] = stateObj
|
||||
? computeLovelaceEntityName(this.hass!, stateObj, entity.name)
|
||||
? this.hass!.formatEntityName(stateObj, entity.name)
|
||||
: entity.entity;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import "../../../state-control/humidifier/ha-state-control-humidifier-humidity";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "../card-features/hui-card-features";
|
||||
import type { LovelaceCardFeatureContext } from "../card-features/types";
|
||||
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
|
||||
import { findEntities } from "../common/find-entities";
|
||||
import { createEntityNotFoundWarning } from "../components/hui-warning";
|
||||
import type {
|
||||
@@ -133,11 +132,7 @@ export class HuiHumidifierCard extends LitElement implements LovelaceCard {
|
||||
`;
|
||||
}
|
||||
|
||||
const name = computeLovelaceEntityName(
|
||||
this.hass,
|
||||
stateObj,
|
||||
this._config.name
|
||||
);
|
||||
const name = this.hass.formatEntityName(stateObj, this._config.name);
|
||||
|
||||
const color = stateColorCss(stateObj);
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import { lightSupportsBrightness } from "../../../data/light";
|
||||
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { actionHandler } from "../common/directives/action-handler-directive";
|
||||
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
|
||||
import { findEntities } from "../common/find-entities";
|
||||
import { handleAction } from "../common/handle-action";
|
||||
import { hasAction } from "../common/has-action";
|
||||
@@ -92,11 +91,7 @@ export class HuiLightCard extends LitElement implements LovelaceCard {
|
||||
((stateObj.attributes.brightness || 0) / 255) * 100
|
||||
);
|
||||
|
||||
const name = computeLovelaceEntityName(
|
||||
this.hass,
|
||||
stateObj,
|
||||
this._config.name
|
||||
);
|
||||
const name = this.hass.formatEntityName(stateObj, this._config.name);
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user