Compare commits

...

81 Commits

Author SHA1 Message Date
Bram Kragten
f7e92b484a Bumped version to 20260325.7 2026-04-10 17:44:15 +02:00
Aidan Timson
9fab7bafdb Allow quick search for non-admins, while hiding inaccessible areas (#51456)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-04-10 17:40:25 +02:00
Petar Petrov
0dabb02007 Fix dialog show animation broken by connectedCallback _open sync (#51450) 2026-04-10 17:38:06 +02:00
Petar Petrov
5b73e86786 Fix toast race condition causing stuck notifications (#51447) 2026-04-10 17:38:05 +02:00
Timothy
144d7c5c3f Android externalAppV2 (#51446) 2026-04-10 17:37:08 +02:00
Petar Petrov
8b396dc640 Preserve browser back/forward keyboard shortcuts in tab group (#51439) 2026-04-10 17:33:34 +02:00
Aidan Timson
9bf48d30ab Handle lazy loaded entity registry when editing scripts from more info (#51438)
* Handle lazy loaded entity registry when editing scripts from more info

* Remove extra check

* Fix type of mixin
2026-04-10 17:33:33 +02:00
GeorgeZ83
35fee46f5b Fix media browser dialog window (#51423)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-04-10 17:33:32 +02:00
Niccolò Betto
9ac6636029 Fix code input dialog undefined value concatenation (#51399) 2026-04-10 17:33:31 +02:00
Simon Lamon
136462114d Increase gauge thickness for accessibility reasons (#51382)
Increase thickness for accessability reasons
2026-04-10 17:33:30 +02:00
Bram Kragten
cf542197e0 Bumped version to 20260325.6 2026-04-03 13:01:51 +02:00
Bram Kragten
5c2627624a Always add options object to triggers and conditions (#51394) 2026-04-03 13:01:42 +02:00
Petar Petrov
698ded9d85 Only use inflight map for pending fragment translation loads (#51393) 2026-04-03 13:01:42 +02:00
Tim Ittermann
9a7fb96873 fix: null value error on ha-form-integer (#51385)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2026-04-03 13:01:41 +02:00
Petar Petrov
204c5b5e14 Fix fragment translation race condition returning stale localize (#51381) 2026-04-03 13:01:40 +02:00
Petar Petrov
8ea3acfa98 Load energy translations in dashboard strategy before generating view titles (#51376) 2026-04-03 13:01:39 +02:00
Wendelin
306739773e Fix login on legacy browsers (#51373) 2026-04-03 13:01:38 +02:00
Petar Petrov
8b3fa3adac Fix next_flow dialog closing immediately after rendering (#51369) 2026-04-03 13:01:37 +02:00
Petar Petrov
37a1d59a24 Fix statistics-graph card not rendering self-imported stats (#51367) 2026-04-03 13:01:37 +02:00
Trevin Chow
6812884e00 Guard against orphaned label references in device list (#51359) 2026-04-03 13:01:36 +02:00
Trevin Chow
bf7ef1f7ae Fix TypeError in Voice Assistants expose page with manual entity filters (#51357) 2026-04-03 13:01:35 +02:00
Paul Bottein
fe57f601ba Fix device page entity names not refreshing after device rename (#51355) 2026-04-03 13:01:34 +02:00
Wendelin
c89d478440 Fix input hint height (#51351) 2026-04-03 13:01:33 +02:00
Petar Petrov
fa27d26e5f Fix history-graph card not showing first value (#51350) 2026-04-03 13:01:32 +02:00
Wendelin
18f411ef53 Fix generic picker filter section padding (#51334)
Fix padding in picker section for improved layout
2026-04-03 13:01:31 +02:00
Wendelin
24826e92f0 Fix picker search padding (#51331) 2026-04-03 13:01:30 +02:00
Wendelin
ea9d369d88 Fix date input field shrink (#51330) 2026-04-03 13:01:29 +02:00
Bram Kragten
a9b026d0ef Bumped version to 20260325.5 2026-04-01 11:15:10 +02:00
Petar Petrov
35339906ec Fix layout of compare card in water/gas views (#51329) 2026-04-01 11:14:50 +02:00
Wendelin
ce23f716cc Improve dialog open logic (#51328) 2026-04-01 11:14:49 +02:00
Petar Petrov
aaf8fa199f Await energy translation fragment before generating dashboard strategy (#51327) 2026-04-01 11:14:48 +02:00
Aidan Timson
fba430d507 Fix target item loading error (#51326) 2026-04-01 11:14:47 +02:00
Petar Petrov
59361cbd38 Fix ZHA device count not including devices without entities (#51322) 2026-04-01 11:14:46 +02:00
Petar Petrov
b558117d8c Use ha-card-border-color for integration cards instead of divider-color (#51321) 2026-04-01 11:14:45 +02:00
Petar Petrov
a7c8347751 Fix Fill example data inserting incorrect datetime format (#51320) 2026-04-01 11:14:44 +02:00
Wendelin
31ca9c849a Remove target description (#51315) 2026-04-01 11:14:43 +02:00
Bram Kragten
6252d7e8f5 Bumped version to 20260325.4 2026-03-31 15:36:46 +02:00
Bram Kragten
f42986adf6 Make translation downloading async (#51314) 2026-03-31 15:36:31 +02:00
Bram Kragten
9e70ea3723 Bumped version to 20260325.3 2026-03-31 14:58:38 +02:00
Bram Kragten
de3b7bf513 Fix has target check for actions (#51309) 2026-03-31 14:58:19 +02:00
Petar Petrov
2c5f491c9e Use boundaryFilter data zoom mode only for line charts (#51307) 2026-03-31 14:58:18 +02:00
Wendelin
1ef13c5100 Fix automation add TCA dialog sometimes not opening (#51306) 2026-03-31 14:58:17 +02:00
Aidan Timson
c166335aca Fix above/below numeric state entity formatting (#51298) 2026-03-31 14:51:11 +02:00
Petar Petrov
c64ec21eca Fix x-axis labels for statistics graph month/year periods (#51295) 2026-03-31 14:51:10 +02:00
Norbert Rittel
8d62056f4a Change picker descriptions of triggers to match new style (#51294) 2026-03-31 14:51:09 +02:00
Bram Kragten
62e73608b6 Triggers/conditions Add usage and grouping to new multi domains (#51287) 2026-03-31 14:51:08 +02:00
Wendelin
aa66d8891c Improve date-range-picker mobile presets (#51285) 2026-03-31 14:51:07 +02:00
Paul Bottein
494a96c635 Hide section when all cards are hidden (#51281) 2026-03-31 14:51:06 +02:00
Petar Petrov
36d77f54ce Disable physics by default for large networks (#51277) 2026-03-31 14:51:05 +02:00
Wendelin
12fec9f580 Fix ha-dropdown z-index for legacy browsers (#51276) 2026-03-31 14:51:04 +02:00
Bram Kragten
5f1f55448a Numeric threshold selector: remove duplicate uom from input (#51275) 2026-03-31 14:51:03 +02:00
Paul Bottein
837e345ecf Reduce heading button badge font size and fix alignement (#51274)
Title: Reduce heading button badge font size and fix alignement
2026-03-31 14:51:02 +02:00
Wendelin
0929d7d18a Remove mobile-specific styles for date-range-picker (#51273)
Remove mobile-specific styles for date-picker component
2026-03-31 14:51:01 +02:00
Aidan Timson
70991d3c1e Limit ha-toast width to window, refactor CSS (#51272)
* Limit `ha-toast` width to window and use safe width

* Query assigned slots to stop actions display

* Constrain max-width

* Increase start/end padding
2026-03-31 14:51:00 +02:00
Wendelin
82e5bd62a1 Fix time input background (#51270)
Fix input color tokens
2026-03-31 14:50:59 +02:00
Wendelin
b8adf4e866 Fix date-range-picker preset selection (#51269) 2026-03-31 14:50:58 +02:00
Tom Carpenter
111be984e0 Add date range picker time validation (#51267)
* Fix base time inputs reportValidity() function

The queryAll selector returns a NodeList not not an array. Need to spread it to an array before we can use every().

* Validate the date range picker time inputs

Enable auto validation to get the nice red underline on invalid values, and then check validity before accepting the input.

* Fix automatic 24hr value conversion in AM/PM format

When using AM/PM, entering a 24 hour value will automatically convert the first time. For example 15 will become 3. However if you then enter 15 again it will stay as 15 and not update.
To fix this, make sure we trigger an update of the input field once the current update cycle is complete.

* Validate time inputs on save not value update

In the value changed callback, the update 24->12hr input correction will not have been updated and therefore they will report invalid.
2026-03-31 14:50:57 +02:00
Tom Carpenter
78a2cbb532 Fix new date-range-picker rendering on small screens (#51257) 2026-03-31 14:50:56 +02:00
ildar170975
34b09b140b Map card editor: use context in attribute selector (#30393)
use context in attribute selector
2026-03-31 14:50:55 +02:00
Simon Lamon
f173f901c5 Gauge improvements (#30368)
* Gauge last improvements

* Change needle

* Fixup

* Feedback comments

* Update src/components/ha-gauge.ts

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-03-31 14:50:55 +02:00
Paul Bottein
ebb6ac8d8b Bumped version to 20260325.2 2026-03-27 22:09:10 +01:00
Wendelin
abe214a33a Fix picker field disabled background (#30385) 2026-03-27 22:08:51 +01:00
Paul Bottein
248332ae27 Revert entity naming change (#30384) 2026-03-27 22:08:50 +01:00
Wendelin
82fc2fccdc Automation add TCA: Fix classMap usage (#30380) 2026-03-27 22:08:49 +01:00
Marcin Bauer
c8f30a7ee4 Use dedicated tab copy in automation add dialogs (#30378)
Co-authored-by: Wendelin <w@pe8.at>
2026-03-27 22:08:48 +01:00
Norbert Rittel
77f48d91cd Shorten collection_key_description to fit available space (#30376) 2026-03-27 22:08:47 +01:00
Paul Bottein
caa707a7b1 Only display entity name instead of friendly name in state info (#30365) 2026-03-27 22:08:46 +01:00
Petar Petrov
0bed0fa37e Fix negative currency display on sensor card (#30359) 2026-03-27 22:08:46 +01:00
Bram Kragten
5b6309d984 Numeric threshold selector fixes (#30350)
* Update numeric threshold

* Update ha-selector-numeric-threshold.ts
2026-03-27 22:08:45 +01:00
Aidan Timson
264818bc70 Fix floating ha-toast (#30344) 2026-03-27 22:08:44 +01:00
Bram Kragten
d664ab6836 Bumped version to 20260325.1 2026-03-26 17:08:11 +01:00
Bram Kragten
a6c4184054 Replace ua-parser-js with simple regexs (#30355) 2026-03-26 17:07:45 +01:00
karwosts
cb6985eb7c Stabilize map colors (#30354) 2026-03-26 17:07:44 +01:00
Bram Kragten
d466ab63bd Add target error badge if target is missing (#30352)
* Add target error badge if target is missing

* Don't show for newly added items
2026-03-26 17:07:40 +01:00
Paul Bottein
1132cdb364 Replace computeLovelaceEntityName with hass.formatEntityName (#30351) 2026-03-26 17:07:39 +01:00
Paul Bottein
0f9d48a03d Use hardcoded label for temperature and humidity sensor in climate dashboard (#30348)
* Only use entity name for climate view sensors

* Use hardcoded text
2026-03-26 17:07:38 +01:00
Paul Bottein
7e085d9b08 Fix stack card scrollbar clipping box-shadows (#30346)
* Fix stack card scrollbar clipping box-shadows

* Remove grid options

* Remove scrollbar
2026-03-26 17:07:37 +01:00
Timothy
1a62c7296c Set tap highlight color to transparent for button (#30340) 2026-03-26 17:07:36 +01:00
Petar Petrov
be1921229c Fix energy pie chart legend showing raw data instead of formatted values (#30339) 2026-03-26 17:07:34 +01:00
Paul Bottein
640558ad35 Add composed/text mode toggle to entity name picker (#30337) 2026-03-26 17:07:33 +01:00
sir-Unknown
99636c9719 Fix calendar event description not preserving line breaks (#30329)
Add `white-space: pre-line` to the event description style so that
newlines in the calendar event description are rendered correctly
instead of being collapsed into a single line.
2026-03-26 17:07:32 +01:00
137 changed files with 2215 additions and 1346 deletions

View File

@@ -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;
}
})
);
});

View File

@@ -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}

View File

@@ -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")

View File

@@ -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",

View File

@@ -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"

View File

@@ -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;

View File

@@ -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);

View File

@@ -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":

View File

@@ -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 {

View File

@@ -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]");

View 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;

View File

@@ -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,

View File

@@ -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;
});
}

View File

@@ -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,

View File

@@ -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",

View File

@@ -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%;
}
}
`,
];
}

View File

@@ -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`

View File

@@ -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;
}

View File

@@ -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}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -123,6 +123,9 @@ export class HaDateInput extends LitElement {
}
static styles = css`
:host {
min-width: 0px;
}
ha-svg-icon {
color: var(--secondary-text-color);
}

View File

@@ -100,6 +100,9 @@ export class HaDropdown extends Dropdown {
#menu {
padding: var(--ha-space-1);
}
wa-popup::part(popup) {
z-index: 200;
}
`,
];
}

View File

@@ -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`

View File

@@ -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}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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">

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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 {

View File

@@ -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;
}
}

View File

@@ -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(

View File

@@ -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),
}
);
}

View File

@@ -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;

View File

@@ -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,
};

View File

@@ -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);

View File

@@ -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>;

View File

@@ -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;
}

View File

@@ -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;
};

View File

@@ -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);

View File

@@ -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

View File

@@ -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,

View File

@@ -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;

View File

@@ -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 = (

View File

@@ -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)
) {

View File

@@ -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);

View File

@@ -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;

View File

@@ -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();
}

View File

@@ -271,6 +271,7 @@ class DialogCalendarEventDetail extends LitElement {
color: var(--secondary-text-color);
max-width: 300px;
overflow-wrap: break-word;
white-space: pre-line;
}
`,
];

View File

@@ -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" }],
});
}

View File

@@ -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();

View File

@@ -161,6 +161,7 @@ export default class HaAutomationAction extends AutomationSortableListMixin<Acti
if (mode === "new") {
row.expand();
row.markAsNew();
}
if (!this.optionsInSidebar) {

View File

@@ -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({

View File

@@ -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();

View File

@@ -169,6 +169,7 @@ export default class HaAutomationCondition extends AutomationSortableListMixin<C
if (mode === "new") {
row.expand();
row.markAsNew();
}
if (!this.optionsInSidebar) {

View File

@@ -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 &&

View File

@@ -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

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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();

View File

@@ -252,6 +252,7 @@ export default class HaAutomationTrigger extends AutomationSortableListMixin<Tri
row.expand();
row.focus();
}
row.markAsNew();
});
}
}

View File

@@ -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 &&

View File

@@ -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,

View File

@@ -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(

View File

@@ -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));

View File

@@ -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);
},
});
};

View File

@@ -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 (

View File

@@ -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

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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,
},
],
};

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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;

View File

@@ -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,
});
})
);

View File

@@ -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
),
});

View File

@@ -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) &&

View File

@@ -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;

View File

@@ -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({

View File

@@ -54,6 +54,9 @@ export class WaterViewStrategy extends ReactiveElement {
section.cards!.push({
type: "energy-compare",
collection_key: collectionKey,
grid_options: {
columns: 36,
},
});
if (hasWaterSources) {

View File

@@ -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

View File

@@ -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.

View File

@@ -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--) {

View File

@@ -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>

View File

@@ -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
);

View File

@@ -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

View File

@@ -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({

View File

@@ -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;
}
`;
}

View File

@@ -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

View File

@@ -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;
});
}

View File

@@ -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);

View File

@@ -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