Compare commits

..

5 Commits

Author SHA1 Message Date
Petar Petrov 86f459d2f7 Support tilt-only covers when toggling groups of entities 2026-07-29 08:57:37 +03:00
Petar Petrov 62089c1649 Only fall back to toggle_cover_tilt when no direction can be derived 2026-07-29 08:56:49 +03:00
Petar Petrov c6565d6d76 Prefer directional tilt services when the cover state is known 2026-07-28 17:23:01 +03:00
Petar Petrov e5e6b6f28f Allow toggle in the action editor for tilt-only covers 2026-07-28 16:56:46 +03:00
Petar Petrov 73ea61395a Use toggle_cover_tilt when toggling tilt-only covers 2026-07-28 16:35:02 +03:00
80 changed files with 1037 additions and 3401 deletions
-65
View File
@@ -1,65 +0,0 @@
---
name: ha-frontend-demo
description: Home Assistant standalone demo structure, configurations, URL navigation, mocked APIs, shared demo stubs used by gallery, and verification. Use when changing files under demo/, changing gallery consumers of demo/src/stubs/, or running and validating the demo.
---
# HA Frontend Demo
Use this skill for standalone demo work and changes to shared demo stubs consumed by the gallery. Follow the persistent repository guidance in `AGENTS.md` and load matching specialist skills alongside this skill, especially `ha-frontend-testing` when running or validating the demo.
## Purpose
The demo is the full Home Assistant frontend running against a mocked backend and is published at <https://demo.home-assistant.io>. It needs no Home Assistant server, which makes it the easiest way to load the real UI in a browser, for example to take screenshots.
## Running The Demo
Run commands from the repository root:
```bash
yarn dev:demo # Development server on http://localhost:8090
yarn dev:demo --background # Detached; also supports --status/--stop/--logs
```
Use the E2E workflows documented in `ha-frontend-testing` when validating the demo.
## Opening A Specific Demo
The demo contains multiple demo configurations. Select one directly with the `demo` query parameter, for example `http://localhost:8090/?demo=<slug>`. The valid slugs are defined in `demoConfigs` in `demo/src/configs/demo-configs.ts`, so "the second demo" means the slug of the second entry in that list. An unknown slug falls back to the default, which is the first entry.
## Opening A Specific Page
The demo build uses hash-based routing: the frontend path goes in the URL hash, and the `demo` query parameter goes before the `#`. The URL format is:
```text
http://localhost:8090/?demo=<slug>#/<path>
```
Useful paths:
- `/lovelace/0`: The selected demo's dashboard, also the default when no hash is given.
- `/energy`: The energy dashboard. Its tabs are views: `/energy/overview` (Summary), `/energy/electricity`, `/energy/gas`, `/energy/water`, and `/energy/now`.
- `/map`, `/history`, `/todo`, `/config`: Other sidebar panels.
For example, the water tab of the energy dashboard of the second demo is:
```text
http://localhost:8090/?demo=<second slug>#/energy/water
```
## Structure
- `demo/src/ha-demo.ts`: Root element that sets up all backend mocks.
- `demo/src/configs/<slug>/`: One directory per demo configuration, containing entities, dashboard, and theme data.
- `demo/src/configs/demo-configs.ts`: Registry of demo configurations and URL slug handling.
- `demo/src/stubs/`: Mocked WebSocket and REST APIs.
- `demo/script/develop_demo`, `demo/script/build_demo`: Development server and static build wrappers.
## Shared Gallery Stubs
`demo/src/stubs/` is shared, not demo-private: gallery pages import from it directly. Before changing or removing anything a stub does, search for its callers across `demo/src/` and `gallery/src/`, then check the affected gallery pages as well as the demo.
The two consumers use a stub differently, so demo behavior does not predict gallery behavior. A gallery page calls stubs against the `hass` from `provideHass`, where `hass.config` is the shared `demoConfig` object. A stub that mutates `hass.config` in place is therefore visible to the page. `demo/src/ha-demo.ts` copies `components` into a new array before the stubs run, so the same mutation never reaches the demo. A change can look correct in the demo while quietly breaking a gallery page.
## Verification
Load `ha-frontend-testing` and use its demo and gallery workflows. Validate both consumers when changing shared stubs. Documentation-only changes do not require code tests unless examples or commands change.
+1 -1
View File
@@ -1 +1 @@
24.18.1
24.18.0
+2 -1
View File
@@ -2,6 +2,8 @@
You are helping develop the Home Assistant frontend. This repository is a TypeScript application built from Lit-based Web Components for the Home Assistant web UI.
For the standalone demo — including how to open a specific demo configuration or page via URL — read `demo/AGENTS.md`.
## Essential Commands
```bash
@@ -45,7 +47,6 @@ Detailed guidance lives in project skills under `.agents/skills/`. Load the matc
- `ha-frontend-user-facing-text`: localization, terminology, sentence case, and Home Assistant text style.
- `ha-frontend-review`: PR template use, review checklist, and recurring review issues.
- `ha-frontend-gallery`: gallery pages, demos, sidebar structure, content, and verification.
- `ha-frontend-demo`: standalone demo structure, configurations, navigation, shared stubs, and verification.
## Pull Requests
+8 -8
View File
@@ -1,15 +1,15 @@
{
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
"frontend-modern": {
"app": 595204,
"core": 57741,
"authorize": 576928,
"onboarding": 685964
"app": 561513,
"core": 54473,
"authorize": 544272,
"onboarding": 647136
},
"frontend-legacy": {
"app": 861452,
"core": 258557,
"authorize": 834356,
"onboarding": 1001360
"app": 790323,
"core": 237208,
"authorize": 765464,
"onboarding": 918679
}
}
@@ -12,42 +12,18 @@
const remapping = require("@ampproject/remapping");
// minify-literals is ESM-only, so load it via dynamic import from this CJS loader.
// Also map to cache loader promises per environment (e.g., 'modern', 'legacy')
const loaderInitPromises = new Map();
const initLoader = (browserslistEnv) => {
if (!loaderInitPromises.has(browserslistEnv)) {
loaderInitPromises.set(
browserslistEnv,
Promise.all([
import("minify-literals"),
import("browserslist"),
import("lightningcss"),
]).then(([minifyModule, browserslistModule, lightningcssModule]) => {
const browserslist = browserslistModule.default;
const { browserslistToTargets } = lightningcssModule;
const rawTargets = browserslist(null, { env: browserslistEnv });
if (!rawTargets.length) {
throw new Error(
`No browsers resolved for browserslist environment "${browserslistEnv}"`
);
}
const lightningcssTargets = browserslistToTargets(rawTargets);
return {
minifyHTMLLiterals: minifyModule.minifyHTMLLiterals,
lightningcssTargets,
};
})
);
let minifyPromise;
const getMinifier = () => {
if (!minifyPromise) {
minifyPromise = import("minify-literals").then((m) => m.minifyHTMLLiterals);
}
return loaderInitPromises.get(browserslistEnv);
return minifyPromise;
};
// HTML options mirror the previous babel-plugin-template-html-minifier config
// (html-minifier-next is option-compatible with html-minifier-terser). CSS in
// css`` templates and inline <style> is handled by minify-literals'
// lightningcss. We pass in the targets from browserslist so lightningcss
// can minify CSS appropriately for the build environment.
// css`` templates and inline <style> is handled by minify-literals' lightningcss
// default.
//
// `keepClosingSlash` is required for `svg`` templates: SVG elements such as
// `<path />` and `<circle />` are not void elements in HTML, so dropping the
@@ -64,16 +40,11 @@ const htmlOptions = {
module.exports = function minifyTemplateLiteralsLoader(source, map, meta) {
const callback = this.async();
const { browserslistEnv } = this.getOptions();
initLoader(browserslistEnv)
.then(({ minifyHTMLLiterals, lightningcssTargets }) =>
getMinifier()
.then((minifyHTMLLiterals) =>
minifyHTMLLiterals(source, {
fileName: this.resourcePath,
html: htmlOptions,
css: {
targets: lightningcssTargets,
},
})
)
.then((result) => {
-5
View File
@@ -96,11 +96,6 @@ const createRspackConfig = ({
__dirname,
"minify-template-literals-loader.cjs"
),
options: {
browserslistEnv: latestBuild
? "modern"
: `legacy${info.issuerLayer === "sw" ? "-sw" : ""}`,
},
},
!latestBuild &&
info.resource.startsWith(
+56
View File
@@ -0,0 +1,56 @@
# Demo Agent Instructions
This file applies to all files under `demo/`. Follow the root `AGENTS.md` for repository-wide standards.
The demo is the full Home Assistant frontend running against a mocked backend (published at https://demo.home-assistant.io). It needs no Home Assistant server, which makes it the easiest way to load the real UI in a browser, for example to take screenshots.
## Running the demo
Run from the repository root:
```bash
yarn dev:demo # dev server on http://localhost:8090
yarn dev:demo --background # detached; also supports --status/--stop/--logs
```
## Opening a specific demo
The demo contains multiple demo configurations. Select one directly with the `demo` query parameter, e.g. `http://localhost:8090/?demo=<slug>`. The valid slugs are defined in `demoConfigs` in `src/configs/demo-configs.ts`, so "the second demo" means the slug of the second entry in that list. An unknown slug falls back to the default (the first entry).
## Opening a specific page
The demo build uses hash-based routing: the frontend path goes in the URL hash, and the `demo` query parameter goes before the `#`. The URL format is:
```
http://localhost:8090/?demo=<slug>#/<path>
```
Useful paths:
- `/lovelace/0` — the selected demo's dashboard (also the default when no hash is given)
- `/energy` — energy dashboard. Its tabs are views: `/energy/overview` (Summary), `/energy/electricity`, `/energy/gas`, `/energy/water`, `/energy/now`
- `/map`, `/history`, `/todo`, `/config` — other sidebar panels
Example — the water tab of the energy dashboard of the second demo:
```
http://localhost:8090/?demo=<second slug>#/energy/water
```
## Structure
- `src/ha-demo.ts`: Root element; sets up all backend mocks.
- `src/configs/<slug>/`: One directory per demo configuration (entities, dashboard, theme).
- `src/configs/demo-configs.ts`: Registry of demo configurations and URL slug handling.
- `src/stubs/`: Mocked WebSocket/REST APIs.
- `script/develop_demo`, `script/build_demo`: Dev server and static build wrappers.
## The gallery imports these stubs too
`demo/src/stubs/` is shared, not demo-private: gallery pages import from it directly. Before changing or removing anything a stub does, grep for its callers across `gallery/` as well as `demo/`, and check the affected gallery pages, not just the demo.
```bash
grep -rn "stubs/<name>" demo/src gallery/src
```
The two consume a stub differently, so demo behavior does not predict gallery behavior. A gallery page calls stubs against the `hass` from `provideHass`, where `hass.config` is the shared `demoConfig` object, so a stub that mutates `hass.config` in place is visible to the page. `ha-demo.ts` copies `components` into a new array before the stubs run, so the same mutation never reaches the demo. A change can therefore look fine in the demo while quietly breaking a gallery page.
+1
View File
@@ -0,0 +1 @@
AGENTS.md
@@ -1,23 +0,0 @@
---
title: Replaced device selectors
subtitle: How device and target selectors surface devices that were split into separate devices
---
A device that used to belong to multiple config entries is split into one
device per config entry. The original composite device is removed from the
registry, so existing references to it (targets in automations, device
selectors) point at a device that no longer exists.
When a selector holds such a reference, it shows a **replaced** state instead of
a plain "not found", and offers to point the reference at the replacement
device(s). The candidate replacements are filtered through the selector's own
filters, so in practice usually a single device matches:
- **Target selector** — the replaced device row offers **Replace**, which adds
every replacement device that matches the target filters and removes the old
reference.
- **Device selector** — when exactly one replacement matches, **Replace** swaps
to it in one click; when several match, a dialog lets you pick one.
All samples below reference the removed composite device `old_composite`, which
was split into a light device and a switch device.
@@ -1,318 +0,0 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row";
import "../../../../src/components/ha-target-picker";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { EntityRegistryDisplayEntry } from "../../../../src/data/entity/entity_registry";
import type { Selector } from "../../../../src/data/selector";
import {
showDialog,
type ShowDialogParams,
} from "../../../../src/dialogs/make-dialog-manager";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { ProvideHassElement } from "../../../../src/mixins/provide-hass-lit-mixin";
import type { HomeAssistant } from "../../../../src/types";
import "../../components/demo-black-white-row";
// The composite device "old_composite" is intentionally NOT in the registry:
// it was split into "device_light" and "device_switch". References to the old
// id (targets, device selectors) should surface a "replaced" state.
const DEVICES: DeviceRegistryEntry[] = [
{
area_id: "bedroom",
configuration_url: null,
config_entries: ["config_entry_light"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_light",
identifiers: [["demo", "light"] as [string, string]],
manufacturer: null,
model: null,
model_id: null,
name_by_user: null,
name: "Living room lamp",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
},
{
area_id: "backyard",
configuration_url: null,
config_entries: ["config_entry_switch"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_switch",
identifiers: [["demo", "switch"] as [string, string]],
manufacturer: null,
model: null,
model_id: null,
name_by_user: null,
name: "Garden socket",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
},
];
const ENTITIES = [
{
entity_id: "light.living_room_lamp",
state: "on",
attributes: { friendly_name: "Living room lamp" },
},
{
entity_id: "switch.garden_socket",
state: "off",
attributes: { friendly_name: "Garden socket" },
},
];
// Registry display entries link the demo entities to the split devices so the
// pickers can filter split candidates by domain.
const ENTITY_REGISTRY: Record<string, EntityRegistryDisplayEntry> = {
"light.living_room_lamp": {
entity_id: "light.living_room_lamp",
name: "Living room lamp",
device_id: "device_light",
area_id: "bedroom",
platform: "demo",
labels: [],
},
"switch.garden_socket": {
entity_id: "switch.garden_socket",
name: "Garden socket",
device_id: "device_switch",
area_id: "backyard",
platform: "demo",
labels: [],
},
};
const AREAS: AreaRegistryEntry[] = [
{
area_id: "backyard",
floor_id: null,
name: "Backyard",
icon: null,
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
{
area_id: "bedroom",
floor_id: null,
name: "Bedroom",
icon: "mdi:bed",
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
];
// Maps the removed composite device to the devices that replaced it.
const COMPOSITE_SPLITS = {
old_composite: {
split_ids: ["device_light", "device_switch"],
primary_id: "device_light",
},
};
interface Sample {
name: string;
description: string;
selector: Selector;
value: unknown;
// Render ha-target-picker directly in compact (chip) mode instead of the
// ha-selector, which does not expose the compact option.
compact?: boolean;
}
const SAMPLES: Sample[] = [
{
name: "Target",
description:
"Migrate adds every replacement device that matches the target filters (here both).",
selector: { target: {} },
value: { device_id: ["old_composite"] },
},
{
name: "Target (compact)",
description:
"In compact mode the replaced reference is shown as a warning chip.",
selector: { target: {} },
value: { device_id: ["old_composite"] },
compact: true,
},
{
name: "Device (unfiltered, multiple matches)",
description:
"Both replacement devices qualify, so Replace opens a dialog to pick one.",
selector: { device: {} },
value: "old_composite",
},
{
name: "Device (filtered to lights, single match)",
description:
"Only the light device passes the filter, so Replace swaps to it in one click.",
selector: { device: { entity: [{ domain: "light" }] } },
value: "old_composite",
},
{
name: "Device (multiple)",
description: "Each slot resolves independently to a matching replacement.",
selector: { device: { multiple: true } },
value: ["old_composite"],
},
];
@customElement("demo-components-ha-selector-replaced-device")
class DemoHaSelectorReplacedDevice
extends LitElement
implements ProvideHassElement
{
@state() public hass!: HomeAssistant;
private _values = SAMPLES.map((sample) => sample.value);
constructor() {
super();
const hass = provideHass(this);
hass.updateTranslations(null, "en");
hass.updateTranslations("config", "en");
hass.addEntities(ENTITIES);
mockEntityRegistry(hass);
mockDeviceRegistry(hass, DEVICES);
mockConfigEntries(hass);
mockHassioSupervisor(hass);
// Provide the demo areas and link the demo entities to the split devices.
// Set them directly via updateHass (typed against the real registry types)
// instead of the area stub, whose demo-specific type differs.
const areas: Record<string, AreaRegistryEntry> = {};
AREAS.forEach((area) => {
areas[area.area_id] = area;
});
hass.updateHass({ areas, entities: ENTITY_REGISTRY });
hass.mockWS(
"config/device_registry/list_composite_splits",
() => COMPOSITE_SPLITS
);
hass.mockWS("extract_from_target", () => ({
referenced_entities: [],
referenced_devices: [],
referenced_areas: [],
}));
hass.mockWS("auth/sign_path", (params) => params);
}
public provideHass(el) {
el.hass = this.hass;
}
public connectedCallback() {
super.connectedCallback();
this.addEventListener("show-dialog", this._dialogManager);
}
public disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("show-dialog", this._dialogManager);
}
private _dialogManager = (e: HASSDomEvent<ShowDialogParams<unknown>>) => {
const { dialogTag, dialogImport, dialogParams, addHistory, parentElement } =
e.detail;
showDialog(
this,
dialogTag,
dialogParams,
dialogImport,
parentElement,
addHistory
);
};
protected render(): TemplateResult {
return html`
${SAMPLES.map(
(sample, idx) => html`
<demo-black-white-row .title=${sample.name}>
${["light", "dark"].map(
(slot) => html`
<ha-settings-row narrow slot=${slot}>
<span slot="heading">${sample.name}</span>
<span slot="description">${sample.description}</span>
${
sample.compact
? html`<ha-target-picker
compact
.hass=${this.hass}
.value=${this._values[idx] as HassServiceTarget}
.sampleIdx=${idx}
@value-changed=${this._handleValueChanged}
></ha-target-picker>`
: html`<ha-selector
.hass=${this.hass}
.selector=${sample.selector}
.value=${this._values[idx]}
.sampleIdx=${idx}
@value-changed=${this._handleValueChanged}
></ha-selector>`
}
</ha-settings-row>
`
)}
</demo-black-white-row>
`
)}
`;
}
private _handleValueChanged(ev) {
const idx = ev.target.sampleIdx;
this._values[idx] = ev.detail.value;
this.requestUpdate();
}
static styles = css`
ha-settings-row {
--settings-row-content-width: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-selector-replaced-device": DemoHaSelectorReplacedDevice;
}
}
+3 -3
View File
@@ -186,7 +186,7 @@
"fs-extra": "11.4.0",
"generate-license-file": "4.2.1",
"glob": "13.0.6",
"globals": "17.8.0",
"globals": "17.7.0",
"gulp": "5.0.1",
"gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0",
@@ -224,12 +224,12 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.21",
"globals": "17.8.0",
"globals": "17.7.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
},
"packageManager": "yarn@4.17.1",
"volta": {
"node": "24.18.1"
"node": "24.18.0"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260729.0"
version = "20260624.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+9
View File
@@ -1,4 +1,6 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { isTiltOnly } from "../../data/cover";
import { CoverEntityFeature } from "../../data/feature/cover_entity_feature";
import type { HomeAssistant } from "../../types";
import { canToggleDomain } from "./can_toggle_domain";
import { computeStateDomain } from "./compute_state_domain";
@@ -26,6 +28,13 @@ export const canToggleState = (hass: HomeAssistant, stateObj: HassEntity) => {
return false;
}
// Tilt-only covers toggle via toggle_cover_tilt, which requires both tilt features
if (domain === "cover" && isTiltOnly(stateObj)) {
return [CoverEntityFeature.OPEN_TILT, CoverEntityFeature.CLOSE_TILT].every(
(f) => supportsFeature(stateObj, f)
);
}
if (
domain in SPECIAL_TOGGLE_ACTIONS &&
SPECIAL_TOGGLE_ACTIONS[domain].feature
@@ -1,15 +0,0 @@
import type { EntityIdPart } from "../../data/entity_id_format";
import { slugify } from "../string/slugify";
export const computeEntityIdFormatExample = (
format: EntityIdPart[],
examples: Record<EntityIdPart, string>
): string => {
const parts = format
.map((item) => examples[item])
.filter(Boolean)
.map((part) => slugify(part, "_"))
.filter(Boolean);
return parts.join("_") || "unknown";
};
+11 -1
View File
@@ -1,3 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { isTiltOnly } from "../../data/cover";
import { CameraEntityFeature } from "../../data/feature/camera_entity_feature";
import { ClimateEntityFeature } from "../../data/feature/climate_entity_feature";
import { CoverEntityFeature } from "../../data/feature/cover_entity_feature";
@@ -63,7 +65,15 @@ export const SPECIAL_TOGGLE_ACTIONS: Record<string, SpecialToggleAction> = {
// This function assumes that the passed domain can toggle, it may otherwise
// return a service that does not exist.
export const getToggleAction = (domain: string, onOff: boolean): string => {
export const getToggleAction = (
domain: string,
onOff: boolean,
stateObj?: HassEntity
): string => {
// Tilt-only covers don't support open_cover/close_cover
if (domain === "cover" && stateObj && isTiltOnly(stateObj)) {
return onOff ? "open_cover_tilt" : "close_cover_tilt";
}
return (
SPECIAL_TOGGLE_ACTIONS[domain]?.[onOff ? "on" : "off"] ||
SPECIAL_TOGGLE_ACTIONS[domain]?.["on"] ||
+35 -10
View File
@@ -1,8 +1,21 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { isTiltOnly } from "../../data/cover";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import { CoverEntityFeature } from "../../data/feature/cover_entity_feature";
import type { HomeAssistant } from "../../types";
import { computeStateDomain } from "./compute_state_domain";
import { getToggleAction } from "./get_toggle_action";
import { supportsFeature } from "./supports-feature";
// Tilt-only covers can only stop their tilt, and may not support it at all
const getStopAction = (stateObj: HassEntity): string | undefined => {
if (!isTiltOnly(stateObj)) {
return "stop_cover";
}
return supportsFeature(stateObj, CoverEntityFeature.STOP_TILT)
? "stop_cover_tilt"
: undefined;
};
export const computeGroupEntitiesState = (states: HassEntity[]): string => {
if (!states.length) {
@@ -58,17 +71,29 @@ export const toggleGroupEntities = (
const isOn = state === "on" || state === "open";
let service = getToggleAction(domain, !isOn);
if (domain === "cover") {
if (state === "opening" || state === "closing") {
// If the cover is opening or closing, we toggle it to stop it
service = "stop_cover";
// If the cover is opening or closing, we toggle it to stop it
const stopping =
domain === "cover" && (state === "opening" || state === "closing");
// The service can differ per entity, e.g. for tilt-only covers,
// so group the entities by the service they need
const entityIdsByService: Record<string, string[]> = {};
states.forEach((stateObj) => {
const service = stopping
? getStopAction(stateObj)
: getToggleAction(domain, !isOn, stateObj);
if (!service) {
return;
}
}
if (!(service in entityIdsByService)) {
entityIdsByService[service] = [];
}
entityIdsByService[service].push(stateObj.entity_id);
});
const entitiesIds = states.map((stateObj) => stateObj.entity_id);
hass.callService(domain, service, {
entity_id: entitiesIds,
Object.entries(entityIdsByService).forEach(([service, entityIds]) => {
hass.callService(domain, service, {
entity_id: entityIds,
});
});
};
@@ -1,29 +1,8 @@
let supported: boolean | undefined;
const detect = (): boolean => {
if (
!globalThis.ElementInternals ||
!globalThis.HTMLElement?.prototype.attachInternals
) {
return false;
}
// Native internals keep their WebIDL brand even when `attachInternals` is
// wrapped (e.g. by `@webcomponents/scoped-custom-element-registry`, which
// broke the previous `[native code]` source check in the app bundle).
// `element-internals-polyfill` swaps in a plain class, which has no brand,
// and must not count as native: login on legacy browsers relies on
// validation being skipped there (#51338).
return (
Object.prototype.toString.call(globalThis.ElementInternals.prototype) ===
"[object ElementInternals]"
);
};
/**
* Indicates whether the current browser has native ElementInternals support.
* Probed on first use so importing this module has no side effects.
*/
export const supportsNativeElementInternals = (): boolean => {
supported ??= detect();
return supported;
};
export const nativeElementInternalsSupported =
Boolean(globalThis.ElementInternals) &&
globalThis.HTMLElement?.prototype.attachInternals
?.toString()
.includes("[native code]");
@@ -1,26 +0,0 @@
import { mainWindow } from "../dom/get_main_window";
/**
* Checks that a path resolves to the origin the frontend is served from, so it
* is safe to use as a link target. Rejects URIs that carry their own scheme,
* like `javascript:`, and URLs pointing at another origin. Resolves against the
* main window, because that is where `navigate()` applies the path.
*/
const isSameOriginPath = (path: string): boolean => {
try {
const { origin } = mainWindow.location;
return new URL(path, origin).origin === origin;
} catch (_err) {
return false;
}
};
/**
* Returns the path if it is safe to navigate to, `undefined` otherwise. Use for
* paths that can be influenced by a URL parameter or by dashboard config before
* they end up in an `href` or in `navigate()`.
*/
export const sanitizeNavigationPath = (
path: string | null | undefined
): string | undefined =>
path != null && isSameOriginPath(path) ? path : undefined;
+1 -1
View File
@@ -126,7 +126,7 @@ export class HaProgressButton extends LitElement {
visibility: hidden;
}
.progress ha-svg-icon {
:host([appearance="brand"]) ha-svg-icon {
color: var(--white-color);
}
`;
-1
View File
@@ -27,7 +27,6 @@ export class HaInputChip extends InputChip {
);
--ha-input-chip-selected-container-opacity: 1;
--md-input-chip-label-text-font: Roboto, sans-serif;
--md-input-chip-label-text-weight: 400;
}
/** Set the size of mdc icons **/
::slotted([slot="icon"]) {
@@ -1,190 +0,0 @@
import { mdiDevices } from "@mdi/js";
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 { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { getDeviceArea } from "../../common/entity/context/get_device_context";
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
import { domainToName } from "../../data/integration";
import type { HomeAssistant } from "../../types";
import type { HassDialog } from "../../dialogs/make-dialog-manager";
import { brandsUrl } from "../../util/brands-url";
import "../ha-dialog";
import "../ha-svg-icon";
import "../item/ha-list-item-button";
import "../list/ha-list-base";
import type { DeviceReplacedDialogParams } from "./show-dialog-device-replaced";
@customElement("dialog-device-replaced")
export class DialogDeviceReplaced
extends LitElement
implements HassDialog<DeviceReplacedDialogParams>
{
@state() private _params?: DeviceReplacedDialogParams;
@state() private _open = false;
@state() private _configEntryLookup?: Record<string, ConfigEntry>;
@property({ attribute: false }) public hass!: HomeAssistant;
public async showDialog(params: DeviceReplacedDialogParams): Promise<void> {
this._params = params;
this._open = true;
this._loadConfigEntries();
}
private async _loadConfigEntries(): Promise<void> {
const configEntries = await getConfigEntries(this.hass);
this._configEntryLookup = Object.fromEntries(
configEntries.map((entry) => [entry.entry_id, entry])
);
}
public closeDialog(): boolean {
this._open = false;
return true;
}
private _dialogClosed(): void {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private _pick(ev: Event): void {
const item = (ev.target as HTMLElement).closest("ha-list-item-button") as
(HTMLElement & { deviceId?: string }) | null;
if (!item?.deviceId) {
return;
}
this._params?.onResolved(item.deviceId);
this.closeDialog();
}
private _items = memoizeOne(
(
candidates: string[],
primaryId: string | null,
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
configEntryLookup: Record<string, ConfigEntry> | undefined
) =>
candidates.map((deviceId) => {
const device = devices[deviceId];
const area = device ? getDeviceArea(device, areas) : undefined;
const configEntry = device?.primary_config_entry
? configEntryLookup?.[device.primary_config_entry]
: undefined;
return {
deviceId,
name: device ? computeDeviceName(device) : deviceId,
area: area ? computeAreaName(area) : undefined,
domain: configEntry?.domain,
domainName: configEntry
? domainToName(this.hass.localize, configEntry.domain)
: undefined,
isPrimary: deviceId === primaryId,
};
})
);
protected render() {
if (!this._params || !this.hass) {
return nothing;
}
return html`
<ha-dialog
.open=${this._open}
.headerTitle=${this.hass.localize(
"ui.components.device-picker.replaced_dialog.title"
)}
@closed=${this._dialogClosed}
>
<p class="description">
${this.hass.localize(
"ui.components.device-picker.replaced_dialog.description"
)}
</p>
<ha-list-base @click=${this._pick}>
${this._items(
this._params.candidates,
this._params.primaryId,
this.hass.devices,
this.hass.areas,
this._configEntryLookup
).map((item) => {
const supportingText = [
item.area,
item.domainName,
item.isPrimary
? this.hass.localize(
"ui.components.device-picker.replaced_dialog.recommended"
)
: undefined,
]
.filter(Boolean)
.join(" • ");
return html`
<ha-list-item-button .deviceId=${item.deviceId}>
${
item.domain
? html`<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: item.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>`
: html`<ha-svg-icon
slot="start"
.path=${mdiDevices}
></ha-svg-icon>`
}
<span slot="headline">${item.name}</span>
${
supportingText
? html`<span slot="supporting-text"
>${supportingText}</span
>`
: nothing
}
</ha-list-item-button>
`;
})}
</ha-list-base>
</ha-dialog>
`;
}
static styles = css`
ha-dialog {
--dialog-content-padding: 0;
--ha-row-item-padding-inline: var(--ha-space-6);
}
.description {
margin: 0;
padding: 0 var(--ha-space-6) var(--ha-space-4);
color: var(--secondary-text-color);
}
img[slot="start"] {
width: 24px;
height: 24px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"dialog-device-replaced": DialogDeviceReplaced;
}
}
@@ -12,7 +12,6 @@ import { fullEntitiesContext } from "../../data/context";
import type { DeviceAutomation } from "../../data/device/device_automation";
import {
deviceAutomationsEqual,
deviceAutomationsSimilar,
sortDeviceAutomations,
} from "../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
@@ -202,22 +201,12 @@ export abstract class HaDeviceAutomationPicker<
: // No device, clear the list of automations
[];
// If there is no value, or if we have changed the device ID, reset the
// value. When the device changed (for example after replacing a removed
// device), try to keep the same automation type/subtype on the new device
// before falling back to the first available automation.
// If there is no value, or if we have changed the device ID, reset the value.
if (!this.value || this.value.device_id !== this.deviceId) {
const equivalent =
this.value && this.deviceId
? this._automations.find((automation) =>
deviceAutomationsSimilar(automation, this.value!)
)
: undefined;
this._setValue(
equivalent ||
(this._automations.length
? this._automations[0]
: this._createNoAutomation(this.deviceId))
this._automations.length
? this._automations[0]
: this._createNoAutomation(this.deviceId)
);
}
this._renderEmpty = true;
+47 -245
View File
@@ -1,7 +1,6 @@
import { mdiAlertOutline } from "@mdi/js";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
@@ -14,20 +13,12 @@ import {
getDevices,
type DevicePickerItem,
} from "../../data/device/device_picker";
import {
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
type DeviceRegistryEntry,
} from "../../data/device/device_registry";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import "../ha-alert";
import "../ha-button";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-svg-icon";
import { showDeviceReplacedDialog } from "./show-dialog-device-replaced";
export type HaDevicePickerDeviceFilterFunc = (
device: DeviceRegistryEntry
@@ -104,10 +95,6 @@ export class HaDevicePicker extends LitElement {
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
private _getDevicesMemoized = memoizeOne(
(
_devices: HomeAssistant["devices"],
@@ -136,29 +123,6 @@ export class HaDevicePicker extends LitElement {
this._loadConfigEntries();
}
protected willUpdate(changedProperties: PropertyValues<this>): void {
if (
!this.hass ||
!this.value ||
this._compositeSplits !== undefined ||
this._loadingCompositeSplits
) {
return;
}
const oldHass = changedProperties.get("hass") as HomeAssistant | undefined;
const devicesChanged =
changedProperties.has("hass") && this.hass.devices !== oldHass?.devices;
if (
(changedProperties.has("value") || devicesChanged) &&
!this.hass.devices[this.value]
) {
// The selected device is not in the registry; it might be a legacy
// composite device that was split into separate devices. Fetch the
// split map so we can offer to replace the reference.
this._loadCompositeSplits();
}
}
private async _loadConfigEntries() {
const configEntries = await getConfigEntries(this.hass);
this._configEntryLookup = Object.fromEntries(
@@ -166,43 +130,6 @@ export class HaDevicePicker extends LitElement {
);
}
private async _loadCompositeSplits() {
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
} catch (_err) {
this._compositeSplits = {};
} finally {
this._loadingCompositeSplits = false;
}
}
private _getReplacement = memoizeOne(
(
value: string | undefined,
_devices: HomeAssistant["devices"],
compositeSplits: DeviceCompositeSplits | undefined,
items: (DevicePickerItem | string)[]
) => {
if (!value || !compositeSplits || this.hass.devices[value]) {
return undefined;
}
const split = compositeSplits[value];
if (!split) {
return undefined;
}
// Keep only the split devices that pass this picker's filters. In
// practice usually exactly one of the split devices matches.
const selectableIds = new Set(
items
.filter((item): item is DevicePickerItem => typeof item !== "string")
.map((item) => item.id)
);
const candidates = split.split_ids.filter((id) => selectableIds.has(id));
return { candidates, primaryId: split.primary_id };
}
);
private _getItems = () =>
this._getDevicesMemoized(
this.hass.devices,
@@ -217,66 +144,49 @@ export class HaDevicePicker extends LitElement {
);
private _valueRenderer = memoizeOne(
(
configEntriesLookup: Record<string, ConfigEntry>,
replacementName: string | undefined
) =>
(value: string) => {
const deviceId = value;
const device = this.hass.devices[deviceId];
(configEntriesLookup: Record<string, ConfigEntry>) => (value: string) => {
const deviceId = value;
const device = this.hass.devices[deviceId];
if (!device) {
// When the device was replaced and a replacement is available, show
// the replacement device's name. Otherwise fall back to the normal
// "not found" display of the raw id.
if (replacementName) {
return html`
<ha-svg-icon
slot="start"
style="color: var(--warning-color)"
.path=${mdiAlertOutline}
></ha-svg-icon>
<span slot="headline">${replacementName}</span>
`;
}
return html`<span slot="headline">${deviceId}</span>`;
}
const area = getDeviceArea(device, this.hass.areas);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const primary = deviceName;
const secondary = areaName;
const configEntry = device.primary_config_entry
? configEntriesLookup[device.primary_config_entry]
: undefined;
return html`
${
configEntry
? html`<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: configEntry.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>`
: nothing
}
<span slot="headline">${primary}</span>
<span slot="supporting-text">${secondary}</span>
`;
if (!device) {
return html`<span slot="headline">${deviceId}</span>`;
}
const area = getDeviceArea(device, this.hass.areas);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const primary = deviceName;
const secondary = areaName;
const configEntry = device.primary_config_entry
? configEntriesLookup[device.primary_config_entry]
: undefined;
return html`
${
configEntry
? html`<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: configEntry.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>`
: nothing
}
<span slot="headline">${primary}</span>
<span slot="supporting-text">${secondary}</span>
`;
}
);
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => html`
@@ -325,39 +235,7 @@ export class HaDevicePicker extends LitElement {
this.placeholder ??
this.hass.localize("ui.components.device-picker.placeholder");
// Only resolve a replacement (which needs the full item list) when the
// value is a missing device that we know was replaced, to avoid computing
// the item list on every render for the common case.
const replacement =
this.value &&
!this.hass.devices[this.value] &&
this._compositeSplits?.[this.value]
? this._getReplacement(
this.value,
this.hass.devices,
this._compositeSplits,
this._getItems()
)
: undefined;
// Only treat the value as "replaced" when there is an available
// replacement device; otherwise fall back to normal "not found" behavior.
const canReplace = !!replacement?.candidates.length;
const replacementName = canReplace
? computeDeviceName(
this.hass.devices[
replacement!.primaryId &&
replacement!.candidates.includes(replacement!.primaryId)
? replacement!.primaryId
: replacement!.candidates[0]
]
)
: undefined;
const valueRenderer = this._valueRenderer(
this._configEntryLookup,
replacementName
);
const valueRenderer = this._valueRenderer(this._configEntryLookup);
return html`
<ha-generic-picker
@@ -378,84 +256,15 @@ export class HaDevicePicker extends LitElement {
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${valueRenderer}
.searchKeys=${deviceComboBoxKeys}
.unknownItemText=${
replacement?.candidates.length
? this.hass.localize(
"ui.components.device-picker.device_replaced_count",
{ count: replacement.candidates.length }
)
: this.hass.localize("ui.components.device-picker.unknown")
}
.unknownItemText=${this.hass.localize(
"ui.components.device-picker.unknown"
)}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
${canReplace ? this._renderReplacedAlert(replacement!) : nothing}
`;
}
private _renderReplacedAlert(replacement: {
candidates: string[];
primaryId: string | null;
}) {
const { candidates } = replacement;
const replacementName =
candidates.length === 1
? computeDeviceName(this.hass.devices[candidates[0]])
: undefined;
return html`
<ha-alert alert-type="warning">
${
replacementName
? this.hass.localize(
"ui.components.device-picker.device_replaced_by_one",
{ device: replacementName }
)
: this.hass.localize(
"ui.components.device-picker.device_replaced_by_multiple",
{ count: candidates.length }
)
}
<ha-button
slot="action"
appearance="plain"
@click=${this._handleReplace}
>
${this.hass.localize("ui.components.device-picker.replace_device")}
</ha-button>
</ha-alert>
`;
}
private _handleReplace = () => {
const replacement = this._getReplacement(
this.value,
this.hass.devices,
this._compositeSplits,
this._getItems()
);
if (!replacement?.candidates.length) {
return;
}
const { candidates, primaryId } = replacement;
if (candidates.length === 1) {
this._setValue(candidates[0]);
return;
}
showDeviceReplacedDialog(this, {
originalDeviceId: this.value!,
candidates,
primaryId,
onResolved: (deviceId) => this._setValue(deviceId),
});
};
private _setValue(value: string) {
this.value = value;
fireEvent(this, "value-changed", { value });
}
public async open() {
await this.updateComplete;
await this._picker?.open();
@@ -472,13 +281,6 @@ export class HaDevicePicker extends LitElement {
this.hass.localize("ui.components.device-picker.no_match", {
term: html`<b>${search}</b>`,
});
static styles = css`
ha-alert {
display: block;
margin-top: 8px;
}
`;
}
declare global {
@@ -1,22 +0,0 @@
import { fireEvent } from "../../common/dom/fire_event";
export interface DeviceReplacedDialogParams {
/** The removed composite device that is being referenced. */
originalDeviceId: string;
/** The split devices the reference can be pointed at. */
candidates: string[];
/** The split device that took over the composite's primary config entry. */
primaryId: string | null;
/** Called with the device the user picked as replacement. */
onResolved: (deviceId: string) => void;
}
export const showDeviceReplacedDialog = (
element: HTMLElement,
params: DeviceReplacedDialogParams
) =>
fireEvent(element, "show-dialog", {
dialogTag: "dialog-device-replaced",
dialogImport: () => import("./dialog-device-replaced"),
dialogParams: params,
});
+1 -1
View File
@@ -128,7 +128,7 @@ export class HaEntityToggle extends LitElement {
const serviceDomain =
stateDomain === "group" ? "homeassistant" : stateDomain;
const service = getToggleAction(stateDomain, turnOn);
const service = getToggleAction(stateDomain, turnOn, this.stateObj);
const currentState = this.stateObj;
+10 -37
View File
@@ -1,7 +1,6 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { PropertyValues } from "lit";
import { css, LitElement, svg } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { formatNumber } from "../common/number/format_number";
import { blankBeforePercent } from "../common/translations/blank_before_percent";
@@ -47,32 +46,15 @@ export class HaGauge extends LitElement {
@state() private _segment_label?: string = "";
@query(".text") private _textSvg?: SVGSVGElement;
@query(".value-text") private _valueText?: SVGTextElement;
private _sortedLevels?: LevelDefinition[];
// Set when the value text could not be measured because we have no layout box
// yet, either disconnected or inside a hidden container.
private _rescalePending = false;
// Measure again once we get a layout box, e.g. when a section hidden by a
// visibility condition is revealed. Nothing else re-renders the gauge then.
// @ts-ignore side-effect-only controller, its value is never read
private _resizeController = new ResizeController(this, {
skipInitial: true,
callback: (entries) => {
if (this._rescalePending && entries[0]?.contentRect.width) {
this._rescaleSvg();
}
},
});
private _rescaleOnConnect = false;
public connectedCallback(): void {
super.connectedCallback();
if (this._rescalePending && this.hasUpdated) {
if (this._rescaleOnConnect) {
this._rescaleSvg();
this._rescaleOnConnect = false;
}
}
@@ -239,22 +221,15 @@ export class HaGauge extends LitElement {
// fit the text
// That way it will auto-scale correctly
if (!this._textSvg || !this._valueText || !this.isConnected) {
this._rescalePending = true;
if (!this.isConnected) {
// Retry this later if we're disconnected, otherwise we get a 0 bbox and missing label
this._rescaleOnConnect = true;
return;
}
const box = this._valueText.getBBox();
// An empty box means we have no layout, so keep the last known good viewBox
// and retry later. A viewBox with a 0 width or height would hide the label.
if (!box.width || !box.height) {
this._rescalePending = true;
return;
}
this._rescalePending = false;
this._textSvg.setAttribute(
const svgRoot = this.shadowRoot!.querySelector(".text")!;
const box = svgRoot.querySelector("text")!.getBBox()!;
svgRoot.setAttribute(
"viewBox",
`${box.x} ${box.y} ${box.width} ${box.height}`
);
@@ -273,8 +248,6 @@ export class HaGauge extends LitElement {
static styles = css`
:host {
/* a non replaced inline element never reports a size to a resize observer */
display: block;
position: relative;
}
+8 -9
View File
@@ -1,7 +1,6 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import type { HomeAssistant } from "../types";
import { subscribeLabFeature } from "../data/labs";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
@@ -82,14 +81,14 @@ export class HaSnowflakes extends SubscribeMixin(LitElement) {
class="snowflake ${
this.narrow && flake.id >= 30 ? "hide-narrow" : ""
}"
style=${styleMap({
left: `${flake.left}%`,
width: `${flake.size}px`,
height: `${flake.size}px`,
"animation-duration": `${flake.duration}s`,
"animation-delay": `${flake.delay}s`,
"--rotation": `${flake.rotation}deg`,
})}
style="
left: ${flake.left}%;
width: ${flake.size}px;
height: ${flake.size}px;
animation-duration: ${flake.duration}s;
animation-delay: ${flake.delay}s;
--rotation: ${flake.rotation}deg;
"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
-55
View File
@@ -27,10 +27,6 @@ import {
getDevices,
type DevicePickerItem,
} from "../data/device/device_picker";
import {
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
} from "../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../data/entity/entity";
import {
entityComboBoxKeys,
@@ -126,10 +122,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
@state()
@consume({ context: labelsContext, subscribe: true })
private _labelRegistry!: LabelRegistryEntry[];
@@ -219,24 +211,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
this._loadConfigEntries();
}
const devicesChanged =
changedProps.has("hass") &&
this.hass.devices !== changedProps.get("hass")?.devices;
if (
(changedProps.has("value") || devicesChanged) &&
this.hass &&
this._compositeSplits === undefined &&
!this._loadingCompositeSplits &&
this.value?.device_id &&
ensureArray(this.value.device_id).some(
(deviceId) => !this.hass.devices[deviceId]
)
) {
// A referenced device is missing from the registry; it might be a legacy
// composite device that was split. Fetch the split map to offer a fix.
this._loadCompositeSplits();
}
if (
this._pendingEntityId &&
changedProps.has("hass") &&
@@ -323,7 +297,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.hass=${this.hass}
type="device"
.itemId=${device_id}
.compositeSplits=${this._compositeSplits}
@remove-target-item=${this._handleRemove}
@expand-target-item=${this._handleExpand}
></ha-target-picker-value-chip>
@@ -417,7 +390,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
<ha-target-picker-item-group
@remove-target-item=${this._handleRemove}
@replace-target-item=${this._handleReplace}
@migrate-target-item=${this._handleMigrate}
type="device"
.hass=${this.hass}
.items=${{ device: deviceIds }}
@@ -426,7 +398,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
.compositeSplits=${this._compositeSplits}
>
</ha-target-picker-item-group>
`
@@ -1204,31 +1175,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
);
}
private async _loadCompositeSplits() {
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
} catch (_err) {
this._compositeSplits = {};
} finally {
this._loadingCompositeSplits = false;
}
}
private _handleMigrate(
ev: HASSDomEvent<HASSDomEvents["migrate-target-item"]>
) {
const { id, replacements } = ev.detail;
let value = this._removeItem(this.value, "device", id);
for (const replacement of replacements) {
value = this._addTargetToValue(value, {
type: "device",
id: replacement,
});
}
fireEvent(this, "value-changed", { value });
}
private _renderRow = (
item:
| PickerComboBoxItem
@@ -1411,7 +1357,6 @@ declare global {
"remove-target-item": TargetItem;
"expand-target-item": TargetItem;
"replace-target-item": TargetItem;
"migrate-target-item": { id: string; replacements: string[] };
"remove-target-group": string;
}
}
+5 -9
View File
@@ -1,7 +1,7 @@
import { type LitElement, css } from "lit";
import { property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { supportsNativeElementInternals } from "../../common/feature-detect/support-native-element-internals";
import { nativeElementInternalsSupported } from "../../common/feature-detect/support-native-element-internals";
import type { Constructor } from "../../types";
/**
@@ -198,7 +198,7 @@ export const WaInputMixin = <T extends Constructor<LitElement>>(
}
public checkValidity(): boolean {
return supportsNativeElementInternals()
return nativeElementInternalsSupported
? (this._formControl?.checkValidity() ?? true)
: true;
}
@@ -211,7 +211,7 @@ export const WaInputMixin = <T extends Constructor<LitElement>>(
protected _handleInput(): void {
this.value = this._formControl?.value ?? undefined;
if (this._invalid && this.checkValidity()) {
if (this._invalid && this._formControl?.checkValidity()) {
this._invalid = false;
}
}
@@ -222,16 +222,12 @@ export const WaInputMixin = <T extends Constructor<LitElement>>(
protected _handleBlur(): void {
if (this.autoValidate) {
this._invalid = !this.checkValidity();
this._invalid = !this._formControl?.checkValidity();
}
}
protected _handleInvalid(): void {
// Polyfilled internals dispatch `invalid` themselves, so only trust the
// event when validity comes from the platform (#51338).
if (supportsNativeElementInternals()) {
this._invalid = true;
}
this._invalid = true;
}
protected _renderLabel = memoizeOne((label: string, required: boolean) => {
@@ -1,6 +1,5 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { DeviceCompositeSplits } from "../../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { TargetType, TargetTypeFloorless } from "../../data/target";
import type { HomeAssistant } from "../../types";
@@ -53,9 +52,6 @@ export class HaTargetPickerItemGroup extends LitElement {
@property({ type: Boolean, attribute: "primary-entities-only" })
public primaryEntitiesOnly?: boolean;
@property({ attribute: false })
public compositeSplits?: DeviceCompositeSplits;
protected render() {
let count = 0;
Object.values(this.items).forEach((items) => {
@@ -91,7 +87,6 @@ export class HaTargetPickerItemGroup extends LitElement {
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
.compositeSplits=${this.compositeSplits}
></ha-target-picker-item-row>`
)
: nothing
@@ -34,10 +34,7 @@ import { computeRTL } from "../../common/util/compute_rtl";
import type { AreaRegistryEntry } from "../../data/area/area_registry";
import { getConfigEntry } from "../../data/config_entries";
import { labelsContext } from "../../data/context";
import type {
DeviceCompositeSplits,
DeviceRegistryEntry,
} from "../../data/device/device_registry";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { FloorRegistryEntry } from "../../data/floor_registry";
import { domainToName } from "../../data/integration";
@@ -57,7 +54,6 @@ import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
import { floorDefaultIconPath } from "../ha-floor-icon";
import "../ha-button";
import "../ha-icon-button";
import "../ha-state-icon";
import "../ha-svg-icon";
@@ -112,9 +108,6 @@ export class HaTargetPickerItemRow extends LitElement {
@property({ type: Boolean, attribute: "primary-entities-only" })
public primaryEntitiesOnly?: boolean;
@property({ attribute: false })
public compositeSplits?: DeviceCompositeSplits;
@state() private _iconImg?: string;
@state() private _domainName?: string;
@@ -135,15 +128,6 @@ export class HaTargetPickerItemRow extends LitElement {
const { name, context, iconPath, fallbackIconPath, stateObject, notFound } =
this._itemData(this.type, this.itemId);
const replacement =
this.type === "device" && notFound
? this._getReplacement(this.itemId)
: undefined;
// Only surface the "replaced" state when there is at least one available
// replacement device to migrate to. If every replacement device was
// deleted (or filtered out), fall back to the plain "not found" state.
const canMigrate = !!replacement?.candidates.length;
const showEntities = this.type !== "entity" && !notFound;
const entries = this.parentEntries || this._entries;
@@ -190,20 +174,15 @@ export class HaTargetPickerItemRow extends LitElement {
}
</div>
<div slot="headline">${(canMigrate && replacement?.name) || name}</div>
<div slot="headline">${name}</div>
${
notFound || (context && !this.hideContext)
? html`<span slot="supporting-text"
>${
notFound
? canMigrate
? this.hass.localize(
"ui.components.target-picker.device_replaced",
{ count: replacement!.candidates.length }
)
: this.hass.localize(
`ui.components.target-picker.${this.type}_not_found`
)
? this.hass.localize(
`ui.components.target-picker.${this.type}_not_found`
)
: context
}</span
>`
@@ -250,24 +229,6 @@ export class HaTargetPickerItemRow extends LitElement {
`
: nothing
}
${
canMigrate
? html`
<ha-button
class="migrate"
slot="end"
appearance="plain"
variant="warning"
size="s"
@click=${this._migrate}
>
${this.hass.localize(
"ui.components.target-picker.replace_device"
)}
</ha-button>
`
: nothing
}
${
!this.expand && !this.subEntry
? html`
@@ -746,51 +707,6 @@ export class HaTargetPickerItemRow extends LitElement {
});
}
// Returns the split devices that replaced a removed composite device and
// pass this row's filters, or undefined if the item is not a replaced device.
private _getReplacement(item: string) {
const split = this.compositeSplits?.[item];
if (!split || this.hass.devices[item]) {
return undefined;
}
const candidates = split.split_ids.filter((id) => {
const device = this.hass.devices[id];
return (
device &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter,
!this.primaryEntitiesOnly
)
);
});
// Display the replaced reference using the primary replacement device's
// name instead of the removed composite device id. Fall back to the first
// available candidate if the primary device itself was deleted.
const nameDevice =
(split.primary_id && this.hass.devices[split.primary_id]) ||
(candidates.length ? this.hass.devices[candidates[0]] : undefined);
const name = nameDevice ? computeDeviceName(nameDevice) : undefined;
return { candidates, name };
}
private _migrate = (ev: MouseEvent) => {
ev.stopPropagation();
const replacement = this._getReplacement(this.itemId);
if (!replacement?.candidates.length) {
return;
}
fireEvent(this, "migrate-target-item", {
id: this.itemId,
replacements: replacement.candidates,
});
};
private _openDetails(ev: MouseEvent) {
ev.stopPropagation();
showTargetDetailsDialog(this, {
@@ -830,11 +746,6 @@ export class HaTargetPickerItemRow extends LitElement {
color: var(--ha-color-on-warning-normal);
}
.migrate {
align-self: center;
white-space: nowrap;
}
.replaceable {
cursor: pointer;
}
@@ -1,7 +1,6 @@
import "@home-assistant/webawesome/dist/components/tag/tag";
import { consume } from "@lit/context";
import {
mdiAlertOutline,
mdiDevices,
mdiHome,
mdiLabel,
@@ -10,7 +9,6 @@ import {
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../../common/color/compute-color";
import { hex2rgb } from "../../common/color/convert-color";
@@ -21,7 +19,6 @@ import { computeStateName } from "../../common/entity/compute_state_name";
import { slugify } from "../../common/string/slugify";
import { getConfigEntry } from "../../data/config_entries";
import { labelsContext } from "../../data/context";
import type { DeviceCompositeSplits } from "../../data/device/device_registry";
import { domainToName } from "../../data/integration";
import type { LabelRegistryEntry } from "../../data/label/label_registry";
import type { TargetType } from "../../data/target";
@@ -42,9 +39,6 @@ export class HaTargetPickerValueChip extends LitElement {
@property({ attribute: "item-id" }) public itemId!: string;
@property({ attribute: false })
public compositeSplits?: DeviceCompositeSplits;
@state() private _domainName?: string;
@state() private _iconImg?: string;
@@ -57,74 +51,38 @@ export class HaTargetPickerValueChip extends LitElement {
const { name, iconPath, fallbackIconPath, stateObject, color } =
this._itemData(this.type, this.itemId);
const split =
this.type === "device" && !this.hass.devices?.[this.itemId]
? this.compositeSplits?.[this.itemId]
: undefined;
// Show the replaced reference using the primary replacement device's name,
// falling back to the first still-existing split device if the primary
// device itself was deleted. If no replacement device exists at all, fall
// back to the normal "not found" display.
const replacementDevice = split
? (split.primary_id && this.hass.devices?.[split.primary_id]) ||
split.split_ids
.map((id) => this.hass.devices?.[id])
.find((device) => device)
: undefined;
const replaced = !!replacementDevice;
const replacedName = replacementDevice
? computeDeviceNameDisplay(
replacementDevice,
this.hass.localize,
this.hass.states
)
: undefined;
return html`
<wa-tag
pill
with-remove
class=${classMap({ [this.type]: true, replaced })}
class=${this.type}
style=${color ? `--color: rgb(${color});` : ""}
@wa-remove=${this._removeItem}
>
<div class="icon">
${
replaced
? html`<ha-svg-icon .path=${mdiAlertOutline}></ha-svg-icon>`
: iconPath
? html`<ha-icon .icon=${iconPath}></ha-icon>`
: this._iconImg
? html`<img
alt=${this._domainName || ""}
width="24"
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon
.path=${fallbackIconPath}
></ha-svg-icon>`
: stateObject
? html`<ha-state-icon
.stateObj=${stateObject}
></ha-state-icon>`
: nothing
iconPath
? html`<ha-icon .icon=${iconPath}></ha-icon>`
: this._iconImg
? html`<img
alt=${this._domainName || ""}
width="24"
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
: stateObject
? html`<ha-state-icon
.stateObj=${stateObject}
></ha-state-icon>`
: nothing
}
</div>
<span class="name">
${
replaced
? replacedName ||
this.hass.localize(
"ui.components.target-picker.replaced_device"
)
: name
}
</span>
<span class="name"> ${name} </span>
${
this.type === "entity" || replaced
this.type === "entity"
? nothing
: html`<ha-tooltip .for="expand-${slugify(this.itemId)}"
>${this.hass.localize(
@@ -167,7 +125,7 @@ export class HaTargetPickerValueChip extends LitElement {
if (type === "device") {
const device = this.hass.devices?.[itemId];
if (device?.primary_config_entry) {
if (device.primary_config_entry) {
this._getDeviceDomain(device.primary_config_entry);
}
@@ -282,11 +240,6 @@ export class HaTargetPickerValueChip extends LitElement {
--background-color: var(--color);
--icon-primary-color: var(--primary-text-color);
}
wa-tag.replaced {
border-color: var(--ha-color-border-warning-normal, var(--warning-color));
--background-color: var(--warning-color);
color: var(--ha-color-on-warning-normal, var(--warning-color));
}
.name {
overflow: hidden;
+1 -1
View File
@@ -112,7 +112,7 @@ export class HaTileContainer extends LitElement {
flex-direction: column;
text-align: center;
justify-content: center;
padding: 10px;
padding: 10px 0;
}
.vertical ::slotted([slot="info"]) {
width: 100%;
+11 -13
View File
@@ -49,25 +49,23 @@ export const YAML_ONLY_ACTION_TYPES = new Set<keyof typeof ACTION_ICONS>([
export const ACTION_COLLECTIONS: AutomationElementGroupCollection[] = [
{
groups: {
device_id: {},
dynamicGroups: {},
event: {},
service: {},
set_conversation_response: {},
},
},
{
titleKey: "ui.panel.config.automation.editor.actions.groups.helpers.label",
groups: {
helpers: {},
},
},
{
titleKey: "ui.panel.config.automation.editor.actions.groups.generic.label",
generic: true,
titleKey: "ui.panel.config.automation.editor.actions.groups.other.label",
groups: {
device_id: {},
},
},
{
titleKey:
"ui.panel.config.automation.editor.actions.groups.integrations.label",
groups: {
integrationGroups: {},
event: {},
service: {},
set_conversation_response: {},
other: {},
},
},
] as const;
+3 -2
View File
@@ -21,6 +21,7 @@ export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [
helpers: {},
template: {},
trigger: {},
other: {},
},
},
{
@@ -34,9 +35,9 @@ export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [
},
{
titleKey:
"ui.panel.config.automation.editor.conditions.groups.integrations.label",
"ui.panel.config.automation.editor.conditions.groups.custom_integrations.label",
groups: {
integrationGroups: {},
customDynamicGroups: {},
},
},
] as const;
+1 -47
View File
@@ -2,10 +2,9 @@ import type { HassEntities } from "home-assistant-js-websocket";
import { computeStateName } from "../../common/entity/compute_state_name";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { HaFormSchema } from "../../components/ha-form/types";
import type { CallWS, HomeAssistant } from "../../types";
import type { CallWS } from "../../types";
import type { BaseTrigger } from "../automation";
import { migrateAutomationTrigger } from "../automation";
import type { DeviceCompositeSplits } from "./device_registry";
import type { EntityRegistryEntry } from "../entity/entity_registry";
import {
entityRegistryByEntityId,
@@ -159,51 +158,6 @@ export const deviceAutomationsEqual = (
return true;
};
// Decides how a device automation editor should handle its referenced device.
// A missing device that was replaced by a split device stays editable so the
// device picker can offer to fix the reference; a genuinely unknown device
// cannot be edited visually. Returns "loading" while the split map is unknown.
export const deviceAutomationEditorMode = (
hass: HomeAssistant,
deviceId: string | undefined,
compositeSplits: DeviceCompositeSplits | undefined
): "editable" | "loading" | "unknown-device" => {
if (!deviceId || deviceId in hass.devices) {
return "editable";
}
if (compositeSplits === undefined) {
return "loading";
}
// Only editable if at least one of the split (replacement) devices still
// exists; otherwise the reference is stale and cannot be fixed here.
const split = compositeSplits[deviceId];
return split?.split_ids.some((id) => id in hass.devices)
? "editable"
: "unknown-device";
};
// Like deviceAutomationsEqual, but ignores device_id and entity_id so an
// automation can be matched to the equivalent one on a different device (for
// example when a referenced device was replaced by a split device).
export const deviceAutomationsSimilar = (
a: DeviceAutomation,
b: DeviceAutomation
) => {
if (typeof a !== typeof b) {
return false;
}
return deviceAutomationIdentifiers
.filter((property) => property !== "device_id" && property !== "entity_id")
.every((property) => {
const inA = property in a;
const inB = property in b;
if (!inA && !inB) {
return true;
}
return Object.is(a[property], b[property]);
});
};
const compareEntityIdWithEntityRegId = (
entityRegistry: EntityRegistryEntry[],
entityIdA?: string,
-64
View File
@@ -1,4 +1,3 @@
import type { Connection } from "home-assistant-js-websocket";
import { computeStateName } from "../../common/entity/compute_state_name";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import type { HomeAssistant } from "../../types";
@@ -55,69 +54,6 @@ export interface DeviceRegistryEntryMutableParams {
labels?: string[];
}
/**
* Describes how a legacy composite device (that lived on multiple config
* entries) was split into separate devices. The composite device no longer
* exists in the registry; references to it (in automations, targets, ...) now
* need to point at one or more of the split devices instead.
*/
export interface DeviceCompositeSplit {
/** Ids of the devices that replaced the composite device. */
split_ids: string[];
/** The split device that took over the composite's primary config entry. */
primary_id: string | null;
}
/** Map of removed composite device id -> its split information. */
export type DeviceCompositeSplits = Record<string, DeviceCompositeSplit>;
// The composite split migration in core is a one-time operation, so the split
// map is static for the lifetime of the connection. Cache the request per
// connection so it is fetched once and shared across all pickers, instead of
// being requested again by every device/target picker.
const compositeSplitsCache = new WeakMap<
Connection,
Promise<DeviceCompositeSplits>
>();
export const fetchDeviceCompositeSplits = (
hass: Pick<HomeAssistant, "connection" | "callWS">
): Promise<DeviceCompositeSplits> => {
const conn = hass.connection;
let request = compositeSplitsCache.get(conn);
if (!request) {
request = hass
.callWS<DeviceCompositeSplits>({
type: "config/device_registry/list_composite_splits",
})
.catch((err) => {
// Don't cache failures so the next caller retries.
compositeSplitsCache.delete(conn);
throw err;
});
compositeSplitsCache.set(conn, request);
}
return request;
};
/**
* Fetch the devices that are linked to the given device because they share at
* least one connection or identifier. These are separate devices (one per
* config entry) that represent the same physical hardware, managed by
* different integrations.
*/
export const fetchLinkedDevices = (
hass: Pick<HomeAssistant, "callWS">,
deviceId: string
): Promise<string[]> =>
hass
.callWS<{ linked_devices: string[] }>({
type: "config/device_registry/list_linked_devices",
device_id: deviceId,
})
.then((result) => result.linked_devices);
export const fallbackDeviceName = (
hass: HomeAssistant,
entities: EntityRegistryEntry[] | EntityRegistryDisplayEntry[] | string[]
@@ -1,22 +0,0 @@
import type { HomeAssistantApi } from "../../types";
import type { EntityIdFormat } from "../entity_id_format";
export interface EntityRegistrySettings {
entity_id_parts: EntityIdFormat | null;
}
export const fetchEntityRegistrySettings = (
api: HomeAssistantApi
): Promise<EntityRegistrySettings> =>
api.callWS<EntityRegistrySettings>({
type: "config/entity_registry/settings/get",
});
export const updateEntityRegistrySettings = (
api: HomeAssistantApi,
updates: Partial<EntityRegistrySettings>
): Promise<EntityRegistrySettings> =>
api.callWS<EntityRegistrySettings>({
type: "config/entity_registry/settings/update",
...updates,
});
-12
View File
@@ -1,12 +0,0 @@
export type EntityIdPart = "area" | "device" | "entity" | "floor";
export type EntityIdFormat = EntityIdPart[];
export const DEFAULT_ENTITY_ID_FORMAT: EntityIdFormat = [
"area",
"device",
"entity",
];
export const isDefaultEntityIdFormat = (format: EntityIdFormat): boolean =>
JSON.stringify(format) === JSON.stringify(DEFAULT_ENTITY_ID_FORMAT);
+3 -2
View File
@@ -35,6 +35,7 @@ export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
webhook: {},
persistent_notification: {},
helpers: {},
other: {},
},
},
{
@@ -47,9 +48,9 @@ export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
},
{
titleKey:
"ui.panel.config.automation.editor.triggers.groups.integrations.label",
"ui.panel.config.automation.editor.triggers.groups.custom_integrations.label",
groups: {
integrationGroups: {},
customDynamicGroups: {},
},
},
] as const;
+2 -5
View File
@@ -4,7 +4,6 @@ import { customElement, eventOptions, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { restoreScroll } from "../common/decorators/restore-scroll";
import { goBack } from "../common/navigate";
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
import "../components/ha-icon-button-arrow-prev";
import "../components/ha-menu-button";
import { haStyleScrollbar } from "../resources/styles";
@@ -30,18 +29,16 @@ class HassSubpage extends LitElement {
@restoreScroll(".content") private _savedScrollPos?: number;
protected render(): TemplateResult {
const backPath = sanitizeNavigationPath(this.backPath);
return html`
<div class="toolbar ${classMap({ narrow: this.narrow })}">
<div class="toolbar-content">
${
this.mainPage || history.state?.root
? html`<ha-menu-button></ha-menu-button>`
: backPath
: this.backPath
? html`
<ha-icon-button-arrow-prev
href=${backPath}
href=${this.backPath}
></ha-icon-button-arrow-prev>
`
: html`
+3 -6
View File
@@ -15,7 +15,6 @@ import { restoreScroll } from "../common/decorators/restore-scroll";
import { isNavigationClick } from "../common/dom/is-navigation-click";
import { goBack, navigate } from "../common/navigate";
import type { LocalizeFunc } from "../common/translations/localize";
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
import "../components/ha-icon-button-arrow-prev";
import "../components/ha-menu-button";
import "../components/ha-svg-icon";
@@ -165,19 +164,17 @@ export class HassTabsSubpage extends LitElement {
this._narrow,
this.localizeFunc || this.hass.localize
);
const backPath = sanitizeNavigationPath(this.backPath);
return html`
<div class="toolbar ${classMap({ narrow: this._narrow })}">
<slot name="toolbar">
<div class="toolbar-content">
${
this.mainPage || (!backPath && history.state?.root)
this.mainPage || (!this.backPath && history.state?.root)
? html`<ha-menu-button></ha-menu-button>`
: backPath
: this.backPath
? html`
<ha-icon-button-arrow-prev
.href=${backPath}
.href=${this.backPath}
></ha-icon-button-arrow-prev>
`
: html`
@@ -13,16 +13,11 @@ import type {
DeviceCapabilities,
} from "../../../../../data/device/device_automation";
import {
deviceAutomationEditorMode,
deviceAutomationsEqual,
fetchDeviceActionCapabilities,
localizeExtraFieldsComputeHelperCallback,
localizeExtraFieldsComputeLabelCallback,
} from "../../../../../data/device/device_automation";
import {
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
} from "../../../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../../types";
@@ -38,10 +33,6 @@ export class HaDeviceAction extends LitElement {
@state() private _capabilities?: DeviceCapabilities;
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
@@ -68,19 +59,14 @@ export class HaDeviceAction extends LitElement {
}
);
public shouldUpdate(_changedProperties: PropertyValues<this>) {
const mode = deviceAutomationEditorMode(
this.hass,
this.action.device_id,
this._compositeSplits
);
if (mode === "loading") {
// The device is missing; wait for the composite split map before deciding
// whether it is a replaced device (editable) or genuinely unknown (YAML).
this._loadCompositeSplits();
return false;
public shouldUpdate(changedProperties: PropertyValues<this>) {
if (!changedProperties.has("action")) {
return true;
}
if (mode === "unknown-device") {
if (
this.action.device_id &&
!(this.action.device_id in this.hass.devices)
) {
fireEvent(
this,
"ui-mode-not-available",
@@ -95,20 +81,6 @@ export class HaDeviceAction extends LitElement {
return true;
}
private async _loadCompositeSplits() {
if (this._loadingCompositeSplits) {
return;
}
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
} catch (_err) {
this._compositeSplits = {};
} finally {
this._loadingCompositeSplits = false;
}
}
protected render() {
const deviceId = this._deviceId || this.action.device_id;
@@ -183,7 +183,12 @@ const ENTITY_DOMAINS_OTHER = new Set([
const ENTITY_DOMAINS_MAIN = new Set(["notify"]);
const DYNAMIC_KEYWORDS = ["dynamicGroups", "helpers", "integrationGroups"];
const DYNAMIC_KEYWORDS = [
"dynamicGroups",
"helpers",
"other",
"customDynamicGroups",
];
const DYNAMIC_TO_GENERIC = new Set([`${DYNAMIC_PREFIX}event`]);
@@ -192,13 +197,7 @@ const DYNAMIC_TO_GENERIC = new Set([`${DYNAMIC_PREFIX}event`]);
// drills into its items, like selecting the matching group in the "by type" tab.
const TIME_LOCATION_GROUPS = ["time", "sun"];
type CollectionGroupType = "helper" | "dynamic" | "integration";
interface DomainClassificationOptions {
type: AddAutomationElementDialogParams["type"];
usedDomains?: Set<string>;
activeSystemDomains?: Set<string>;
}
type CollectionGroupType = "helper" | "other" | "dynamic" | "customDynamic";
@customElement("add-automation-element-dialog")
class DialogAddAutomationElement
@@ -502,9 +501,7 @@ class DialogAddAutomationElement
);
private get _systemDomains() {
// System domains are derived from trigger/condition descriptions, so
// they don't apply to actions.
if (!this._manifests || this._params?.type === "action") {
if (!this._manifests) {
return undefined;
}
const descriptions =
@@ -1032,6 +1029,7 @@ class DialogAddAutomationElement
this._params!.type,
this._selectedGroup,
this._selectedCollectionIndex ?? 0,
this._domains,
this.hass.localize,
this.hass.services,
this._manifests,
@@ -1125,13 +1123,6 @@ class DialogAddAutomationElement
const exclusiveDomains = this._getExclusiveDomains(type);
const domainList =
type === "trigger"
? Object.keys(triggerDescriptions ?? {}).map(getTriggerDomain)
: type === "condition"
? Object.keys(conditionDescriptions ?? {}).map(getConditionDomain)
: Object.keys(services ?? {});
collections.forEach((collection, index) => {
let collectionGroups = Object.entries(collection.groups);
const groups: AddAutomationElementListItem[] = [];
@@ -1143,23 +1134,70 @@ class DialogAddAutomationElement
if (collection.groups.helpers) {
types.push("helper");
}
if (collection.groups.integrationGroups) {
types.push("integration");
if (collection.groups.other) {
types.push("other");
}
if (collection.groups.customDynamicGroups) {
types.push("customDynamic");
}
if (types.length) {
if (
type === "trigger" &&
Object.keys(collection.groups).some((item) =>
DYNAMIC_KEYWORDS.includes(item)
)
) {
groups.push(
...this._dynamicDomainGroups(
...this._triggerGroups(
localize,
domainList,
triggerDescriptions,
manifests,
domains,
types,
{
type,
usedDomains: domains,
activeSystemDomains: this._systemDomains?.active,
exclusiveDomains,
}
exclusiveDomains
)
);
collectionGroups = collectionGroups.filter(
([key]) => !DYNAMIC_KEYWORDS.includes(key)
);
} else if (
type === "condition" &&
Object.keys(collection.groups).some((item) =>
DYNAMIC_KEYWORDS.includes(item)
)
) {
groups.push(
...this._conditionGroups(
localize,
conditionDescriptions,
manifests,
domains,
types,
exclusiveDomains
)
);
collectionGroups = collectionGroups.filter(
([key]) => !DYNAMIC_KEYWORDS.includes(key)
);
} else if (
type === "action" &&
Object.keys(collection.groups).some((item) =>
DYNAMIC_KEYWORDS.includes(item)
)
) {
groups.push(
...this._serviceGroups(
localize,
services,
manifests,
domains,
collection.groups.dynamicGroups
? undefined
: collection.groups.helpers
? "helper"
: "other"
)
);
@@ -1251,6 +1289,7 @@ class DialogAddAutomationElement
type: AddAutomationElementDialogParams["type"],
group: string,
collectionIndex: number,
domains: Set<string> | undefined,
localize: LocalizeFunc,
services: HomeAssistant["services"],
manifests?: DomainManifestLookup,
@@ -1300,69 +1339,144 @@ class DialogAddAutomationElement
}
}
if (type === "action") {
if (!this._selectedGroup) {
result.unshift(
...this._serviceGroups(
localize,
services,
manifests,
domains,
undefined
)
);
} else if (this._selectedGroup === "helpers") {
result.unshift(
...this._serviceGroups(
localize,
services,
manifests,
domains,
"helper"
)
);
} else if (this._selectedGroup === "other") {
result.unshift(
...this._serviceGroups(
localize,
services,
manifests,
domains,
"other"
)
);
}
}
return result.sort((a, b) =>
stringCompare(a.name, b.name, this.hass.locale.language)
);
}
);
private _classifyDomain(
private _serviceGroups = (
localize: LocalizeFunc,
services: HomeAssistant["services"],
manifests: DomainManifestLookup | undefined,
domains: Set<string> | undefined,
type: "helper" | "other" | undefined
): AddAutomationElementListItem[] => {
if (!services || !manifests) {
return [];
}
const result: AddAutomationElementListItem[] = [];
Object.keys(services).forEach((domain) => {
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 || "")))
) {
result.push({
icon: html`
<ha-domain-icon .domain=${domain} brand-fallback></ha-domain-icon>
`,
key: `${DYNAMIC_PREFIX}${domain}`,
name: domainToName(localize, domain, manifest),
description: "",
});
}
});
return result.sort((a, b) =>
stringCompare(a.name, b.name, this.hass.locale.language)
);
};
private _domainMatchesGroupType(
domain: string,
manifest: DomainManifestLookup[string] | undefined,
options: DomainClassificationOptions
): CollectionGroupType | undefined {
const integrationType = manifest?.integration_type;
domainUsed: boolean,
types: CollectionGroupType[]
): boolean {
const matchDynamic =
((types.includes("dynamic") && (!manifest || manifest.is_built_in)) ||
(types.includes("customDynamic") &&
!(manifest?.is_built_in ?? true))) &&
(ENTITY_DOMAINS_MAIN.has(domain) ||
(manifest?.integration_type === "entity" &&
!ENTITY_DOMAINS_OTHER.has(domain) &&
(domainUsed || (this._systemDomains?.active.has(domain) ?? false))) ||
(manifest?.integration_type === "system" &&
(this._systemDomains?.active.has(domain) ?? false)));
if (integrationType === "helper") {
return "helper";
}
const matchHelper =
types.includes("helper") && manifest?.integration_type === "helper";
if (ENTITY_DOMAINS_MAIN.has(domain) || integrationType === "entity") {
// Core entity domains. Actions always list them; triggers/conditions
// only when matching entities exist or a system domain covers them.
if (
options.type === "action" ||
!options.usedDomains ||
options.usedDomains.has(domain) ||
ENTITY_DOMAINS_OTHER.has(domain) ||
(options.activeSystemDomains?.has(domain) ?? false)
) {
return "dynamic";
}
return undefined;
}
const matchOther =
types.includes("other") &&
!ENTITY_DOMAINS_MAIN.has(domain) &&
(ENTITY_DOMAINS_OTHER.has(domain) ||
!["helper", "entity", "system"].includes(
manifest?.integration_type || ""
));
if (integrationType === "system" && options.type !== "action") {
return options.activeSystemDomains?.has(domain) ? "dynamic" : undefined;
}
// Integrations that bring their own elements, built-in (like Apple TV,
// FFmpeg) and custom (like HACS) alike.
return "integration";
return matchDynamic || matchHelper || matchOther;
}
private _dynamicDomainGroups = (
private _triggerGroups = (
localize: LocalizeFunc,
domains: string[],
triggers: TriggerDescriptions,
manifests: DomainManifestLookup | undefined,
domains: Set<string> | undefined,
types: CollectionGroupType[],
options: DomainClassificationOptions & { exclusiveDomains?: Set<string> }
exclusiveDomains: Set<string>
): AddAutomationElementListItem[] => {
if (!manifests) {
if (!triggers || !manifests) {
return [];
}
const result: AddAutomationElementListItem[] = [];
const addedDomains = new Set<string>();
domains.forEach((domain) => {
if (addedDomains.has(domain) || options.exclusiveDomains?.has(domain)) {
Object.keys(triggers).forEach((trigger) => {
const domain = getTriggerDomain(trigger);
if (addedDomains.has(domain) || exclusiveDomains.has(domain)) {
return;
}
addedDomains.add(domain);
const manifest = manifests[domain];
const groupType = this._classifyDomain(domain, manifest, options);
const domainUsed = !domains ? true : domains.has(domain);
if (groupType && types.includes(groupType)) {
if (this._domainMatchesGroupType(domain, manifest, domainUsed, types)) {
result.push({
icon: html`
<ha-domain-icon .domain=${domain} brand-fallback></ha-domain-icon>
@@ -1411,6 +1525,46 @@ class DialogAddAutomationElement
}
);
private _conditionGroups = (
localize: LocalizeFunc,
conditions: ConditionDescriptions,
manifests: DomainManifestLookup | undefined,
domains: Set<string> | undefined,
types: CollectionGroupType[],
exclusiveDomains: Set<string>
): AddAutomationElementListItem[] => {
if (!conditions || !manifests) {
return [];
}
const result: AddAutomationElementListItem[] = [];
const addedDomains = new Set<string>();
Object.keys(conditions).forEach((condition) => {
const domain = getConditionDomain(condition);
if (addedDomains.has(domain) || exclusiveDomains.has(domain)) {
return;
}
addedDomains.add(domain);
const manifest = manifests[domain];
const domainUsed = !domains ? true : domains.has(domain);
if (this._domainMatchesGroupType(domain, manifest, domainUsed, types)) {
result.push({
icon: html`
<ha-domain-icon .domain=${domain} brand-fallback></ha-domain-icon>
`,
key: `${DYNAMIC_PREFIX}${domain}`,
name: domainToName(localize, domain, manifest),
description: "",
});
}
});
return result.sort((a, b) =>
stringCompare(a.name, b.name, this.hass.locale.language)
);
};
private _conditions = memoizeOne(
(
localize: LocalizeFunc,
@@ -1511,13 +1665,26 @@ class DialogAddAutomationElement
);
}
if (group) {
if (group && !["helpers", "other"].includes(group)) {
return [];
}
Object.keys(services)
.sort()
.forEach((dmn) => addDomain(dmn));
.forEach((dmn) => {
const manifest = manifests?.[dmn];
if (group === "helpers" && manifest?.integration_type !== "helper") {
return;
}
if (
group === "other" &&
(ENTITY_DOMAINS_OTHER.has(dmn) ||
["helper", "entity"].includes(manifest?.integration_type || ""))
) {
return;
}
addDomain(dmn);
});
return result;
}
@@ -1528,20 +1695,25 @@ class DialogAddAutomationElement
);
private _getDomainType(domain: string) {
const groupType = this._classifyDomain(domain, this._manifests?.[domain], {
type: this._params!.type,
usedDomains: this._domains,
activeSystemDomains: this._systemDomains?.active,
});
if (groupType === "helper") {
if (
ENTITY_DOMAINS_MAIN.has(domain) ||
(this._manifests?.[domain]?.integration_type === "entity" &&
!ENTITY_DOMAINS_OTHER.has(domain) &&
(this._domains?.has(domain) ||
(this._systemDomains?.active.has(domain) ?? false)))
) {
return "dynamicGroups";
}
if (this._manifests?.[domain]?.integration_type === "helper") {
return "helpers";
}
if (groupType === "integration") {
return "integrationGroups";
if (
this._manifests?.[domain]?.integration_type === "system" &&
this._systemDomains?.active.has(domain)
) {
return "dynamicGroups";
}
// "dynamic", plus domains hidden in the by-type list (like unused entity
// domains) that can still surface when browsing by target.
return "dynamicGroups";
return "other";
}
private _sortDomainsByCollection(
@@ -13,16 +13,11 @@ import type {
DeviceCondition,
} from "../../../../../data/device/device_automation";
import {
deviceAutomationEditorMode,
deviceAutomationsEqual,
fetchDeviceConditionCapabilities,
localizeExtraFieldsComputeHelperCallback,
localizeExtraFieldsComputeLabelCallback,
} from "../../../../../data/device/device_automation";
import {
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
} from "../../../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../../types";
@@ -38,10 +33,6 @@ export class HaDeviceCondition extends LitElement {
@state() private _capabilities?: DeviceCapabilities;
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
@@ -69,19 +60,14 @@ export class HaDeviceCondition extends LitElement {
}
);
public shouldUpdate(_changedProperties: PropertyValues<this>) {
const mode = deviceAutomationEditorMode(
this.hass,
this.condition.device_id,
this._compositeSplits
);
if (mode === "loading") {
// The device is missing; wait for the composite split map before deciding
// whether it is a replaced device (editable) or genuinely unknown (YAML).
this._loadCompositeSplits();
return false;
public shouldUpdate(changedProperties: PropertyValues<this>) {
if (!changedProperties.has("condition")) {
return true;
}
if (mode === "unknown-device") {
if (
this.condition.device_id &&
!(this.condition.device_id in this.hass.devices)
) {
fireEvent(
this,
"ui-mode-not-available",
@@ -96,20 +82,6 @@ export class HaDeviceCondition extends LitElement {
return true;
}
private async _loadCompositeSplits() {
if (this._loadingCompositeSplits) {
return;
}
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
} catch (_err) {
this._compositeSplits = {};
} finally {
this._loadingCompositeSplits = false;
}
}
protected render() {
const deviceId = this._deviceId || this.condition.device_id;
@@ -346,19 +346,17 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
maxWidth: "82px",
sortable: true,
groupable: true,
hidden: narrow,
type: "overflow",
title: this.hass.localize("ui.panel.config.automation.picker.state"),
template: (automation) =>
narrow
? automation.formatted_state
: html`
<ha-switch
@click=${stopPropagation}
@change=${this._handleSwitchToggle}
.automation=${automation}
.checked=${automation.state === "on"}
></ha-switch>
`,
template: (automation) => html`
<ha-switch
@click=${stopPropagation}
@change=${this._handleSwitchToggle}
.automation=${automation}
.checked=${automation.state === "on"}
></ha-switch>
`,
},
actions: {
lastFixed: true,
@@ -33,16 +33,11 @@ import type { ConfigEntry } from "../../../../data/config_entries";
import {
apiContext,
configEntriesContext,
connectionContext,
internationalizationContext,
labelsContext,
registriesContext,
statesContext,
} from "../../../../data/context";
import {
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
} from "../../../../data/device/device_registry";
import type { LabelRegistryEntry } from "../../../../data/label/label_registry";
import {
deviceMeetsTargetSelector,
@@ -94,16 +89,9 @@ export class HaAutomationRowTargets extends LitElement {
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@consume({ context: connectionContext, subscribe: true })
private _connection!: ContextType<typeof connectionContext>;
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
private _countCache = new Map<
string,
Promise<number | undefined> | number | undefined
@@ -120,43 +108,6 @@ export class HaAutomationRowTargets extends LitElement {
) {
this._rerenderCount = true;
}
if (
(changedProps.has("target") || changedProps.has("_registries")) &&
this._compositeSplits === undefined &&
!this._loadingCompositeSplits &&
this._hasMissingDevice()
) {
// A referenced device is missing from the registry; it might be a legacy
// composite device that was split. Fetch the split map so we can flag it.
this._loadCompositeSplits();
}
}
private _hasMissingDevice(): boolean {
const deviceIds = this.target?.device_id
? ensureArray(this.target.device_id)
: [];
return deviceIds.some(
(id) => !isTemplate(id) && !this._registries?.devices?.[id]
);
}
private async _loadCompositeSplits() {
if (!this._api || !this._connection) {
return;
}
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits({
connection: this._connection.connection,
callWS: this._api.callWS,
});
} catch (_err) {
this._compositeSplits = {};
} finally {
this._loadingCompositeSplits = false;
}
}
protected updated(changedProps: PropertyValues) {
@@ -391,8 +342,7 @@ export class HaAutomationRowTargets extends LitElement {
error = false,
targetId?: string,
targetType?: string,
countTemplate: unknown = nothing,
title?: string
countTemplate: unknown = nothing
) {
if (!this.interactive || !targetId || !targetType) {
return html`<div
@@ -401,7 +351,6 @@ export class HaAutomationRowTargets extends LitElement {
warning,
error,
})}
title=${title ?? nothing}
.targetId=${targetId}
.targetType=${targetType}
.label=${label}
@@ -417,7 +366,6 @@ export class HaAutomationRowTargets extends LitElement {
warning,
error,
})}
title=${title ?? nothing}
.targetId=${targetId}
.targetType=${targetType}
.label=${label}
@@ -437,7 +385,6 @@ export class HaAutomationRowTargets extends LitElement {
let icon: string | undefined;
let label: string;
let warning = false;
let title: string | undefined;
let badgeTargetId: string | undefined = targetId;
let badgeTargetType: string | undefined = targetType;
let countTemplate: unknown = nothing;
@@ -461,28 +408,17 @@ export class HaAutomationRowTargets extends LitElement {
const exists = this._checkTargetExists(targetType, targetId);
if (!exists) {
icon = mdiAlert;
label = getTargetText(
this._registries,
this._states,
this._i18n.localize,
targetType,
targetId,
this._getLabel
);
warning = true;
badgeTargetId = undefined;
badgeTargetType = undefined;
if (targetType === "device" && this._compositeSplits?.[targetId]) {
// The device was replaced by one or more split devices; make clear
// this reference needs to be updated, distinct from "unknown device".
label = this._i18n.localize(
"ui.panel.config.automation.editor.target_summary.device_replaced"
);
title = this._i18n.localize(
"ui.panel.config.automation.editor.target_summary.device_replaced_description"
);
} else {
label = getTargetText(
this._registries,
this._states,
this._i18n.localize,
targetType,
targetId,
this._getLabel
);
}
} else {
label = getTargetText(
this._registries,
@@ -520,7 +456,6 @@ export class HaAutomationRowTargets extends LitElement {
targetType: badgeTargetType,
label,
}}
title=${title ?? nothing}
class=${classMap({
warning,
})}
@@ -535,8 +470,7 @@ export class HaAutomationRowTargets extends LitElement {
false,
badgeTargetId,
badgeTargetType,
countTemplate,
title
countTemplate
);
}
@@ -15,16 +15,11 @@ import type {
DeviceTrigger,
} from "../../../../../data/device/device_automation";
import {
deviceAutomationEditorMode,
deviceAutomationsEqual,
fetchDeviceTriggerCapabilities,
localizeExtraFieldsComputeHelperCallback,
localizeExtraFieldsComputeLabelCallback,
} from "../../../../../data/device/device_automation";
import {
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
} from "../../../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../../types";
@@ -40,10 +35,6 @@ export class HaDeviceTrigger extends LitElement {
@state() private _capabilities?: DeviceCapabilities;
@state() private _compositeSplits?: DeviceCompositeSplits;
private _loadingCompositeSplits = false;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
@@ -73,19 +64,14 @@ export class HaDeviceTrigger extends LitElement {
}
);
public shouldUpdate(_changedProperties: PropertyValues<this>) {
const mode = deviceAutomationEditorMode(
this.hass,
this.trigger.device_id,
this._compositeSplits
);
if (mode === "loading") {
// The device is missing; wait for the composite split map before deciding
// whether it is a replaced device (editable) or genuinely unknown (YAML).
this._loadCompositeSplits();
return false;
public shouldUpdate(changedProperties: PropertyValues<this>) {
if (!changedProperties.has("trigger")) {
return true;
}
if (mode === "unknown-device") {
if (
this.trigger.device_id &&
!(this.trigger.device_id in this.hass.devices)
) {
fireEvent(
this,
"ui-mode-not-available",
@@ -100,20 +86,6 @@ export class HaDeviceTrigger extends LitElement {
return true;
}
private async _loadCompositeSplits() {
if (this._loadingCompositeSplits) {
return;
}
this._loadingCompositeSplits = true;
try {
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
} catch (_err) {
this._compositeSplits = {};
} finally {
this._loadingCompositeSplits = false;
}
}
protected render() {
const deviceId = this._deviceId || this.trigger.device_id;
-9
View File
@@ -22,7 +22,6 @@ import {
mdiPuzzle,
mdiRadioTower,
mdiRemote,
mdiRenameOutline,
mdiRobot,
mdiScrewdriver,
mdiScriptText,
@@ -495,14 +494,6 @@ export const configSections: Record<string, PageNavigation[]> = {
component: "backup",
adminOnly: true,
},
{
path: "/config/entity-id-format",
translationKey: "entity_id_format",
iconPath: mdiRenameOutline,
iconColor: "#24a5cb",
core: true,
adminOnly: true,
},
{
path: "/config/analytics",
translationKey: "analytics",
@@ -1,215 +0,0 @@
import { consume, type ContextType } from "@lit/context";
import { mdiRestore } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { computeEntityIdFormatExample } from "../../../common/entity/compute_entity_id_format_example";
import type { LocalizeFunc } from "../../../common/translations/localize";
import type { HaProgressButton } from "../../../components/buttons/ha-progress-button";
import "../../../components/buttons/ha-progress-button";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-svg-icon";
import { apiContext, configContext } from "../../../data/context";
import {
fetchEntityRegistrySettings,
updateEntityRegistrySettings,
} from "../../../data/entity/entity_registry_settings";
import {
DEFAULT_ENTITY_ID_FORMAT,
isDefaultEntityIdFormat,
type EntityIdFormat,
type EntityIdPart,
} from "../../../data/entity_id_format";
import { haStyle } from "../../../resources/styles";
import { documentationUrl } from "../../../util/documentation-url";
import "./ha-entity-id-format-editor";
const EXAMPLE_DOMAIN = "sensor";
@customElement("ha-config-entity-id-format")
export class HaConfigEntityIdFormat extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@state()
@consume({ context: configContext, subscribe: true })
private _config!: ContextType<typeof configContext>;
@state() private _format?: EntityIdFormat;
@state() private _error?: string;
protected async firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
try {
const settings = await fetchEntityRegistrySettings(this._api);
this._format = settings.entity_id_parts ?? DEFAULT_ENTITY_ID_FORMAT;
} catch (err: any) {
this._error = err.message;
}
}
private _examples: Record<EntityIdPart, string> = {
area: "Living room",
device: "Thermostat",
entity: "Temperature",
floor: "Ground floor",
};
protected render() {
return html`
<ha-card
outlined
.header=${this._localize(
"ui.panel.config.entity_id_format.card.header"
)}
>
<div class="card-content">
${
this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing
}
<p class="description">
${this._localize(
"ui.panel.config.entity_id_format.card.description"
)}
<a
href=${documentationUrl(
this._config,
"/docs/configuration/customizing-devices/"
)}
target="_blank"
rel="noreferrer"
>${this._localize(
"ui.panel.config.entity_id_format.card.learn_more"
)}</a
>
</p>
${
this._format
? html`
<ha-entity-id-format-editor
.value=${this._format}
.label=${this._localize(
"ui.panel.config.entity_id_format.card.editor.label"
)}
@value-changed=${this._formatChanged}
></ha-entity-id-format-editor>
${this._renderPreview()}
`
: nothing
}
</div>
<div class="card-actions">
<ha-button
appearance="plain"
@click=${this._reset}
.disabled=${!this._format || isDefaultEntityIdFormat(this._format)}
>
<ha-svg-icon slot="start" .path=${mdiRestore}></ha-svg-icon>
${this._localize("ui.panel.config.entity_id_format.card.reset")}
</ha-button>
<ha-progress-button
appearance="filled"
@click=${this._save}
.disabled=${!this._format}
>
${this._localize("ui.common.save")}
</ha-progress-button>
</div>
</ha-card>
`;
}
private _renderPreview() {
const example = computeEntityIdFormatExample(this._format!, this._examples);
return html`
<div class="preview">
<span class="preview-label">
${this._localize("ui.panel.config.entity_id_format.card.preview")}
</span>
<code>${EXAMPLE_DOMAIN}.${example}</code>
</div>
`;
}
private _formatChanged(ev: CustomEvent) {
this._format = ev.detail.value;
}
private _reset() {
this._format = [...DEFAULT_ENTITY_ID_FORMAT];
}
private async _save(ev: Event) {
const button = ev.currentTarget as HaProgressButton;
if (button.progress) {
return;
}
button.progress = true;
this._error = undefined;
try {
const format = this._format!;
const settings = await updateEntityRegistrySettings(this._api, {
entity_id_parts: isDefaultEntityIdFormat(format) ? null : format,
});
this._format = settings.entity_id_parts ?? DEFAULT_ENTITY_ID_FORMAT;
button.actionSuccess();
} catch (err: any) {
button.actionError();
this._error = err.message;
} finally {
button.progress = false;
}
}
static styles = [
haStyle,
css`
:host {
display: block;
}
.description {
margin-top: 0;
color: var(--secondary-text-color);
}
.preview {
display: flex;
flex-direction: column;
gap: var(--ha-space-1);
margin-top: var(--ha-space-5);
}
.preview-label {
font-weight: 500;
}
.preview code {
display: block;
padding: var(--ha-space-3);
border-radius: var(--ha-border-radius-md);
background-color: var(--secondary-background-color);
font-family: var(--ha-font-family-code);
overflow-wrap: anywhere;
}
.card-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-entity-id-format": HaConfigEntityIdFormat;
}
}
@@ -1,51 +0,0 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../types";
import "./ha-config-entity-id-format";
@customElement("ha-config-section-entity-id-format")
export class HaConfigSectionEntityIdFormat extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
protected render() {
return html`
<hass-subpage
back-path="/config/system"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize(
"ui.panel.config.entity_id_format.caption"
)}
>
<div class="content">
<ha-config-entity-id-format></ha-config-entity-id-format>
</div>
</hass-subpage>
`;
}
static styles = css`
.content {
padding: var(--ha-space-7) var(--ha-space-5) 0;
max-width: 1040px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--ha-space-5);
}
ha-config-entity-id-format {
max-width: 600px;
margin: 0 auto;
width: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-section-entity-id-format": HaConfigSectionEntityIdFormat;
}
}
@@ -1,348 +0,0 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { fireEvent } from "../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/chips/ha-assist-chip";
import "../../../components/chips/ha-chip-set";
import "../../../components/chips/ha-input-chip";
import "../../../components/ha-combo-box-item";
import "../../../components/ha-generic-picker";
import type { HaGenericPicker } from "../../../components/ha-generic-picker";
import type { PickerComboBoxItem } from "../../../components/ha-picker-combo-box";
import "../../../components/ha-sortable";
import "../../../components/ha-svg-icon";
import type {
EntityIdFormat,
EntityIdPart,
} from "../../../data/entity_id_format";
import type { ValueChangedEvent } from "../../../types";
const STRUCTURAL_TYPES = ["area", "device", "entity", "floor"] as const;
const REQUIRED_TYPES: readonly EntityIdPart[] = ["device", "entity"];
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
<ha-combo-box-item type="button" compact>
<span slot="headline">${item.primary}</span>
</ha-combo-box-item>
`;
const encodeType = (type: EntityIdPart) => `___${type}___`;
const decodeType = (value: string): EntityIdPart | undefined => {
const type =
value.startsWith("___") && value.endsWith("___")
? value.slice(3, -3)
: value;
return (STRUCTURAL_TYPES as readonly string[]).includes(type)
? (type as EntityIdPart)
: undefined;
};
@customElement("ha-entity-id-format-editor")
export class HaEntityIdFormatEditor extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false }) public value: EntityIdFormat = [];
@property() public label?: string;
@property({ type: Boolean, reflect: true }) public disabled = false;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@state() private _editIndex?: number;
protected render() {
return html`
<div class="container">
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-generic-picker
.disabled=${this.disabled}
.getItems=${this._getFilteredItems}
.rowRenderer=${rowRenderer}
.value=${this._getPickerValue()}
@value-changed=${this._pickerValueChanged}
.searchLabel=${this._localize(
"ui.panel.config.entity_id_format.card.editor.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.value,
(item) => item,
(item: EntityIdPart, idx) => {
const label = this._formatType(item);
if (REQUIRED_TYPES.includes(item)) {
return html`
<ha-assist-chip
filled
class="required"
.label=${label}
.disabled=${this.disabled}
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</ha-assist-chip>
`;
}
return html`
<ha-input-chip
data-idx=${idx}
@remove=${this._removeItem}
@click=${this._editItem}
.label=${label}
.selected=${!this.disabled}
.disabled=${this.disabled}
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</ha-input-chip>
`;
}
)}
${
this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this._localize(
"ui.panel.config.entity_id_format.card.editor.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>
</div>
`;
}
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();
}
private _moveItem(ev: CustomEvent) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const newValue = this.value.concat();
const element = newValue.splice(oldIndex, 1)[0];
newValue.splice(newIndex, 0, element);
this._setValue(newValue);
}
private _removeItem(ev: Event) {
ev.stopPropagation();
const value = [...this.value];
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 type = decodeType(value);
if (!type) {
return;
}
const newValue = [...this.value];
if (this._editIndex != null) {
newValue[this._editIndex] = type;
this._editIndex = undefined;
} else {
newValue.push(type);
}
this._setValue(newValue);
if (this._picker) {
this._picker.value = undefined;
}
}
private _setValue(value: EntityIdFormat) {
this.value = value;
fireEvent(this, "value-changed", { value });
}
private _formatType = (type: EntityIdPart) =>
this._localize(`ui.components.entity.entity-name-picker.types.${type}`);
private _getItems = memoizeOne(
(localize: LocalizeFunc): PickerComboBoxItem[] =>
STRUCTURAL_TYPES.map((type) => {
const primary = localize(
`ui.components.entity.entity-name-picker.types.${type}`
);
const id = encodeType(type);
return {
id,
primary,
search_labels: { primary, id },
sorting_label: primary,
};
})
);
private _getPickerValue(): string | undefined {
if (this._editIndex != null) {
const item = this.value[this._editIndex];
return item ? encodeType(item) : undefined;
}
return undefined;
}
private _getFilteredItems = (): PickerComboBoxItem[] => {
const items = this._getItems(this._localize);
const currentValue =
this._editIndex != null
? encodeType(this.value[this._editIndex])
: undefined;
const usedValues = new Set(this.value.map((item) => encodeType(item)));
return items.filter(
(item) => !usedValues.has(item.id) || item.id === currentValue
);
};
static styles = css`
:host {
position: relative;
width: 100%;
}
.container {
display: flex;
flex-direction: column;
gap: var(--ha-space-2);
}
label {
display: block;
font-weight: 500;
}
ha-generic-picker {
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);
}
.field:after {
display: block;
content: "";
position: absolute;
pointer-events: none;
bottom: 0;
left: 0;
right: 0;
height: 1px;
width: 100%;
background-color: var(
--mdc-text-field-idle-line-color,
rgba(0, 0, 0, 0.42)
);
}
:host([disabled]) .field:after {
background-color: var(
--mdc-text-field-disabled-line-color,
rgba(0, 0, 0, 0.42)
);
}
.field:focus-within:after {
height: 2px;
background-color: var(--mdc-theme-primary);
}
ha-chip-set {
padding: var(--ha-space-3);
}
ha-assist-chip.required {
--ha-assist-chip-filled-container-color: rgba(
var(--rgb-primary-text-color),
0.15
);
}
.add {
order: 1;
}
.sortable-fallback {
display: none;
opacity: 0;
}
.sortable-ghost {
opacity: 0.4;
}
.sortable-drag {
cursor: grabbing;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-entity-id-format-editor": HaEntityIdFormatEditor;
}
}
@@ -1,196 +0,0 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { computeDeviceNameDisplay } from "../../../../common/entity/compute_device_name";
import { caseInsensitiveStringCompare } from "../../../../common/string/compare";
import "../../../../components/ha-card";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-list-item";
import type { ConfigEntry } from "../../../../data/config_entries";
import type { DeviceRegistryEntry } from "../../../../data/device/device_registry";
import { fetchLinkedDevices } from "../../../../data/device/device_registry";
import { domainToName } from "../../../../data/integration";
import type { HomeAssistant } from "../../../../types";
import { brandsUrl } from "../../../../util/brands-url";
@customElement("ha-device-linked-devices-card")
export class HaDeviceLinkedDevicesCard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public deviceId!: string;
@property({ attribute: false }) public entries!: ConfigEntry[];
@state() private _linkedDeviceIds: string[] = [];
private _entryLookup = memoizeOne(
(entries: ConfigEntry[]): Record<string, ConfigEntry> => {
const lookup: Record<string, ConfigEntry> = {};
for (const entry of entries) {
lookup[entry.entry_id] = entry;
}
return lookup;
}
);
private _linkedDevices = memoizeOne(
(
linkedDeviceIds: string[],
devices: Record<string, DeviceRegistryEntry>
): DeviceRegistryEntry[] =>
linkedDeviceIds
.map((id) => devices[id])
.filter((device): device is DeviceRegistryEntry => Boolean(device))
.sort((d1, d2) =>
caseInsensitiveStringCompare(
computeDeviceNameDisplay(d1, this.hass.localize, this.hass.states),
computeDeviceNameDisplay(d2, this.hass.localize, this.hass.states),
this.hass.locale.language
)
)
);
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (changedProps.has("deviceId")) {
this._fetchLinkedDevices();
}
}
protected render() {
const linkedDevices = this._linkedDevices(
this._linkedDeviceIds,
this.hass.devices
);
if (linkedDevices.length === 0) {
return nothing;
}
const entryLookup = this._entryLookup(this.entries ?? []);
return html`
<ha-card>
<h1 class="card-header">
${this.hass.localize("ui.panel.config.devices.linked_devices.heading")}
</h1>
<div class="card-content">
${this.hass.localize(
"ui.panel.config.devices.linked_devices.description"
)}
</div>
${linkedDevices.map((linkedDevice) => {
const domain = this._deviceDomain(linkedDevice, entryLookup);
const integrationName = domain
? domainToName(this.hass.localize, domain)
: undefined;
return html`
<a href=${`/config/devices/device/${linkedDevice.id}`}>
<ha-list-item
graphic=${domain ? "icon" : nothing}
hasMeta
.twoline=${!!integrationName}
>
${
domain
? html`<img
slot="graphic"
loading="lazy"
alt=""
src=${brandsUrl(
{
domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>`
: nothing
}
${computeDeviceNameDisplay(
linkedDevice,
this.hass.localize,
this.hass.states
)}
${
integrationName
? html`<span slot="secondary">${integrationName}</span>`
: nothing
}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>
`;
})}
</ha-card>
`;
}
private _deviceDomain(
device: DeviceRegistryEntry,
entryLookup: Record<string, ConfigEntry>
): string | undefined {
const entryId =
device.primary_config_entry ?? device.config_entries[0] ?? undefined;
const entry = entryId ? entryLookup[entryId] : undefined;
return entry?.domain;
}
private async _fetchLinkedDevices() {
const deviceId = this.deviceId;
if (!deviceId) {
this._linkedDeviceIds = [];
return;
}
let linkedDeviceIds: string[];
try {
linkedDeviceIds = await fetchLinkedDevices(this.hass, deviceId);
} catch (_err) {
linkedDeviceIds = [];
}
if (this.deviceId !== deviceId) {
// Device changed while fetching, ignore stale result.
return;
}
this._linkedDeviceIds = linkedDeviceIds;
}
static styles = css`
:host {
display: block;
}
ha-card {
overflow: hidden;
}
.card-header {
padding-bottom: 0;
}
.card-content {
color: var(--secondary-text-color);
padding-bottom: var(--ha-space-2);
}
a {
text-decoration: none;
color: var(--primary-text-color);
}
ha-list-item img {
width: 24px;
height: 24px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-device-linked-devices-card": HaDeviceLinkedDevicesCard;
}
}
@@ -102,7 +102,6 @@ import { fileDownload } from "../../../util/file_download";
import "../../logbook/ha-logbook";
import "./device-detail/ha-device-entities-card";
import "./device-detail/ha-device-info-card";
import "./device-detail/ha-device-linked-devices-card";
import "./device-detail/ha-device-via-devices-card";
import { showDeviceAddToDialog } from "./device-detail/show-dialog-device-add-to";
import {
@@ -893,11 +892,6 @@ export class HaConfigDevicePage extends LitElement {
: ""
}
</ha-device-info-card>
<ha-device-linked-devices-card
.hass=${this.hass}
.deviceId=${this.deviceId}
.entries=${this.entries}
></ha-device-linked-devices-card>
`;
const entitiesColumn = html`
@@ -1,6 +1,6 @@
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/entity/ha-statistic-picker";
import "../../../../components/ha-button";
@@ -29,11 +29,10 @@ import "./ha-energy-power-config";
import {
buildPowerExcludeList,
getInitialPowerConfig,
getPowerHelperEntityId,
getPowerTypeFromConfig,
isPowerConfigValid,
type HaEnergyPowerConfig,
type PowerType,
} from "./power-config";
} from "./ha-energy-power-config";
import type { EnergySettingsBatteryDialogParams } from "./show-dialogs-energy";
import type { HaInput } from "../../../../components/input/ha-input";
@@ -68,6 +67,8 @@ export class DialogEnergyBatterySettings
@state() private _error?: string;
@query("ha-energy-power-config") private _powerConfigEl?: HaEnergyPowerConfig;
private _excludeList?: string[];
private _excludeListPower?: string[];
@@ -228,10 +229,6 @@ export class DialogEnergyBatterySettings
.powerType=${this._powerType}
.powerConfig=${this._powerConfig}
.excludeList=${this._excludeListPower}
.helperEntityId=${getPowerHelperEntityId(
this._params.source,
this._powerConfig
)}
.localizeBaseKey=${"ui.panel.config.energy.battery.dialog"}
@power-config-changed=${this._handlePowerConfigChanged}
></ha-energy-power-config>
@@ -298,7 +295,11 @@ export class DialogEnergyBatterySettings
}
// Check power config validity
return isPowerConfigValid(this._powerType, this._powerConfig);
if (this._powerConfigEl && !this._powerConfigEl.isValid()) {
return false;
}
return true;
}
private async _updateMetadata(statId: string) {
@@ -373,7 +374,6 @@ export class DialogEnergyBatterySettings
if (this._source.capacity === undefined) {
delete this._source.capacity;
}
this._updateFormDirtyState();
}
private async _save() {
@@ -1,6 +1,6 @@
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/entity/ha-entity-picker";
import "../../../../components/entity/ha-statistic-picker";
@@ -33,11 +33,10 @@ import "./ha-energy-power-config";
import {
buildPowerExcludeList,
getInitialPowerConfig,
getPowerHelperEntityId,
getPowerTypeFromConfig,
isPowerConfigValid,
type HaEnergyPowerConfig,
type PowerType,
} from "./power-config";
} from "./ha-energy-power-config";
import type { EnergySettingsGridDialogParams } from "./show-dialogs-energy";
import type { HaInput } from "../../../../components/input/ha-input";
@@ -78,6 +77,8 @@ export class DialogEnergyGridSettings
@state() private _error?: string;
@query("ha-energy-power-config") private _powerConfigEl?: HaEnergyPowerConfig;
private _excludeList?: string[];
private _excludeListPower?: string[];
@@ -470,10 +471,6 @@ export class DialogEnergyGridSettings
.powerType=${this._powerType}
.powerConfig=${this._powerConfig}
.excludeList=${this._excludeListPower}
.helperEntityId=${getPowerHelperEntityId(
this._params.source,
this._powerConfig
)}
.localizeBaseKey=${"ui.panel.config.energy.grid.dialog"}
@power-config-changed=${this._handlePowerConfigChanged}
></ha-energy-power-config>
@@ -511,8 +508,10 @@ export class DialogEnergyGridSettings
}
// Check power config validity (if power is configured)
if (hasPower && !isPowerConfigValid(this._powerType, this._powerConfig)) {
return false;
if (hasPower) {
if (this._powerConfigEl && !this._powerConfigEl.isValid()) {
return false;
}
}
return true;
@@ -1,24 +1,99 @@
import { mdiInformationOutline } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import type { LocalizeKeys } from "../../../../common/translations/localize";
import "../../../../components/entity/ha-statistic-picker";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-tooltip";
import "../../../../components/radio/ha-radio-group";
import type { HaRadioGroup } from "../../../../components/radio/ha-radio-group";
import "../../../../components/radio/ha-radio-option";
import type { PowerConfig } from "../../../../data/energy";
import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
import { buttonLinkStyle } from "../../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
import type { PowerType } from "./power-config";
export type PowerType = "none" | "standard" | "inverted" | "two_sensors";
const powerUnitClasses = ["power"];
/**
* Extracts the power type from a PowerConfig object.
*/
export function getPowerTypeFromConfig(
powerConfig?: PowerConfig,
statRate?: string
): PowerType {
if (powerConfig) {
if (powerConfig.stat_rate_inverted) {
return "inverted";
}
if (powerConfig.stat_rate_from || powerConfig.stat_rate_to) {
return "two_sensors";
}
if (powerConfig.stat_rate) {
return "standard";
}
} else if (statRate) {
// Legacy format - treat as standard
return "standard";
}
return "none";
}
/**
* Creates an initial PowerConfig from existing config or legacy stat_rate.
*/
export function getInitialPowerConfig(
powerConfig?: PowerConfig,
statRate?: string
): PowerConfig {
if (powerConfig) {
return { ...powerConfig };
}
if (statRate) {
return { stat_rate: statRate };
}
return {};
}
/**
* Builds an exclude list for power statistics from existing sources.
*/
export function buildPowerExcludeList(
sources: { stat_rate?: string; power_config?: PowerConfig }[],
currentPowerConfig: PowerConfig,
currentStatRate?: string
): string[] {
const powerIds: string[] = [];
sources.forEach((entry) => {
if (entry.stat_rate) powerIds.push(entry.stat_rate);
if (entry.power_config) {
if (entry.power_config.stat_rate) {
powerIds.push(entry.power_config.stat_rate);
}
if (entry.power_config.stat_rate_inverted) {
powerIds.push(entry.power_config.stat_rate_inverted);
}
if (entry.power_config.stat_rate_from) {
powerIds.push(entry.power_config.stat_rate_from);
}
if (entry.power_config.stat_rate_to) {
powerIds.push(entry.power_config.stat_rate_to);
}
}
});
const currentPowerIds = [
currentPowerConfig.stat_rate,
currentPowerConfig.stat_rate_inverted,
currentPowerConfig.stat_rate_from,
currentPowerConfig.stat_rate_to,
currentStatRate,
].filter(Boolean) as string[];
return powerIds.filter((id) => !currentPowerIds.includes(id));
}
declare global {
interface HASSDomEvents {
"power-config-changed": { powerType: PowerType; powerConfig: PowerConfig };
@@ -35,14 +110,10 @@ export class HaEnergyPowerConfig extends LitElement {
@property({ attribute: false }) public excludeList?: string[];
/** Entity id of the power sensor generated for the saved config, if any. */
@property({ attribute: false }) public helperEntityId?: string;
/**
* Base key for localization lookups.
* Should include keys for: sensor_type, sensor_type_para, type_none, type_standard,
* type_inverted, type_two_sensors, power, power_helper, type_inverted_description,
* power_from, power_to, helper_sensor_note, helper_sensor_in_use
* Should include keys for: sensor_type, type_none, type_standard, type_inverted,
* type_two_sensors, power, power_helper, type_inverted_description, power_from, power_to
*/
@property({ attribute: false }) public localizeBaseKey =
"ui.panel.config.energy.battery.dialog";
@@ -93,13 +164,11 @@ export class HaEnergyPowerConfig extends LitElement {
${this.hass.localize(
`${this.localizeBaseKey}.type_inverted` as LocalizeKeys
)}
${this._renderHelperSensorNote("inverted")}
</ha-radio-option>
<ha-radio-option value="two_sensors">
${this.hass.localize(
`${this.localizeBaseKey}.type_two_sensors` as LocalizeKeys
)}
${this._renderHelperSensorNote("two_sensors")}
</ha-radio-option>
</ha-radio-group>
@@ -174,50 +243,9 @@ export class HaEnergyPowerConfig extends LitElement {
`
: nothing
}
${this._renderHelperSensorInUse()}
`;
}
private _renderHelperSensorNote(powerType: PowerType) {
const id = `helper-sensor-note-${powerType}`;
return html`
<ha-svg-icon
id=${id}
tabindex="0"
class="note-icon"
.path=${mdiInformationOutline}
@click=${stopPropagation}
></ha-svg-icon>
<ha-tooltip .for=${id} placement="top">
${this.hass.localize(
`${this.localizeBaseKey}.helper_sensor_note` as LocalizeKeys
)}
</ha-tooltip>
`;
}
private _renderHelperSensorInUse() {
if (!this.helperEntityId) {
return nothing;
}
return html`
<p class="helper-sensor-in-use">
${this.hass.localize(
`${this.localizeBaseKey}.helper_sensor_in_use` as LocalizeKeys,
{
entity: html`<button class="link" @click=${this._showHelperSensor}>
${this.helperEntityId}
</button>`,
}
)}
</p>
`;
}
private _showHelperSensor() {
fireEvent(this, "hass-more-info", { entityId: this.helperEntityId! });
}
private _handlePowerTypeChanged(ev: Event) {
const newPowerType = (ev.currentTarget as HaRadioGroup).value as PowerType;
// Clear power config when switching types
@@ -261,46 +289,48 @@ export class HaEnergyPowerConfig extends LitElement {
});
}
static readonly styles: CSSResultGroup = [
buttonLinkStyle,
css`
ha-statistic-picker {
display: block;
margin-bottom: var(--ha-space-4);
}
ha-statistic-picker:last-of-type {
margin-bottom: 0;
}
ha-radio-group {
margin-bottom: var(--ha-space-4);
}
.power-section-label {
margin-top: var(--ha-space-4);
margin-bottom: var(--ha-space-2);
}
.power-section-description {
margin-top: 0;
margin-bottom: var(--ha-space-2);
color: var(--secondary-text-color);
font-size: 0.875em;
}
.note-icon {
margin-inline-start: var(--ha-space-1);
color: var(--secondary-text-color);
--mdc-icon-size: 18px;
}
.helper-sensor-in-use {
margin: var(--ha-space-2) 0 0 0;
color: var(--secondary-text-color);
font-size: 0.875em;
}
.helper-sensor-in-use button.link {
color: var(--primary-color);
/* entity ids offer no break opportunities of their own */
overflow-wrap: anywhere;
}
`,
];
/**
* Validates that the power config is complete for the selected type.
*/
public isValid(): boolean {
switch (this.powerType) {
case "none":
return true;
case "standard":
return !!this.powerConfig.stat_rate;
case "inverted":
return !!this.powerConfig.stat_rate_inverted;
case "two_sensors":
return (
!!this.powerConfig.stat_rate_from && !!this.powerConfig.stat_rate_to
);
default:
return false;
}
}
static readonly styles: CSSResultGroup = css`
ha-statistic-picker {
display: block;
margin-bottom: var(--ha-space-4);
}
ha-statistic-picker:last-of-type {
margin-bottom: 0;
}
ha-radio-group {
margin-bottom: var(--ha-space-4);
}
.power-section-label {
margin-top: var(--ha-space-4);
margin-bottom: var(--ha-space-2);
}
.power-section-description {
margin-top: 0;
margin-bottom: var(--ha-space-2);
color: var(--secondary-text-color);
font-size: 0.875em;
}
`;
}
declare global {
@@ -1,127 +0,0 @@
import type { PowerConfig } from "../../../../data/energy";
import { deepEqual } from "../../../../common/util/deep-equal";
export type PowerType = "none" | "standard" | "inverted" | "two_sensors";
/**
* Extracts the power type from a PowerConfig object.
*/
export function getPowerTypeFromConfig(
powerConfig?: PowerConfig,
statRate?: string
): PowerType {
if (powerConfig) {
if (powerConfig.stat_rate_inverted) {
return "inverted";
}
if (powerConfig.stat_rate_from || powerConfig.stat_rate_to) {
return "two_sensors";
}
if (powerConfig.stat_rate) {
return "standard";
}
} else if (statRate) {
// Legacy format - treat as standard
return "standard";
}
return "none";
}
/**
* Creates an initial PowerConfig from existing config or legacy stat_rate.
*/
export function getInitialPowerConfig(
powerConfig?: PowerConfig,
statRate?: string
): PowerConfig {
if (powerConfig) {
return { ...powerConfig };
}
if (statRate) {
return { stat_rate: statRate };
}
return {};
}
/**
* Checks that the power config is complete for the selected power type.
*/
export function isPowerConfigValid(
powerType: PowerType,
powerConfig: PowerConfig
): boolean {
switch (powerType) {
case "none":
return true;
case "standard":
return !!powerConfig.stat_rate;
case "inverted":
return !!powerConfig.stat_rate_inverted;
case "two_sensors":
return !!powerConfig.stat_rate_from && !!powerConfig.stat_rate_to;
default:
return false;
}
}
/**
* Builds an exclude list for power statistics from existing sources.
*/
export function buildPowerExcludeList(
sources: { stat_rate?: string; power_config?: PowerConfig }[],
currentPowerConfig: PowerConfig,
currentStatRate?: string
): string[] {
const powerIds: string[] = [];
sources.forEach((entry) => {
if (entry.stat_rate) powerIds.push(entry.stat_rate);
if (entry.power_config) {
if (entry.power_config.stat_rate) {
powerIds.push(entry.power_config.stat_rate);
}
if (entry.power_config.stat_rate_inverted) {
powerIds.push(entry.power_config.stat_rate_inverted);
}
if (entry.power_config.stat_rate_from) {
powerIds.push(entry.power_config.stat_rate_from);
}
if (entry.power_config.stat_rate_to) {
powerIds.push(entry.power_config.stat_rate_to);
}
}
});
const currentPowerIds = [
currentPowerConfig.stat_rate,
currentPowerConfig.stat_rate_inverted,
currentPowerConfig.stat_rate_from,
currentPowerConfig.stat_rate_to,
currentStatRate,
].filter(Boolean) as string[];
return powerIds.filter((id) => !currentPowerIds.includes(id));
}
/**
* Returns the entity id of the power sensor the backend generated for a saved
* source, if any. Inverted and two sensor configs get such a helper, its entity
* id is stored in `stat_rate`. Returns nothing while the config differs from the
* saved one, as the helper doesn't match the edited config yet.
*/
export function getPowerHelperEntityId(
source: { stat_rate?: string; power_config?: PowerConfig } | undefined,
currentPowerConfig: PowerConfig
): string | undefined {
if (!source?.stat_rate || !source.power_config) {
return undefined;
}
const savedPowerType = getPowerTypeFromConfig(source.power_config);
if (savedPowerType !== "inverted" && savedPowerType !== "two_sensors") {
return undefined;
}
if (!deepEqual(source.power_config, currentPowerConfig)) {
return undefined;
}
return source.stat_rate;
}
-4
View File
@@ -171,10 +171,6 @@ class HaPanelConfig extends HassRouterPage {
tag: "ha-config-section-ai-tasks",
load: () => import("./core/ha-config-section-ai-tasks"),
},
"entity-id-format": {
tag: "ha-config-section-entity-id-format",
load: () => import("./core/ha-config-section-entity-id-format"),
},
zha: {
tag: "zha-config-dashboard-router",
load: () =>
@@ -397,20 +397,8 @@ class HaConfigHttpForm extends LitElement {
this._error = undefined;
this._fieldErrors = {};
this._showNoChanges = false;
// Drop empty entries from multi-value fields, and omit the field entirely
// once it is empty so the backend applies its default. Otherwise a cleared
// "IP address to bind to" would submit [""] / [], which binds to nothing.
const config = Object.fromEntries(
Object.entries(this._config).map(([key, value]) => {
if (Array.isArray(value)) {
const filtered = value.filter(Boolean);
return [key, filtered.length ? filtered : undefined];
}
return [key, value];
})
) as HttpConfig;
try {
const result = await saveHttpConfig(this.hass, config);
const result = await saveHttpConfig(this.hass, this._config);
if (!result.restart) {
this._showNoChanges = true;
}
+1 -4
View File
@@ -2,7 +2,6 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { navigate } from "../../common/navigate";
import { sanitizeNavigationPath } from "../../common/url/sanitize-navigation-path";
import "../../components/ha-alert";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-menu-button";
@@ -162,9 +161,7 @@ class PanelEnergy extends LitElement {
.route=${this.route}
.panel=${this.panel}
.backButton=${this._searchParms.has("historyBack")}
.backPath=${
sanitizeNavigationPath(this._searchParms.get("backPath")) || "/"
}
.backPath=${this._searchParms.get("backPath") || "/"}
@reload-energy-panel=${this._reloadConfig}
>
</hui-root>
@@ -11,7 +11,6 @@ import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-control-button";
import "../../../components/ha-control-button-group";
import { apiContext, servicesContext } from "../../../data/context";
import { forwardHaptic } from "../../../data/haptics";
import {
hasRequiredScriptFieldsForServices,
requiredScriptFieldsFilledForServices,
@@ -97,8 +96,6 @@ class HuiButtonCardFeature extends LitElement implements LovelaceCardFeature {
: {}),
};
forwardHaptic(this, "light");
this._api.callService(domain, service, serviceData);
}
@@ -1,4 +1,7 @@
import { STATES_OFF } from "../../../../common/const";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { isTiltOnly } from "../../../../data/cover";
import { UNAVAILABLE, UNKNOWN } from "../../../../data/entity/entity";
import type { HomeAssistant, ServiceCallResponse } from "../../../../types";
import { turnOnOffEntity } from "./turn-on-off-entity";
@@ -6,6 +9,20 @@ export const toggleEntity = (
hass: HomeAssistant,
entityId: string
): Promise<ServiceCallResponse> => {
const turnOn = STATES_OFF.includes(hass.states[entityId].state);
const stateObj = hass.states[entityId];
// Tilt-only covers may not report a state to pick a direction from,
// let core pick one based on the tilt position instead.
if (
computeDomain(entityId) === "cover" &&
isTiltOnly(stateObj) &&
(stateObj.state === UNKNOWN || stateObj.state === UNAVAILABLE)
) {
return hass.callService("cover", "toggle_cover_tilt", {
entity_id: entityId,
});
}
const turnOn = STATES_OFF.includes(stateObj.state);
return turnOnOffEntity(hass, entityId, turnOn);
};
@@ -8,27 +8,31 @@ export const turnOnOffEntities = (
entityIds: string[],
turnOn = true
): void => {
const domainsToCall = {};
const callsToMake: Record<
string,
{ domain: string; service: string; entityIds: string[] }
> = {};
entityIds.forEach((entityId) => {
if (STATES_OFF.includes(hass.states[entityId].state) === turnOn) {
const stateObj = hass.states[entityId];
if (STATES_OFF.includes(stateObj.state) === turnOn) {
const stateDomain = computeDomain(entityId);
// Entities with non-standard toggle action need separate calls
const serviceDomain =
const domain =
getToggleAction(stateDomain, true) !== "turn_on"
? stateDomain
: "homeassistant";
// The service can differ per entity, e.g. for tilt-only covers
const service = getToggleAction(domain, turnOn, stateObj);
if (!(serviceDomain in domainsToCall)) {
domainsToCall[serviceDomain] = [];
const key = `${domain}.${service}`;
if (!(key in callsToMake)) {
callsToMake[key] = { domain, service, entityIds: [] };
}
domainsToCall[serviceDomain].push(entityId);
callsToMake[key].entityIds.push(entityId);
}
});
Object.keys(domainsToCall).forEach((domain) => {
const service = getToggleAction(domain, turnOn);
const entities = domainsToCall[domain];
hass.callService(domain, service, { entity_id: entities });
Object.values(callsToMake).forEach(({ domain, service, entityIds: ids }) => {
hass.callService(domain, service, { entity_id: ids });
});
};
@@ -10,7 +10,7 @@ export const turnOnOffEntity = (
const stateDomain = computeDomain(entityId);
const serviceDomain = stateDomain === "group" ? "homeassistant" : stateDomain;
const service = getToggleAction(stateDomain, turnOn);
const service = getToggleAction(stateDomain, turnOn, hass.states[entityId]);
return hass.callService(serviceDomain, service, { entity_id: entityId });
};
+9 -13
View File
@@ -31,7 +31,6 @@ import { isNavigationClick } from "../../common/dom/is-navigation-click";
import { goBack, navigate } from "../../common/navigate";
import type { LocalizeKeys } from "../../common/translations/localize";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import { sanitizeNavigationPath } from "../../common/url/sanitize-navigation-path";
import {
addSearchParam,
extractSearchParamsObject,
@@ -894,12 +893,10 @@ class HUIRoot extends LitElement {
const curViewConfig =
typeof this._curView === "number" ? views[this._curView] : undefined;
const backPath = sanitizeNavigationPath(
curViewConfig?.back_path ?? this.backPath
);
if (backPath) {
navigate(backPath, { replace: true });
if (curViewConfig?.back_path != null) {
navigate(curViewConfig.back_path, { replace: true });
} else if (this.backPath) {
navigate(this.backPath, { replace: true });
} else if (history.length > 1) {
goBack();
} else if (!views[0].subview) {
@@ -921,12 +918,11 @@ class HUIRoot extends LitElement {
const curViewConfig =
typeof this._curView === "number" ? views[this._curView] : undefined;
const backPath = sanitizeNavigationPath(
curViewConfig?.back_path ?? this.backPath
);
if (backPath) {
return backPath;
if (curViewConfig?.back_path != null) {
return curViewConfig.back_path;
}
if (this.backPath) {
return this.backPath;
}
return curViewConfig?.subview ? this.route!.prefix : undefined;
}
+13 -51
View File
@@ -804,9 +804,6 @@
"device_not_found": "Device not found",
"entity_not_found": "Entity not found",
"label_not_found": "Label not found",
"replaced_device": "Replaced device",
"device_replaced": "Replaced by {count} {count, plural,\n one {device}\n other {devices}\n}",
"replace_device": "Replace",
"devices_count": "{count} {count, plural,\n one {device}\n other {devices}\n}",
"entities_count": "{count} {count, plural,\n one {entity}\n other {entities}\n}",
"target_details": "Target details",
@@ -919,16 +916,7 @@
"unnamed_device": "Unnamed device",
"no_area": "No area",
"placeholder": "Select a device",
"unknown": "Unknown device selected",
"device_replaced_count": "Replaced by {count} {count, plural,\n one {device}\n other {devices}\n}",
"device_replaced_by_one": "This device was replaced by {device}.",
"device_replaced_by_multiple": "This device was replaced by {count} devices.",
"replace_device": "Replace",
"replaced_dialog": {
"title": "Replace device",
"description": "This device was replaced by multiple devices. Choose which one to use.",
"recommended": "Recommended"
}
"unknown": "Unknown device selected"
},
"category-picker": {
"clear": "Clear",
@@ -4211,9 +4199,7 @@
"power_stat": "Power sensor",
"power_helper": "Pick a sensor which measures grid power in either of {unit}.",
"power_from": "Power imported from grid",
"power_to": "Power exported to grid",
"helper_sensor_note": "[%key:ui::panel::config::energy::battery::dialog::helper_sensor_note%]",
"helper_sensor_in_use": "[%key:ui::panel::config::energy::battery::dialog::helper_sensor_in_use%]"
"power_to": "Power exported to grid"
},
"flow_dialog": {
"from": {
@@ -4280,9 +4266,7 @@
"type_inverted_description": "Positive values indicate charging, negative values indicate discharging.",
"type_two_sensors": "Two sensors",
"power_from": "Discharge power",
"power_to": "Charge power",
"helper_sensor_note": "Home Assistant will create a helper sensor entity for this option.",
"helper_sensor_in_use": "Currently using helper {entity}"
"power_to": "Charge power"
}
},
"gas": {
@@ -5224,8 +5208,6 @@
"all_entities": "All entities",
"none_entities": "No entities",
"template": "Template",
"device_replaced": "Replaced device",
"device_replaced_description": "This device was replaced by one or more new devices. Update this automation to use them.",
"types": {
"entity": "[%key:ui::components::target-picker::type::entities%]",
"device": "[%key:ui::components::target-picker::type::devices%]",
@@ -5283,8 +5265,8 @@
"generic": {
"label": "Generic"
},
"integrations": {
"label": "Integrations"
"custom_integrations": {
"label": "Custom integrations"
}
},
"type": {
@@ -5595,8 +5577,8 @@
"building_blocks": {
"label": "Building blocks"
},
"integrations": {
"label": "Integrations"
"custom_integrations": {
"label": "Custom integrations"
}
},
"type": {
@@ -5765,11 +5747,11 @@
"device_id": {
"label": "Device"
},
"generic": {
"label": "Generic"
"helpers": {
"label": "Helpers"
},
"integrations": {
"label": "Integrations"
"other": {
"label": "Other actions"
},
"building_blocks": {
"label": "Building blocks"
@@ -6428,7 +6410,7 @@
"account": {
"thank_you_title": "Thank you!",
"getting_renewal_date": "Getting renewal date…",
"funding_note": "Every Cloud subscription funds the Open Home Foundation, paying full-time developers to work on and keep the Home Assistant project independent. No private equity, no ads, no data harvesting.",
"funding_note": "Every subscription funds Home Assistant and the Open Home Foundation, paying full-time developers and keeping the project independent. No private equity, no ads, and no data harvesting.",
"email": "Email",
"show_email": "Show email",
"hide_email": "Hide email",
@@ -6560,7 +6542,7 @@
"reset_data_confirm_text": "This will reset all your cloud settings. This includes your remote connection, Google Assistant, and Amazon Alexa integrations. This action cannot be undone.",
"reset": "Reset",
"reset_data_failed": "Failed to reset cloud data",
"thank_you_note": "It's because of people like you that we are able to make a great home automation experience for everyone.",
"thank_you_note": "Thank you for being part of Home Assistant Cloud. It's because of people like you that we are able to make a great home automation experience for everyone. Thank you!",
"nabu_casa_account": "Nabu Casa account",
"connection_status": "Cloud connection status",
"manage_account": "Manage account",
@@ -6783,10 +6765,6 @@
"heading": "Connected devices",
"show_more": "+{count} devices not shown"
},
"linked_devices": {
"heading": "Linked devices",
"description": "These devices share hardware with this device and are managed by other integrations."
},
"type": {
"device_heading": "Device",
"device": "device",
@@ -8640,22 +8618,6 @@
"caption": "AI tasks",
"description": "Configure AI suggestions and task preferences"
},
"entity_id_format": {
"caption": "Entity ID format",
"description": "Manage the format of newly created entity IDs",
"card": {
"header": "Entity ID format for new entities",
"description": "Entity IDs are used to reference entities in automations, scripts, and dashboards. This format only applies when a new entity is created. Using it is optional, and you can still rename each entity ID afterwards in its settings",
"learn_more": "Learn more",
"preview": "Preview",
"reset": "Reset to default",
"editor": {
"label": "Format",
"add": "Add",
"search": "Search part"
}
}
},
"labs": {
"caption": "Labs",
"custom_integration": "Custom integration",
@@ -2,6 +2,7 @@ import { assert, describe, it } from "vitest";
import { canToggleState } from "../../../src/common/entity/can_toggle_state";
import { ClimateEntityFeature } from "../../../src/data/climate";
import { CoverEntityFeature } from "../../../src/data/feature/cover_entity_feature";
describe("canToggleState", () => {
const hass: any = {
@@ -66,6 +67,39 @@ describe("canToggleState", () => {
assert.isFalse(canToggleState(hass, stateObj));
});
it("Detects cover with toggle", () => {
const stateObj: any = {
entity_id: "cover.bla",
attributes: {
supported_features: CoverEntityFeature.OPEN + CoverEntityFeature.CLOSE,
},
};
assert.isTrue(canToggleState(hass, stateObj));
});
it("Detects tilt-only cover with toggle", () => {
const stateObj: any = {
entity_id: "cover.bla",
attributes: {
supported_features:
CoverEntityFeature.OPEN_TILT +
CoverEntityFeature.CLOSE_TILT +
CoverEntityFeature.SET_TILT_POSITION,
},
};
assert.isTrue(canToggleState(hass, stateObj));
});
it("Detects tilt-only cover without toggle", () => {
const stateObj: any = {
entity_id: "cover.bla",
attributes: {
supported_features: CoverEntityFeature.STOP_TILT,
},
};
assert.isFalse(canToggleState(hass, stateObj));
});
it("Detects group with missing entity", () => {
const stateObj: any = {
entity_id: "group.bla",
@@ -0,0 +1,55 @@
import { assert, describe, it } from "vitest";
import { getToggleAction } from "../../../src/common/entity/get_toggle_action";
import { CoverEntityFeature } from "../../../src/data/feature/cover_entity_feature";
describe("getToggleAction", () => {
it("Uses turn_on/turn_off for default domains", () => {
assert.strictEqual(getToggleAction("light", true), "turn_on");
assert.strictEqual(getToggleAction("light", false), "turn_off");
});
it("Uses open_cover/close_cover for covers without a state object", () => {
assert.strictEqual(getToggleAction("cover", true), "open_cover");
assert.strictEqual(getToggleAction("cover", false), "close_cover");
});
it("Uses open_cover/close_cover for covers supporting open/close", () => {
const stateObj: any = {
entity_id: "cover.bla",
attributes: {
supported_features:
CoverEntityFeature.OPEN +
CoverEntityFeature.CLOSE +
CoverEntityFeature.OPEN_TILT +
CoverEntityFeature.CLOSE_TILT,
},
};
assert.strictEqual(getToggleAction("cover", true, stateObj), "open_cover");
assert.strictEqual(
getToggleAction("cover", false, stateObj),
"close_cover"
);
});
it("Uses tilt services for tilt-only covers", () => {
const stateObj: any = {
entity_id: "cover.bla",
state: "open",
attributes: {
supported_features:
CoverEntityFeature.OPEN_TILT +
CoverEntityFeature.CLOSE_TILT +
CoverEntityFeature.SET_TILT_POSITION,
},
};
assert.strictEqual(
getToggleAction("cover", true, stateObj),
"open_cover_tilt"
);
assert.strictEqual(
getToggleAction("cover", false, stateObj),
"close_cover_tilt"
);
});
});
+101
View File
@@ -0,0 +1,101 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { describe, expect, it, vi } from "vitest";
import { toggleGroupEntities } from "../../../src/common/entity/group_entities";
import { CoverEntityFeature } from "../../../src/data/feature/cover_entity_feature";
import type { HomeAssistant } from "../../../src/types";
const cover = (
entityId: string,
state: string,
supportedFeatures: number
): HassEntity =>
({
entity_id: entityId,
state,
attributes: { supported_features: supportedFeatures },
}) as HassEntity;
const TILT_ONLY =
CoverEntityFeature.OPEN_TILT +
CoverEntityFeature.CLOSE_TILT +
CoverEntityFeature.STOP_TILT;
const OPEN_CLOSE = CoverEntityFeature.OPEN + CoverEntityFeature.CLOSE;
const mockHass = () => {
const callService = vi.fn();
return { hass: { callService } as unknown as HomeAssistant, callService };
};
describe("toggleGroupEntities", () => {
it("Uses tilt services for a group of tilt-only covers", () => {
const { hass, callService } = mockHass();
toggleGroupEntities(hass, [cover("cover.tilt", "closed", TILT_ONLY)]);
expect(callService).toHaveBeenCalledOnce();
expect(callService).toHaveBeenCalledWith("cover", "open_cover_tilt", {
entity_id: ["cover.tilt"],
});
});
it("Splits the call when a group mixes tilt-only and regular covers", () => {
const { hass, callService } = mockHass();
toggleGroupEntities(hass, [
cover("cover.regular", "closed", OPEN_CLOSE),
cover("cover.tilt", "closed", TILT_ONLY),
]);
expect(callService).toHaveBeenCalledTimes(2);
expect(callService).toHaveBeenCalledWith("cover", "open_cover", {
entity_id: ["cover.regular"],
});
expect(callService).toHaveBeenCalledWith("cover", "open_cover_tilt", {
entity_id: ["cover.tilt"],
});
});
it("Stops the tilt of tilt-only covers", () => {
const { hass, callService } = mockHass();
toggleGroupEntities(hass, [
cover("cover.regular", "opening", OPEN_CLOSE),
cover("cover.tilt", "closed", TILT_ONLY),
]);
expect(callService).toHaveBeenCalledTimes(2);
expect(callService).toHaveBeenCalledWith("cover", "stop_cover", {
entity_id: ["cover.regular"],
});
expect(callService).toHaveBeenCalledWith("cover", "stop_cover_tilt", {
entity_id: ["cover.tilt"],
});
});
it("Skips tilt-only covers that can't stop", () => {
const { hass, callService } = mockHass();
toggleGroupEntities(hass, [
cover("cover.regular", "opening", OPEN_CLOSE),
cover(
"cover.tilt",
"closed",
CoverEntityFeature.OPEN_TILT + CoverEntityFeature.CLOSE_TILT
),
]);
expect(callService).toHaveBeenCalledOnce();
expect(callService).toHaveBeenCalledWith("cover", "stop_cover", {
entity_id: ["cover.regular"],
});
});
it("Uses a single call for a group of regular covers", () => {
const { hass, callService } = mockHass();
toggleGroupEntities(hass, [
cover("cover.one", "open", OPEN_CLOSE),
cover("cover.two", "open", OPEN_CLOSE),
]);
expect(callService).toHaveBeenCalledOnce();
expect(callService).toHaveBeenCalledWith("cover", "close_cover", {
entity_id: ["cover.one", "cover.two"],
});
});
});
@@ -1,76 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const originalAttachInternals = HTMLElement.prototype.attachInternals;
const originalElementInternals = window.ElementInternals;
// The probe memoizes its result, so re-import the module for each scenario.
const loadProbe = async () => {
vi.resetModules();
const mod =
await import("../../../src/common/feature-detect/support-native-element-internals");
return mod.supportsNativeElementInternals;
};
describe("supportsNativeElementInternals", () => {
afterEach(() => {
HTMLElement.prototype.attachInternals = originalAttachInternals;
window.ElementInternals = originalElementInternals;
});
it("returns true with native ElementInternals", async () => {
const supportsNativeElementInternals = await loadProbe();
expect(supportsNativeElementInternals()).toBe(true);
});
it("returns true when attachInternals is wrapped by a delegating function", async () => {
// Simulates @webcomponents/scoped-custom-element-registry, which the app
// bundle loads. Wrapping used to make detection fail (#53337).
HTMLElement.prototype.attachInternals = function (this: HTMLElement) {
return originalAttachInternals.call(this);
};
const supportsNativeElementInternals = await loadProbe();
expect(supportsNativeElementInternals()).toBe(true);
});
it("returns false when element-internals-polyfill replaces the global", async () => {
// The polyfill swaps in its own class, so the mere presence of
// window.ElementInternals says nothing about native support (#51338).
class PolyfilledElementInternals {
public setFormValue = (): void => undefined;
public setValidity = (): void => undefined;
public checkValidity = (): boolean => true;
public reportValidity = (): boolean => true;
}
window.ElementInternals =
PolyfilledElementInternals as unknown as typeof window.ElementInternals;
HTMLElement.prototype.attachInternals = () =>
new PolyfilledElementInternals() as unknown as ElementInternals;
const supportsNativeElementInternals = await loadProbe();
expect(supportsNativeElementInternals()).toBe(false);
});
it("returns false without ElementInternals", async () => {
window.ElementInternals =
undefined as unknown as typeof window.ElementInternals;
const supportsNativeElementInternals = await loadProbe();
expect(supportsNativeElementInternals()).toBe(false);
});
it("returns false without attachInternals", async () => {
HTMLElement.prototype.attachInternals =
undefined as unknown as typeof originalAttachInternals;
const supportsNativeElementInternals = await loadProbe();
expect(supportsNativeElementInternals()).toBe(false);
});
it("probes on first use rather than on import", async () => {
// Imported while support is present, so an eager probe would cache true.
const supportsNativeElementInternals = await loadProbe();
window.ElementInternals =
undefined as unknown as typeof window.ElementInternals;
expect(supportsNativeElementInternals()).toBe(false);
});
});
@@ -1,42 +0,0 @@
import { describe, expect, it } from "vitest";
import { sanitizeNavigationPath } from "../../../src/common/url/sanitize-navigation-path";
describe("sanitizeNavigationPath", () => {
it("keeps paths on the current origin", () => {
expect(sanitizeNavigationPath("/")).toEqual("/");
expect(sanitizeNavigationPath("/config/areas")).toEqual("/config/areas");
expect(sanitizeNavigationPath("/energy?historyBack=1")).toEqual(
"/energy?historyBack=1"
);
expect(sanitizeNavigationPath("config/areas")).toEqual("config/areas");
expect(sanitizeNavigationPath(`${location.origin}/lovelace/0`)).toEqual(
`${location.origin}/lovelace/0`
);
});
/* eslint-disable no-script-url */
it("rejects URIs with their own scheme", () => {
expect(sanitizeNavigationPath("javascript:alert(1)")).toBeUndefined();
expect(sanitizeNavigationPath("JavaScript:alert(1)")).toBeUndefined();
// the URL parser strips tabs and newlines, just like the browser does for href
expect(sanitizeNavigationPath("java\tscript:alert(1)")).toBeUndefined();
expect(sanitizeNavigationPath(" javascript:alert(1)")).toBeUndefined();
expect(
sanitizeNavigationPath("data:text/html,<script>alert(1)</script>")
).toBeUndefined();
expect(sanitizeNavigationPath("vbscript:msgbox(1)")).toBeUndefined();
});
/* eslint-enable no-script-url */
it("rejects other origins", () => {
expect(sanitizeNavigationPath("https://example.com/")).toBeUndefined();
expect(sanitizeNavigationPath("//example.com/")).toBeUndefined();
expect(sanitizeNavigationPath("\\\\example.com/")).toBeUndefined();
});
it("rejects missing values", () => {
expect(sanitizeNavigationPath(undefined)).toBeUndefined();
expect(sanitizeNavigationPath(null)).toBeUndefined();
});
});
@@ -1,144 +0,0 @@
import { LitElement } from "lit";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { WaInput } from "../../../src/components/input/wa-input-mixin";
import { WaInputMixin } from "../../../src/components/input/wa-input-mixin";
const supportsNative = vi.hoisted(() => ({ value: true }));
vi.mock(
"../../../src/common/feature-detect/support-native-element-internals",
() => ({
supportsNativeElementInternals: () => supportsNative.value,
})
);
// Subclassing gives legitimate access to the mixin's protected members, which
// ha-input and ha-textarea reach through their @input/@blur/@wa-invalid bindings.
class TestInput extends WaInputMixin(LitElement) {
public controlValid = true;
protected get _formControl(): WaInput {
return {
value: "",
select: () => undefined,
setSelectionRange: () => undefined,
setRangeText: () => undefined,
checkValidity: () => this.controlValid,
validationMessage: "Please fill out this field.",
};
}
public get isInvalid(): boolean {
return this._invalid;
}
public set isInvalid(value: boolean) {
this._invalid = value;
}
public handleInput(): void {
this._handleInput();
}
public handleBlur(): void {
this._handleBlur();
}
public handleInvalid(): void {
this._handleInvalid();
}
}
customElements.define("test-wa-input-mixin", TestInput);
declare global {
interface HTMLElementTagNameMap {
"test-wa-input-mixin": TestInput;
}
}
const createInput = (props: Partial<TestInput> = {}): TestInput => {
const el = document.createElement("test-wa-input-mixin");
Object.assign(el, props);
return el;
};
describe("WaInputMixin validity", () => {
beforeEach(() => {
supportsNative.value = true;
});
describe("with native element internals", () => {
it("marks invalid on blur when auto-validate is set", () => {
const el = createInput({ autoValidate: true, controlValid: false });
el.handleBlur();
expect(el.isInvalid).toBe(true);
});
it("keeps valid on blur when the control is valid", () => {
const el = createInput({ autoValidate: true, controlValid: true });
el.handleBlur();
expect(el.isInvalid).toBe(false);
});
it("ignores blur without auto-validate", () => {
const el = createInput({ autoValidate: false, controlValid: false });
el.handleBlur();
expect(el.isInvalid).toBe(false);
});
it("clears invalid on input once the control is valid", () => {
const el = createInput({ controlValid: true });
el.isInvalid = true;
el.handleInput();
expect(el.isInvalid).toBe(false);
});
it("keeps invalid on input while the control is invalid", () => {
const el = createInput({ controlValid: false });
el.isInvalid = true;
el.handleInput();
expect(el.isInvalid).toBe(true);
});
it("marks invalid on the invalid event", () => {
const el = createInput();
el.handleInvalid();
expect(el.isInvalid).toBe(true);
});
});
// Polyfilled internals report validity the app cannot trust, so every path
// must agree with checkValidity() and leave the field alone (#51338).
describe("without native element internals", () => {
beforeEach(() => {
supportsNative.value = false;
});
it("does not mark invalid on blur", () => {
const el = createInput({ autoValidate: true, controlValid: false });
el.handleBlur();
expect(el.isInvalid).toBe(false);
});
it("clears invalid on input", () => {
const el = createInput({ controlValid: false });
el.isInvalid = true;
el.handleInput();
expect(el.isInvalid).toBe(false);
});
it("ignores the invalid event", () => {
const el = createInput();
el.handleInvalid();
expect(el.isInvalid).toBe(false);
});
it("reports valid so submission is never blocked", () => {
const el = createInput({ required: true, controlValid: false });
expect(el.checkValidity()).toBe(true);
expect(el.reportValidity()).toBe(true);
expect(el.isInvalid).toBe(false);
});
});
});
-42
View File
@@ -1,42 +0,0 @@
import { LitElement } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
// The real back button pulls in the localize context, which is not provided here.
vi.mock("../../src/components/ha-icon-button-arrow-prev", () => ({}));
vi.mock("../../src/components/ha-menu-button", () => ({}));
customElements.define("ha-icon-button-arrow-prev", class extends LitElement {});
customElements.define("ha-menu-button", class extends LitElement {});
await import("../../src/layouts/hass-subpage");
let host: HTMLDivElement | undefined;
const mount = async (backPath: string) => {
host = document.createElement("div");
document.body.append(host);
const element = document.createElement("hass-subpage");
element.setAttribute("back-path", backPath);
host.append(element);
await (element as LitElement).updateComplete;
return element.shadowRoot!.querySelector("ha-icon-button-arrow-prev");
};
afterEach(() => {
host?.remove();
host = undefined;
});
describe("hass-subpage back path", () => {
it("links to a path on the current origin", async () => {
const backButton = await mount("/config/system");
expect(backButton!.getAttribute("href")).toEqual("/config/system");
});
// eslint-disable-next-line no-script-url
it.each(["javascript:alert(1)", "https://example.com/"])(
"does not link to %s",
async (backPath) => {
const backButton = await mount(backPath);
expect(backButton!.hasAttribute("href")).toBe(false);
}
);
});
-60
View File
@@ -1,60 +0,0 @@
import { LitElement } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HassTabsSubpage } from "../../src/layouts/hass-tabs-subpage";
import type { HomeAssistant } from "../../src/types";
// The real back button pulls in the localize context, which is not provided here.
vi.mock("../../src/components/ha-icon-button-arrow-prev", () => ({}));
vi.mock("../../src/components/ha-menu-button", () => ({}));
vi.mock("../../src/components/ha-tab", () => ({}));
customElements.define("ha-icon-button-arrow-prev", class extends LitElement {});
customElements.define("ha-menu-button", class extends LitElement {});
customElements.define("ha-tab", class extends LitElement {});
await import("../../src/layouts/hass-tabs-subpage");
const hass = {
config: { components: [] },
language: "en",
localize: (key: string) => key,
} as unknown as HomeAssistant;
let host: HTMLDivElement | undefined;
const mount = async (backPath: string) => {
host = document.createElement("div");
document.body.append(host);
const element = document.createElement(
"hass-tabs-subpage"
) as HassTabsSubpage;
Object.assign(element, {
hass,
route: { prefix: "", path: "" },
tabs: [],
backPath,
});
host.append(element);
await element.updateComplete;
return element.shadowRoot!.querySelector("ha-icon-button-arrow-prev") as
(LitElement & { href?: string }) | null;
};
afterEach(() => {
host?.remove();
host = undefined;
});
describe("hass-tabs-subpage back path", () => {
it("links to a path on the current origin", async () => {
const backButton = await mount("/config");
expect(backButton!.href).toEqual("/config");
});
// eslint-disable-next-line no-script-url
it.each(["javascript:alert(1)", "https://example.com/"])(
"does not link to %s",
async (backPath) => {
const backButton = await mount(backPath);
expect(backButton!.href).toBeUndefined();
}
);
});
@@ -1,71 +0,0 @@
import { assert, describe, it } from "vitest";
import { getPowerHelperEntityId } from "../../../../src/panels/config/energy/dialogs/power-config";
describe("getPowerHelperEntityId", () => {
it("returns the helper for an inverted config", () => {
const powerConfig = { stat_rate_inverted: "sensor.battery_power" };
assert.strictEqual(
getPowerHelperEntityId(
{
stat_rate: "sensor.battery_power_inverted",
power_config: powerConfig,
},
{ ...powerConfig }
),
"sensor.battery_power_inverted"
);
});
it("returns the helper for a two sensor config", () => {
const powerConfig = {
stat_rate_from: "sensor.discharge",
stat_rate_to: "sensor.charge",
};
assert.strictEqual(
getPowerHelperEntityId(
{
stat_rate: "sensor.energy_battery_discharge_charge_net_power",
power_config: powerConfig,
},
{ ...powerConfig }
),
"sensor.energy_battery_discharge_charge_net_power"
);
});
it("returns nothing for a standard config, as no helper is created", () => {
const powerConfig = { stat_rate: "sensor.battery_power" };
assert.isUndefined(
getPowerHelperEntityId(
{ stat_rate: "sensor.battery_power", power_config: powerConfig },
{ ...powerConfig }
)
);
});
it("returns nothing for a legacy config without power_config", () => {
assert.isUndefined(
getPowerHelperEntityId({ stat_rate: "sensor.battery_power" }, {})
);
});
it("returns nothing when the config was edited", () => {
assert.isUndefined(
getPowerHelperEntityId(
{
stat_rate: "sensor.battery_power_inverted",
power_config: { stat_rate_inverted: "sensor.battery_power" },
},
{ stat_rate_inverted: "sensor.other_battery_power" }
)
);
});
it("returns nothing for an unsaved source", () => {
assert.isUndefined(
getPowerHelperEntityId(undefined, {
stat_rate_inverted: "sensor.battery_power",
})
);
});
});
@@ -1,55 +0,0 @@
import { describe, expect, it } from "vitest";
import { isPowerConfigValid } from "../../../../src/panels/config/energy/dialogs/power-config";
describe("isPowerConfigValid", () => {
it("accepts any config when no power sensor is configured", () => {
expect(isPowerConfigValid("none", {})).toBe(true);
expect(isPowerConfigValid("none", { stat_rate: "sensor.power" })).toBe(
true
);
});
it("requires a rate statistic for the standard type", () => {
expect(isPowerConfigValid("standard", {})).toBe(false);
expect(isPowerConfigValid("standard", { stat_rate: "" })).toBe(false);
expect(isPowerConfigValid("standard", { stat_rate: "sensor.power" })).toBe(
true
);
expect(
isPowerConfigValid("standard", { stat_rate_inverted: "sensor.power" })
).toBe(false);
});
it("requires an inverted rate statistic for the inverted type", () => {
expect(isPowerConfigValid("inverted", {})).toBe(false);
expect(isPowerConfigValid("inverted", { stat_rate: "sensor.power" })).toBe(
false
);
expect(
isPowerConfigValid("inverted", { stat_rate_inverted: "sensor.power" })
).toBe(true);
});
it("requires both statistics for the two sensors type", () => {
expect(isPowerConfigValid("two_sensors", {})).toBe(false);
expect(
isPowerConfigValid("two_sensors", { stat_rate_from: "sensor.power_from" })
).toBe(false);
expect(
isPowerConfigValid("two_sensors", { stat_rate_to: "sensor.power_to" })
).toBe(false);
expect(
isPowerConfigValid("two_sensors", {
stat_rate_from: "sensor.power_from",
stat_rate_to: "sensor.power_to",
})
).toBe(true);
});
it("rejects unknown power types", () => {
expect(
isPowerConfigValid("unexpected" as never, { stat_rate: "sensor.power" })
).toBe(false);
});
});
@@ -0,0 +1,83 @@
import { describe, expect, it, vi } from "vitest";
import { CoverEntityFeature } from "../../../../../src/data/feature/cover_entity_feature";
import { turnOnOffEntities } from "../../../../../src/panels/lovelace/common/entity/turn-on-off-entities";
import type { HomeAssistant } from "../../../../../src/types";
const TILT_ONLY = CoverEntityFeature.OPEN_TILT + CoverEntityFeature.CLOSE_TILT;
const OPEN_CLOSE = CoverEntityFeature.OPEN + CoverEntityFeature.CLOSE;
const mockHass = (states: Record<string, any>) => {
const callService = vi.fn();
return {
hass: { states, callService } as unknown as HomeAssistant,
callService,
};
};
describe("turnOnOffEntities", () => {
it("Batches standard domains into a single homeassistant call", () => {
const { hass, callService } = mockHass({
"light.one": { entity_id: "light.one", state: "off", attributes: {} },
"switch.two": { entity_id: "switch.two", state: "off", attributes: {} },
});
turnOnOffEntities(hass, ["light.one", "switch.two"], true);
expect(callService).toHaveBeenCalledOnce();
expect(callService).toHaveBeenCalledWith("homeassistant", "turn_on", {
entity_id: ["light.one", "switch.two"],
});
});
it("Uses tilt services for tilt-only covers", () => {
const { hass, callService } = mockHass({
"cover.tilt": {
entity_id: "cover.tilt",
state: "closed",
attributes: { supported_features: TILT_ONLY },
},
});
turnOnOffEntities(hass, ["cover.tilt"], true);
expect(callService).toHaveBeenCalledOnce();
expect(callService).toHaveBeenCalledWith("cover", "open_cover_tilt", {
entity_id: ["cover.tilt"],
});
});
it("Splits the call when covers need different services", () => {
const { hass, callService } = mockHass({
"cover.regular": {
entity_id: "cover.regular",
state: "closed",
attributes: { supported_features: OPEN_CLOSE },
},
"cover.tilt": {
entity_id: "cover.tilt",
state: "closed",
attributes: { supported_features: TILT_ONLY },
},
});
turnOnOffEntities(hass, ["cover.regular", "cover.tilt"], true);
expect(callService).toHaveBeenCalledTimes(2);
expect(callService).toHaveBeenCalledWith("cover", "open_cover", {
entity_id: ["cover.regular"],
});
expect(callService).toHaveBeenCalledWith("cover", "open_cover_tilt", {
entity_id: ["cover.tilt"],
});
});
it("Skips entities already in the requested state", () => {
const { hass, callService } = mockHass({
"light.on": { entity_id: "light.on", state: "on", attributes: {} },
"light.off": { entity_id: "light.off", state: "off", attributes: {} },
});
turnOnOffEntities(hass, ["light.on", "light.off"], true);
expect(callService).toHaveBeenCalledOnce();
expect(callService).toHaveBeenCalledWith("homeassistant", "turn_on", {
entity_id: ["light.off"],
});
});
});
-1
View File
@@ -3,4 +3,3 @@ global.navigator = (global.navigator ?? {}) as any;
global.__DEMO__ = false;
global.__DEV__ = false;
global.__HASS_URL__ = "";
+5 -5
View File
@@ -9675,10 +9675,10 @@ __metadata:
languageName: node
linkType: hard
"globals@npm:17.8.0":
version: 17.8.0
resolution: "globals@npm:17.8.0"
checksum: 10/b7b854b2052d2608d1878884bf730c027e17b9e2d194834f48c3280a7f0005b06d5f51dba362d3149cc05eb90d36d990e5c996728ecd3585a43dc3b4a286ed85
"globals@npm:17.7.0":
version: 17.7.0
resolution: "globals@npm:17.7.0"
checksum: 10/79304ccc4d2ca167ea15bdb25da346aa34ce3847b18fbd6c3cad182e152505305db3c9722fd5e292c62f6db97a8fa06e0c110a1e7703d7325498e5351d08cab4
languageName: node
linkType: hard
@@ -10011,7 +10011,7 @@ __metadata:
fuse.js: "npm:7.5.0"
generate-license-file: "npm:4.2.1"
glob: "npm:13.0.6"
globals: "npm:17.8.0"
globals: "npm:17.7.0"
gulp: "npm:5.0.1"
gulp-brotli: "npm:3.0.0"
gulp-json-transform: "npm:0.5.0"