mirror of
https://github.com/home-assistant/frontend.git
synced 2026-11-14 17:02:50 +00:00
Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ed9333dc2 | ||
|
|
2706bd89a1 | ||
|
|
bd434eeb0d | ||
|
|
16f01e1081 | ||
|
|
050251db23 | ||
|
|
13128609de | ||
|
|
0f70813ada | ||
|
|
5762da9d22 | ||
|
|
33f709cd21 | ||
|
|
1f77e4cd59 | ||
|
|
77170b447f | ||
|
|
e2bb744202 | ||
|
|
0eec83448a | ||
|
|
1864f6f802 | ||
|
|
9a19cc3bf6 | ||
|
|
ec51fb4f8d | ||
|
|
6a1a9bfb1b | ||
|
|
16807d2765 | ||
|
|
9caad65c91 | ||
|
|
af523f1087 | ||
|
|
349d5f7125 | ||
|
|
de64bd3c59 | ||
|
|
5253eef284 | ||
|
|
b1164739c3 | ||
|
|
80a516c673 | ||
|
|
d5d0f45919 | ||
|
|
f922bc59a5 | ||
|
|
a71a79b01b | ||
|
|
9b8409d7ec | ||
|
|
fc8d21b920 | ||
|
|
fce0f4da4e | ||
|
|
2391c700c6 | ||
|
|
8cd79a6776 | ||
|
|
b4f6e4e2ea | ||
|
|
50bb1cf53e | ||
|
|
70a1946874 | ||
|
|
3da9ba8119 | ||
|
|
eb8ac4139b | ||
|
|
f9aae72325 | ||
|
|
81279aa6d2 | ||
|
|
33b8afffca | ||
|
|
b157dd174e | ||
|
|
4a0381e82d | ||
|
|
92e248c913 | ||
|
|
5b1ecdb0b0 | ||
|
|
ee35dd8cf6 | ||
|
|
42ce434a94 | ||
|
|
06d564dbd8 | ||
|
|
93f0c2c45a | ||
|
|
fb40d07621 | ||
|
|
a6073e44f7 | ||
|
|
df69f3c7ee |
No files matched your search
@@ -157,6 +157,99 @@ async function pollProcess(lokaliseApi, projectId, processId) {
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
|
||||
const DRAFTS_ACCEPTED_KEYS = [
|
||||
// Written per language rather than translated from English, so an English edit doesn't make them outdated
|
||||
"ui.components.rooms.keywords",
|
||||
];
|
||||
|
||||
async function exportTranslations(lokaliseApi, projectId) {
|
||||
const exportProcess = await lokaliseApi.files().async_download(projectId, {
|
||||
format: "json",
|
||||
original_filenames: false,
|
||||
replace_breaks: false,
|
||||
json_unescaped_slashes: true,
|
||||
export_empty_as: "skip",
|
||||
filter_data: ["verified"],
|
||||
});
|
||||
|
||||
const finishedProcess = await pollProcess(
|
||||
lokaliseApi,
|
||||
projectId,
|
||||
exportProcess.process_id
|
||||
);
|
||||
|
||||
const bundleUrl = finishedProcess.details.download_url;
|
||||
|
||||
console.log(`Downloading translations from: ${bundleUrl}`);
|
||||
|
||||
const response = await fetch(bundleUrl);
|
||||
|
||||
if (response.status !== 200 && response.status !== 0) {
|
||||
throw new Error(response.statusText);
|
||||
}
|
||||
|
||||
console.log(`Extracting translations...`);
|
||||
|
||||
const contents = await JSZip.loadAsync(await response.arrayBuffer());
|
||||
|
||||
const files = {};
|
||||
await Promise.all(
|
||||
Object.keys(contents.files).map(async (filename) => {
|
||||
const file = contents.file(filename);
|
||||
if (!file) {
|
||||
// no file, probably a directory
|
||||
return;
|
||||
}
|
||||
files[filename.split("/").splice(-1)[0]] = await file.async("string");
|
||||
})
|
||||
);
|
||||
return files;
|
||||
}
|
||||
|
||||
async function fetchDrafts(lokaliseApi, projectId) {
|
||||
const english = JSON.parse(
|
||||
await fs.readFile("src/translations/en.json", { encoding })
|
||||
);
|
||||
const keyNames = DRAFTS_ACCEPTED_KEYS.flatMap((key) => {
|
||||
const keys = key.split(".");
|
||||
const drafts = keys.reduce((obj, part) => obj?.[part], english) ?? {};
|
||||
return Object.keys(drafts).map((name) => [...keys, name].join("::"));
|
||||
});
|
||||
if (!keyNames.length) {
|
||||
return [];
|
||||
}
|
||||
const { items } = await lokaliseApi.keys().list({
|
||||
project_id: projectId,
|
||||
filter_keys: keyNames.join(","),
|
||||
include_translations: 1,
|
||||
limit: keyNames.length,
|
||||
});
|
||||
return items.flatMap((key) =>
|
||||
key.translations
|
||||
.filter(({ translation }) => translation)
|
||||
.map(({ language_iso, translation }) => ({
|
||||
filename: `${language_iso}.json`,
|
||||
path: key.key_name.web.split("::"),
|
||||
translation,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
function acceptDrafts(files, drafts) {
|
||||
const data = {};
|
||||
drafts.forEach(({ filename, path: keyPath, translation }) => {
|
||||
data[filename] ??= files[filename] ? JSON.parse(files[filename]) : {};
|
||||
const parent = keyPath.slice(0, -1).reduce((obj, part) => {
|
||||
obj[part] ??= {};
|
||||
return obj[part];
|
||||
}, data[filename]);
|
||||
parent[keyPath.at(-1)] = translation;
|
||||
});
|
||||
Object.entries(data).forEach(([filename, content]) => {
|
||||
files[filename] = JSON.stringify(content, null, 2);
|
||||
});
|
||||
}
|
||||
|
||||
gulp.task("fetch-lokalise", async function () {
|
||||
let apiKey;
|
||||
try {
|
||||
@@ -193,52 +286,24 @@ gulp.task("fetch-lokalise", async function () {
|
||||
await Promise.all(
|
||||
projects.map(async ([project, projectId]) => {
|
||||
try {
|
||||
const exportProcess = await lokaliseApi
|
||||
.files()
|
||||
.async_download(projectId, {
|
||||
format: "json",
|
||||
original_filenames: false,
|
||||
replace_breaks: false,
|
||||
json_unescaped_slashes: true,
|
||||
export_empty_as: "skip",
|
||||
filter_data: ["verified"],
|
||||
});
|
||||
|
||||
const finishedProcess = await pollProcess(
|
||||
lokaliseApi,
|
||||
projectId,
|
||||
exportProcess.process_id
|
||||
);
|
||||
|
||||
const bundleUrl = finishedProcess.details.download_url;
|
||||
|
||||
console.log(`Downloading translations from: ${bundleUrl}`);
|
||||
|
||||
const response = await fetch(bundleUrl);
|
||||
|
||||
if (response.status !== 200 && response.status !== 0) {
|
||||
throw new Error(response.statusText);
|
||||
const [files, drafts] = await Promise.all([
|
||||
exportTranslations(lokaliseApi, projectId),
|
||||
project === "frontend"
|
||||
? fetchDrafts(lokaliseApi, projectId)
|
||||
: undefined,
|
||||
]);
|
||||
if (drafts) {
|
||||
acceptDrafts(files, drafts);
|
||||
}
|
||||
|
||||
console.log(`Extracting translations...`);
|
||||
|
||||
const contents = await JSZip.loadAsync(await response.arrayBuffer());
|
||||
|
||||
await mkdirPromise;
|
||||
await Promise.all(
|
||||
Object.keys(contents.files).map(async (filename) => {
|
||||
const file = contents.file(filename);
|
||||
if (!file) {
|
||||
// no file, probably a directory
|
||||
return;
|
||||
}
|
||||
const content = await file.async("nodebuffer");
|
||||
await fs.writeFile(
|
||||
path.join(inDir, project, filename.split("/").splice(-1)[0]),
|
||||
content,
|
||||
{ flag: "w", encoding }
|
||||
);
|
||||
})
|
||||
Object.entries(files).map(([filename, content]) =>
|
||||
fs.writeFile(path.join(inDir, project, filename), content, {
|
||||
flag: "w",
|
||||
encoding,
|
||||
})
|
||||
)
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
@@ -44,14 +44,21 @@ export const lorawanFixtures: ConnectivityFixtures = {
|
||||
],
|
||||
commands: ["lorawan/"],
|
||||
manifests: [
|
||||
manifest("lorawan", "LoRaWAN", { integration_type: "helper" }),
|
||||
manifest("lorawan", "LoRaWAN", {
|
||||
config_flow: false,
|
||||
integration_type: "service",
|
||||
}),
|
||||
manifest("sensecap_lorawan", "SenseCAP", {
|
||||
integration_type: "device",
|
||||
dependencies: ["lorawan"],
|
||||
}),
|
||||
manifest("chirpstack", "ChirpStack", { integration_type: "hub" }),
|
||||
manifest("chirpstack", "ChirpStack", {
|
||||
integration_type: "hub",
|
||||
dependencies: ["lorawan"],
|
||||
}),
|
||||
manifest("thethingsnetwork_lorawan", "The Things Network", {
|
||||
integration_type: "hub",
|
||||
dependencies: ["lorawan"],
|
||||
}),
|
||||
],
|
||||
configEntries: [
|
||||
|
||||
@@ -10,19 +10,26 @@ export const FRONIUS_ENTRY_ID = "mock-fronius";
|
||||
export const RS485_PORT = "/dev/ttyUSB1";
|
||||
|
||||
export const modbusFixtures: ConnectivityFixtures = {
|
||||
components: ["modbus"],
|
||||
components: ["modbus", "flexit", "solaredge_modbus", "fronius"],
|
||||
commands: ["modbus/"],
|
||||
manifests: [
|
||||
manifest("modbus", "Modbus", {
|
||||
config_flow: false,
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("flexit", "Flexit", {
|
||||
integration_type: "device",
|
||||
dependencies: ["modbus"],
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("solaredge_modbus", "SolarEdge Modbus", {
|
||||
integration_type: "device",
|
||||
dependencies: ["modbus"],
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("fronius", "Fronius", {
|
||||
integration_type: "device",
|
||||
dependencies: ["modbus"],
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -25,7 +25,6 @@ class DemoMoreInfo extends LitElement {
|
||||
!computeShowNewMoreInfo(state)
|
||||
? html`<state-card-content
|
||||
.stateObj=${state}
|
||||
.hass=${this.hass}
|
||||
in-dialog
|
||||
></state-card-content>`
|
||||
: nothing
|
||||
|
||||
@@ -117,11 +117,17 @@ shared slots/attributes below.
|
||||
|
||||
**Attributes**
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| ----------------- | ------- | ------- | --------------------------------------- |
|
||||
| `headline` | String | — | Primary text. Overridden by the slot. |
|
||||
| `supporting-text` | String | — | Secondary text. Overridden by the slot. |
|
||||
| `disabled` | Boolean | `false` | Dims the row and blocks pointer events. |
|
||||
| Name | Type | Default | Description |
|
||||
| ---------------------- | ------- | ------- | -------------------------------------------------- |
|
||||
| `headline` | String | — | Primary text. Overridden by the slot. |
|
||||
| `supporting-text` | String | — | Secondary text. Overridden by the slot. |
|
||||
| `wrap-headline` | Boolean | `false` | Wrap the headline instead of truncating it. |
|
||||
| `wrap-supporting-text` | Boolean | `false` | Wrap the supporting text instead of truncating it. |
|
||||
| `disabled` | Boolean | `false` | Dims the row and blocks pointer events. |
|
||||
|
||||
The headline and the supporting text stay on one line and are truncated with an
|
||||
ellipsis by default. Set `wrap-headline` or `wrap-supporting-text` on rows whose
|
||||
text has to stay readable in full, like settings with a description.
|
||||
|
||||
**CSS parts**
|
||||
|
||||
@@ -165,6 +171,18 @@ Interactive row. Renders an inner `<a>` when `href` is set, otherwise a
|
||||
roving tabindex, the host is the tab stop and the inner element carries
|
||||
`tabindex="-1"`.
|
||||
|
||||
Only the headline and supporting text are inside the `<a>` or `<button>`; its
|
||||
hit area is stretched over the whole row. The `start` and `end` slots sit next
|
||||
to it, so controls placed there, like an `ha-icon-button` or `ha-dropdown`,
|
||||
stay separate controls for screen readers. Slotted elements are raised above
|
||||
the hit area so they can be clicked, except icons, badges, `span`s and empty
|
||||
`div`s (dividers), which let clicks and hover through to the row. A focusable
|
||||
element (with `tabindex`) is always raised. To keep another element clickable
|
||||
on its own, like a drag handle, give it `z-index: 1` in the consumer's styles.
|
||||
|
||||
Subclasses customize the row by overriding `_renderStart()`,
|
||||
`_renderContent()` and `_renderEnd()`. `_renderInner()` is not used.
|
||||
|
||||
**Attributes**
|
||||
|
||||
- `href` (String) — when set, renders an `<a>` instead of a `<button>`.
|
||||
@@ -174,6 +192,7 @@ roving tabindex, the host is the tab stop and the inner element carries
|
||||
|
||||
**CSS parts**
|
||||
|
||||
- `control` — the inner `<a>` or `<button>`.
|
||||
- `ripple` — the ripple effect element.
|
||||
|
||||
### `<ha-list-item-option>`
|
||||
|
||||
@@ -2,10 +2,12 @@ import {
|
||||
mdiAccount,
|
||||
mdiChevronRight,
|
||||
mdiCog,
|
||||
mdiDelete,
|
||||
mdiHome,
|
||||
mdiInformationOutline,
|
||||
mdiMapMarker,
|
||||
mdiOpenInNew,
|
||||
mdiPencil,
|
||||
mdiViewDashboard,
|
||||
mdiWifi,
|
||||
} from "@mdi/js";
|
||||
@@ -13,6 +15,8 @@ import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import "../../../../src/components/ha-card";
|
||||
import "../../../../src/components/ha-icon-button";
|
||||
import "../../../../src/components/ha-icon-next";
|
||||
import "../../../../src/components/ha-svg-icon";
|
||||
import "../../../../src/components/item/ha-list-item-base";
|
||||
import "../../../../src/components/item/ha-list-item-button";
|
||||
@@ -77,6 +81,8 @@ const treeKey = (group: TreeGroup, child: TreeChild) =>
|
||||
export class DemoHaList extends LitElement {
|
||||
@state() private _buttonClicks = 0;
|
||||
|
||||
@state() private _lastAction = "none";
|
||||
|
||||
@state() private _single: number | Set<number> = -1;
|
||||
|
||||
@state() private _multiLine: number | Set<number> = new Set();
|
||||
@@ -185,6 +191,20 @@ export class DemoHaList extends LitElement {
|
||||
<ha-list-item-base headline="Disabled row" disabled>
|
||||
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
|
||||
</ha-list-item-base>
|
||||
<ha-list-item-base
|
||||
headline="Truncated by default, a long headline stays on one line and ends with an ellipsis"
|
||||
supporting-text="Long supporting text is truncated as well, so every row in the list keeps the same height"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
|
||||
</ha-list-item-base>
|
||||
<ha-list-item-base
|
||||
wrap-headline
|
||||
wrap-supporting-text
|
||||
headline="With wrap-headline, a long headline wraps onto as many lines as it needs"
|
||||
supporting-text="With wrap-supporting-text, long supporting text wraps too, which suits settings with a description"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
|
||||
</ha-list-item-base>
|
||||
</ha-list-base>
|
||||
</ha-card>
|
||||
|
||||
@@ -220,6 +240,51 @@ export class DemoHaList extends LitElement {
|
||||
</ha-list-base>
|
||||
</ha-card>
|
||||
|
||||
<ha-card header="Row with actions in the end slot">
|
||||
<p class="card-text">
|
||||
Last action: <code>${this._lastAction}</code>. The action buttons are
|
||||
separate controls; icons and the divider activate the row.
|
||||
</p>
|
||||
<ha-list-base aria-label="Rows with actions">
|
||||
<ha-list-item-button data-action="row" @click=${this._onActionClick}>
|
||||
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
|
||||
<span slot="headline">Button row</span>
|
||||
<span slot="supporting-text">Click the row or an action</span>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
<div slot="end" class="vertical-divider"></div>
|
||||
<ha-icon-button
|
||||
slot="end"
|
||||
data-action="edit"
|
||||
.path=${mdiPencil}
|
||||
label="Edit"
|
||||
@click=${this._onActionClick}
|
||||
></ha-icon-button>
|
||||
<ha-icon-button
|
||||
slot="end"
|
||||
data-action="delete"
|
||||
.path=${mdiDelete}
|
||||
label="Delete"
|
||||
@click=${this._onActionClick}
|
||||
></ha-icon-button>
|
||||
</ha-list-item-button>
|
||||
<ha-list-item-button href="#components/ha-list">
|
||||
<ha-svg-icon slot="start" .path=${mdiMapMarker}></ha-svg-icon>
|
||||
<span slot="headline">Link row</span>
|
||||
<span slot="supporting-text"
|
||||
>Cmd/Ctrl-click on the icon or chevron opens a new tab</span
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="end"
|
||||
data-action="link-edit"
|
||||
.path=${mdiPencil}
|
||||
label="Edit"
|
||||
@click=${this._onActionClick}
|
||||
></ha-icon-button>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-list-item-button>
|
||||
</ha-list-base>
|
||||
</ha-card>
|
||||
|
||||
<h2>ha-list-selectable + ha-list-item-option</h2>
|
||||
<p>
|
||||
Selectable list (<code>role="listbox"</code>). Items must be
|
||||
@@ -428,6 +493,12 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
|
||||
return value instanceof Set ? [...value] : value;
|
||||
}
|
||||
|
||||
private _onActionClick = (ev: MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
this._lastAction =
|
||||
(ev.currentTarget as HTMLElement).dataset.action ?? "none";
|
||||
};
|
||||
|
||||
private _onButtonClick = () => {
|
||||
this._buttonClicks++;
|
||||
};
|
||||
@@ -542,6 +613,15 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
|
||||
ha-card {
|
||||
max-width: 560px;
|
||||
}
|
||||
.card-text {
|
||||
margin: 0;
|
||||
padding: 0 var(--ha-space-4) var(--ha-space-2);
|
||||
}
|
||||
.vertical-divider {
|
||||
align-self: stretch;
|
||||
width: 1px;
|
||||
background: var(--divider-color);
|
||||
}
|
||||
pre {
|
||||
padding: var(--ha-space-4);
|
||||
background: var(--secondary-background-color);
|
||||
|
||||
+21
-21
@@ -54,15 +54,15 @@
|
||||
"@codemirror/view": "6.43.15",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "8.0.2",
|
||||
"@formatjs/intl-displaynames": "8.0.1",
|
||||
"@formatjs/intl-durationformat": "0.12.3",
|
||||
"@formatjs/intl-getcanonicallocales": "3.3.0",
|
||||
"@formatjs/intl-listformat": "9.0.1",
|
||||
"@formatjs/intl-locale": "5.4.2",
|
||||
"@formatjs/intl-numberformat": "10.0.2",
|
||||
"@formatjs/intl-pluralrules": "7.0.2",
|
||||
"@formatjs/intl-relativetimeformat": "13.0.1",
|
||||
"@formatjs/intl-datetimeformat": "8.0.3",
|
||||
"@formatjs/intl-displaynames": "8.0.2",
|
||||
"@formatjs/intl-durationformat": "0.12.4",
|
||||
"@formatjs/intl-getcanonicallocales": "3.3.1",
|
||||
"@formatjs/intl-listformat": "9.0.2",
|
||||
"@formatjs/intl-locale": "5.4.3",
|
||||
"@formatjs/intl-numberformat": "10.0.3",
|
||||
"@formatjs/intl-pluralrules": "7.0.3",
|
||||
"@formatjs/intl-relativetimeformat": "13.0.2",
|
||||
"@home-assistant/webawesome": "3.14.0-ha.2",
|
||||
"@lezer/common": "1.5.5",
|
||||
"@lezer/highlight": "1.2.5",
|
||||
@@ -79,16 +79,16 @@
|
||||
"@swc/helpers": "0.5.24",
|
||||
"@tsparticles/engine": "4.5.3",
|
||||
"@tsparticles/preset-links": "4.5.3",
|
||||
"@versatiles/style": "8.1.1",
|
||||
"@versatiles/style": "9.1.0",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@vvo/tzdb": "6.203.0",
|
||||
"@vvo/tzdb": "6.204.0",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
"barcode-detector": "3.2.3",
|
||||
"cally": "0.9.2",
|
||||
"color-name": "2.1.1",
|
||||
"comlink": "4.4.2",
|
||||
"core-js": "3.51.0",
|
||||
"cropperjs": "1.7.0",
|
||||
"cropperjs": "2.3.1",
|
||||
"culori": "4.0.2",
|
||||
"date-fns": "4.4.0",
|
||||
"decimal.js": "10.6.0",
|
||||
@@ -102,14 +102,14 @@
|
||||
"hls.js": "1.7.4",
|
||||
"home-assistant-js-websocket": "9.8.1",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "12.1.4",
|
||||
"intl-messageformat": "12.1.5",
|
||||
"js-yaml": "5.5.0",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"maplibre-gl": "6.16.0",
|
||||
"marked": "18.1.3",
|
||||
"maplibre-gl": "6.17.0",
|
||||
"marked": "18.2.0",
|
||||
"memoize-one": "6.0.0",
|
||||
"nanoid": "6.0.2",
|
||||
"node-vibrant": "4.0.4",
|
||||
@@ -147,13 +147,13 @@
|
||||
"@eslint/js": "10.0.1",
|
||||
"@gfx/zopfli": "1.0.15",
|
||||
"@home-assistant/components": "workspace:*",
|
||||
"@html-eslint/eslint-plugin": "0.67.0",
|
||||
"@html-eslint/eslint-plugin": "0.68.0",
|
||||
"@lokalise/node-api": "17.0.1",
|
||||
"@octokit/auth-oauth-device": "8.0.5",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.65.0",
|
||||
"@rsdoctor/rspack-plugin": "1.6.5",
|
||||
"@rspack/core": "2.3.1",
|
||||
"@rspack/core": "2.3.2",
|
||||
"@rspack/dev-server": "2.2.2",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "7.0.1",
|
||||
@@ -180,7 +180,7 @@
|
||||
"eslint": "10.14.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.18.0",
|
||||
"eslint-plugin-import-x": "4.18.1",
|
||||
"eslint-plugin-lit": "2.3.1",
|
||||
"eslint-plugin-lit-a11y": "5.1.3",
|
||||
"eslint-plugin-unused-imports": "4.4.1",
|
||||
@@ -214,8 +214,8 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.72.1",
|
||||
"vite": "8.3.6",
|
||||
"typescript-eslint": "8.72.2",
|
||||
"vite": "8.3.8",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "5.1.0",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
@@ -224,6 +224,6 @@
|
||||
},
|
||||
"packageManager": "[email protected]",
|
||||
"volta": {
|
||||
"node": "24.22.2"
|
||||
"node": "24.22.3"
|
||||
}
|
||||
}
|
||||
Generated
+515
-426
File diff suppressed because it is too large.
Load diff
@@ -89,10 +89,19 @@ export class LegendFitController {
|
||||
|
||||
private _fit() {
|
||||
const list = this._element;
|
||||
const rowWidth = list?.getBoundingClientRect().width;
|
||||
if (!list || !rowWidth) {
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
const style = getComputedStyle(list);
|
||||
const rowWidth = parseFloat(style.width);
|
||||
// Bounding rects include transforms, such as a dialog scaling in, which
|
||||
// never resize the list, so measure in layout pixels
|
||||
const scale = list.getBoundingClientRect().width / rowWidth;
|
||||
if (!rowWidth || !scale) {
|
||||
return;
|
||||
}
|
||||
const layoutWidth = (element: Element) =>
|
||||
element.getBoundingClientRect().width / scale;
|
||||
const children = Array.from(list.children);
|
||||
const more = children.find((child) =>
|
||||
child.classList.contains("legend-more")
|
||||
@@ -107,9 +116,7 @@ export class LegendFitController {
|
||||
}
|
||||
this._more = more;
|
||||
}
|
||||
const widths = children
|
||||
.filter((child) => child !== more)
|
||||
.map((child) => child.getBoundingClientRect().width);
|
||||
const widths = children.filter((child) => child !== more).map(layoutWidth);
|
||||
// Values resize the items on every state update. Fitting each item at its
|
||||
// widest since the last resize keeps the more button from flickering.
|
||||
if (rowWidth !== this._rowWidth || widths.length !== this._widths.length) {
|
||||
@@ -122,9 +129,9 @@ export class LegendFitController {
|
||||
}
|
||||
const fit = fitInRows(
|
||||
this._widths,
|
||||
more?.getBoundingClientRect().width ?? 0,
|
||||
more ? layoutWidth(more) : 0,
|
||||
rowWidth,
|
||||
parseFloat(getComputedStyle(list).columnGap) || 0,
|
||||
parseFloat(style.columnGap) || 0,
|
||||
rowWidth < NARROW_WIDTH ? NARROW_MAX_ROWS : MAX_ROWS
|
||||
);
|
||||
if (fit !== this.fit) {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
const INTERACTIVE_SELECTOR = [
|
||||
"a[href]",
|
||||
"button",
|
||||
"input",
|
||||
"select",
|
||||
"textarea",
|
||||
"summary",
|
||||
"label",
|
||||
"[contenteditable]:not([contenteditable='false'])",
|
||||
"[tabindex]:not([tabindex='-1'])",
|
||||
"[role='button']",
|
||||
"[role='link']",
|
||||
"[role='checkbox']",
|
||||
"[role='radio']",
|
||||
"[role='switch']",
|
||||
"[role='slider']",
|
||||
"[role='tab']",
|
||||
"[role='menuitem']",
|
||||
"[role='option']",
|
||||
"[role='combobox']",
|
||||
"[role='textbox']",
|
||||
].join(",");
|
||||
|
||||
/**
|
||||
* Whether `node` is an element that handles clicks or keyboard input on its
|
||||
* own, such as a button, link, form control or a focusable widget.
|
||||
*/
|
||||
export const isInteractiveElement = (node: EventTarget): boolean =>
|
||||
node instanceof Element && node.matches(INTERACTIVE_SELECTOR);
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassServices } from "home-assistant-js-websocket";
|
||||
import { getToggleAction } from "./get_toggle_action";
|
||||
|
||||
export const canToggleDomain = (hass: HomeAssistant, domain: string) => {
|
||||
const services = hass.services[domain];
|
||||
if (!services) {
|
||||
export const canToggleDomain = (services: HassServices, domain: string) => {
|
||||
const domainServices = services[domain];
|
||||
if (!domainServices) {
|
||||
return false;
|
||||
}
|
||||
const actionOn = getToggleAction(domain, true);
|
||||
const actionOff = getToggleAction(domain, false);
|
||||
return actionOn in services && actionOff in services;
|
||||
return actionOn in domainServices && actionOff in domainServices;
|
||||
};
|
||||
@@ -1,23 +1,30 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type {
|
||||
HassEntities,
|
||||
HassEntity,
|
||||
HassServices,
|
||||
} from "home-assistant-js-websocket";
|
||||
import { canToggleDomain } from "./can_toggle_domain";
|
||||
import { computeStateDomain } from "./compute_state_domain";
|
||||
import { supportsFeature } from "./supports-feature";
|
||||
import { SPECIAL_TOGGLE_ACTIONS } from "./get_toggle_action";
|
||||
|
||||
export const canToggleState = (hass: HomeAssistant, stateObj: HassEntity) => {
|
||||
export const canToggleState = (
|
||||
states: HassEntities,
|
||||
services: HassServices,
|
||||
stateObj: HassEntity
|
||||
) => {
|
||||
const domain = computeStateDomain(stateObj);
|
||||
|
||||
if (domain === "group") {
|
||||
if (
|
||||
stateObj.attributes?.entity_id?.some((entity) => {
|
||||
const entityStateObj = hass.states[entity];
|
||||
const entityStateObj = states[entity];
|
||||
if (!entityStateObj) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const entityDomain = computeStateDomain(entityStateObj);
|
||||
return canToggleDomain(hass, entityDomain);
|
||||
return canToggleDomain(services, entityDomain);
|
||||
})
|
||||
) {
|
||||
return stateObj.state === "on" || stateObj.state === "off";
|
||||
@@ -35,5 +42,5 @@ export const canToggleState = (hass: HomeAssistant, stateObj: HassEntity) => {
|
||||
);
|
||||
}
|
||||
|
||||
return canToggleDomain(hass, domain);
|
||||
return canToggleDomain(services, domain);
|
||||
};
|
||||
@@ -1,11 +1,18 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type {
|
||||
HassEntities,
|
||||
HassEntity,
|
||||
HassServices,
|
||||
} from "home-assistant-js-websocket";
|
||||
import { UNAVAILABLE } from "../../data/entity/entity";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { DOMAINS_WITH_CARD } from "../const";
|
||||
import { canToggleState } from "./can_toggle_state";
|
||||
import { computeStateDomain } from "./compute_state_domain";
|
||||
|
||||
export const stateCardType = (hass: HomeAssistant, stateObj: HassEntity) => {
|
||||
export const stateCardType = (
|
||||
states: HassEntities,
|
||||
services: HassServices,
|
||||
stateObj: HassEntity
|
||||
) => {
|
||||
if (stateObj.state === UNAVAILABLE) {
|
||||
return "display";
|
||||
}
|
||||
@@ -16,7 +23,7 @@ export const stateCardType = (hass: HomeAssistant, stateObj: HassEntity) => {
|
||||
return domain;
|
||||
}
|
||||
if (
|
||||
canToggleState(hass, stateObj) &&
|
||||
canToggleState(states, services, stateObj) &&
|
||||
stateObj.attributes.control !== "hidden"
|
||||
) {
|
||||
return "toggle";
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { LocalizeFunc } from "./localize";
|
||||
|
||||
const YAML_ERROR_REASON_KEYS = new Map(
|
||||
Object.entries({
|
||||
"duplicated mapping key": "duplicated_mapping_key",
|
||||
"bad indentation of a mapping entry": "bad_mapping_indentation",
|
||||
"bad indentation of a sequence entry": "bad_sequence_indentation",
|
||||
"deficient indentation": "deficient_indentation",
|
||||
"tab characters must not be used in indentation": "tab_indentation",
|
||||
"can not read a block mapping entry; a multiline key may not be an implicit key":
|
||||
"multiline_implicit_key",
|
||||
"a whitespace character is expected after the key-value separator within a block mapping":
|
||||
"missing_key_value_space",
|
||||
"expected ':' after a mapping key": "missing_colon",
|
||||
"missed comma between flow collection entries": "missing_comma",
|
||||
"unexpected end of the stream within a flow collection":
|
||||
"unterminated_flow_collection",
|
||||
"unexpected end of the stream within a single quoted scalar":
|
||||
"unterminated_single_quote",
|
||||
"unexpected end of the stream within a double quoted scalar":
|
||||
"unterminated_double_quote",
|
||||
"end of the stream or a document separator is expected":
|
||||
"unexpected_content",
|
||||
} as const)
|
||||
);
|
||||
|
||||
export const localizeYamlErrorReason = (
|
||||
localize: LocalizeFunc,
|
||||
reason: string | undefined
|
||||
): string | undefined => {
|
||||
const key = reason ? YAML_ERROR_REASON_KEYS.get(reason) : undefined;
|
||||
return key ? localize(`ui.components.yaml-editor.reasons.${key}`) : reason;
|
||||
};
|
||||
@@ -5,19 +5,3 @@ export const stringifyPath = (path: ConfigPath): string => path.join("/");
|
||||
|
||||
export const pathEquals = (a: ConfigPath, b: ConfigPath): boolean =>
|
||||
a.length === b.length && a.every((segment, index) => segment === b[index]);
|
||||
|
||||
export const pathStartsWith = (path: ConfigPath, prefix: ConfigPath): boolean =>
|
||||
prefix.length <= path.length &&
|
||||
prefix.every((segment, index) => segment === path[index]);
|
||||
|
||||
/** Formats a path the way it reads in YAML, e.g. `actions[1].then[0].delay`. */
|
||||
export const formatPath = (path: ConfigPath): string =>
|
||||
path
|
||||
.map((segment, index) =>
|
||||
typeof segment === "number"
|
||||
? `[${segment}]`
|
||||
: index === 0
|
||||
? segment
|
||||
: `.${segment}`
|
||||
)
|
||||
.join("");
|
||||
@@ -30,13 +30,40 @@ export class HaAutomationRow extends LitElement {
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public dim = false;
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public error = false;
|
||||
/** Makes the row a link, with the icons slot outside of it */
|
||||
@property() public href?: string;
|
||||
|
||||
@query(".row")
|
||||
private _rowElement?: HTMLDivElement;
|
||||
|
||||
@query(".link")
|
||||
private _linkElement?: HTMLAnchorElement;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const content = html`
|
||||
<div class="leading-icon-wrapper">
|
||||
<slot name="leading-icon"></slot>
|
||||
</div>
|
||||
<div class="header">
|
||||
<slot name="header"></slot>
|
||||
<slot name="event"></slot>
|
||||
</div>
|
||||
`;
|
||||
const icons = html`
|
||||
<div class="icons">
|
||||
<slot name="icons"></slot>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (this.href) {
|
||||
return html`
|
||||
<div class="row has-link">
|
||||
<a class="link" href=${this.href}>${content}</a>
|
||||
${icons}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="row"
|
||||
@@ -56,17 +83,7 @@ export class HaAutomationRow extends LitElement {
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<div class="leading-icon-wrapper">
|
||||
<slot name="leading-icon"></slot>
|
||||
</div>
|
||||
<div class="header">
|
||||
<slot name="header"></slot>
|
||||
<slot name="event"></slot>
|
||||
</div>
|
||||
|
||||
<div class="icons">
|
||||
<slot name="icons"></slot>
|
||||
</div>
|
||||
${content} ${icons}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -122,7 +139,7 @@ export class HaAutomationRow extends LitElement {
|
||||
|
||||
public focus() {
|
||||
requestAnimationFrame(() => {
|
||||
this._rowElement?.focus();
|
||||
(this._linkElement ?? this._rowElement)?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -147,6 +164,30 @@ export class HaAutomationRow extends LitElement {
|
||||
outline: var(--wa-focus-ring);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.row.has-link {
|
||||
padding-left: 0;
|
||||
padding-inline-start: 0;
|
||||
cursor: default;
|
||||
}
|
||||
.link {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-self: stretch;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-left: var(--ha-space-3);
|
||||
padding-inline-start: var(--ha-space-3);
|
||||
padding-inline-end: initial;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
border-radius: inherit;
|
||||
}
|
||||
.link:focus-visible {
|
||||
outline: var(--wa-focus-ring);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.expand-button {
|
||||
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
color: var(--ha-color-on-neutral-quiet);
|
||||
@@ -235,15 +276,6 @@ export class HaAutomationRow extends LitElement {
|
||||
:host([highlight]) .row:hover {
|
||||
background-color: rgba(var(--rgb-primary-color), 0.16);
|
||||
}
|
||||
:host([error]) .row {
|
||||
background-color: var(--ha-color-fill-danger-quiet-resting);
|
||||
}
|
||||
:host([error][selected]) .row,
|
||||
:host([error][selected]) .row:focus {
|
||||
outline: 2px solid var(--ha-color-border-danger-loud);
|
||||
outline-offset: -2px;
|
||||
background-color: var(--ha-color-fill-danger-normal-resting);
|
||||
}
|
||||
|
||||
.icons,
|
||||
.leading-icon-wrapper,
|
||||
|
||||
@@ -19,6 +19,7 @@ interface MinMaxFrame {
|
||||
maxPoint: Point;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
lastX: number;
|
||||
gapPoint: Point | undefined;
|
||||
}
|
||||
|
||||
@@ -68,6 +69,7 @@ function newFrame(
|
||||
maxPoint: point,
|
||||
maxX: x,
|
||||
maxY: y,
|
||||
lastX: x,
|
||||
gapPoint,
|
||||
};
|
||||
}
|
||||
@@ -174,6 +176,7 @@ export function downSampleLineData<
|
||||
if (!frame) {
|
||||
frames.set(frameIndex, newFrame(point, x, y, undefined));
|
||||
} else if (frame.hasValue) {
|
||||
frame.lastX = x;
|
||||
// Match the original strict-less / strict-greater comparisons so the
|
||||
// first occurrence wins on ties.
|
||||
if (y < frame.minY) {
|
||||
@@ -189,6 +192,7 @@ export function downSampleLineData<
|
||||
} else {
|
||||
// the frame held nothing but a marker so far
|
||||
frame.hasValue = true;
|
||||
frame.lastX = x;
|
||||
frame.minPoint = point;
|
||||
frame.minX = x;
|
||||
frame.minY = y;
|
||||
@@ -213,11 +217,8 @@ export function downSampleLineData<
|
||||
if (frame.gapPoint !== undefined) {
|
||||
// A marker followed by a value in its own frame is a gap that closed
|
||||
// within one frame, which is about one device pixel: too narrow to show.
|
||||
// The kept points are exactly min and max, so comparing against the
|
||||
// later of the two catches that without any work on the ingest path. A
|
||||
// marker-only frame compares against its own x and always passes.
|
||||
const lastValueX = frame.minX > frame.maxX ? frame.minX : frame.maxX;
|
||||
if (Number(getPointData(frame.gapPoint)[0]) >= lastValueX) {
|
||||
// That value may be neither min nor max, so check the frame's last x.
|
||||
if (Number(getPointData(frame.gapPoint)[0]) >= frame.lastX) {
|
||||
result.push(frame.gapPoint as T);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,9 +401,13 @@ export class HaDataTable extends LitElement {
|
||||
}
|
||||
|
||||
if (properties.has("data")) {
|
||||
// Clean up checked rows that no longer exist in the data
|
||||
// Clean up checked rows that no longer exist or can no longer be selected
|
||||
if (this._checkedRows.length) {
|
||||
const validIds = new Set(this.data.map((row) => String(row[this.id])));
|
||||
const validIds = new Set(
|
||||
this.data
|
||||
.filter((row) => row.selectable !== false)
|
||||
.map((row) => String(row[this.id]))
|
||||
);
|
||||
const validCheckedRows = this._checkedRows.filter((id) =>
|
||||
validIds.has(id)
|
||||
);
|
||||
|
||||
@@ -14,12 +14,6 @@ class HaDeviceActionPicker extends HaDeviceAutomationPicker<DeviceAction> {
|
||||
);
|
||||
}
|
||||
|
||||
protected get NOT_SUPPORTED_TEXT() {
|
||||
return this._i18n.localize(
|
||||
"ui.panel.config.devices.automation.actions.not_supported"
|
||||
);
|
||||
}
|
||||
|
||||
protected get UNKNOWN_AUTOMATION_TEXT() {
|
||||
return this._i18n.localize(
|
||||
"ui.panel.config.devices.automation.actions.unknown_action"
|
||||
|
||||
@@ -49,10 +49,6 @@ export abstract class HaDeviceAutomationPicker<
|
||||
|
||||
@property({ type: Object }) public value?: T;
|
||||
|
||||
@property({ type: Boolean }) public invalid = false;
|
||||
|
||||
@property({ attribute: "error-message" }) public errorMessage?: string;
|
||||
|
||||
@state() private _automations?: T[];
|
||||
|
||||
// Trigger an empty render so we start with a clean DOM.
|
||||
@@ -110,13 +106,6 @@ export abstract class HaDeviceAutomationPicker<
|
||||
);
|
||||
}
|
||||
|
||||
/** Error shown when the picked device has nothing to choose from. */
|
||||
protected get NOT_SUPPORTED_TEXT() {
|
||||
return this._i18n.localize(
|
||||
"ui.panel.config.devices.automation.actions.not_supported"
|
||||
);
|
||||
}
|
||||
|
||||
private _localizeDeviceAutomation: (
|
||||
localize: LocalizeFunc,
|
||||
states: HassEntities,
|
||||
@@ -174,12 +163,6 @@ export abstract class HaDeviceAutomationPicker<
|
||||
.value=${value}
|
||||
.disabled=${this._automations?.length === 0}
|
||||
.loading=${this._automations === undefined}
|
||||
.invalid=${this.invalid}
|
||||
.errorMessage=${
|
||||
this._automations?.length === 0
|
||||
? this.NOT_SUPPORTED_TEXT
|
||||
: this.errorMessage
|
||||
}
|
||||
.getItems=${this._getItems(
|
||||
value,
|
||||
this._automations,
|
||||
|
||||
@@ -14,12 +14,6 @@ class HaDeviceConditionPicker extends HaDeviceAutomationPicker<DeviceCondition>
|
||||
);
|
||||
}
|
||||
|
||||
protected get NOT_SUPPORTED_TEXT() {
|
||||
return this._i18n.localize(
|
||||
"ui.panel.config.devices.automation.conditions.not_supported"
|
||||
);
|
||||
}
|
||||
|
||||
protected get UNKNOWN_AUTOMATION_TEXT() {
|
||||
return this._i18n.localize(
|
||||
"ui.panel.config.devices.automation.conditions.unknown_condition"
|
||||
|
||||
@@ -107,10 +107,6 @@ export class HaDevicePicker extends LitElement {
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean }) public invalid = false;
|
||||
|
||||
@property({ attribute: "error-message" }) public errorMessage?: string;
|
||||
|
||||
@property() public placeholder?: string;
|
||||
|
||||
@property({ type: String, attribute: "search-label" })
|
||||
@@ -525,8 +521,6 @@ export class HaDevicePicker extends LitElement {
|
||||
.autofocus=${this.autofocus}
|
||||
.disabled=${this.disabled}
|
||||
.helper=${this.helper}
|
||||
.invalid=${this.invalid}
|
||||
.errorMessage=${this.errorMessage}
|
||||
.label=${this.label}
|
||||
.searchLabel=${this.searchLabel}
|
||||
.notFoundLabel=${this._notFoundLabel}
|
||||
|
||||
@@ -14,12 +14,6 @@ class HaDeviceTriggerPicker extends HaDeviceAutomationPicker<DeviceTrigger> {
|
||||
);
|
||||
}
|
||||
|
||||
protected get NOT_SUPPORTED_TEXT() {
|
||||
return this._i18n.localize(
|
||||
"ui.panel.config.devices.automation.triggers.not_supported"
|
||||
);
|
||||
}
|
||||
|
||||
protected get UNKNOWN_AUTOMATION_TEXT() {
|
||||
return this._i18n.localize(
|
||||
"ui.panel.config.devices.automation.triggers.unknown_trigger"
|
||||
|
||||
@@ -371,25 +371,24 @@ export class HaEntityPicker extends LitElement {
|
||||
excludeEntities?: string[],
|
||||
value?: string
|
||||
) =>
|
||||
getEntities(
|
||||
{
|
||||
states,
|
||||
...registries,
|
||||
language: i18n.language,
|
||||
translationMetadata: i18n.translationMetadata,
|
||||
localize: i18n.localize,
|
||||
},
|
||||
{
|
||||
includeDomains,
|
||||
excludeDomains,
|
||||
entityFilter,
|
||||
includeDeviceClasses,
|
||||
includeUnitOfMeasurement,
|
||||
includeEntities,
|
||||
excludeEntities,
|
||||
value,
|
||||
}
|
||||
)
|
||||
getEntities({
|
||||
states,
|
||||
entities: registries.entities,
|
||||
devices: registries.devices,
|
||||
areas: registries.areas,
|
||||
floors: registries.floors,
|
||||
localize: i18n.localize,
|
||||
language: i18n.language,
|
||||
translationMetadata: i18n.translationMetadata,
|
||||
includeDomains,
|
||||
excludeDomains,
|
||||
entityFilter,
|
||||
includeDeviceClasses,
|
||||
includeUnitOfMeasurement,
|
||||
includeEntities,
|
||||
excludeEntities,
|
||||
value,
|
||||
})
|
||||
);
|
||||
|
||||
private _sortByRelatedContext = memoizeOne(
|
||||
|
||||
@@ -6,7 +6,7 @@ import { repeat } from "lit/directives/repeat";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consume } from "../common/decorators/consume";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { fireEvent, type HASSDomEvent } from "../common/dom/fire_event";
|
||||
import { supportsFeature } from "../common/entity/supports-feature";
|
||||
import {
|
||||
CameraEntityFeature,
|
||||
@@ -17,8 +17,14 @@ import {
|
||||
STREAM_TYPE_WEB_RTC,
|
||||
type CameraCapabilities,
|
||||
type CameraEntity,
|
||||
type StreamType,
|
||||
} from "../data/camera";
|
||||
import {
|
||||
MJPEG_STREAM,
|
||||
selectCameraStreams,
|
||||
type CameraStream,
|
||||
type CameraStreamTracks,
|
||||
type CameraStreamType,
|
||||
} from "../data/camera-streams";
|
||||
import {
|
||||
apiContext,
|
||||
configContext,
|
||||
@@ -29,14 +35,7 @@ import "./ha-hls-player";
|
||||
import "./ha-web-rtc-player";
|
||||
import type { HaWebRtcPlayer } from "./ha-web-rtc-player";
|
||||
|
||||
const MJPEG_STREAM = "mjpeg";
|
||||
|
||||
export type CameraStreamType = StreamType | typeof MJPEG_STREAM;
|
||||
|
||||
interface Stream {
|
||||
type: CameraStreamType;
|
||||
visible: boolean;
|
||||
}
|
||||
export type { CameraStreamType } from "../data/camera-streams";
|
||||
|
||||
@customElement("ha-camera-stream")
|
||||
export class HaCameraStream extends LitElement {
|
||||
@@ -78,13 +77,15 @@ export class HaCameraStream extends LitElement {
|
||||
|
||||
@state() private _capabilities?: CameraCapabilities;
|
||||
|
||||
@state() private _hlsStreams?: { hasAudio: boolean; hasVideo: boolean };
|
||||
@state() private _hlsStreams?: CameraStreamTracks;
|
||||
|
||||
@state() private _webRtcStreams?: { hasAudio: boolean; hasVideo: boolean };
|
||||
@state() private _webRtcStreams?: CameraStreamTracks;
|
||||
|
||||
@query("ha-web-rtc-player") private _webRtcPlayer?: HaWebRtcPlayer;
|
||||
|
||||
private _visibleStreamType?: Stream["type"];
|
||||
private _visibleStreamType?: CameraStreamType;
|
||||
|
||||
private _capabilitiesRequest = 0;
|
||||
|
||||
private _thumbnailApi = memoizeOne(
|
||||
(
|
||||
@@ -132,6 +133,10 @@ export class HaCameraStream extends LitElement {
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._connected = true;
|
||||
if (this.hasUpdated) {
|
||||
// The players start again when reattached, so their results do as well
|
||||
this._resetStreams();
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
@@ -146,9 +151,8 @@ export class HaCameraStream extends LitElement {
|
||||
capabilities: this._capabilities,
|
||||
});
|
||||
}
|
||||
const visibleStreamType = this._currentStreams().find(
|
||||
(stream) => stream.visible
|
||||
)?.type;
|
||||
const streams = this._currentStreams();
|
||||
const visibleStreamType = streams.find((stream) => stream.visible)?.type;
|
||||
if (visibleStreamType !== this._visibleStreamType) {
|
||||
this._visibleStreamType = visibleStreamType;
|
||||
fireEvent(this, "stream-type-changed", { type: visibleStreamType });
|
||||
@@ -167,7 +171,7 @@ export class HaCameraStream extends LitElement {
|
||||
)}`;
|
||||
}
|
||||
|
||||
private _renderStream(stream: Stream) {
|
||||
private _renderStream(stream: CameraStream) {
|
||||
if (!this.stateObj) {
|
||||
return nothing;
|
||||
}
|
||||
@@ -229,15 +233,14 @@ export class HaCameraStream extends LitElement {
|
||||
return this._streams(
|
||||
this._capabilities?.frontend_stream_types,
|
||||
this._hlsStreams,
|
||||
this._webRtcStreams,
|
||||
this.muted
|
||||
this._webRtcStreams
|
||||
);
|
||||
}
|
||||
|
||||
private async _getCapabilities() {
|
||||
this._capabilities = undefined;
|
||||
this._hlsStreams = undefined;
|
||||
this._webRtcStreams = undefined;
|
||||
this._resetStreams();
|
||||
const request = ++this._capabilitiesRequest;
|
||||
if (!supportsFeature(this.stateObj!, CameraEntityFeature.STREAM)) {
|
||||
this._capabilities = {
|
||||
frontend_stream_types: [],
|
||||
@@ -245,10 +248,19 @@ export class HaCameraStream extends LitElement {
|
||||
};
|
||||
return;
|
||||
}
|
||||
this._capabilities = await fetchCameraCapabilities(
|
||||
const capabilities = await fetchCameraCapabilities(
|
||||
this._api,
|
||||
this.stateObj!.entity_id
|
||||
);
|
||||
if (request !== this._capabilitiesRequest) {
|
||||
return;
|
||||
}
|
||||
this._capabilities = capabilities;
|
||||
}
|
||||
|
||||
private _resetStreams() {
|
||||
this._hlsStreams = undefined;
|
||||
this._webRtcStreams = undefined;
|
||||
}
|
||||
|
||||
private async _getPosterUrl(): Promise<void> {
|
||||
@@ -269,89 +281,38 @@ export class HaCameraStream extends LitElement {
|
||||
fireEvent(this, "load");
|
||||
}
|
||||
|
||||
private _handleHlsStreams(ev: CustomEvent) {
|
||||
private _handleHlsStreams(ev: HASSDomEvent<HASSDomEvents["streams"]>) {
|
||||
this._hlsStreams = ev.detail;
|
||||
}
|
||||
|
||||
private _handleWebRtcStreams(ev: CustomEvent) {
|
||||
private _handleWebRtcStreams(ev: HASSDomEvent<HASSDomEvents["streams"]>) {
|
||||
if (
|
||||
!ev.detail.hasVideo &&
|
||||
!ev.detail.hasAudio &&
|
||||
this._capabilities?.frontend_stream_types.length === 1 &&
|
||||
typeof RTCPeerConnection !== "undefined"
|
||||
) {
|
||||
// Without another stream type, keep the player to show why it failed.
|
||||
// Only a browser without WebRTC falls back to MJPEG.
|
||||
return;
|
||||
}
|
||||
this._webRtcStreams = ev.detail;
|
||||
}
|
||||
|
||||
private _streams = memoizeOne(
|
||||
(
|
||||
supportedTypes?: StreamType[],
|
||||
hlsStreams?: { hasAudio: boolean; hasVideo: boolean },
|
||||
webRtcStreams?: { hasAudio: boolean; hasVideo: boolean },
|
||||
muted?: boolean
|
||||
): Stream[] => {
|
||||
supportedTypes?: CameraCapabilities["frontend_stream_types"],
|
||||
hlsStreams?: CameraStreamTracks,
|
||||
webRtcStreams?: CameraStreamTracks
|
||||
): CameraStream[] => {
|
||||
if (__DEMO__) {
|
||||
return [{ type: MJPEG_STREAM, visible: true }];
|
||||
}
|
||||
if (!supportedTypes) {
|
||||
return [];
|
||||
}
|
||||
if (supportedTypes.length === 0) {
|
||||
// doesn't support any stream type, fallback to mjpeg
|
||||
return [{ type: MJPEG_STREAM, visible: true }];
|
||||
}
|
||||
if (supportedTypes.length === 1) {
|
||||
// only 1 stream type, no need to choose
|
||||
if (
|
||||
(supportedTypes[0] === STREAM_TYPE_HLS &&
|
||||
hlsStreams?.hasVideo === false) ||
|
||||
(supportedTypes[0] === STREAM_TYPE_WEB_RTC &&
|
||||
webRtcStreams?.hasVideo === false)
|
||||
) {
|
||||
// stream failed to load, fallback to mjpeg
|
||||
return [{ type: MJPEG_STREAM, visible: true }];
|
||||
}
|
||||
return [{ type: supportedTypes[0], visible: true }];
|
||||
}
|
||||
if (hlsStreams && webRtcStreams) {
|
||||
// fully loaded
|
||||
if (
|
||||
hlsStreams.hasVideo &&
|
||||
hlsStreams.hasAudio &&
|
||||
!webRtcStreams.hasAudio &&
|
||||
!muted
|
||||
) {
|
||||
// webRTC stream is missing audio and audio is not muted, use HLS
|
||||
return [{ type: STREAM_TYPE_HLS, visible: true }];
|
||||
}
|
||||
if (webRtcStreams.hasVideo) {
|
||||
return [{ type: STREAM_TYPE_WEB_RTC, visible: true }];
|
||||
}
|
||||
// both streams failed to load, fallback to mjpeg
|
||||
return [{ type: MJPEG_STREAM, visible: true }];
|
||||
}
|
||||
|
||||
if (hlsStreams?.hasVideo !== webRtcStreams?.hasVideo) {
|
||||
// one of the two streams is loaded, or errored
|
||||
// choose the one that has video or is still loading
|
||||
if (hlsStreams?.hasVideo) {
|
||||
return [
|
||||
{ type: STREAM_TYPE_HLS, visible: true },
|
||||
{ type: STREAM_TYPE_WEB_RTC, visible: false },
|
||||
];
|
||||
}
|
||||
if (hlsStreams?.hasVideo === false) {
|
||||
return [{ type: STREAM_TYPE_WEB_RTC, visible: true }];
|
||||
}
|
||||
if (webRtcStreams?.hasVideo) {
|
||||
return [
|
||||
{ type: STREAM_TYPE_WEB_RTC, visible: true },
|
||||
{ type: STREAM_TYPE_HLS, visible: false },
|
||||
];
|
||||
}
|
||||
if (webRtcStreams?.hasVideo === false) {
|
||||
return [{ type: STREAM_TYPE_HLS, visible: true }];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
{ type: STREAM_TYPE_HLS, visible: true },
|
||||
{ type: STREAM_TYPE_WEB_RTC, visible: false },
|
||||
];
|
||||
return selectCameraStreams({
|
||||
supportedTypes,
|
||||
hlsStreams,
|
||||
webRtcStreams,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ContextType } from "@lit/context";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { consume } from "../common/decorators/consume";
|
||||
import { configContext } from "../data/context";
|
||||
import "./ha-icon-next";
|
||||
@@ -34,17 +35,14 @@ class HaConfigNavigationList extends LitElement {
|
||||
.href=${externalApp ? undefined : page.path}
|
||||
@click=${externalApp ? this._handleExternalApp : undefined}
|
||||
>
|
||||
<div
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
class=${page.iconColor ? "icon-background" : ""}
|
||||
.style="background-color: ${page.iconColor || "undefined"}"
|
||||
>
|
||||
<ha-svg-icon
|
||||
.path=${page.iconPath}
|
||||
.secondaryPath=${page.iconSecondaryPath}
|
||||
.viewBox=${page.iconViewBox}
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
style=${styleMap({ backgroundColor: page.iconColor })}
|
||||
.path=${page.iconPath}
|
||||
.secondaryPath=${page.iconSecondaryPath}
|
||||
.viewBox=${page.iconViewBox}
|
||||
></ha-svg-icon>
|
||||
<span slot="headline">${page.name}</span>
|
||||
${
|
||||
this.hasSecondary
|
||||
@@ -82,8 +80,6 @@ class HaConfigNavigationList extends LitElement {
|
||||
}
|
||||
.icon-background {
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
}
|
||||
.icon-background ha-svg-icon {
|
||||
color: #fff;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -112,6 +112,9 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
|
||||
@property({ type: Boolean, attribute: "no-sort" }) public noSort = false;
|
||||
|
||||
@property({ attribute: "pin-block" })
|
||||
public pinBlock?: "start" | "center" | "end" | "nearest";
|
||||
|
||||
// Skip the "unknown value" highlight and note for a value that is not in the
|
||||
// list but that the value renderer presents on its own.
|
||||
@property({ type: Boolean, attribute: "no-unknown-state" })
|
||||
@@ -347,6 +350,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
.searchKeys=${this.searchKeys}
|
||||
.customValueLabel=${this.customValueLabel}
|
||||
.noSort=${this.noSort}
|
||||
.pinBlock=${this.pinBlock}
|
||||
></ha-picker-combo-box>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -180,13 +180,14 @@ class HaHLSPlayer extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.entityid) {
|
||||
const entityId = this.entityid;
|
||||
if (!entityId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { url } = await fetchStreamUrl(
|
||||
{ callWS: this._api.callWS, hassUrl: this._connection.hassUrl },
|
||||
this.entityid
|
||||
entityId
|
||||
);
|
||||
|
||||
this._url = this._connection.hassUrl(url);
|
||||
@@ -198,6 +199,10 @@ class HaHLSPlayer extends LitElement {
|
||||
// eslint-disable-next-line
|
||||
console.error(err);
|
||||
|
||||
if (!this.isConnected || entityId !== this.entityid) {
|
||||
// Removed or switched camera while fetching, the result is outdated
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "streams", { hasAudio: false, hasVideo: false });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import type { TemplateResult } from "lit";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { caseInsensitiveStringCompare } from "../common/string/compare";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { customIcons } from "../data/custom_icons";
|
||||
import type { ValueChangedEvent } from "../types";
|
||||
import "./ha-combo-box-item";
|
||||
import "./ha-generic-picker";
|
||||
import type { HaGenericPicker } from "./ha-generic-picker";
|
||||
import "./ha-icon";
|
||||
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
|
||||
|
||||
@@ -50,16 +54,17 @@ const createIconItem = (icon: IconData, prefix: string): PickerComboBoxItem => {
|
||||
const loadIcons = async () => {
|
||||
ICONS_LOADED = true;
|
||||
|
||||
const iconList = await import("../../build/mdi/iconList.json");
|
||||
ICONS = iconList.default.map((icon) => createIconItem(icon, "mdi"));
|
||||
const sortIcons = (icons: PickerComboBoxItem[]) =>
|
||||
icons.sort((a, b) => caseInsensitiveStringCompare(a.id, b.id));
|
||||
|
||||
const customIconLoads: Promise<PickerComboBoxItem[]>[] = [];
|
||||
Object.keys(customIcons).forEach((iconSet) => {
|
||||
customIconLoads.push(loadCustomIconItems(iconSet));
|
||||
});
|
||||
(await Promise.all(customIconLoads)).forEach((customIconItems) => {
|
||||
ICONS.push(...customIconItems);
|
||||
});
|
||||
const iconList = await import("../../build/mdi/iconList.json");
|
||||
const mdiIcons = iconList.default.map((icon) => createIconItem(icon, "mdi"));
|
||||
ICONS = sortIcons([...mdiIcons]);
|
||||
|
||||
const customIconItems = await Promise.all(
|
||||
Object.keys(customIcons).map(loadCustomIconItems)
|
||||
);
|
||||
ICONS = sortIcons([...mdiIcons, ...customIconItems.flat()]);
|
||||
};
|
||||
|
||||
const loadCustomIconItems = async (
|
||||
@@ -81,8 +86,8 @@ const loadCustomIconItems = async (
|
||||
|
||||
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
|
||||
<ha-combo-box-item>
|
||||
<ha-icon .icon=${item.id} slot="start"></ha-icon>
|
||||
${item.id}
|
||||
<ha-icon .icon=${item.icon ?? item.id} slot="start"></ha-icon>
|
||||
${item.icon ?? item.id}
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
|
||||
@@ -104,12 +109,32 @@ export class HaIconPicker extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public invalid = false;
|
||||
|
||||
private _getIconPickerItems = (): PickerComboBoxItem[] => ICONS;
|
||||
@property({ attribute: false }) public suggestedIcons?: string[];
|
||||
|
||||
@state() @consumeLocalize() private _localize!: LocalizeFunc;
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
|
||||
private _getIconPickerItems = () =>
|
||||
this._itemsWithSuggestions(ICONS, this.suggestedIcons, this._localize);
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
super.updated(changedProps);
|
||||
if (changedProps.has("suggestedIcons") || changedProps.has("_localize")) {
|
||||
this._picker?.refreshItems();
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
allow-custom-value
|
||||
no-sort
|
||||
.pinBlock=${
|
||||
this.value && this.suggestedIcons?.includes(this.value)
|
||||
? "end"
|
||||
: undefined
|
||||
}
|
||||
.getItems=${this._getIconPickerItems}
|
||||
.helper=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
@@ -128,15 +153,35 @@ export class HaIconPicker extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _itemsWithSuggestions = memoizeOne(
|
||||
(
|
||||
icons: PickerComboBoxItem[],
|
||||
suggestedIcons: string[] | undefined,
|
||||
localize: LocalizeFunc
|
||||
): (PickerComboBoxItem | string)[] => {
|
||||
if (!suggestedIcons?.length) {
|
||||
return icons;
|
||||
}
|
||||
const suggested = new Set(suggestedIcons);
|
||||
return [
|
||||
localize("ui.components.icon_picker.suggested"),
|
||||
...suggestedIcons.map((icon) => ({ id: icon, primary: icon, icon })),
|
||||
localize("ui.components.icon_picker.all_icons"),
|
||||
...icons.filter((item) => !suggested.has(item.id)),
|
||||
];
|
||||
}
|
||||
);
|
||||
|
||||
// Filter can take a significant chunk of frame (up to 3-5 ms)
|
||||
private _filterIcons = memoizeOne(
|
||||
(
|
||||
filter: string,
|
||||
filteredItems: PickerComboBoxItem[],
|
||||
allItems: PickerComboBoxItem[]
|
||||
_allItems: PickerComboBoxItem[]
|
||||
): PickerComboBoxItem[] => {
|
||||
const normalizedFilter = filter.toLowerCase().replace(/\s+/g, "-");
|
||||
const iconItems = allItems?.length ? allItems : filteredItems;
|
||||
// The full list also holds the suggestions and their section titles
|
||||
const iconItems = ICONS.length ? ICONS : filteredItems;
|
||||
|
||||
if (!normalizedFilter.length) {
|
||||
return iconItems;
|
||||
@@ -175,8 +220,7 @@ export class HaIconPicker extends LitElement {
|
||||
protected firstUpdated() {
|
||||
if (!ICONS_LOADED) {
|
||||
loadIcons().then(() => {
|
||||
this._getIconPickerItems = (): PickerComboBoxItem[] => ICONS;
|
||||
this.requestUpdate();
|
||||
this._picker?.refreshItems();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,6 +406,8 @@ export class HaItemDisplayEditor extends LitElement {
|
||||
cursor: move;
|
||||
padding: 8px;
|
||||
margin: -8px;
|
||||
/* Keep the handle above the row's hit area so dragging can start */
|
||||
z-index: 1;
|
||||
}
|
||||
.separator {
|
||||
width: 1px;
|
||||
|
||||
@@ -201,6 +201,9 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
@property({ type: Boolean, attribute: "no-sort" }) public noSort = false;
|
||||
|
||||
@property({ attribute: "pin-block" })
|
||||
public pinBlock?: "start" | "center" | "end" | "nearest";
|
||||
|
||||
@query(".list") private _list?: HaListBase;
|
||||
|
||||
@query("ha-input-search") private _searchFieldElement?: HaInputSearch;
|
||||
@@ -453,6 +456,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
this.value
|
||||
)}
|
||||
.pinIndex=${this.value ? this._getInitialSelectedIndex() : undefined}
|
||||
.pinBlock=${this.pinBlock ?? "center"}
|
||||
@mousedown=${this._preventBlur}
|
||||
@focus=${this._focusList}
|
||||
@blur=${this._resetSelectedItem}
|
||||
@@ -685,6 +689,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
fireEvent(this, "index-selected", { index, item, newTab });
|
||||
}
|
||||
|
||||
// Fuse indexes every row as text when the first one is a section title
|
||||
private _searchableItems = memoizeOne((items: PickerComboBoxItem[]) =>
|
||||
items.filter((item) => typeof item !== "string")
|
||||
);
|
||||
|
||||
private _fuseIndex = memoizeOne(
|
||||
(states: PickerComboBoxItem[], searchKeys?: FuseWeightedKey[]) =>
|
||||
Fuse.createIndex(searchKeys || DEFAULT_SEARCH_KEYS, states)
|
||||
@@ -708,10 +717,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = this._fuseIndex(this._allItems, this.searchKeys);
|
||||
const searchableItems = this._searchableItems(this._allItems);
|
||||
const index = this._fuseIndex(searchableItems, this.searchKeys);
|
||||
|
||||
let filteredItems = multiTermSortedSearch<PickerComboBoxItem>(
|
||||
this._allItems,
|
||||
searchableItems,
|
||||
searchString,
|
||||
(item) => item.id,
|
||||
index
|
||||
|
||||
@@ -50,6 +50,7 @@ const wait = (ms: number) =>
|
||||
* @cssprop --ha-ripple-hover-color - Hover color.
|
||||
* @cssprop --ha-ripple-pressed-color - Pressed color.
|
||||
* @cssprop --ha-ripple-hover-opacity - Hover opacity. Defaults to 0.08.
|
||||
* @cssprop --ha-ripple-hover-duration - Fade duration of the hover color. Defaults to 15ms.
|
||||
* @cssprop --ha-ripple-pressed-opacity - Pressed opacity. Defaults to 0.12.
|
||||
*/
|
||||
@customElement("ha-ripple")
|
||||
@@ -396,8 +397,8 @@ export class HaRipple extends LitElement {
|
||||
var(--ha-ripple-color, var(--secondary-text-color))
|
||||
);
|
||||
transition:
|
||||
opacity 15ms linear,
|
||||
background-color 15ms linear;
|
||||
opacity var(--ha-ripple-hover-duration, 15ms) linear,
|
||||
background-color var(--ha-ripple-hover-duration, 15ms) linear;
|
||||
}
|
||||
.hovered .hover {
|
||||
opacity: var(--ha-ripple-hover-opacity, 0.08);
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
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 { ensureArray } from "../../common/array/ensure-array";
|
||||
import { consume } from "../../common/decorators/consume";
|
||||
import {
|
||||
apiContext,
|
||||
connectionContext,
|
||||
devicesContext,
|
||||
entitiesContext,
|
||||
statesContext,
|
||||
} from "../../data/context";
|
||||
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
|
||||
import { getDeviceIntegrationLookup } from "../../data/device/device_registry";
|
||||
import type { EntitySources } from "../../data/entity/entity_sources";
|
||||
@@ -15,7 +24,7 @@ import {
|
||||
filterSelectorEntities,
|
||||
} from "../../data/selector";
|
||||
import type { HassServiceTargetWithExclude } from "../../data/target";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HomeAssistant, HomeAssistantConnection } from "../../types";
|
||||
import "../ha-target-picker";
|
||||
|
||||
type TargetSelectorFilters = NonNullable<TargetSelector["target"]>;
|
||||
@@ -28,8 +37,6 @@ const ALL_DEVICES = () => true;
|
||||
|
||||
@customElement("ha-selector-target")
|
||||
export class HaTargetSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selector!: TargetSelector;
|
||||
|
||||
@property({ type: Object }) public value?: HassServiceTargetWithExclude;
|
||||
@@ -44,9 +51,27 @@ export class HaTargetSelector extends LitElement {
|
||||
|
||||
@state() private _createDomains: string[] | undefined;
|
||||
|
||||
@consume({ context: apiContext, subscribe: true })
|
||||
private _api?: ContextType<typeof apiContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: connectionContext, subscribe: true })
|
||||
private _connection?: ContextType<typeof connectionContext>;
|
||||
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
private _states?: ContextType<typeof statesContext>;
|
||||
|
||||
@consume({ context: entitiesContext, subscribe: true })
|
||||
private _entities?: ContextType<typeof entitiesContext>;
|
||||
|
||||
@consume({ context: devicesContext, subscribe: true })
|
||||
private _devices?: ContextType<typeof devicesContext>;
|
||||
|
||||
private _fetchedEntitySources = false;
|
||||
|
||||
private _deviceIntegrationLookup = memoizeOne(
|
||||
(entities: HomeAssistant["entities"]) =>
|
||||
getDeviceIntegrationLookup(Object.values(entities))
|
||||
(entities: HomeAssistant["entities"] | undefined) =>
|
||||
getDeviceIntegrationLookup(Object.values(entities ?? {}))
|
||||
);
|
||||
|
||||
private _hasIntegration(selector: TargetSelector) {
|
||||
@@ -62,18 +87,31 @@ export class HaTargetSelector extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues<this>): void {
|
||||
protected updated(changedProperties: PropertyValues): void {
|
||||
super.updated(changedProperties);
|
||||
const oldConnection = changedProperties.get("_connection") as
|
||||
HomeAssistantConnection | undefined;
|
||||
if (
|
||||
changedProperties.has("selector") &&
|
||||
this._hasIntegration(this.selector) &&
|
||||
!this._entitySources
|
||||
oldConnection &&
|
||||
this._connection &&
|
||||
(oldConnection.connection !== this._connection.connection ||
|
||||
(!oldConnection.connected && this._connection.connected))
|
||||
) {
|
||||
fetchEntitySourcesWithCache(this.hass.callWS, this.hass.states).then(
|
||||
(sources) => {
|
||||
this._fetchedEntitySources = false;
|
||||
}
|
||||
if (
|
||||
this._api &&
|
||||
!this._fetchedEntitySources &&
|
||||
this._hasIntegration(this.selector)
|
||||
) {
|
||||
this._fetchedEntitySources = true;
|
||||
fetchEntitySourcesWithCache(this._api.callWS, this._states ?? {})
|
||||
.then((sources) => {
|
||||
this._entitySources = sources;
|
||||
}
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
this._entitySources ??= {};
|
||||
});
|
||||
}
|
||||
if (changedProperties.has("selector")) {
|
||||
this._createDomains = computeCreateDomains(this.selector);
|
||||
@@ -87,7 +125,6 @@ export class HaTargetSelector extends LitElement {
|
||||
|
||||
return html` ${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-target-picker
|
||||
.hass=${this.hass}
|
||||
.value=${this.value}
|
||||
.helper=${this.helper}
|
||||
.deviceFilter=${this._deviceFilter(this.selector.target?.device)}
|
||||
@@ -118,8 +155,8 @@ export class HaTargetSelector extends LitElement {
|
||||
filter,
|
||||
entity,
|
||||
entitySources,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
this._entities,
|
||||
this._devices
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -132,7 +169,7 @@ export class HaTargetSelector extends LitElement {
|
||||
}
|
||||
return (device: DeviceRegistryEntry): boolean => {
|
||||
const deviceIntegrations = this._hasIntegration(this.selector)
|
||||
? this._deviceIntegrationLookup(this.hass.entities)
|
||||
? this._deviceIntegrationLookup(this._entities)
|
||||
: undefined;
|
||||
return ensureArray(filters).some((filter) =>
|
||||
filterSelectorDevices(filter, device, deviceIntegrations)
|
||||
|
||||
@@ -41,7 +41,6 @@ import "./ha-selector/ha-selector";
|
||||
import type { HaSelector } from "./ha-selector/ha-selector";
|
||||
import "./ha-service-picker";
|
||||
import "./ha-service-section-icon";
|
||||
import "./ha-input-helper-text";
|
||||
import "./ha-settings-row";
|
||||
import "./ha-yaml-editor";
|
||||
import type { HaYamlEditor } from "./ha-yaml-editor";
|
||||
@@ -120,15 +119,6 @@ export class HaServiceControl extends LitElement {
|
||||
@property({ attribute: "hide-description", type: Boolean })
|
||||
public hideDescription = false;
|
||||
|
||||
/** Error shown below the target picker. */
|
||||
@property({ attribute: false }) public targetError?: string;
|
||||
|
||||
/** Errors shown below the data fields, keyed by field. */
|
||||
@property({ attribute: false }) public dataErrors?: Record<string, string>;
|
||||
|
||||
/** Error of the data as a whole. */
|
||||
@property({ attribute: false }) public dataError?: string;
|
||||
|
||||
@state() private _value!: this["value"];
|
||||
|
||||
@state() private _checkedKeys = new Set();
|
||||
@@ -552,14 +542,13 @@ export class HaServiceControl extends LitElement {
|
||||
${
|
||||
serviceData && "target" in serviceData
|
||||
? html`<ha-selector
|
||||
class="target-selector"
|
||||
.hass=${this.hass}
|
||||
.selector=${targetSelector}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._targetChanged}
|
||||
.value=${this._value?.target}
|
||||
></ha-selector>
|
||||
${this._renderError(this.targetError)}`
|
||||
class="target-selector"
|
||||
.hass=${this.hass}
|
||||
.selector=${targetSelector}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._targetChanged}
|
||||
.value=${this._value?.target}
|
||||
></ha-selector>`
|
||||
: entityId
|
||||
? html`<ha-entity-picker
|
||||
.disabled=${this.disabled}
|
||||
@@ -605,10 +594,7 @@ export class HaServiceControl extends LitElement {
|
||||
this._hasFilteredFields(fields, targetEntities)
|
||||
? html`<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${
|
||||
!dataField.collapsed ||
|
||||
fields.some((field) => this.dataErrors?.[field.key])
|
||||
}
|
||||
.expanded=${!dataField.collapsed}
|
||||
.header=${
|
||||
this.hass.localize(
|
||||
`component.${domain}.services.${serviceName}.sections.${dataField.key}.name`,
|
||||
@@ -640,12 +626,7 @@ export class HaServiceControl extends LitElement {
|
||||
</ha-expansion-panel>`
|
||||
: nothing;
|
||||
})
|
||||
}
|
||||
${this._unshownErrors(
|
||||
serviceData,
|
||||
targetEntities,
|
||||
Boolean(shouldRenderServiceDataYaml)
|
||||
).map((error) => this._renderError(error))}`;
|
||||
} `;
|
||||
}
|
||||
|
||||
private _getSectionDescription(
|
||||
@@ -709,25 +690,6 @@ export class HaServiceControl extends LitElement {
|
||||
? this.hass.services[domain][serviceName].description_placeholders
|
||||
: undefined;
|
||||
|
||||
const fieldError = this.dataErrors?.[dataField.key];
|
||||
const selectorTemplate = html`<ha-selector
|
||||
.context=${this._selectorContext(targetEntities)}
|
||||
.disabled=${
|
||||
this.disabled ||
|
||||
(showOptional &&
|
||||
!this._checkedKeys.has(dataField.key) &&
|
||||
(!this._value?.data || this._value.data[dataField.key] === undefined))
|
||||
}
|
||||
.hass=${this.hass}
|
||||
.selector=${selector}
|
||||
.key=${dataField.key}
|
||||
@value-changed=${this._serviceDataChanged}
|
||||
.value=${this._value?.data ? this._value.data[dataField.key] : undefined}
|
||||
.placeholder=${dataField.default}
|
||||
.localizeValue=${this._localizeValueCallback}
|
||||
.required=${dataField.required}
|
||||
></ha-selector>`;
|
||||
|
||||
return dataField.selector
|
||||
? html`<ha-settings-row
|
||||
.narrow=${
|
||||
@@ -780,50 +742,30 @@ export class HaServiceControl extends LitElement {
|
||||
}
|
||||
></ha-markdown>
|
||||
</span>
|
||||
<div class="field">
|
||||
${selectorTemplate}${this._renderError(fieldError)}
|
||||
</div>
|
||||
<ha-selector
|
||||
.context=${this._selectorContext(targetEntities)}
|
||||
.disabled=${
|
||||
this.disabled ||
|
||||
(showOptional &&
|
||||
!this._checkedKeys.has(dataField.key) &&
|
||||
(!this._value?.data ||
|
||||
this._value.data[dataField.key] === undefined))
|
||||
}
|
||||
.hass=${this.hass}
|
||||
.selector=${selector}
|
||||
.key=${dataField.key}
|
||||
@value-changed=${this._serviceDataChanged}
|
||||
.value=${
|
||||
this._value?.data ? this._value.data[dataField.key] : undefined
|
||||
}
|
||||
.placeholder=${dataField.default}
|
||||
.localizeValue=${this._localizeValueCallback}
|
||||
.required=${dataField.required}
|
||||
></ha-selector>
|
||||
</ha-settings-row>`
|
||||
: "";
|
||||
};
|
||||
|
||||
// Errors without a field to show them on, e.g. of a field no target
|
||||
// supports or of a target the action doesn't have a picker for
|
||||
private _unshownErrors(
|
||||
serviceData: ExtHassService | undefined,
|
||||
targetEntities: string[] | null,
|
||||
dataYaml: boolean
|
||||
): string[] {
|
||||
const targetShown = Boolean(serviceData && "target" in serviceData);
|
||||
const shownFields = new Set(
|
||||
dataYaml
|
||||
? []
|
||||
: (serviceData?.flatFields ?? [])
|
||||
.filter(
|
||||
(field) =>
|
||||
field.selector &&
|
||||
(!field.filter ||
|
||||
this._filterField(field.filter, targetEntities))
|
||||
)
|
||||
.map((field) => field.key)
|
||||
);
|
||||
return [
|
||||
...(this.targetError && !targetShown ? [this.targetError] : []),
|
||||
...(this.dataError ? [this.dataError] : []),
|
||||
...Object.entries(this.dataErrors ?? {})
|
||||
.filter(([key]) => !shownFields.has(key))
|
||||
.map(([key, error]) => `${key}: ${error}`),
|
||||
];
|
||||
}
|
||||
|
||||
private _renderError(error: string | undefined) {
|
||||
return error
|
||||
? html`<ha-input-helper-text class="error"
|
||||
>${error}</ha-input-helper-text
|
||||
>`
|
||||
: nothing;
|
||||
}
|
||||
|
||||
private _selectorContext = memoizeOne((targetEntities: string[] | null) => ({
|
||||
filter_entity: targetEntities || undefined,
|
||||
}));
|
||||
@@ -1049,17 +991,6 @@ export class HaServiceControl extends LitElement {
|
||||
ha-settings-row {
|
||||
padding: var(--service-control-padding, 0 var(--ha-space-4));
|
||||
}
|
||||
/* Every field has this wrapper, so the selector isn't rebuilt when its
|
||||
error comes or goes */
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
ha-input-helper-text.error {
|
||||
color: var(--ha-color-on-danger-quiet);
|
||||
padding-top: var(--ha-space-1);
|
||||
padding-bottom: var(--ha-space-2);
|
||||
}
|
||||
ha-settings-row[narrow] {
|
||||
padding-bottom: var(--ha-space-2);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import { consume } from "../common/decorators/consume";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { entitiesContext, statesContext } from "../data/context";
|
||||
import type { DataTableFiltersValue } from "../data/data_table_filters";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../data/entity/entity";
|
||||
import { entityTypeFilterFunc } from "../data/entity/entity_type";
|
||||
@@ -108,8 +111,6 @@ export const applySourceFilters = (
|
||||
*/
|
||||
@customElement("ha-sources-picker")
|
||||
export class HaSourcesPicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public value: HassServiceTarget = {};
|
||||
|
||||
/** The filters the page applies, so the picker counts what they keep. */
|
||||
@@ -125,6 +126,14 @@ export class HaSourcesPicker extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@state()
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
private _states!: ContextType<typeof statesContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: entitiesContext, subscribe: true })
|
||||
private _entities!: ContextType<typeof entitiesContext>;
|
||||
|
||||
protected render() {
|
||||
const noTargets = countTargets(this.value) === 0;
|
||||
|
||||
@@ -136,13 +145,12 @@ export class HaSourcesPicker extends LitElement {
|
||||
}
|
||||
<ha-target-picker
|
||||
class=${classMap({ "no-padding-top": noTargets })}
|
||||
.hass=${this.hass}
|
||||
.value=${this.value}
|
||||
.entityFilter=${this.entityFilter}
|
||||
.activeFilter=${this._activeFilter(
|
||||
this.filters,
|
||||
this.hass.states,
|
||||
this.hass.entities,
|
||||
this._states,
|
||||
this._entities,
|
||||
this.entitySources
|
||||
)}
|
||||
.primaryEntitiesOnly=${false}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import "@home-assistant/webawesome/dist/components/popover/popover";
|
||||
import { mdiPlaylistMinus, mdiPlus, mdiTextureBox } from "@mdi/js";
|
||||
import Fuse from "fuse.js";
|
||||
@@ -8,6 +9,7 @@ import { customElement, property, query, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consume } from "../common/decorators/consume";
|
||||
import { transform } from "../common/decorators/transform";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import type { HASSDomEvent } from "../common/dom/fire_event";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
@@ -21,7 +23,17 @@ import {
|
||||
type FloorComboBoxItem,
|
||||
} from "../data/area_floor_picker";
|
||||
import { getConfigEntries, type ConfigEntry } from "../data/config_entries";
|
||||
import { labelsContext } from "../data/context";
|
||||
import {
|
||||
apiContext,
|
||||
brandsAccessTokenContext,
|
||||
configContext,
|
||||
connectionContext,
|
||||
internationalizationContext,
|
||||
labelsContext,
|
||||
registriesContext,
|
||||
statesContext,
|
||||
uiContext,
|
||||
} from "../data/context";
|
||||
import {
|
||||
deviceComboBoxKeys,
|
||||
getDevices,
|
||||
@@ -47,7 +59,6 @@ import {
|
||||
type TargetType,
|
||||
type TargetTypeFloorless,
|
||||
} from "../data/target";
|
||||
import { SubscribeMixin } from "../mixins/subscribe-mixin";
|
||||
import { isHelperDomain } from "../panels/config/helpers/const";
|
||||
import { showHelperDetailDialog } from "../panels/config/helpers/show-dialog-helper-detail";
|
||||
import {
|
||||
@@ -55,7 +66,11 @@ import {
|
||||
multiTermSortedSearch,
|
||||
type FuseWeightedKey,
|
||||
} from "../resources/fuseMultiTerm";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import type {
|
||||
HomeAssistant,
|
||||
HomeAssistantUI,
|
||||
ValueChangedEvent,
|
||||
} from "../types";
|
||||
import { brandsUrl } from "../util/brands-url";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
|
||||
import "./ha-button";
|
||||
@@ -89,9 +104,7 @@ const isTargetType = (value: string): value is TargetType =>
|
||||
value === "floor";
|
||||
|
||||
@customElement("ha-target-picker")
|
||||
export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
export class HaTargetPicker extends LitElement {
|
||||
@property({ attribute: false }) public value?: HassServiceTargetWithExclude;
|
||||
|
||||
@property() public helper?: string;
|
||||
@@ -157,6 +170,47 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
@consume({ context: labelsContext, subscribe: true })
|
||||
private _labelRegistry!: LabelRegistryEntry[];
|
||||
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
private _states!: ContextType<typeof statesContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
@transform<HomeAssistant["states"], boolean>({
|
||||
transformer: function (this: HaTargetPicker, states) {
|
||||
return !!this._pendingEntityId && !!states?.[this._pendingEntityId];
|
||||
},
|
||||
watch: ["_pendingEntityId"],
|
||||
})
|
||||
private _pendingEntityLoaded = false;
|
||||
|
||||
@state()
|
||||
@consume({ context: registriesContext, subscribe: true })
|
||||
private _registries!: ContextType<typeof registriesContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: uiContext, subscribe: true })
|
||||
@transform<HomeAssistantUI, boolean | undefined>({
|
||||
transformer: (ui) => ui?.themes?.darkMode,
|
||||
})
|
||||
private _darkMode?: boolean;
|
||||
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _config?: ContextType<typeof configContext>;
|
||||
|
||||
@consume({ context: apiContext, subscribe: true })
|
||||
private _api?: ContextType<typeof apiContext>;
|
||||
|
||||
@consume({ context: connectionContext, subscribe: true })
|
||||
private _connection?: ContextType<typeof connectionContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: brandsAccessTokenContext, subscribe: true })
|
||||
private _brandsAccessToken?: string;
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
|
||||
@query("ha-generic-picker.exclude") private _excludePicker?: HaGenericPicker;
|
||||
@@ -167,7 +221,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
private _getDevicesMemoized = memoizeOne(
|
||||
(
|
||||
hass: HomeAssistant,
|
||||
states: HomeAssistant["states"],
|
||||
registries: ContextType<typeof registriesContext>,
|
||||
i18n: ContextType<typeof internationalizationContext>,
|
||||
configEntryLookup: Record<string, ConfigEntry>,
|
||||
includeDomains?: string[],
|
||||
includeDeviceClasses?: string[],
|
||||
@@ -178,13 +234,13 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
idPrefix?: string
|
||||
) =>
|
||||
getDevices({
|
||||
states: hass.states,
|
||||
areas: hass.areas,
|
||||
devices: hass.devices,
|
||||
entities: hass.entities,
|
||||
localize: hass.localize,
|
||||
language: hass.locale.language,
|
||||
translationMetadata: hass.translationMetadata,
|
||||
states,
|
||||
areas: registries.areas,
|
||||
devices: registries.devices,
|
||||
entities: registries.entities,
|
||||
localize: i18n.localize,
|
||||
language: i18n.locale.language,
|
||||
translationMetadata: i18n.translationMetadata,
|
||||
configEntryLookup,
|
||||
includeDomains,
|
||||
includeDeviceClasses,
|
||||
@@ -201,7 +257,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
private _getEntitiesMemoized = memoizeOne(
|
||||
(
|
||||
hass: HomeAssistant,
|
||||
states: HomeAssistant["states"],
|
||||
registries: ContextType<typeof registriesContext>,
|
||||
i18n: ContextType<typeof internationalizationContext>,
|
||||
includeDomains?: string[],
|
||||
excludeDomains?: string[],
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc,
|
||||
@@ -212,7 +270,15 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
value?: string,
|
||||
idPrefix?: string
|
||||
) =>
|
||||
getEntities(hass, {
|
||||
getEntities({
|
||||
states,
|
||||
entities: registries.entities,
|
||||
devices: registries.devices,
|
||||
areas: registries.areas,
|
||||
floors: registries.floors,
|
||||
localize: i18n.localize,
|
||||
language: i18n.language,
|
||||
translationMetadata: i18n.translationMetadata,
|
||||
includeDomains,
|
||||
excludeDomains,
|
||||
entityFilter,
|
||||
@@ -228,7 +294,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
private _getAreasAndFloorsMemoized = memoizeOne(getAreasAndFloors);
|
||||
|
||||
private get _showEntityId() {
|
||||
return this.hass.userData?.showEntityIdPicker;
|
||||
return this._config?.userData?.showEntityIdPicker;
|
||||
}
|
||||
|
||||
private _fuseIndexes = {
|
||||
@@ -248,36 +314,34 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _pendingEntityId?: string;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
public willUpdate(changedProps: PropertyValues) {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
if (!this.hasUpdated) {
|
||||
this._loadConfigEntries();
|
||||
}
|
||||
|
||||
const devices = this._registries?.devices;
|
||||
const devicesChanged =
|
||||
changedProps.has("hass") &&
|
||||
this.hass.devices !== changedProps.get("hass")?.devices;
|
||||
changedProps.has("_registries") &&
|
||||
devices !==
|
||||
(
|
||||
changedProps.get("_registries") as
|
||||
ContextType<typeof registriesContext> | undefined
|
||||
)?.devices;
|
||||
if (
|
||||
(changedProps.has("value") || devicesChanged) &&
|
||||
this.hass &&
|
||||
devices &&
|
||||
this._compositeSplits === undefined &&
|
||||
!this._loadingCompositeSplits &&
|
||||
this.value?.device_id &&
|
||||
ensureArray(this.value.device_id).some(
|
||||
(deviceId) => !this.hass.devices[deviceId]
|
||||
)
|
||||
ensureArray(this.value.device_id).some((deviceId) => !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();
|
||||
}
|
||||
|
||||
const registriesChanged =
|
||||
changedProps.has("hass") &&
|
||||
(devicesChanged ||
|
||||
this.hass.entities !== changedProps.get("hass")?.entities ||
|
||||
this.hass.areas !== changedProps.get("hass")?.areas);
|
||||
if (
|
||||
changedProps.has("value") ||
|
||||
changedProps.has("allowExclude") ||
|
||||
@@ -285,18 +349,12 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
changedProps.has("deviceFilter") ||
|
||||
changedProps.has("includeDomains") ||
|
||||
changedProps.has("includeDeviceClasses") ||
|
||||
changedProps.has("entityFilter") ||
|
||||
registriesChanged
|
||||
changedProps.has("entityFilter")
|
||||
) {
|
||||
this._exclusions.resolve();
|
||||
}
|
||||
|
||||
if (
|
||||
this._pendingEntityId &&
|
||||
changedProps.has("hass") &&
|
||||
this.hass.states !== changedProps.get("hass")?.states &&
|
||||
this.hass.states[this._pendingEntityId]
|
||||
) {
|
||||
if (this._pendingEntityId && this._pendingEntityLoaded) {
|
||||
this._addTarget(this._pendingEntityId, "entity");
|
||||
this._pendingEntityId = undefined;
|
||||
}
|
||||
@@ -453,7 +511,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<div class="exclusion-list-label">${this.hass.localize(label)}</div>
|
||||
<div class="exclusion-list-label">${this._i18n.localize(label)}</div>
|
||||
<ha-list-base>
|
||||
${items.map(
|
||||
(item) =>
|
||||
@@ -486,16 +544,16 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
.helper=${this.helper}
|
||||
.sections=${sections}
|
||||
.notFoundLabel=${this._noTargetFoundLabel}
|
||||
.emptyLabel=${this.hass.localize(
|
||||
.emptyLabel=${this._i18n.localize(
|
||||
"ui.components.target-picker.no_targets"
|
||||
)}
|
||||
.selectedSection=${this._selectedSection}
|
||||
.popoverAnchor=${this._replaceTargetAnchor}
|
||||
.rowRenderer=${this._renderRow}
|
||||
.rowRenderer=${this._rowRenderer(this._brandsAccessToken)}
|
||||
.getItems=${this._getItems}
|
||||
@value-changed=${this._targetPicked}
|
||||
@picker-closed=${this._handlePickerClosed}
|
||||
.addButtonLabel=${this.hass.localize(
|
||||
.addButtonLabel=${this._i18n.localize(
|
||||
"ui.components.target-picker.add_target"
|
||||
)}
|
||||
.getAdditionalItems=${this._getAdditionalItems}
|
||||
@@ -510,11 +568,11 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
.disabled=${this.disabled}
|
||||
.sections=${sections}
|
||||
.notFoundLabel=${this._noTargetFoundLabel}
|
||||
.emptyLabel=${this.hass.localize(
|
||||
.emptyLabel=${this._i18n.localize(
|
||||
"ui.components.target-picker.no_targets"
|
||||
)}
|
||||
.selectedSection=${this._excludeSection}
|
||||
.rowRenderer=${this._renderRow}
|
||||
.rowRenderer=${this._rowRenderer(this._brandsAccessToken)}
|
||||
.getItems=${this._getExcludeItems}
|
||||
@value-changed=${this._excludePicked}
|
||||
>
|
||||
@@ -529,7 +587,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
.path=${mdiPlaylistMinus}
|
||||
slot="start"
|
||||
></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
${this._i18n.localize(
|
||||
"ui.components.target-picker.exclude_target"
|
||||
)}
|
||||
</ha-button>
|
||||
@@ -545,20 +603,20 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
return [
|
||||
{
|
||||
id: "entity",
|
||||
label: this.hass.localize("ui.components.target-picker.type.entities"),
|
||||
label: this._i18n.localize("ui.components.target-picker.type.entities"),
|
||||
},
|
||||
{
|
||||
id: "device",
|
||||
label: this.hass.localize("ui.components.target-picker.type.devices"),
|
||||
label: this._i18n.localize("ui.components.target-picker.type.devices"),
|
||||
},
|
||||
{
|
||||
id: "area",
|
||||
label: this.hass.localize("ui.components.target-picker.type.areas"),
|
||||
label: this._i18n.localize("ui.components.target-picker.type.areas"),
|
||||
},
|
||||
"separator" as const,
|
||||
{
|
||||
id: "label",
|
||||
label: this.hass.localize("ui.components.target-picker.type.labels"),
|
||||
label: this._i18n.localize("ui.components.target-picker.type.labels"),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -687,7 +745,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
this._excludeSection = section as TargetTypeFloorless | undefined;
|
||||
|
||||
return this._getItemsMemoized(
|
||||
this.hass.localize,
|
||||
this._i18n,
|
||||
this._excludeEntityFilter(
|
||||
this.entityFilter,
|
||||
this._exclusions.includedEntities
|
||||
@@ -699,7 +757,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
undefined,
|
||||
searchString,
|
||||
this._configEntryLookup,
|
||||
this.hass.entities,
|
||||
this._states,
|
||||
this._registries,
|
||||
this._excludeSection,
|
||||
this._excludePickerIncludedDevices(this.value)
|
||||
);
|
||||
@@ -816,7 +875,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
domain,
|
||||
dialogClosedCallback: (item) => {
|
||||
if (item.entityId) {
|
||||
if (this.hass.states[item.entityId]) {
|
||||
if (this._states?.[item.entityId]) {
|
||||
this._addTarget(item.entityId, "entity");
|
||||
} else {
|
||||
this._pendingEntityId = item.entityId;
|
||||
@@ -903,7 +962,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
this._selectedSection = section as TargetTypeFloorless | undefined;
|
||||
|
||||
return this._getItemsMemoized(
|
||||
this.hass.localize,
|
||||
this._i18n,
|
||||
this.entityFilter,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
@@ -912,14 +971,15 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
this._replaceTarget,
|
||||
searchString,
|
||||
this._configEntryLookup,
|
||||
this.hass.entities,
|
||||
this._states,
|
||||
this._registries,
|
||||
this._selectedSection
|
||||
);
|
||||
};
|
||||
|
||||
private _getItemsMemoized = memoizeOne(
|
||||
(
|
||||
localize: HomeAssistant["localize"],
|
||||
i18n: ContextType<typeof internationalizationContext>,
|
||||
entityFilter: this["entityFilter"],
|
||||
deviceFilter: this["deviceFilter"],
|
||||
includeDomains: this["includeDomains"],
|
||||
@@ -928,11 +988,13 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
replaceTarget: TargetItem | undefined,
|
||||
searchTerm: string,
|
||||
configEntryLookup: Record<string, ConfigEntry>,
|
||||
_entities: HomeAssistant["entities"],
|
||||
states: HomeAssistant["states"],
|
||||
registries: ContextType<typeof registriesContext>,
|
||||
filterType?: TargetTypeFloorless,
|
||||
// Hidden without hiding their child devices.
|
||||
hiddenDeviceIds?: string[]
|
||||
) => {
|
||||
const localize = i18n.localize;
|
||||
const replacingEntityId =
|
||||
replaceTarget?.type === "entity" ? replaceTarget.id : undefined;
|
||||
const replacingDeviceId =
|
||||
@@ -950,7 +1012,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
if (!filterType || filterType === "entity") {
|
||||
let entityItems = this._getEntitiesMemoized(
|
||||
this.hass,
|
||||
states,
|
||||
registries,
|
||||
i18n,
|
||||
includeDomains,
|
||||
undefined,
|
||||
entityFilter,
|
||||
@@ -1004,7 +1068,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
...(hiddenDeviceIds ?? []),
|
||||
...(selectedDeviceIds ?? []),
|
||||
...(selectedDeviceIds
|
||||
? Object.values(this.hass.devices)
|
||||
? Object.values(registries.devices)
|
||||
.filter(
|
||||
(device) =>
|
||||
device.parent_device_id !== null &&
|
||||
@@ -1015,7 +1079,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
]
|
||||
: undefined;
|
||||
let deviceItems = this._getDevicesMemoized(
|
||||
this.hass,
|
||||
states,
|
||||
registries,
|
||||
i18n,
|
||||
configEntryLookup,
|
||||
includeDomains,
|
||||
includeDeviceClasses,
|
||||
@@ -1066,11 +1132,11 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
if (!filterType || filterType === "area") {
|
||||
let areasAndFloors = this._getAreasAndFloorsMemoized(
|
||||
this.hass.states,
|
||||
this.hass.floors,
|
||||
this.hass.areas,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
states,
|
||||
registries.floors,
|
||||
registries.areas,
|
||||
registries.devices,
|
||||
registries.entities,
|
||||
memoizeOne((value: AreaFloorValue): string =>
|
||||
[value.type, value.id].join(SEPARATOR)
|
||||
),
|
||||
@@ -1131,10 +1197,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
if (!filterType || filterType === "label") {
|
||||
let labels = this._getLabelsMemoized(
|
||||
this.hass.states,
|
||||
this.hass.areas,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
states,
|
||||
registries.areas,
|
||||
registries.devices,
|
||||
registries.entities,
|
||||
this._labelRegistry,
|
||||
includeDomains,
|
||||
undefined,
|
||||
@@ -1195,30 +1261,32 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _getAdditionalItems = () => this._getCreateItems(this.createDomains);
|
||||
private _getAdditionalItems = () =>
|
||||
this._getCreateItems(this.createDomains, this._i18n.localize);
|
||||
|
||||
private _getCreateItems = memoizeOne(
|
||||
(createDomains: this["createDomains"]) => {
|
||||
(
|
||||
createDomains: this["createDomains"],
|
||||
localize: ContextType<typeof internationalizationContext>["localize"]
|
||||
) => {
|
||||
if (!createDomains?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return createDomains.map((domain) => {
|
||||
const primary = this.hass.localize(
|
||||
const primary = localize(
|
||||
"ui.components.entity.entity-picker.create_helper",
|
||||
{
|
||||
domain: isHelperDomain(domain)
|
||||
? this.hass.localize(`ui.panel.config.helpers.types.${domain}`)
|
||||
: domainToName(this.hass.localize, domain),
|
||||
? localize(`ui.panel.config.helpers.types.${domain}`)
|
||||
: domainToName(localize, domain),
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
id: CREATE_ID + domain,
|
||||
primary: primary,
|
||||
secondary: this.hass.localize(
|
||||
"ui.components.entity.entity-picker.new_entity"
|
||||
),
|
||||
secondary: localize("ui.components.entity.entity-picker.new_entity"),
|
||||
icon_path: mdiPlus,
|
||||
} satisfies EntityComboBoxItem;
|
||||
});
|
||||
@@ -1226,18 +1294,24 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
);
|
||||
|
||||
private async _loadConfigEntries() {
|
||||
const configEntries = await getConfigEntries(this.hass);
|
||||
if (!this._api) {
|
||||
return;
|
||||
}
|
||||
const configEntries = await getConfigEntries(this._api);
|
||||
this._configEntryLookup = Object.fromEntries(
|
||||
configEntries.map((entry) => [entry.entry_id, entry])
|
||||
);
|
||||
}
|
||||
|
||||
private async _loadCompositeSplits() {
|
||||
if (!this._api || !this._connection) {
|
||||
return;
|
||||
}
|
||||
this._loadingCompositeSplits = true;
|
||||
try {
|
||||
this._compositeSplits = await fetchDeviceCompositeSplits(
|
||||
this.hass.connection,
|
||||
this.hass.callWS
|
||||
this._connection.connection,
|
||||
this._api.callWS
|
||||
);
|
||||
} catch (_err) {
|
||||
this._compositeSplits = {};
|
||||
@@ -1262,6 +1336,13 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
// A new renderer when the brands token arrives, so open lists redraw icons.
|
||||
private _rowRenderer = memoizeOne(
|
||||
(_brandsAccessToken: string | undefined) =>
|
||||
(item: Parameters<HaTargetPicker["_renderRow"]>[0]) =>
|
||||
this._renderRow(item)
|
||||
);
|
||||
|
||||
private _renderRow = (
|
||||
item:
|
||||
| PickerComboBoxItem
|
||||
@@ -1281,8 +1362,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
type === "device" && !!(item as DevicePickerItem).is_child;
|
||||
if (type === "area" || type === "floor" || isChildDeviceRow) {
|
||||
rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
this._i18n.language,
|
||||
this._i18n.translationMetadata.translations
|
||||
);
|
||||
hasFloor =
|
||||
type === "area" && !!(item as FloorComboBoxItem).area?.floor_id;
|
||||
@@ -1348,9 +1429,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
{
|
||||
domain: (item as DevicePickerItem).domain!,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes.darkMode,
|
||||
darkOptimized: this._darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
this._config?.auth?.data.hassUrl
|
||||
)}
|
||||
/>
|
||||
`
|
||||
@@ -1396,7 +1477,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
};
|
||||
|
||||
private _noTargetFoundLabel = (search: string) =>
|
||||
this.hass.localize("ui.components.target-picker.no_target_found", {
|
||||
this._i18n.localize("ui.components.target-picker.no_target_found", {
|
||||
term: html`<b>‘${search}’</b>`,
|
||||
});
|
||||
|
||||
@@ -1404,7 +1485,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
caseInsensitiveStringCompare(
|
||||
(entityA as PickerComboBoxItem).sorting_label!,
|
||||
(entityB as PickerComboBoxItem).sorting_label!,
|
||||
this.hass?.locale.language ?? navigator.language
|
||||
this._i18n?.locale.language ?? navigator.language
|
||||
);
|
||||
|
||||
static styles = css`
|
||||
|
||||
@@ -24,8 +24,23 @@ import { showToast } from "../util/toast";
|
||||
|
||||
const HIDDEN_CLEANUP_DELAY = 60000;
|
||||
|
||||
// How long the backend gets to answer the offer, it has to connect to the camera
|
||||
const ANSWER_TIMEOUT = 20000;
|
||||
|
||||
// How long finding a network path gets after the answer. Browsers can take
|
||||
// minutes to give up on their own, or never do.
|
||||
const ICE_TIMEOUT = 30000;
|
||||
|
||||
// How often a stream that lost its connection or got a backend error is
|
||||
// started again. A new connection that gets no answer or finds no path is not
|
||||
// retried, as a retry would most likely end the same way.
|
||||
const MAX_RETRIES = 1;
|
||||
|
||||
// Gives the backend time to tear down the failed session before the next one
|
||||
const RETRY_DELAY = 1000;
|
||||
|
||||
interface WebRtcPlayerError {
|
||||
type: "not_supported" | "start_failed" | "connect_failed";
|
||||
type: "not_supported" | "start_failed" | "connect_failed" | "no_connection";
|
||||
message?: string;
|
||||
}
|
||||
|
||||
@@ -99,7 +114,7 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
if (this._microphoneDenied) {
|
||||
return "denied";
|
||||
}
|
||||
if (this._microphoneConnecting) {
|
||||
if (this._microphoneConnecting || this._retryMicrophoneTrack) {
|
||||
return "connecting";
|
||||
}
|
||||
return this._localReturnAudioTrack ? "on" : "off";
|
||||
@@ -110,11 +125,17 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
}
|
||||
|
||||
public async toggleMicrophone() {
|
||||
if (this._localReturnAudioTrack || this._microphoneConnecting) {
|
||||
if (
|
||||
this._localReturnAudioTrack ||
|
||||
this._microphoneConnecting ||
|
||||
this._retryMicrophoneTrack
|
||||
) {
|
||||
// Stop (not disable) so the browser releases the microphone, also cancels connecting
|
||||
this._microphoneRequest++;
|
||||
this._localReturnAudioTrack?.stop();
|
||||
this._localReturnAudioTrack = undefined;
|
||||
this._retryMicrophoneTrack?.stop();
|
||||
this._retryMicrophoneTrack = undefined;
|
||||
this._microphoneConnecting = false;
|
||||
this._fireMicrophoneChanged();
|
||||
await this._microphoneSender?.replaceTrack(null).catch(() => undefined);
|
||||
@@ -197,6 +218,18 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
|
||||
private _cleanUpCount = 0;
|
||||
|
||||
private _connectTimeout?: number;
|
||||
|
||||
private _retryTimeout?: number;
|
||||
|
||||
private _retryMicrophoneTrack?: MediaStreamTrack;
|
||||
|
||||
private _retries = 0;
|
||||
|
||||
// The stream showed video since it was started, so a lost connection is
|
||||
// worth a retry
|
||||
private _hasPlayed = false;
|
||||
|
||||
private _handleVisibilityChange = () => {
|
||||
if (document.pictureInPictureElement) {
|
||||
// video is playing in picture-in-picture mode, don't do anything
|
||||
@@ -212,7 +245,7 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
clearTimeout(this._hiddenCleanupTimeout);
|
||||
this._hiddenCleanupTimeout = undefined;
|
||||
} else {
|
||||
this._startWebRtc();
|
||||
this._restartWebRtc();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -253,7 +286,7 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
public override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this.hasUpdated && this.entityid) {
|
||||
this._startWebRtc();
|
||||
this._restartWebRtc();
|
||||
}
|
||||
document.addEventListener("visibilitychange", this._handleVisibilityChange);
|
||||
}
|
||||
@@ -274,6 +307,13 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
if (!changedProperties.has("entityid")) {
|
||||
return;
|
||||
}
|
||||
this._restartWebRtc();
|
||||
}
|
||||
|
||||
/** Starts a new stream, not a retry of the current one. */
|
||||
private _restartWebRtc() {
|
||||
this._retries = 0;
|
||||
this._hasPlayed = false;
|
||||
this._startWebRtc();
|
||||
}
|
||||
|
||||
@@ -415,7 +455,7 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
while (this._candidatesList.length) {
|
||||
const candidate = this._candidatesList.pop();
|
||||
if (candidate) {
|
||||
candidates += `a=${candidate}\r\n`;
|
||||
candidates += `a=${candidate.candidate}\r\n`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,6 +463,8 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
|
||||
this._logEvent("start webRtcOffer", offer_sdp);
|
||||
|
||||
this._startConnectTimeout(ANSWER_TIMEOUT);
|
||||
|
||||
this._unsub = webRtcOffer(
|
||||
this._connection,
|
||||
this.entityid,
|
||||
@@ -443,11 +485,61 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
"ice connection state change",
|
||||
this._peerConnection?.iceConnectionState
|
||||
);
|
||||
if (this._peerConnection?.iceConnectionState === "failed") {
|
||||
this._peerConnection.restartIce();
|
||||
const iceState = this._peerConnection?.iceConnectionState;
|
||||
if (iceState === "connected" || iceState === "completed") {
|
||||
clearTimeout(this._connectTimeout);
|
||||
this._connectTimeout = undefined;
|
||||
} else if (iceState === "failed") {
|
||||
// An ICE restart needs a new offer, which the backend answers from a new
|
||||
// peer that can't continue this connection, so start a new one instead.
|
||||
this._retryOrFail({ type: "no_connection" });
|
||||
}
|
||||
};
|
||||
|
||||
private _startConnectTimeout(timeout: number) {
|
||||
clearTimeout(this._connectTimeout);
|
||||
this._connectTimeout = window.setTimeout(() => {
|
||||
this._connectTimeout = undefined;
|
||||
this._logEvent("connect timeout");
|
||||
this._retryOrFail({ type: "no_connection" });
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
private _retryOrFail(error: WebRtcPlayerError) {
|
||||
if (
|
||||
this._retries >= MAX_RETRIES ||
|
||||
// No answer or no path for a stream that never played, a retry would
|
||||
// most likely end the same way
|
||||
(error.type === "no_connection" && !this._hasPlayed) ||
|
||||
// A microphone connect the user started is handled by starting again
|
||||
// without the microphone. A retry keeps the microphone of the stream.
|
||||
(this._localReturnAudioTrack &&
|
||||
this._microphoneConnecting &&
|
||||
this._retries === 0)
|
||||
) {
|
||||
this._failStart(error);
|
||||
return;
|
||||
}
|
||||
this._retries++;
|
||||
this._logEvent("retry", this._retries, error);
|
||||
// Keep showing the last frame instead of the poster while starting again
|
||||
this._captureReconnectFrame();
|
||||
// Taken out before the clean up, so a running microphone is not stopped
|
||||
const microphoneTrack = this._localReturnAudioTrack;
|
||||
this._localReturnAudioTrack = undefined;
|
||||
this._cleanUp();
|
||||
if (microphoneTrack) {
|
||||
this._retryMicrophoneTrack = microphoneTrack;
|
||||
this._fireMicrophoneChanged();
|
||||
}
|
||||
this._retryTimeout = window.setTimeout(() => {
|
||||
this._retryTimeout = undefined;
|
||||
const track = this._retryMicrophoneTrack;
|
||||
this._retryMicrophoneTrack = undefined;
|
||||
this._startWebRtc(track);
|
||||
}, RETRY_DELAY);
|
||||
}
|
||||
|
||||
private async _handleOfferEvent(
|
||||
peerConnection: RTCPeerConnection,
|
||||
event: WebRtcOfferEvent
|
||||
@@ -470,6 +562,10 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
this._candidatesList = [];
|
||||
}
|
||||
if (event.type === "answer") {
|
||||
const iceState = peerConnection.iceConnectionState;
|
||||
if (iceState !== "connected" && iceState !== "completed") {
|
||||
this._startConnectTimeout(ICE_TIMEOUT);
|
||||
}
|
||||
this._logEvent("answer", event.answer);
|
||||
|
||||
this._handleAnswer(peerConnection, event);
|
||||
@@ -495,7 +591,8 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
}
|
||||
}
|
||||
if (event.type === "error") {
|
||||
this._failStart({ type: "start_failed", message: event.message });
|
||||
// Often temporary, like the backend still closing the previous session
|
||||
this._retryOrFail({ type: "start_failed", message: event.message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,8 +684,13 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
}
|
||||
|
||||
private _failStart(error: WebRtcPlayerError) {
|
||||
// The camera can reject the microphone, keep the video running without it
|
||||
if (this._localReturnAudioTrack && this._microphoneConnecting) {
|
||||
// The camera can reject the microphone, keep the video running without it.
|
||||
// After retries it is the connection that failed, not the microphone.
|
||||
if (
|
||||
this._localReturnAudioTrack &&
|
||||
this._microphoneConnecting &&
|
||||
this._retries === 0
|
||||
) {
|
||||
showToast(this, {
|
||||
message: this._localize(
|
||||
"ui.components.web-rtc-player.microphone_connect_failed",
|
||||
@@ -599,12 +701,19 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
return;
|
||||
}
|
||||
this._error = error;
|
||||
this._reconnectPoster = undefined;
|
||||
this._cleanUp();
|
||||
// Tell the camera stream this player has no video, so it can fall back
|
||||
fireEvent(this, "streams", { hasAudio: false, hasVideo: false });
|
||||
}
|
||||
|
||||
private _cleanUp() {
|
||||
this._cleanUpCount++;
|
||||
this._microphoneRequest++;
|
||||
clearTimeout(this._connectTimeout);
|
||||
this._connectTimeout = undefined;
|
||||
clearTimeout(this._retryTimeout);
|
||||
this._retryTimeout = undefined;
|
||||
if (this._remoteStream) {
|
||||
this._remoteStream.getTracks().forEach((track) => {
|
||||
track.stop();
|
||||
@@ -612,9 +721,15 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
|
||||
this._remoteStream = undefined;
|
||||
}
|
||||
if (this._localReturnAudioTrack || this._microphoneConnecting) {
|
||||
if (
|
||||
this._localReturnAudioTrack ||
|
||||
this._microphoneConnecting ||
|
||||
this._retryMicrophoneTrack
|
||||
) {
|
||||
this._localReturnAudioTrack?.stop();
|
||||
this._localReturnAudioTrack = undefined;
|
||||
this._retryMicrophoneTrack?.stop();
|
||||
this._retryMicrophoneTrack = undefined;
|
||||
this._microphoneConnecting = false;
|
||||
this._fireMicrophoneChanged();
|
||||
}
|
||||
@@ -666,6 +781,8 @@ export class HaWebRtcPlayer extends LitElement {
|
||||
|
||||
this._logEvent("loadedData", data);
|
||||
this._stopTimer();
|
||||
this._retries = 0;
|
||||
this._hasPlayed = true;
|
||||
|
||||
// Without a track the microphone is still waiting for permission
|
||||
if (this._microphoneConnecting && this._localReturnAudioTrack) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { customElement, property, query, state } from "lit/decorators";
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { consume } from "../common/decorators/consume";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { localizeYamlErrorReason } from "../common/translations/localize-yaml-error";
|
||||
import { copyToClipboard } from "../common/util/copy-clipboard";
|
||||
import { haStyle } from "../resources/styles";
|
||||
import { showToast } from "../util/toast";
|
||||
@@ -180,7 +181,7 @@ export class HaYamlEditor extends LitElement {
|
||||
let errorMsg: string | undefined;
|
||||
let yamlError: {
|
||||
mark?: { position: number; line: number; column: number };
|
||||
message?: string;
|
||||
reason?: string;
|
||||
} | null = null;
|
||||
|
||||
if (this._yaml) {
|
||||
@@ -189,8 +190,12 @@ export class HaYamlEditor extends LitElement {
|
||||
} catch (err: any) {
|
||||
// Invalid YAML
|
||||
isValid = false;
|
||||
yamlError = err;
|
||||
errorMsg = `${this._i18n!.localize("ui.components.yaml-editor.error", { reason: err.reason })}${err.mark ? ` (${this._i18n!.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
|
||||
const reason = localizeYamlErrorReason(
|
||||
this._i18n!.localize,
|
||||
err.reason
|
||||
);
|
||||
yamlError = { mark: err.mark, reason };
|
||||
errorMsg = `${this._i18n!.localize("ui.components.yaml-editor.error", { reason })}${err.mark ? ` (${this._i18n!.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
|
||||
}
|
||||
} else {
|
||||
parsed = {};
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { keyed } from "lit/directives/keyed";
|
||||
import { isInteractiveElement } from "../../common/dom/is-interactive-element";
|
||||
import "../ha-ripple";
|
||||
import { HaListItemBase } from "./ha-list-item-base";
|
||||
|
||||
@@ -15,6 +17,19 @@ import { HaListItemBase } from "./ha-list-item-base";
|
||||
* roving tabindex, the host is the tab stop and the inner element carries
|
||||
* `tabindex="-1"`. For a non-interactive row, use `ha-list-item-base`.
|
||||
*
|
||||
* Only the headline and supporting text are rendered inside the `<a>` or
|
||||
* `<button>`, and its hit area is stretched over the whole row. The `start`
|
||||
* and `end` slots sit next to it, so controls placed there stay separate
|
||||
* controls for assistive technology. Slotted elements are raised above the
|
||||
* stretched hit area, except for icons, badges, plain text and empty dividers
|
||||
* without `tabindex`, so clicks and hover on those reach the row. To keep such
|
||||
* an element clickable on its own, like a drag handle, give it `z-index: 1`.
|
||||
*
|
||||
* Subclasses customize the row with `_renderStart()`, `_renderContent()` and
|
||||
* `_renderEnd()`. `_renderInner()` is not used.
|
||||
*
|
||||
* @csspart base - The row container.
|
||||
* @csspart control - The inner `<a>` or `<button>`.
|
||||
* @csspart ripple - The ripple effect element.
|
||||
*
|
||||
* @attr {string} href - URL. When set, renders an `<a>` instead of a `<button>`.
|
||||
@@ -40,11 +55,27 @@ export class HaListItemButton extends HaListItemBase {
|
||||
this._item?.click();
|
||||
}
|
||||
|
||||
protected _renderBase(inner: TemplateResult): TemplateResult {
|
||||
protected override render(): TemplateResult {
|
||||
return this._renderBase(this._renderContent());
|
||||
}
|
||||
|
||||
protected _renderBase(content: TemplateResult): TemplateResult {
|
||||
// The ripple binds to #item on connect, so render it after the control and
|
||||
// recreate it with the control
|
||||
return html`<div part="base" class="base" @click=${this._handleBaseClick}>
|
||||
${this._renderStart()}${this._renderControl(content)}${keyed(
|
||||
`${this.href !== undefined}-${this.interactive}`,
|
||||
this._renderRipple()
|
||||
)}${this._renderEnd()}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Renders the native `<a>` or `<button>` around the row content. */
|
||||
protected _renderControl(content: TemplateResult): TemplateResult {
|
||||
if (this.href !== undefined) {
|
||||
return html`<a
|
||||
part="base"
|
||||
class="base interactive"
|
||||
part="control"
|
||||
class="control"
|
||||
id="item"
|
||||
href=${ifDefined(this.disabled ? undefined : this.href)}
|
||||
target=${ifDefined(this.target)}
|
||||
@@ -53,18 +84,18 @@ export class HaListItemButton extends HaListItemBase {
|
||||
tabindex="-1"
|
||||
aria-disabled=${this.disabled ? "true" : "false"}
|
||||
>
|
||||
${this._renderRipple()}${inner}
|
||||
${content}
|
||||
</a>`;
|
||||
}
|
||||
return html`<button
|
||||
part="base"
|
||||
class="base interactive"
|
||||
part="control"
|
||||
class="control"
|
||||
id="item"
|
||||
type="button"
|
||||
?disabled=${this.disabled}
|
||||
tabindex="-1"
|
||||
>
|
||||
${this._renderRipple()}${inner}
|
||||
${content}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
@@ -76,18 +107,48 @@ export class HaListItemButton extends HaListItemBase {
|
||||
></ha-ripple>`;
|
||||
}
|
||||
|
||||
// Raised slot content that is not a control, like a wrapper without
|
||||
// controls, still activates the row
|
||||
private _handleBaseClick(ev: MouseEvent): void {
|
||||
const item = this._item;
|
||||
if (!item || this.disabled) {
|
||||
return;
|
||||
}
|
||||
const path = ev.composedPath();
|
||||
const baseIndex = path.indexOf(ev.currentTarget as EventTarget);
|
||||
for (const node of path.slice(0, baseIndex)) {
|
||||
if (node === item || isInteractiveElement(node)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
ev.stopPropagation();
|
||||
// A copy of the event keeps modifier keys, so links can open in a new tab
|
||||
item.dispatchEvent(new MouseEvent("click", ev));
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = [
|
||||
HaListItemBase.styles,
|
||||
css`
|
||||
:host {
|
||||
cursor: pointer;
|
||||
--ha-ripple-color: var(--primary-text-color);
|
||||
/* Smooth the hover when moving between controls in the row */
|
||||
--ha-ripple-hover-duration: var(--ha-animation-duration-fast);
|
||||
}
|
||||
:host([disabled]) {
|
||||
cursor: default;
|
||||
}
|
||||
.base.interactive {
|
||||
width: 100%;
|
||||
.base {
|
||||
isolation: isolate;
|
||||
}
|
||||
.control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
@@ -97,8 +158,52 @@ export class HaListItemButton extends HaListItemBase {
|
||||
appearance: none;
|
||||
cursor: inherit;
|
||||
}
|
||||
:host([disabled]) .base.interactive {
|
||||
color: var(--disabled-text-color);
|
||||
/* Stretch the hit area of the control over the whole row */
|
||||
.control::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--ha-list-item-focus-radius);
|
||||
}
|
||||
.control:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
.control:focus-visible::before {
|
||||
outline: var(--ha-list-item-focus-width) solid var(--ha-color-focus);
|
||||
outline-offset: var(--ha-list-item-focus-offset);
|
||||
}
|
||||
/*
|
||||
* Slotted content is painted above the hit area, so it can be clicked.
|
||||
* Icons, badges, text and empty dividers go below it, so clicks on
|
||||
* them reach the row.
|
||||
* Some icons are positioned, so they need a negative z-index.
|
||||
*/
|
||||
::slotted([slot="start"]),
|
||||
::slotted([slot="end"]) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
::slotted(
|
||||
:is(
|
||||
ha-svg-icon,
|
||||
ha-icon,
|
||||
ha-icon-next,
|
||||
ha-state-icon,
|
||||
ha-domain-icon,
|
||||
ha-floor-icon,
|
||||
ha-spinner,
|
||||
ha-progress-ring,
|
||||
ha-user-badge,
|
||||
ha-person-badge,
|
||||
ha-tree-indicator,
|
||||
ha-app-icon,
|
||||
state-badge,
|
||||
img,
|
||||
span,
|
||||
div:empty
|
||||
):not([tabindex])
|
||||
) {
|
||||
z-index: -1;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -34,6 +34,8 @@ import type { HASSDomTargetEvent } from "../../common/dom/fire_event";
|
||||
*
|
||||
* @attr {string} headline - Primary text. Overridden by the `headline` slot.
|
||||
* @attr {string} supporting-text - Secondary text. Overridden by the `supporting-text` slot.
|
||||
* @attr {boolean} wrap-headline - Wraps the headline onto multiple lines instead of truncating it.
|
||||
* @attr {boolean} wrap-supporting-text - Wraps the supporting text onto multiple lines instead of truncating it.
|
||||
* @attr {boolean} disabled - Dims the row and blocks pointer events.
|
||||
*/
|
||||
@customElement("ha-row-item")
|
||||
@@ -43,6 +45,12 @@ export class HaRowItem extends LitElement {
|
||||
@property({ type: String, attribute: "supporting-text" })
|
||||
public supportingText?: string;
|
||||
|
||||
@property({ type: Boolean, reflect: true, attribute: "wrap-headline" })
|
||||
public wrapHeadline = false;
|
||||
|
||||
@property({ type: Boolean, reflect: true, attribute: "wrap-supporting-text" })
|
||||
public wrapSupportingText = false;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||||
|
||||
protected readonly _slotController = new HasSlotController(
|
||||
@@ -84,19 +92,21 @@ export class HaRowItem extends LitElement {
|
||||
}
|
||||
|
||||
protected _renderInner(): TemplateResult {
|
||||
return html`
|
||||
${this._renderStart()}${this._renderContent()}${this._renderEnd()}
|
||||
`;
|
||||
}
|
||||
|
||||
protected _renderContent(): TemplateResult {
|
||||
const hasContent = this._slotController.test("content");
|
||||
|
||||
return html`
|
||||
${this._renderStart()}
|
||||
<div part="content" class="content">
|
||||
${
|
||||
hasContent
|
||||
? html`<slot name="content"></slot>`
|
||||
: this._renderDefaultContent()
|
||||
}
|
||||
</div>
|
||||
${this._renderEnd()}
|
||||
`;
|
||||
return html`<div part="content" class="content">
|
||||
${
|
||||
hasContent
|
||||
? html`<slot name="content"></slot>`
|
||||
: this._renderDefaultContent()
|
||||
}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
protected _renderStart(): TemplateResult {
|
||||
@@ -190,6 +200,10 @@ export class HaRowItem extends LitElement {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
:host([wrap-headline]) .headline,
|
||||
:host([wrap-supporting-text]) .supporting {
|
||||
white-space: normal;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { HaListItemButton } from "../item/ha-list-item-button";
|
||||
import "./ha-media-browser-thumbnail";
|
||||
|
||||
/**
|
||||
* @element ha-media-player-browse-list-item
|
||||
* @extends {HaListItemButton}
|
||||
*
|
||||
* @summary
|
||||
* Row of the media browser list. Always reserves a graphic next to the title
|
||||
* so titles line up, and shows a thumbnail with an optional play button over
|
||||
* it. Without `interactive` the row is plain text, used for the "not shown"
|
||||
* row. The row is rendered inside the shadow root of `ha-list-virtualized`, so
|
||||
* it carries its own styles.
|
||||
*
|
||||
* @slot start - Play or pick button, shown over the thumbnail.
|
||||
* @slot headline - Title of the row.
|
||||
*
|
||||
* @csspart start - The graphic container.
|
||||
*
|
||||
* @attr {string} thumbnail - URL of the thumbnail. Shown inside the graphic.
|
||||
*/
|
||||
@customElement("ha-media-player-browse-list-item")
|
||||
export class HaMediaPlayerBrowseListItem extends HaListItemButton {
|
||||
@property() public thumbnail?: string;
|
||||
|
||||
// The "not shown" row is plain text, not a button
|
||||
protected override _renderControl(content: TemplateResult): TemplateResult {
|
||||
if (!this.interactive) {
|
||||
return content;
|
||||
}
|
||||
return super._renderControl(content);
|
||||
}
|
||||
|
||||
// Always reserve the graphic, so titles line up across rows
|
||||
protected override _renderStart(): TemplateResult {
|
||||
return html`<div part="start" class="start">
|
||||
<div class="graphic">
|
||||
${
|
||||
this.thumbnail
|
||||
? html`<ha-media-browser-thumbnail
|
||||
.url=${this.thumbnail}
|
||||
></ha-media-browser-thumbnail>`
|
||||
: nothing
|
||||
}
|
||||
<slot name="start"></slot>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = [
|
||||
HaListItemButton.styles,
|
||||
css`
|
||||
:host {
|
||||
direction: var(--direction);
|
||||
--ha-row-item-padding-block: 0;
|
||||
--ha-row-item-min-height: 48px;
|
||||
}
|
||||
|
||||
:host(.show-images) {
|
||||
--ha-row-item-padding-block: var(--ha-space-1);
|
||||
--ha-row-item-min-height: 64px;
|
||||
}
|
||||
|
||||
:host(.not-shown) {
|
||||
font-style: italic;
|
||||
color: var(--secondary-text-color);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
::slotted(ha-svg-icon) {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.graphic {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:host(.show-images) .graphic {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
ha-media-browser-thumbnail {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
::slotted(.play) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
opacity: 0;
|
||||
transition: all 0.5s;
|
||||
background-color: rgba(var(--rgb-card-background-color), 0.5);
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
--ha-icon-button-size: 40px;
|
||||
}
|
||||
|
||||
:host(:hover) ::slotted(.play) {
|
||||
opacity: 1;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
::slotted(.play.show) {
|
||||
opacity: 1;
|
||||
background-color: transparent;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-media-player-browse-list-item": HaMediaPlayerBrowseListItem;
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,6 @@ import "../ha-svg-icon";
|
||||
import "../ha-tooltip";
|
||||
import "../input/ha-input-search";
|
||||
import type { HaInputSearch } from "../input/ha-input-search";
|
||||
import { HaListItemButton } from "../item/ha-list-item-button";
|
||||
import "../list/ha-list-virtualized";
|
||||
import type {
|
||||
HaListVirtualized,
|
||||
@@ -88,6 +87,7 @@ import type { ManualMediaPickedEvent } from "./ha-browse-media-manual";
|
||||
import "./ha-browse-media-tts";
|
||||
import type { TtsMediaPickedEvent } from "./ha-browse-media-tts";
|
||||
import "./ha-media-browser-thumbnail";
|
||||
import "./ha-media-player-browse-list-item";
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
@@ -120,105 +120,6 @@ type MediaBrowseListRow =
|
||||
count: number;
|
||||
});
|
||||
|
||||
@customElement("ha-media-player-browse-list-item")
|
||||
export class HaMediaPlayerBrowseListItem extends HaListItemButton {
|
||||
@property() public thumbnail?: string;
|
||||
|
||||
// The "not shown" row is plain text, not a button
|
||||
protected override _renderBase(inner: TemplateResult): TemplateResult {
|
||||
if (!this.interactive) {
|
||||
return html`<div part="base" class="base" id="item">${inner}</div>`;
|
||||
}
|
||||
return super._renderBase(inner);
|
||||
}
|
||||
|
||||
// Always reserve the graphic, so titles line up across rows
|
||||
protected override _renderStart(): TemplateResult {
|
||||
return html`<div part="start" class="start">
|
||||
<div class="graphic">
|
||||
${
|
||||
this.thumbnail
|
||||
? html`<ha-media-browser-thumbnail
|
||||
.url=${this.thumbnail}
|
||||
></ha-media-browser-thumbnail>`
|
||||
: nothing
|
||||
}
|
||||
<slot name="start"></slot>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = [
|
||||
HaListItemButton.styles,
|
||||
css`
|
||||
:host {
|
||||
direction: var(--direction);
|
||||
--ha-row-item-padding-block: 0;
|
||||
--ha-row-item-min-height: 48px;
|
||||
}
|
||||
|
||||
:host(.show-images) {
|
||||
--ha-row-item-padding-block: var(--ha-space-1);
|
||||
--ha-row-item-min-height: 64px;
|
||||
}
|
||||
|
||||
:host(.not-shown) {
|
||||
font-style: italic;
|
||||
color: var(--secondary-text-color);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
::slotted(ha-svg-icon) {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.graphic {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:host(.show-images) .graphic {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
ha-media-browser-thumbnail {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
::slotted(.play) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
opacity: 0;
|
||||
transition: all 0.5s;
|
||||
background-color: rgba(var(--rgb-card-background-color), 0.5);
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
--ha-icon-button-size: 40px;
|
||||
}
|
||||
|
||||
:host(:hover) ::slotted(.play) {
|
||||
opacity: 1;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
::slotted(.play.show) {
|
||||
opacity: 1;
|
||||
background-color: transparent;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
/** Folders with at least this many items get a filter field when their source cannot search. */
|
||||
const FILTER_MIN_CHILDREN = 10;
|
||||
|
||||
@@ -1945,6 +1846,5 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-media-player-browse": HaMediaPlayerBrowse;
|
||||
"ha-media-player-browse-list-item": HaMediaPlayerBrowseListItem;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,20 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../../../common/array/ensure-array";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import {
|
||||
apiContext,
|
||||
entitiesContext,
|
||||
statesContext,
|
||||
} from "../../../data/context";
|
||||
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
|
||||
import { getDeviceIntegrationLookup } from "../../../data/device/device_registry";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../../data/entity/entity";
|
||||
@@ -33,7 +42,16 @@ import type { TargetDetailsDialogParams } from "./show-dialog-target-details";
|
||||
|
||||
@customElement("ha-dialog-target-details")
|
||||
class DialogTargetDetails extends LitElement implements HassDialog {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@state() @consumeLocalize() private _localize!: LocalizeFunc;
|
||||
|
||||
@consume({ context: apiContext, subscribe: true })
|
||||
private _api!: ContextType<typeof apiContext>;
|
||||
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
private _states!: ContextType<typeof statesContext>;
|
||||
|
||||
@consume({ context: entitiesContext, subscribe: true })
|
||||
private _entities!: ContextType<typeof entitiesContext>;
|
||||
|
||||
@state() private _params?: TargetDetailsDialogParams;
|
||||
|
||||
@@ -50,8 +68,8 @@ class DialogTargetDetails extends LitElement implements HassDialog {
|
||||
private _exclusionsRequest = 0;
|
||||
|
||||
private _deviceIntegrationLookup = memoizeOne(
|
||||
(entities: HomeAssistant["entities"]) =>
|
||||
getDeviceIntegrationLookup(Object.values(entities))
|
||||
(entities: HomeAssistant["entities"] | null) =>
|
||||
getDeviceIntegrationLookup(Object.values(entities ?? {}))
|
||||
);
|
||||
|
||||
public showDialog(params: TargetDetailsDialogParams): void {
|
||||
@@ -59,8 +77,17 @@ class DialogTargetDetails extends LitElement implements HassDialog {
|
||||
this._initialExclusions = params.exclusions ?? [];
|
||||
this._exclusions = this._initialExclusions;
|
||||
this._opened = true;
|
||||
if (!params.exclusions && params.exclude) {
|
||||
this._resolveExclusions(params);
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues): void {
|
||||
super.willUpdate(changedProperties);
|
||||
if (
|
||||
changedProperties.has("_params") &&
|
||||
this._params &&
|
||||
!this._params.exclusions &&
|
||||
this._params.exclude
|
||||
) {
|
||||
this._resolveExclusions(this._params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +96,7 @@ class DialogTargetDetails extends LitElement implements HassDialog {
|
||||
let exclusions: TargetExclusion[];
|
||||
try {
|
||||
exclusions = await resolveItemExclusions(
|
||||
this.hass,
|
||||
this._api.callWS,
|
||||
{ type: params.type, id: params.itemId },
|
||||
params.exclude,
|
||||
params.selector
|
||||
@@ -128,8 +155,8 @@ class DialogTargetDetails extends LitElement implements HassDialog {
|
||||
private async _loadEntitySources(): Promise<void> {
|
||||
try {
|
||||
this._entitySources = await fetchEntitySourcesWithCache(
|
||||
this.hass.callWS,
|
||||
this.hass.states
|
||||
this._api.callWS,
|
||||
this._states ?? {}
|
||||
);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
@@ -155,7 +182,7 @@ class DialogTargetDetails extends LitElement implements HassDialog {
|
||||
return true;
|
||||
}
|
||||
const deviceIntegrations = this._hasIntegration({ target })
|
||||
? this._deviceIntegrationLookup(this.hass.entities)
|
||||
? this._deviceIntegrationLookup(this._entities)
|
||||
: undefined;
|
||||
return ensureArray(target.device).some((d) =>
|
||||
filterSelectorDevices(d, device, deviceIntegrations)
|
||||
@@ -217,19 +244,19 @@ class DialogTargetDetails extends LitElement implements HassDialog {
|
||||
return html`
|
||||
<ha-adaptive-dialog
|
||||
.open=${this._opened}
|
||||
header-title=${this.hass.localize(
|
||||
header-title=${this._localize(
|
||||
"ui.components.target-picker.target_details"
|
||||
)}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<div class="type-wrapper">
|
||||
<div class="type-label">
|
||||
${this.hass.localize(
|
||||
${this._localize(
|
||||
`ui.components.target-picker.type.${this._params.type}`
|
||||
)}
|
||||
</div>
|
||||
<ha-list-base
|
||||
.ariaLabel=${`${this.hass.localize(`ui.components.target-picker.type.${this._params.type}`)}: ${this._params.title}`}
|
||||
.ariaLabel=${`${this._localize(`ui.components.target-picker.type.${this._params.type}`)}: ${this._params.title}`}
|
||||
wrap-focus
|
||||
>
|
||||
${
|
||||
@@ -262,10 +289,10 @@ class DialogTargetDetails extends LitElement implements HassDialog {
|
||||
appearance="plain"
|
||||
@click=${this.closeDialog}
|
||||
>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
${this._localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button slot="primaryAction" @click=${this._apply}>
|
||||
${this.hass.localize("ui.common.apply")}
|
||||
${this._localize("ui.common.apply")}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
`
|
||||
|
||||
@@ -3,14 +3,24 @@ import memoizeOne from "memoize-one";
|
||||
import {
|
||||
consumeContext,
|
||||
ContextController,
|
||||
ContextSubscriptionController,
|
||||
} from "../../common/decorators/consume";
|
||||
import {
|
||||
apiContext,
|
||||
registriesContext,
|
||||
statesContext,
|
||||
} from "../../data/context";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
|
||||
import {
|
||||
explainTarget,
|
||||
type HassServiceTargetWithExclude,
|
||||
type TargetItem,
|
||||
} from "../../data/target";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type {
|
||||
HomeAssistant,
|
||||
HomeAssistantApi,
|
||||
HomeAssistantRegistries,
|
||||
} from "../../types";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
|
||||
import { filterTargetEntries } from "./filter-target-entries";
|
||||
import { targetExpandGroupContext } from "./target-expand-group-context";
|
||||
@@ -26,7 +36,6 @@ import {
|
||||
|
||||
export type TargetExclusionsHost = ReactiveControllerHost &
|
||||
HTMLElement & {
|
||||
hass: HomeAssistant;
|
||||
value?: HassServiceTargetWithExclude;
|
||||
allowExclude: boolean;
|
||||
primaryEntitiesOnly?: boolean;
|
||||
@@ -55,11 +64,31 @@ export class TargetExclusionsController extends ContextController {
|
||||
@consumeContext({ context: targetExpandGroupContext, subscribe: true })
|
||||
private _expandGroup?: boolean;
|
||||
|
||||
@consumeContext({ context: registriesContext, subscribe: true })
|
||||
private _registries?: HomeAssistantRegistries;
|
||||
|
||||
@consumeContext({
|
||||
context: apiContext,
|
||||
subscribe: true,
|
||||
transform: (api) => api?.callWS,
|
||||
})
|
||||
private _callWS?: HomeAssistantApi["callWS"];
|
||||
|
||||
private _states?: HomeAssistant["states"] | null;
|
||||
|
||||
private _request = 0;
|
||||
|
||||
constructor(host: TargetExclusionsHost) {
|
||||
super(host);
|
||||
this._host = host;
|
||||
// States change all the time, so they only resolve again once loaded.
|
||||
new ContextSubscriptionController(host, statesContext, (states) => {
|
||||
const loaded = !this._states && !!states;
|
||||
this._states = states;
|
||||
if (loaded) {
|
||||
this.resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected contextUpdated() {
|
||||
@@ -78,9 +107,9 @@ export class TargetExclusionsController extends ContextController {
|
||||
*/
|
||||
public async resolve({ force = false }: { force?: boolean } = {}) {
|
||||
const request = ++this._request;
|
||||
const { hass, value, allowExclude } = this._host;
|
||||
const { value, allowExclude } = this._host;
|
||||
if (
|
||||
!hass ||
|
||||
!this._ready ||
|
||||
!value ||
|
||||
(!force &&
|
||||
!hasIncludedTargets(value.exclude) &&
|
||||
@@ -140,7 +169,7 @@ export class TargetExclusionsController extends ContextController {
|
||||
item: TargetItem
|
||||
): Promise<TargetExclusion | undefined> {
|
||||
const { value } = this._host;
|
||||
if (!value) {
|
||||
if (!this._ready || !value) {
|
||||
return undefined;
|
||||
}
|
||||
const explained = await this._explain({
|
||||
@@ -152,9 +181,19 @@ export class TargetExclusionsController extends ContextController {
|
||||
]?.[0];
|
||||
}
|
||||
|
||||
private get _ready(): boolean {
|
||||
return !!(
|
||||
this._callWS &&
|
||||
this._states &&
|
||||
this._registries?.entities &&
|
||||
this._registries.devices &&
|
||||
this._registries.areas
|
||||
);
|
||||
}
|
||||
|
||||
private _explain(target: HassServiceTargetWithExclude) {
|
||||
return explainTarget(
|
||||
this._host.hass.callWS,
|
||||
this._callWS!,
|
||||
target,
|
||||
this.expandGroup,
|
||||
this._host.primaryEntitiesOnly ?? true
|
||||
@@ -170,8 +209,9 @@ export class TargetExclusionsController extends ContextController {
|
||||
// What the picker's filters leave of a target, narrowed like the target's
|
||||
// row, so what an exclusion takes out matches what the row counts.
|
||||
private _keeps(): TargetNarrowing {
|
||||
const states = this._states!;
|
||||
const { entities, devices, areas } = this._registries!;
|
||||
const {
|
||||
hass,
|
||||
deviceFilter,
|
||||
entityFilter,
|
||||
includeDomains,
|
||||
@@ -180,10 +220,10 @@ export class TargetExclusionsController extends ContextController {
|
||||
} = this._host;
|
||||
return (include) =>
|
||||
filterTargetEntries(
|
||||
hass.states,
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
states,
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
include,
|
||||
{
|
||||
referenced_areas: include.referenced_areas,
|
||||
|
||||
@@ -145,7 +145,7 @@ export const computeExclusionsPerTarget = (
|
||||
* `expandGroup` follows what the target is used for.
|
||||
*/
|
||||
export const resolveItemExclusions = async (
|
||||
hass: HomeAssistant,
|
||||
callWS: HomeAssistant["callWS"],
|
||||
item: TargetItem,
|
||||
exclude: HassServiceTarget | undefined,
|
||||
primaryEntitiesOnly?: boolean,
|
||||
@@ -155,7 +155,7 @@ export const resolveItemExclusions = async (
|
||||
return [];
|
||||
}
|
||||
const explained = await explainTarget(
|
||||
hass.callWS,
|
||||
callWS,
|
||||
{ [`${item.type}_id`]: [item.id], exclude },
|
||||
expandGroup,
|
||||
primaryEntitiesOnly
|
||||
|
||||
@@ -112,7 +112,7 @@ export class HaTracePathDetails extends LitElement {
|
||||
initialValue: {
|
||||
options: [],
|
||||
showIndices: false,
|
||||
select: () => undefined,
|
||||
select: () => true,
|
||||
fixDuplicateIds: async () => undefined,
|
||||
updateTriggerId: () => false,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
|
||||
export const ROOM_ICONS = {
|
||||
living_room: [
|
||||
"mdi:sofa",
|
||||
"mdi:sofa-single",
|
||||
"mdi:television",
|
||||
"mdi:fireplace",
|
||||
"mdi:floor-lamp",
|
||||
],
|
||||
bedroom: ["mdi:bed", "mdi:bed-king", "mdi:bed-double", "mdi:lamp"],
|
||||
kids_room: ["mdi:teddy-bear", "mdi:toy-brick", "mdi:bunk-bed", "mdi:cradle"],
|
||||
kitchen: [
|
||||
"mdi:stove",
|
||||
"mdi:fridge",
|
||||
"mdi:countertop",
|
||||
"mdi:chef-hat",
|
||||
"mdi:pot-steam",
|
||||
"mdi:coffee-maker",
|
||||
],
|
||||
dining_room: [
|
||||
"mdi:silverware-fork-knife",
|
||||
"mdi:table-chair",
|
||||
"mdi:table-furniture",
|
||||
"mdi:glass-wine",
|
||||
],
|
||||
bathroom: [
|
||||
"mdi:shower",
|
||||
"mdi:bathtub",
|
||||
"mdi:shower-head",
|
||||
"mdi:toothbrush",
|
||||
"mdi:hair-dryer",
|
||||
],
|
||||
toilet: ["mdi:toilet", "mdi:paper-roll"],
|
||||
office: [
|
||||
"mdi:desk",
|
||||
"mdi:laptop",
|
||||
"mdi:monitor",
|
||||
"mdi:chair-rolling",
|
||||
"mdi:bookshelf",
|
||||
],
|
||||
laundry_room: [
|
||||
"mdi:washing-machine",
|
||||
"mdi:tumble-dryer",
|
||||
"mdi:iron",
|
||||
"mdi:hanger",
|
||||
],
|
||||
hallway: ["mdi:door", "mdi:door-open", "mdi:coat-rack", "mdi:key"],
|
||||
stairs: ["mdi:stairs", "mdi:stairs-up", "mdi:stairs-down"],
|
||||
garage: ["mdi:garage", "mdi:garage-variant", "mdi:car", "mdi:tools"],
|
||||
garden: [
|
||||
"mdi:flower",
|
||||
"mdi:tree",
|
||||
"mdi:grass",
|
||||
"mdi:sprout",
|
||||
"mdi:watering-can",
|
||||
"mdi:greenhouse",
|
||||
],
|
||||
terrace: [
|
||||
"mdi:balcony",
|
||||
"mdi:grill",
|
||||
"mdi:umbrella-outline",
|
||||
"mdi:coach-lamp",
|
||||
"mdi:patio-heater",
|
||||
],
|
||||
basement: [
|
||||
"mdi:home-floor-negative-1",
|
||||
"mdi:stairs-down",
|
||||
"mdi:water-boiler",
|
||||
],
|
||||
attic: ["mdi:home-roof", "mdi:package-variant-closed"],
|
||||
storage: ["mdi:archive", "mdi:package-variant-closed", "mdi:warehouse"],
|
||||
utility_room: [
|
||||
"mdi:water-boiler",
|
||||
"mdi:heat-pump",
|
||||
"mdi:server-network",
|
||||
"mdi:flash",
|
||||
],
|
||||
pool: ["mdi:pool", "mdi:swim"],
|
||||
media_room: [
|
||||
"mdi:theater",
|
||||
"mdi:projector",
|
||||
"mdi:television",
|
||||
"mdi:movie-open",
|
||||
],
|
||||
game_room: [
|
||||
"mdi:gamepad-variant",
|
||||
"mdi:billiards",
|
||||
"mdi:cards-playing",
|
||||
"mdi:dice-multiple",
|
||||
],
|
||||
music_room: ["mdi:piano", "mdi:guitar-acoustic", "mdi:music"],
|
||||
library: ["mdi:bookshelf", "mdi:book-open-variant", "mdi:library"],
|
||||
bar: [
|
||||
"mdi:glass-cocktail",
|
||||
"mdi:glass-mug-variant",
|
||||
"mdi:bottle-wine",
|
||||
"mdi:beer",
|
||||
],
|
||||
dressing_room: [
|
||||
"mdi:hanger",
|
||||
"mdi:wardrobe",
|
||||
"mdi:tshirt-crew",
|
||||
"mdi:shoe-heel",
|
||||
],
|
||||
workshop: ["mdi:tools", "mdi:hammer-wrench", "mdi:toolbox", "mdi:saw-blade"],
|
||||
server_room: ["mdi:server-network", "mdi:server", "mdi:router-network"],
|
||||
safe_room: ["mdi:shield-home", "mdi:shield-lock", "mdi:safe"],
|
||||
prayer_room: ["mdi:hands-pray", "mdi:meditation", "mdi:candle"],
|
||||
sauna: ["mdi:spa", "mdi:hot-tub", "mdi:thermometer-high"],
|
||||
outdoor_kitchen: ["mdi:grill", "mdi:grill-outline", "mdi:pizza"],
|
||||
driveway: ["mdi:road-variant", "mdi:car", "mdi:gate"],
|
||||
} as const satisfies Record<string, readonly string[]>;
|
||||
|
||||
export type RoomType = keyof typeof ROOM_ICONS;
|
||||
|
||||
type RoomKeywords = Partial<Record<RoomType, string[]>>;
|
||||
|
||||
export const ROOM_TYPES = Object.keys(ROOM_ICONS) as RoomType[];
|
||||
|
||||
export const isRoomType = (value: string): value is RoomType =>
|
||||
(ROOM_TYPES as string[]).includes(value);
|
||||
|
||||
// Languages written without spaces between words
|
||||
const NO_WORD_SPACING =
|
||||
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}]/u;
|
||||
|
||||
const normalizeRoomText = (text: string): string =>
|
||||
text
|
||||
.normalize("NFD")
|
||||
.replace(/\p{M}/gu, "")
|
||||
.toLowerCase()
|
||||
.replace(/['’]/g, "")
|
||||
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
||||
.trim();
|
||||
|
||||
interface RoomMatch {
|
||||
room: RoomType;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
const findMatches = (name: string, keywords: RoomKeywords): RoomMatch[] => {
|
||||
const text = ` ${normalizeRoomText(name)} `;
|
||||
const candidates: RoomMatch[] = [];
|
||||
for (const room of ROOM_TYPES) {
|
||||
for (const keyword of keywords[room] ?? []) {
|
||||
const phrase = normalizeRoomText(keyword);
|
||||
if (!phrase) {
|
||||
continue;
|
||||
}
|
||||
const needle = NO_WORD_SPACING.test(phrase) ? phrase : ` ${phrase} `;
|
||||
const start = text.indexOf(needle);
|
||||
if (start !== -1) {
|
||||
candidates.push({ room, start, end: start + needle.length });
|
||||
}
|
||||
}
|
||||
}
|
||||
// The longest phrase wins, so "guest bedroom" is not also read as "bedroom"
|
||||
candidates.sort((a, b) => b.end - b.start - (a.end - a.start));
|
||||
const kept: RoomMatch[] = [];
|
||||
for (const match of candidates) {
|
||||
const overlaps = kept.some(
|
||||
(other) => match.start < other.end - 1 && other.start < match.end - 1
|
||||
);
|
||||
if (!overlaps) {
|
||||
kept.push(match);
|
||||
}
|
||||
}
|
||||
return kept.sort((a, b) => a.start - b.start);
|
||||
};
|
||||
|
||||
const matchRoomTypes = (
|
||||
names: string[],
|
||||
keywords: RoomKeywords
|
||||
): RoomType[] => {
|
||||
const rooms = new Set<RoomType>();
|
||||
for (const name of names) {
|
||||
for (const match of findMatches(name, keywords)) {
|
||||
rooms.add(match.room);
|
||||
}
|
||||
}
|
||||
return [...rooms];
|
||||
};
|
||||
|
||||
const roomIconSuggestions = (rooms: RoomType[]): string[] => [
|
||||
...new Set(rooms.flatMap((room) => ROOM_ICONS[room])),
|
||||
];
|
||||
|
||||
export const getAreaIconSuggestions = (
|
||||
names: string[],
|
||||
localize: LocalizeFunc
|
||||
): string[] => {
|
||||
const keywords: RoomKeywords = Object.fromEntries(
|
||||
ROOM_TYPES.map((room) => [
|
||||
room,
|
||||
localize(`ui.components.rooms.keywords.${room}`).split("|"),
|
||||
])
|
||||
);
|
||||
return roomIconSuggestions(matchRoomTypes(names, keywords));
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
export const STREAM_TYPE_HLS = "hls";
|
||||
export const STREAM_TYPE_WEB_RTC = "web_rtc";
|
||||
|
||||
export type StreamType = typeof STREAM_TYPE_HLS | typeof STREAM_TYPE_WEB_RTC;
|
||||
|
||||
export const MJPEG_STREAM = "mjpeg";
|
||||
|
||||
export type CameraStreamType = StreamType | typeof MJPEG_STREAM;
|
||||
|
||||
export interface CameraStream {
|
||||
type: CameraStreamType;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
/** What a player reported, `hasVideo: false` means it failed to load. */
|
||||
export interface CameraStreamTracks {
|
||||
hasAudio: boolean;
|
||||
hasVideo: boolean;
|
||||
}
|
||||
|
||||
export interface CameraStreamSelectionOptions {
|
||||
supportedTypes?: StreamType[];
|
||||
hlsStreams?: CameraStreamTracks;
|
||||
webRtcStreams?: CameraStreamTracks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides which players are rendered and which one is shown. Hidden players
|
||||
* are still loading in the background.
|
||||
*/
|
||||
export const selectCameraStreams = ({
|
||||
supportedTypes,
|
||||
hlsStreams,
|
||||
webRtcStreams,
|
||||
}: CameraStreamSelectionOptions): CameraStream[] => {
|
||||
if (!supportedTypes) {
|
||||
return [];
|
||||
}
|
||||
if (supportedTypes.length === 0) {
|
||||
// doesn't support any stream type, fallback to mjpeg
|
||||
return [{ type: MJPEG_STREAM, visible: true }];
|
||||
}
|
||||
if (supportedTypes.length === 1) {
|
||||
// only 1 stream type, no need to choose
|
||||
if (
|
||||
(supportedTypes[0] === STREAM_TYPE_HLS &&
|
||||
hlsStreams?.hasVideo === false) ||
|
||||
(supportedTypes[0] === STREAM_TYPE_WEB_RTC &&
|
||||
webRtcStreams?.hasVideo === false)
|
||||
) {
|
||||
// stream failed to load, fallback to mjpeg
|
||||
return [{ type: MJPEG_STREAM, visible: true }];
|
||||
}
|
||||
return [{ type: supportedTypes[0], visible: true }];
|
||||
}
|
||||
|
||||
const hlsFailed = hlsStreams?.hasVideo === false;
|
||||
|
||||
if (webRtcStreams?.hasVideo) {
|
||||
// WebRTC is preferred whenever it plays, it has the lowest latency
|
||||
return [{ type: STREAM_TYPE_WEB_RTC, visible: true }];
|
||||
}
|
||||
|
||||
if (webRtcStreams?.hasVideo === false) {
|
||||
if (hlsFailed) {
|
||||
// both streams failed to load, fallback to mjpeg
|
||||
return [{ type: MJPEG_STREAM, visible: true }];
|
||||
}
|
||||
return [{ type: STREAM_TYPE_HLS, visible: true }];
|
||||
}
|
||||
|
||||
// WebRTC is still loading
|
||||
if (hlsFailed) {
|
||||
return [{ type: STREAM_TYPE_WEB_RTC, visible: true }];
|
||||
}
|
||||
return [
|
||||
{ type: STREAM_TYPE_HLS, visible: true },
|
||||
{ type: STREAM_TYPE_WEB_RTC, visible: false },
|
||||
];
|
||||
};
|
||||
+6
-4
@@ -7,13 +7,15 @@ import { timeCacheEntityPromiseFunc } from "../common/util/time-cache-entity-pro
|
||||
import type { HomeAssistant, HomeAssistantApi } from "../types";
|
||||
import { fileDownload } from "../util/file_download";
|
||||
import { getSignedPath } from "./auth";
|
||||
import type { StreamType } from "./camera-streams";
|
||||
|
||||
export const CAMERA_ORIENTATIONS = [1, 2, 3, 4, 6, 8];
|
||||
|
||||
export const STREAM_TYPE_HLS = "hls";
|
||||
export const STREAM_TYPE_WEB_RTC = "web_rtc";
|
||||
|
||||
export type StreamType = typeof STREAM_TYPE_HLS | typeof STREAM_TYPE_WEB_RTC;
|
||||
export {
|
||||
STREAM_TYPE_HLS,
|
||||
STREAM_TYPE_WEB_RTC,
|
||||
type StreamType,
|
||||
} from "./camera-streams";
|
||||
|
||||
export { CameraEntityFeature } from "./feature/camera_entity_feature";
|
||||
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import type { ConfigPath } from "../common/util/config-path";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
export interface ConfigValidationError {
|
||||
message: string;
|
||||
/** Where the problem is, `[]` when core does not know. */
|
||||
path: ConfigPath;
|
||||
code?: string | null;
|
||||
}
|
||||
|
||||
export interface ValidConfig {
|
||||
valid: true;
|
||||
error: null;
|
||||
@@ -16,14 +8,10 @@ export interface ValidConfig {
|
||||
export interface InvalidConfig {
|
||||
valid: false;
|
||||
error: string;
|
||||
/** Paths start at the item index of the section. */
|
||||
errors: ConfigValidationError[];
|
||||
}
|
||||
|
||||
type ValidKeys = "triggers" | "actions" | "conditions";
|
||||
|
||||
export type ValidationSectionKeys = Partial<Record<ValidKeys, string>>;
|
||||
|
||||
export const validateConfig = <T extends Partial<Record<ValidKeys, unknown>>>(
|
||||
hass: HomeAssistant,
|
||||
config: T
|
||||
@@ -32,61 +20,3 @@ export const validateConfig = <T extends Partial<Record<ValidKeys, unknown>>>(
|
||||
type: "validate_config",
|
||||
...config,
|
||||
});
|
||||
|
||||
/**
|
||||
* Flattens a `validate_config` result into errors with paths from the config
|
||||
* root. `sectionKeys` names the config key of a section when it differs from
|
||||
* the key it is validated under, e.g. `{ actions: "sequence" }` for scripts.
|
||||
*/
|
||||
export const validationResultErrors = (
|
||||
result: Partial<Record<ValidKeys, ValidConfig | InvalidConfig>>,
|
||||
sectionKeys: ValidationSectionKeys = {}
|
||||
): ConfigValidationError[] =>
|
||||
(Object.keys(result) as ValidKeys[]).flatMap((key) => {
|
||||
const section = result[key];
|
||||
if (!section || section.valid) {
|
||||
return [];
|
||||
}
|
||||
const sectionKey = sectionKeys[key] ?? key;
|
||||
return section.errors.map((error) => ({
|
||||
...error,
|
||||
path: [sectionKey, ...error.path],
|
||||
}));
|
||||
});
|
||||
|
||||
export const validateConfigErrors = async (
|
||||
hass: HomeAssistant,
|
||||
config: Partial<Record<ValidKeys, unknown>>,
|
||||
sectionKeys?: ValidationSectionKeys
|
||||
): Promise<ConfigValidationError[]> =>
|
||||
validationResultErrors(await validateConfig(hass, config), sectionKeys);
|
||||
|
||||
export interface ConfigSaveError {
|
||||
message: string;
|
||||
errors: ConfigValidationError[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the errors of a failed config save. Errors that are not about the
|
||||
* config, like a network error, only have a message.
|
||||
*/
|
||||
export const parseConfigSaveError = (err: unknown): ConfigSaveError => {
|
||||
const { body, error } = (err ?? {}) as { body?: unknown; error?: unknown };
|
||||
const bodyObject =
|
||||
body && typeof body === "object"
|
||||
? (body as { message?: unknown; errors?: unknown })
|
||||
: undefined;
|
||||
const message = String(
|
||||
bodyObject?.message ||
|
||||
error ||
|
||||
(typeof body === "string" && body) ||
|
||||
(err instanceof Error && err.message) ||
|
||||
err
|
||||
);
|
||||
return {
|
||||
message,
|
||||
errors: Array.isArray(bodyObject?.errors)
|
||||
? (bodyObject.errors as ConfigValidationError[])
|
||||
: [],
|
||||
};
|
||||
};
|
||||
@@ -220,24 +220,30 @@ export class ConfigEntryStream {
|
||||
private _entries: ConfigEntry[] = [];
|
||||
|
||||
processMessage(message: ConfigEntryUpdate[]) {
|
||||
let entries =
|
||||
message.length === 0 ||
|
||||
message.some((configEntry) => configEntry.type === null)
|
||||
? []
|
||||
: [...this._entries];
|
||||
message.forEach((configEntry) => {
|
||||
if (configEntry.type === null || configEntry.type === "added") {
|
||||
this._entries.push(configEntry.entry);
|
||||
entries.push(configEntry.entry);
|
||||
return;
|
||||
}
|
||||
if (configEntry.type === "removed") {
|
||||
this._entries = this._entries.filter(
|
||||
entries = entries.filter(
|
||||
(entry) => entry.entry_id !== configEntry.entry.entry_id
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (configEntry.type === "updated") {
|
||||
const newEntry = configEntry.entry;
|
||||
this._entries = this._entries.map((entry) =>
|
||||
entries = entries.map((entry) =>
|
||||
entry.entry_id === newEntry.entry_id ? newEntry : entry
|
||||
);
|
||||
}
|
||||
});
|
||||
return this._entries;
|
||||
this._entries = entries;
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
@@ -30,11 +30,11 @@ const HEADERS = {
|
||||
};
|
||||
|
||||
export const createConfigFlow = (
|
||||
hass: HomeAssistant,
|
||||
callApi: HomeAssistant["callApi"],
|
||||
handler: string,
|
||||
entry_id?: string
|
||||
) =>
|
||||
hass.callApi<DataEntryFlowStep>(
|
||||
callApi<DataEntryFlowStep>(
|
||||
"POST",
|
||||
"config/config_entries/flow",
|
||||
{
|
||||
@@ -53,11 +53,11 @@ export const fetchConfigFlow = (hass: HomeAssistant, flowId: string) =>
|
||||
);
|
||||
|
||||
export const handleConfigFlowStep = (
|
||||
hass: HomeAssistant,
|
||||
callApi: HomeAssistant["callApi"],
|
||||
flowId: string,
|
||||
data: Record<string, any>
|
||||
) =>
|
||||
hass.callApi<DataEntryFlowStep>(
|
||||
callApi<DataEntryFlowStep>(
|
||||
"POST",
|
||||
`config/config_entries/flow/${flowId}`,
|
||||
data,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
computeEntitySearchLabels,
|
||||
} from "../../common/entity/compute_entity_name_display";
|
||||
import type { RelatedIdSets } from "../../common/search/related-context";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import { caseInsensitiveStringCompare } from "../../common/string/compare";
|
||||
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
|
||||
import type { FuseWeightedKey } from "../../resources/fuseMultiTerm";
|
||||
@@ -51,6 +52,14 @@ export const entityComboBoxKeys: FuseWeightedKey[] = [
|
||||
];
|
||||
|
||||
export interface GetEntitiesOptions {
|
||||
states: HomeAssistant["states"];
|
||||
entities: HomeAssistant["entities"];
|
||||
devices: HomeAssistant["devices"];
|
||||
areas: HomeAssistant["areas"];
|
||||
floors: HomeAssistant["floors"];
|
||||
localize: LocalizeFunc;
|
||||
language: string;
|
||||
translationMetadata: HomeAssistant["translationMetadata"];
|
||||
includeDomains?: string[];
|
||||
excludeDomains?: string[];
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc;
|
||||
@@ -63,20 +72,11 @@ export interface GetEntitiesOptions {
|
||||
}
|
||||
|
||||
export const getEntities = (
|
||||
hass: Pick<
|
||||
HomeAssistant,
|
||||
| "states"
|
||||
| "entities"
|
||||
| "devices"
|
||||
| "areas"
|
||||
| "floors"
|
||||
| "language"
|
||||
| "translationMetadata"
|
||||
| "localize"
|
||||
>,
|
||||
options?: GetEntitiesOptions
|
||||
options: GetEntitiesOptions
|
||||
): EntityComboBoxItem[] => {
|
||||
const {
|
||||
states,
|
||||
localize,
|
||||
includeDomains,
|
||||
excludeDomains,
|
||||
entityFilter,
|
||||
@@ -86,11 +86,11 @@ export const getEntities = (
|
||||
excludeEntities,
|
||||
value,
|
||||
idPrefix = "",
|
||||
} = options ?? {};
|
||||
} = options;
|
||||
|
||||
let items: EntityComboBoxItem[];
|
||||
|
||||
let entityIds = Object.keys(hass.states);
|
||||
let entityIds = Object.keys(states);
|
||||
|
||||
// These run over every entity, so use Sets for O(1) membership instead of
|
||||
// repeated Array.includes scans.
|
||||
@@ -126,21 +126,21 @@ export const getEntities = (
|
||||
// of inside the map over (potentially thousands of) entities.
|
||||
const domainNames = new Map<string, string>();
|
||||
const displayOptions = {
|
||||
entities: hass.entities,
|
||||
devices: hass.devices,
|
||||
areas: hass.areas,
|
||||
floors: hass.floors,
|
||||
language: hass.language,
|
||||
translationMetadata: hass.translationMetadata,
|
||||
entities: options.entities,
|
||||
devices: options.devices,
|
||||
areas: options.areas,
|
||||
floors: options.floors,
|
||||
language: options.language,
|
||||
translationMetadata: options.translationMetadata,
|
||||
};
|
||||
|
||||
items = entityIds.map<EntityComboBoxItem>((entityId) => {
|
||||
const stateObj = hass.states[entityId];
|
||||
const stateObj = states[entityId];
|
||||
|
||||
const domain = computeDomain(entityId);
|
||||
let domainName = domainNames.get(domain);
|
||||
if (domainName === undefined) {
|
||||
domainName = domainToName(hass.localize, domain);
|
||||
domainName = domainToName(localize, domain);
|
||||
domainNames.set(domain, domainName);
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ export const getEntities = (
|
||||
domain_name: domainName,
|
||||
sorting_label: [primary, secondary].filter(Boolean).join("_"),
|
||||
search_labels: {
|
||||
...computeEntitySearchLabels(stateObj, hass),
|
||||
...computeEntitySearchLabels(stateObj, displayOptions),
|
||||
domainName: domainName || null,
|
||||
entityId: entityId,
|
||||
},
|
||||
|
||||
@@ -60,3 +60,18 @@ export const validateHassioSession = async (
|
||||
): Promise<void> => {
|
||||
await hass.callWS({ type: "supervisor/ingress/validate_session", session });
|
||||
};
|
||||
|
||||
// Extends the session, or replaces it with a new one for the app if it has
|
||||
// expired. Returns the session to keep using.
|
||||
export const refreshHassioSession = async (
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
slug: string,
|
||||
session: string
|
||||
): Promise<string> => {
|
||||
try {
|
||||
await validateHassioSession(hass, session);
|
||||
return session;
|
||||
} catch (_err: any) {
|
||||
return createHassioSession(hass, slug);
|
||||
}
|
||||
};
|
||||
+11
-7
@@ -209,7 +209,15 @@ export const watchForNewMatterDevice = (
|
||||
callback: (device: DeviceRegistryEntry) => void
|
||||
): UnsubscribeFunc => {
|
||||
let curMatterDevices: Set<string> | undefined;
|
||||
const unsubDeviceReg = subscribeDeviceRegistry(hass.connection, (entries) => {
|
||||
let unsubDeviceReg: UnsubscribeFunc | undefined;
|
||||
// Callers also unsubscribe after a find. The registry collection counts its
|
||||
// subscribers, so a second unsubscribe would stop its updates for everyone.
|
||||
const unsubscribe = () => {
|
||||
unsubDeviceReg?.();
|
||||
unsubDeviceReg = undefined;
|
||||
curMatterDevices = undefined;
|
||||
};
|
||||
unsubDeviceReg = subscribeDeviceRegistry(hass.connection, (entries) => {
|
||||
if (!curMatterDevices) {
|
||||
curMatterDevices = new Set(
|
||||
Object.values(entries)
|
||||
@@ -226,15 +234,11 @@ export const watchForNewMatterDevice = (
|
||||
!curMatterDevices!.has(device.id)
|
||||
);
|
||||
if (newMatterDevices.length) {
|
||||
unsubDeviceReg();
|
||||
curMatterDevices = undefined;
|
||||
unsubscribe();
|
||||
callback(newMatterDevices[0]);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
unsubDeviceReg();
|
||||
curMatterDevices = undefined;
|
||||
};
|
||||
return unsubscribe;
|
||||
};
|
||||
|
||||
export const redirectOnNewMatterDevice = (
|
||||
|
||||
+2
-2
@@ -20,10 +20,10 @@ export interface SensorDeviceClassUnits {
|
||||
}
|
||||
|
||||
export const getSensorDeviceClassConvertibleUnits = (
|
||||
hass: HomeAssistant,
|
||||
callWS: HomeAssistant["callWS"],
|
||||
deviceClass: string
|
||||
): Promise<SensorDeviceClassUnits> =>
|
||||
hass.callWS({
|
||||
callWS({
|
||||
type: "sensor/device_class_convertible_units",
|
||||
device_class: deviceClass,
|
||||
});
|
||||
+1
-1
@@ -101,7 +101,7 @@ export const updateItem = (
|
||||
"update_item",
|
||||
{
|
||||
item: item.uid,
|
||||
rename: item.summary,
|
||||
rename: item.summary || undefined,
|
||||
status: item.status,
|
||||
description: item.description,
|
||||
due_datetime: item.due?.includes("T") ? item.due : undefined,
|
||||
|
||||
@@ -26,7 +26,7 @@ export const showConfigFlowDialog = (
|
||||
showDevices: true,
|
||||
createFlow: async (hass, handler) => {
|
||||
const [step] = await Promise.all([
|
||||
createConfigFlow(hass, handler, dialogParams.entryId),
|
||||
createConfigFlow(hass.callApi, handler, dialogParams.entryId),
|
||||
hass.loadFragmentTranslation("config"),
|
||||
hass.loadBackendTranslation("config", handler),
|
||||
hass.loadBackendTranslation("selector", handler),
|
||||
@@ -51,7 +51,7 @@ export const showConfigFlowDialog = (
|
||||
return step;
|
||||
},
|
||||
handleFlowStep: async (hass, flowId, data) => {
|
||||
const step = await handleConfigFlowStep(hass, flowId, data);
|
||||
const step = await handleConfigFlowStep(hass.callApi, flowId, data);
|
||||
await loadFlowStepTranslations(hass, step);
|
||||
return step;
|
||||
},
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import Cropper from "cropperjs";
|
||||
// @ts-ignore
|
||||
import cropperCss from "cropperjs/dist/cropper.css";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing, unsafeCSS } from "lit";
|
||||
import "cropperjs";
|
||||
import type {
|
||||
CropperCanvas,
|
||||
CropperGrid,
|
||||
CropperHandle,
|
||||
CropperImage,
|
||||
CropperSelection,
|
||||
CropperShade,
|
||||
} from "cropperjs";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { consume } from "../../common/decorators/consume";
|
||||
import "../../components/ha-alert";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-dialog-footer";
|
||||
import "../../components/ha-dialog";
|
||||
@@ -16,76 +25,314 @@ import { haStyleDialog } from "../../resources/styles";
|
||||
import { DialogMixin } from "../dialog-mixin";
|
||||
import type { HaImageCropperDialogParams } from "./show-image-cropper-dialog";
|
||||
|
||||
interface Rect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const MIN_SELECTION_SIZE = 50;
|
||||
|
||||
const INITIAL_COVERAGE = 0.8;
|
||||
|
||||
const EPSILON = 0.05;
|
||||
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(Math.max(value, min), max);
|
||||
|
||||
const contains = (outer: Rect, inner: Rect): boolean =>
|
||||
inner.x >= outer.x - EPSILON &&
|
||||
inner.y >= outer.y - EPSILON &&
|
||||
inner.x + inner.width <= outer.x + outer.width + EPSILON &&
|
||||
inner.y + inner.height <= outer.y + outer.height + EPSILON;
|
||||
|
||||
const intersect = (a: Rect, b: Rect): Rect => {
|
||||
const x = Math.max(a.x, b.x);
|
||||
const y = Math.max(a.y, b.y);
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width: Math.min(a.x + a.width, b.x + b.width) - x,
|
||||
height: Math.min(a.y + a.height, b.y + b.height) - y,
|
||||
};
|
||||
};
|
||||
|
||||
const sameSize = (a: Rect, b: Rect): boolean =>
|
||||
Math.abs(a.width - b.width) < EPSILON &&
|
||||
Math.abs(a.height - b.height) < EPSILON;
|
||||
|
||||
const moveInto = (rect: Rect, bounds: Rect): Rect => ({
|
||||
...rect,
|
||||
x: clamp(rect.x, bounds.x, bounds.x + bounds.width - rect.width),
|
||||
y: clamp(rect.y, bounds.y, bounds.y + bounds.height - rect.height),
|
||||
});
|
||||
|
||||
const anchorOf = (
|
||||
start: number,
|
||||
size: number,
|
||||
nextStart: number,
|
||||
nextSize: number
|
||||
): number => {
|
||||
if (Math.abs(nextStart - start) < EPSILON) {
|
||||
return nextStart;
|
||||
}
|
||||
if (Math.abs(nextStart + nextSize - (start + size)) < EPSILON) {
|
||||
return start + size;
|
||||
}
|
||||
return nextStart + nextSize / 2;
|
||||
};
|
||||
|
||||
const shrinkInto = (current: Rect, next: Rect, bounds: Rect): Rect => {
|
||||
const ax = anchorOf(current.x, current.width, next.x, next.width);
|
||||
const ay = anchorOf(current.y, current.height, next.y, next.height);
|
||||
let scale = 1;
|
||||
if (next.x < bounds.x) {
|
||||
scale = Math.min(scale, (ax - bounds.x) / (ax - next.x));
|
||||
}
|
||||
if (next.x + next.width > bounds.x + bounds.width) {
|
||||
scale = Math.min(
|
||||
scale,
|
||||
(bounds.x + bounds.width - ax) / (next.x + next.width - ax)
|
||||
);
|
||||
}
|
||||
if (next.y < bounds.y) {
|
||||
scale = Math.min(scale, (ay - bounds.y) / (ay - next.y));
|
||||
}
|
||||
if (next.y + next.height > bounds.y + bounds.height) {
|
||||
scale = Math.min(
|
||||
scale,
|
||||
(bounds.y + bounds.height - ay) / (next.y + next.height - ay)
|
||||
);
|
||||
}
|
||||
return {
|
||||
x: ax + (next.x - ax) * scale,
|
||||
y: ay + (next.y - ay) * scale,
|
||||
width: next.width * scale,
|
||||
height: next.height * scale,
|
||||
};
|
||||
};
|
||||
|
||||
const scaleRect = ({ x, y, width, height }: Rect, ratio: number): Rect => ({
|
||||
x: x * ratio,
|
||||
y: y * ratio,
|
||||
width: width * ratio,
|
||||
height: height * ratio,
|
||||
});
|
||||
|
||||
@customElement("image-cropper-dialog")
|
||||
export class HaImagecropperDialog extends DirtyStateProviderMixin<Cropper.Data>()(
|
||||
export class HaImagecropperDialog extends DirtyStateProviderMixin<Rect>()(
|
||||
DialogMixin<HaImageCropperDialogParams>(LitElement)
|
||||
) {
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@query("img") private _image!: HTMLImageElement;
|
||||
@query("cropper-image") private _image?: CropperImage;
|
||||
|
||||
private _cropper?: Cropper;
|
||||
@query("cropper-selection") private _selection?: CropperSelection;
|
||||
|
||||
@state() private _imageSize?: { width: number; height: number };
|
||||
|
||||
@state() private _shown = false;
|
||||
|
||||
@state() private _cropReady = false;
|
||||
|
||||
@state() private _loadError = false;
|
||||
|
||||
@state() private _isTargetAspectRatio?: boolean;
|
||||
|
||||
private _imageUrl?: string;
|
||||
|
||||
private _resizeObserver?: ResizeObserver;
|
||||
|
||||
private _canvasWidth?: number;
|
||||
|
||||
private _refitting = false;
|
||||
|
||||
private _cropped = false;
|
||||
|
||||
private _closing = false;
|
||||
|
||||
private _shiftLocked = false;
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
if (!this._cropped) {
|
||||
this.params?.cancelCallback?.();
|
||||
}
|
||||
this._cropper?.destroy();
|
||||
this._cropper = undefined;
|
||||
if (this._imageUrl) {
|
||||
URL.revokeObjectURL(this._imageUrl);
|
||||
this._imageUrl = undefined;
|
||||
}
|
||||
this._resizeObserver?.disconnect();
|
||||
this._resizeObserver = undefined;
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
if (!this.params) {
|
||||
return;
|
||||
}
|
||||
this._image.src = URL.createObjectURL(this.params.file);
|
||||
this._cropper = new Cropper(this._image, {
|
||||
aspectRatio: this.params.options.aspectRatio,
|
||||
viewMode: 1,
|
||||
dragMode: "move",
|
||||
minCropBoxWidth: 50,
|
||||
ready: () => {
|
||||
this._isTargetAspectRatio = this._checkMatchAspectRatio();
|
||||
URL.revokeObjectURL(this._image!.src);
|
||||
this._initDirtyTracking({ type: "deep" }, this._cropper!.getData());
|
||||
this._cropReady = true;
|
||||
},
|
||||
crop: () => {
|
||||
if (this._cropReady) {
|
||||
this._updateDirtyState(this._cropper!.getData());
|
||||
}
|
||||
},
|
||||
});
|
||||
this._imageUrl = URL.createObjectURL(this.params.file);
|
||||
this._loadImageSize(this._imageUrl);
|
||||
}
|
||||
|
||||
private _checkMatchAspectRatio(): boolean {
|
||||
private async _loadImageSize(url: string) {
|
||||
const image = new Image();
|
||||
image.src = url;
|
||||
try {
|
||||
await image.decode();
|
||||
} catch {
|
||||
this._loadError = true;
|
||||
return;
|
||||
}
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
this._imageSize = {
|
||||
width: image.naturalWidth,
|
||||
height: image.naturalHeight,
|
||||
};
|
||||
this._isTargetAspectRatio = this._checkMatchAspectRatio(
|
||||
image.naturalWidth,
|
||||
image.naturalHeight
|
||||
);
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
super.updated(changedProps);
|
||||
if (
|
||||
!this._cropReady &&
|
||||
(changedProps.has("_imageSize") || changedProps.has("_shown")) &&
|
||||
this._image
|
||||
) {
|
||||
this._initCropper(this._image);
|
||||
}
|
||||
}
|
||||
|
||||
private async _initCropper(image: CropperImage) {
|
||||
try {
|
||||
await image.$ready();
|
||||
} catch {
|
||||
this._loadError = true;
|
||||
return;
|
||||
}
|
||||
if (!this.isConnected || image !== this._image) {
|
||||
return;
|
||||
}
|
||||
const selection = this._selection!;
|
||||
if (!selection.width) {
|
||||
await new Promise<void>((resolve) => {
|
||||
selection.addEventListener("change", () => resolve(), { once: true });
|
||||
});
|
||||
if (!this.isConnected || image !== this._image) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
this._initDirtyTracking(
|
||||
{ type: "deep" },
|
||||
this._cropData(this._selectionRect(), this._imageRect())
|
||||
);
|
||||
this._cropReady = true;
|
||||
this._resizeObserver = new ResizeObserver((entries) => {
|
||||
this._refit(entries[0].contentRect);
|
||||
});
|
||||
this._resizeObserver.observe(image.parentElement!);
|
||||
}
|
||||
|
||||
private _selectionRect(): Rect {
|
||||
const { x, y, width, height } = this._selection!;
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
private _canvasRect(): Rect {
|
||||
const { width, height } =
|
||||
this._image!.parentElement!.getBoundingClientRect();
|
||||
return { x: 0, y: 0, width, height };
|
||||
}
|
||||
|
||||
private _imageRect(): Rect {
|
||||
const canvasRect = this._image!.parentElement!.getBoundingClientRect();
|
||||
const imageRect = this._image!.getBoundingClientRect();
|
||||
return {
|
||||
x: imageRect.x - canvasRect.x,
|
||||
y: imageRect.y - canvasRect.y,
|
||||
width: imageRect.width,
|
||||
height: imageRect.height,
|
||||
};
|
||||
}
|
||||
|
||||
private _cropData(selection: Rect, image: Rect): Rect {
|
||||
const scale = image.width / this._imageSize!.width;
|
||||
return {
|
||||
x: Math.round((selection.x - image.x) / scale),
|
||||
y: Math.round((selection.y - image.y) / scale),
|
||||
width: Math.round(selection.width / scale),
|
||||
height: Math.round(selection.height / scale),
|
||||
};
|
||||
}
|
||||
|
||||
private _refit(canvas: { width: number; height: number }) {
|
||||
const previous = this._canvasWidth;
|
||||
this._canvasWidth = canvas.width;
|
||||
if (!previous || !canvas.width || previous === canvas.width) {
|
||||
return;
|
||||
}
|
||||
const current = this._selectionRect();
|
||||
const ratio = Math.min(
|
||||
canvas.width / previous,
|
||||
canvas.height / current.height
|
||||
);
|
||||
const image = scaleRect(this._imageRect(), ratio);
|
||||
const selection = scaleRect(current, ratio);
|
||||
const dy =
|
||||
image.height <= canvas.height
|
||||
? (canvas.height - image.height) / 2 - image.y
|
||||
: clamp(
|
||||
0,
|
||||
-selection.y,
|
||||
canvas.height - selection.y - selection.height
|
||||
);
|
||||
image.y += dy;
|
||||
selection.y += dy;
|
||||
const { width, height } = this._imageSize!;
|
||||
this._refitting = true;
|
||||
this._image!.$setTransform(
|
||||
image.width / width,
|
||||
0,
|
||||
0,
|
||||
image.height / height,
|
||||
image.x + image.width / 2 - width / 2,
|
||||
image.y + image.height / 2 - height / 2
|
||||
);
|
||||
this._selection!.$change(
|
||||
selection.x,
|
||||
selection.y,
|
||||
selection.width,
|
||||
selection.height
|
||||
);
|
||||
this._refitting = false;
|
||||
}
|
||||
|
||||
private _checkMatchAspectRatio(
|
||||
naturalWidth: number,
|
||||
naturalHeight: number
|
||||
): boolean {
|
||||
const targetRatio = this.params?.options.aspectRatio;
|
||||
if (!targetRatio) {
|
||||
return true;
|
||||
}
|
||||
const imageData = this._cropper!.getImageData();
|
||||
if (imageData.aspectRatio === targetRatio) {
|
||||
if (naturalWidth / naturalHeight === targetRatio) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the image is not exactly the aspect ratio see if it is within a pixel.
|
||||
if (imageData.naturalWidth > imageData.naturalHeight) {
|
||||
const targetHeight = imageData.naturalWidth / targetRatio;
|
||||
return Math.abs(targetHeight - imageData.naturalHeight) <= 1;
|
||||
if (naturalWidth > naturalHeight) {
|
||||
const targetHeight = naturalWidth / targetRatio;
|
||||
return Math.abs(targetHeight - naturalHeight) <= 1;
|
||||
}
|
||||
const targetWidth = imageData.naturalHeight * targetRatio;
|
||||
return Math.abs(targetWidth - imageData.naturalWidth) <= 1;
|
||||
const targetWidth = naturalHeight * targetRatio;
|
||||
return Math.abs(targetWidth - naturalWidth) <= 1;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | typeof nothing {
|
||||
@@ -93,23 +340,74 @@ export class HaImagecropperDialog extends DirtyStateProviderMixin<Cropper.Data>(
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const label = this._i18n.localize("ui.dialogs.image_cropper.crop_image");
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
open
|
||||
header-title=${this._i18n.localize(
|
||||
"ui.dialogs.image_cropper.crop_image"
|
||||
)}
|
||||
header-title=${label}
|
||||
.preventScrimClose=${this.isDirtyState}
|
||||
@wa-hide=${this._handleHide}
|
||||
@after-show=${this._handleAfterShow}
|
||||
>
|
||||
<div
|
||||
class="container ${classMap({
|
||||
round: Boolean(this.params.options.round),
|
||||
})}"
|
||||
>
|
||||
<img
|
||||
alt=${this._i18n.localize("ui.dialogs.image_cropper.crop_image")}
|
||||
/>
|
||||
${
|
||||
this._loadError
|
||||
? html`<ha-alert alert-type="error">
|
||||
${this._i18n.localize("ui.dialogs.image_cropper.load_error")}
|
||||
</ha-alert>`
|
||||
: this._imageSize && this._shown
|
||||
? html`
|
||||
<cropper-canvas
|
||||
background
|
||||
@actionmove=${this._handleCanvasActionMove}
|
||||
style=${styleMap({
|
||||
aspectRatio: `${this._imageSize.width} / ${this._imageSize.height}`,
|
||||
})}
|
||||
>
|
||||
<cropper-image
|
||||
src=${this._imageUrl!}
|
||||
alt=${label}
|
||||
scalable
|
||||
translatable
|
||||
@change=${this._handleImageChange}
|
||||
></cropper-image>
|
||||
<cropper-shade hidden></cropper-shade>
|
||||
<cropper-handle action="move" plain></cropper-handle>
|
||||
<cropper-selection
|
||||
aspect-ratio=${ifDefined(
|
||||
this.params.options.aspectRatio || undefined
|
||||
)}
|
||||
initial-coverage=${INITIAL_COVERAGE}
|
||||
precise
|
||||
movable
|
||||
resizable
|
||||
outlined
|
||||
@change=${this._handleSelectionChange}
|
||||
>
|
||||
<cropper-grid
|
||||
role="grid"
|
||||
bordered
|
||||
covered
|
||||
></cropper-grid>
|
||||
<cropper-handle action="move" plain></cropper-handle>
|
||||
<cropper-handle action="n-resize"></cropper-handle>
|
||||
<cropper-handle action="e-resize"></cropper-handle>
|
||||
<cropper-handle action="s-resize"></cropper-handle>
|
||||
<cropper-handle action="w-resize"></cropper-handle>
|
||||
<cropper-handle action="ne-resize"></cropper-handle>
|
||||
<cropper-handle action="nw-resize"></cropper-handle>
|
||||
<cropper-handle action="se-resize"></cropper-handle>
|
||||
<cropper-handle action="sw-resize"></cropper-handle>
|
||||
</cropper-selection>
|
||||
</cropper-canvas>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
@@ -127,9 +425,7 @@ export class HaImagecropperDialog extends DirtyStateProviderMixin<Cropper.Data>(
|
||||
appearance="plain"
|
||||
@click=${this._useOriginal}
|
||||
>
|
||||
${this._i18n.localize(
|
||||
"ui.dialogs.image_cropper.use_original"
|
||||
)}
|
||||
${this._i18n.localize("ui.dialogs.image_cropper.use_original")}
|
||||
</ha-button>
|
||||
`
|
||||
: nothing
|
||||
@@ -146,17 +442,99 @@ export class HaImagecropperDialog extends DirtyStateProviderMixin<Cropper.Data>(
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleAfterShow() {
|
||||
this._shown = true;
|
||||
}
|
||||
|
||||
private _handleHide(ev: Event) {
|
||||
// Ignore hide events from nested overlays and blocked dismissals.
|
||||
if (ev.eventPhase !== Event.AT_TARGET || ev.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
this._closing = true;
|
||||
}
|
||||
|
||||
private _cropImage() {
|
||||
const canvas = this._cropper?.getCroppedCanvas();
|
||||
if (!canvas) {
|
||||
private _handleCanvasActionMove(
|
||||
ev: CustomEvent<{ relatedEvent?: PointerEvent }>
|
||||
) {
|
||||
this._shiftLocked = ev.detail.relatedEvent?.shiftKey ?? false;
|
||||
}
|
||||
|
||||
private _handleSelectionChange(ev: CustomEvent<Rect>) {
|
||||
if (!this._cropReady || this._refitting) {
|
||||
return;
|
||||
}
|
||||
const selection = this._selectionRect();
|
||||
const image = this._imageRect();
|
||||
const bounds = intersect(image, this._canvasRect());
|
||||
const next = ev.detail;
|
||||
if (next.width < MIN_SELECTION_SIZE && next.width < selection.width) {
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!contains(bounds, next)) {
|
||||
ev.preventDefault();
|
||||
const fitted = sameSize(next, selection)
|
||||
? moveInto(next, bounds)
|
||||
: this.params?.options.aspectRatio || this._shiftLocked
|
||||
? shrinkInto(selection, next, bounds)
|
||||
: intersect(next, bounds);
|
||||
this._selection!.$change(fitted.x, fitted.y, fitted.width, fitted.height);
|
||||
return;
|
||||
}
|
||||
this._updateDirtyState(this._cropData(next, image));
|
||||
}
|
||||
|
||||
private _handleImageChange(ev: CustomEvent<Rect>) {
|
||||
if (!this._cropReady || this._refitting) {
|
||||
return;
|
||||
}
|
||||
const selection = this._selectionRect();
|
||||
const next = ev.detail;
|
||||
if (!contains(next, selection)) {
|
||||
ev.preventDefault();
|
||||
const current = this._imageRect();
|
||||
if (sameSize(next, current)) {
|
||||
const x = clamp(
|
||||
next.x,
|
||||
selection.x + selection.width - next.width,
|
||||
selection.x
|
||||
);
|
||||
const y = clamp(
|
||||
next.y,
|
||||
selection.y + selection.height - next.height,
|
||||
selection.y
|
||||
);
|
||||
if (x !== current.x || y !== current.y) {
|
||||
this._image!.$move(x - current.x, y - current.y);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._updateDirtyState(this._cropData(selection, next));
|
||||
}
|
||||
|
||||
private async _cropImage() {
|
||||
const selection = this._selection;
|
||||
if (!selection || !this._cropReady) {
|
||||
return;
|
||||
}
|
||||
const image = this._imageRect();
|
||||
const rect = this._selectionRect();
|
||||
const x = clamp(rect.x, image.x, image.x + image.width - rect.width);
|
||||
const y = clamp(rect.y, image.y, image.y + image.height - rect.height);
|
||||
if (x !== rect.x || y !== rect.y) {
|
||||
selection.$change(x, y);
|
||||
}
|
||||
const crop = this._cropData(this._selectionRect(), image);
|
||||
let canvas: HTMLCanvasElement;
|
||||
try {
|
||||
canvas = await selection.$toCanvas({
|
||||
width: crop.width,
|
||||
beforeDraw: (_context, target) => {
|
||||
target.height = crop.height;
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const params = this.params!;
|
||||
@@ -188,22 +566,22 @@ export class HaImagecropperDialog extends DirtyStateProviderMixin<Cropper.Data>(
|
||||
return [
|
||||
haStyleDialog,
|
||||
css`
|
||||
${unsafeCSS(cropperCss)}
|
||||
.container {
|
||||
max-width: 640px;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
cropper-canvas {
|
||||
width: 100%;
|
||||
direction: ltr;
|
||||
--theme-color: var(--primary-color);
|
||||
}
|
||||
.container.round .cropper-view-box,
|
||||
.container.round .cropper-face {
|
||||
cropper-handle {
|
||||
--theme-color: var(--primary-color);
|
||||
}
|
||||
.container.round cropper-selection,
|
||||
.container.round cropper-shade,
|
||||
.container.round cropper-grid {
|
||||
border-radius: var(--ha-picture-border-radius);
|
||||
}
|
||||
.cropper-line,
|
||||
.cropper-point,
|
||||
.cropper-point.point-se::before {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
@@ -212,5 +590,11 @@ export class HaImagecropperDialog extends DirtyStateProviderMixin<Cropper.Data>(
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"image-cropper-dialog": HaImagecropperDialog;
|
||||
"cropper-canvas": CropperCanvas;
|
||||
"cropper-image": CropperImage;
|
||||
"cropper-shade": CropperShade;
|
||||
"cropper-handle": CropperHandle;
|
||||
"cropper-selection": CropperSelection;
|
||||
"cropper-grid": CropperGrid;
|
||||
}
|
||||
}
|
||||
@@ -167,16 +167,12 @@ export class HaMoreInfoFavorites extends LitElement {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: calc(var(--ha-space-2) * -1);
|
||||
gap: var(--ha-space-4);
|
||||
flex-wrap: wrap;
|
||||
max-width: var(--favorite-items-max-width, 250px);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.container > * {
|
||||
margin: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.favorite {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -88,10 +88,7 @@ class MoreInfoGroup extends LitElement {
|
||||
return "";
|
||||
}
|
||||
return html`
|
||||
<state-card-content
|
||||
.stateObj=${entityState}
|
||||
.hass=${this.hass}
|
||||
></state-card-content>
|
||||
<state-card-content .stateObj=${entityState}></state-card-content>
|
||||
`;
|
||||
})}`;
|
||||
}
|
||||
|
||||
@@ -124,7 +124,12 @@ class MoreInfoMediaPlayer extends LitElement {
|
||||
private _volumeController = new VolumeSliderController({
|
||||
getSlider: () => this._volumeSlider,
|
||||
step: this._volumeStep,
|
||||
onSetVolume: (value) => this._setVolume(value),
|
||||
onSetVolume: (value) => {
|
||||
// The final value is set right away, so drop the pending update from
|
||||
// the input events before it, or the same value is sent twice
|
||||
this._debouncedVolumeSet.cancel();
|
||||
this._setVolume(value);
|
||||
},
|
||||
onSetVolumeDebounced: (value) => this._debouncedVolumeSet(value),
|
||||
});
|
||||
|
||||
|
||||
@@ -74,7 +74,6 @@ export class MoreInfoInfo extends LitElement {
|
||||
<state-card-content
|
||||
in-dialog
|
||||
.stateObj=${stateObj}
|
||||
.hass=${this.hass}
|
||||
></state-card-content>
|
||||
`
|
||||
}
|
||||
|
||||
@@ -642,7 +642,17 @@ export class QuickBar extends LitElement {
|
||||
);
|
||||
|
||||
private _getEntitiesMemoized = memoizeOne((hass: HomeAssistant) =>
|
||||
getEntities(hass, { idPrefix: `entity${SEPARATOR}` })
|
||||
getEntities({
|
||||
states: hass.states,
|
||||
entities: hass.entities,
|
||||
devices: hass.devices,
|
||||
areas: hass.areas,
|
||||
floors: hass.floors,
|
||||
localize: hass.localize,
|
||||
language: hass.language,
|
||||
translationMetadata: hass.translationMetadata,
|
||||
idPrefix: `entity${SEPARATOR}`,
|
||||
})
|
||||
);
|
||||
|
||||
private _getDevicesMemoized = memoizeOne(
|
||||
|
||||
@@ -150,9 +150,11 @@ class DialogRestart extends LitElement {
|
||||
"ui.dialogs.restart.reload.description"
|
||||
)}
|
||||
</div>
|
||||
<div slot="start" class="icon-background reload">
|
||||
<ha-svg-icon .path=${mdiAutoFix}></ha-svg-icon>
|
||||
</div>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
class="icon-background reload"
|
||||
.path=${mdiAutoFix}
|
||||
></ha-svg-icon>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-list-item-button>
|
||||
<ha-list-item-button
|
||||
@@ -160,9 +162,11 @@ class DialogRestart extends LitElement {
|
||||
@click=${this._handleAction}
|
||||
.disabled=${this._loadingBackupInfo}
|
||||
>
|
||||
<div slot="start" class="icon-background restart">
|
||||
<ha-svg-icon .path=${mdiRefresh}></ha-svg-icon>
|
||||
</div>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
class="icon-background restart"
|
||||
.path=${mdiRefresh}
|
||||
></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize("ui.dialogs.restart.restart.title")}
|
||||
</div>
|
||||
@@ -188,14 +192,11 @@ class DialogRestart extends LitElement {
|
||||
@click=${this._handleAction}
|
||||
.disabled=${this._loadingBackupInfo}
|
||||
>
|
||||
<div
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
class="icon-background reboot"
|
||||
>
|
||||
<ha-svg-icon
|
||||
.path=${mdiPowerCycle}
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
.path=${mdiPowerCycle}
|
||||
></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.restart.reboot.title"
|
||||
@@ -213,12 +214,11 @@ class DialogRestart extends LitElement {
|
||||
@click=${this._handleAction}
|
||||
.disabled=${this._loadingBackupInfo}
|
||||
>
|
||||
<div
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
class="icon-background shutdown"
|
||||
>
|
||||
<ha-svg-icon .path=${mdiPower}></ha-svg-icon>
|
||||
</div>
|
||||
.path=${mdiPower}
|
||||
></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.restart.shutdown.title"
|
||||
@@ -239,12 +239,11 @@ class DialogRestart extends LitElement {
|
||||
@click=${this._handleAction}
|
||||
.disabled=${this._loadingBackupInfo}
|
||||
>
|
||||
<div
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
class="icon-background restart-safe-mode"
|
||||
>
|
||||
<ha-svg-icon .path=${mdiLifebuoy}></ha-svg-icon>
|
||||
</div>
|
||||
.path=${mdiLifebuoy}
|
||||
></ha-svg-icon>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.restart.restart-safe-mode.title"
|
||||
@@ -436,12 +435,8 @@ class DialogRestart extends LitElement {
|
||||
.icon-background {
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* 24px icon + padding = 40px circle */
|
||||
padding: var(--ha-space-2);
|
||||
}
|
||||
.reload {
|
||||
background-color: #5f8a49;
|
||||
|
||||
@@ -310,7 +310,7 @@ export class HaVoiceAssistantSetupStepLocal extends LitElement {
|
||||
|
||||
if (configFlow) {
|
||||
const step = await handleConfigFlowStep(
|
||||
this.hass,
|
||||
this.hass.callApi,
|
||||
configFlow.flow_id,
|
||||
{}
|
||||
);
|
||||
@@ -343,11 +343,15 @@ export class HaVoiceAssistantSetupStepLocal extends LitElement {
|
||||
}
|
||||
|
||||
private async _createConfigEntry(type: "tts" | "stt") {
|
||||
const configFlow = await createConfigFlow(this.hass, "wyoming");
|
||||
const step = await handleConfigFlowStep(this.hass, configFlow.flow_id, {
|
||||
host: type === "tts" ? this._ttsHostName : this._sttHostName,
|
||||
port: type === "tts" ? this._ttsPort : this._sttPort,
|
||||
});
|
||||
const configFlow = await createConfigFlow(this.hass.callApi, "wyoming");
|
||||
const step = await handleConfigFlowStep(
|
||||
this.hass.callApi,
|
||||
configFlow.flow_id,
|
||||
{
|
||||
host: type === "tts" ? this._ttsHostName : this._sttHostName,
|
||||
port: type === "tts" ? this._ttsPort : this._sttPort,
|
||||
}
|
||||
);
|
||||
if (step.type !== "create_entry") {
|
||||
throw new Error(
|
||||
`${this.hass.localize("ui.panel.config.voice_assistants.satellite_wizard.local.errors.failed_create_entry", { app: type === "tts" ? this._ttsProviderName : this._sttProviderName })}${"errors" in step ? `: ${step.errors?.base}` : ""}`
|
||||
|
||||
@@ -45,15 +45,50 @@
|
||||
}
|
||||
#ha-launch-screen .ha-launch-screen-spacer-top {
|
||||
flex: 1;
|
||||
margin-top: calc( 2 * max(var(--safe-area-inset-top, 0px), 48px) + 46px );
|
||||
margin-top: 142px;
|
||||
padding-top: 48px;
|
||||
}
|
||||
#ha-launch-screen .ha-launch-screen-spacer-bottom {
|
||||
flex: 1;
|
||||
padding-top: 16px;
|
||||
}
|
||||
#ha-launch-screen-info-box p {
|
||||
max-width: 400px;
|
||||
margin: 0 16px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
/* Margins, not flex `gap` (Safari 14.1). */
|
||||
#ha-launch-screen .ha-launch-screen-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin: 0 16px;
|
||||
}
|
||||
#ha-launch-screen .ha-launch-screen-actions label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
#ha-launch-screen .ha-launch-screen-actions input {
|
||||
margin: 0 8px 0 0;
|
||||
accent-color: #009ac7;
|
||||
}
|
||||
#ha-launch-screen .ha-launch-screen-actions button {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 18px;
|
||||
background: none;
|
||||
color: #009ac7;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ohf-logo {
|
||||
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
|
||||
margin: 48px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -73,6 +108,16 @@
|
||||
aspect-ratio: 237 / 24;
|
||||
height: auto;
|
||||
}
|
||||
/* max() with var() fails after the cascade, so plain fallbacks above
|
||||
need @supports (Chrome below 79). `body`: Jinja2, see below. */
|
||||
@supports (margin: max(1px, 2px)) {
|
||||
body #ha-launch-screen .ha-launch-screen-spacer-top {
|
||||
margin-top: calc( 2 * max(var(--safe-area-inset-top, 0px), 48px) + 46px );
|
||||
}
|
||||
.ohf-logo {
|
||||
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
|
||||
}
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
/*
|
||||
* Core renders the app's index.html through Jinja2, where the minified
|
||||
|
||||
@@ -65,23 +65,86 @@
|
||||
</script>
|
||||
<script>
|
||||
if (!window.latestJS) {
|
||||
// Below the [legacy] browserslist floor the entry bundles cannot be
|
||||
// parsed, or dialogs cannot open, and nothing would ever leave the
|
||||
// launch screen. Test the features that set the floors: BigInt for
|
||||
// Chrome 67, native <dialog> for Safari 15.4 and Firefox 98. Not
|
||||
// transpiled, so keep this ES5.
|
||||
if (
|
||||
typeof BigInt === "undefined" ||
|
||||
typeof document.createElement("dialog").showModal !== "function"
|
||||
) {
|
||||
document.getElementById("ha-launch-screen-info-box").textContent =
|
||||
"This browser is too old to run Home Assistant.";
|
||||
} else {
|
||||
window.customPanelJS = "<%= es5CustomPanelJS %>";
|
||||
<% for (const entry of es5EntryJS) { %>
|
||||
_ls("<%= entry %>", true);
|
||||
<% } %>
|
||||
}
|
||||
// Floor check: BigInt for Chrome 67, <dialog> for Safari 15.4 and
|
||||
// Firefox 98. Not transpiled, so keep this ES5.
|
||||
(function () {
|
||||
var ACCEPTED_KEY = "haUnsupportedBrowserAccepted";
|
||||
var REMIND_AFTER = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function loadApp() {
|
||||
window.customPanelJS = "<%= es5CustomPanelJS %>";
|
||||
<% for (const entry of es5EntryJS) { %>
|
||||
_ls("<%= entry %>", true);
|
||||
<% } %>
|
||||
}
|
||||
|
||||
if (
|
||||
typeof BigInt !== "undefined" &&
|
||||
typeof document.createElement("dialog").showModal === "function"
|
||||
) {
|
||||
loadApp();
|
||||
return;
|
||||
}
|
||||
|
||||
// Shown while the app loads too.
|
||||
var box = document.getElementById("ha-launch-screen-info-box");
|
||||
var message = document.createElement("p");
|
||||
message.textContent =
|
||||
"This browser is no longer supported. Some parts of Home Assistant may not work.";
|
||||
box.appendChild(message);
|
||||
|
||||
// Stored: "always", or when the user last continued (ms).
|
||||
var stored = null;
|
||||
try {
|
||||
stored = localStorage.getItem(ACCEPTED_KEY);
|
||||
} catch (_err) {
|
||||
// Ask on every load.
|
||||
}
|
||||
if (stored === "always") {
|
||||
loadApp();
|
||||
return;
|
||||
}
|
||||
var acceptedAt = Number(stored) || 0;
|
||||
// A clock behind the stored time asks again.
|
||||
var age = Date.now() - acceptedAt;
|
||||
if (acceptedAt && age >= 0 && age < REMIND_AFTER) {
|
||||
loadApp();
|
||||
return;
|
||||
}
|
||||
|
||||
var actions = document.createElement("div");
|
||||
actions.className = "ha-launch-screen-actions";
|
||||
box.appendChild(actions);
|
||||
|
||||
var always = null;
|
||||
var button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = "Continue anyway";
|
||||
button.addEventListener("click", function () {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
ACCEPTED_KEY,
|
||||
always && always.checked ? "always" : String(Date.now())
|
||||
);
|
||||
} catch (_err) {
|
||||
// Continue this time only.
|
||||
}
|
||||
box.removeChild(actions);
|
||||
loadApp();
|
||||
});
|
||||
actions.appendChild(button);
|
||||
|
||||
// From the first reminder on. A checkbox, so it is not chosen by
|
||||
// accident.
|
||||
if (acceptedAt) {
|
||||
var label = document.createElement("label");
|
||||
always = document.createElement("input");
|
||||
always.type = "checkbox";
|
||||
label.appendChild(always);
|
||||
label.appendChild(document.createTextNode("Don't show again"));
|
||||
actions.appendChild(label);
|
||||
}
|
||||
})();
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -155,6 +155,10 @@ export const DirtyStateProviderMixin =
|
||||
|
||||
protected updated(changedProperties: PropertyValues<this>): void {
|
||||
super.updated(changedProperties);
|
||||
// A late fetch can still update a removed provider
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
connectedDirtyStateProviders.set(this, this._blocksReload);
|
||||
publishGlobalDirtyState();
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { IngressAppInfo } from "../../data/hassio/ingress";
|
||||
import {
|
||||
createHassioSession,
|
||||
fetchIngressAppInfo,
|
||||
validateHassioSession,
|
||||
refreshHassioSession,
|
||||
} from "../../data/hassio/ingress";
|
||||
import {
|
||||
showAlertDialog,
|
||||
@@ -60,6 +60,10 @@ class HaPanelApp extends LitElement {
|
||||
|
||||
private _sessionKeepAlive?: number;
|
||||
|
||||
private _session?: { slug: string; session: string };
|
||||
|
||||
private _sessionRefresh?: Promise<void>;
|
||||
|
||||
private _fetchDataTimeout?: number;
|
||||
|
||||
private _autoRetryUntil?: number;
|
||||
@@ -92,17 +96,22 @@ class HaPanelApp extends LitElement {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("message", this._handleIframeMessage);
|
||||
window.addEventListener("resize", this._handleResize);
|
||||
document.addEventListener("visibilitychange", this._handleVisibility);
|
||||
document.addEventListener("resume", this._handleVisibility);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("message", this._handleIframeMessage);
|
||||
window.removeEventListener("resize", this._handleResize);
|
||||
document.removeEventListener("visibilitychange", this._handleVisibility);
|
||||
document.removeEventListener("resume", this._handleVisibility);
|
||||
|
||||
if (this._sessionKeepAlive) {
|
||||
clearInterval(this._sessionKeepAlive);
|
||||
this._sessionKeepAlive = undefined;
|
||||
}
|
||||
this._session = undefined;
|
||||
if (this._fetchDataTimeout) {
|
||||
clearTimeout(this._fetchDataTimeout);
|
||||
this._fetchDataTimeout = undefined;
|
||||
@@ -342,17 +351,51 @@ class HaPanelApp extends LitElement {
|
||||
if (this._sessionKeepAlive) {
|
||||
clearInterval(this._sessionKeepAlive);
|
||||
}
|
||||
this._sessionKeepAlive = window.setInterval(async () => {
|
||||
try {
|
||||
await validateHassioSession(this.hass, session);
|
||||
} catch (_err: any) {
|
||||
session = await createHassioSession(this.hass, addonSlug);
|
||||
}
|
||||
}, 60000);
|
||||
this._session = { slug: addonSlug, session };
|
||||
this._sessionKeepAlive = window.setInterval(this._refreshSession, 60000);
|
||||
|
||||
this._addon = addon;
|
||||
}
|
||||
|
||||
// The keep-alive timer does not run while the device sleeps, so the session
|
||||
// may have expired by the time the page is visible again. Refresh it right
|
||||
// away instead of leaving the app unreachable until the next tick.
|
||||
// "resume" only fires in Chromium after a frozen page, other browsers rely
|
||||
// on "visibilitychange".
|
||||
private _handleVisibility = () => {
|
||||
if (!document.hidden) {
|
||||
this._refreshSession();
|
||||
}
|
||||
};
|
||||
|
||||
private _refreshSession = (): Promise<void> => {
|
||||
const current = this._session;
|
||||
if (!current) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (this._sessionRefresh) {
|
||||
return this._sessionRefresh;
|
||||
}
|
||||
this._sessionRefresh = refreshHassioSession(
|
||||
this.hass,
|
||||
current.slug,
|
||||
current.session
|
||||
)
|
||||
.then((session) => {
|
||||
// the panel may have switched to another app meanwhile
|
||||
if (this._session === current) {
|
||||
this._session = { slug: current.slug, session };
|
||||
}
|
||||
})
|
||||
.catch((_err: any) => {
|
||||
// Connection to Home Assistant is gone, the next refresh will retry
|
||||
})
|
||||
.finally(() => {
|
||||
this._sessionRefresh = undefined;
|
||||
});
|
||||
return this._sessionRefresh;
|
||||
};
|
||||
|
||||
private async _checkLoaded(ev: Event): Promise<void> {
|
||||
const iframe = ev.target as HTMLIFrameElement;
|
||||
|
||||
|
||||
@@ -816,7 +816,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
|
||||
this._currentAddon.version
|
||||
? html`
|
||||
<wa-divider></wa-divider>
|
||||
<ha-row-item>
|
||||
<ha-row-item wrap-supporting-text>
|
||||
<span slot="headline">
|
||||
${this.i18n.localize(
|
||||
"ui.panel.config.apps.dashboard.option.boot.title"
|
||||
@@ -842,7 +842,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
|
||||
${
|
||||
this._currentAddon.startup !== "once"
|
||||
? html`
|
||||
<ha-row-item>
|
||||
<ha-row-item wrap-supporting-text>
|
||||
<span slot="headline">
|
||||
${this.i18n.localize(
|
||||
"ui.panel.config.apps.dashboard.option.watchdog.title"
|
||||
@@ -867,7 +867,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<ha-row-item>
|
||||
<ha-row-item wrap-supporting-text>
|
||||
<span slot="headline">
|
||||
${this.i18n.localize(
|
||||
"ui.panel.config.apps.dashboard.option.auto_update.title"
|
||||
@@ -893,7 +893,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
|
||||
!this._computeCannotIngressSidebar &&
|
||||
this._currentAddon.ingress
|
||||
? html`
|
||||
<ha-row-item>
|
||||
<ha-row-item wrap-supporting-text>
|
||||
<span slot="headline">
|
||||
${this.i18n.localize(
|
||||
"ui.panel.config.apps.dashboard.option.ingress_panel.title"
|
||||
@@ -923,7 +923,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
|
||||
${
|
||||
this._computeUsesProtectedOptions
|
||||
? html`
|
||||
<ha-row-item>
|
||||
<ha-row-item wrap-supporting-text>
|
||||
<span slot="headline">
|
||||
${this.i18n.localize(
|
||||
"ui.panel.config.apps.dashboard.option.protected.title"
|
||||
@@ -954,7 +954,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
|
||||
${
|
||||
this._currentAddon.version && this._currentAddon.state === "started"
|
||||
? html`<wa-divider></wa-divider>
|
||||
<ha-row-item>
|
||||
<ha-row-item wrap-supporting-text>
|
||||
<span slot="supporting-text">
|
||||
${this.i18n.localize(
|
||||
"ui.panel.config.apps.dashboard.hostname"
|
||||
@@ -1630,10 +1630,6 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
|
||||
color: var(--ha-color-text-secondary);
|
||||
}
|
||||
|
||||
.controls ha-row-item::part(supporting-text) {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.controls .content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
@@ -29,6 +29,10 @@ import type {
|
||||
AreaRegistryEntryMutableParams,
|
||||
} from "../../../data/area/area_registry";
|
||||
import { deleteAreaRegistryEntry } from "../../../data/area/area_registry";
|
||||
import {
|
||||
getAreaIconSuggestions,
|
||||
ROOM_ICONS,
|
||||
} from "../../../data/area_icon_suggestions";
|
||||
import {
|
||||
SENSOR_DEVICE_CLASS_HUMIDITY,
|
||||
SENSOR_DEVICE_CLASS_TEMPERATURE,
|
||||
@@ -96,6 +100,10 @@ class DialogAreaDetail
|
||||
|
||||
@state() private _submitting = false;
|
||||
|
||||
@state() private _suggestedIcons?: string[];
|
||||
|
||||
private _iconFollowsName = false;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _suggestionInclude: MetadataSuggestionInclude = {
|
||||
@@ -128,6 +136,7 @@ class DialogAreaDetail
|
||||
this._temperatureEntity = null;
|
||||
this._humidityEntity = null;
|
||||
}
|
||||
this._iconFollowsName = !this._params.entry;
|
||||
this._open = true;
|
||||
this._initDirtyTracking({ type: "deep" }, this._currentState());
|
||||
await this.updateComplete;
|
||||
@@ -151,6 +160,30 @@ class DialogAreaDetail
|
||||
return true;
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues) {
|
||||
super.willUpdate(changedProps);
|
||||
if (changedProps.has("_name") || changedProps.has("_aliases")) {
|
||||
this._updateSuggestedIcons();
|
||||
}
|
||||
}
|
||||
|
||||
private _updateSuggestedIcons() {
|
||||
const icons = getAreaIconSuggestions(
|
||||
[this._name ?? "", ...(this._aliases ?? [])],
|
||||
this.hass.localize
|
||||
);
|
||||
this._suggestedIcons = icons;
|
||||
if (this._iconFollowsName) {
|
||||
const untouched = !this.isDirtyState;
|
||||
this._icon = icons[0] ?? null;
|
||||
if (untouched) {
|
||||
this._initDirtyTracking({ type: "deep" }, this._currentState());
|
||||
} else {
|
||||
this._updateDirtyState(this._currentState());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _entityName(entityId: string | null): string | null {
|
||||
const stateObj = entityId ? this.hass.states[entityId] : undefined;
|
||||
return stateObj ? this.hass.formatEntityName(stateObj) : null;
|
||||
@@ -193,6 +226,7 @@ class DialogAreaDetail
|
||||
.value=${this._icon}
|
||||
@value-changed=${this._iconChanged}
|
||||
.label=${this.hass.localize("ui.panel.config.areas.editor.icon")}
|
||||
.suggestedIcons=${this._suggestedIcons}
|
||||
></ha-icon-picker>
|
||||
|
||||
<ha-floor-picker
|
||||
@@ -300,6 +334,7 @@ class DialogAreaDetail
|
||||
this._suggestionInclude = {
|
||||
...this._suggestionInclude,
|
||||
name: this._name.trim() === "",
|
||||
room: !this._icon || this._iconFollowsName,
|
||||
};
|
||||
|
||||
return generateMetadataSuggestionTask<{
|
||||
@@ -349,6 +384,11 @@ class DialogAreaDetail
|
||||
this._floor = processed.floor;
|
||||
}
|
||||
|
||||
if (processed.room && (!this._icon || this._iconFollowsName)) {
|
||||
this._icon = ROOM_ICONS[processed.room][0];
|
||||
this._iconFollowsName = false;
|
||||
}
|
||||
|
||||
this._updateDirtyState(this._currentState());
|
||||
}
|
||||
|
||||
@@ -464,6 +504,7 @@ class DialogAreaDetail
|
||||
private _iconChanged(ev) {
|
||||
this._error = undefined;
|
||||
this._icon = ev.detail.value;
|
||||
this._iconFollowsName = false;
|
||||
this._updateDirtyState(this._currentState());
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +129,6 @@ import "./types/ha-automation-action-wait_template";
|
||||
import { renderCtrlOrCmd } from "../../../../common/keyboard/ctrl-or-cmd";
|
||||
import { scrollRowIntoView } from "../scroll-row-into-view";
|
||||
import { AutomationPathController } from "../automation-path";
|
||||
import { renderRowErrorIcon } from "../automation-row-error-icon";
|
||||
|
||||
export interface ActionElement extends LitElement {
|
||||
action: Action;
|
||||
@@ -499,11 +498,6 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
${this._runResult?.title}
|
||||
</ha-automation-row-event-chip>
|
||||
|
||||
${renderRowErrorIcon(
|
||||
this.pathController.errors,
|
||||
this.pathController.path,
|
||||
this.hass.localize("ui.panel.config.automation.editor.errors.label")
|
||||
)}
|
||||
<slot name="icons" slot="icons"></slot>
|
||||
|
||||
<ha-dropdown
|
||||
@@ -804,7 +798,6 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
...ACTION_COMBINED_BLOCKS,
|
||||
].includes(blockType!)}
|
||||
.sortSelected=${this.sortSelected}
|
||||
.error=${this.pathController.errors.length > 0}
|
||||
.dim=${this._running}
|
||||
@click=${this._toggleSidebar}
|
||||
@toggle-collapsed=${this._toggleCollapse}
|
||||
|
||||
@@ -27,18 +27,9 @@ import {
|
||||
} from "../../../../../data/device/device_registry";
|
||||
import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import {
|
||||
deviceAutomationErrorText,
|
||||
deviceErrorText,
|
||||
} from "../../automation-errors";
|
||||
import { AutomationFieldErrorsController } from "../../automation-field-errors";
|
||||
|
||||
@customElement("ha-automation-action-device_id")
|
||||
export class HaDeviceAction extends LitElement {
|
||||
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
|
||||
inlineFields: ["device_id", "domain"],
|
||||
});
|
||||
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
@@ -140,20 +131,12 @@ export class HaDeviceAction extends LitElement {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const deviceError = this._fieldErrors.fieldError("device_id");
|
||||
const automationError = this._fieldErrors.fieldError("domain");
|
||||
const deviceId = this._deviceId || this.action.device_id;
|
||||
|
||||
return html`
|
||||
<ha-device-picker
|
||||
.value=${deviceId}
|
||||
.replacementDeviceIds=${this._replacementDeviceIds}
|
||||
.invalid=${!!deviceError}
|
||||
.errorMessage=${
|
||||
deviceError
|
||||
? deviceErrorText(this.hass.localize, deviceError)
|
||||
: undefined
|
||||
}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._devicePicked}
|
||||
label=${this.hass.localize(
|
||||
@@ -161,16 +144,6 @@ export class HaDeviceAction extends LitElement {
|
||||
)}
|
||||
></ha-device-picker>
|
||||
<ha-device-action-picker
|
||||
.invalid=${!!automationError}
|
||||
.errorMessage=${
|
||||
automationError
|
||||
? deviceAutomationErrorText(
|
||||
this.hass.localize,
|
||||
"actions",
|
||||
automationError
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
.value=${this.action}
|
||||
.deviceId=${deviceId}
|
||||
.disabled=${this.disabled}
|
||||
|
||||
@@ -12,8 +12,6 @@ import type { ServiceAction } from "../../../../../data/script";
|
||||
import { serviceActionStruct } from "../../../../../data/script";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import { targetErrorText } from "../../automation-errors";
|
||||
import { AutomationFieldErrorsController } from "../../automation-field-errors";
|
||||
import type { ActionElement } from "../ha-automation-action-row";
|
||||
|
||||
@customElement("ha-automation-action-service")
|
||||
@@ -30,10 +28,6 @@ export class HaServiceAction extends LitElement implements ActionElement {
|
||||
|
||||
@state() private _responseChecked = false;
|
||||
|
||||
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
|
||||
inlineFields: ["target", "data"],
|
||||
});
|
||||
|
||||
public static get defaultConfig(): ServiceAction {
|
||||
return { action: "", data: {} };
|
||||
}
|
||||
@@ -86,8 +80,6 @@ export class HaServiceAction extends LitElement implements ActionElement {
|
||||
? this._action.action.split(".", 2)
|
||||
: [undefined, undefined];
|
||||
|
||||
const targetError = this._fieldErrors.fieldError("target");
|
||||
|
||||
const optionalResponse =
|
||||
domain && service
|
||||
? !!this.hass.services[domain]?.[service]?.response?.optional
|
||||
@@ -100,13 +92,6 @@ export class HaServiceAction extends LitElement implements ActionElement {
|
||||
.value=${this._action}
|
||||
.disabled=${this.disabled}
|
||||
.hidePicker=${!!this._action.metadata}
|
||||
.targetError=${
|
||||
targetError
|
||||
? targetErrorText(this.hass.localize, targetError)
|
||||
: undefined
|
||||
}
|
||||
.dataErrors=${this._fieldErrors.nestedFieldErrors("data")}
|
||||
.dataError=${this._fieldErrors.ownFieldError("data")?.message}
|
||||
@value-changed=${this._actionChanged}
|
||||
></ha-service-control>
|
||||
${
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { WaitAction } from "../../../../../data/script";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import type { ActionElement } from "../ha-automation-action-row";
|
||||
import "../../../../../components/ha-form/ha-form";
|
||||
import { AutomationFieldErrorsController } from "../../automation-field-errors";
|
||||
import type {
|
||||
HaFormSchema,
|
||||
SchemaUnion,
|
||||
@@ -16,10 +15,6 @@ import type { TimeoutType } from "../../types";
|
||||
|
||||
@customElement("ha-automation-action-wait_template")
|
||||
export class HaWaitAction extends LitElement implements ActionElement {
|
||||
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
|
||||
inlineFields: ["wait_template", "timeout", "continue_on_timeout"],
|
||||
});
|
||||
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public action!: WaitAction;
|
||||
@@ -70,7 +65,6 @@ export class HaWaitAction extends LitElement implements ActionElement {
|
||||
: "duration";
|
||||
return html`
|
||||
<ha-form
|
||||
.error=${this._fieldErrors.fields}
|
||||
.hass=${this.hass}
|
||||
.data=${this.action}
|
||||
.schema=${this._schema(timeoutType, timeout !== undefined)}
|
||||
|
||||
@@ -102,19 +102,6 @@ import type {
|
||||
AutomationItemComboBoxItem,
|
||||
} from "./types";
|
||||
|
||||
type PickerSources = Pick<
|
||||
HomeAssistant,
|
||||
| "states"
|
||||
| "entities"
|
||||
| "devices"
|
||||
| "areas"
|
||||
| "floors"
|
||||
| "localize"
|
||||
| "language"
|
||||
| "locale"
|
||||
| "translationMetadata"
|
||||
>;
|
||||
|
||||
const TARGET_SEARCH_SECTIONS = [
|
||||
"separator",
|
||||
"entity",
|
||||
@@ -218,18 +205,20 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
|
||||
private _getDevicesMemoized = memoizeOne(
|
||||
(
|
||||
sources: PickerSources,
|
||||
states: HomeAssistant["states"],
|
||||
registries: ContextType<typeof registriesContext>,
|
||||
i18n: ContextType<typeof internationalizationContext>,
|
||||
configEntryLookup: Record<string, ConfigEntry>,
|
||||
idPrefix: string
|
||||
) =>
|
||||
getDevices({
|
||||
states: sources.states,
|
||||
areas: sources.areas,
|
||||
devices: sources.devices,
|
||||
entities: sources.entities,
|
||||
localize: sources.localize,
|
||||
language: sources.locale.language,
|
||||
translationMetadata: sources.translationMetadata,
|
||||
states,
|
||||
areas: registries.areas,
|
||||
devices: registries.devices,
|
||||
entities: registries.entities,
|
||||
localize: i18n.localize,
|
||||
language: i18n.locale.language,
|
||||
translationMetadata: i18n.translationMetadata,
|
||||
configEntryLookup,
|
||||
idPrefix,
|
||||
})
|
||||
@@ -238,8 +227,23 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
private _getLabelsMemoized = memoizeOne(getLabels);
|
||||
|
||||
private _getEntitiesMemoized = memoizeOne(
|
||||
(sources: PickerSources, idPrefix: string) =>
|
||||
getEntities(sources, { idPrefix })
|
||||
(
|
||||
states: HomeAssistant["states"],
|
||||
registries: ContextType<typeof registriesContext>,
|
||||
i18n: ContextType<typeof internationalizationContext>,
|
||||
idPrefix: string
|
||||
) =>
|
||||
getEntities({
|
||||
states,
|
||||
entities: registries.entities,
|
||||
devices: registries.devices,
|
||||
areas: registries.areas,
|
||||
floors: registries.floors,
|
||||
localize: i18n.localize,
|
||||
language: i18n.language,
|
||||
translationMetadata: i18n.translationMetadata,
|
||||
idPrefix,
|
||||
})
|
||||
);
|
||||
|
||||
private _getAreasAndFloorsMemoized = memoizeOne(getAreasAndFloors);
|
||||
@@ -247,21 +251,6 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
private _formatAreaFloorId = (value: AreaFloorValue): string =>
|
||||
[value.type, value.id].join(TARGET_SEPARATOR);
|
||||
|
||||
private _getPickerSources = memoizeOne(
|
||||
(
|
||||
states: HomeAssistant["states"],
|
||||
registries: ContextType<typeof registriesContext>,
|
||||
i18n: ContextType<typeof internationalizationContext>
|
||||
): PickerSources => ({
|
||||
states,
|
||||
...registries,
|
||||
localize: i18n.localize,
|
||||
language: i18n.language,
|
||||
locale: i18n.locale,
|
||||
translationMetadata: i18n.translationMetadata,
|
||||
})
|
||||
);
|
||||
|
||||
private _removeKeyboardShortcuts?: () => void;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
@@ -683,7 +672,9 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
|
||||
if (!selectedSection || selectedSection === "entity") {
|
||||
let entityItems = this._getEntitiesMemoized(
|
||||
this._getPickerSources(this._states, this._registries, this._i18n),
|
||||
this._states,
|
||||
this._registries,
|
||||
this._i18n,
|
||||
`entity${TARGET_SEPARATOR}`
|
||||
);
|
||||
|
||||
@@ -720,7 +711,9 @@ export class HaAutomationAddSearch extends LitElement {
|
||||
|
||||
if (!selectedSection || selectedSection === "device") {
|
||||
let deviceItems = this._getDevicesMemoized(
|
||||
this._getPickerSources(this._states, this._registries, this._i18n),
|
||||
this._states,
|
||||
this._registries,
|
||||
this._i18n,
|
||||
configEntryLookup,
|
||||
`device${TARGET_SEPARATOR}`
|
||||
);
|
||||
|
||||
@@ -1,350 +0,0 @@
|
||||
import { createContext } from "@lit/context";
|
||||
import { ensureArray } from "../../../common/array/ensure-array";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import {
|
||||
formatPath,
|
||||
pathEquals,
|
||||
pathStartsWith,
|
||||
type ConfigPath,
|
||||
} from "../../../common/util/config-path";
|
||||
import type { ConfigValidationError } from "../../../data/config";
|
||||
import type {
|
||||
AutomationPathRow,
|
||||
AutomationRowRegistry,
|
||||
} from "./automation-path";
|
||||
|
||||
/** The validation errors of the config being edited, paths from its root. */
|
||||
export const automationErrorsContext =
|
||||
createContext<ConfigValidationError[]>("automation-errors");
|
||||
|
||||
export const NO_ERRORS: ConfigValidationError[] = [];
|
||||
|
||||
/**
|
||||
* The row an error belongs to: the deepest rendered row on its path. Rows
|
||||
* above it only contain the error, they are expanded to show it.
|
||||
*/
|
||||
export const errorOwner = (
|
||||
registry: AutomationRowRegistry,
|
||||
error: ConfigValidationError
|
||||
): AutomationPathRow | undefined => {
|
||||
const rows = registry.rowsAlong(error.path);
|
||||
return rows[rows.length - 1];
|
||||
};
|
||||
|
||||
export const ownedErrors = (
|
||||
registry: AutomationRowRegistry,
|
||||
errors: ConfigValidationError[],
|
||||
row: AutomationPathRow
|
||||
): ConfigValidationError[] => {
|
||||
const owned = errors.filter((error) => errorOwner(registry, error) === row);
|
||||
return owned.length ? owned : NO_ERRORS;
|
||||
};
|
||||
|
||||
export const fieldErrors = (
|
||||
errors: ConfigValidationError[],
|
||||
path: ConfigPath
|
||||
): Record<string, string> => {
|
||||
const fields: Record<string, string> = {};
|
||||
errors.forEach((error) => {
|
||||
const field = errorField(error, path);
|
||||
if (field !== undefined && !(field in fields)) {
|
||||
fields[field] = error.message;
|
||||
}
|
||||
});
|
||||
return fields;
|
||||
};
|
||||
|
||||
/** The field of the config at `path` an error is about, if any. */
|
||||
export const errorField = (
|
||||
error: ConfigValidationError,
|
||||
path: ConfigPath
|
||||
): string | undefined =>
|
||||
error.path.length > path.length && pathStartsWith(error.path, path)
|
||||
? String(error.path[path.length])
|
||||
: undefined;
|
||||
|
||||
export const errorText = (
|
||||
error: ConfigValidationError,
|
||||
path: ConfigPath = []
|
||||
): string => {
|
||||
const relative = pathStartsWith(error.path, path)
|
||||
? error.path.slice(path.length)
|
||||
: error.path;
|
||||
return relative.length
|
||||
? `${formatPath(relative)}: ${error.message}`
|
||||
: error.message;
|
||||
};
|
||||
|
||||
export const errorsEqual = (
|
||||
a: ConfigValidationError[],
|
||||
b: ConfigValidationError[]
|
||||
): boolean =>
|
||||
a.length === b.length &&
|
||||
a.every(
|
||||
(error, index) =>
|
||||
error.message === b[index].message &&
|
||||
error.code === b[index].code &&
|
||||
pathEquals(error.path, b[index].path)
|
||||
);
|
||||
|
||||
const TRIGGER_LIST_KEYS = ["triggers", "wait_for_trigger"];
|
||||
|
||||
const isTriggerSublist = (value: unknown): value is { triggers: unknown } =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
Object.keys(value).length === 1 &&
|
||||
"triggers" in value;
|
||||
|
||||
// Core validates triggers after flattening `triggers:` sublists one level
|
||||
// deep, so its trigger indexes count the triggers of the flat list.
|
||||
const flatTriggerPaths = (triggers: unknown[]): ConfigPath[] =>
|
||||
triggers.flatMap((trigger, index) =>
|
||||
isTriggerSublist(trigger)
|
||||
? (ensureArray(trigger.triggers) ?? []).map((_, subIndex) => [
|
||||
index,
|
||||
"triggers",
|
||||
subIndex,
|
||||
])
|
||||
: [[index]]
|
||||
);
|
||||
|
||||
const isConfigObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const childAt = (value: unknown, segment: string | number): unknown =>
|
||||
typeof value === "object" && value !== null
|
||||
? (value as Record<string | number, unknown>)[segment]
|
||||
: undefined;
|
||||
|
||||
const valueAt = (config: unknown, path: ConfigPath): unknown =>
|
||||
path.reduce<unknown>(childAt, config);
|
||||
|
||||
/** Maps an error path of core's validated config onto the edited config. */
|
||||
export const configErrorPath = (
|
||||
config: unknown,
|
||||
path: ConfigPath
|
||||
): ConfigPath => {
|
||||
const result: ConfigPath = [];
|
||||
let value = config;
|
||||
for (let index = 0; index < path.length; index++) {
|
||||
const segment = path[index];
|
||||
const parentKey = result[result.length - 1];
|
||||
// Core wraps each action of the `parallel` shorthand in a sequence
|
||||
if (
|
||||
segment === "sequence" &&
|
||||
path[index + 1] === 0 &&
|
||||
result[result.length - 2] === "parallel" &&
|
||||
isConfigObject(value) &&
|
||||
!("sequence" in value)
|
||||
) {
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
const mapped =
|
||||
typeof segment === "number" &&
|
||||
Array.isArray(value) &&
|
||||
TRIGGER_LIST_KEYS.includes(parentKey as string)
|
||||
? flatTriggerPaths(value)[segment]
|
||||
: undefined;
|
||||
(mapped ?? [segment]).forEach((part) => {
|
||||
result.push(part);
|
||||
value = childAt(value, part);
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const mapErrorsToConfig = (
|
||||
config: unknown,
|
||||
errors: ConfigValidationError[]
|
||||
): ConfigValidationError[] =>
|
||||
errors.map((error) => ({
|
||||
...error,
|
||||
path: configErrorPath(config, error.path),
|
||||
}));
|
||||
|
||||
// Rows sit at list items that are objects, not at entries of value lists
|
||||
// like `entity_id`
|
||||
const errorItemPath = (config: unknown, path: ConfigPath): ConfigPath => {
|
||||
let value = config;
|
||||
let end = 0;
|
||||
path.forEach((segment, index) => {
|
||||
value = childAt(value, segment);
|
||||
if (typeof segment === "number" && isConfigObject(value)) {
|
||||
end = index + 1;
|
||||
}
|
||||
});
|
||||
return path.slice(0, end);
|
||||
};
|
||||
|
||||
/** A config item with an error, to recognize it after edits. */
|
||||
export interface KnownErrorItem {
|
||||
path: ConfigPath;
|
||||
item?: object;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
const knownItemAt = (config: unknown, path: ConfigPath): KnownErrorItem => {
|
||||
const item = valueAt(config, path);
|
||||
return isConfigObject(item)
|
||||
? { path, item, content: JSON.stringify(item) }
|
||||
: { path };
|
||||
};
|
||||
|
||||
export const knownErrorItems = (
|
||||
config: unknown,
|
||||
errors: ConfigValidationError[]
|
||||
): KnownErrorItem[] => {
|
||||
const known: KnownErrorItem[] = [];
|
||||
errors.forEach((error) => {
|
||||
const path = errorItemPath(config, error.path);
|
||||
if (!known.some((entry) => pathEquals(entry.path, path))) {
|
||||
known.push(knownItemAt(config, path));
|
||||
}
|
||||
});
|
||||
return known;
|
||||
};
|
||||
|
||||
interface ConfigItems {
|
||||
paths: Map<object, ConfigPath>;
|
||||
contents: Map<string, ConfigPath[]>;
|
||||
}
|
||||
|
||||
// The list entries that are objects, the items rows show
|
||||
const configItems = (config: unknown): ConfigItems => {
|
||||
const items: ConfigItems = { paths: new Map(), contents: new Map() };
|
||||
const walk = (value: unknown, path: ConfigPath) => {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return;
|
||||
}
|
||||
if (typeof path[path.length - 1] === "number" && isConfigObject(value)) {
|
||||
items.paths.set(value, path);
|
||||
const content = JSON.stringify(value);
|
||||
items.contents.set(content, [
|
||||
...(items.contents.get(content) ?? []),
|
||||
path,
|
||||
]);
|
||||
}
|
||||
Object.entries(value).forEach(([key, child]) => {
|
||||
walk(child, [...path, Array.isArray(value) ? Number(key) : key]);
|
||||
});
|
||||
};
|
||||
walk(config, []);
|
||||
return items;
|
||||
};
|
||||
|
||||
const followItem = (
|
||||
entry: KnownErrorItem,
|
||||
previous: ConfigItems,
|
||||
current: ConfigItems,
|
||||
config: unknown
|
||||
): KnownErrorItem | undefined => {
|
||||
// Unchanged or moved
|
||||
const path = entry.item && current.paths.get(entry.item);
|
||||
if (path) {
|
||||
return { ...entry, path };
|
||||
}
|
||||
// Moved after the YAML was parsed again
|
||||
const samePaths = entry.content
|
||||
? current.contents.get(entry.content)
|
||||
: undefined;
|
||||
if (samePaths?.length === 1) {
|
||||
return knownItemAt(config, samePaths[0]);
|
||||
}
|
||||
// Identical items, e.g. two copies of a row, keep their place
|
||||
if (samePaths?.some((other) => pathEquals(other, entry.path))) {
|
||||
return knownItemAt(config, entry.path);
|
||||
}
|
||||
const now = valueAt(config, entry.path);
|
||||
if (!isConfigObject(now)) {
|
||||
return undefined;
|
||||
}
|
||||
// An item that was already there took the place, e.g. after a delete
|
||||
const beforePaths = previous.paths.has(now)
|
||||
? [previous.paths.get(now)!]
|
||||
: (previous.contents.get(JSON.stringify(now)) ?? []);
|
||||
if (
|
||||
beforePaths.length &&
|
||||
!beforePaths.some((other) => pathEquals(other, entry.path))
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
// Edited in place
|
||||
return knownItemAt(config, entry.path);
|
||||
};
|
||||
|
||||
/** Follows known items from `previous` to `config` through moves and edits. */
|
||||
export const followKnownErrorItems = (
|
||||
known: KnownErrorItem[],
|
||||
previous: unknown,
|
||||
config: unknown
|
||||
): KnownErrorItem[] => {
|
||||
const previousItems = configItems(previous);
|
||||
const currentItems = configItems(config);
|
||||
return known
|
||||
.map((entry) => followItem(entry, previousItems, currentItems, config))
|
||||
.filter((entry): entry is KnownErrorItem => entry !== undefined);
|
||||
};
|
||||
|
||||
/**
|
||||
* Moves errors of `previous` to where their items are in `config`. Errors of
|
||||
* items that are gone are dropped.
|
||||
*/
|
||||
export const followErrors = (
|
||||
errors: ConfigValidationError[],
|
||||
previous: unknown,
|
||||
config: unknown
|
||||
): ConfigValidationError[] => {
|
||||
const previousItems = configItems(previous);
|
||||
const currentItems = configItems(config);
|
||||
return errors.flatMap((error) => {
|
||||
const path = errorItemPath(previous, error.path);
|
||||
const moved = followItem(
|
||||
knownItemAt(previous, path),
|
||||
previousItems,
|
||||
currentItems,
|
||||
config
|
||||
);
|
||||
return moved
|
||||
? [{ ...error, path: [...moved.path, ...error.path.slice(path.length)] }]
|
||||
: [];
|
||||
});
|
||||
};
|
||||
|
||||
/** The errors on items that already had one. */
|
||||
export const errorsOnKnownItems = (
|
||||
known: KnownErrorItem[],
|
||||
config: unknown,
|
||||
errors: ConfigValidationError[]
|
||||
): ConfigValidationError[] =>
|
||||
errors.filter((error) => {
|
||||
const path = errorItemPath(config, error.path);
|
||||
return known.some((entry) => pathEquals(entry.path, path));
|
||||
});
|
||||
|
||||
export const targetErrorText = (
|
||||
localize: LocalizeFunc,
|
||||
error: ConfigValidationError
|
||||
): string =>
|
||||
localize(
|
||||
error.code === "required"
|
||||
? "ui.panel.config.automation.editor.errors.target_required"
|
||||
: "ui.panel.config.automation.editor.errors.target_invalid"
|
||||
);
|
||||
|
||||
export const deviceErrorText = (
|
||||
localize: LocalizeFunc,
|
||||
error: ConfigValidationError
|
||||
): string =>
|
||||
error.code === "required"
|
||||
? localize("ui.panel.config.automation.editor.errors.device_required")
|
||||
: error.message;
|
||||
|
||||
export const deviceAutomationErrorText = (
|
||||
localize: LocalizeFunc,
|
||||
kind: "triggers" | "conditions" | "actions",
|
||||
error: ConfigValidationError
|
||||
): string =>
|
||||
error.code === "required"
|
||||
? localize(`ui.panel.config.devices.automation.${kind}.required`)
|
||||
: error.message;
|
||||
@@ -1,186 +0,0 @@
|
||||
import { createContext } from "@lit/context";
|
||||
import {
|
||||
consumeContext,
|
||||
ContextController,
|
||||
} from "../../../common/decorators/consume";
|
||||
import { pathEquals, type ConfigPath } from "../../../common/util/config-path";
|
||||
import type { ConfigValidationError } from "../../../data/config";
|
||||
import {
|
||||
automationErrorsContext,
|
||||
errorField,
|
||||
errorsEqual,
|
||||
fieldErrors,
|
||||
NO_ERRORS,
|
||||
ownedErrors,
|
||||
} from "./automation-errors";
|
||||
import {
|
||||
automationPathContext,
|
||||
automationRowRegistryContext,
|
||||
type AutomationRowRegistry,
|
||||
} from "./automation-path";
|
||||
|
||||
const fieldsEqual = (a: Record<string, string>, b: Record<string, string>) => {
|
||||
const keys = Object.keys(a);
|
||||
return (
|
||||
keys.length === Object.keys(b).length &&
|
||||
keys.every((key) => a[key] === b[key])
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary The fields whose errors an editor shows next to the field, so
|
||||
* the sidebar doesn't list them again.
|
||||
*/
|
||||
export class InlineErrorFields {
|
||||
private _fields = new Map<object, string[]>();
|
||||
|
||||
constructor(private readonly _onChange: () => void) {}
|
||||
|
||||
public set(owner: object, fields: string[]) {
|
||||
this._fields.set(owner, fields);
|
||||
this._onChange();
|
||||
}
|
||||
|
||||
public delete(owner: object) {
|
||||
if (this._fields.delete(owner)) {
|
||||
this._onChange();
|
||||
}
|
||||
}
|
||||
|
||||
public has(field: string): boolean {
|
||||
return [...this._fields.values()].some((fields) => fields.includes(field));
|
||||
}
|
||||
}
|
||||
|
||||
export const automationInlineErrorFieldsContext =
|
||||
createContext<InlineErrorFields>("automation-inline-error-fields");
|
||||
|
||||
interface AutomationFieldErrorsOptions {
|
||||
/** Fields the host shows the error of, see `InlineErrorFields`. */
|
||||
inlineFields?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Validation errors of the config at the host's path.
|
||||
*
|
||||
* Consumes the errors and the path of the closest ancestor and updates the
|
||||
* host when the errors it exposes change.
|
||||
*/
|
||||
export class AutomationFieldErrorsController extends ContextController {
|
||||
public fields: Record<string, string> = {};
|
||||
|
||||
/** Errors that belong to the row at exactly the path, see `errorOwner`. */
|
||||
public rowErrors: ConfigValidationError[] = NO_ERRORS;
|
||||
|
||||
public path: ConfigPath = [];
|
||||
|
||||
@consumeContext({ context: automationErrorsContext, subscribe: true })
|
||||
private _errors: ConfigValidationError[] = NO_ERRORS;
|
||||
|
||||
@consumeContext({ context: automationPathContext, subscribe: true })
|
||||
private _path: ConfigPath = [];
|
||||
|
||||
@consumeContext({ context: automationRowRegistryContext })
|
||||
private _registry?: AutomationRowRegistry;
|
||||
|
||||
@consumeContext({ context: automationInlineErrorFieldsContext })
|
||||
private _inlineErrorFields?: InlineErrorFields;
|
||||
|
||||
private readonly _inlineFields?: string[];
|
||||
|
||||
private _claimedIn?: InlineErrorFields;
|
||||
|
||||
constructor(
|
||||
host: ConstructorParameters<typeof ContextController>[0],
|
||||
options: AutomationFieldErrorsOptions = {}
|
||||
) {
|
||||
super(host);
|
||||
this._inlineFields = options.inlineFields;
|
||||
}
|
||||
|
||||
private _nestedFieldErrors = new Map<string, Record<string, string>>();
|
||||
|
||||
/** First error message of each key inside a field, keyed by the key. */
|
||||
public nestedFieldErrors(field: string): Record<string, string> {
|
||||
const errors = fieldErrors(this._errors, [...this._path, field]);
|
||||
const previous = this._nestedFieldErrors.get(field);
|
||||
// The same object while nothing changed, so the host's child doesn't update
|
||||
if (previous && fieldsEqual(previous, errors)) {
|
||||
return previous;
|
||||
}
|
||||
this._nestedFieldErrors.set(field, errors);
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** The error of a field as a whole, not of something inside it. */
|
||||
public ownFieldError(field: string): ConfigValidationError | undefined {
|
||||
return this._errors.find((error) =>
|
||||
pathEquals(error.path, [...this._path, field])
|
||||
);
|
||||
}
|
||||
|
||||
/** The first error of a field of the config at the path. */
|
||||
public fieldError(field: string): ConfigValidationError | undefined {
|
||||
return this._errors.find(
|
||||
(error) => errorField(error, this._path) === field
|
||||
);
|
||||
}
|
||||
|
||||
private _unsubscribeRegistry?: () => void;
|
||||
|
||||
public hostConnected() {
|
||||
this._unsubscribeRegistry ??= this._registry?.subscribe(() => this._sync());
|
||||
this._claimInlineFields();
|
||||
this._sync();
|
||||
}
|
||||
|
||||
public hostDisconnected() {
|
||||
this._unsubscribeRegistry?.();
|
||||
this._unsubscribeRegistry = undefined;
|
||||
this._claimedIn?.delete(this);
|
||||
this._claimedIn = undefined;
|
||||
}
|
||||
|
||||
protected contextUpdated() {
|
||||
this._claimInlineFields();
|
||||
this._sync();
|
||||
}
|
||||
|
||||
private _claimInlineFields() {
|
||||
if (
|
||||
!this._inlineFields ||
|
||||
!this._inlineErrorFields ||
|
||||
this._claimedIn === this._inlineErrorFields
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this._claimedIn?.delete(this);
|
||||
this._inlineErrorFields.set(this, this._inlineFields);
|
||||
this._claimedIn = this._inlineErrorFields;
|
||||
}
|
||||
|
||||
private _sync() {
|
||||
const fields = this._errors.length
|
||||
? fieldErrors(this._errors, this._path)
|
||||
: {};
|
||||
const row =
|
||||
this._errors.length && this._path.length
|
||||
? this._registry?.rowAt(this._path)
|
||||
: undefined;
|
||||
const rowErrors =
|
||||
row && this._registry
|
||||
? ownedErrors(this._registry, this._errors, row)
|
||||
: NO_ERRORS;
|
||||
if (
|
||||
this.path === this._path &&
|
||||
fieldsEqual(fields, this.fields) &&
|
||||
errorsEqual(rowErrors, this.rowErrors)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.path = this._path;
|
||||
this.fields = fields;
|
||||
this.rowErrors = rowErrors;
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
}
|
||||
@@ -9,13 +9,6 @@ import {
|
||||
stringifyPath,
|
||||
type ConfigPath,
|
||||
} from "../../../common/util/config-path";
|
||||
import type { ConfigValidationError } from "../../../data/config";
|
||||
import {
|
||||
automationErrorsContext,
|
||||
errorsEqual,
|
||||
NO_ERRORS,
|
||||
ownedErrors,
|
||||
} from "./automation-errors";
|
||||
|
||||
export const automationPathContext =
|
||||
createContext<ConfigPath>("automation-path");
|
||||
@@ -41,19 +34,14 @@ export class AutomationRowRegistry {
|
||||
{ path: ConfigPath; rows: AutomationPathRow[] }
|
||||
>();
|
||||
|
||||
private _listeners = new Set<() => void>();
|
||||
|
||||
private _notifyScheduled = false;
|
||||
|
||||
public register(path: ConfigPath, row: AutomationPathRow) {
|
||||
const key = stringifyPath(path);
|
||||
const entry = this._rows.get(key);
|
||||
if (!entry) {
|
||||
this._rows.set(key, { path, rows: [row] });
|
||||
} else {
|
||||
entry.rows = [...entry.rows.filter((claimed) => claimed !== row), row];
|
||||
return;
|
||||
}
|
||||
this._changed();
|
||||
entry.rows = [...entry.rows.filter((claimed) => claimed !== row), row];
|
||||
}
|
||||
|
||||
public unregister(path: ConfigPath, row: AutomationPathRow) {
|
||||
@@ -66,33 +54,6 @@ export class AutomationRowRegistry {
|
||||
if (!entry.rows.length) {
|
||||
this._rows.delete(key);
|
||||
}
|
||||
this._changed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls `listener` after rows were registered or unregistered, once for all
|
||||
* changes made in the same task. Returns the function to unsubscribe.
|
||||
*/
|
||||
public subscribe(listener: () => void): () => void {
|
||||
this._listeners.add(listener);
|
||||
return () => {
|
||||
this._listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
private _changed() {
|
||||
if (this._notifyScheduled || !this._listeners.size) {
|
||||
return;
|
||||
}
|
||||
this._notifyScheduled = true;
|
||||
queueMicrotask(() => {
|
||||
this._notifyScheduled = false;
|
||||
this._listeners.forEach((listener) => listener());
|
||||
});
|
||||
}
|
||||
|
||||
public rowAt(path: ConfigPath): AutomationPathRow | undefined {
|
||||
return owner(this._rows.get(stringifyPath(path))?.rows);
|
||||
}
|
||||
|
||||
public pathOf(row: AutomationPathRow): ConfigPath | undefined {
|
||||
@@ -100,11 +61,6 @@ export class AutomationRowRegistry {
|
||||
?.path;
|
||||
}
|
||||
|
||||
public selectedPath(): ConfigPath | undefined {
|
||||
return [...this._rows.values()].find((entry) => owner(entry.rows)?.selected)
|
||||
?.path;
|
||||
}
|
||||
|
||||
/** The rows containing `path`, outermost first. */
|
||||
public rowsAlong(path: ConfigPath): AutomationPathRow[] {
|
||||
return path
|
||||
@@ -127,19 +83,11 @@ interface AutomationPathControllerOptions {
|
||||
|
||||
/**
|
||||
* Consumes the path of the closest ancestor and provides it extended by the
|
||||
* host's own segment to its descendants. For a row, also finds the validation
|
||||
* errors the row owns and updates the host when they change.
|
||||
* host's own segment to its descendants.
|
||||
*/
|
||||
export class AutomationPathController extends ContextController {
|
||||
public path: ConfigPath = [];
|
||||
|
||||
public errors: ConfigValidationError[] = NO_ERRORS;
|
||||
|
||||
@consumeContext({ context: automationErrorsContext, subscribe: true })
|
||||
private _configErrors: ConfigValidationError[] = NO_ERRORS;
|
||||
|
||||
private _unsubscribeRegistry?: () => void;
|
||||
|
||||
@consumeContext({ context: automationPathContext, subscribe: true })
|
||||
private _parentPath: ConfigPath = [];
|
||||
|
||||
@@ -176,12 +124,6 @@ export class AutomationPathController extends ContextController {
|
||||
public hostConnected() {
|
||||
// A row moved by a drag reconnects without an update
|
||||
this._sync();
|
||||
if (this._options.row && this._registry && !this._unsubscribeRegistry) {
|
||||
// Another row on the path of an error can take it over or give it back
|
||||
this._unsubscribeRegistry = this._registry.subscribe(() =>
|
||||
this._syncErrors()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private _sync() {
|
||||
@@ -191,26 +133,12 @@ export class AutomationPathController extends ContextController {
|
||||
this._provider.setValue(path);
|
||||
}
|
||||
this._syncRegistration();
|
||||
this._syncErrors();
|
||||
}
|
||||
|
||||
public hostDisconnected() {
|
||||
this._unsubscribeRegistry?.();
|
||||
this._unsubscribeRegistry = undefined;
|
||||
this._unregister();
|
||||
}
|
||||
|
||||
private _syncErrors() {
|
||||
const errors =
|
||||
this._registeredRow && this._registry && this._configErrors.length
|
||||
? ownedErrors(this._registry, this._configErrors, this._registeredRow)
|
||||
: NO_ERRORS;
|
||||
if (!errorsEqual(errors, this.errors)) {
|
||||
this.errors = errors;
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private _syncRegistration() {
|
||||
if (!this._registry || !this._options.row) {
|
||||
return;
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { mdiAlertCircle } from "@mdi/js";
|
||||
import { html, nothing } from "lit";
|
||||
import type { ConfigPath } from "../../../common/util/config-path";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import "../../../components/ha-tooltip";
|
||||
import type { ConfigValidationError } from "../../../data/config";
|
||||
import { errorText } from "./automation-errors";
|
||||
|
||||
export const renderRowErrorIcon = (
|
||||
errors: ConfigValidationError[],
|
||||
path: ConfigPath,
|
||||
label: string
|
||||
) =>
|
||||
errors.length
|
||||
? html`<ha-svg-icon
|
||||
id="error-icon"
|
||||
slot="icons"
|
||||
tabindex="0"
|
||||
class="error-indicator"
|
||||
.path=${mdiAlertCircle}
|
||||
role="img"
|
||||
aria-label=${label}
|
||||
></ha-svg-icon>
|
||||
<ha-tooltip for="error-icon" slot="icons">
|
||||
${errors.map((error) => html`<p>${errorText(error, path)}</p>`)}
|
||||
</ha-tooltip>`
|
||||
: nothing;
|
||||
@@ -97,7 +97,6 @@ import "./types/ha-automation-condition-zone";
|
||||
import { renderCtrlOrCmd } from "../../../../common/keyboard/ctrl-or-cmd";
|
||||
import { scrollRowIntoView } from "../scroll-row-into-view";
|
||||
import { AutomationPathController } from "../automation-path";
|
||||
import { renderRowErrorIcon } from "../automation-row-error-icon";
|
||||
|
||||
export interface ConditionElement extends LitElement {
|
||||
condition: Condition;
|
||||
@@ -263,11 +262,6 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
)}
|
||||
</ha-automation-row-event-chip>
|
||||
|
||||
${renderRowErrorIcon(
|
||||
this.pathController.errors,
|
||||
this.pathController.path,
|
||||
this.hass.localize("ui.panel.config.automation.editor.errors.label")
|
||||
)}
|
||||
<slot name="icons" slot="icons"></slot>
|
||||
|
||||
<ha-dropdown
|
||||
@@ -535,7 +529,6 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
this.condition.condition
|
||||
)}
|
||||
.sortSelected=${this.sortSelected}
|
||||
.error=${this.pathController.errors.length > 0}
|
||||
.dim=${this._testing}
|
||||
@click=${this._toggleSidebar}
|
||||
@toggle-collapsed=${this._toggleCollapse}
|
||||
|
||||
@@ -27,18 +27,9 @@ import {
|
||||
} from "../../../../../data/device/device_registry";
|
||||
import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import {
|
||||
deviceAutomationErrorText,
|
||||
deviceErrorText,
|
||||
} from "../../automation-errors";
|
||||
import { AutomationFieldErrorsController } from "../../automation-field-errors";
|
||||
|
||||
@customElement("ha-automation-condition-device")
|
||||
export class HaDeviceCondition extends LitElement {
|
||||
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
|
||||
inlineFields: ["device_id", "domain"],
|
||||
});
|
||||
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Object }) public condition!: DeviceCondition;
|
||||
@@ -141,20 +132,12 @@ export class HaDeviceCondition extends LitElement {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const deviceError = this._fieldErrors.fieldError("device_id");
|
||||
const automationError = this._fieldErrors.fieldError("domain");
|
||||
const deviceId = this._deviceId || this.condition.device_id;
|
||||
|
||||
return html`
|
||||
<ha-device-picker
|
||||
.value=${deviceId}
|
||||
.replacementDeviceIds=${this._replacementDeviceIds}
|
||||
.invalid=${!!deviceError}
|
||||
.errorMessage=${
|
||||
deviceError
|
||||
? deviceErrorText(this.hass.localize, deviceError)
|
||||
: undefined
|
||||
}
|
||||
@value-changed=${this._devicePicked}
|
||||
.disabled=${this.disabled}
|
||||
.label=${this.hass.localize(
|
||||
@@ -162,16 +145,6 @@ export class HaDeviceCondition extends LitElement {
|
||||
)}
|
||||
></ha-device-picker>
|
||||
<ha-device-condition-picker
|
||||
.invalid=${!!automationError}
|
||||
.errorMessage=${
|
||||
automationError
|
||||
? deviceAutomationErrorText(
|
||||
this.hass.localize,
|
||||
"conditions",
|
||||
automationError
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
.value=${this.condition}
|
||||
.deviceId=${deviceId}
|
||||
@value-changed=${this._deviceConditionPicked}
|
||||
|
||||
@@ -34,9 +34,6 @@ import {
|
||||
} from "../../../../../data/target";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import { documentationUrl } from "../../../../../util/documentation-url";
|
||||
import "../../../../../components/ha-input-helper-text";
|
||||
import { targetErrorText } from "../../automation-errors";
|
||||
import { AutomationFieldErrorsController } from "../../automation-field-errors";
|
||||
import { targetExpandGroupContext } from "../../../../../components/target-picker/target-expand-group-context";
|
||||
|
||||
// Mirrors `MAX_HISTORY_PRIMING_LOOKBACK` in homeassistant/helpers/condition.py:
|
||||
@@ -56,10 +53,6 @@ export class HaPlatformCondition extends LitElement {
|
||||
@provide({ context: targetExpandGroupContext })
|
||||
protected _targetExpandGroup = false;
|
||||
|
||||
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
|
||||
inlineFields: ["target"],
|
||||
});
|
||||
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public condition!: PlatformCondition;
|
||||
@@ -241,7 +234,6 @@ export class HaPlatformCondition extends LitElement {
|
||||
></ha-selector>`
|
||||
: nothing
|
||||
}
|
||||
${this._renderTargetError()}
|
||||
${
|
||||
shouldRenderDataYaml
|
||||
? html`<ha-yaml-editor
|
||||
@@ -266,15 +258,6 @@ export class HaPlatformCondition extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderTargetError() {
|
||||
const error = this._fieldErrors.fieldError("target");
|
||||
return error
|
||||
? html`<ha-input-helper-text class="target-error">
|
||||
${targetErrorText(this.hass.localize, error)}
|
||||
</ha-input-helper-text>`
|
||||
: nothing;
|
||||
}
|
||||
|
||||
private _targetSelector = memoizeOne(
|
||||
(targetSelector: TargetSelector["target"] | null | undefined) =>
|
||||
targetSelector ? { target: { ...targetSelector } } : { target: {} }
|
||||
@@ -697,10 +680,6 @@ export class HaPlatformCondition extends LitElement {
|
||||
1px solid var(--divider-color)
|
||||
);
|
||||
}
|
||||
.target-error {
|
||||
color: var(--ha-color-on-danger-quiet);
|
||||
padding-bottom: var(--ha-space-2);
|
||||
}
|
||||
ha-yaml-editor {
|
||||
padding: var(--ha-space-4) 0;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import { ensureArray } from "../../../../../common/array/ensure-array";
|
||||
import { createDurationData } from "../../../../../common/datetime/create_duration_data";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import "../../../../../components/ha-form/ha-form";
|
||||
import { AutomationFieldErrorsController } from "../../automation-field-errors";
|
||||
import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
import type { StateCondition } from "../../../../../data/automation";
|
||||
import { STATE_CONDITION_HIDDEN_ATTRIBUTES } from "../../../../../data/entity/entity_attributes";
|
||||
@@ -72,10 +71,6 @@ const SCHEMA = memoizeOne(
|
||||
|
||||
@customElement("ha-automation-condition-state")
|
||||
export class HaStateCondition extends LitElement implements ConditionElement {
|
||||
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
|
||||
inlineFields: ["entity_id", "attribute", "state", "for"],
|
||||
});
|
||||
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public condition!: StateCondition;
|
||||
@@ -123,7 +118,6 @@ export class HaStateCondition extends LitElement implements ConditionElement {
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
.error=${this._fieldErrors.fields}
|
||||
.hass=${this.hass}
|
||||
.data=${data}
|
||||
.schema=${SCHEMA(hasAttribute)}
|
||||
|
||||
@@ -6,8 +6,10 @@ import { consume } from "../../../../../common/decorators/consume";
|
||||
import { ensureArray } from "../../../../../common/array/ensure-array";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { capitalizeFirstLetter } from "../../../../../common/string/capitalize-first-letter";
|
||||
import "../../../../../components/animation/ha-fade-in";
|
||||
import "../../../../../components/ha-alert";
|
||||
import "../../../../../components/ha-button";
|
||||
import "../../../../../components/ha-spinner";
|
||||
import "../../../../../components/ha-svg-icon";
|
||||
import "../../../../../components/ha-trigger-icon";
|
||||
import "../../../../../components/item/ha-list-item-option";
|
||||
@@ -17,6 +19,7 @@ import type { TriggerCondition } from "../../../../../data/automation";
|
||||
import {
|
||||
automationTriggerContext,
|
||||
hasCustomTriggerId,
|
||||
triggerOptionsResolved,
|
||||
type AutomationTriggerContext,
|
||||
type TriggerIdOption,
|
||||
} from "../../trigger/automation-trigger-id";
|
||||
@@ -53,16 +56,33 @@ export class HaTriggerCondition extends LitElement {
|
||||
const selectedIds = (ensureArray(this.condition.id) ?? []).filter(Boolean);
|
||||
const triggerIdOptions = this._triggers?.options ?? [];
|
||||
const missingIds = this._missingIds(this.condition.id, triggerIdOptions);
|
||||
const status = this._triggers?.blueprintStatus;
|
||||
const resolved = triggerOptionsResolved(this._triggers);
|
||||
|
||||
if (!triggerIdOptions.length && !selectedIds.length) {
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.no_triggers"
|
||||
);
|
||||
switch (status) {
|
||||
case "loading":
|
||||
return html`<ha-fade-in .delay=${500}>
|
||||
<ha-spinner size="small"></ha-spinner>
|
||||
</ha-fade-in>`;
|
||||
case "failed":
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.blueprint_triggers_unavailable"
|
||||
);
|
||||
case "loaded":
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.blueprint_no_trigger_ids"
|
||||
);
|
||||
case undefined:
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.no_triggers"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return html`
|
||||
${
|
||||
missingIds.length
|
||||
resolved && missingIds.length
|
||||
? html`<ha-alert alert-type="warning">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.missing_triggers"
|
||||
@@ -106,7 +126,13 @@ export class HaTriggerCondition extends LitElement {
|
||||
appearance="checkbox"
|
||||
>
|
||||
<span slot="start" class="trigger-row-leading">
|
||||
<span class="trigger-index-badge">${option.index + 1}</span>
|
||||
${
|
||||
this._triggers?.showIndices
|
||||
? html`<span class="trigger-index-badge"
|
||||
>${option.index + 1}</span
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
<ha-trigger-icon
|
||||
.trigger=${
|
||||
"trigger" in option.trigger ? option.trigger.trigger : ""
|
||||
@@ -119,7 +145,9 @@ export class HaTriggerCondition extends LitElement {
|
||||
)}
|
||||
</span>
|
||||
${
|
||||
option.duplicate || hasCustomTriggerId(option)
|
||||
option.duplicate ||
|
||||
hasCustomTriggerId(option) ||
|
||||
!this._triggers?.showIndices
|
||||
? html`<span
|
||||
slot="supporting-text"
|
||||
class="trigger-id ${option.duplicate ? "duplicate" : ""}"
|
||||
@@ -152,14 +180,22 @@ export class HaTriggerCondition extends LitElement {
|
||||
.disabled=${this.disabled}
|
||||
appearance="checkbox"
|
||||
>
|
||||
<span slot="start" class="missing-trigger-badge">
|
||||
<ha-svg-icon .path=${mdiLinkVariantOff}></ha-svg-icon>
|
||||
<span class="missing-trigger-id">${id}</span>
|
||||
</span>
|
||||
${
|
||||
resolved
|
||||
? html`<span slot="start" class="missing-trigger-badge">
|
||||
<ha-svg-icon .path=${mdiLinkVariantOff}></ha-svg-icon>
|
||||
<span class="missing-trigger-id">${id}</span>
|
||||
</span>`
|
||||
: nothing
|
||||
}
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.missing_trigger"
|
||||
)}
|
||||
${
|
||||
resolved
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.missing_trigger"
|
||||
)
|
||||
: id
|
||||
}
|
||||
</span>
|
||||
</ha-list-item-option>
|
||||
`
|
||||
@@ -197,8 +233,7 @@ export class HaTriggerCondition extends LitElement {
|
||||
? ids
|
||||
: [...ids, id]
|
||||
: ids.filter((_id) => _id !== id);
|
||||
if (this._triggers) {
|
||||
this._triggers.select(this.condition, selectedIds);
|
||||
if (this._triggers?.select(this.condition, selectedIds)) {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "value-changed", {
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
triggerAutomationActions,
|
||||
} from "../../../data/automation";
|
||||
import { substituteBlueprint } from "../../../data/blueprint";
|
||||
import { validateConfig } from "../../../data/config";
|
||||
import { UNAVAILABLE } from "../../../data/entity/entity";
|
||||
import {
|
||||
type EntityRegistryEntry,
|
||||
@@ -70,7 +71,7 @@ import "../../../layouts/hass-subpage";
|
||||
import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
|
||||
import { PreventUnsavedMixin } from "../../../mixins/prevent-unsaved-mixin";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { ValueChangedEvent } from "../../../types";
|
||||
import type { Entries, ValueChangedEvent } from "../../../types";
|
||||
import { isMac } from "../../../util/is_mac";
|
||||
import { showEditorToast } from "./editor-toast";
|
||||
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
|
||||
@@ -145,6 +146,15 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
this._manualEditor?.resetPastedConfig();
|
||||
this._updateConfig(config);
|
||||
},
|
||||
fetchBlueprintTriggers: async ({ path, input }) => {
|
||||
const result = await substituteBlueprint(
|
||||
this.hass,
|
||||
"automation",
|
||||
path,
|
||||
input || {}
|
||||
);
|
||||
return normalizeAutomationConfig(result.substituted_config).triggers;
|
||||
},
|
||||
});
|
||||
|
||||
private _newAutomationId?: string;
|
||||
@@ -158,14 +168,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
const { id, ...rest } = normalizeAutomationConfig(config, report);
|
||||
return { id, ...rest };
|
||||
},
|
||||
validationSections: (config) =>
|
||||
"use_blueprint" in config
|
||||
? {}
|
||||
: {
|
||||
triggers: config.triggers,
|
||||
conditions: config.conditions,
|
||||
actions: config.actions,
|
||||
},
|
||||
checkValidation: () => this._checkValidation(),
|
||||
};
|
||||
|
||||
private _undoRedoController = new UndoRedoController<AutomationConfig>(this, {
|
||||
@@ -507,7 +510,16 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
@value-changed=${this._valueChanged}
|
||||
@save-automation=${this._handleSaveAutomation}
|
||||
>
|
||||
${this.renderConfigErrorsAlert("alerts")}
|
||||
${
|
||||
this.errors
|
||||
? html`<ha-alert
|
||||
alert-type="error"
|
||||
slot="alerts"
|
||||
>
|
||||
${this.errors}
|
||||
</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
</blueprint-automation-editor>
|
||||
`
|
||||
: html`
|
||||
@@ -519,7 +531,6 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
.config=${this.config}
|
||||
.disabled=${this.readOnly}
|
||||
.saving=${this.saving}
|
||||
.configErrors=${this.configErrors}
|
||||
@value-changed=${this._valueChanged}
|
||||
@save-automation=${this._handleSaveAutomation}
|
||||
@editor-save=${this._handleSaveAutomation}
|
||||
@@ -528,17 +539,26 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
<div class="alert-wrapper" slot="alerts">
|
||||
${this._renderDeprecatedMigratedAlert()}
|
||||
${
|
||||
stateObj?.state === UNAVAILABLE
|
||||
this.errors || stateObj?.state === UNAVAILABLE
|
||||
? html`<ha-alert
|
||||
alert-type="error"
|
||||
.title=${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.unavailable"
|
||||
)}
|
||||
.title=${
|
||||
stateObj?.state === UNAVAILABLE
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.automation.editor.unavailable"
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiRobotConfused}
|
||||
></ha-svg-icon>
|
||||
${this.errors || this.validationErrors}
|
||||
${
|
||||
stateObj?.state === UNAVAILABLE
|
||||
? html`<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiRobotConfused}
|
||||
></ha-svg-icon>`
|
||||
: nothing
|
||||
}
|
||||
</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
@@ -617,7 +637,6 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
`
|
||||
: this.mode === "yaml"
|
||||
? html`${this._renderDeprecatedMigratedAlert()}
|
||||
${this.renderConfigErrorsAlert()}
|
||||
${
|
||||
stateObj?.state === "off"
|
||||
? html`
|
||||
@@ -737,7 +756,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
}
|
||||
);
|
||||
this.checkValidation();
|
||||
this._checkValidation();
|
||||
});
|
||||
this.currentEntityId = this.entityId;
|
||||
this.readOnly = true;
|
||||
@@ -765,6 +784,32 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
this.currentEntityId = automation?.entity_id;
|
||||
}
|
||||
|
||||
private async _checkValidation() {
|
||||
this.validationErrors = undefined;
|
||||
if (!this.currentEntityId || !this.config) {
|
||||
return;
|
||||
}
|
||||
const stateObj = this.hass.states[this.currentEntityId];
|
||||
if (stateObj?.state !== UNAVAILABLE) {
|
||||
return;
|
||||
}
|
||||
const validation = await validateConfig(this.hass, {
|
||||
triggers: this.config.triggers,
|
||||
conditions: this.config.conditions,
|
||||
actions: this.config.actions,
|
||||
});
|
||||
this.validationErrors = (
|
||||
Object.entries(validation) as Entries<typeof validation>
|
||||
).map(([key, value]) =>
|
||||
value.valid
|
||||
? ""
|
||||
: html`${this.hass.localize(
|
||||
`ui.panel.config.automation.editor.${key}.name`
|
||||
)}:
|
||||
${value.error}<br />`
|
||||
);
|
||||
}
|
||||
|
||||
private _valueChanged(ev: ValueChangedEvent<AutomationConfig>) {
|
||||
ev.stopPropagation();
|
||||
const config = ev.detail.value;
|
||||
@@ -794,6 +839,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
config: this.config,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
this.errors = undefined;
|
||||
}
|
||||
|
||||
private _showInfo() {
|
||||
@@ -883,6 +929,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
config: this.config!,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
this.errors = undefined;
|
||||
}
|
||||
|
||||
protected async confirmUnsavedChanged(addHistory = true): Promise<boolean> {
|
||||
@@ -963,8 +1010,9 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
this._yamlEditor?.setValue(this.config);
|
||||
}
|
||||
this.readOnly = true;
|
||||
this.errors = undefined;
|
||||
} catch (err: any) {
|
||||
showEditorToast(this, { message: err.message });
|
||||
this.errors = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1077,7 +1125,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
|
||||
private async _saveAutomation(id): Promise<void> {
|
||||
this.saving = true;
|
||||
const config = this.config!;
|
||||
this.validationErrors = undefined;
|
||||
|
||||
let entityRegPromise: Promise<EntityRegistryEntry> | undefined;
|
||||
if (this.entityRegistryUpdate !== undefined && !this.currentEntityId) {
|
||||
@@ -1088,7 +1136,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
}
|
||||
|
||||
try {
|
||||
await saveAutomationConfig(this.hass, id, config);
|
||||
await saveAutomationConfig(this.hass, id, this.config!);
|
||||
|
||||
if (this.entityRegistryUpdate !== undefined) {
|
||||
let entityId = this.currentEntityId;
|
||||
@@ -1135,9 +1183,11 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
|
||||
this._markDirtyStateClean();
|
||||
this.deprecatedConfigMigrated = false;
|
||||
this.clearConfigErrors();
|
||||
} catch (errors: unknown) {
|
||||
this.showSaveErrors(errors, config);
|
||||
} catch (errors: any) {
|
||||
this.errors = errors.body?.message || errors.error || errors.body;
|
||||
showEditorToast(this, {
|
||||
message: errors.body?.message || errors.error || errors.body,
|
||||
});
|
||||
throw errors;
|
||||
} finally {
|
||||
this.saving = false;
|
||||
|
||||
@@ -4,26 +4,16 @@ import type {
|
||||
PropertyValues,
|
||||
TemplateResult,
|
||||
} from "lit";
|
||||
import { css, html, nothing } from "lit";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { property, query, state } from "lit/decorators";
|
||||
import { css, html } from "lit";
|
||||
import { property, state } from "lit/decorators";
|
||||
import { consume } from "../../../common/decorators/consume";
|
||||
import { transform } from "../../../common/decorators/transform";
|
||||
import { goBack, navigate } from "../../../common/navigate";
|
||||
import { debounce } from "../../../common/util/debounce";
|
||||
import { afterNextRender } from "../../../common/util/render-status";
|
||||
import "../../../components/animation/ha-fade-in";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-spinner"; // used by renderLoading() provided to both editors
|
||||
import type { AutomationMigrationReport } from "../../../data/automation";
|
||||
import {
|
||||
parseConfigSaveError,
|
||||
validateConfigErrors,
|
||||
type ConfigValidationError,
|
||||
type ValidationSectionKeys,
|
||||
} from "../../../data/config";
|
||||
import { fireRelatedContext, fullEntitiesContext } from "../../../data/context";
|
||||
import { UNAVAILABLE } from "../../../data/entity/entity";
|
||||
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
|
||||
import {
|
||||
showAlertDialog,
|
||||
@@ -32,28 +22,7 @@ import {
|
||||
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
|
||||
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
|
||||
import type { Constructor, HomeAssistant, Route } from "../../../types";
|
||||
import { showToast } from "../../../util/toast";
|
||||
import {
|
||||
errorsEqual,
|
||||
errorsOnKnownItems,
|
||||
errorText,
|
||||
followErrors,
|
||||
followKnownErrorItems,
|
||||
knownErrorItems,
|
||||
mapErrorsToConfig,
|
||||
NO_ERRORS,
|
||||
type KnownErrorItem,
|
||||
} from "./automation-errors";
|
||||
import type { EntityRegistryUpdate } from "./automation-save-dialog/show-dialog-automation-save";
|
||||
import { showEditorToast } from "./editor-toast";
|
||||
|
||||
const CONFIG_ERRORS_TOAST_ID = "config-errors";
|
||||
|
||||
const REVALIDATE_DELAY = 500;
|
||||
|
||||
interface ErrorsEditorElement extends HTMLElement {
|
||||
revealErrors(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Minimum config shape shared by both AutomationConfig and ScriptConfig. */
|
||||
interface BaseEditorConfig {
|
||||
@@ -88,10 +57,6 @@ export const automationScriptEditorStyles: CSSResult = css`
|
||||
p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.config-errors p {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
ha-button[slot="fab"] {
|
||||
position: fixed;
|
||||
right: calc(16px + var(--safe-area-inset-right, 0px));
|
||||
@@ -117,11 +82,7 @@ export const automationScriptEditorStyles: CSSResult = css`
|
||||
export interface EditorDomainHooks<TConfig> {
|
||||
fetchFileConfig(hass: HomeAssistant, id: string): Promise<TConfig>;
|
||||
normalizeConfig(raw: TConfig, report?: AutomationMigrationReport): TConfig;
|
||||
/** The parts of the config `validate_config` checks, none for blueprints. */
|
||||
validationSections(
|
||||
config: TConfig
|
||||
): Partial<Record<"triggers" | "conditions" | "actions", unknown>>;
|
||||
validationSectionKeys?: ValidationSectionKeys;
|
||||
checkValidation(): Promise<void>;
|
||||
domain: "automation" | "script";
|
||||
}
|
||||
|
||||
@@ -150,6 +111,8 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
@consume({ context: fullEntitiesContext, subscribe: true })
|
||||
entityRegistry?: EntityRegistryEntry[];
|
||||
|
||||
@state() protected errors?: string;
|
||||
|
||||
@state() protected yamlErrors?: string;
|
||||
|
||||
@state() protected currentEntityId?: string;
|
||||
@@ -162,7 +125,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
|
||||
@state() protected deprecatedConfigMigrated = false;
|
||||
|
||||
@state() protected configErrors: ConfigValidationError[] = NO_ERRORS;
|
||||
@state() protected validationErrors?: (string | TemplateResult)[];
|
||||
|
||||
@state() protected config?: TConfig;
|
||||
|
||||
@@ -194,14 +157,6 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
|
||||
private _relatedContextAreaId?: string;
|
||||
|
||||
@query("manual-automation-editor, manual-script-editor")
|
||||
private _errorsEditor?: ErrorsEditorElement;
|
||||
|
||||
// Validation responses of an older run are dropped
|
||||
private _validationRun = 0;
|
||||
|
||||
private _knownErrorItems: KnownErrorItem[] = [];
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (
|
||||
@@ -212,212 +167,6 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues): void {
|
||||
super.updated(changedProps);
|
||||
if (changedProps.has("config") && this.configErrors.length) {
|
||||
this._knownErrorItems = followKnownErrorItems(
|
||||
this._knownErrorItems,
|
||||
changedProps.get("config"),
|
||||
this.config
|
||||
);
|
||||
this._revalidate();
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._revalidate.cancel();
|
||||
if (this.configErrors.length) {
|
||||
this._closeErrorsToast();
|
||||
}
|
||||
}
|
||||
|
||||
/** Shows the errors of saving `submitted`, the config sent to core. */
|
||||
protected async showSaveErrors(err: unknown, submitted: TConfig) {
|
||||
const { message, errors } = parseConfigSaveError(err);
|
||||
if (!errors.length) {
|
||||
this._showSaveFailedToast(message);
|
||||
return;
|
||||
}
|
||||
this._validationRun++;
|
||||
const submittedErrors = mapErrorsToConfig(submitted, errors);
|
||||
// Edits made while saving moved or removed some of the items
|
||||
const changed = submitted !== this.config;
|
||||
this._setConfigErrors(
|
||||
this.config,
|
||||
changed
|
||||
? followErrors(submittedErrors, submitted, this.config)
|
||||
: submittedErrors
|
||||
);
|
||||
if (changed) {
|
||||
// Followed errors miss items that were renamed and moved, so check
|
||||
// everything again
|
||||
await this._validateConfig(false);
|
||||
}
|
||||
this._showSaveFailedToast(
|
||||
errors.length === 1
|
||||
? errors[0].message
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.automation.editor.errors.problems_found",
|
||||
{ count: errors.length }
|
||||
)
|
||||
);
|
||||
await this._revealErrors();
|
||||
}
|
||||
|
||||
protected async checkValidation() {
|
||||
if (!this.currentEntityId || !this.config) {
|
||||
return;
|
||||
}
|
||||
if (this.hass.states[this.currentEntityId]?.state !== UNAVAILABLE) {
|
||||
return;
|
||||
}
|
||||
const config = this.config;
|
||||
const sections = this.domainHooks.validationSections(config);
|
||||
if (!Object.keys(sections).length) {
|
||||
return;
|
||||
}
|
||||
const run = ++this._validationRun;
|
||||
let errors: ConfigValidationError[];
|
||||
try {
|
||||
errors = await validateConfigErrors(
|
||||
this.hass,
|
||||
sections,
|
||||
this.domainHooks.validationSectionKeys
|
||||
);
|
||||
} catch (_err: unknown) {
|
||||
// The unavailable alert still shows, only the rows stay unmarked
|
||||
return;
|
||||
}
|
||||
if (run !== this._validationRun || config !== this.config) {
|
||||
return;
|
||||
}
|
||||
this._setConfigErrors(config, mapErrorsToConfig(config, errors));
|
||||
}
|
||||
|
||||
private _setConfigErrors(
|
||||
config: TConfig | undefined,
|
||||
errors: ConfigValidationError[]
|
||||
) {
|
||||
this.configErrors = errors;
|
||||
this._knownErrorItems = knownErrorItems(config, errors);
|
||||
}
|
||||
|
||||
private async _revealErrors() {
|
||||
await this.updateComplete;
|
||||
if (this.mode === "gui") {
|
||||
await this._errorsEditor?.revealErrors();
|
||||
}
|
||||
}
|
||||
|
||||
/** All errors, for the editors that have no rows to show them on. */
|
||||
protected renderConfigErrorsAlert(slot?: string) {
|
||||
return this.configErrors.length
|
||||
? html`<ha-alert
|
||||
alert-type="error"
|
||||
class="config-errors"
|
||||
slot=${ifDefined(slot)}
|
||||
>
|
||||
${this.configErrors.map((error) => html`<p>${errorText(error)}</p>`)}
|
||||
</ha-alert>`
|
||||
: nothing;
|
||||
}
|
||||
|
||||
private _showSaveFailedToast(problems: string) {
|
||||
showEditorToast(this, {
|
||||
id: CONFIG_ERRORS_TOAST_ID,
|
||||
message: this.hass.localize(
|
||||
"ui.panel.config.automation.editor.errors.save_failed",
|
||||
{ problems }
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
private _closeErrorsToast() {
|
||||
// A removed editor can't reach the toast manager by bubbling
|
||||
const host = this.isConnected
|
||||
? this
|
||||
: document.querySelector<HTMLElement>("home-assistant");
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
showToast(host, {
|
||||
id: CONFIG_ERRORS_TOAST_ID,
|
||||
message: "",
|
||||
duration: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Updates the errors of the items that had one, so fixed problems clear
|
||||
// while editing. New problems elsewhere, and errors outside the validated
|
||||
// sections like an invalid mode, wait for the next save.
|
||||
private _revalidate = debounce(() => {
|
||||
this._validateConfig(true);
|
||||
}, REVALIDATE_DELAY);
|
||||
|
||||
/**
|
||||
* Validates the sections of the current config. With `onlyKnown`, only
|
||||
* errors on items that already had one are kept.
|
||||
*/
|
||||
private async _validateConfig(onlyKnown: boolean) {
|
||||
this._revalidate.cancel();
|
||||
const config = this.config;
|
||||
if (!config || (onlyKnown && !this.configErrors.length)) {
|
||||
return;
|
||||
}
|
||||
const sections = this.domainHooks.validationSections(config);
|
||||
// Blueprint inputs can't be validated on their own, so their errors
|
||||
// only last until the next edit
|
||||
if (!Object.keys(sections).length) {
|
||||
this.clearConfigErrors();
|
||||
return;
|
||||
}
|
||||
const sectionKeys = this.domainHooks.validationSectionKeys ?? {};
|
||||
const run = ++this._validationRun;
|
||||
let sectionErrors: ConfigValidationError[];
|
||||
try {
|
||||
sectionErrors = await validateConfigErrors(
|
||||
this.hass,
|
||||
sections,
|
||||
sectionKeys
|
||||
);
|
||||
} catch (_err: unknown) {
|
||||
return;
|
||||
}
|
||||
if (run !== this._validationRun || config !== this.config) {
|
||||
return;
|
||||
}
|
||||
const mapped = mapErrorsToConfig(config, sectionErrors);
|
||||
const validated = Object.keys(sections).map(
|
||||
(key) => sectionKeys[key] ?? key
|
||||
);
|
||||
const errors = [
|
||||
...this.configErrors.filter(
|
||||
(error) => !validated.includes(error.path[0] as string)
|
||||
),
|
||||
...(onlyKnown
|
||||
? errorsOnKnownItems(this._knownErrorItems, config, mapped)
|
||||
: mapped),
|
||||
];
|
||||
if (!errors.length) {
|
||||
this.clearConfigErrors();
|
||||
return;
|
||||
}
|
||||
this._setConfigErrors(
|
||||
config,
|
||||
errorsEqual(errors, this.configErrors) ? this.configErrors : errors
|
||||
);
|
||||
}
|
||||
|
||||
protected clearConfigErrors() {
|
||||
this._validationRun++;
|
||||
this._revalidate.cancel();
|
||||
if (this.configErrors.length) {
|
||||
this.configErrors = NO_ERRORS;
|
||||
this._closeErrorsToast();
|
||||
}
|
||||
}
|
||||
|
||||
private _setRelatedContext(): void {
|
||||
const areaId = this.currentEntityId
|
||||
? this.entityRegistry?.find(
|
||||
@@ -528,7 +277,6 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
protected async loadConfig(id: string) {
|
||||
const hooks = this.domainHooks;
|
||||
const domain = hooks.domain;
|
||||
this.clearConfigErrors();
|
||||
try {
|
||||
const config = await hooks.fetchFileConfig(this.hass, id);
|
||||
if (!this.isConnected) {
|
||||
@@ -548,7 +296,7 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
}
|
||||
);
|
||||
this.checkValidation();
|
||||
hooks.checkValidation();
|
||||
} catch (err: any) {
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ContextProvider } from "@lit/context";
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { tinykeys } from "tinykeys";
|
||||
@@ -6,7 +5,6 @@ import { consume } from "../../../common/decorators/consume";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { computeRTL } from "../../../common/util/compute_rtl";
|
||||
import { pathEquals } from "../../../common/util/config-path";
|
||||
import "../../../components/ha-snap-bottom-sheet";
|
||||
import type { HaSnapBottomSheet } from "../../../components/ha-snap-bottom-sheet";
|
||||
import {
|
||||
@@ -24,11 +22,6 @@ import { manifestsContext } from "../../../data/context";
|
||||
import type { DomainManifestLookup } from "../../../data/integration";
|
||||
import { isTriggerList } from "../../../data/trigger";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import {
|
||||
automationPathContext,
|
||||
automationRowRegistryContext,
|
||||
type AutomationRowRegistry,
|
||||
} from "./automation-path";
|
||||
import "./sidebar/ha-automation-sidebar-action";
|
||||
import type HaAutomationSidebarAction from "./sidebar/ha-automation-sidebar-action";
|
||||
import "./sidebar/ha-automation-sidebar-condition";
|
||||
@@ -68,17 +61,6 @@ export default class HaAutomationSidebar extends LitElement {
|
||||
@consume({ context: manifestsContext, subscribe: true })
|
||||
private _manifests?: DomainManifestLookup;
|
||||
|
||||
@consume({ context: automationRowRegistryContext })
|
||||
private _registry?: AutomationRowRegistry;
|
||||
|
||||
// Editors in the sidebar are not inside their row, so they get its path here
|
||||
private readonly _pathProvider = new ContextProvider(this, {
|
||||
context: automationPathContext,
|
||||
initialValue: [],
|
||||
});
|
||||
|
||||
private _unsubscribeRegistry?: () => void;
|
||||
|
||||
@query("ha-snap-bottom-sheet")
|
||||
private _bottomSheetElement?: HaSnapBottomSheet;
|
||||
|
||||
@@ -101,28 +83,6 @@ export default class HaAutomationSidebar extends LitElement {
|
||||
|
||||
private _tinykeysUnsub?: () => void;
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
// A row dragged to another place while it is open changes its path
|
||||
this._unsubscribeRegistry = this._registry?.subscribe(() =>
|
||||
this._syncPath()
|
||||
);
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>) {
|
||||
super.willUpdate(changedProperties);
|
||||
if (changedProperties.has("config")) {
|
||||
this._syncPath();
|
||||
}
|
||||
}
|
||||
|
||||
private _syncPath() {
|
||||
const path = this.config ? (this._registry?.selectedPath() ?? []) : [];
|
||||
if (!pathEquals(path, this._pathProvider.value)) {
|
||||
this._pathProvider.setValue(path);
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues<this>) {
|
||||
super.updated(changedProperties);
|
||||
if (
|
||||
@@ -145,8 +105,6 @@ export default class HaAutomationSidebar extends LitElement {
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._unsubscribeRegistry?.();
|
||||
this._unsubscribeRegistry = undefined;
|
||||
this._unregisterResizeHandlers();
|
||||
this._tinykeysUnsub?.();
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { provide } from "@lit/context";
|
||||
import { mdiContentSave } from "@mdi/js";
|
||||
import {
|
||||
html,
|
||||
nothing,
|
||||
type LitElement,
|
||||
type nothing,
|
||||
type PropertyValues,
|
||||
type TemplateResult,
|
||||
} from "lit";
|
||||
@@ -16,7 +16,6 @@ import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { replaceCurrentUrl } from "../../../common/navigate";
|
||||
import { constructUrlCurrentPath } from "../../../common/url/construct-url";
|
||||
import { nextRender } from "../../../common/util/render-status";
|
||||
import {
|
||||
extractSearchParam,
|
||||
removeSearchParam,
|
||||
@@ -25,7 +24,6 @@ import "../../../components/ha-button";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-markdown";
|
||||
import type { SidebarConfig } from "../../../data/automation";
|
||||
import type { ConfigValidationError } from "../../../data/config";
|
||||
import {
|
||||
dirtyStateContext,
|
||||
type DirtyStateContext,
|
||||
@@ -36,19 +34,10 @@ import type {
|
||||
ValueChangedEvent,
|
||||
} from "../../../types";
|
||||
import { showToast } from "../../../util/toast";
|
||||
import "../../../components/ha-alert";
|
||||
import {
|
||||
automationErrorsContext,
|
||||
errorOwner,
|
||||
errorText,
|
||||
NO_ERRORS,
|
||||
} from "./automation-errors";
|
||||
import {
|
||||
AutomationRowRegistry,
|
||||
automationRowRegistryContext,
|
||||
type AutomationPathRow,
|
||||
} from "./automation-path";
|
||||
import { scrollRowIntoView } from "./scroll-row-into-view";
|
||||
import "./ha-automation-sidebar";
|
||||
import type HaAutomationSidebar from "./ha-automation-sidebar";
|
||||
|
||||
@@ -101,10 +90,6 @@ export const ManualEditorMixin = <TConfig>(
|
||||
@provide({ context: automationRowRegistryContext })
|
||||
protected rowRegistry = new AutomationRowRegistry();
|
||||
|
||||
@provide({ context: automationErrorsContext })
|
||||
@property({ attribute: false })
|
||||
public configErrors: ConfigValidationError[] = NO_ERRORS;
|
||||
|
||||
declare protected collapsableElements?: NodeListOf<
|
||||
Element & {
|
||||
expandAll(): void;
|
||||
@@ -118,44 +103,16 @@ export const ManualEditorMixin = <TConfig>(
|
||||
throw new Error("Not implemented");
|
||||
};
|
||||
|
||||
private _unsubscribeRows?: () => void;
|
||||
|
||||
// Rows register while they render, so errors only count as without a
|
||||
// row once the rows had a frame to render
|
||||
@state() private _rowsRendered = false;
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.addEventListener("paste", this.handlePaste);
|
||||
// Which errors have a row changes as rows render
|
||||
this._unsubscribeRows = this.rowRegistry.subscribe(() => {
|
||||
if (this.configErrors.length) {
|
||||
this.requestUpdate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
window.removeEventListener("paste", this.handlePaste);
|
||||
this._unsubscribeRows?.();
|
||||
this._unsubscribeRows = undefined;
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private _renderUnownedErrors() {
|
||||
if (!this._rowsRendered) {
|
||||
return nothing;
|
||||
}
|
||||
const unowned = this.configErrors.filter(
|
||||
(error) => !errorOwner(this.rowRegistry, error)
|
||||
);
|
||||
return unowned.length
|
||||
? html`<ha-alert alert-type="error" class="unowned-errors">
|
||||
${unowned.map((error) => html`<p>${errorText(error)}</p>`)}
|
||||
</ha-alert>`
|
||||
: nothing;
|
||||
}
|
||||
|
||||
protected renderContent(): TemplateResult | typeof nothing {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
@@ -184,7 +141,7 @@ export const ManualEditorMixin = <TConfig>(
|
||||
>
|
||||
<div class="content">
|
||||
<slot name="alerts"></slot>
|
||||
${this._renderUnownedErrors()} ${this.renderContent()}
|
||||
${this.renderContent()}
|
||||
</div>
|
||||
<div class="fab-positioner">
|
||||
<ha-button
|
||||
@@ -222,9 +179,6 @@ export const ManualEditorMixin = <TConfig>(
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>): void {
|
||||
super.firstUpdated(changedProps);
|
||||
nextRender().then(() => {
|
||||
this._rowsRendered = true;
|
||||
});
|
||||
|
||||
this.style.setProperty(
|
||||
"--sidebar-dynamic-width",
|
||||
@@ -349,54 +303,6 @@ export const ManualEditorMixin = <TConfig>(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands the rows along the path of every error, then scrolls to the
|
||||
* first one and opens it in the sidebar. Does nothing while a row with an
|
||||
* error is open, so the user stays where they are.
|
||||
*/
|
||||
public async revealErrors() {
|
||||
await this.updateComplete;
|
||||
const errorRows = () =>
|
||||
this.configErrors.map((error) =>
|
||||
this.rowRegistry.rowsAlong(error.path)
|
||||
);
|
||||
const owners = (rows: AutomationPathRow[][]) =>
|
||||
rows
|
||||
.map((along) => along[along.length - 1])
|
||||
.filter((owner) => owner !== undefined);
|
||||
let rows = errorRows();
|
||||
if (owners(rows).some((owner) => owner.selected)) {
|
||||
return;
|
||||
}
|
||||
const depthOf = (along: AutomationPathRow[][]) =>
|
||||
along.reduce((sum, path) => sum + path.length, 0);
|
||||
// Collapsed rows don't render their children, so expand one level at a
|
||||
// time until no deeper row shows up
|
||||
let depth = -1;
|
||||
while (depthOf(rows) > depth) {
|
||||
depth = depthOf(rows);
|
||||
rows.flat().forEach((row) => row.expand());
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await nextRender();
|
||||
rows = errorRows();
|
||||
}
|
||||
// Errors aren't in config order, so take the topmost row
|
||||
const first = owners(rows).reduce<AutomationPathRow | undefined>(
|
||||
(top, row) =>
|
||||
!top ||
|
||||
row.getBoundingClientRect().top < top.getBoundingClientRect().top
|
||||
? row
|
||||
: top,
|
||||
undefined
|
||||
);
|
||||
if (!first) {
|
||||
return;
|
||||
}
|
||||
first.openSidebar();
|
||||
await nextRender();
|
||||
scrollRowIntoView(first);
|
||||
}
|
||||
|
||||
public expandAll() {
|
||||
this.collapsableElements?.forEach((element) => {
|
||||
element.expandAll();
|
||||
|
||||
@@ -65,7 +65,6 @@ import {
|
||||
import "../trigger/ha-automation-trigger-references";
|
||||
import { getUiSupportWarnings, isConfigObject } from "../ui-support";
|
||||
import { AutomationPathController } from "../automation-path";
|
||||
import { renderRowErrorIcon } from "../automation-row-error-icon";
|
||||
|
||||
@customElement("ha-automation-option-row")
|
||||
export default class HaAutomationOptionRow extends LitElement {
|
||||
@@ -273,11 +272,6 @@ export default class HaAutomationOptionRow extends LitElement {
|
||||
}
|
||||
</h3>
|
||||
|
||||
${renderRowErrorIcon(
|
||||
this.pathController.errors,
|
||||
this.pathController.path,
|
||||
this.hass.localize("ui.panel.config.automation.editor.errors.label")
|
||||
)}
|
||||
<slot name="icons" slot="icons"></slot>
|
||||
|
||||
${
|
||||
@@ -508,7 +502,6 @@ export default class HaAutomationOptionRow extends LitElement {
|
||||
.collapsed=${this._collapsed}
|
||||
.selected=${this._selected}
|
||||
.sortSelected=${this.sortSelected}
|
||||
.error=${this.pathController.errors.length > 0}
|
||||
@click=${this._toggleSidebar}
|
||||
@toggle-collapsed=${this._toggleCollapse}
|
||||
@delete-row=${this._removeOption}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { provide } from "@lit/context";
|
||||
import { mdiClose, mdiDotsVertical } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
@@ -13,13 +12,6 @@ import "../../../../components/ha-icon-button";
|
||||
import { ScrollableFadeMixin } from "../../../../mixins/scrollable-fade-mixin";
|
||||
import { haStyleScrollbar } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import "../../../../components/ha-alert";
|
||||
import { errorField, errorText } from "../automation-errors";
|
||||
import {
|
||||
automationInlineErrorFieldsContext,
|
||||
AutomationFieldErrorsController,
|
||||
InlineErrorFields,
|
||||
} from "../automation-field-errors";
|
||||
import "../ha-automation-editor-warning";
|
||||
|
||||
export interface SidebarOverflowMenuEntry {
|
||||
@@ -52,22 +44,11 @@ export default class HaAutomationSidebarCard extends ScrollableFadeMixin(
|
||||
|
||||
@query(".card-content") private _contentElement!: HTMLDivElement;
|
||||
|
||||
private readonly _errors = new AutomationFieldErrorsController(this);
|
||||
|
||||
@provide({ context: automationInlineErrorFieldsContext })
|
||||
protected inlineErrorFields = new InlineErrorFields(() =>
|
||||
this.requestUpdate()
|
||||
);
|
||||
|
||||
protected get scrollableElement(): HTMLElement | null {
|
||||
return this._contentElement;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const errors = this._errors.rowErrors.filter((error) => {
|
||||
const field = errorField(error, this._errors.path);
|
||||
return field === undefined || !this.inlineErrorFields.has(field);
|
||||
});
|
||||
return html`
|
||||
<ha-card
|
||||
outlined
|
||||
@@ -109,15 +90,6 @@ export default class HaAutomationSidebarCard extends ScrollableFadeMixin(
|
||||
</ha-automation-editor-warning>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
errors.length
|
||||
? html`<ha-alert alert-type="error" class="errors">
|
||||
${errors.map(
|
||||
(error) => html`<p>${errorText(error, this._errors.path)}</p>`
|
||||
)}
|
||||
</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
<div class="card-content ha-scrollbar">
|
||||
<slot></slot>
|
||||
${this.renderScrollableFades(this.isWide)}
|
||||
@@ -176,15 +148,6 @@ export default class HaAutomationSidebarCard extends ScrollableFadeMixin(
|
||||
padding-bottom: max(var(--safe-area-inset-bottom, 0px), 32px);
|
||||
}
|
||||
|
||||
.errors {
|
||||
margin: 0 var(--ha-space-4) var(--ha-space-4);
|
||||
}
|
||||
|
||||
.errors p {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.fade-top {
|
||||
top: var(--ha-space-17);
|
||||
}
|
||||
|
||||
@@ -124,19 +124,6 @@ export const rowStyles = css`
|
||||
white-space: pre-wrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error-indicator {
|
||||
color: var(--ha-color-on-danger-quiet);
|
||||
padding: var(--ha-space-3);
|
||||
}
|
||||
.error-indicator + ha-tooltip::part(body) {
|
||||
cursor: default;
|
||||
max-width: 300px;
|
||||
}
|
||||
.error-indicator + ha-tooltip p {
|
||||
white-space: pre-wrap;
|
||||
margin: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export const editorStyles = css`
|
||||
@@ -293,15 +280,6 @@ export const manualEditorStyles = css`
|
||||
.header a {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.unowned-errors {
|
||||
display: block;
|
||||
margin-bottom: var(--ha-space-2);
|
||||
}
|
||||
.unowned-errors p {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
`;
|
||||
|
||||
export const automationRowsStyles = css`
|
||||
|
||||
@@ -3,11 +3,14 @@ import type { LitElement, ReactiveController } from "lit";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type {
|
||||
AutomationConfig,
|
||||
BlueprintAutomationConfig,
|
||||
SidebarConfig,
|
||||
Trigger,
|
||||
TriggerCondition,
|
||||
} from "../../../../data/automation";
|
||||
import { ContextSubscriptionController } from "../../../../common/decorators/consume";
|
||||
import { debounce } from "../../../../common/util/debounce";
|
||||
import { deepEqual } from "../../../../common/util/deep-equal";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import { internationalizationContext } from "../../../../data/context";
|
||||
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
|
||||
@@ -16,12 +19,15 @@ import {
|
||||
automationTriggerContext,
|
||||
cleanupRemovedGeneratedTriggerReferences,
|
||||
cleanupUnusedGeneratedTriggerIds,
|
||||
getBlueprintTriggerIdOptions,
|
||||
getTriggerIdOptions,
|
||||
getExplicitTriggerIds,
|
||||
isGeneratedTriggerId,
|
||||
makeDuplicateTriggerIdsUnique,
|
||||
replaceLeafTrigger,
|
||||
updateTriggerCondition,
|
||||
type TriggerIdOption,
|
||||
type BlueprintTriggersStatus,
|
||||
} from "./automation-trigger-id";
|
||||
import { preserveAutomationRowKeys } from "../ha-automation-sortable-list-mixin";
|
||||
|
||||
@@ -31,6 +37,10 @@ interface AutomationTriggerControllerOptions {
|
||||
canEdit: () => boolean;
|
||||
/** Apply one complete change through the editor's undo and dirty-state handling. */
|
||||
commit: (config: AutomationConfig) => void;
|
||||
/** Resolve a blueprint automation's triggers, with its inputs substituted. */
|
||||
fetchBlueprintTriggers: (
|
||||
useBlueprint: BlueprintAutomationConfig["use_blueprint"]
|
||||
) => Promise<Trigger | Trigger[] | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,6 +53,9 @@ interface AutomationTriggerControllerOptions {
|
||||
*
|
||||
* The transformation helpers only change configuration. This controller preserves
|
||||
* the corresponding row keys so an open sidebar continues to track the edited row.
|
||||
*
|
||||
* A blueprint automation has no triggers of its own: options come from the
|
||||
* blueprint's substituted triggers, and conditions in its inputs change themselves.
|
||||
*/
|
||||
export class AutomationTriggerController implements ReactiveController {
|
||||
// Only read in event handlers, so it does not need to rerender the host.
|
||||
@@ -54,6 +67,18 @@ export class AutomationTriggerController implements ReactiveController {
|
||||
// changes, so rendering and selection use the same IDs across host updates.
|
||||
private _getTriggerIdOptions = memoizeOne(getTriggerIdOptions);
|
||||
|
||||
private _getBlueprintTriggerIdOptions = memoizeOne(
|
||||
getBlueprintTriggerIdOptions
|
||||
);
|
||||
|
||||
/** The blueprint use whose triggers were last requested. */
|
||||
private _blueprintRequested?: BlueprintAutomationConfig["use_blueprint"];
|
||||
|
||||
private _blueprintTriggers?: Trigger | Trigger[];
|
||||
|
||||
/** "failed" while required inputs are empty. */
|
||||
private _blueprintStatus: BlueprintTriggersStatus = "loading";
|
||||
|
||||
constructor(
|
||||
private _host: LitElement,
|
||||
private _options: AutomationTriggerControllerOptions
|
||||
@@ -78,15 +103,39 @@ export class AutomationTriggerController implements ReactiveController {
|
||||
_host.addController(this);
|
||||
}
|
||||
|
||||
public hostDisconnected() {
|
||||
this._fetchBlueprintTriggers.cancel();
|
||||
this._blueprintRequested = undefined;
|
||||
}
|
||||
|
||||
/** Publish options before descendants render, including after undo or redo. */
|
||||
public hostUpdate() {
|
||||
const config = this._options.getConfig();
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
const options = this._getTriggerIdOptions(config.triggers);
|
||||
if (options !== this._provider.value.options) {
|
||||
this._provider.setValue({ ...this._provider.value, options });
|
||||
let options: TriggerIdOption[];
|
||||
let blueprintStatus: BlueprintTriggersStatus | undefined;
|
||||
let showIndices = this._provider.value.showIndices;
|
||||
if ("use_blueprint" in config && config.use_blueprint) {
|
||||
this._requestBlueprintTriggers(config.use_blueprint);
|
||||
options = this._getBlueprintTriggerIdOptions(this._blueprintTriggers);
|
||||
blueprintStatus = this._blueprintStatus;
|
||||
showIndices = false;
|
||||
} else {
|
||||
options = this._getTriggerIdOptions(config.triggers);
|
||||
}
|
||||
if (
|
||||
options !== this._provider.value.options ||
|
||||
blueprintStatus !== this._provider.value.blueprintStatus ||
|
||||
showIndices !== this._provider.value.showIndices
|
||||
) {
|
||||
this._provider.setValue({
|
||||
...this._provider.value,
|
||||
options,
|
||||
blueprintStatus,
|
||||
showIndices,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,11 +187,55 @@ export class AutomationTriggerController implements ReactiveController {
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private _selectTriggerIds = (condition: TriggerCondition, ids: string[]) => {
|
||||
const config = this._options.getConfig();
|
||||
if (!config || !this._options.canEdit()) {
|
||||
/** Request the blueprint's triggers again whenever its path or inputs change. */
|
||||
private _requestBlueprintTriggers(
|
||||
useBlueprint: BlueprintAutomationConfig["use_blueprint"]
|
||||
) {
|
||||
if (deepEqual(useBlueprint, this._blueprintRequested)) {
|
||||
return;
|
||||
}
|
||||
if (useBlueprint.path !== this._blueprintRequested?.path) {
|
||||
this._blueprintTriggers = undefined;
|
||||
}
|
||||
this._blueprintStatus = "loading";
|
||||
this._blueprintRequested = useBlueprint;
|
||||
this._fetchBlueprintTriggers(useBlueprint);
|
||||
}
|
||||
|
||||
private _fetchBlueprintTriggers = debounce(
|
||||
async (useBlueprint: BlueprintAutomationConfig["use_blueprint"]) => {
|
||||
let triggers: Trigger | Trigger[] | undefined;
|
||||
try {
|
||||
triggers = await this._options.fetchBlueprintTriggers(useBlueprint);
|
||||
} catch (_err) {
|
||||
if (useBlueprint === this._blueprintRequested) {
|
||||
this._blueprintStatus = "failed";
|
||||
this._host.requestUpdate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (useBlueprint !== this._blueprintRequested) {
|
||||
return;
|
||||
}
|
||||
this._blueprintTriggers = triggers;
|
||||
this._blueprintStatus = "loaded";
|
||||
this._host.requestUpdate();
|
||||
},
|
||||
500,
|
||||
true
|
||||
);
|
||||
|
||||
private _selectTriggerIds = (
|
||||
condition: TriggerCondition,
|
||||
ids: string[]
|
||||
): boolean => {
|
||||
const config = this._options.getConfig();
|
||||
if (config && "use_blueprint" in config) {
|
||||
return false;
|
||||
}
|
||||
if (!config || !this._options.canEdit()) {
|
||||
return true;
|
||||
}
|
||||
const triggers = assignGeneratedTriggerIds(
|
||||
config.triggers,
|
||||
this._getTriggerIdOptions(config.triggers),
|
||||
@@ -157,6 +250,7 @@ export class AutomationTriggerController implements ReactiveController {
|
||||
// Generated IDs that no condition or action references are no longer in use.
|
||||
// Strip them so the YAML does not accumulate stale identifiers.
|
||||
this._commit(cleanupUnusedGeneratedTriggerIds(updated));
|
||||
return true;
|
||||
};
|
||||
|
||||
private _fixDuplicateTriggerIds = async () => {
|
||||
|
||||
@@ -38,13 +38,28 @@ export interface TriggerIdOption {
|
||||
duplicate: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a blueprint's substituted triggers are known. While "loading" or
|
||||
* "failed", options keep the last triggers fetched, if any.
|
||||
*/
|
||||
export type BlueprintTriggersStatus = "loaded" | "loading" | "failed";
|
||||
|
||||
/** Controller-provided options and operations for editors, sidebars, and row summaries. */
|
||||
export interface AutomationTriggerContext {
|
||||
options: TriggerIdOption[];
|
||||
/**
|
||||
* Left out for an automation with its own triggers. Set for a blueprint
|
||||
* automation, whose missing IDs can only be flagged once "loaded".
|
||||
*/
|
||||
blueprintStatus?: BlueprintTriggersStatus;
|
||||
/** True while the user is editing a `triggered_by` condition; trigger rows show their index badges only in this state. */
|
||||
showIndices: boolean;
|
||||
/** Pass the original condition object so identical conditions remain distinguishable. */
|
||||
select: (condition: TriggerCondition, ids: string[]) => void;
|
||||
/**
|
||||
* Pass the original condition object so identical conditions remain distinguishable.
|
||||
* Return false when the condition is not one of the automation's conditions, as in
|
||||
* a blueprint automation, whose conditions live in its inputs.
|
||||
*/
|
||||
select: (condition: TriggerCondition, ids: string[]) => boolean;
|
||||
/** Requests confirmation before migrating duplicate IDs throughout the automation. */
|
||||
fixDuplicateIds: () => Promise<void>;
|
||||
/**
|
||||
@@ -54,6 +69,13 @@ export interface AutomationTriggerContext {
|
||||
updateTriggerId: (original: Trigger, updated: Trigger) => boolean;
|
||||
}
|
||||
|
||||
/** Whether options list every trigger, so an ID not among them is really missing. */
|
||||
export const triggerOptionsResolved = (
|
||||
context: AutomationTriggerContext | undefined
|
||||
): boolean =>
|
||||
context?.blueprintStatus === undefined ||
|
||||
context.blueprintStatus === "loaded";
|
||||
|
||||
export const automationTriggerContext =
|
||||
createContext<AutomationTriggerContext>("automationTriggers");
|
||||
|
||||
@@ -140,6 +162,27 @@ export const getTriggerIdOptions = (
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Build options from a blueprint's substituted triggers. The blueprint owns its
|
||||
* triggers, so only those with a stored ID can be referenced, and triggers sharing
|
||||
* an ID are one option: a condition on that ID matches any of them.
|
||||
*/
|
||||
export const getBlueprintTriggerIdOptions = (
|
||||
triggers: Trigger | Trigger[] | undefined
|
||||
): TriggerIdOption[] => {
|
||||
const options: TriggerIdOption[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
flattenTriggers(triggers).forEach((trigger, index) => {
|
||||
const id = getTriggerId(trigger);
|
||||
if (!id || seenIds.has(id)) {
|
||||
return;
|
||||
}
|
||||
seenIds.add(id);
|
||||
options.push({ id, trigger, index, draft: false, duplicate: false });
|
||||
});
|
||||
return options;
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform leaf triggers recursively, retaining list wrappers and scalar/array shape.
|
||||
* Copy only changed branches; callbacks must return replacements rather than mutate leaves.
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { EntityRegistryEntry } from "../../../../data/entity/entity_registr
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import {
|
||||
automationTriggerContext,
|
||||
triggerOptionsResolved,
|
||||
type AutomationTriggerContext,
|
||||
} from "./automation-trigger-id";
|
||||
|
||||
@@ -41,6 +42,7 @@ export class HaAutomationTriggerReferences extends LitElement {
|
||||
const missingIds = selectedIds.filter(
|
||||
(id) => !options.some((option) => option.id === id)
|
||||
);
|
||||
const resolved = triggerOptionsResolved(this._triggers);
|
||||
|
||||
if (options.length && !selectedIds.length) {
|
||||
return html`<span class="trigger-reference no-selection">
|
||||
@@ -87,17 +89,21 @@ export class HaAutomationTriggerReferences extends LitElement {
|
||||
</span>
|
||||
`
|
||||
),
|
||||
missingIds.map(
|
||||
() => html`
|
||||
<span class="trigger-reference missing">
|
||||
<ha-svg-icon .path=${mdiLinkVariantOff}></ha-svg-icon>
|
||||
<span>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.missing_trigger"
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
`
|
||||
missingIds.map((id) =>
|
||||
resolved
|
||||
? html`
|
||||
<span class="trigger-reference missing">
|
||||
<ha-svg-icon .path=${mdiLinkVariantOff}></ha-svg-icon>
|
||||
<span>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.type.trigger.missing_trigger"
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
`
|
||||
: html`<span class="trigger-reference">
|
||||
<span class="trigger-reference-label">${id}</span>
|
||||
</span>`
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -121,7 +121,6 @@ import "./types/ha-automation-trigger-zone";
|
||||
import { renderCtrlOrCmd } from "../../../../common/keyboard/ctrl-or-cmd";
|
||||
import { scrollRowIntoView } from "../scroll-row-into-view";
|
||||
import { AutomationPathController } from "../automation-path";
|
||||
import { renderRowErrorIcon } from "../automation-row-error-icon";
|
||||
import { targetExpandGroupContext } from "../../../../components/target-picker/target-expand-group-context";
|
||||
|
||||
export interface TriggerElement extends LitElement {
|
||||
@@ -401,11 +400,6 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
)}
|
||||
</ha-automation-row-event-chip>
|
||||
|
||||
${renderRowErrorIcon(
|
||||
this.pathController.errors,
|
||||
this.pathController.path,
|
||||
this.hass.localize("ui.panel.config.automation.editor.errors.label")
|
||||
)}
|
||||
<slot name="icons" slot="icons"></slot>
|
||||
|
||||
<ha-dropdown
|
||||
@@ -670,7 +664,6 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
.selected=${this._selected}
|
||||
.highlight=${this.highlight}
|
||||
.sortSelected=${this.sortSelected}
|
||||
.error=${this.pathController.errors.length > 0}
|
||||
.dim=${this._triggered}
|
||||
@click=${this._toggleSidebar}
|
||||
>${
|
||||
|
||||
@@ -29,18 +29,9 @@ import {
|
||||
} from "../../../../../data/device/device_registry";
|
||||
import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import {
|
||||
deviceAutomationErrorText,
|
||||
deviceErrorText,
|
||||
} from "../../automation-errors";
|
||||
import { AutomationFieldErrorsController } from "../../automation-field-errors";
|
||||
|
||||
@customElement("ha-automation-trigger-device")
|
||||
export class HaDeviceTrigger extends LitElement {
|
||||
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
|
||||
inlineFields: ["device_id", "domain"],
|
||||
});
|
||||
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Object }) public trigger!: DeviceTrigger;
|
||||
@@ -145,20 +136,12 @@ export class HaDeviceTrigger extends LitElement {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const deviceError = this._fieldErrors.fieldError("device_id");
|
||||
const automationError = this._fieldErrors.fieldError("domain");
|
||||
const deviceId = this._deviceId || this.trigger.device_id;
|
||||
|
||||
return html`
|
||||
<ha-device-picker
|
||||
.value=${deviceId}
|
||||
.replacementDeviceIds=${this._replacementDeviceIds}
|
||||
.invalid=${!!deviceError}
|
||||
.errorMessage=${
|
||||
deviceError
|
||||
? deviceErrorText(this.hass.localize, deviceError)
|
||||
: undefined
|
||||
}
|
||||
@value-changed=${this._devicePicked}
|
||||
.disabled=${this.disabled}
|
||||
.label=${this.hass.localize(
|
||||
@@ -166,16 +149,6 @@ export class HaDeviceTrigger extends LitElement {
|
||||
)}
|
||||
></ha-device-picker>
|
||||
<ha-device-trigger-picker
|
||||
.invalid=${!!automationError}
|
||||
.errorMessage=${
|
||||
automationError
|
||||
? deviceAutomationErrorText(
|
||||
this.hass.localize,
|
||||
"triggers",
|
||||
automationError
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
.value=${this.trigger}
|
||||
.deviceId=${deviceId}
|
||||
@value-changed=${this._deviceTriggerPicked}
|
||||
|
||||
@@ -4,7 +4,6 @@ import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../../../../../common/array/ensure-array";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import "../../../../../components/ha-form/ha-form";
|
||||
import { AutomationFieldErrorsController } from "../../automation-field-errors";
|
||||
import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
import type { NumericStateTrigger } from "../../../../../data/automation";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
@@ -184,17 +183,6 @@ const SCHEMA = [
|
||||
|
||||
@customElement("ha-automation-trigger-numeric_state")
|
||||
export class HaNumericStateTrigger extends LitElement {
|
||||
private readonly _fieldErrors = new AutomationFieldErrorsController(this, {
|
||||
inlineFields: [
|
||||
"entity_id",
|
||||
"attribute",
|
||||
"value_template",
|
||||
"above",
|
||||
"below",
|
||||
"for",
|
||||
],
|
||||
});
|
||||
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public trigger!: NumericStateTrigger;
|
||||
@@ -242,7 +230,6 @@ export class HaNumericStateTrigger extends LitElement {
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
.error=${this._fieldErrors.fields}
|
||||
.hass=${this.hass}
|
||||
.data=${data}
|
||||
.schema=${SCHEMA}
|
||||
|
||||
Loaded 100 of 226 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user