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;
|
||||
};
|
||||
@@ -30,10 +30,40 @@ export class HaAutomationRow extends LitElement {
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public dim = 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"
|
||||
@@ -53,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>
|
||||
`;
|
||||
}
|
||||
@@ -119,7 +139,7 @@ export class HaAutomationRow extends LitElement {
|
||||
|
||||
public focus() {
|
||||
requestAnimationFrame(() => {
|
||||
this._rowElement?.focus();
|
||||
(this._linkElement ?? this._rowElement)?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -144,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);
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`
|
||||
);
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -146,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;
|
||||
|
||||
@@ -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>`
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ class HaBackupConfigAddon extends LitElement {
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-list-base>
|
||||
<ha-list-item-base>
|
||||
<ha-list-item-base wrap-headline wrap-supporting-text>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.backup.schedule.update_preference.label`
|
||||
@@ -55,7 +55,7 @@ class HaBackupConfigAddon extends LitElement {
|
||||
]}
|
||||
></ha-select>
|
||||
</ha-list-item-base>
|
||||
<ha-list-item-base>
|
||||
<ha-list-item-base wrap-headline wrap-supporting-text>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(`ui.panel.config.backup.schedule.retention`)}
|
||||
</span>
|
||||
@@ -113,10 +113,6 @@ class HaBackupConfigAddon extends LitElement {
|
||||
ha-list-base {
|
||||
--ha-row-item-padding-inline: 0;
|
||||
}
|
||||
ha-list-item-base::part(headline),
|
||||
ha-list-item-base::part(supporting-text) {
|
||||
white-space: wrap;
|
||||
}
|
||||
ha-select {
|
||||
min-width: 210px;
|
||||
}
|
||||
|
||||
@@ -197,7 +197,7 @@ class HaBackupConfigAgents extends LitElement {
|
||||
!this.cloudStatus.active_subscription;
|
||||
|
||||
return html`
|
||||
<ha-list-item-base>
|
||||
<ha-list-item-base wrap-headline wrap-supporting-text>
|
||||
${this._renderAgentIcon(agentId)}
|
||||
<div slot="headline" class="name">${name}</div>
|
||||
${
|
||||
@@ -248,7 +248,7 @@ class HaBackupConfigAgents extends LitElement {
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-list-item-base>
|
||||
<ha-list-item-base wrap-headline>
|
||||
${this._renderAgentIcon(agentId)}
|
||||
<div slot="headline" class="name">${name}</div>
|
||||
<ha-icon-button
|
||||
@@ -317,10 +317,6 @@ class HaBackupConfigAgents extends LitElement {
|
||||
--mdc-icon-size: 48px;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
ha-list-item-base::part(headline),
|
||||
ha-list-item-base::part(supporting-text) {
|
||||
white-space: wrap;
|
||||
}
|
||||
ha-list-item-base::part(end) {
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ class HaBackupConfigData extends LitElement {
|
||||
return html`
|
||||
${this._renderSizeEstimate()}
|
||||
<ha-list-base>
|
||||
<ha-list-item-base>
|
||||
<ha-list-item-base wrap-headline wrap-supporting-text>
|
||||
<ha-svg-icon slot="start" .path=${mdiCog}></ha-svg-icon>
|
||||
<span slot="headline">
|
||||
${this.hass.localize("ui.panel.config.backup.data.ha_settings")}
|
||||
@@ -266,7 +266,7 @@ class HaBackupConfigData extends LitElement {
|
||||
|
||||
${
|
||||
this._showDbOption
|
||||
? html`<ha-list-item-base>
|
||||
? html`<ha-list-item-base wrap-headline wrap-supporting-text>
|
||||
<ha-svg-icon slot="start" .path=${mdiChartBox}></ha-svg-icon>
|
||||
<span slot="headline">
|
||||
${this.hass.localize("ui.panel.config.backup.data.history")}
|
||||
@@ -288,7 +288,7 @@ class HaBackupConfigData extends LitElement {
|
||||
${
|
||||
isHassio
|
||||
? html`
|
||||
<ha-list-item-base>
|
||||
<ha-list-item-base wrap-headline wrap-supporting-text>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiPlayBoxMultiple}
|
||||
@@ -309,7 +309,7 @@ class HaBackupConfigData extends LitElement {
|
||||
></ha-switch>
|
||||
</ha-list-item-base>
|
||||
|
||||
<ha-list-item-base>
|
||||
<ha-list-item-base wrap-headline wrap-supporting-text>
|
||||
<ha-svg-icon slot="start" .path=${mdiFolder}></ha-svg-icon>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
@@ -332,7 +332,7 @@ class HaBackupConfigData extends LitElement {
|
||||
${
|
||||
this._hasLocalAddons(this._addons)
|
||||
? html`
|
||||
<ha-list-item-base>
|
||||
<ha-list-item-base wrap-headline wrap-supporting-text>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiFolder}
|
||||
@@ -360,7 +360,7 @@ class HaBackupConfigData extends LitElement {
|
||||
${
|
||||
this._addons.length
|
||||
? html`
|
||||
<ha-list-item-base>
|
||||
<ha-list-item-base wrap-headline wrap-supporting-text>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiPuzzle}
|
||||
@@ -566,10 +566,6 @@ class HaBackupConfigData extends LitElement {
|
||||
ha-list-base {
|
||||
--ha-row-item-padding-inline: 0;
|
||||
}
|
||||
ha-list-item-base::part(headline),
|
||||
ha-list-item-base::part(supporting-text) {
|
||||
white-space: wrap;
|
||||
}
|
||||
ha-list-item-base::part(start) {
|
||||
color: var(--ha-color-text-secondary);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ class HaBackupConfigEncryptionKey extends LitElement {
|
||||
if (this._value) {
|
||||
return html`
|
||||
<ha-list-base>
|
||||
<ha-list-item-base>
|
||||
<ha-list-item-base wrap-headline wrap-supporting-text>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.encryption_key.download_emergency_kit"
|
||||
@@ -48,7 +48,7 @@ class HaBackupConfigEncryptionKey extends LitElement {
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-list-item-base>
|
||||
<ha-list-item-base>
|
||||
<ha-list-item-base wrap-headline wrap-supporting-text>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.encryption_key.show_encryption_key"
|
||||
@@ -70,7 +70,7 @@ class HaBackupConfigEncryptionKey extends LitElement {
|
||||
)}
|
||||
</ha-button>
|
||||
</ha-list-item-base>
|
||||
<ha-list-item-base>
|
||||
<ha-list-item-base wrap-headline wrap-supporting-text>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.encryption_key.change_encryption_key"
|
||||
@@ -99,7 +99,7 @@ class HaBackupConfigEncryptionKey extends LitElement {
|
||||
|
||||
return html`
|
||||
<ha-list-base>
|
||||
<ha-list-item-base>
|
||||
<ha-list-item-base wrap-headline wrap-supporting-text>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.encryption_key.set_encryption_key"
|
||||
@@ -152,10 +152,6 @@ class HaBackupConfigEncryptionKey extends LitElement {
|
||||
ha-list-base {
|
||||
--ha-row-item-padding-inline: 0;
|
||||
}
|
||||
ha-list-item-base::part(headline),
|
||||
ha-list-item-base::part(supporting-text) {
|
||||
white-space: wrap;
|
||||
}
|
||||
ha-button[size="s"] ha-svg-icon {
|
||||
--mdc-icon-size: 16px;
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ class HaBackupConfigRetention extends LitElement {
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-list-item-base>
|
||||
<ha-list-item-base wrap-headline wrap-supporting-text>
|
||||
<span slot="headline">
|
||||
${
|
||||
this.headline ??
|
||||
@@ -256,11 +256,6 @@ class HaBackupConfigRetention extends LitElement {
|
||||
ha-row-item::part(end) {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
ha-list-item-base::part(headline),
|
||||
ha-list-item-base::part(supporting-text) {
|
||||
white-space: wrap;
|
||||
}
|
||||
ha-select {
|
||||
min-width: 210px;
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ class HaBackupConfigSchedule extends LitElement {
|
||||
|
||||
return html`
|
||||
<ha-list-base>
|
||||
<ha-list-item-base>
|
||||
<ha-list-item-base wrap-headline wrap-supporting-text>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.schedule.schedule"
|
||||
@@ -154,7 +154,7 @@ class HaBackupConfigSchedule extends LitElement {
|
||||
)}
|
||||
outlined
|
||||
>
|
||||
<ha-row-item class="days">
|
||||
<ha-row-item wrap-headline class="days">
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.schedule.backup_every"
|
||||
@@ -186,7 +186,7 @@ class HaBackupConfigSchedule extends LitElement {
|
||||
(data.recurrence === BackupScheduleRecurrence.CUSTOM_DAYS &&
|
||||
data.days.length > 0)
|
||||
? html`
|
||||
<ha-list-item-base>
|
||||
<ha-list-item-base wrap-headline wrap-supporting-text>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.schedule.time"
|
||||
@@ -231,7 +231,7 @@ class HaBackupConfigSchedule extends LitElement {
|
||||
)}
|
||||
outlined
|
||||
>
|
||||
<ha-row-item>
|
||||
<ha-row-item wrap-headline wrap-supporting-text>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.schedule.custom_time_label"
|
||||
@@ -266,7 +266,7 @@ class HaBackupConfigSchedule extends LitElement {
|
||||
${
|
||||
this.supervisor
|
||||
? html`
|
||||
<ha-list-item-base>
|
||||
<ha-list-item-base wrap-headline wrap-supporting-text>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.backup.schedule.update_preference.label`
|
||||
@@ -415,12 +415,6 @@ class HaBackupConfigSchedule extends LitElement {
|
||||
ha-list-base {
|
||||
--ha-row-item-padding-inline: 0;
|
||||
}
|
||||
ha-row-item::part(headline),
|
||||
ha-row-item::part(supporting-text),
|
||||
ha-list-item-base::part(headline),
|
||||
ha-list-item-base::part(supporting-text) {
|
||||
white-space: wrap;
|
||||
}
|
||||
ha-select {
|
||||
min-width: 210px;
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ class HaBackupOverviewBackups extends LitElement {
|
||||
return html`
|
||||
<ha-backup-summary-card .heading=${heading} .status=${status}>
|
||||
<ha-list-base>
|
||||
<ha-list-item-base>
|
||||
<ha-list-item-base wrap-headline>
|
||||
<ha-svg-icon slot="start" .path=${mdiBackupRestore}></ha-svg-icon>
|
||||
<span slot="headline" class=${headline === null ? "skeleton" : ""}
|
||||
>${headline}</span
|
||||
@@ -74,7 +74,7 @@ class HaBackupOverviewBackups extends LitElement {
|
||||
${
|
||||
description || description === null
|
||||
? html`
|
||||
<ha-list-item-base>
|
||||
<ha-list-item-base wrap-headline>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiCalendar}
|
||||
@@ -367,9 +367,6 @@ class HaBackupOverviewBackups extends LitElement {
|
||||
ha-list-item-base::part(start) {
|
||||
color: var(--ha-color-text-secondary);
|
||||
}
|
||||
ha-list-item-base::part(headline) {
|
||||
white-space: wrap;
|
||||
}
|
||||
span.skeleton {
|
||||
position: relative;
|
||||
display: block;
|
||||
|
||||
@@ -152,7 +152,7 @@ class HaConfigBackupDetails extends LitElement {
|
||||
${
|
||||
CLOUD_AGENT === this.agentId
|
||||
? html`
|
||||
<ha-row-item>
|
||||
<ha-row-item wrap-supporting-text>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.location.encryption.location_encrypted"
|
||||
@@ -179,7 +179,7 @@ class HaConfigBackupDetails extends LitElement {
|
||||
`
|
||||
: encrypted
|
||||
? html`
|
||||
<ha-row-item>
|
||||
<ha-row-item wrap-supporting-text>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.location.encryption.location_encrypted"
|
||||
@@ -213,7 +213,7 @@ class HaConfigBackupDetails extends LitElement {
|
||||
"ui.panel.config.backup.location.encryption.warning_encryption_turn_off_description"
|
||||
)}
|
||||
</ha-alert>
|
||||
<ha-row-item>
|
||||
<ha-row-item wrap-supporting-text>
|
||||
<span slot="headline">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.location.encryption.location_unencrypted"
|
||||
@@ -364,9 +364,6 @@ class HaConfigBackupDetails extends LitElement {
|
||||
ha-backup-data-picker {
|
||||
display: block;
|
||||
}
|
||||
ha-row-item::part(supporting-text) {
|
||||
white-space: wrap;
|
||||
}
|
||||
.dot {
|
||||
display: block;
|
||||
position: relative;
|
||||
|
||||
@@ -170,7 +170,7 @@ export class CloudRemotePref extends LitElement {
|
||||
>
|
||||
<div class="card-content">
|
||||
<ha-list-base>
|
||||
<ha-list-item-base>
|
||||
<ha-list-item-base wrap-supporting-text>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.remote.external_activation"
|
||||
@@ -187,7 +187,7 @@ export class CloudRemotePref extends LitElement {
|
||||
@change=${this._toggleAllowRemoteEnabledChanged}
|
||||
></ha-switch>
|
||||
</ha-list-item-base>
|
||||
<ha-list-item-base>
|
||||
<ha-list-item-base wrap-supporting-text>
|
||||
<span slot="headline"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.remote.certificate_info"
|
||||
@@ -311,9 +311,6 @@ export class CloudRemotePref extends LitElement {
|
||||
ha-list-base {
|
||||
--ha-row-item-padding-inline: 0;
|
||||
}
|
||||
ha-list-item-base::part(supporting-text) {
|
||||
white-space: normal;
|
||||
}
|
||||
ha-tip {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
|
||||
@@ -199,7 +199,7 @@ export class CloudWebhooks extends LitElement {
|
||||
|
||||
private _renderHookRow(entry: Webhook) {
|
||||
return html`
|
||||
<ha-row-item>
|
||||
<ha-row-item wrap-headline wrap-supporting-text>
|
||||
<span slot="headline"
|
||||
>${entry.name}
|
||||
${
|
||||
@@ -374,7 +374,6 @@ export class CloudWebhooks extends LitElement {
|
||||
}
|
||||
ha-row-item::part(headline),
|
||||
ha-row-item::part(supporting-text) {
|
||||
white-space: wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { dump } from "js-yaml";
|
||||
import type { AITaskStructure, GenDataTaskResult } from "../../../data/ai_task";
|
||||
import type { RoomType } from "../../../data/area_icon_suggestions";
|
||||
import { isRoomType, ROOM_TYPES } from "../../../data/area_icon_suggestions";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { SuggestWithAIGenerateTask } from "../../../components/ha-suggest-with-ai-button";
|
||||
import {
|
||||
@@ -14,6 +16,7 @@ export interface MetadataSuggestionResult {
|
||||
category?: string;
|
||||
labels?: string[];
|
||||
floor?: string;
|
||||
room?: RoomType;
|
||||
}
|
||||
|
||||
export type MetadataSuggestionDomain =
|
||||
@@ -25,6 +28,7 @@ export interface MetadataSuggestionInclude {
|
||||
categories?: boolean;
|
||||
labels?: boolean;
|
||||
floor?: boolean;
|
||||
room?: boolean;
|
||||
}
|
||||
|
||||
export const SUGGESTION_INCLUDE_DEFAULT: MetadataSuggestionInclude = {
|
||||
@@ -86,6 +90,7 @@ export async function generateMetadataSuggestionTask<T>(
|
||||
// providers reject a select/enum schema with an empty options list.
|
||||
const includeCategories = categoryOptions.length > 0;
|
||||
const includeFloor = floorOptions.length > 0;
|
||||
const includeRoom = Boolean(include.room) && domain === "area";
|
||||
|
||||
const structure: AITaskStructure = {
|
||||
...(include.name && {
|
||||
@@ -139,6 +144,20 @@ export async function generateMetadataSuggestionTask<T>(
|
||||
},
|
||||
},
|
||||
}),
|
||||
...(includeRoom && {
|
||||
room: {
|
||||
description: `The type of room that best matches the ${domain}`,
|
||||
required: false,
|
||||
selector: {
|
||||
select: {
|
||||
options: ROOM_TYPES.map((room) => ({
|
||||
value: room,
|
||||
label: room.replace(/_/g, " "),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const requestedParts = [
|
||||
@@ -147,6 +166,7 @@ export async function generateMetadataSuggestionTask<T>(
|
||||
includeCategories ? "a category" : null,
|
||||
include.labels ? "labels" : null,
|
||||
includeFloor ? "a floor" : null,
|
||||
includeRoom ? "a room type" : null,
|
||||
].filter((entry): entry is string => entry !== null);
|
||||
|
||||
const categoryLabels: string[] = [
|
||||
@@ -233,6 +253,10 @@ export async function processMetadataSuggestion(
|
||||
const processed: MetadataSuggestionResult = {
|
||||
name: include.name ? result.data.name : undefined,
|
||||
description: include.description ? result.data.description : undefined,
|
||||
room:
|
||||
include.room && result.data.room && isRoomType(result.data.room)
|
||||
? result.data.room
|
||||
: undefined,
|
||||
};
|
||||
|
||||
// Convert category name to ID
|
||||
|
||||
@@ -229,9 +229,9 @@ export class MCPPref extends LitElement {
|
||||
this._enabling = true;
|
||||
try {
|
||||
// The config flow only asks for confirmation, so submit it directly
|
||||
let step = await createConfigFlow(this.hass, MCP_SERVER_DOMAIN);
|
||||
let step = await createConfigFlow(this.hass.callApi, MCP_SERVER_DOMAIN);
|
||||
if (step.type === "form") {
|
||||
step = await handleConfigFlowStep(this.hass, step.flow_id, {});
|
||||
step = await handleConfigFlowStep(this.hass.callApi, step.flow_id, {});
|
||||
}
|
||||
if (step.type === "form") {
|
||||
showConfigFlowDialog(this, {
|
||||
|
||||
@@ -60,9 +60,10 @@ class HaConfigUpdates extends LitElement {
|
||||
})
|
||||
private _formatEntityName!: HomeAssistantFormatters["formatEntityName"];
|
||||
|
||||
private _renderUpdateProgress(entity: UpdateEntity) {
|
||||
private _renderUpdateProgress(entity: UpdateEntity, slot?: string) {
|
||||
if (entity.attributes.update_percentage != null) {
|
||||
return html`<ha-progress-ring
|
||||
slot=${ifDefined(slot)}
|
||||
size="small"
|
||||
.value=${entity.attributes.update_percentage}
|
||||
.label=${this._localize("ui.panel.config.updates.update_in_progress")}
|
||||
@@ -71,6 +72,7 @@ class HaConfigUpdates extends LitElement {
|
||||
|
||||
if (entity.attributes.in_progress) {
|
||||
return html`<ha-spinner
|
||||
slot=${ifDefined(slot)}
|
||||
size="small"
|
||||
.ariaLabel=${this._localize(
|
||||
"ui.panel.config.updates.update_in_progress"
|
||||
@@ -78,7 +80,7 @@ class HaConfigUpdates extends LitElement {
|
||||
></ha-spinner>`;
|
||||
}
|
||||
|
||||
return html`<ha-icon-next></ha-icon-next>`;
|
||||
return html`<ha-icon-next slot=${ifDefined(slot)}></ha-icon-next>`;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -150,9 +152,7 @@ class HaConfigUpdates extends LitElement {
|
||||
</span>
|
||||
${
|
||||
!this.narrow
|
||||
? html`<div slot="end">
|
||||
${this._renderUpdateProgress(entity)}
|
||||
</div>`
|
||||
? this._renderUpdateProgress(entity, "end")
|
||||
: nothing
|
||||
}
|
||||
</ha-list-item-button>
|
||||
|
||||
@@ -1173,6 +1173,9 @@ export class HaConfigDevicePage extends LitElement {
|
||||
buttons.push({
|
||||
action: async () => {
|
||||
const confirmed = await showConfirmationDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.devices.confirm_delete_title"
|
||||
),
|
||||
text:
|
||||
this._integrations(device, this.entries, this.manifests)
|
||||
.length > 1
|
||||
@@ -1188,7 +1191,7 @@ export class HaConfigDevicePage extends LitElement {
|
||||
: this.hass.localize(
|
||||
`ui.panel.config.devices.confirm_delete`
|
||||
),
|
||||
confirmText: this.hass.localize("ui.common.delete"),
|
||||
confirmText: this.hass.localize("ui.common.remove"),
|
||||
dismissText: this.hass.localize("ui.common.cancel"),
|
||||
destructive: true,
|
||||
});
|
||||
|
||||
@@ -474,6 +474,7 @@ export class HaConfigDeviceDashboard extends LitElement {
|
||||
name: computeDeviceNameDisplay(device, this.hass.localize),
|
||||
model:
|
||||
device.model ||
|
||||
device.model_id ||
|
||||
`<${localize("ui.panel.config.devices.data_table.unknown")}>`,
|
||||
manufacturer:
|
||||
device.manufacturer ||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { mdiBatteryHigh, mdiDelete, mdiPencil, mdiPlus } from "@mdi/js";
|
||||
import { mdiBatteryHigh, mdiDelete, mdiPlus } from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../../../common/dom/stop_propagation";
|
||||
import "../../../../components/automation/ha-automation-row";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/ha-icon-button";
|
||||
import "../../../../components/ha-settings-row";
|
||||
import type {
|
||||
BatterySourceTypeEnergyPreference,
|
||||
EnergyPreferences,
|
||||
@@ -24,10 +26,17 @@ import {
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { documentationUrl } from "../../../../util/documentation-url";
|
||||
import { showEnergySettingsBatteryDialog } from "../dialogs/show-dialogs-energy";
|
||||
import { showEnergySettingsDialog } from "../dialogs/show-dialogs-energy";
|
||||
import type { EnergyEditorConfig } from "../editors/energy-editor";
|
||||
import "../ha-energy-editor-page";
|
||||
import type { EnergyEditorPageConfig } from "../ha-energy-editor-page";
|
||||
import "./ha-energy-validation-result";
|
||||
import { energyCardStyles } from "./styles";
|
||||
|
||||
const isBatterySource = (
|
||||
source: EnergyPreferences["energy_sources"][number]
|
||||
): source is BatterySourceTypeEnergyPreference => source.type === "battery";
|
||||
|
||||
@customElement("ha-energy-battery-settings")
|
||||
export class EnergyBatterySettings extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -41,12 +50,29 @@ export class EnergyBatterySettings extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public validationResult?: EnergyPreferencesValidation;
|
||||
|
||||
/** Path of the list page, editors open on pages under it instead of dialogs */
|
||||
@property({ attribute: false }) public listPath?: string;
|
||||
|
||||
/** Shows the editor page of the item with this key, or `new` to add one */
|
||||
@property({ attribute: false }) public editKey?: string;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
if (this.listPath && this.editKey !== undefined) {
|
||||
return html`<ha-energy-editor-page
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.backPath=${this.listPath}
|
||||
.config=${this._editorPage(this.editKey)}
|
||||
></ha-energy-editor-page>`;
|
||||
}
|
||||
|
||||
const batterySources: BatterySourceTypeEnergyPreference[] = [];
|
||||
const batteryValidation: EnergyValidationIssue[][] = [];
|
||||
|
||||
this.preferences.energy_sources.forEach((source, idx) => {
|
||||
if (source.type !== "battery") {
|
||||
if (!isBatterySource(source)) {
|
||||
return;
|
||||
}
|
||||
batterySources.push(source);
|
||||
@@ -86,38 +112,36 @@ export class EnergyBatterySettings extends LitElement {
|
||||
${
|
||||
batterySources.length > 0
|
||||
? html`
|
||||
<div class="items-container">
|
||||
<div class="items">
|
||||
${batterySources.map((source) => {
|
||||
const toEntityState =
|
||||
this.hass.states[source.stat_energy_to];
|
||||
return html`
|
||||
<div class="row" .source=${source}>
|
||||
${
|
||||
toEntityState?.attributes.icon
|
||||
? html`<ha-icon
|
||||
.icon=${toEntityState.attributes.icon}
|
||||
></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
.path=${mdiBatteryHigh}
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
<div class="content">
|
||||
<span class="label"
|
||||
>${
|
||||
source.name ||
|
||||
getStatisticLabel(
|
||||
this.hass.states,
|
||||
this.hass.formatEntityName,
|
||||
source.stat_energy_from,
|
||||
this.statsMetadata?.[source.stat_energy_from]
|
||||
)
|
||||
}</span
|
||||
>
|
||||
<ha-card outlined>
|
||||
<ha-automation-row
|
||||
.source=${source}
|
||||
.href=${this._editorHref(source)}
|
||||
@click=${this.listPath ? nothing : this._editSource}
|
||||
>
|
||||
${
|
||||
source.name
|
||||
? nothing
|
||||
: html`
|
||||
<span class="label"
|
||||
toEntityState?.attributes.icon
|
||||
? html`<ha-icon
|
||||
slot="leading-icon"
|
||||
.icon=${toEntityState.attributes.icon}
|
||||
></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
slot="leading-icon"
|
||||
.path=${mdiBatteryHigh}
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
<div slot="header" class="row-header">
|
||||
<span class="label"
|
||||
>${this._sourceName(source)}</span
|
||||
>
|
||||
${
|
||||
source.name
|
||||
? nothing
|
||||
: html`<span class="label secondary"
|
||||
>${getStatisticLabel(
|
||||
this.hass.states,
|
||||
this.hass.formatEntityName,
|
||||
@@ -126,127 +150,207 @@ export class EnergyBatterySettings extends LitElement {
|
||||
source.stat_energy_to
|
||||
]
|
||||
)}</span
|
||||
>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.battery.edit_battery_system"
|
||||
)}
|
||||
@click=${this._editSource}
|
||||
.path=${mdiPencil}
|
||||
></ha-icon-button>
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.battery.delete_battery_system"
|
||||
)}
|
||||
@click=${this._deleteSource}
|
||||
.path=${mdiDelete}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
>`
|
||||
}
|
||||
</div>
|
||||
<ha-icon-button
|
||||
slot="icons"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.battery.delete_battery_system"
|
||||
)}
|
||||
.path=${mdiDelete}
|
||||
.source=${source}
|
||||
@click=${this._deleteSourceClicked}
|
||||
@keydown=${stopPropagation}
|
||||
></ha-icon-button>
|
||||
</ha-automation-row>
|
||||
</ha-card>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
: nothing
|
||||
}
|
||||
<div class="row">
|
||||
<ha-button @click=${this._addSource} appearance="filled" size="s">
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.battery.add_battery_system"
|
||||
)}</ha-button
|
||||
>
|
||||
</div>
|
||||
<ha-button
|
||||
.href=${this._editorHref()}
|
||||
@click=${this.listPath ? nothing : this._addSource}
|
||||
appearance="filled"
|
||||
size="s"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.battery.add_battery_system"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _sourceName(source: BatterySourceTypeEnergyPreference) {
|
||||
return (
|
||||
source.name ||
|
||||
getStatisticLabel(
|
||||
this.hass.states,
|
||||
this.hass.formatEntityName,
|
||||
source.stat_energy_from,
|
||||
this.statsMetadata?.[source.stat_energy_from]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private _addSource() {
|
||||
showEnergySettingsBatteryDialog(this, {
|
||||
statsMetadata: this.statsMetadata,
|
||||
battery_sources: this.preferences.energy_sources.filter(
|
||||
(src) => src.type === "battery"
|
||||
) as BatterySourceTypeEnergyPreference[],
|
||||
saveCallback: async (source) => {
|
||||
await this._savePreferences({
|
||||
...this.preferences,
|
||||
energy_sources: this.preferences.energy_sources.concat(source),
|
||||
});
|
||||
},
|
||||
});
|
||||
this._openEditor();
|
||||
}
|
||||
|
||||
private _editSource(ev) {
|
||||
const origSource: BatterySourceTypeEnergyPreference =
|
||||
ev.currentTarget.closest(".row").source;
|
||||
showEnergySettingsBatteryDialog(this, {
|
||||
statsMetadata: this.statsMetadata,
|
||||
source: { ...origSource },
|
||||
battery_sources: this.preferences.energy_sources.filter(
|
||||
(src) => src.type === "battery"
|
||||
) as BatterySourceTypeEnergyPreference[],
|
||||
saveCallback: async (newSource) => {
|
||||
await this._savePreferences({
|
||||
...this.preferences,
|
||||
energy_sources: this.preferences.energy_sources.map((src) =>
|
||||
src === origSource ? newSource : src
|
||||
),
|
||||
});
|
||||
},
|
||||
this._openEditor(ev.currentTarget.source);
|
||||
}
|
||||
|
||||
private _openEditor(source?: BatterySourceTypeEnergyPreference) {
|
||||
showEnergySettingsDialog(this, {
|
||||
editor: this._editorConfig(this.preferences, source),
|
||||
});
|
||||
}
|
||||
|
||||
private async _deleteSource(ev) {
|
||||
const sourceToDelete: BatterySourceTypeEnergyPreference =
|
||||
ev.currentTarget.closest(".row").source;
|
||||
private _editorHref(source?: BatterySourceTypeEnergyPreference) {
|
||||
return this.listPath
|
||||
? `${this.listPath}/battery/${source ? encodeURIComponent(source.stat_energy_from) : "new"}`
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private _editorConfig(
|
||||
preferences: EnergyPreferences,
|
||||
source?: BatterySourceTypeEnergyPreference
|
||||
): EnergyEditorConfig {
|
||||
return {
|
||||
kind: "battery",
|
||||
params: {
|
||||
statsMetadata: this.statsMetadata,
|
||||
source: source ? { ...source } : undefined,
|
||||
battery_sources: preferences.energy_sources.filter(isBatterySource),
|
||||
saveCallback: (newSource) => this._saveSource(newSource, source),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private _sourceIndex(source: BatterySourceTypeEnergyPreference) {
|
||||
const sources = this.preferences.energy_sources;
|
||||
const index = sources.indexOf(source);
|
||||
if (index !== -1) {
|
||||
return index;
|
||||
}
|
||||
// Saving replaces the preferences, after which only the statistic matches
|
||||
return sources.findIndex(
|
||||
(src) =>
|
||||
isBatterySource(src) && src.stat_energy_from === source.stat_energy_from
|
||||
);
|
||||
}
|
||||
|
||||
// Kept per item: a save replaces the preferences right before the page leaves
|
||||
private _editorPage = memoizeOne(
|
||||
(key: string): EnergyEditorPageConfig | undefined => {
|
||||
if (key === "new") {
|
||||
return this._pageConfig(this._editorConfig(this.preferences));
|
||||
}
|
||||
const source = this.preferences.energy_sources.find(
|
||||
(src): src is BatterySourceTypeEnergyPreference =>
|
||||
isBatterySource(src) && src.stat_energy_from === key
|
||||
);
|
||||
return (
|
||||
source &&
|
||||
this._pageConfig(this._editorConfig(this.preferences, source), source)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
private _pageConfig(
|
||||
editor: EnergyEditorConfig,
|
||||
source?: BatterySourceTypeEnergyPreference
|
||||
): EnergyEditorPageConfig {
|
||||
if (!source) {
|
||||
return {
|
||||
editor,
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.energy.battery.add_battery_system"
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
editor,
|
||||
title: this._sourceName(source),
|
||||
delete: {
|
||||
label: this.hass.localize(
|
||||
"ui.panel.config.energy.battery.delete_battery_system"
|
||||
),
|
||||
action: () => this._deleteSource(source),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async _saveSource(
|
||||
source: BatterySourceTypeEnergyPreference,
|
||||
original?: BatterySourceTypeEnergyPreference
|
||||
) {
|
||||
const sources = [...this.preferences.energy_sources];
|
||||
const index = original ? this._sourceIndex(original) : -1;
|
||||
if (index === -1) {
|
||||
sources.push(source);
|
||||
} else {
|
||||
sources[index] = source;
|
||||
}
|
||||
await this._savePreferences({
|
||||
...this.preferences,
|
||||
energy_sources: sources,
|
||||
});
|
||||
}
|
||||
|
||||
private _deleteSourceClicked(ev) {
|
||||
ev.stopPropagation();
|
||||
this._deleteSource(ev.currentTarget.source);
|
||||
}
|
||||
|
||||
private async _deleteSource(
|
||||
source: BatterySourceTypeEnergyPreference
|
||||
): Promise<boolean> {
|
||||
if (
|
||||
!(await showConfirmationDialog(this, {
|
||||
title: this.hass.localize("ui.panel.config.energy.delete_source"),
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const index = this._sourceIndex(source);
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await this._savePreferences({
|
||||
...this.preferences,
|
||||
energy_sources: this.preferences.energy_sources.filter(
|
||||
(source) => source !== sourceToDelete
|
||||
(_src, idx) => idx !== index
|
||||
),
|
||||
});
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, { title: `Failed to save config: ${err.message}` });
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize("ui.panel.config.energy.save_config_failed", {
|
||||
error: err.message,
|
||||
}),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async _savePreferences(preferences: EnergyPreferences) {
|
||||
const result = await saveEnergyPreferences(this.hass, preferences);
|
||||
fireEvent(this, "value-changed", { value: result });
|
||||
return result;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
energyCardStyles,
|
||||
css`
|
||||
.row {
|
||||
height: 58px;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
`,
|
||||
];
|
||||
return [haStyle, energyCardStyles];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,17 +3,20 @@ import {
|
||||
mdiDelete,
|
||||
mdiDragHorizontalVariant,
|
||||
mdiFire,
|
||||
mdiPencil,
|
||||
mdiPlus,
|
||||
mdiWater,
|
||||
} from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../../../common/dom/stop_propagation";
|
||||
import type { LocalizeKeys } from "../../../../common/translations/localize";
|
||||
import { computeAreaName } from "../../../../common/entity/compute_area_name";
|
||||
import { getEntityAreaId } from "../../../../common/entity/context/get_entity_context";
|
||||
import "../../../../components/automation/ha-automation-row";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-icon-button";
|
||||
@@ -42,10 +45,12 @@ import {
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { documentationUrl } from "../../../../util/documentation-url";
|
||||
import { showEnergySettingsDeviceWaterGasDialog } from "../dialogs/show-dialogs-energy";
|
||||
import { showEnergySettingsDialog } from "../dialogs/show-dialogs-energy";
|
||||
import type { EnergyEditorConfig } from "../editors/energy-editor";
|
||||
import "../ha-energy-editor-page";
|
||||
import type { EnergyEditorPageConfig } from "../ha-energy-editor-page";
|
||||
import "./ha-energy-validation-result";
|
||||
import { energyCardStyles } from "./styles";
|
||||
import type { LocalizeKeys } from "../../../../common/translations/localize";
|
||||
|
||||
const ICONS: Record<WaterGasResource, string> = {
|
||||
water: mdiWater,
|
||||
@@ -67,6 +72,14 @@ export class EnergyDeviceSettingsWaterGas extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public validationResult?: EnergyPreferencesValidation;
|
||||
|
||||
/** Path of the list page, editors open on pages under it instead of dialogs */
|
||||
@property({ attribute: false }) public listPath?: string;
|
||||
|
||||
/** Shows the editor page of the item with this key, or `new` to add one */
|
||||
@property({ attribute: false }) public editKey?: string;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
private get _devices(): DeviceConsumptionEnergyPreference[] {
|
||||
return getWaterGasDevices(this.preferences, this.resource);
|
||||
}
|
||||
@@ -87,15 +100,18 @@ export class EnergyDeviceSettingsWaterGas extends LitElement {
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
if (this.listPath && this.editKey !== undefined) {
|
||||
return html`<ha-energy-editor-page
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.backPath=${this.listPath}
|
||||
.config=${this._editorPage(this.editKey)}
|
||||
></ha-energy-editor-page>`;
|
||||
}
|
||||
|
||||
const devices = this._devices;
|
||||
const validation =
|
||||
this.validationResult?.[getWaterGasDevicesKey(this.resource)];
|
||||
const labels = computeEnergyDeviceLabels(
|
||||
this.hass.states,
|
||||
this.hass.formatEntityName,
|
||||
devices,
|
||||
this.statsMetadata
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
@@ -128,63 +144,40 @@ export class EnergyDeviceSettingsWaterGas extends LitElement {
|
||||
${
|
||||
devices.length > 0
|
||||
? html`
|
||||
<div class="items-container">
|
||||
<ha-sortable
|
||||
handle-selector=".handle"
|
||||
@item-moved=${this._itemMoved}
|
||||
>
|
||||
<div class="devices">
|
||||
${repeat(
|
||||
devices,
|
||||
(device) => device.stat_consumption,
|
||||
(device, index) => html`
|
||||
<div class="row" .device=${device}>
|
||||
<div class="handle">
|
||||
<ha-svg-icon
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
${this._renderName(device, labels)}
|
||||
${this._renderIssueIndicator(
|
||||
validation?.[index],
|
||||
index
|
||||
)}
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize("ui.common.edit")}
|
||||
@click=${this._editDevice}
|
||||
.path=${mdiPencil}
|
||||
></ha-icon-button>
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize("ui.common.delete")}
|
||||
@click=${this._deleteDevice}
|
||||
.device=${device}
|
||||
.path=${mdiDelete}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</ha-sortable>
|
||||
</div>
|
||||
<ha-sortable
|
||||
handle-selector=".handle"
|
||||
@item-moved=${this._itemMoved}
|
||||
>
|
||||
<div class="items">
|
||||
${repeat(
|
||||
devices,
|
||||
(device) => device.stat_consumption,
|
||||
(device, index) => this._renderDevice(device, index)
|
||||
)}
|
||||
</div>
|
||||
</ha-sortable>
|
||||
`
|
||||
: ""
|
||||
: nothing
|
||||
}
|
||||
<div class="row">
|
||||
<ha-button @click=${this._addDevice} appearance="filled" size="s">
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon
|
||||
>${this._t("add_device")}</ha-button
|
||||
>
|
||||
</div>
|
||||
<ha-button
|
||||
.href=${this._editorHref()}
|
||||
@click=${this.listPath ? nothing : this._addDevice}
|
||||
appearance="filled"
|
||||
size="s"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this._t("add_device")}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderName(
|
||||
private _renderDevice(
|
||||
device: DeviceConsumptionEnergyPreference,
|
||||
labels: Record<string, string>
|
||||
index: number
|
||||
) {
|
||||
const name = labels[device.stat_consumption];
|
||||
const entityState = this.hass.states[device.stat_consumption];
|
||||
const areaId = getEntityAreaId(
|
||||
device.stat_consumption,
|
||||
this.hass.entities,
|
||||
@@ -193,17 +186,64 @@ export class EnergyDeviceSettingsWaterGas extends LitElement {
|
||||
const area = areaId ? this.hass.areas[areaId] : undefined;
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
return html`
|
||||
<div class="content">
|
||||
<span class="label">${name}</span>
|
||||
${
|
||||
areaName
|
||||
? html`<span class="label secondary">${areaName}</span>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<ha-card outlined>
|
||||
<ha-automation-row
|
||||
.device=${device}
|
||||
.href=${this._editorHref(device)}
|
||||
@click=${this.listPath ? nothing : this._editDevice}
|
||||
>
|
||||
${
|
||||
entityState?.attributes.icon
|
||||
? html`<ha-icon
|
||||
slot="leading-icon"
|
||||
.icon=${entityState.attributes.icon}
|
||||
></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
slot="leading-icon"
|
||||
.path=${ICONS[this.resource]}
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
<div slot="header" class="row-header">
|
||||
<span class="label">${this._deviceName(device)}</span>
|
||||
${
|
||||
areaName
|
||||
? html`<span class="label secondary">${areaName}</span>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${this._renderIssueIndicator(
|
||||
this.validationResult?.[getWaterGasDevicesKey(this.resource)]?.[
|
||||
index
|
||||
],
|
||||
index
|
||||
)}
|
||||
<ha-icon-button
|
||||
slot="icons"
|
||||
.label=${this.hass.localize("ui.common.delete")}
|
||||
.path=${mdiDelete}
|
||||
.device=${device}
|
||||
@click=${this._deleteDeviceClicked}
|
||||
@keydown=${stopPropagation}
|
||||
></ha-icon-button>
|
||||
<div class="handle" slot="icons" @click=${stopPropagation}>
|
||||
<ha-svg-icon .path=${mdiDragHorizontalVariant}></ha-svg-icon>
|
||||
</div>
|
||||
</ha-automation-row>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _deviceLabels = memoizeOne(computeEnergyDeviceLabels);
|
||||
|
||||
private _deviceName(device: DeviceConsumptionEnergyPreference) {
|
||||
return this._deviceLabels(
|
||||
this.hass.states,
|
||||
this.hass.formatEntityName,
|
||||
this._devices,
|
||||
this.statsMetadata
|
||||
)[device.stat_consumption];
|
||||
}
|
||||
|
||||
private _renderIssueIndicator(
|
||||
issues: EnergyValidationIssue[] | undefined,
|
||||
index: number
|
||||
@@ -221,11 +261,12 @@ export class EnergyDeviceSettingsWaterGas extends LitElement {
|
||||
return html`
|
||||
<ha-svg-icon
|
||||
id=${id}
|
||||
slot="icons"
|
||||
class="issue-icon"
|
||||
.path=${mdiAlertCircle}
|
||||
aria-label=${label}
|
||||
></ha-svg-icon>
|
||||
<ha-tooltip .for=${id} placement="top">${label}</ha-tooltip>
|
||||
<ha-tooltip slot="icons" .for=${id} placement="top">${label}</ha-tooltip>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -241,6 +282,43 @@ export class EnergyDeviceSettingsWaterGas extends LitElement {
|
||||
this._savePreferences(newPrefs);
|
||||
}
|
||||
|
||||
private _addDevice() {
|
||||
this._openEditor();
|
||||
}
|
||||
|
||||
private _editDevice(ev) {
|
||||
this._openEditor(ev.currentTarget.device);
|
||||
}
|
||||
|
||||
private _openEditor(device?: DeviceConsumptionEnergyPreference) {
|
||||
showEnergySettingsDialog(this, {
|
||||
editor: this._editorConfig(this.preferences, device),
|
||||
});
|
||||
}
|
||||
|
||||
private _editorHref(device?: DeviceConsumptionEnergyPreference) {
|
||||
return this.listPath
|
||||
? `${this.listPath}/device/${device ? encodeURIComponent(device.stat_consumption) : "new"}`
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private _editorConfig(
|
||||
preferences: EnergyPreferences,
|
||||
device?: DeviceConsumptionEnergyPreference
|
||||
): EnergyEditorConfig {
|
||||
return {
|
||||
kind: "water_gas_device",
|
||||
params: {
|
||||
resource: this.resource,
|
||||
allowedUnitClass: this._allowedUnitClass(device?.stat_consumption),
|
||||
statsMetadata: this.statsMetadata,
|
||||
device: device ? { ...device } : undefined,
|
||||
device_consumptions: getWaterGasDevices(preferences, this.resource),
|
||||
saveCallback: (newDevice) => this._saveDevice(newDevice, device),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private _allowedUnitClass(excludeSource?: string) {
|
||||
return this.resource === "gas"
|
||||
? getEnergyGasUnitClass(
|
||||
@@ -252,36 +330,68 @@ export class EnergyDeviceSettingsWaterGas extends LitElement {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private _editDevice(ev) {
|
||||
const origDevice: DeviceConsumptionEnergyPreference =
|
||||
ev.currentTarget.closest(".row").device;
|
||||
showEnergySettingsDeviceWaterGasDialog(this, {
|
||||
resource: this.resource,
|
||||
statsMetadata: this.statsMetadata,
|
||||
device: { ...origDevice },
|
||||
allowedUnitClass: this._allowedUnitClass(origDevice.stat_consumption),
|
||||
device_consumptions: this._devices,
|
||||
saveCallback: async (newDevice) => {
|
||||
const newPrefs = this._withDevices(
|
||||
this._devices.map((d) => (d === origDevice ? newDevice : d))
|
||||
);
|
||||
await this._savePreferences(this._sanitizeParents(newPrefs));
|
||||
},
|
||||
});
|
||||
private _deviceIndex(device: DeviceConsumptionEnergyPreference) {
|
||||
const devices = this._devices;
|
||||
const index = devices.indexOf(device);
|
||||
if (index !== -1) {
|
||||
return index;
|
||||
}
|
||||
// Saving replaces the preferences, after which only the statistic matches
|
||||
return devices.findIndex(
|
||||
(d) => d.stat_consumption === device.stat_consumption
|
||||
);
|
||||
}
|
||||
|
||||
private _addDevice() {
|
||||
showEnergySettingsDeviceWaterGasDialog(this, {
|
||||
resource: this.resource,
|
||||
statsMetadata: this.statsMetadata,
|
||||
allowedUnitClass: this._allowedUnitClass(),
|
||||
device_consumptions: this._devices,
|
||||
saveCallback: async (device) => {
|
||||
await this._savePreferences(
|
||||
this._withDevices(this._devices.concat(device))
|
||||
);
|
||||
// Kept per item: a save replaces the preferences right before the page leaves
|
||||
private _editorPage = memoizeOne(
|
||||
(key: string): EnergyEditorPageConfig | undefined => {
|
||||
if (key === "new") {
|
||||
return this._pageConfig(this._editorConfig(this.preferences));
|
||||
}
|
||||
const device = this._devices.find(
|
||||
(item) => item.stat_consumption === key
|
||||
);
|
||||
return (
|
||||
device &&
|
||||
this._pageConfig(this._editorConfig(this.preferences, device), device)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
private _pageConfig(
|
||||
editor: EnergyEditorConfig,
|
||||
device?: DeviceConsumptionEnergyPreference
|
||||
): EnergyEditorPageConfig {
|
||||
if (!device) {
|
||||
return {
|
||||
editor,
|
||||
title: this._t("add_device"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
editor,
|
||||
title: this._deviceName(device),
|
||||
delete: {
|
||||
label: this.hass.localize("ui.common.delete"),
|
||||
action: () => this._deleteDevice(device),
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
private async _saveDevice(
|
||||
device: DeviceConsumptionEnergyPreference,
|
||||
original?: DeviceConsumptionEnergyPreference
|
||||
) {
|
||||
const devices = [...this._devices];
|
||||
const index = original ? this._deviceIndex(original) : -1;
|
||||
if (index === -1) {
|
||||
devices.push(device);
|
||||
} else {
|
||||
devices[index] = device;
|
||||
}
|
||||
await this._savePreferences(
|
||||
this._sanitizeParents(this._withDevices(devices))
|
||||
);
|
||||
}
|
||||
|
||||
private _sanitizeParents(prefs: EnergyPreferences): EnergyPreferences {
|
||||
@@ -297,33 +407,49 @@ export class EnergyDeviceSettingsWaterGas extends LitElement {
|
||||
} as EnergyPreferences;
|
||||
}
|
||||
|
||||
private async _deleteDevice(ev) {
|
||||
const deviceToDelete: DeviceConsumptionEnergyPreference =
|
||||
ev.currentTarget.device;
|
||||
private _deleteDeviceClicked(ev) {
|
||||
ev.stopPropagation();
|
||||
this._deleteDevice(ev.currentTarget.device);
|
||||
}
|
||||
|
||||
private async _deleteDevice(
|
||||
device: DeviceConsumptionEnergyPreference
|
||||
): Promise<boolean> {
|
||||
if (
|
||||
!(await showConfirmationDialog(this, {
|
||||
title: this.hass.localize("ui.panel.config.energy.delete_source"),
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const index = this._deviceIndex(device);
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const newPrefs = this._sanitizeParents(
|
||||
this._withDevices(
|
||||
this._devices.filter((device) => device !== deviceToDelete)
|
||||
await this._savePreferences(
|
||||
this._sanitizeParents(
|
||||
this._withDevices(
|
||||
this._devices.filter((_device, idx) => idx !== index)
|
||||
)
|
||||
)
|
||||
);
|
||||
await this._savePreferences(newPrefs);
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, { title: `Failed to save config: ${err.message}` });
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize("ui.panel.config.energy.save_config_failed", {
|
||||
error: err.message,
|
||||
}),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async _savePreferences(preferences: EnergyPreferences) {
|
||||
const result = await saveEnergyPreferences(this.hass, preferences);
|
||||
fireEvent(this, "value-changed", { value: result });
|
||||
return result;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
@@ -331,26 +457,6 @@ export class EnergyDeviceSettingsWaterGas extends LitElement {
|
||||
haStyle,
|
||||
energyCardStyles,
|
||||
css`
|
||||
.row {
|
||||
height: 58px;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.label.secondary {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.handle {
|
||||
cursor: move; /* fallback if grab cursor is unsupported */
|
||||
cursor: grab;
|
||||
}
|
||||
.issue-icon {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
@@ -3,16 +3,18 @@ import {
|
||||
mdiDelete,
|
||||
mdiDevices,
|
||||
mdiDragHorizontalVariant,
|
||||
mdiPencil,
|
||||
mdiPlus,
|
||||
} from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../../../common/dom/stop_propagation";
|
||||
import { computeAreaName } from "../../../../common/entity/compute_area_name";
|
||||
import { getEntityAreaId } from "../../../../common/entity/context/get_entity_context";
|
||||
import "../../../../components/automation/ha-automation-row";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-icon-button";
|
||||
@@ -37,7 +39,10 @@ import {
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { documentationUrl } from "../../../../util/documentation-url";
|
||||
import { showEnergySettingsDeviceDialog } from "../dialogs/show-dialogs-energy";
|
||||
import { showEnergySettingsDialog } from "../dialogs/show-dialogs-energy";
|
||||
import type { EnergyEditorConfig } from "../editors/energy-editor";
|
||||
import "../ha-energy-editor-page";
|
||||
import type { EnergyEditorPageConfig } from "../ha-energy-editor-page";
|
||||
import "./ha-energy-validation-result";
|
||||
import { energyCardStyles } from "./styles";
|
||||
|
||||
@@ -54,13 +59,23 @@ export class EnergyDeviceSettings extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public validationResult?: EnergyPreferencesValidation;
|
||||
|
||||
/** Path of the list page, editors open on pages under it instead of dialogs */
|
||||
@property({ attribute: false }) public listPath?: string;
|
||||
|
||||
/** Shows the editor page of the item with this key, or `new` to add one */
|
||||
@property({ attribute: false }) public editKey?: string;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const labels = computeEnergyDeviceLabels(
|
||||
this.hass.states,
|
||||
this.hass.formatEntityName,
|
||||
this.preferences.device_consumption,
|
||||
this.statsMetadata
|
||||
);
|
||||
if (this.listPath && this.editKey !== undefined) {
|
||||
return html`<ha-energy-editor-page
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.backPath=${this.listPath}
|
||||
.config=${this._editorPage(this.editKey)}
|
||||
></ha-energy-editor-page>`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-card>
|
||||
@@ -99,67 +114,42 @@ export class EnergyDeviceSettings extends LitElement {
|
||||
${
|
||||
this.preferences.device_consumption.length > 0
|
||||
? html`
|
||||
<div class="items-container">
|
||||
<ha-sortable
|
||||
handle-selector=".handle"
|
||||
@item-moved=${this._itemMoved}
|
||||
>
|
||||
<div class="devices">
|
||||
${repeat(
|
||||
this.preferences.device_consumption,
|
||||
(device) => device.stat_consumption,
|
||||
(device, index) => html`
|
||||
<div class="row" .device=${device}>
|
||||
<div class="handle">
|
||||
<ha-svg-icon
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
${this._renderName(device, labels)}
|
||||
${this._renderIssueIndicator(
|
||||
this.validationResult?.device_consumption[
|
||||
index
|
||||
],
|
||||
index
|
||||
)}
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize("ui.common.edit")}
|
||||
@click=${this._editDevice}
|
||||
.path=${mdiPencil}
|
||||
></ha-icon-button>
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize("ui.common.delete")}
|
||||
@click=${this._deleteDevice}
|
||||
.device=${device}
|
||||
.path=${mdiDelete}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</ha-sortable>
|
||||
</div>
|
||||
<ha-sortable
|
||||
handle-selector=".handle"
|
||||
@item-moved=${this._itemMoved}
|
||||
>
|
||||
<div class="items">
|
||||
${repeat(
|
||||
this.preferences.device_consumption,
|
||||
(device) => device.stat_consumption,
|
||||
(device, index) => this._renderDevice(device, index)
|
||||
)}
|
||||
</div>
|
||||
</ha-sortable>
|
||||
`
|
||||
: ""
|
||||
: nothing
|
||||
}
|
||||
<div class="row">
|
||||
<ha-button @click=${this._addDevice} appearance="filled" size="s">
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption.add_device"
|
||||
)}</ha-button
|
||||
>
|
||||
</div>
|
||||
<ha-button
|
||||
.href=${this._editorHref()}
|
||||
@click=${this.listPath ? nothing : this._addDevice}
|
||||
appearance="filled"
|
||||
size="s"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption.add_device"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderName(
|
||||
private _renderDevice(
|
||||
device: DeviceConsumptionEnergyPreference,
|
||||
labels: Record<string, string>
|
||||
index: number
|
||||
) {
|
||||
const name = labels[device.stat_consumption];
|
||||
const entityState = this.hass.states[device.stat_consumption];
|
||||
const areaId = getEntityAreaId(
|
||||
device.stat_consumption,
|
||||
this.hass.entities,
|
||||
@@ -168,17 +158,62 @@ export class EnergyDeviceSettings extends LitElement {
|
||||
const area = areaId ? this.hass.areas[areaId] : undefined;
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
return html`
|
||||
<div class="content">
|
||||
<span class="label">${name}</span>
|
||||
${
|
||||
areaName
|
||||
? html`<span class="label secondary">${areaName}</span>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<ha-card outlined>
|
||||
<ha-automation-row
|
||||
.device=${device}
|
||||
.href=${this._editorHref(device)}
|
||||
@click=${this.listPath ? nothing : this._editDevice}
|
||||
>
|
||||
${
|
||||
entityState?.attributes.icon
|
||||
? html`<ha-icon
|
||||
slot="leading-icon"
|
||||
.icon=${entityState.attributes.icon}
|
||||
></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
slot="leading-icon"
|
||||
.path=${mdiDevices}
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
<div slot="header" class="row-header">
|
||||
<span class="label">${this._deviceName(device)}</span>
|
||||
${
|
||||
areaName
|
||||
? html`<span class="label secondary">${areaName}</span>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${this._renderIssueIndicator(
|
||||
this.validationResult?.device_consumption[index],
|
||||
index
|
||||
)}
|
||||
<ha-icon-button
|
||||
slot="icons"
|
||||
.label=${this.hass.localize("ui.common.delete")}
|
||||
.path=${mdiDelete}
|
||||
.device=${device}
|
||||
@click=${this._deleteDeviceClicked}
|
||||
@keydown=${stopPropagation}
|
||||
></ha-icon-button>
|
||||
<div class="handle" slot="icons" @click=${stopPropagation}>
|
||||
<ha-svg-icon .path=${mdiDragHorizontalVariant}></ha-svg-icon>
|
||||
</div>
|
||||
</ha-automation-row>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _deviceLabels = memoizeOne(computeEnergyDeviceLabels);
|
||||
|
||||
private _deviceName(device: DeviceConsumptionEnergyPreference) {
|
||||
return this._deviceLabels(
|
||||
this.hass.states,
|
||||
this.hass.formatEntityName,
|
||||
this.preferences.device_consumption,
|
||||
this.statsMetadata
|
||||
)[device.stat_consumption];
|
||||
}
|
||||
|
||||
private _renderIssueIndicator(
|
||||
issues: EnergyValidationIssue[] | undefined,
|
||||
index: number
|
||||
@@ -196,11 +231,12 @@ export class EnergyDeviceSettings extends LitElement {
|
||||
return html`
|
||||
<ha-svg-icon
|
||||
id=${id}
|
||||
slot="icons"
|
||||
class="issue-icon"
|
||||
.path=${mdiAlertCircle}
|
||||
aria-label=${label}
|
||||
></ha-svg-icon>
|
||||
<ha-tooltip .for=${id} placement="top">${label}</ha-tooltip>
|
||||
<ha-tooltip slot="icons" .for=${id} placement="top">${label}</ha-tooltip>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -219,40 +255,105 @@ export class EnergyDeviceSettings extends LitElement {
|
||||
this._savePreferences(newPrefs);
|
||||
}
|
||||
|
||||
private _addDevice() {
|
||||
this._openEditor();
|
||||
}
|
||||
|
||||
private _editDevice(ev) {
|
||||
const origDevice: DeviceConsumptionEnergyPreference =
|
||||
ev.currentTarget.closest(".row").device;
|
||||
showEnergySettingsDeviceDialog(this, {
|
||||
statsMetadata: this.statsMetadata,
|
||||
device: { ...origDevice },
|
||||
device_consumptions: this.preferences
|
||||
.device_consumption as DeviceConsumptionEnergyPreference[],
|
||||
saveCallback: async (newDevice) => {
|
||||
const newPrefs = {
|
||||
...this.preferences,
|
||||
device_consumption: this.preferences.device_consumption.map((d) =>
|
||||
d === origDevice ? newDevice : d
|
||||
),
|
||||
};
|
||||
this._sanitizeParents(newPrefs);
|
||||
await this._savePreferences(newPrefs);
|
||||
},
|
||||
this._openEditor(ev.currentTarget.device);
|
||||
}
|
||||
|
||||
private _openEditor(device?: DeviceConsumptionEnergyPreference) {
|
||||
showEnergySettingsDialog(this, {
|
||||
editor: this._editorConfig(this.preferences, device),
|
||||
});
|
||||
}
|
||||
|
||||
private _addDevice() {
|
||||
showEnergySettingsDeviceDialog(this, {
|
||||
statsMetadata: this.statsMetadata,
|
||||
device_consumptions: this.preferences
|
||||
.device_consumption as DeviceConsumptionEnergyPreference[],
|
||||
saveCallback: async (device) => {
|
||||
await this._savePreferences({
|
||||
...this.preferences,
|
||||
device_consumption:
|
||||
this.preferences.device_consumption.concat(device),
|
||||
});
|
||||
private _editorHref(device?: DeviceConsumptionEnergyPreference) {
|
||||
return this.listPath
|
||||
? `${this.listPath}/device/${device ? encodeURIComponent(device.stat_consumption) : "new"}`
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private _editorConfig(
|
||||
preferences: EnergyPreferences,
|
||||
device?: DeviceConsumptionEnergyPreference
|
||||
): EnergyEditorConfig {
|
||||
return {
|
||||
kind: "device",
|
||||
params: {
|
||||
statsMetadata: this.statsMetadata,
|
||||
device: device ? { ...device } : undefined,
|
||||
device_consumptions: preferences.device_consumption,
|
||||
saveCallback: (newDevice) => this._saveDevice(newDevice, device),
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
private _deviceIndex(device: DeviceConsumptionEnergyPreference) {
|
||||
const devices = this.preferences.device_consumption;
|
||||
const index = devices.indexOf(device);
|
||||
if (index !== -1) {
|
||||
return index;
|
||||
}
|
||||
// Saving replaces the preferences, after which only the statistic matches
|
||||
return devices.findIndex(
|
||||
(d) => d.stat_consumption === device.stat_consumption
|
||||
);
|
||||
}
|
||||
|
||||
// Kept per item: a save replaces the preferences right before the page leaves
|
||||
private _editorPage = memoizeOne(
|
||||
(key: string): EnergyEditorPageConfig | undefined => {
|
||||
if (key === "new") {
|
||||
return this._pageConfig(this._editorConfig(this.preferences));
|
||||
}
|
||||
const device = this.preferences.device_consumption.find(
|
||||
(item) => item.stat_consumption === key
|
||||
);
|
||||
return (
|
||||
device &&
|
||||
this._pageConfig(this._editorConfig(this.preferences, device), device)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
private _pageConfig(
|
||||
editor: EnergyEditorConfig,
|
||||
device?: DeviceConsumptionEnergyPreference
|
||||
): EnergyEditorPageConfig {
|
||||
if (!device) {
|
||||
return {
|
||||
editor,
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.energy.device_consumption.add_device"
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
editor,
|
||||
title: this._deviceName(device),
|
||||
delete: {
|
||||
label: this.hass.localize("ui.common.delete"),
|
||||
action: () => this._deleteDevice(device),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async _saveDevice(
|
||||
device: DeviceConsumptionEnergyPreference,
|
||||
original?: DeviceConsumptionEnergyPreference
|
||||
) {
|
||||
const devices = [...this.preferences.device_consumption];
|
||||
const index = original ? this._deviceIndex(original) : -1;
|
||||
if (index === -1) {
|
||||
devices.push(device);
|
||||
} else {
|
||||
devices[index] = device;
|
||||
}
|
||||
const newPrefs = { ...this.preferences, device_consumption: devices };
|
||||
this._sanitizeParents(newPrefs);
|
||||
await this._savePreferences(newPrefs);
|
||||
}
|
||||
|
||||
private _sanitizeParents(prefs: EnergyPreferences) {
|
||||
@@ -264,35 +365,50 @@ export class EnergyDeviceSettings extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private async _deleteDevice(ev) {
|
||||
const deviceToDelete: DeviceConsumptionEnergyPreference =
|
||||
ev.currentTarget.device;
|
||||
private _deleteDeviceClicked(ev) {
|
||||
ev.stopPropagation();
|
||||
this._deleteDevice(ev.currentTarget.device);
|
||||
}
|
||||
|
||||
private async _deleteDevice(
|
||||
device: DeviceConsumptionEnergyPreference
|
||||
): Promise<boolean> {
|
||||
if (
|
||||
!(await showConfirmationDialog(this, {
|
||||
title: this.hass.localize("ui.panel.config.energy.delete_source"),
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const index = this._deviceIndex(device);
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const newPrefs = {
|
||||
...this.preferences,
|
||||
device_consumption: this.preferences.device_consumption.filter(
|
||||
(device) => device !== deviceToDelete
|
||||
(_device, idx) => idx !== index
|
||||
),
|
||||
};
|
||||
this._sanitizeParents(newPrefs);
|
||||
await this._savePreferences(newPrefs);
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, { title: `Failed to save config: ${err.message}` });
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize("ui.panel.config.energy.save_config_failed", {
|
||||
error: err.message,
|
||||
}),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async _savePreferences(preferences: EnergyPreferences) {
|
||||
const result = await saveEnergyPreferences(this.hass, preferences);
|
||||
fireEvent(this, "value-changed", { value: result });
|
||||
return result;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
@@ -300,26 +416,6 @@ export class EnergyDeviceSettings extends LitElement {
|
||||
haStyle,
|
||||
energyCardStyles,
|
||||
css`
|
||||
.row {
|
||||
height: 58px;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.label.secondary {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.handle {
|
||||
cursor: move; /* fallback if grab cursor is unsupported */
|
||||
cursor: grab;
|
||||
}
|
||||
.issue-icon {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { mdiDelete, mdiFire, mdiPencil, mdiPlus } from "@mdi/js";
|
||||
import { mdiDelete, mdiFire, mdiPlus } from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../../../common/dom/stop_propagation";
|
||||
import "../../../../components/automation/ha-automation-row";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-icon-button";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type {
|
||||
EnergyPreferences,
|
||||
EnergyPreferencesValidation,
|
||||
@@ -25,10 +29,17 @@ import {
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { documentationUrl } from "../../../../util/documentation-url";
|
||||
import { showEnergySettingsGasDialog } from "../dialogs/show-dialogs-energy";
|
||||
import { showEnergySettingsDialog } from "../dialogs/show-dialogs-energy";
|
||||
import type { EnergyEditorConfig } from "../editors/energy-editor";
|
||||
import "../ha-energy-editor-page";
|
||||
import type { EnergyEditorPageConfig } from "../ha-energy-editor-page";
|
||||
import "./ha-energy-validation-result";
|
||||
import { energyCardStyles } from "./styles";
|
||||
|
||||
const isGasSource = (
|
||||
source: EnergyPreferences["energy_sources"][number]
|
||||
): source is GasSourceTypeEnergyPreference => source.type === "gas";
|
||||
|
||||
@customElement("ha-energy-gas-settings")
|
||||
export class EnergyGasSettings extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -42,12 +53,29 @@ export class EnergyGasSettings extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public validationResult?: EnergyPreferencesValidation;
|
||||
|
||||
/** Path of the list page, editors open on pages under it instead of dialogs */
|
||||
@property({ attribute: false }) public listPath?: string;
|
||||
|
||||
/** Shows the editor page of the item with this key, or `new` to add one */
|
||||
@property({ attribute: false }) public editKey?: string;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
if (this.listPath && this.editKey !== undefined) {
|
||||
return html`<ha-energy-editor-page
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.backPath=${this.listPath}
|
||||
.config=${this._editorPage(this.editKey)}
|
||||
></ha-energy-editor-page>`;
|
||||
}
|
||||
|
||||
const gasSources: GasSourceTypeEnergyPreference[] = [];
|
||||
const gasValidation: EnergyValidationIssue[][] = [];
|
||||
|
||||
this.preferences.energy_sources.forEach((source, idx) => {
|
||||
if (source.type !== "gas") {
|
||||
if (!isGasSource(source)) {
|
||||
return;
|
||||
}
|
||||
gasSources.push(source);
|
||||
@@ -85,141 +113,230 @@ export class EnergyGasSettings extends LitElement {
|
||||
${
|
||||
gasSources.length > 0
|
||||
? html`
|
||||
<div class="items-container">
|
||||
<div class="items">
|
||||
${gasSources.map((source) => {
|
||||
const entityState =
|
||||
this.hass.states[source.stat_energy_from];
|
||||
return html`
|
||||
<div class="row" .source=${source}>
|
||||
${
|
||||
entityState?.attributes.icon
|
||||
? html`<ha-icon
|
||||
.icon=${entityState.attributes.icon}
|
||||
></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
.path=${mdiFire}
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
<span class="content"
|
||||
>${
|
||||
source.name ||
|
||||
getStatisticLabel(
|
||||
this.hass.states,
|
||||
this.hass.formatEntityName,
|
||||
source.stat_energy_from,
|
||||
this.statsMetadata?.[source.stat_energy_from]
|
||||
)
|
||||
}</span
|
||||
<ha-card outlined>
|
||||
<ha-automation-row
|
||||
.source=${source}
|
||||
.href=${this._editorHref(source)}
|
||||
@click=${this.listPath ? nothing : this._editSource}
|
||||
>
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.gas.edit_gas_source"
|
||||
)}
|
||||
@click=${this._editSource}
|
||||
.path=${mdiPencil}
|
||||
></ha-icon-button>
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.gas.delete_gas_source"
|
||||
)}
|
||||
@click=${this._deleteSource}
|
||||
.path=${mdiDelete}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
${
|
||||
entityState?.attributes.icon
|
||||
? html`<ha-icon
|
||||
slot="leading-icon"
|
||||
.icon=${entityState.attributes.icon}
|
||||
></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
slot="leading-icon"
|
||||
.path=${mdiFire}
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
<div slot="header" class="row-header">
|
||||
<span class="label"
|
||||
>${this._sourceName(source)}</span
|
||||
>
|
||||
</div>
|
||||
<ha-icon-button
|
||||
slot="icons"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.gas.delete_gas_source"
|
||||
)}
|
||||
.path=${mdiDelete}
|
||||
.source=${source}
|
||||
@click=${this._deleteSourceClicked}
|
||||
@keydown=${stopPropagation}
|
||||
></ha-icon-button>
|
||||
</ha-automation-row>
|
||||
</ha-card>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
: nothing
|
||||
}
|
||||
<div class="row">
|
||||
<ha-button @click=${this._addSource} appearance="filled" size="s">
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.energy.gas.add_gas_source"
|
||||
)}</ha-button
|
||||
>
|
||||
</div>
|
||||
<ha-button
|
||||
.href=${this._editorHref()}
|
||||
@click=${this.listPath ? nothing : this._addSource}
|
||||
appearance="filled"
|
||||
size="s"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize("ui.panel.config.energy.gas.add_gas_source")}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _sourceName(source: GasSourceTypeEnergyPreference) {
|
||||
return (
|
||||
source.name ||
|
||||
getStatisticLabel(
|
||||
this.hass.states,
|
||||
this.hass.formatEntityName,
|
||||
source.stat_energy_from,
|
||||
this.statsMetadata?.[source.stat_energy_from]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private _addSource() {
|
||||
showEnergySettingsGasDialog(this, {
|
||||
statsMetadata: this.statsMetadata,
|
||||
allowedGasUnitClass: getEnergyGasUnitClass(
|
||||
this.preferences,
|
||||
undefined,
|
||||
this.statsMetadata,
|
||||
"source"
|
||||
),
|
||||
gas_sources: this.preferences.energy_sources.filter(
|
||||
(src) => src.type === "gas"
|
||||
) as GasSourceTypeEnergyPreference[],
|
||||
saveCallback: async (source) => {
|
||||
delete source.unit_of_measurement;
|
||||
await this._savePreferences({
|
||||
...this.preferences,
|
||||
energy_sources: this.preferences.energy_sources.concat(source),
|
||||
});
|
||||
},
|
||||
});
|
||||
this._openEditor();
|
||||
}
|
||||
|
||||
private _editSource(ev) {
|
||||
const origSource: GasSourceTypeEnergyPreference =
|
||||
ev.currentTarget.closest(".row").source;
|
||||
showEnergySettingsGasDialog(this, {
|
||||
source: { ...origSource },
|
||||
statsMetadata: this.statsMetadata,
|
||||
allowedGasUnitClass: getEnergyGasUnitClass(
|
||||
this.preferences,
|
||||
origSource.stat_energy_from,
|
||||
this.statsMetadata,
|
||||
"source"
|
||||
),
|
||||
gas_sources: this.preferences.energy_sources.filter(
|
||||
(src) => src.type === "gas"
|
||||
) as GasSourceTypeEnergyPreference[],
|
||||
saveCallback: async (newSource) => {
|
||||
await this._savePreferences({
|
||||
...this.preferences,
|
||||
energy_sources: this.preferences.energy_sources.map((src) =>
|
||||
src === origSource ? newSource : src
|
||||
),
|
||||
});
|
||||
},
|
||||
this._openEditor(ev.currentTarget.source);
|
||||
}
|
||||
|
||||
private _openEditor(source?: GasSourceTypeEnergyPreference) {
|
||||
showEnergySettingsDialog(this, {
|
||||
editor: this._editorConfig(this.preferences, source),
|
||||
});
|
||||
}
|
||||
|
||||
private async _deleteSource(ev) {
|
||||
const sourceToDelete: GasSourceTypeEnergyPreference =
|
||||
ev.currentTarget.closest(".row").source;
|
||||
private _editorHref(source?: GasSourceTypeEnergyPreference) {
|
||||
return this.listPath
|
||||
? `${this.listPath}/gas/${source ? encodeURIComponent(source.stat_energy_from) : "new"}`
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private _editorConfig(
|
||||
preferences: EnergyPreferences,
|
||||
source?: GasSourceTypeEnergyPreference
|
||||
): EnergyEditorConfig {
|
||||
return {
|
||||
kind: "gas",
|
||||
params: {
|
||||
source: source ? { ...source } : undefined,
|
||||
statsMetadata: this.statsMetadata,
|
||||
allowedGasUnitClass: getEnergyGasUnitClass(
|
||||
preferences,
|
||||
source?.stat_energy_from,
|
||||
this.statsMetadata,
|
||||
"source"
|
||||
),
|
||||
gas_sources: preferences.energy_sources.filter(isGasSource),
|
||||
saveCallback: (newSource) => this._saveSource(newSource, source),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private _sourceIndex(source: GasSourceTypeEnergyPreference) {
|
||||
const sources = this.preferences.energy_sources;
|
||||
const index = sources.indexOf(source);
|
||||
if (index !== -1) {
|
||||
return index;
|
||||
}
|
||||
// Saving replaces the preferences, after which only the statistic matches
|
||||
return sources.findIndex(
|
||||
(src) =>
|
||||
isGasSource(src) && src.stat_energy_from === source.stat_energy_from
|
||||
);
|
||||
}
|
||||
|
||||
// Kept per item: a save replaces the preferences right before the page leaves
|
||||
private _editorPage = memoizeOne(
|
||||
(key: string): EnergyEditorPageConfig | undefined => {
|
||||
if (key === "new") {
|
||||
return this._pageConfig(this._editorConfig(this.preferences));
|
||||
}
|
||||
const source = this.preferences.energy_sources.find(
|
||||
(src): src is GasSourceTypeEnergyPreference =>
|
||||
isGasSource(src) && src.stat_energy_from === key
|
||||
);
|
||||
return (
|
||||
source &&
|
||||
this._pageConfig(this._editorConfig(this.preferences, source), source)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
private _pageConfig(
|
||||
editor: EnergyEditorConfig,
|
||||
source?: GasSourceTypeEnergyPreference
|
||||
): EnergyEditorPageConfig {
|
||||
if (!source) {
|
||||
return {
|
||||
editor,
|
||||
title: this.hass.localize("ui.panel.config.energy.gas.add_gas_source"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
editor,
|
||||
title: this._sourceName(source),
|
||||
delete: {
|
||||
label: this.hass.localize(
|
||||
"ui.panel.config.energy.gas.delete_gas_source"
|
||||
),
|
||||
action: () => this._deleteSource(source),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async _saveSource(
|
||||
source: GasSourceTypeEnergyPreference,
|
||||
original?: GasSourceTypeEnergyPreference
|
||||
) {
|
||||
const sources = [...this.preferences.energy_sources];
|
||||
const index = original ? this._sourceIndex(original) : -1;
|
||||
if (index === -1) {
|
||||
delete source.unit_of_measurement;
|
||||
sources.push(source);
|
||||
} else {
|
||||
sources[index] = source;
|
||||
}
|
||||
await this._savePreferences({
|
||||
...this.preferences,
|
||||
energy_sources: sources,
|
||||
});
|
||||
}
|
||||
|
||||
private _deleteSourceClicked(ev) {
|
||||
ev.stopPropagation();
|
||||
this._deleteSource(ev.currentTarget.source);
|
||||
}
|
||||
|
||||
private async _deleteSource(
|
||||
source: GasSourceTypeEnergyPreference
|
||||
): Promise<boolean> {
|
||||
if (
|
||||
!(await showConfirmationDialog(this, {
|
||||
title: this.hass.localize("ui.panel.config.energy.delete_source"),
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const index = this._sourceIndex(source);
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await this._savePreferences({
|
||||
...this.preferences,
|
||||
energy_sources: this.preferences.energy_sources.filter(
|
||||
(source) => source !== sourceToDelete
|
||||
(_src, idx) => idx !== index
|
||||
),
|
||||
});
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, { title: `Failed to save config: ${err.message}` });
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize("ui.panel.config.energy.save_config_failed", {
|
||||
error: err.message,
|
||||
}),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async _savePreferences(preferences: EnergyPreferences) {
|
||||
const result = await saveEnergyPreferences(this.hass, preferences);
|
||||
fireEvent(this, "value-changed", { value: result });
|
||||
return result;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { mdiDelete, mdiPencil, mdiPlus, mdiTransmissionTower } from "@mdi/js";
|
||||
import { mdiDelete, mdiPlus, mdiTransmissionTower } from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../../../common/dom/stop_propagation";
|
||||
import "../../../../components/automation/ha-automation-row";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-icon-button";
|
||||
@@ -30,10 +33,21 @@ import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { brandsUrl } from "../../../../util/brands-url";
|
||||
import { documentationUrl } from "../../../../util/documentation-url";
|
||||
import { showEnergySettingsGridDialog } from "../dialogs/show-dialogs-energy";
|
||||
import { showEnergySettingsDialog } from "../dialogs/show-dialogs-energy";
|
||||
import type { EnergyEditorConfig } from "../editors/energy-editor";
|
||||
import "../ha-energy-editor-page";
|
||||
import type { EnergyEditorPageConfig } from "../ha-energy-editor-page";
|
||||
import "./ha-energy-validation-result";
|
||||
import { energyCardStyles } from "./styles";
|
||||
|
||||
const isGridSource = (
|
||||
source: EnergyPreferences["energy_sources"][number]
|
||||
): source is GridSourceTypeEnergyPreference => source.type === "grid";
|
||||
|
||||
// At least one of import/export/power must exist (enforced by validation)
|
||||
const primaryStat = (source: GridSourceTypeEnergyPreference) =>
|
||||
(source.stat_energy_from || source.stat_energy_to || source.stat_rate)!;
|
||||
|
||||
@customElement("ha-energy-grid-settings")
|
||||
export class EnergyGridSettings extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -47,6 +61,14 @@ export class EnergyGridSettings extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public validationResult?: EnergyPreferencesValidation;
|
||||
|
||||
/** Path of the list page, editors open on pages under it instead of dialogs */
|
||||
@property({ attribute: false }) public listPath?: string;
|
||||
|
||||
/** Shows the editor page of the item with this key, or `new` to add one */
|
||||
@property({ attribute: false }) public editKey?: string;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@state() private _co2ConfigEntry?: ConfigEntry;
|
||||
|
||||
protected firstUpdated() {
|
||||
@@ -54,11 +76,20 @@ export class EnergyGridSettings extends LitElement {
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
if (this.listPath && this.editKey !== undefined) {
|
||||
return html`<ha-energy-editor-page
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.backPath=${this.listPath}
|
||||
.config=${this._editorPage(this.editKey)}
|
||||
></ha-energy-editor-page>`;
|
||||
}
|
||||
|
||||
const gridSources: GridSourceTypeEnergyPreference[] = [];
|
||||
const gridValidation: EnergyValidationIssue[][] = [];
|
||||
|
||||
this.preferences.energy_sources.forEach((source, idx) => {
|
||||
if (source.type !== "grid") {
|
||||
if (!isGridSource(source)) {
|
||||
return;
|
||||
}
|
||||
gridSources.push(source);
|
||||
@@ -107,82 +138,76 @@ export class EnergyGridSettings extends LitElement {
|
||||
${
|
||||
gridSources.length > 0
|
||||
? html`
|
||||
<div class="items-container">
|
||||
${gridSources.map((source, idx) => {
|
||||
// At least one of import/export/power must exist (enforced by validation)
|
||||
const primaryStat = (source.stat_energy_from ||
|
||||
source.stat_energy_to ||
|
||||
source.stat_rate)!;
|
||||
const primaryEntityState = this.hass.states[primaryStat];
|
||||
<div class="items">
|
||||
${gridSources.map((source) => {
|
||||
const stat = primaryStat(source);
|
||||
const entityState = this.hass.states[stat];
|
||||
return html`
|
||||
<div class="row" .source=${source} .sourceIndex=${idx}>
|
||||
${
|
||||
primaryEntityState?.attributes.icon
|
||||
? html`<ha-icon
|
||||
.icon=${primaryEntityState.attributes.icon}
|
||||
></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
.path=${mdiTransmissionTower}
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
<div class="content">
|
||||
<span class="label"
|
||||
>${
|
||||
source.name ||
|
||||
getStatisticLabel(
|
||||
this.hass.states,
|
||||
this.hass.formatEntityName,
|
||||
primaryStat,
|
||||
this.statsMetadata?.[primaryStat]
|
||||
)
|
||||
}</span
|
||||
>
|
||||
<ha-card outlined>
|
||||
<ha-automation-row
|
||||
.source=${source}
|
||||
.href=${this._editorHref(source)}
|
||||
@click=${this.listPath ? nothing : this._editSource}
|
||||
>
|
||||
${
|
||||
source.stat_energy_from &&
|
||||
source.stat_energy_to &&
|
||||
!source.name
|
||||
? html`<span class="label secondary"
|
||||
>${getStatisticLabel(
|
||||
this.hass.states,
|
||||
this.hass.formatEntityName,
|
||||
source.stat_energy_to,
|
||||
this.statsMetadata?.[
|
||||
source.stat_energy_to
|
||||
]
|
||||
)}</span
|
||||
>`
|
||||
: nothing
|
||||
entityState?.attributes.icon
|
||||
? html`<ha-icon
|
||||
slot="leading-icon"
|
||||
.icon=${entityState.attributes.icon}
|
||||
></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
slot="leading-icon"
|
||||
.path=${mdiTransmissionTower}
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
</div>
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.grid.edit_connection"
|
||||
)}
|
||||
@click=${this._editSource}
|
||||
.path=${mdiPencil}
|
||||
></ha-icon-button>
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.grid.delete_connection"
|
||||
)}
|
||||
@click=${this._deleteSource}
|
||||
.path=${mdiDelete}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
<div slot="header" class="row-header">
|
||||
<span class="label"
|
||||
>${this._sourceName(source)}</span
|
||||
>
|
||||
${
|
||||
source.stat_energy_from &&
|
||||
source.stat_energy_to &&
|
||||
!source.name
|
||||
? html`<span class="label secondary"
|
||||
>${getStatisticLabel(
|
||||
this.hass.states,
|
||||
this.hass.formatEntityName,
|
||||
source.stat_energy_to,
|
||||
this.statsMetadata?.[
|
||||
source.stat_energy_to
|
||||
]
|
||||
)}</span
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<ha-icon-button
|
||||
slot="icons"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.grid.delete_connection"
|
||||
)}
|
||||
.path=${mdiDelete}
|
||||
.source=${source}
|
||||
@click=${this._deleteSourceClicked}
|
||||
@keydown=${stopPropagation}
|
||||
></ha-icon-button>
|
||||
</ha-automation-row>
|
||||
</ha-card>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<div class="row">
|
||||
<ha-button @click=${this._addSource} appearance="filled" size="s">
|
||||
<ha-svg-icon .path=${mdiPlus} slot="start"></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.grid.add_connection"
|
||||
)}</ha-button
|
||||
>
|
||||
</div>
|
||||
<ha-button
|
||||
.href=${this._editorHref()}
|
||||
@click=${this.listPath ? nothing : this._addSource}
|
||||
appearance="filled"
|
||||
size="s"
|
||||
>
|
||||
<ha-svg-icon .path=${mdiPlus} slot="start"></ha-svg-icon>
|
||||
${this.hass.localize("ui.panel.config.energy.grid.add_connection")}
|
||||
</ha-button>
|
||||
|
||||
<h3>
|
||||
${this.hass.localize(
|
||||
@@ -192,50 +217,54 @@ export class EnergyGridSettings extends LitElement {
|
||||
${
|
||||
this._co2ConfigEntry
|
||||
? html`
|
||||
<div class="items-container">
|
||||
<div class="row" .entry=${this._co2ConfigEntry}>
|
||||
<img
|
||||
alt=""
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain: "co2signal",
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
/>
|
||||
<span class="content">${this._co2ConfigEntry.title}</span>
|
||||
<ha-icon-button
|
||||
.path=${mdiPencil}
|
||||
href=${`/config/integrations/integration/${this._co2ConfigEntry?.domain}`}
|
||||
<div class="items">
|
||||
<ha-card outlined>
|
||||
<ha-automation-row
|
||||
.href=${`/config/integrations/integration/${this._co2ConfigEntry.domain}`}
|
||||
>
|
||||
</ha-icon-button>
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.grid.remove_co2_signal"
|
||||
)}
|
||||
@click=${this._removeCO2Sensor}
|
||||
.path=${mdiDelete}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
<img
|
||||
slot="leading-icon"
|
||||
alt=""
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain: "co2signal",
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
/>
|
||||
<div slot="header" class="row-header">
|
||||
<span class="label"
|
||||
>${this._co2ConfigEntry.title}</span
|
||||
>
|
||||
</div>
|
||||
<ha-icon-button
|
||||
slot="icons"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.grid.remove_co2_signal"
|
||||
)}
|
||||
.path=${mdiDelete}
|
||||
@click=${this._removeCO2Sensor}
|
||||
@keydown=${stopPropagation}
|
||||
></ha-icon-button>
|
||||
</ha-automation-row>
|
||||
</ha-card>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="row">
|
||||
<ha-button
|
||||
@click=${this._addCO2Sensor}
|
||||
appearance="filled"
|
||||
size="s"
|
||||
>
|
||||
<ha-svg-icon .path=${mdiPlus} slot="start"></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.grid.add_co2_signal"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
<ha-button
|
||||
@click=${this._addCO2Sensor}
|
||||
appearance="filled"
|
||||
size="s"
|
||||
>
|
||||
<ha-svg-icon .path=${mdiPlus} slot="start"></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.grid.add_co2_signal"
|
||||
)}
|
||||
</ha-button>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
@@ -243,6 +272,19 @@ export class EnergyGridSettings extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _sourceName(source: GridSourceTypeEnergyPreference) {
|
||||
const stat = primaryStat(source);
|
||||
return (
|
||||
source.name ||
|
||||
getStatisticLabel(
|
||||
this.hass.states,
|
||||
this.hass.formatEntityName,
|
||||
stat,
|
||||
this.statsMetadata?.[stat]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private async _fetchCO2SignalConfigEntries() {
|
||||
const entries = await getConfigEntries(this.hass, { domain: "co2signal" });
|
||||
this._co2ConfigEntry = entries.length ? entries[0] : undefined;
|
||||
@@ -257,8 +299,8 @@ export class EnergyGridSettings extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private async _removeCO2Sensor(ev) {
|
||||
const entryId = ev.currentTarget.closest(".row").entry.entry_id;
|
||||
private async _removeCO2Sensor(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
if (
|
||||
!(await showConfirmationDialog(this, {
|
||||
title: this.hass.localize("ui.panel.config.energy.delete_integration"),
|
||||
@@ -267,114 +309,160 @@ export class EnergyGridSettings extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
await deleteConfigEntry(this.hass, entryId);
|
||||
await deleteConfigEntry(this.hass, this._co2ConfigEntry!.entry_id);
|
||||
this._fetchCO2SignalConfigEntries();
|
||||
}
|
||||
|
||||
private _getGridSources(): GridSourceTypeEnergyPreference[] {
|
||||
return this.preferences.energy_sources.filter(
|
||||
(src): src is GridSourceTypeEnergyPreference => src.type === "grid"
|
||||
);
|
||||
}
|
||||
|
||||
private _addSource() {
|
||||
showEnergySettingsGridDialog(this, {
|
||||
statsMetadata: this.statsMetadata,
|
||||
grid_sources: this._getGridSources(),
|
||||
saveCallback: async (source) => {
|
||||
const preferences: EnergyPreferences = {
|
||||
...this.preferences,
|
||||
energy_sources: [...this.preferences.energy_sources, source],
|
||||
};
|
||||
await this._savePreferences(preferences);
|
||||
},
|
||||
});
|
||||
this._openEditor();
|
||||
}
|
||||
|
||||
private _editSource(ev) {
|
||||
const row = ev.currentTarget.closest(".row");
|
||||
const origSource: GridSourceTypeEnergyPreference = row.source;
|
||||
const sourceIndex: number = row.sourceIndex;
|
||||
this._openEditor(ev.currentTarget.source);
|
||||
}
|
||||
|
||||
showEnergySettingsGridDialog(this, {
|
||||
statsMetadata: this.statsMetadata,
|
||||
source: { ...origSource },
|
||||
grid_sources: this._getGridSources(),
|
||||
saveCallback: async (newSource) => {
|
||||
const nonGridSources = this.preferences.energy_sources.filter(
|
||||
(src) => src.type !== "grid"
|
||||
);
|
||||
const updatedGrids = this._getGridSources().map((src, idx) =>
|
||||
idx === sourceIndex ? newSource : src
|
||||
);
|
||||
|
||||
const preferences: EnergyPreferences = {
|
||||
...this.preferences,
|
||||
energy_sources: [...nonGridSources, ...updatedGrids],
|
||||
};
|
||||
await this._savePreferences(preferences);
|
||||
},
|
||||
private _openEditor(source?: GridSourceTypeEnergyPreference) {
|
||||
showEnergySettingsDialog(this, {
|
||||
editor: this._editorConfig(this.preferences, source),
|
||||
});
|
||||
}
|
||||
|
||||
private async _deleteSource(ev) {
|
||||
const row = ev.currentTarget.closest(".row");
|
||||
const sourceIndex: number = row.sourceIndex;
|
||||
private _editorHref(source?: GridSourceTypeEnergyPreference) {
|
||||
return this.listPath
|
||||
? `${this.listPath}/grid/${source ? encodeURIComponent(primaryStat(source)) : "new"}`
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private _editorConfig(
|
||||
preferences: EnergyPreferences,
|
||||
source?: GridSourceTypeEnergyPreference
|
||||
): EnergyEditorConfig {
|
||||
return {
|
||||
kind: "grid",
|
||||
params: {
|
||||
statsMetadata: this.statsMetadata,
|
||||
source: source ? { ...source } : undefined,
|
||||
grid_sources: preferences.energy_sources.filter(isGridSource),
|
||||
saveCallback: (newSource) => this._saveSource(newSource, source),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private _sourceIndex(source: GridSourceTypeEnergyPreference) {
|
||||
const sources = this.preferences.energy_sources;
|
||||
const index = sources.indexOf(source);
|
||||
if (index !== -1) {
|
||||
return index;
|
||||
}
|
||||
// Saving replaces the preferences, after which only the statistic matches
|
||||
const stat = primaryStat(source);
|
||||
return sources.findIndex(
|
||||
(src) => isGridSource(src) && primaryStat(src) === stat
|
||||
);
|
||||
}
|
||||
|
||||
// Kept per item: a save replaces the preferences right before the page leaves
|
||||
private _editorPage = memoizeOne(
|
||||
(key: string): EnergyEditorPageConfig | undefined => {
|
||||
if (key === "new") {
|
||||
return this._pageConfig(this._editorConfig(this.preferences));
|
||||
}
|
||||
const source = this.preferences.energy_sources.find(
|
||||
(src): src is GridSourceTypeEnergyPreference =>
|
||||
isGridSource(src) && primaryStat(src) === key
|
||||
);
|
||||
return (
|
||||
source &&
|
||||
this._pageConfig(this._editorConfig(this.preferences, source), source)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
private _pageConfig(
|
||||
editor: EnergyEditorConfig,
|
||||
source?: GridSourceTypeEnergyPreference
|
||||
): EnergyEditorPageConfig {
|
||||
if (!source) {
|
||||
return {
|
||||
editor,
|
||||
title: this.hass.localize("ui.panel.config.energy.grid.add_connection"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
editor,
|
||||
title: this._sourceName(source),
|
||||
delete: {
|
||||
label: this.hass.localize(
|
||||
"ui.panel.config.energy.grid.delete_connection"
|
||||
),
|
||||
action: () => this._deleteSource(source),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async _saveSource(
|
||||
source: GridSourceTypeEnergyPreference,
|
||||
original?: GridSourceTypeEnergyPreference
|
||||
) {
|
||||
const sources = [...this.preferences.energy_sources];
|
||||
const index = original ? this._sourceIndex(original) : -1;
|
||||
if (index === -1) {
|
||||
sources.push(source);
|
||||
} else {
|
||||
sources[index] = source;
|
||||
}
|
||||
await this._savePreferences({
|
||||
...this.preferences,
|
||||
energy_sources: sources,
|
||||
});
|
||||
}
|
||||
|
||||
private _deleteSourceClicked(ev) {
|
||||
ev.stopPropagation();
|
||||
this._deleteSource(ev.currentTarget.source);
|
||||
}
|
||||
|
||||
private async _deleteSource(
|
||||
source: GridSourceTypeEnergyPreference
|
||||
): Promise<boolean> {
|
||||
if (
|
||||
!(await showConfirmationDialog(this, {
|
||||
title: this.hass.localize("ui.panel.config.energy.delete_source"),
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const nonGridSources = this.preferences.energy_sources.filter(
|
||||
(src) => src.type !== "grid"
|
||||
);
|
||||
const updatedGrids = this._getGridSources().filter(
|
||||
(_, idx) => idx !== sourceIndex
|
||||
);
|
||||
|
||||
const preferences: EnergyPreferences = {
|
||||
...this.preferences,
|
||||
energy_sources: [...nonGridSources, ...updatedGrids],
|
||||
};
|
||||
await this._savePreferences(preferences);
|
||||
const index = this._sourceIndex(source);
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await this._savePreferences({
|
||||
...this.preferences,
|
||||
energy_sources: this.preferences.energy_sources.filter(
|
||||
(_src, idx) => idx !== index
|
||||
),
|
||||
});
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize("ui.panel.config.energy.save_config_failed", {
|
||||
error: err.message,
|
||||
}),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async _savePreferences(preferences: EnergyPreferences) {
|
||||
try {
|
||||
const result = await saveEnergyPreferences(this.hass, preferences);
|
||||
fireEvent(this, "value-changed", { value: result });
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, { title: `Failed to save config: ${err.message}` });
|
||||
}
|
||||
const result = await saveEnergyPreferences(this.hass, preferences);
|
||||
fireEvent(this, "value-changed", { value: result });
|
||||
return result;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
energyCardStyles,
|
||||
css`
|
||||
.row {
|
||||
height: 58px;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.label.secondary {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
`,
|
||||
];
|
||||
return [haStyle, energyCardStyles];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { mdiDelete, mdiHome, mdiPencil, mdiPlus } from "@mdi/js";
|
||||
import { mdiDelete, mdiHome, mdiPlus } from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../../../common/dom/stop_propagation";
|
||||
import "../../../../components/automation/ha-automation-row";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-icon-button";
|
||||
@@ -26,10 +29,17 @@ import {
|
||||
} from "../../../../dialogs/generic/show-dialog-box";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { showEnergySettingsHomeDialog } from "../dialogs/show-dialogs-energy";
|
||||
import { showEnergySettingsDialog } from "../dialogs/show-dialogs-energy";
|
||||
import type { EnergyEditorConfig } from "../editors/energy-editor";
|
||||
import "../ha-energy-editor-page";
|
||||
import type { EnergyEditorPageConfig } from "../ha-energy-editor-page";
|
||||
import "./ha-energy-validation-result";
|
||||
import { energyCardStyles } from "./styles";
|
||||
|
||||
const isHomeSource = (
|
||||
source: EnergyPreferences["energy_sources"][number]
|
||||
): source is HomeSourceTypeEnergyPreference => source.type === "home";
|
||||
|
||||
@customElement("ha-energy-home-settings")
|
||||
export class EnergyHomeSettings extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -52,12 +62,29 @@ export class EnergyHomeSettings extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public validationResult?: EnergyPreferencesValidation;
|
||||
|
||||
/** Path of the list page, editors open on pages under it instead of dialogs */
|
||||
@property({ attribute: false }) public listPath?: string;
|
||||
|
||||
/** Shows the editor page of the item with this key, or `new` to add one */
|
||||
@property({ attribute: false }) public editKey?: string;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
if (this.listPath && this.editKey !== undefined) {
|
||||
return html`<ha-energy-editor-page
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.backPath=${this.listPath}
|
||||
.config=${this._editorPage(this.editKey)}
|
||||
></ha-energy-editor-page>`;
|
||||
}
|
||||
|
||||
const homeSources: HomeSourceTypeEnergyPreference[] = [];
|
||||
const homeValidation: EnergyValidationIssue[][] = [];
|
||||
|
||||
this.preferences.energy_sources.forEach((source, idx) => {
|
||||
if (source.type !== "home") {
|
||||
if (!isHomeSource(source)) {
|
||||
return;
|
||||
}
|
||||
homeSources.push(source);
|
||||
@@ -87,116 +114,198 @@ export class EnergyHomeSettings extends LitElement {
|
||||
${
|
||||
homeSources.length > 0
|
||||
? html`
|
||||
<div class="items-container">
|
||||
<div class="items">
|
||||
${homeSources.map((source) => {
|
||||
const entityState = this._states[source.stat_energy_to];
|
||||
return html`
|
||||
<div class="row" .source=${source}>
|
||||
${
|
||||
entityState?.attributes.icon
|
||||
? html`<ha-icon
|
||||
.icon=${entityState.attributes.icon}
|
||||
></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
.path=${mdiHome}
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
<span class="content"
|
||||
>${
|
||||
source.name ||
|
||||
this._hassConfig.config.location_name
|
||||
}</span
|
||||
<ha-card outlined>
|
||||
<ha-automation-row
|
||||
.source=${source}
|
||||
.href=${this._editorHref(source)}
|
||||
@click=${this.listPath ? nothing : this._editSource}
|
||||
>
|
||||
<ha-icon-button
|
||||
.label=${this._i18n.localize(
|
||||
"ui.panel.config.energy.home.edit_home_meter"
|
||||
)}
|
||||
@click=${this._editSource}
|
||||
.path=${mdiPencil}
|
||||
></ha-icon-button>
|
||||
<ha-icon-button
|
||||
.label=${this._i18n.localize(
|
||||
"ui.panel.config.energy.home.remove_home_meter"
|
||||
)}
|
||||
@click=${this._deleteSource}
|
||||
.path=${mdiDelete}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
${
|
||||
entityState?.attributes.icon
|
||||
? html`<ha-icon
|
||||
slot="leading-icon"
|
||||
.icon=${entityState.attributes.icon}
|
||||
></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
slot="leading-icon"
|
||||
.path=${mdiHome}
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
<div slot="header" class="row-header">
|
||||
<span class="label"
|
||||
>${this._sourceName(source)}</span
|
||||
>
|
||||
</div>
|
||||
<ha-icon-button
|
||||
slot="icons"
|
||||
.label=${this._i18n.localize(
|
||||
"ui.panel.config.energy.home.remove_home_meter"
|
||||
)}
|
||||
.path=${mdiDelete}
|
||||
.source=${source}
|
||||
@click=${this._deleteSourceClicked}
|
||||
@keydown=${stopPropagation}
|
||||
></ha-icon-button>
|
||||
</ha-automation-row>
|
||||
</ha-card>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
homeSources.length === 0
|
||||
? html`
|
||||
<div class="row">
|
||||
<ha-button
|
||||
@click=${this._addSource}
|
||||
appearance="filled"
|
||||
size="s"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this._i18n.localize(
|
||||
"ui.panel.config.energy.home.add_home_meter"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
<ha-button
|
||||
.href=${this._editorHref()}
|
||||
@click=${this.listPath ? nothing : this._addSource}
|
||||
appearance="filled"
|
||||
size="s"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this._i18n.localize(
|
||||
"ui.panel.config.energy.home.add_home_meter"
|
||||
)}
|
||||
</ha-button>
|
||||
`
|
||||
: ""
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _sourceName(source: HomeSourceTypeEnergyPreference) {
|
||||
return source.name || this._hassConfig.config.location_name;
|
||||
}
|
||||
|
||||
private _addSource() {
|
||||
showEnergySettingsHomeDialog(this, {
|
||||
hass: this.hass,
|
||||
saveCallback: async (source) => {
|
||||
await this._savePreferences({
|
||||
...this.preferences,
|
||||
energy_sources: this.preferences.energy_sources.concat(source),
|
||||
});
|
||||
},
|
||||
});
|
||||
this._openEditor();
|
||||
}
|
||||
|
||||
private _editSource(ev) {
|
||||
const origSource: HomeSourceTypeEnergyPreference =
|
||||
ev.currentTarget.closest(".row").source;
|
||||
showEnergySettingsHomeDialog(this, {
|
||||
hass: this.hass,
|
||||
source: { ...origSource },
|
||||
saveCallback: async (newSource) => {
|
||||
await this._savePreferences({
|
||||
...this.preferences,
|
||||
energy_sources: this.preferences.energy_sources.map((src) =>
|
||||
src === origSource ? newSource : src
|
||||
),
|
||||
});
|
||||
this._openEditor(ev.currentTarget.source);
|
||||
}
|
||||
|
||||
private _openEditor(source?: HomeSourceTypeEnergyPreference) {
|
||||
showEnergySettingsDialog(this, { editor: this._editorConfig(source) });
|
||||
}
|
||||
|
||||
private _editorHref(source?: HomeSourceTypeEnergyPreference) {
|
||||
return this.listPath
|
||||
? `${this.listPath}/home/${source ? encodeURIComponent(source.stat_energy_to) : "new"}`
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private _editorConfig(
|
||||
source?: HomeSourceTypeEnergyPreference
|
||||
): EnergyEditorConfig {
|
||||
return {
|
||||
kind: "home",
|
||||
params: {
|
||||
source: source ? { ...source } : undefined,
|
||||
saveCallback: (newSource) => this._saveSource(newSource, source),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private _sourceIndex(source: HomeSourceTypeEnergyPreference) {
|
||||
const sources = this.preferences.energy_sources;
|
||||
const index = sources.indexOf(source);
|
||||
if (index !== -1) {
|
||||
return index;
|
||||
}
|
||||
// Saving replaces the preferences, after which only the statistic matches
|
||||
return sources.findIndex(
|
||||
(src) => isHomeSource(src) && src.stat_energy_to === source.stat_energy_to
|
||||
);
|
||||
}
|
||||
|
||||
// Kept per item: a save replaces the preferences right before the page leaves
|
||||
private _editorPage = memoizeOne(
|
||||
(key: string): EnergyEditorPageConfig | undefined => {
|
||||
if (key === "new") {
|
||||
return this._pageConfig(this._editorConfig());
|
||||
}
|
||||
const source = this.preferences.energy_sources.find(
|
||||
(src): src is HomeSourceTypeEnergyPreference =>
|
||||
isHomeSource(src) && src.stat_energy_to === key
|
||||
);
|
||||
return source && this._pageConfig(this._editorConfig(source), source);
|
||||
}
|
||||
);
|
||||
|
||||
private _pageConfig(
|
||||
editor: EnergyEditorConfig,
|
||||
source?: HomeSourceTypeEnergyPreference
|
||||
): EnergyEditorPageConfig {
|
||||
if (!source) {
|
||||
return {
|
||||
editor,
|
||||
title: this._i18n.localize(
|
||||
"ui.panel.config.energy.home.add_home_meter"
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
editor,
|
||||
title: this._sourceName(source),
|
||||
delete: {
|
||||
label: this._i18n.localize(
|
||||
"ui.panel.config.energy.home.remove_home_meter"
|
||||
),
|
||||
action: () => this._deleteSource(source),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async _saveSource(
|
||||
source: HomeSourceTypeEnergyPreference,
|
||||
original?: HomeSourceTypeEnergyPreference
|
||||
) {
|
||||
const sources = [...this.preferences.energy_sources];
|
||||
const index = original ? this._sourceIndex(original) : -1;
|
||||
if (index === -1) {
|
||||
sources.push(source);
|
||||
} else {
|
||||
sources[index] = source;
|
||||
}
|
||||
await this._savePreferences({
|
||||
...this.preferences,
|
||||
energy_sources: sources,
|
||||
});
|
||||
}
|
||||
|
||||
private async _deleteSource(ev) {
|
||||
const sourceToDelete: HomeSourceTypeEnergyPreference =
|
||||
ev.currentTarget.closest(".row").source;
|
||||
private _deleteSourceClicked(ev) {
|
||||
ev.stopPropagation();
|
||||
this._deleteSource(ev.currentTarget.source);
|
||||
}
|
||||
|
||||
private async _deleteSource(
|
||||
source: HomeSourceTypeEnergyPreference
|
||||
): Promise<boolean> {
|
||||
if (
|
||||
!(await showConfirmationDialog(this, {
|
||||
title: this._i18n.localize("ui.panel.config.energy.delete_source"),
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const index = this._sourceIndex(source);
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await this._savePreferences({
|
||||
...this.preferences,
|
||||
energy_sources: this.preferences.energy_sources.filter(
|
||||
(source) => source !== sourceToDelete
|
||||
(_src, idx) => idx !== index
|
||||
),
|
||||
});
|
||||
} catch (err: any) {
|
||||
@@ -206,12 +315,15 @@ export class EnergyHomeSettings extends LitElement {
|
||||
{ error: err.message }
|
||||
),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async _savePreferences(preferences: EnergyPreferences) {
|
||||
const result = await saveEnergyPreferences(this.hass, preferences);
|
||||
fireEvent(this, "value-changed", { value: result });
|
||||
return result;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { mdiDelete, mdiPencil, mdiPlus, mdiSolarPower } from "@mdi/js";
|
||||
import { mdiDelete, mdiPlus, mdiSolarPower } from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-card";
|
||||
import { stopPropagation } from "../../../../common/dom/stop_propagation";
|
||||
import "../../../../components/automation/ha-automation-row";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-icon-button";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type {
|
||||
EnergyInfo,
|
||||
EnergyPreferences,
|
||||
@@ -17,16 +21,23 @@ import { saveEnergyPreferences } from "../../../../data/energy";
|
||||
import type { StatisticsMetaData } from "../../../../data/recorder";
|
||||
import { getStatisticLabel } from "../../../../data/recorder";
|
||||
import {
|
||||
showConfirmationDialog,
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
} from "../../../../dialogs/generic/show-dialog-box";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { documentationUrl } from "../../../../util/documentation-url";
|
||||
import { showEnergySettingsSolarDialog } from "../dialogs/show-dialogs-energy";
|
||||
import { showEnergySettingsDialog } from "../dialogs/show-dialogs-energy";
|
||||
import type { EnergyEditorConfig } from "../editors/energy-editor";
|
||||
import "../ha-energy-editor-page";
|
||||
import type { EnergyEditorPageConfig } from "../ha-energy-editor-page";
|
||||
import "./ha-energy-validation-result";
|
||||
import { energyCardStyles } from "./styles";
|
||||
|
||||
const isSolarSource = (
|
||||
source: EnergyPreferences["energy_sources"][number]
|
||||
): source is SolarSourceTypeEnergyPreference => source.type === "solar";
|
||||
|
||||
@customElement("ha-energy-solar-settings")
|
||||
export class EnergySolarSettings extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -43,12 +54,29 @@ export class EnergySolarSettings extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public info?: EnergyInfo;
|
||||
|
||||
/** Path of the list page, editors open on pages under it instead of dialogs */
|
||||
@property({ attribute: false }) public listPath?: string;
|
||||
|
||||
/** Shows the editor page of the item with this key, or `new` to add one */
|
||||
@property({ attribute: false }) public editKey?: string;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
if (this.listPath && this.editKey !== undefined) {
|
||||
return html`<ha-energy-editor-page
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.backPath=${this.listPath}
|
||||
.config=${this._editorPage(this.editKey)}
|
||||
></ha-energy-editor-page>`;
|
||||
}
|
||||
|
||||
const solarSources: SolarSourceTypeEnergyPreference[] = [];
|
||||
const solarValidation: EnergyValidationIssue[][] = [];
|
||||
|
||||
this.preferences.energy_sources.forEach((source, idx) => {
|
||||
if (source.type !== "solar") {
|
||||
if (!isSolarSource(source)) {
|
||||
return;
|
||||
}
|
||||
solarSources.push(source);
|
||||
@@ -88,146 +116,237 @@ export class EnergySolarSettings extends LitElement {
|
||||
${
|
||||
solarSources.length > 0
|
||||
? html`
|
||||
<div class="items-container">
|
||||
<div class="items">
|
||||
${solarSources.map((source) => {
|
||||
const entityState =
|
||||
this.hass.states[source.stat_energy_from];
|
||||
return html`
|
||||
<div class="row" .source=${source}>
|
||||
${
|
||||
entityState?.attributes.icon
|
||||
? html`<ha-icon
|
||||
.icon=${entityState.attributes.icon}
|
||||
></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
.path=${mdiSolarPower}
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
<span class="content"
|
||||
>${
|
||||
source.name ||
|
||||
getStatisticLabel(
|
||||
this.hass.states,
|
||||
this.hass.formatEntityName,
|
||||
source.stat_energy_from,
|
||||
this.statsMetadata?.[source.stat_energy_from]
|
||||
)
|
||||
}</span
|
||||
<ha-card outlined>
|
||||
<ha-automation-row
|
||||
.source=${source}
|
||||
.href=${this._editorHref(source)}
|
||||
@click=${this.listPath ? nothing : this._editSource}
|
||||
>
|
||||
${
|
||||
this.info
|
||||
? html`
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.solar.edit_solar_production"
|
||||
)}
|
||||
@click=${this._editSource}
|
||||
.path=${mdiPencil}
|
||||
></ha-icon-button>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.solar.delete_solar_production"
|
||||
)}
|
||||
@click=${this._deleteSource}
|
||||
.path=${mdiDelete}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
${
|
||||
entityState?.attributes.icon
|
||||
? html`<ha-icon
|
||||
slot="leading-icon"
|
||||
.icon=${entityState.attributes.icon}
|
||||
></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
slot="leading-icon"
|
||||
.path=${mdiSolarPower}
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
<div slot="header" class="row-header">
|
||||
<span class="label"
|
||||
>${this._sourceName(source)}</span
|
||||
>
|
||||
</div>
|
||||
<ha-icon-button
|
||||
slot="icons"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.solar.delete_solar_production"
|
||||
)}
|
||||
.path=${mdiDelete}
|
||||
.source=${source}
|
||||
@click=${this._deleteSourceClicked}
|
||||
@keydown=${stopPropagation}
|
||||
></ha-icon-button>
|
||||
</ha-automation-row>
|
||||
</ha-card>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this.info
|
||||
? html`
|
||||
<div class="row">
|
||||
<ha-button
|
||||
@click=${this._addSource}
|
||||
appearance="filled"
|
||||
size="s"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.solar.add_solar_production"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
<ha-button
|
||||
.href=${this._editorHref()}
|
||||
@click=${this.listPath ? nothing : this._addSource}
|
||||
appearance="filled"
|
||||
size="s"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.solar.add_solar_production"
|
||||
)}
|
||||
</ha-button>
|
||||
`
|
||||
: ""
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _sourceName(source: SolarSourceTypeEnergyPreference) {
|
||||
return (
|
||||
source.name ||
|
||||
getStatisticLabel(
|
||||
this.hass.states,
|
||||
this.hass.formatEntityName,
|
||||
source.stat_energy_from,
|
||||
this.statsMetadata?.[source.stat_energy_from]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private _addSource() {
|
||||
showEnergySettingsSolarDialog(this, {
|
||||
statsMetadata: this.statsMetadata,
|
||||
info: this.info!,
|
||||
solar_sources: this.preferences.energy_sources.filter(
|
||||
(src) => src.type === "solar"
|
||||
) as SolarSourceTypeEnergyPreference[],
|
||||
saveCallback: async (source) => {
|
||||
await this._savePreferences({
|
||||
...this.preferences,
|
||||
energy_sources: this.preferences.energy_sources.concat(source),
|
||||
});
|
||||
},
|
||||
});
|
||||
this._openEditor();
|
||||
}
|
||||
|
||||
private _editSource(ev) {
|
||||
const origSource: SolarSourceTypeEnergyPreference =
|
||||
ev.currentTarget.closest(".row").source;
|
||||
showEnergySettingsSolarDialog(this, {
|
||||
statsMetadata: this.statsMetadata,
|
||||
info: this.info!,
|
||||
source: { ...origSource },
|
||||
solar_sources: this.preferences.energy_sources.filter(
|
||||
(src) => src.type === "solar"
|
||||
) as SolarSourceTypeEnergyPreference[],
|
||||
saveCallback: async (newSource) => {
|
||||
await this._savePreferences({
|
||||
...this.preferences,
|
||||
energy_sources: this.preferences.energy_sources.map((src) =>
|
||||
src === origSource ? newSource : src
|
||||
),
|
||||
});
|
||||
},
|
||||
if (!this.info) {
|
||||
return;
|
||||
}
|
||||
this._openEditor(ev.currentTarget.source);
|
||||
}
|
||||
|
||||
private _openEditor(source?: SolarSourceTypeEnergyPreference) {
|
||||
showEnergySettingsDialog(this, {
|
||||
editor: this._editorConfig(this.preferences, source),
|
||||
});
|
||||
}
|
||||
|
||||
private async _deleteSource(ev) {
|
||||
const sourceToDelete: SolarSourceTypeEnergyPreference =
|
||||
ev.currentTarget.closest(".row").source;
|
||||
private _editorHref(source?: SolarSourceTypeEnergyPreference) {
|
||||
return this.listPath
|
||||
? `${this.listPath}/solar/${source ? encodeURIComponent(source.stat_energy_from) : "new"}`
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private _editorConfig(
|
||||
preferences: EnergyPreferences,
|
||||
source?: SolarSourceTypeEnergyPreference
|
||||
): EnergyEditorConfig {
|
||||
return {
|
||||
kind: "solar",
|
||||
params: {
|
||||
statsMetadata: this.statsMetadata,
|
||||
info: this.info!,
|
||||
source: source ? { ...source } : undefined,
|
||||
solar_sources: preferences.energy_sources.filter(isSolarSource),
|
||||
saveCallback: (newSource) => this._saveSource(newSource, source),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private _sourceIndex(source: SolarSourceTypeEnergyPreference) {
|
||||
const sources = this.preferences.energy_sources;
|
||||
const index = sources.indexOf(source);
|
||||
if (index !== -1) {
|
||||
return index;
|
||||
}
|
||||
// Saving replaces the preferences, after which only the statistic matches
|
||||
return sources.findIndex(
|
||||
(src) =>
|
||||
isSolarSource(src) && src.stat_energy_from === source.stat_energy_from
|
||||
);
|
||||
}
|
||||
|
||||
// Kept per item: a save replaces the preferences right before the page leaves
|
||||
private _editorPage = memoizeOne(
|
||||
(key: string): EnergyEditorPageConfig | undefined => {
|
||||
if (key === "new") {
|
||||
return this._pageConfig(this._editorConfig(this.preferences));
|
||||
}
|
||||
const source = this.preferences.energy_sources.find(
|
||||
(src): src is SolarSourceTypeEnergyPreference =>
|
||||
isSolarSource(src) && src.stat_energy_from === key
|
||||
);
|
||||
return (
|
||||
source &&
|
||||
this._pageConfig(this._editorConfig(this.preferences, source), source)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
private _pageConfig(
|
||||
editor: EnergyEditorConfig,
|
||||
source?: SolarSourceTypeEnergyPreference
|
||||
): EnergyEditorPageConfig {
|
||||
if (!source) {
|
||||
return {
|
||||
editor,
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.energy.solar.add_solar_production"
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
editor,
|
||||
title: this._sourceName(source),
|
||||
delete: {
|
||||
label: this.hass.localize(
|
||||
"ui.panel.config.energy.solar.delete_solar_production"
|
||||
),
|
||||
action: () => this._deleteSource(source),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async _saveSource(
|
||||
source: SolarSourceTypeEnergyPreference,
|
||||
original?: SolarSourceTypeEnergyPreference
|
||||
) {
|
||||
const sources = [...this.preferences.energy_sources];
|
||||
const index = original ? this._sourceIndex(original) : -1;
|
||||
if (index === -1) {
|
||||
sources.push(source);
|
||||
} else {
|
||||
sources[index] = source;
|
||||
}
|
||||
await this._savePreferences({
|
||||
...this.preferences,
|
||||
energy_sources: sources,
|
||||
});
|
||||
}
|
||||
|
||||
private _deleteSourceClicked(ev) {
|
||||
ev.stopPropagation();
|
||||
this._deleteSource(ev.currentTarget.source);
|
||||
}
|
||||
|
||||
private async _deleteSource(
|
||||
source: SolarSourceTypeEnergyPreference
|
||||
): Promise<boolean> {
|
||||
if (
|
||||
!(await showConfirmationDialog(this, {
|
||||
title: this.hass.localize("ui.panel.config.energy.delete_source"),
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const index = this._sourceIndex(source);
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await this._savePreferences({
|
||||
...this.preferences,
|
||||
energy_sources: this.preferences.energy_sources.filter(
|
||||
(source) => source !== sourceToDelete
|
||||
(_src, idx) => idx !== index
|
||||
),
|
||||
});
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, { title: `Failed to save config: ${err.message}` });
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize("ui.panel.config.energy.save_config_failed", {
|
||||
error: err.message,
|
||||
}),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async _savePreferences(preferences: EnergyPreferences) {
|
||||
const result = await saveEnergyPreferences(this.hass, preferences);
|
||||
fireEvent(this, "value-changed", { value: result });
|
||||
return result;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { mdiDelete, mdiWater, mdiPencil, mdiPlus } from "@mdi/js";
|
||||
import { mdiDelete, mdiPlus, mdiWater } from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-card";
|
||||
import { stopPropagation } from "../../../../common/dom/stop_propagation";
|
||||
import "../../../../components/automation/ha-automation-row";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-icon-button";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type {
|
||||
EnergyPreferences,
|
||||
EnergyPreferencesValidation,
|
||||
@@ -22,10 +26,17 @@ import {
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { documentationUrl } from "../../../../util/documentation-url";
|
||||
import { showEnergySettingsWaterDialog } from "../dialogs/show-dialogs-energy";
|
||||
import { showEnergySettingsDialog } from "../dialogs/show-dialogs-energy";
|
||||
import type { EnergyEditorConfig } from "../editors/energy-editor";
|
||||
import "../ha-energy-editor-page";
|
||||
import type { EnergyEditorPageConfig } from "../ha-energy-editor-page";
|
||||
import "./ha-energy-validation-result";
|
||||
import { energyCardStyles } from "./styles";
|
||||
|
||||
const isWaterSource = (
|
||||
source: EnergyPreferences["energy_sources"][number]
|
||||
): source is WaterSourceTypeEnergyPreference => source.type === "water";
|
||||
|
||||
@customElement("ha-energy-water-settings")
|
||||
export class EnergyWaterSettings extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -39,12 +50,29 @@ export class EnergyWaterSettings extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public validationResult?: EnergyPreferencesValidation;
|
||||
|
||||
/** Path of the list page, editors open on pages under it instead of dialogs */
|
||||
@property({ attribute: false }) public listPath?: string;
|
||||
|
||||
/** Shows the editor page of the item with this key, or `new` to add one */
|
||||
@property({ attribute: false }) public editKey?: string;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
if (this.listPath && this.editKey !== undefined) {
|
||||
return html`<ha-energy-editor-page
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.backPath=${this.listPath}
|
||||
.config=${this._editorPage(this.editKey)}
|
||||
></ha-energy-editor-page>`;
|
||||
}
|
||||
|
||||
const waterSources: WaterSourceTypeEnergyPreference[] = [];
|
||||
const waterValidation: EnergyValidationIssue[][] = [];
|
||||
|
||||
this.preferences.energy_sources.forEach((source, idx) => {
|
||||
if (source.type !== "water") {
|
||||
if (!isWaterSource(source)) {
|
||||
return;
|
||||
}
|
||||
waterSources.push(source);
|
||||
@@ -84,129 +112,228 @@ export class EnergyWaterSettings extends LitElement {
|
||||
${
|
||||
waterSources.length > 0
|
||||
? html`
|
||||
<div class="items-container">
|
||||
<div class="items">
|
||||
${waterSources.map((source) => {
|
||||
const entityState =
|
||||
this.hass.states[source.stat_energy_from];
|
||||
return html`
|
||||
<div class="row" .source=${source}>
|
||||
${
|
||||
entityState?.attributes.icon
|
||||
? html`<ha-icon
|
||||
.icon=${entityState.attributes.icon}
|
||||
></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
.path=${mdiWater}
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
<span class="content"
|
||||
>${
|
||||
source.name ||
|
||||
getStatisticLabel(
|
||||
this.hass.states,
|
||||
this.hass.formatEntityName,
|
||||
source.stat_energy_from,
|
||||
this.statsMetadata?.[source.stat_energy_from]
|
||||
)
|
||||
}</span
|
||||
<ha-card outlined>
|
||||
<ha-automation-row
|
||||
.source=${source}
|
||||
.href=${this._editorHref(source)}
|
||||
@click=${this.listPath ? nothing : this._editSource}
|
||||
>
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.water.edit_water_source"
|
||||
)}
|
||||
@click=${this._editSource}
|
||||
.path=${mdiPencil}
|
||||
></ha-icon-button>
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.water.delete_water_source"
|
||||
)}
|
||||
@click=${this._deleteSource}
|
||||
.path=${mdiDelete}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
${
|
||||
entityState?.attributes.icon
|
||||
? html`<ha-icon
|
||||
slot="leading-icon"
|
||||
.icon=${entityState.attributes.icon}
|
||||
></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
slot="leading-icon"
|
||||
.path=${mdiWater}
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
<div slot="header" class="row-header">
|
||||
<span class="label"
|
||||
>${this._sourceName(source)}</span
|
||||
>
|
||||
</div>
|
||||
<ha-icon-button
|
||||
slot="icons"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.water.delete_water_source"
|
||||
)}
|
||||
.path=${mdiDelete}
|
||||
.source=${source}
|
||||
@click=${this._deleteSourceClicked}
|
||||
@keydown=${stopPropagation}
|
||||
></ha-icon-button>
|
||||
</ha-automation-row>
|
||||
</ha-card>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
: nothing
|
||||
}
|
||||
<div class="row">
|
||||
<ha-button @click=${this._addSource} appearance="filled" size="s">
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.energy.water.add_water_source"
|
||||
)}</ha-button
|
||||
>
|
||||
</div>
|
||||
<ha-button
|
||||
.href=${this._editorHref()}
|
||||
@click=${this.listPath ? nothing : this._addSource}
|
||||
appearance="filled"
|
||||
size="s"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.water.add_water_source"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _sourceName(source: WaterSourceTypeEnergyPreference) {
|
||||
return (
|
||||
source.name ||
|
||||
getStatisticLabel(
|
||||
this.hass.states,
|
||||
this.hass.formatEntityName,
|
||||
source.stat_energy_from,
|
||||
this.statsMetadata?.[source.stat_energy_from]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private _addSource() {
|
||||
showEnergySettingsWaterDialog(this, {
|
||||
statsMetadata: this.statsMetadata,
|
||||
water_sources: this.preferences.energy_sources.filter(
|
||||
(src) => src.type === "water"
|
||||
) as WaterSourceTypeEnergyPreference[],
|
||||
saveCallback: async (source) => {
|
||||
delete source.unit_of_measurement;
|
||||
await this._savePreferences({
|
||||
...this.preferences,
|
||||
energy_sources: this.preferences.energy_sources.concat(source),
|
||||
});
|
||||
},
|
||||
});
|
||||
this._openEditor();
|
||||
}
|
||||
|
||||
private _editSource(ev) {
|
||||
const origSource: WaterSourceTypeEnergyPreference =
|
||||
ev.currentTarget.closest(".row").source;
|
||||
showEnergySettingsWaterDialog(this, {
|
||||
statsMetadata: this.statsMetadata,
|
||||
source: { ...origSource },
|
||||
water_sources: this.preferences.energy_sources.filter(
|
||||
(src) => src.type === "water"
|
||||
) as WaterSourceTypeEnergyPreference[],
|
||||
saveCallback: async (newSource) => {
|
||||
await this._savePreferences({
|
||||
...this.preferences,
|
||||
energy_sources: this.preferences.energy_sources.map((src) =>
|
||||
src === origSource ? newSource : src
|
||||
),
|
||||
});
|
||||
},
|
||||
this._openEditor(ev.currentTarget.source);
|
||||
}
|
||||
|
||||
private _openEditor(source?: WaterSourceTypeEnergyPreference) {
|
||||
showEnergySettingsDialog(this, {
|
||||
editor: this._editorConfig(this.preferences, source),
|
||||
});
|
||||
}
|
||||
|
||||
private async _deleteSource(ev) {
|
||||
const sourceToDelete: WaterSourceTypeEnergyPreference =
|
||||
ev.currentTarget.closest(".row").source;
|
||||
private _editorHref(source?: WaterSourceTypeEnergyPreference) {
|
||||
return this.listPath
|
||||
? `${this.listPath}/water/${source ? encodeURIComponent(source.stat_energy_from) : "new"}`
|
||||
: undefined;
|
||||
}
|
||||
|
||||
private _editorConfig(
|
||||
preferences: EnergyPreferences,
|
||||
source?: WaterSourceTypeEnergyPreference
|
||||
): EnergyEditorConfig {
|
||||
return {
|
||||
kind: "water",
|
||||
params: {
|
||||
source: source ? { ...source } : undefined,
|
||||
statsMetadata: this.statsMetadata,
|
||||
water_sources: preferences.energy_sources.filter(isWaterSource),
|
||||
saveCallback: (newSource) => this._saveSource(newSource, source),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private _sourceIndex(source: WaterSourceTypeEnergyPreference) {
|
||||
const sources = this.preferences.energy_sources;
|
||||
const index = sources.indexOf(source);
|
||||
if (index !== -1) {
|
||||
return index;
|
||||
}
|
||||
// Saving replaces the preferences, after which only the statistic matches
|
||||
return sources.findIndex(
|
||||
(src) =>
|
||||
isWaterSource(src) && src.stat_energy_from === source.stat_energy_from
|
||||
);
|
||||
}
|
||||
|
||||
// Kept per item: a save replaces the preferences right before the page leaves
|
||||
private _editorPage = memoizeOne(
|
||||
(key: string): EnergyEditorPageConfig | undefined => {
|
||||
if (key === "new") {
|
||||
return this._pageConfig(this._editorConfig(this.preferences));
|
||||
}
|
||||
const source = this.preferences.energy_sources.find(
|
||||
(src): src is WaterSourceTypeEnergyPreference =>
|
||||
isWaterSource(src) && src.stat_energy_from === key
|
||||
);
|
||||
return (
|
||||
source &&
|
||||
this._pageConfig(this._editorConfig(this.preferences, source), source)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
private _pageConfig(
|
||||
editor: EnergyEditorConfig,
|
||||
source?: WaterSourceTypeEnergyPreference
|
||||
): EnergyEditorPageConfig {
|
||||
if (!source) {
|
||||
return {
|
||||
editor,
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.energy.water.add_water_source"
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
editor,
|
||||
title: this._sourceName(source),
|
||||
delete: {
|
||||
label: this.hass.localize(
|
||||
"ui.panel.config.energy.water.delete_water_source"
|
||||
),
|
||||
action: () => this._deleteSource(source),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async _saveSource(
|
||||
source: WaterSourceTypeEnergyPreference,
|
||||
original?: WaterSourceTypeEnergyPreference
|
||||
) {
|
||||
const sources = [...this.preferences.energy_sources];
|
||||
const index = original ? this._sourceIndex(original) : -1;
|
||||
if (index === -1) {
|
||||
delete source.unit_of_measurement;
|
||||
sources.push(source);
|
||||
} else {
|
||||
sources[index] = source;
|
||||
}
|
||||
await this._savePreferences({
|
||||
...this.preferences,
|
||||
energy_sources: sources,
|
||||
});
|
||||
}
|
||||
|
||||
private _deleteSourceClicked(ev) {
|
||||
ev.stopPropagation();
|
||||
this._deleteSource(ev.currentTarget.source);
|
||||
}
|
||||
|
||||
private async _deleteSource(
|
||||
source: WaterSourceTypeEnergyPreference
|
||||
): Promise<boolean> {
|
||||
if (
|
||||
!(await showConfirmationDialog(this, {
|
||||
title: this.hass.localize("ui.panel.config.energy.delete_source"),
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const index = this._sourceIndex(source);
|
||||
if (index === -1) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await this._savePreferences({
|
||||
...this.preferences,
|
||||
energy_sources: this.preferences.energy_sources.filter(
|
||||
(source) => source !== sourceToDelete
|
||||
(_src, idx) => idx !== index
|
||||
),
|
||||
});
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, { title: `Failed to save config: ${err.message}` });
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize("ui.panel.config.energy.save_config_failed", {
|
||||
error: err.message,
|
||||
}),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async _savePreferences(preferences: EnergyPreferences) {
|
||||
const result = await saveEnergyPreferences(this.hass, preferences);
|
||||
fireEvent(this, "value-changed", { value: result });
|
||||
return result;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -57,4 +57,41 @@ export const energyCardStyles = css`
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
.items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
margin-bottom: var(--ha-space-3);
|
||||
}
|
||||
ha-automation-row img[slot="leading-icon"] {
|
||||
display: block;
|
||||
height: 24px;
|
||||
}
|
||||
.row-header {
|
||||
/* Two lines fit the row's 48px, which keeps its icons centered */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
min-height: 48px;
|
||||
padding: var(--ha-space-1) 0;
|
||||
}
|
||||
.row-header .label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.row-header .secondary {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: var(--ha-font-size-s);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
}
|
||||
.handle {
|
||||
display: flex;
|
||||
padding: var(--ha-space-3);
|
||||
cursor: move; /* fallback if grab cursor is unsupported */
|
||||
cursor: grab;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
`;
|
||||
@@ -1,649 +0,0 @@
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { HASSDomCurrentTargetEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/entity/ha-entity-picker";
|
||||
import "../../../../components/entity/ha-statistic-picker";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-dialog";
|
||||
import "../../../../components/ha-dialog-footer";
|
||||
import "../../../../components/ha-markdown";
|
||||
import "../../../../components/radio/ha-radio-group";
|
||||
import type { HaRadioGroup } from "../../../../components/radio/ha-radio-group";
|
||||
import "../../../../components/radio/ha-radio-option";
|
||||
import "../../../../components/input/ha-input";
|
||||
import type { GasSourceTypeEnergyPreference } from "../../../../data/energy";
|
||||
import {
|
||||
computeEnergyLabel,
|
||||
emptyGasEnergyPreference,
|
||||
energyStatisticHelpUrl,
|
||||
} from "../../../../data/energy";
|
||||
import {
|
||||
getDisplayUnit,
|
||||
getStatisticLabel,
|
||||
getStatisticMetadata,
|
||||
isExternalStatistic,
|
||||
} from "../../../../data/recorder";
|
||||
import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
|
||||
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
|
||||
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
|
||||
import { haStyle, haStyleDialog } from "../../../../resources/styles";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import type { EnergySettingsGasDialogParams } from "./show-dialogs-energy";
|
||||
import type { HaInput } from "../../../../components/input/ha-input";
|
||||
import type { IntegralUnits } from "./energy-integral";
|
||||
import {
|
||||
confirmIntegrals,
|
||||
findIntegral,
|
||||
integralTotalUnit,
|
||||
integralUnits,
|
||||
} from "./energy-integral";
|
||||
|
||||
type CostType = "no-costs" | "number" | "entity" | "statistic";
|
||||
|
||||
interface GasFormState {
|
||||
source: GasSourceTypeEnergyPreference;
|
||||
costs: CostType;
|
||||
}
|
||||
|
||||
const gasDeviceClasses = ["gas", "energy"];
|
||||
const gasUnitClasses = ["volume", "energy"];
|
||||
const flowRateUnitClasses = ["volume_flow_rate"];
|
||||
|
||||
@customElement("dialog-energy-gas-settings")
|
||||
export class DialogEnergyGasSettings
|
||||
extends DirtyStateProviderMixin<GasFormState>()(LitElement)
|
||||
implements HassDialog<EnergySettingsGasDialogParams>
|
||||
{
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: EnergySettingsGasDialogParams;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _source?: GasSourceTypeEnergyPreference;
|
||||
|
||||
@state() private _costs?: CostType;
|
||||
|
||||
@state() private _numberPriceText = "";
|
||||
|
||||
@state() private _pickedDisplayUnit?: string | null;
|
||||
|
||||
@state() private _energy_units?: string[];
|
||||
|
||||
@state() private _gas_units?: string[];
|
||||
|
||||
@state() private _flow_rate_units?: string[];
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@state() private _saving = false;
|
||||
|
||||
private _excludeList?: string[];
|
||||
|
||||
private _excludeListFlowRate?: string[];
|
||||
|
||||
public async showDialog(
|
||||
params: EnergySettingsGasDialogParams
|
||||
): Promise<void> {
|
||||
this._params = params;
|
||||
this._error = undefined;
|
||||
this._saving = false;
|
||||
this._source = params.source
|
||||
? { ...params.source }
|
||||
: emptyGasEnergyPreference();
|
||||
this._pickedDisplayUnit = getDisplayUnit(
|
||||
this.hass.states,
|
||||
params.source?.stat_energy_from,
|
||||
params.source?.stat_energy_from
|
||||
? params.statsMetadata?.[params.source?.stat_energy_from]
|
||||
: undefined
|
||||
);
|
||||
this._costs = this._source.entity_energy_price
|
||||
? "entity"
|
||||
: typeof this._source.number_energy_price === "number"
|
||||
? "number"
|
||||
: this._source.stat_cost
|
||||
? "statistic"
|
||||
: "no-costs";
|
||||
this._numberPriceText =
|
||||
typeof this._source.number_energy_price === "number"
|
||||
? String(this._source.number_energy_price)
|
||||
: "";
|
||||
this._energy_units = (
|
||||
await getSensorDeviceClassConvertibleUnits(this.hass, "energy")
|
||||
).units;
|
||||
this._gas_units = (
|
||||
await getSensorDeviceClassConvertibleUnits(this.hass, "gas")
|
||||
).units;
|
||||
this._flow_rate_units = (
|
||||
await getSensorDeviceClassConvertibleUnits(this.hass, "volume_flow_rate")
|
||||
).units;
|
||||
this._excludeList = this._params.gas_sources
|
||||
.map((entry) => entry.stat_energy_from)
|
||||
.filter((id) => id !== this._source?.stat_energy_from);
|
||||
this._excludeListFlowRate = this._params.gas_sources
|
||||
.map((entry) => entry.stat_rate)
|
||||
.filter((id) => id && id !== this._source?.stat_rate) as string[];
|
||||
|
||||
this._open = true;
|
||||
this._initDirtyTracking(
|
||||
{ type: "deep" },
|
||||
{ source: this._source!, costs: this._costs! }
|
||||
);
|
||||
}
|
||||
|
||||
public closeDialog() {
|
||||
this._open = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private _dialogClosed() {
|
||||
this._params = undefined;
|
||||
this._source = undefined;
|
||||
this._pickedDisplayUnit = undefined;
|
||||
this._error = undefined;
|
||||
this._excludeList = undefined;
|
||||
this._excludeListFlowRate = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params || !this._source) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const pickableUnit =
|
||||
this._params.allowedGasUnitClass === undefined
|
||||
? [...(this._gas_units || []), ...(this._energy_units || [])].join(", ")
|
||||
: this._params.allowedGasUnitClass === "energy"
|
||||
? this._energy_units?.join(", ") || ""
|
||||
: this._gas_units?.join(", ") || "";
|
||||
|
||||
const displayUnit = this._source.stat_energy_from
|
||||
? this._pickedDisplayUnit
|
||||
: this._integralTotalUnit();
|
||||
|
||||
const unitPrice = displayUnit
|
||||
? `${this.hass.config.currency}/${displayUnit}`
|
||||
: undefined;
|
||||
|
||||
const pickedUnitClass =
|
||||
displayUnit && this._energy_units?.includes(displayUnit)
|
||||
? "energy"
|
||||
: displayUnit && this._gas_units?.includes(displayUnit)
|
||||
? "volume"
|
||||
: undefined;
|
||||
|
||||
const labelStatistic =
|
||||
this._source.stat_energy_from || this._source.stat_rate;
|
||||
|
||||
const externalSource =
|
||||
this._source.stat_energy_from &&
|
||||
isExternalStatistic(this._source.stat_energy_from);
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
header-title=${this.hass.localize(
|
||||
"ui.panel.config.energy.gas.dialog.header"
|
||||
)}
|
||||
.preventScrimClose=${this.isDirtyState}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
${this._error ? html`<p class="error">${this._error}</p>` : ""}
|
||||
<div>
|
||||
<p>
|
||||
${this.hass.localize("ui.panel.config.energy.gas.dialog.paragraph")}
|
||||
</p>
|
||||
<p>
|
||||
${this.hass.localize("ui.panel.config.energy.gas.dialog.note_para")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ha-statistic-picker
|
||||
.helpMissingEntityUrl=${energyStatisticHelpUrl}
|
||||
.includeUnitClass=${
|
||||
this._params.allowedGasUnitClass || gasUnitClasses
|
||||
}
|
||||
.includeDeviceClass=${gasDeviceClasses}
|
||||
.value=${this._source.stat_energy_from}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.gas.dialog.gas_usage"
|
||||
)}
|
||||
.excludeStatistics=${this._excludeList}
|
||||
@value-changed=${this._statisticChanged}
|
||||
.disabled=${this._saving}
|
||||
.helper=${this.hass.localize(
|
||||
"ui.panel.config.energy.gas.dialog.entity_para",
|
||||
{ unit: pickableUnit }
|
||||
)}
|
||||
autofocus
|
||||
></ha-statistic-picker>
|
||||
|
||||
<ha-statistic-picker
|
||||
.includeUnitClass=${flowRateUnitClasses}
|
||||
.value=${this._source.stat_rate}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.gas.dialog.gas_flow_rate"
|
||||
)}
|
||||
.excludeStatistics=${this._excludeListFlowRate}
|
||||
@value-changed=${this._flowRateStatisticChanged}
|
||||
.disabled=${this._saving}
|
||||
.invalid=${!!this._flowRateError()}
|
||||
.errorMessage=${this._flowRateError()}
|
||||
.helper=${this.hass.localize(
|
||||
"ui.panel.config.energy.gas.dialog.flow_rate_para",
|
||||
{ unit: this._flow_rate_units?.join(", ") || "" }
|
||||
)}
|
||||
></ha-statistic-picker>
|
||||
|
||||
<ha-input
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.gas.dialog.display_name"
|
||||
)}
|
||||
type="text"
|
||||
.disabled=${!labelStatistic || this._saving}
|
||||
.value=${this._source?.name || ""}
|
||||
.placeholder=${
|
||||
labelStatistic
|
||||
? getStatisticLabel(
|
||||
this.hass.states,
|
||||
this.hass.formatEntityName,
|
||||
labelStatistic,
|
||||
this._params?.statsMetadata?.[labelStatistic]
|
||||
)
|
||||
: ""
|
||||
}
|
||||
@input=${this._nameChanged}
|
||||
>
|
||||
</ha-input>
|
||||
|
||||
<ha-radio-group
|
||||
.disabled=${this._saving}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.gas.dialog.cost_para"
|
||||
)}
|
||||
.value=${this._costs}
|
||||
name="costs"
|
||||
@change=${this._handleCostChanged}
|
||||
>
|
||||
<ha-radio-option value="no-costs">
|
||||
${this.hass.localize("ui.panel.config.energy.gas.dialog.no_cost")}
|
||||
</ha-radio-option>
|
||||
<ha-radio-option value="statistic">
|
||||
${this.hass.localize("ui.panel.config.energy.gas.dialog.cost_stat")}
|
||||
</ha-radio-option>
|
||||
<ha-radio-option value="entity" .disabled=${externalSource}>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.gas.dialog.cost_entity"
|
||||
)}
|
||||
</ha-radio-option>
|
||||
<ha-radio-option value="number" .disabled=${externalSource}>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.gas.dialog.cost_number"
|
||||
)}
|
||||
</ha-radio-option>
|
||||
</ha-radio-group>
|
||||
${
|
||||
this._costs === "statistic"
|
||||
? html`<ha-statistic-picker
|
||||
.disabled=${this._saving}
|
||||
class="price-options"
|
||||
statistic-types="sum"
|
||||
.value=${this._source.stat_cost}
|
||||
.label=${`${this.hass.localize(
|
||||
"ui.panel.config.energy.gas.dialog.cost_stat_input"
|
||||
)} (${this.hass.config.currency})`}
|
||||
@value-changed=${this._priceStatChanged}
|
||||
></ha-statistic-picker>`
|
||||
: this._costs === "entity"
|
||||
? html`<ha-entity-picker
|
||||
.disabled=${this._saving}
|
||||
class="price-options"
|
||||
include-domains='["sensor", "input_number"]'
|
||||
.value=${this._source.entity_energy_price}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.gas.dialog.cost_entity_input"
|
||||
)}
|
||||
.helper=${
|
||||
pickedUnitClass
|
||||
? html`<ha-markdown
|
||||
.content=${this.hass.localize(
|
||||
"ui.panel.config.energy.gas.dialog.cost_entity_helper",
|
||||
pickedUnitClass === "energy"
|
||||
? {
|
||||
currency: this.hass.config.currency,
|
||||
class: this.hass.localize(
|
||||
"ui.panel.config.energy.gas.dialog.cost_entity_helper_energy"
|
||||
),
|
||||
unit1: "kWh",
|
||||
unit2: "Wh",
|
||||
}
|
||||
: {
|
||||
currency: this.hass.config.currency,
|
||||
class: this.hass.localize(
|
||||
"ui.panel.config.energy.gas.dialog.cost_entity_helper_volume"
|
||||
),
|
||||
unit1: "m³",
|
||||
unit2: "ft³",
|
||||
}
|
||||
)}
|
||||
></ha-markdown>`
|
||||
: nothing
|
||||
}
|
||||
@value-changed=${this._priceEntityChanged}
|
||||
></ha-entity-picker>`
|
||||
: this._costs === "number"
|
||||
? html`<ha-input
|
||||
.disabled=${this._saving}
|
||||
.label=${`${this.hass.localize(
|
||||
"ui.panel.config.energy.gas.dialog.cost_number_input"
|
||||
)} ${unitPrice ? ` (${unitPrice})` : ""}`}
|
||||
class="price-options"
|
||||
step="any"
|
||||
type="number"
|
||||
required
|
||||
auto-validate
|
||||
.value=${this._numberPriceText}
|
||||
@input=${this._numberPriceChanged}
|
||||
>
|
||||
${
|
||||
unitPrice
|
||||
? html`<span slot="end">${unitPrice}</span>`
|
||||
: nothing
|
||||
}
|
||||
</ha-input>`
|
||||
: nothing
|
||||
}
|
||||
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
@click=${this.closeDialog}
|
||||
slot="secondaryAction"
|
||||
>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
@click=${this._save}
|
||||
.disabled=${
|
||||
this._saving ||
|
||||
!(this._source!.stat_energy_from || this._integralUnits()) ||
|
||||
(this._costs === "number" &&
|
||||
typeof this._source!.number_energy_price !== "number") ||
|
||||
(!!this._params?.source && !this.isDirtyState)
|
||||
}
|
||||
.loading=${this._saving}
|
||||
slot="primaryAction"
|
||||
>
|
||||
${this.hass.localize("ui.common.save")}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleCostChanged(ev: HASSDomCurrentTargetEvent<HaRadioGroup>) {
|
||||
this._costs = (ev.currentTarget as HaRadioGroup).value as CostType;
|
||||
this._updateFormDirtyState();
|
||||
}
|
||||
|
||||
private _numberPriceChanged(ev: HASSDomCurrentTargetEvent<HaInput>) {
|
||||
// Render the typed text so partial input such as "0." is not rewritten
|
||||
this._numberPriceText = ev.currentTarget.value ?? "";
|
||||
this._source = {
|
||||
...this._source!,
|
||||
number_energy_price: this._numberPriceText
|
||||
? parseFloat(this._numberPriceText)
|
||||
: null,
|
||||
entity_energy_price: null,
|
||||
stat_cost: null,
|
||||
};
|
||||
this._updateFormDirtyState();
|
||||
}
|
||||
|
||||
private _priceStatChanged(ev: CustomEvent) {
|
||||
this._numberPriceText = "";
|
||||
this._source = {
|
||||
...this._source!,
|
||||
entity_energy_price: null,
|
||||
number_energy_price: null,
|
||||
stat_cost: ev.detail.value,
|
||||
};
|
||||
this._updateFormDirtyState();
|
||||
}
|
||||
|
||||
private _priceEntityChanged(ev: CustomEvent) {
|
||||
this._numberPriceText = "";
|
||||
this._source = {
|
||||
...this._source!,
|
||||
entity_energy_price: ev.detail.value,
|
||||
number_energy_price: null,
|
||||
stat_cost: null,
|
||||
};
|
||||
this._updateFormDirtyState();
|
||||
}
|
||||
|
||||
private _flowRateStatisticChanged(ev: ValueChangedEvent<string>) {
|
||||
this._source = {
|
||||
...this._source!,
|
||||
stat_rate: ev.detail.value || undefined,
|
||||
};
|
||||
this._updateFormDirtyState();
|
||||
}
|
||||
|
||||
private async _statisticChanged(ev: ValueChangedEvent<string>) {
|
||||
if (ev.detail.value) {
|
||||
const [metadata] = await getStatisticMetadata(this.hass.callWS, [
|
||||
ev.detail.value,
|
||||
]);
|
||||
if (
|
||||
metadata &&
|
||||
isExternalStatistic(ev.detail.value) &&
|
||||
this._params?.statsMetadata &&
|
||||
!(ev.detail.value in this._params.statsMetadata)
|
||||
) {
|
||||
this._params.statsMetadata[ev.detail.value] = metadata;
|
||||
this.requestUpdate("_params");
|
||||
}
|
||||
this._pickedDisplayUnit = getDisplayUnit(
|
||||
this.hass.states,
|
||||
ev.detail.value,
|
||||
metadata
|
||||
);
|
||||
if (isExternalStatistic(ev.detail.value) && this._costs !== "statistic") {
|
||||
this._costs = "no-costs";
|
||||
}
|
||||
} else {
|
||||
this._pickedDisplayUnit = undefined;
|
||||
}
|
||||
this._source = {
|
||||
...this._source!,
|
||||
stat_energy_from: ev.detail.value,
|
||||
};
|
||||
this._updateFormDirtyState();
|
||||
}
|
||||
|
||||
private _nameChanged(ev: InputEvent) {
|
||||
this._source = {
|
||||
...this._source!,
|
||||
name: (ev.target as HaInput).value,
|
||||
};
|
||||
if (!this._source.name) {
|
||||
delete this._source.name;
|
||||
}
|
||||
this._updateFormDirtyState();
|
||||
}
|
||||
|
||||
private _updateFormDirtyState(): void {
|
||||
this._updateDirtyState({ source: this._source!, costs: this._costs! });
|
||||
}
|
||||
|
||||
private _integralUnits(): IntegralUnits | undefined {
|
||||
const statRate = this._source?.stat_rate;
|
||||
// Gas sources can't mix volume and energy units
|
||||
if (
|
||||
!statRate ||
|
||||
!this._gas_units ||
|
||||
this._params?.allowedGasUnitClass === "energy"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return integralUnits(
|
||||
this.hass.states[statRate]?.attributes.unit_of_measurement,
|
||||
this._gas_units
|
||||
);
|
||||
}
|
||||
|
||||
private _flowRateError(): string | undefined {
|
||||
if (
|
||||
this._source?.stat_energy_from ||
|
||||
!this._source?.stat_rate ||
|
||||
!this._gas_units ||
|
||||
this._integralUnits()
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return this.hass.localize(
|
||||
this._params?.allowedGasUnitClass === "energy"
|
||||
? "ui.panel.config.energy.gas.dialog.flow_rate_energy_sources"
|
||||
: "ui.panel.config.energy.gas.dialog.flow_rate_no_volume"
|
||||
);
|
||||
}
|
||||
|
||||
private _integralTotalUnit(): string | undefined {
|
||||
const units = this._integralUnits();
|
||||
if (!units) {
|
||||
return undefined;
|
||||
}
|
||||
const statRate = this._source!.stat_rate!;
|
||||
// A reused helper can display another unit than a new one would
|
||||
const existing = findIntegral(
|
||||
this.hass.states,
|
||||
this.hass.entities,
|
||||
statRate,
|
||||
"gas",
|
||||
this._excludeList
|
||||
);
|
||||
return existing
|
||||
? getDisplayUnit(this.hass.states, existing, undefined) || undefined
|
||||
: integralTotalUnit(
|
||||
this.hass.states[statRate].attributes.unit_of_measurement!,
|
||||
units
|
||||
);
|
||||
}
|
||||
|
||||
private async _save() {
|
||||
// Closing or reopening the dialog replaces its state, but a created helper
|
||||
// must still be saved with the submitted source
|
||||
const params = this._params!;
|
||||
const costs = this._costs;
|
||||
let source = this._source!;
|
||||
const isCurrent = () => this._params === params;
|
||||
this._error = undefined;
|
||||
this._saving = true;
|
||||
try {
|
||||
if (!source.stat_energy_from) {
|
||||
const statEnergyFrom = await this._gasFromFlowRate(source);
|
||||
if (!statEnergyFrom) {
|
||||
return;
|
||||
}
|
||||
source = { ...source, stat_energy_from: statEnergyFrom };
|
||||
if (isCurrent()) {
|
||||
this._pickedDisplayUnit = this._integralTotalUnit();
|
||||
// Keep edits made meanwhile, only fill in the new sensor
|
||||
this._source = { ...this._source!, stat_energy_from: statEnergyFrom };
|
||||
this._updateFormDirtyState();
|
||||
}
|
||||
}
|
||||
if (costs === "no-costs") {
|
||||
source = {
|
||||
...source,
|
||||
entity_energy_price: null,
|
||||
number_energy_price: null,
|
||||
stat_cost: null,
|
||||
};
|
||||
}
|
||||
await params.saveCallback(source);
|
||||
if (isCurrent()) {
|
||||
this._markDirtyStateClean();
|
||||
this.closeDialog();
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (isCurrent()) {
|
||||
this._error = err.message;
|
||||
}
|
||||
} finally {
|
||||
if (isCurrent()) {
|
||||
this._saving = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _gasFromFlowRate(
|
||||
draft: GasSourceTypeEnergyPreference
|
||||
): Promise<string | undefined> {
|
||||
const statRate = draft.stat_rate!;
|
||||
const units = this._integralUnits();
|
||||
if (!units) {
|
||||
return undefined;
|
||||
}
|
||||
const sensors = await confirmIntegrals(this, this.hass, {
|
||||
deviceClass: "gas",
|
||||
states: this.hass.states,
|
||||
entities: this.hass.entities,
|
||||
requests: [
|
||||
{
|
||||
rate: statRate,
|
||||
units,
|
||||
name: this.hass.localize(
|
||||
"ui.panel.config.energy.integral.gas.sensor_name",
|
||||
{
|
||||
name: computeEnergyLabel(
|
||||
this.hass.states,
|
||||
this.hass.formatEntityName,
|
||||
statRate,
|
||||
this._params?.statsMetadata?.[statRate],
|
||||
draft.name
|
||||
),
|
||||
}
|
||||
),
|
||||
},
|
||||
],
|
||||
intro: this.hass.localize(
|
||||
"ui.panel.config.energy.gas.dialog.gas_sensor_needed"
|
||||
),
|
||||
exclude: this._excludeList,
|
||||
statsMetadata: this._params?.statsMetadata,
|
||||
});
|
||||
return sensors?.[0];
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-statistic-picker {
|
||||
display: block;
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
ha-radio-group {
|
||||
margin-top: var(--ha-space-4);
|
||||
}
|
||||
.price-options {
|
||||
display: block;
|
||||
margin-top: var(--ha-space-3);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-energy-gas-settings": DialogEnergyGasSettings;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators";
|
||||
import { consumeLocalize } from "../../../../common/decorators/consume-context-entry";
|
||||
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-dialog";
|
||||
import "../../../../components/ha-dialog-footer";
|
||||
import { DialogMixin } from "../../../../dialogs/dialog-mixin";
|
||||
import type {
|
||||
EnergyEditorConfig,
|
||||
EnergyEditorElement,
|
||||
EnergyEditorStatus,
|
||||
} from "../editors/energy-editor";
|
||||
import { renderEnergyEditor } from "../editors/render-energy-editor";
|
||||
import type { EnergySettingsDialogParams } from "./show-dialogs-energy";
|
||||
|
||||
const HEADERS = {
|
||||
grid: "ui.panel.config.energy.grid.dialog.header",
|
||||
solar: "ui.panel.config.energy.solar.dialog.header",
|
||||
home: "ui.panel.config.energy.home.dialog.header",
|
||||
battery: "ui.panel.config.energy.battery.dialog.header",
|
||||
device: "ui.panel.config.energy.device_consumption.dialog.header",
|
||||
gas: "ui.panel.config.energy.gas.dialog.header",
|
||||
water: "ui.panel.config.energy.water.dialog.header",
|
||||
water_gas_device: {
|
||||
water: "ui.panel.config.energy.device_consumption_water.dialog.header",
|
||||
gas: "ui.panel.config.energy.device_consumption_gas.dialog.header",
|
||||
},
|
||||
} as const;
|
||||
|
||||
@customElement("dialog-energy-settings")
|
||||
export class DialogEnergySettings extends DialogMixin<EnergySettingsDialogParams>(
|
||||
LitElement
|
||||
) {
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@state() private _status?: EnergyEditorStatus;
|
||||
|
||||
@query(".energy-editor") private _editor?: EnergyEditorElement;
|
||||
|
||||
protected render() {
|
||||
if (!this.params) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
open
|
||||
header-title=${this._localize(this._header(this.params.editor))}
|
||||
.preventScrimClose=${!!this._status?.dirty}
|
||||
@energy-editor-status-changed=${this._statusChanged}
|
||||
>
|
||||
${renderEnergyEditor(this.params.editor)}
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
@click=${this.closeDialog}
|
||||
slot="secondaryAction"
|
||||
>
|
||||
${this._localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
@click=${this._save}
|
||||
.disabled=${
|
||||
!this._status?.dirty || !this._status.valid || this._status.saving
|
||||
}
|
||||
.loading=${!!this._status?.saving}
|
||||
slot="primaryAction"
|
||||
>
|
||||
${this._localize("ui.common.save")}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private _header(editor: EnergyEditorConfig) {
|
||||
return editor.kind === "water_gas_device"
|
||||
? HEADERS.water_gas_device[editor.params.resource]
|
||||
: HEADERS[editor.kind];
|
||||
}
|
||||
|
||||
private _statusChanged(
|
||||
ev: HASSDomEvent<HASSDomEvents["energy-editor-status-changed"]>
|
||||
) {
|
||||
this._status = ev.detail;
|
||||
}
|
||||
|
||||
private async _save() {
|
||||
if (await this._editor?.save()) {
|
||||
this.closeDialog();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-energy-settings": DialogEnergySettings;
|
||||
}
|
||||
}
|
||||
@@ -1,530 +0,0 @@
|
||||
import { mdiPlus } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { HASSDomCurrentTargetEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/entity/ha-statistic-picker";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-checkbox";
|
||||
import type { HaCheckbox } from "../../../../components/ha-checkbox";
|
||||
import "../../../../components/ha-dialog";
|
||||
import "../../../../components/ha-dialog-footer";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/radio/ha-radio-group";
|
||||
import "../../../../components/input/ha-input";
|
||||
import { domainToName } from "../../../../data/integration";
|
||||
import type { HaRadioGroup } from "../../../../components/radio/ha-radio-group";
|
||||
import "../../../../components/radio/ha-radio-option";
|
||||
import type { ConfigEntry } from "../../../../data/config_entries";
|
||||
import { getConfigEntries } from "../../../../data/config_entries";
|
||||
import type { SolarSourceTypeEnergyPreference } from "../../../../data/energy";
|
||||
import {
|
||||
computeEnergyLabel,
|
||||
emptySolarEnergyPreference,
|
||||
energyStatisticHelpUrl,
|
||||
} from "../../../../data/energy";
|
||||
import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
|
||||
import { showConfigFlowDialog } from "../../../../dialogs/config-flow/show-dialog-config-flow";
|
||||
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
|
||||
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
|
||||
import { haStyle, haStyleDialog } from "../../../../resources/styles";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import { brandsUrl } from "../../../../util/brands-url";
|
||||
import type { EnergySettingsSolarDialogParams } from "./show-dialogs-energy";
|
||||
import {
|
||||
getStatisticLabel,
|
||||
getStatisticMetadata,
|
||||
isExternalStatistic,
|
||||
} from "../../../../data/recorder";
|
||||
import type { HaInput } from "../../../../components/input/ha-input";
|
||||
import type { IntegralUnits } from "./energy-integral";
|
||||
import { confirmIntegrals, integralUnits } from "./energy-integral";
|
||||
|
||||
interface SolarFormState {
|
||||
source: SolarSourceTypeEnergyPreference;
|
||||
forecast: boolean;
|
||||
}
|
||||
|
||||
const energyUnitClasses = ["energy"];
|
||||
const powerUnitClasses = ["power"];
|
||||
|
||||
@customElement("dialog-energy-solar-settings")
|
||||
export class DialogEnergySolarSettings
|
||||
extends DirtyStateProviderMixin<SolarFormState>()(LitElement)
|
||||
implements HassDialog<EnergySettingsSolarDialogParams>
|
||||
{
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: EnergySettingsSolarDialogParams;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _source?: SolarSourceTypeEnergyPreference;
|
||||
|
||||
@state() private _configEntries?: ConfigEntry[];
|
||||
|
||||
@state() private _forecast?: boolean;
|
||||
|
||||
@state() private _energy_units?: string[];
|
||||
|
||||
@state() private _power_units?: string[];
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@state() private _saving = false;
|
||||
|
||||
private _excludeList?: string[];
|
||||
|
||||
private _excludeListPower?: string[];
|
||||
|
||||
public async showDialog(
|
||||
params: EnergySettingsSolarDialogParams
|
||||
): Promise<void> {
|
||||
this._params = params;
|
||||
this._error = undefined;
|
||||
this._saving = false;
|
||||
this._fetchSolarForecastConfigEntries();
|
||||
this._source = params.source
|
||||
? { ...params.source }
|
||||
: emptySolarEnergyPreference();
|
||||
this._forecast = this._source.config_entry_solar_forecast !== null;
|
||||
this._energy_units = (
|
||||
await getSensorDeviceClassConvertibleUnits(this.hass, "energy")
|
||||
).units;
|
||||
this._power_units = (
|
||||
await getSensorDeviceClassConvertibleUnits(this.hass, "power")
|
||||
).units;
|
||||
this._excludeList = this._params.solar_sources
|
||||
.map((entry) => entry.stat_energy_from)
|
||||
.filter((id) => id !== this._source?.stat_energy_from);
|
||||
this._excludeListPower = this._params.solar_sources
|
||||
.map((entry) => entry.stat_rate)
|
||||
.filter((id) => id && id !== this._source?.stat_rate) as string[];
|
||||
|
||||
this._open = true;
|
||||
this._initDirtyTracking(
|
||||
{ type: "deep" },
|
||||
{ source: this._source!, forecast: this._forecast! }
|
||||
);
|
||||
}
|
||||
|
||||
public closeDialog() {
|
||||
this._open = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private _dialogClosed() {
|
||||
this._params = undefined;
|
||||
this._source = undefined;
|
||||
this._error = undefined;
|
||||
this._excludeList = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params || !this._source) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const labelStatistic =
|
||||
this._source.stat_energy_from || this._source.stat_rate;
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
header-title=${this.hass.localize(
|
||||
"ui.panel.config.energy.solar.dialog.header"
|
||||
)}
|
||||
.preventScrimClose=${this.isDirtyState}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
${this._error ? html`<p class="error">${this._error}</p>` : ""}
|
||||
|
||||
<ha-statistic-picker
|
||||
.helpMissingEntityUrl=${energyStatisticHelpUrl}
|
||||
.includeUnitClass=${energyUnitClasses}
|
||||
.value=${this._source.stat_energy_from}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.solar.dialog.solar_production_energy"
|
||||
)}
|
||||
.excludeStatistics=${this._excludeList}
|
||||
@value-changed=${this._statisticChanged}
|
||||
.disabled=${this._saving}
|
||||
.helper=${this.hass.localize(
|
||||
"ui.panel.config.energy.solar.dialog.entity_para",
|
||||
{ unit: this._energy_units?.join(", ") || "" }
|
||||
)}
|
||||
autofocus
|
||||
></ha-statistic-picker>
|
||||
|
||||
<ha-input
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.solar.dialog.display_name"
|
||||
)}
|
||||
type="text"
|
||||
.disabled=${!labelStatistic || this._saving}
|
||||
.value=${this._source?.name || ""}
|
||||
.placeholder=${
|
||||
labelStatistic
|
||||
? getStatisticLabel(
|
||||
this.hass.states,
|
||||
this.hass.formatEntityName,
|
||||
labelStatistic,
|
||||
this._params?.statsMetadata?.[labelStatistic]
|
||||
)
|
||||
: ""
|
||||
}
|
||||
@input=${this._nameChanged}
|
||||
>
|
||||
</ha-input>
|
||||
|
||||
<ha-statistic-picker
|
||||
.includeUnitClass=${powerUnitClasses}
|
||||
.value=${this._source.stat_rate}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.solar.dialog.solar_production_power"
|
||||
)}
|
||||
.excludeStatistics=${this._excludeListPower}
|
||||
@value-changed=${this._powerStatisticChanged}
|
||||
.disabled=${this._saving}
|
||||
.helper=${this.hass.localize(
|
||||
"ui.panel.config.energy.solar.dialog.entity_para",
|
||||
{ unit: this._power_units?.join(", ") || "" }
|
||||
)}
|
||||
></ha-statistic-picker>
|
||||
|
||||
<h3>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.solar.dialog.solar_production_forecast"
|
||||
)}
|
||||
</h3>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.solar.dialog.solar_production_forecast_description"
|
||||
)}
|
||||
</p>
|
||||
|
||||
<ha-radio-group
|
||||
.value=${this._forecast ? "true" : "false"}
|
||||
.disabled=${this._saving}
|
||||
name="forecast"
|
||||
@change=${this._handleForecastChanged}
|
||||
>
|
||||
<ha-radio-option value="false">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.solar.dialog.dont_forecast_production"
|
||||
)}
|
||||
</ha-radio-option>
|
||||
<ha-radio-option value="true">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.solar.dialog.forecast_production"
|
||||
)}
|
||||
</ha-radio-option>
|
||||
</ha-radio-group>
|
||||
${
|
||||
this._forecast
|
||||
? html`<div class="forecast-options">
|
||||
${this._configEntries?.map(
|
||||
(entry) =>
|
||||
html`<ha-checkbox
|
||||
.entry=${entry}
|
||||
@change=${this._forecastCheckChanged}
|
||||
.disabled=${this._saving}
|
||||
.checked=${!!this._source?.config_entry_solar_forecast?.includes(
|
||||
entry.entry_id
|
||||
)}
|
||||
>
|
||||
<div style="display: flex; align-items: center;">
|
||||
<img
|
||||
alt=""
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
style="height: 24px; margin-right: 16px; margin-inline-end: 16px; margin-inline-start: initial;"
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain: entry.domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
/>${entry.title || domainToName(this.hass.localize, entry.domain)}
|
||||
</div>
|
||||
</ha-checkbox>`
|
||||
)}
|
||||
<ha-button
|
||||
appearance="filled"
|
||||
size="s"
|
||||
@click=${this._addForecast}
|
||||
.disabled=${this._saving}
|
||||
>
|
||||
<ha-svg-icon .path=${mdiPlus} slot="start"></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.solar.dialog.add_forecast"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>`
|
||||
: ""
|
||||
}
|
||||
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
@click=${this.closeDialog}
|
||||
slot="secondaryAction"
|
||||
>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
@click=${this._save}
|
||||
.disabled=${
|
||||
this._saving ||
|
||||
!(this._source!.stat_energy_from || this._integralUnits()) ||
|
||||
(!!this._params?.source && !this.isDirtyState)
|
||||
}
|
||||
.loading=${this._saving}
|
||||
slot="primaryAction"
|
||||
>
|
||||
${this.hass.localize("ui.common.save")}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _fetchSolarForecastConfigEntries() {
|
||||
const domains = this._params!.info.solar_forecast_domains;
|
||||
this._configEntries =
|
||||
domains.length === 0
|
||||
? []
|
||||
: domains.length === 1
|
||||
? await getConfigEntries(this.hass, {
|
||||
type: ["service"],
|
||||
domain: domains[0],
|
||||
})
|
||||
: (await getConfigEntries(this.hass, { type: ["service"] })).filter(
|
||||
(entry) => domains.includes(entry.domain)
|
||||
);
|
||||
}
|
||||
|
||||
private _handleForecastChanged(ev: HASSDomCurrentTargetEvent<HaRadioGroup>) {
|
||||
this._forecast = (ev.currentTarget as HaRadioGroup).value === "true";
|
||||
this._updateFormDirtyState();
|
||||
}
|
||||
|
||||
private _forecastCheckChanged(ev) {
|
||||
const input = ev.currentTarget as HaCheckbox;
|
||||
const entry = (input as any).entry as ConfigEntry;
|
||||
const checked = input.checked;
|
||||
const list = this._source!.config_entry_solar_forecast
|
||||
? [...this._source!.config_entry_solar_forecast]
|
||||
: [];
|
||||
if (checked) {
|
||||
list.push(entry.entry_id);
|
||||
} else {
|
||||
list.splice(list.indexOf(entry.entry_id), 1);
|
||||
}
|
||||
this._source = { ...this._source!, config_entry_solar_forecast: list };
|
||||
this._updateFormDirtyState();
|
||||
}
|
||||
|
||||
private _addForecast() {
|
||||
showConfigFlowDialog(this, {
|
||||
startFlowHandler: "forecast_solar",
|
||||
dialogClosedCallback: (params) => {
|
||||
if (params.entryId) {
|
||||
const list = this._source!.config_entry_solar_forecast
|
||||
? [...this._source!.config_entry_solar_forecast]
|
||||
: [];
|
||||
list.push(params.entryId);
|
||||
this._source = {
|
||||
...this._source!,
|
||||
config_entry_solar_forecast: list,
|
||||
};
|
||||
this._fetchSolarForecastConfigEntries();
|
||||
this._updateFormDirtyState();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async _statisticChanged(ev: ValueChangedEvent<string>) {
|
||||
this._source = { ...this._source!, stat_energy_from: ev.detail.value };
|
||||
if (
|
||||
ev.detail.value &&
|
||||
isExternalStatistic(ev.detail.value) &&
|
||||
this._params?.statsMetadata &&
|
||||
!(ev.detail.value in this._params.statsMetadata)
|
||||
) {
|
||||
const [metadata] = await getStatisticMetadata(this.hass.callWS, [
|
||||
ev.detail.value,
|
||||
]);
|
||||
if (metadata) {
|
||||
this._params.statsMetadata[ev.detail.value] = metadata;
|
||||
this.requestUpdate("_params");
|
||||
}
|
||||
}
|
||||
this._updateFormDirtyState();
|
||||
}
|
||||
|
||||
private _powerStatisticChanged(ev: ValueChangedEvent<string>) {
|
||||
this._source = { ...this._source!, stat_rate: ev.detail.value };
|
||||
this._updateFormDirtyState();
|
||||
}
|
||||
|
||||
private _nameChanged(ev: InputEvent) {
|
||||
this._source = {
|
||||
...this._source!,
|
||||
name: (ev.target as HaInput).value,
|
||||
};
|
||||
if (!this._source.name) {
|
||||
delete this._source.name;
|
||||
}
|
||||
this._updateFormDirtyState();
|
||||
}
|
||||
|
||||
private _updateFormDirtyState(): void {
|
||||
this._updateDirtyState({
|
||||
source: this._source!,
|
||||
forecast: this._forecast!,
|
||||
});
|
||||
}
|
||||
|
||||
private _integralUnits(): IntegralUnits | undefined {
|
||||
const statRate = this._source?.stat_rate;
|
||||
if (!statRate || !this._energy_units) {
|
||||
return undefined;
|
||||
}
|
||||
return integralUnits(
|
||||
this.hass.states[statRate]?.attributes.unit_of_measurement,
|
||||
this._energy_units
|
||||
);
|
||||
}
|
||||
|
||||
private async _save() {
|
||||
// Closing or reopening the dialog replaces its state, but a created helper
|
||||
// must still be saved with the submitted source
|
||||
const params = this._params!;
|
||||
const forecast = this._forecast;
|
||||
let source = this._source!;
|
||||
const isCurrent = () => this._params === params;
|
||||
this._error = undefined;
|
||||
this._saving = true;
|
||||
try {
|
||||
if (!source.stat_energy_from) {
|
||||
const statEnergyFrom = await this._energyFromPower(source);
|
||||
if (!statEnergyFrom) {
|
||||
return;
|
||||
}
|
||||
source = { ...source, stat_energy_from: statEnergyFrom };
|
||||
if (isCurrent()) {
|
||||
// Keep edits made meanwhile, only fill in the new energy sensor
|
||||
this._source = { ...this._source!, stat_energy_from: statEnergyFrom };
|
||||
this._updateFormDirtyState();
|
||||
}
|
||||
}
|
||||
if (!forecast) {
|
||||
source = { ...source, config_entry_solar_forecast: null };
|
||||
}
|
||||
await params.saveCallback(source);
|
||||
if (isCurrent()) {
|
||||
this._markDirtyStateClean();
|
||||
this.closeDialog();
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (isCurrent()) {
|
||||
this._error = err.message;
|
||||
}
|
||||
} finally {
|
||||
if (isCurrent()) {
|
||||
this._saving = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _energyFromPower(
|
||||
draft: SolarSourceTypeEnergyPreference
|
||||
): Promise<string | undefined> {
|
||||
const statRate = draft.stat_rate!;
|
||||
const units = this._integralUnits();
|
||||
if (!units) {
|
||||
return undefined;
|
||||
}
|
||||
const sensors = await confirmIntegrals(this, this.hass, {
|
||||
deviceClass: "energy",
|
||||
states: this.hass.states,
|
||||
entities: this.hass.entities,
|
||||
requests: [
|
||||
{
|
||||
rate: statRate,
|
||||
units,
|
||||
name: this.hass.localize(
|
||||
"ui.panel.config.energy.integral.energy.sensor_name",
|
||||
{
|
||||
name: computeEnergyLabel(
|
||||
this.hass.states,
|
||||
this.hass.formatEntityName,
|
||||
statRate,
|
||||
this._params?.statsMetadata?.[statRate],
|
||||
draft.name
|
||||
),
|
||||
}
|
||||
),
|
||||
},
|
||||
],
|
||||
intro: this.hass.localize(
|
||||
"ui.panel.config.energy.solar.dialog.energy_sensor_needed"
|
||||
),
|
||||
exclude: this._excludeList,
|
||||
statsMetadata: this._params?.statsMetadata,
|
||||
});
|
||||
return sensors?.[0];
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-statistic-picker {
|
||||
display: block;
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
img {
|
||||
height: 24px;
|
||||
margin-right: 16px;
|
||||
margin-inline-end: 16px;
|
||||
margin-inline-start: initial;
|
||||
}
|
||||
ha-statistic-picker {
|
||||
width: 100%;
|
||||
}
|
||||
ha-radio-group {
|
||||
margin-bottom: var(--ha-space-3);
|
||||
}
|
||||
.forecast-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
gap: var(--ha-space-2);
|
||||
margin-inline-start: var(--ha-space-3);
|
||||
}
|
||||
.forecast-options ha-button {
|
||||
margin-top: var(--ha-space-4);
|
||||
width: fit-content;
|
||||
}
|
||||
.forecast-options ha-checkbox {
|
||||
justify-content: center;
|
||||
min-height: 40px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-energy-solar-settings": DialogEnergySolarSettings;
|
||||
}
|
||||
}
|
||||
@@ -1,546 +0,0 @@
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { HASSDomCurrentTargetEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/entity/ha-entity-picker";
|
||||
import "../../../../components/entity/ha-statistic-picker";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-dialog-footer";
|
||||
import "../../../../components/ha-markdown";
|
||||
import "../../../../components/ha-dialog";
|
||||
import "../../../../components/radio/ha-radio-group";
|
||||
import type { HaRadioGroup } from "../../../../components/radio/ha-radio-group";
|
||||
import "../../../../components/radio/ha-radio-option";
|
||||
import "../../../../components/input/ha-input";
|
||||
import type { WaterSourceTypeEnergyPreference } from "../../../../data/energy";
|
||||
import {
|
||||
computeEnergyLabel,
|
||||
emptyWaterEnergyPreference,
|
||||
energyStatisticHelpUrl,
|
||||
} from "../../../../data/energy";
|
||||
import {
|
||||
getStatisticLabel,
|
||||
getStatisticMetadata,
|
||||
isExternalStatistic,
|
||||
} from "../../../../data/recorder";
|
||||
import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
|
||||
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
|
||||
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
|
||||
import { haStyle, haStyleDialog } from "../../../../resources/styles";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import type { EnergySettingsWaterDialogParams } from "./show-dialogs-energy";
|
||||
import type { HaInput } from "../../../../components/input/ha-input";
|
||||
import type { IntegralUnits } from "./energy-integral";
|
||||
import { confirmIntegrals, integralUnits } from "./energy-integral";
|
||||
|
||||
type CostType = "no-costs" | "number" | "entity" | "statistic";
|
||||
|
||||
interface WaterFormState {
|
||||
source: WaterSourceTypeEnergyPreference;
|
||||
costs: CostType;
|
||||
}
|
||||
|
||||
const flowRateUnitClasses = ["volume_flow_rate"];
|
||||
|
||||
@customElement("dialog-energy-water-settings")
|
||||
export class DialogEnergyWaterSettings
|
||||
extends DirtyStateProviderMixin<WaterFormState>()(LitElement)
|
||||
implements HassDialog<EnergySettingsWaterDialogParams>
|
||||
{
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: EnergySettingsWaterDialogParams;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _source?: WaterSourceTypeEnergyPreference;
|
||||
|
||||
@state() private _costs?: CostType;
|
||||
|
||||
@state() private _numberPriceText = "";
|
||||
|
||||
@state() private _water_units?: string[];
|
||||
|
||||
@state() private _flow_rate_units?: string[];
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@state() private _saving = false;
|
||||
|
||||
private _excludeList?: string[];
|
||||
|
||||
private _excludeListFlowRate?: string[];
|
||||
|
||||
public async showDialog(
|
||||
params: EnergySettingsWaterDialogParams
|
||||
): Promise<void> {
|
||||
this._params = params;
|
||||
this._error = undefined;
|
||||
this._saving = false;
|
||||
this._source = params.source
|
||||
? { ...params.source }
|
||||
: emptyWaterEnergyPreference();
|
||||
this._costs = this._source.entity_energy_price
|
||||
? "entity"
|
||||
: typeof this._source.number_energy_price === "number"
|
||||
? "number"
|
||||
: this._source.stat_cost
|
||||
? "statistic"
|
||||
: "no-costs";
|
||||
this._numberPriceText =
|
||||
typeof this._source.number_energy_price === "number"
|
||||
? String(this._source.number_energy_price)
|
||||
: "";
|
||||
this._water_units = (
|
||||
await getSensorDeviceClassConvertibleUnits(this.hass, "water")
|
||||
).units;
|
||||
this._flow_rate_units = (
|
||||
await getSensorDeviceClassConvertibleUnits(this.hass, "volume_flow_rate")
|
||||
).units;
|
||||
this._excludeList = this._params.water_sources
|
||||
.map((entry) => entry.stat_energy_from)
|
||||
.filter((id) => id !== this._source?.stat_energy_from);
|
||||
this._excludeListFlowRate = this._params.water_sources
|
||||
.map((entry) => entry.stat_rate)
|
||||
.filter((id) => id && id !== this._source?.stat_rate) as string[];
|
||||
|
||||
this._open = true;
|
||||
this._initDirtyTracking(
|
||||
{ type: "deep" },
|
||||
{ source: this._source!, costs: this._costs! }
|
||||
);
|
||||
}
|
||||
|
||||
public closeDialog() {
|
||||
this._open = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private _dialogClosed() {
|
||||
this._params = undefined;
|
||||
this._source = undefined;
|
||||
this._error = undefined;
|
||||
this._excludeList = undefined;
|
||||
this._excludeListFlowRate = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params || !this._source) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const pickableUnit = this._water_units?.join(", ") || "";
|
||||
|
||||
const unitPriceFixed = `${this.hass.config.currency}/${
|
||||
this.hass.config.unit_system.volume === "gal" ? "gal" : "m³"
|
||||
}`;
|
||||
|
||||
const externalSource =
|
||||
this._source.stat_energy_from &&
|
||||
isExternalStatistic(this._source.stat_energy_from);
|
||||
|
||||
const labelStatistic =
|
||||
this._source.stat_energy_from || this._source.stat_rate;
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
header-title=${this.hass.localize(
|
||||
"ui.panel.config.energy.water.dialog.header"
|
||||
)}
|
||||
.preventScrimClose=${this.isDirtyState}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
${this._error ? html`<p class="error">${this._error}</p>` : ""}
|
||||
|
||||
<ha-statistic-picker
|
||||
.helpMissingEntityUrl=${energyStatisticHelpUrl}
|
||||
include-unit-class="volume"
|
||||
include-device-class="water"
|
||||
.value=${this._source.stat_energy_from}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.water.dialog.water_usage"
|
||||
)}
|
||||
.excludeStatistics=${this._excludeList}
|
||||
@value-changed=${this._statisticChanged}
|
||||
.disabled=${this._saving}
|
||||
.helper=${this.hass.localize(
|
||||
"ui.panel.config.energy.water.dialog.entity_para",
|
||||
{ unit: pickableUnit }
|
||||
)}
|
||||
autofocus
|
||||
></ha-statistic-picker>
|
||||
|
||||
<ha-statistic-picker
|
||||
.includeUnitClass=${flowRateUnitClasses}
|
||||
.value=${this._source.stat_rate}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.water.dialog.water_flow_rate"
|
||||
)}
|
||||
.excludeStatistics=${this._excludeListFlowRate}
|
||||
@value-changed=${this._flowRateStatisticChanged}
|
||||
.disabled=${this._saving}
|
||||
.invalid=${!!this._flowRateError()}
|
||||
.errorMessage=${this._flowRateError()}
|
||||
.helper=${this.hass.localize(
|
||||
"ui.panel.config.energy.water.dialog.flow_rate_para",
|
||||
{ unit: this._flow_rate_units?.join(", ") || "" }
|
||||
)}
|
||||
></ha-statistic-picker>
|
||||
|
||||
<ha-input
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.water.dialog.display_name"
|
||||
)}
|
||||
type="text"
|
||||
.disabled=${!labelStatistic || this._saving}
|
||||
.value=${this._source?.name || ""}
|
||||
.placeholder=${
|
||||
labelStatistic
|
||||
? getStatisticLabel(
|
||||
this.hass.states,
|
||||
this.hass.formatEntityName,
|
||||
labelStatistic,
|
||||
this._params?.statsMetadata?.[labelStatistic]
|
||||
)
|
||||
: ""
|
||||
}
|
||||
@input=${this._nameChanged}
|
||||
>
|
||||
</ha-input>
|
||||
|
||||
<ha-radio-group
|
||||
.disabled=${this._saving}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.water.dialog.cost_para"
|
||||
)}
|
||||
.value=${this._costs}
|
||||
name="costs"
|
||||
@change=${this._handleCostChanged}
|
||||
>
|
||||
<ha-radio-option value="no-costs">
|
||||
${this.hass.localize("ui.panel.config.energy.water.dialog.no_cost")}
|
||||
</ha-radio-option>
|
||||
<ha-radio-option value="statistic">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.water.dialog.cost_stat"
|
||||
)}
|
||||
</ha-radio-option>
|
||||
<ha-radio-option value="entity" .disabled=${externalSource}>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.water.dialog.cost_entity"
|
||||
)}
|
||||
</ha-radio-option>
|
||||
<ha-radio-option value="number" .disabled=${externalSource}>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.energy.water.dialog.cost_number"
|
||||
)}
|
||||
</ha-radio-option>
|
||||
</ha-radio-group>
|
||||
${
|
||||
this._costs === "statistic"
|
||||
? html`<ha-statistic-picker
|
||||
.disabled=${this._saving}
|
||||
class="price-options"
|
||||
statistic-types="sum"
|
||||
.value=${this._source.stat_cost}
|
||||
.label=${`${this.hass.localize(
|
||||
"ui.panel.config.energy.water.dialog.cost_stat_input"
|
||||
)} (${this.hass.config.currency})`}
|
||||
@value-changed=${this._priceStatChanged}
|
||||
></ha-statistic-picker>`
|
||||
: this._costs === "entity"
|
||||
? html`<ha-entity-picker
|
||||
.disabled=${this._saving}
|
||||
class="price-options"
|
||||
include-domains='["sensor", "input_number"]'
|
||||
.value=${this._source.entity_energy_price}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.energy.water.dialog.cost_entity_input"
|
||||
)}
|
||||
.helper=${html`<ha-markdown
|
||||
.content=${this.hass.localize(
|
||||
"ui.panel.config.energy.water.dialog.cost_entity_helper",
|
||||
{ currency: this.hass.config.currency }
|
||||
)}
|
||||
></ha-markdown>`}
|
||||
@value-changed=${this._priceEntityChanged}
|
||||
></ha-entity-picker>`
|
||||
: this._costs === "number"
|
||||
? html`<ha-input
|
||||
.disabled=${this._saving}
|
||||
.label=${`${this.hass.localize(
|
||||
"ui.panel.config.energy.water.dialog.cost_number_input"
|
||||
)} (${unitPriceFixed})`}
|
||||
class="price-options"
|
||||
step="any"
|
||||
type="number"
|
||||
required
|
||||
auto-validate
|
||||
.value=${this._numberPriceText}
|
||||
@input=${this._numberPriceChanged}
|
||||
>
|
||||
<span slot="end">${unitPriceFixed}</span>
|
||||
</ha-input>`
|
||||
: nothing
|
||||
}
|
||||
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
@click=${this.closeDialog}
|
||||
slot="secondaryAction"
|
||||
>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
@click=${this._save}
|
||||
.disabled=${
|
||||
this._saving ||
|
||||
!(this._source!.stat_energy_from || this._integralUnits()) ||
|
||||
(this._costs === "number" &&
|
||||
typeof this._source!.number_energy_price !== "number") ||
|
||||
(!!this._params?.source && !this.isDirtyState)
|
||||
}
|
||||
.loading=${this._saving}
|
||||
slot="primaryAction"
|
||||
>
|
||||
${this.hass.localize("ui.common.save")}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleCostChanged(ev: HASSDomCurrentTargetEvent<HaRadioGroup>) {
|
||||
this._costs = (ev.currentTarget as HaRadioGroup).value as CostType;
|
||||
this._updateFormDirtyState();
|
||||
}
|
||||
|
||||
private _numberPriceChanged(ev: HASSDomCurrentTargetEvent<HaInput>) {
|
||||
// Render the typed text so partial input such as "0." is not rewritten
|
||||
this._numberPriceText = ev.currentTarget.value ?? "";
|
||||
this._source = {
|
||||
...this._source!,
|
||||
number_energy_price: this._numberPriceText
|
||||
? parseFloat(this._numberPriceText)
|
||||
: null,
|
||||
entity_energy_price: null,
|
||||
stat_cost: null,
|
||||
};
|
||||
this._updateFormDirtyState();
|
||||
}
|
||||
|
||||
private _priceStatChanged(ev: CustomEvent) {
|
||||
this._numberPriceText = "";
|
||||
this._source = {
|
||||
...this._source!,
|
||||
entity_energy_price: null,
|
||||
number_energy_price: null,
|
||||
stat_cost: ev.detail.value,
|
||||
};
|
||||
this._updateFormDirtyState();
|
||||
}
|
||||
|
||||
private _priceEntityChanged(ev: CustomEvent) {
|
||||
this._numberPriceText = "";
|
||||
this._source = {
|
||||
...this._source!,
|
||||
entity_energy_price: ev.detail.value,
|
||||
number_energy_price: null,
|
||||
stat_cost: null,
|
||||
};
|
||||
this._updateFormDirtyState();
|
||||
}
|
||||
|
||||
private _flowRateStatisticChanged(ev: ValueChangedEvent<string>) {
|
||||
this._source = {
|
||||
...this._source!,
|
||||
stat_rate: ev.detail.value || undefined,
|
||||
};
|
||||
this._updateFormDirtyState();
|
||||
}
|
||||
|
||||
private async _statisticChanged(ev: ValueChangedEvent<string>) {
|
||||
if (
|
||||
ev.detail.value &&
|
||||
isExternalStatistic(ev.detail.value) &&
|
||||
this._costs !== "statistic"
|
||||
) {
|
||||
this._costs = "no-costs";
|
||||
}
|
||||
this._source = {
|
||||
...this._source!,
|
||||
stat_energy_from: ev.detail.value,
|
||||
};
|
||||
|
||||
if (
|
||||
ev.detail.value &&
|
||||
isExternalStatistic(ev.detail.value) &&
|
||||
this._params?.statsMetadata &&
|
||||
!(ev.detail.value in this._params.statsMetadata)
|
||||
) {
|
||||
const [metadata] = await getStatisticMetadata(this.hass.callWS, [
|
||||
ev.detail.value,
|
||||
]);
|
||||
if (metadata) {
|
||||
this._params.statsMetadata[ev.detail.value] = metadata;
|
||||
this.requestUpdate("_params");
|
||||
}
|
||||
}
|
||||
this._updateFormDirtyState();
|
||||
}
|
||||
|
||||
private _nameChanged(ev: InputEvent) {
|
||||
this._source = {
|
||||
...this._source!,
|
||||
name: (ev.target as HaInput).value,
|
||||
};
|
||||
if (!this._source.name) {
|
||||
delete this._source.name;
|
||||
}
|
||||
this._updateFormDirtyState();
|
||||
}
|
||||
|
||||
private _updateFormDirtyState(): void {
|
||||
this._updateDirtyState({ source: this._source!, costs: this._costs! });
|
||||
}
|
||||
|
||||
private _flowRateError(): string | undefined {
|
||||
if (
|
||||
this._source?.stat_energy_from ||
|
||||
!this._source?.stat_rate ||
|
||||
!this._water_units ||
|
||||
this._integralUnits()
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.energy.water.dialog.flow_rate_no_volume"
|
||||
);
|
||||
}
|
||||
|
||||
private _integralUnits(): IntegralUnits | undefined {
|
||||
const statRate = this._source?.stat_rate;
|
||||
if (!statRate || !this._water_units) {
|
||||
return undefined;
|
||||
}
|
||||
return integralUnits(
|
||||
this.hass.states[statRate]?.attributes.unit_of_measurement,
|
||||
this._water_units
|
||||
);
|
||||
}
|
||||
|
||||
private async _save() {
|
||||
// Closing or reopening the dialog replaces its state, but a created helper
|
||||
// must still be saved with the submitted source
|
||||
const params = this._params!;
|
||||
const costs = this._costs;
|
||||
let source = this._source!;
|
||||
const isCurrent = () => this._params === params;
|
||||
this._error = undefined;
|
||||
this._saving = true;
|
||||
try {
|
||||
if (!source.stat_energy_from) {
|
||||
const statEnergyFrom = await this._waterFromFlowRate(source);
|
||||
if (!statEnergyFrom) {
|
||||
return;
|
||||
}
|
||||
source = { ...source, stat_energy_from: statEnergyFrom };
|
||||
if (isCurrent()) {
|
||||
// Keep edits made meanwhile, only fill in the new sensor
|
||||
this._source = { ...this._source!, stat_energy_from: statEnergyFrom };
|
||||
this._updateFormDirtyState();
|
||||
}
|
||||
}
|
||||
if (costs === "no-costs") {
|
||||
source = {
|
||||
...source,
|
||||
entity_energy_price: null,
|
||||
number_energy_price: null,
|
||||
stat_cost: null,
|
||||
};
|
||||
}
|
||||
await params.saveCallback(source);
|
||||
if (isCurrent()) {
|
||||
this._markDirtyStateClean();
|
||||
this.closeDialog();
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (isCurrent()) {
|
||||
this._error = err.message;
|
||||
}
|
||||
} finally {
|
||||
if (isCurrent()) {
|
||||
this._saving = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _waterFromFlowRate(
|
||||
draft: WaterSourceTypeEnergyPreference
|
||||
): Promise<string | undefined> {
|
||||
const statRate = draft.stat_rate!;
|
||||
const units = this._integralUnits();
|
||||
if (!units) {
|
||||
return undefined;
|
||||
}
|
||||
const sensors = await confirmIntegrals(this, this.hass, {
|
||||
deviceClass: "water",
|
||||
states: this.hass.states,
|
||||
entities: this.hass.entities,
|
||||
requests: [
|
||||
{
|
||||
rate: statRate,
|
||||
units,
|
||||
name: this.hass.localize(
|
||||
"ui.panel.config.energy.integral.water.sensor_name",
|
||||
{
|
||||
name: computeEnergyLabel(
|
||||
this.hass.states,
|
||||
this.hass.formatEntityName,
|
||||
statRate,
|
||||
this._params?.statsMetadata?.[statRate],
|
||||
draft.name
|
||||
),
|
||||
}
|
||||
),
|
||||
},
|
||||
],
|
||||
intro: this.hass.localize(
|
||||
"ui.panel.config.energy.water.dialog.water_sensor_needed"
|
||||
),
|
||||
exclude: this._excludeList,
|
||||
statsMetadata: this._params?.statsMetadata,
|
||||
});
|
||||
return sensors?.[0];
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-statistic-picker {
|
||||
display: block;
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
ha-radio-group {
|
||||
margin-top: var(--ha-space-4);
|
||||
}
|
||||
.price-options {
|
||||
display: block;
|
||||
margin-top: var(--ha-space-3);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-energy-water-settings": DialogEnergyWaterSettings;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,12 @@ import type { StatisticsMetaData } from "../../../../data/recorder";
|
||||
import { getStatisticLabel } from "../../../../data/recorder";
|
||||
import { findRelated } from "../../../../data/search";
|
||||
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import type {
|
||||
HomeAssistant,
|
||||
HomeAssistantApi,
|
||||
HomeAssistantFormatters,
|
||||
} from "../../../../types";
|
||||
import type { PowerType } from "./power-config";
|
||||
|
||||
export type IntegralDeviceClass = "energy" | "gas" | "water";
|
||||
@@ -154,23 +159,24 @@ export function integralFlowInput(
|
||||
}
|
||||
|
||||
async function createIntegral(
|
||||
hass: HomeAssistant,
|
||||
api: HomeAssistantApi,
|
||||
localize: LocalizeFunc,
|
||||
request: IntegralRequest,
|
||||
deviceClass: IntegralDeviceClass
|
||||
): Promise<string> {
|
||||
let flowId: string | undefined;
|
||||
let entityId: string | undefined;
|
||||
try {
|
||||
flowId = (await createConfigFlow(hass, "integration")).flow_id;
|
||||
flowId = (await createConfigFlow(api.callApi, "integration")).flow_id;
|
||||
const step = await handleConfigFlowStep(
|
||||
hass,
|
||||
api.callApi,
|
||||
flowId,
|
||||
integralFlowInput(request.name, request.rate, request.units, deviceClass)
|
||||
);
|
||||
if (step.type === "create_entry" && step.result) {
|
||||
flowId = undefined;
|
||||
const related = await findRelated(
|
||||
hass,
|
||||
api,
|
||||
"config_entry",
|
||||
step.result.entry_id
|
||||
);
|
||||
@@ -181,17 +187,18 @@ async function createIntegral(
|
||||
}
|
||||
if (flowId) {
|
||||
// Rejected input keeps the flow open
|
||||
await deleteConfigFlow(hass, flowId).catch(() => undefined);
|
||||
await deleteConfigFlow(api, flowId).catch(() => undefined);
|
||||
}
|
||||
if (!entityId) {
|
||||
throw new Error(
|
||||
hass.localize("ui.panel.config.energy.integral.create_failed")
|
||||
);
|
||||
throw new Error(localize("ui.panel.config.energy.integral.create_failed"));
|
||||
}
|
||||
return entityId;
|
||||
}
|
||||
|
||||
export interface ConfirmIntegralsOptions {
|
||||
api: HomeAssistantApi;
|
||||
localize: LocalizeFunc;
|
||||
formatEntityName: HomeAssistantFormatters["formatEntityName"];
|
||||
deviceClass: IntegralDeviceClass;
|
||||
/** Live states and entities, so a retry finds the helpers already created. */
|
||||
states: HomeAssistant["states"];
|
||||
@@ -211,11 +218,10 @@ export interface ConfirmIntegralsOptions {
|
||||
*/
|
||||
export async function confirmIntegrals(
|
||||
element: HTMLElement,
|
||||
hass: HomeAssistant,
|
||||
options: ConfirmIntegralsOptions
|
||||
): Promise<string[] | undefined> {
|
||||
const { localize, formatEntityName } = hass;
|
||||
const { deviceClass, states, entities } = options;
|
||||
const { api, localize, formatEntityName, deviceClass, states, entities } =
|
||||
options;
|
||||
const label = (statisticId: string) =>
|
||||
getStatisticLabel(
|
||||
states,
|
||||
@@ -284,7 +290,7 @@ export async function confirmIntegrals(
|
||||
const settled = await Promise.allSettled(
|
||||
plan.map(
|
||||
({ request, existing }) =>
|
||||
existing ?? createIntegral(hass, request, deviceClass)
|
||||
existing ?? createIntegral(api, localize, request, deviceClass)
|
||||
)
|
||||
);
|
||||
const failure = settled.find(
|
||||
|
||||
Loaded 100 of 190 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user