mirror of
https://github.com/home-assistant/frontend.git
synced 2026-11-14 08:53:25 +00:00
Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
692338459d | ||
|
|
a8ee519fcf | ||
|
|
fdbab9046c | ||
|
|
9f19a66a51 | ||
|
|
7b5d02bdba | ||
|
|
3a3228bf79 | ||
|
|
a299f5f9a7 | ||
|
|
27902ef586 | ||
|
|
2d0ab5349b | ||
|
|
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 |
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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ReactiveElement } from "lit";
|
||||
import { ContextSubscriptionController } from "../../../src/common/decorators/consume";
|
||||
import { uiContext } from "../../../src/data/context";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
// A demo that provides its own hass hides the gallery theme from its children,
|
||||
// so pass the gallery light or dark mode on to it.
|
||||
export const followGalleryTheme = (
|
||||
host: ReactiveElement,
|
||||
hass: MockHomeAssistant
|
||||
) =>
|
||||
new ContextSubscriptionController(host, uiContext, (ui) => {
|
||||
if (ui) {
|
||||
hass.updateHass({
|
||||
themes: ui.themes,
|
||||
selectedTheme: ui.selectedTheme,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
} from "../../../../src/data/history";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
import { followGalleryTheme } from "../../data/follow-gallery-theme";
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const HOURS = 48;
|
||||
@@ -166,6 +167,7 @@ export class DemoStateHistoryCharts extends LitElement {
|
||||
super.firstUpdated(changedProps);
|
||||
const hass = provideHass(this);
|
||||
hass.updateTranslations(null, "en");
|
||||
followGalleryTheme(this, hass);
|
||||
hass.addEntities(ENTITIES);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { ChartSelectMode } from "../../../../src/components/chart/ha-chart-base";
|
||||
import "../../../../src/components/chart/statistics-chart";
|
||||
import "../../../../src/components/ha-button";
|
||||
import "../../../../src/components/ha-card";
|
||||
import type {
|
||||
Statistics,
|
||||
@@ -11,7 +13,9 @@ import type {
|
||||
} from "../../../../src/data/recorder";
|
||||
import { StatisticMeanType } from "../../../../src/data/recorder";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
import { followGalleryTheme } from "../../data/follow-gallery-theme";
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const HOURS = 48;
|
||||
@@ -108,6 +112,64 @@ interface Example {
|
||||
height?: string;
|
||||
}
|
||||
|
||||
interface TimeRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface RangeExample {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
statistics: Statistics;
|
||||
statTypes: StatisticType[];
|
||||
chartType: "line" | "bar";
|
||||
unit: string;
|
||||
selectMode: ChartSelectMode;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
const RANGE_EXAMPLES: RangeExample[] = [
|
||||
{
|
||||
id: "point",
|
||||
title: "Point selection",
|
||||
description:
|
||||
"With select-mode set to point, a click selects the hour that contains it, and everything outside fades. Dragging still zooms.",
|
||||
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP },
|
||||
statTypes: ["change"],
|
||||
chartType: "bar",
|
||||
unit: "kWh",
|
||||
selectMode: "point",
|
||||
hint: "Click on a bar to select its hour.",
|
||||
},
|
||||
{
|
||||
id: "range-bar",
|
||||
title: "Range selection on bars",
|
||||
description:
|
||||
"With select-mode set to range, dragging selects a time range instead of zooming, so several bars can be selected at once. A click still selects one bar.",
|
||||
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP },
|
||||
statTypes: ["change"],
|
||||
chartType: "bar",
|
||||
unit: "kWh",
|
||||
selectMode: "range",
|
||||
hint: "Click or drag on the chart to select one or more bars.",
|
||||
},
|
||||
{
|
||||
id: "range",
|
||||
title: "Range selection on lines",
|
||||
description: "The same on a line chart.",
|
||||
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM },
|
||||
statTypes: ["mean", "min", "max"],
|
||||
chartType: "line",
|
||||
unit: "°C",
|
||||
selectMode: "range",
|
||||
hint: "Click or drag on the chart to select a period.",
|
||||
},
|
||||
];
|
||||
|
||||
const formatRange = (range: TimeRange) =>
|
||||
`${new Date(range.start).toLocaleString()} to ${new Date(range.end).toLocaleString()}`;
|
||||
|
||||
const EXAMPLES: Example[] = [
|
||||
{
|
||||
title: "Sum as bars",
|
||||
@@ -159,17 +221,20 @@ const EXAMPLES: Example[] = [
|
||||
export class DemoStatisticsChart extends LitElement {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@state() private _selections: Record<string, TimeRange | undefined> = {};
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
const hass = provideHass(this);
|
||||
hass.updateTranslations(null, "en");
|
||||
followGalleryTheme(this, hass);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return nothing;
|
||||
}
|
||||
return EXAMPLES.map(
|
||||
return html`${EXAMPLES.map(
|
||||
(example) => html`
|
||||
<ha-card header=${example.title}>
|
||||
<div class="card-content">
|
||||
@@ -191,7 +256,66 @@ export class DemoStatisticsChart extends LitElement {
|
||||
</div>
|
||||
</ha-card>
|
||||
`
|
||||
);
|
||||
)}
|
||||
${RANGE_EXAMPLES.map((example) => this._renderRangeExample(example))}`;
|
||||
}
|
||||
|
||||
private _renderRangeExample(example: RangeExample) {
|
||||
const selection = this._selections[example.id];
|
||||
return html`
|
||||
<ha-card header=${example.title}>
|
||||
<div class="card-content">
|
||||
<p>${example.description}</p>
|
||||
<statistics-chart
|
||||
.statisticsData=${example.statistics}
|
||||
.metadata=${METADATA}
|
||||
.names=${NAMES}
|
||||
.statTypes=${example.statTypes}
|
||||
.chartType=${example.chartType}
|
||||
period="hour"
|
||||
.unit=${example.unit}
|
||||
.startTime=${START}
|
||||
.endTime=${END}
|
||||
.highlight=${selection}
|
||||
.clickForMoreInfo=${false}
|
||||
data-example=${example.id}
|
||||
.selectMode=${example.selectMode}
|
||||
hide-legend
|
||||
@chart-select=${this._rangeSelected}
|
||||
></statistics-chart>
|
||||
<div class="selection">
|
||||
${
|
||||
selection
|
||||
? html`
|
||||
<span>${formatRange(selection)}</span>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="s"
|
||||
data-example=${example.id}
|
||||
@click=${this._clearSelection}
|
||||
>
|
||||
Clear
|
||||
</ha-button>
|
||||
`
|
||||
: html`<span class="hint">${example.hint}</span>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _rangeSelected(ev: HASSDomEvent<HASSDomEvents["chart-select"]>) {
|
||||
const id = (ev.currentTarget as HTMLElement).dataset.example!;
|
||||
this._selections = {
|
||||
...this._selections,
|
||||
[id]: ev.detail,
|
||||
};
|
||||
}
|
||||
|
||||
private _clearSelection(ev: Event) {
|
||||
const id = (ev.currentTarget as HTMLElement).dataset.example!;
|
||||
this._selections = { ...this._selections, [id]: undefined };
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
@@ -202,6 +326,20 @@ export class DemoStatisticsChart extends LitElement {
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
.selection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--ha-space-2);
|
||||
min-height: 48px;
|
||||
margin-top: var(--ha-space-2);
|
||||
padding: 0 var(--ha-space-2) 0 var(--ha-space-4);
|
||||
border-radius: var(--ha-border-radius-lg);
|
||||
background-color: var(--secondary-background-color);
|
||||
}
|
||||
.hint {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
+13
-13
@@ -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",
|
||||
@@ -88,7 +88,7 @@
|
||||
"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,7 +102,7 @@
|
||||
"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",
|
||||
@@ -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",
|
||||
@@ -215,7 +215,7 @@
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.72.1",
|
||||
"vite": "8.3.6",
|
||||
"vite": "8.3.7",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "5.1.0",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
|
||||
Generated
+253
-151
@@ -222,32 +222,32 @@ importers:
|
||||
specifier: 2.0.17
|
||||
version: 2.0.17
|
||||
'@formatjs/intl-datetimeformat':
|
||||
specifier: 8.0.3
|
||||
version: 8.0.3
|
||||
'@formatjs/intl-displaynames':
|
||||
specifier: 8.0.2
|
||||
version: 8.0.2
|
||||
'@formatjs/intl-displaynames':
|
||||
specifier: 8.0.1
|
||||
version: 8.0.1
|
||||
'@formatjs/intl-durationformat':
|
||||
specifier: 0.12.3
|
||||
version: 0.12.3
|
||||
specifier: 0.12.4
|
||||
version: 0.12.4
|
||||
'@formatjs/intl-getcanonicallocales':
|
||||
specifier: 3.3.0
|
||||
version: 3.3.0
|
||||
specifier: 3.3.1
|
||||
version: 3.3.1
|
||||
'@formatjs/intl-listformat':
|
||||
specifier: 9.0.1
|
||||
version: 9.0.1
|
||||
specifier: 9.0.2
|
||||
version: 9.0.2
|
||||
'@formatjs/intl-locale':
|
||||
specifier: 5.4.2
|
||||
version: 5.4.2
|
||||
specifier: 5.4.3
|
||||
version: 5.4.3
|
||||
'@formatjs/intl-numberformat':
|
||||
specifier: 10.0.2
|
||||
version: 10.0.2
|
||||
specifier: 10.0.3
|
||||
version: 10.0.3
|
||||
'@formatjs/intl-pluralrules':
|
||||
specifier: 7.0.2
|
||||
version: 7.0.2
|
||||
specifier: 7.0.3
|
||||
version: 7.0.3
|
||||
'@formatjs/intl-relativetimeformat':
|
||||
specifier: 13.0.1
|
||||
version: 13.0.1
|
||||
specifier: 13.0.2
|
||||
version: 13.0.2
|
||||
'@home-assistant/webawesome':
|
||||
specifier: 3.14.0-ha.2
|
||||
version: 3.14.0-ha.2(@floating-ui/[email protected])(@types/[email protected])
|
||||
@@ -324,8 +324,8 @@ importers:
|
||||
specifier: 3.51.0
|
||||
version: 3.51.0
|
||||
cropperjs:
|
||||
specifier: 1.7.0
|
||||
version: 1.7.0
|
||||
specifier: 2.3.1
|
||||
version: 2.3.1
|
||||
culori:
|
||||
specifier: 4.0.2
|
||||
version: 4.0.2
|
||||
@@ -366,8 +366,8 @@ importers:
|
||||
specifier: 6.3.0
|
||||
version: 6.3.0
|
||||
intl-messageformat:
|
||||
specifier: 12.1.4
|
||||
version: 12.1.4
|
||||
specifier: 12.1.5
|
||||
version: 12.1.5
|
||||
js-yaml:
|
||||
specifier: 5.5.0
|
||||
version: 5.5.0
|
||||
@@ -595,8 +595,8 @@ importers:
|
||||
specifier: 0.13.11
|
||||
version: 0.13.11([email protected])
|
||||
eslint-plugin-import-x:
|
||||
specifier: 4.18.0
|
||||
version: 4.18.0(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected])
|
||||
specifier: 4.18.1
|
||||
version: 4.18.1(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected])
|
||||
eslint-plugin-lit:
|
||||
specifier: 2.3.1
|
||||
version: 2.3.1([email protected]([email protected]))
|
||||
@@ -700,14 +700,14 @@ importers:
|
||||
specifier: 8.72.1
|
||||
version: 8.72.1([email protected]([email protected]))([email protected])([email protected])
|
||||
vite:
|
||||
specifier: 8.3.6
|
||||
version: 8.3.6(@types/[email protected])([email protected])([email protected])
|
||||
specifier: 8.3.7
|
||||
version: 8.3.7(@types/[email protected])([email protected])([email protected])
|
||||
vite-tsconfig-paths:
|
||||
specifier: 6.1.1
|
||||
version: 6.1.1([email protected])([email protected])([email protected].6(@types/[email protected])([email protected])([email protected]))
|
||||
version: 6.1.1([email protected])([email protected])([email protected].7(@types/[email protected])([email protected])([email protected]))
|
||||
vitest:
|
||||
specifier: 5.1.0
|
||||
version: 5.1.0(@types/[email protected])(@vitest/[email protected])([email protected])([email protected].6(@types/[email protected])([email protected])([email protected]))
|
||||
version: 5.1.0(@types/[email protected])(@vitest/[email protected])([email protected])([email protected].7(@types/[email protected])([email protected])([email protected]))
|
||||
webpack-stats-plugin:
|
||||
specifier: 1.1.3
|
||||
version: 1.1.3
|
||||
@@ -1863,6 +1863,39 @@ packages:
|
||||
peerDependencies:
|
||||
commander: ~15.0.0
|
||||
|
||||
'@cropper/[email protected]':
|
||||
resolution: {integrity: sha512-UH3VMyjn58PdwYkPiTHuvRUjGzmyKr6E2pYWxTxN/04wnjgYLA77B5u5c1HhV6vD1ZKSvxJ/i9TkiySR9P1D6A==}
|
||||
|
||||
'@cropper/[email protected]':
|
||||
resolution: {integrity: sha512-Vz2heRwr2nd2rZ161vKyE4JwYV3eSodGCGGZq39TR0LICGOOeggLq0R664Af/eFB9PU8+tfJ42lWLoUdxQlVJQ==}
|
||||
|
||||
'@cropper/[email protected]':
|
||||
resolution: {integrity: sha512-6LDaUDxH2xdA7biTcqnihPQVHjlT4bXysA403s9rU4jo5f4pPnFHtNbB+FQTYKNz5EKun52WgVtR+6oWbmbxlg==}
|
||||
|
||||
'@cropper/[email protected]':
|
||||
resolution: {integrity: sha512-ygLw89PWu9k8SuKW7rjRBpD6ZfmtVr5ayWux9YKHU7PxX0SeL5kbCD+PpOxQB1ycW4UcyH0tHt77EWnsdSoOHw==}
|
||||
|
||||
'@cropper/[email protected]':
|
||||
resolution: {integrity: sha512-rPE7OX/BPTw66+DjpN5aOuPb1xSAjQMgAJZXdRgmeJnn0YcDDhfGQm8VwcL8/PMLxtWFcKEM+aUiezbO01j4fg==}
|
||||
|
||||
'@cropper/[email protected]':
|
||||
resolution: {integrity: sha512-b8sabaFbHeQYY459uItDIeDY0IisZBO/wF+SYhKvAgI8TExj/f2mNcBZXjPHYCkWel2+1BSUTTNZxy+tR8sDEA==}
|
||||
|
||||
'@cropper/[email protected]':
|
||||
resolution: {integrity: sha512-X5ceZsy7iBxuIRhsL05H9+cPYzxZuvM7/9j5irXIbNsDiNwTedxIHdjhEhPbUYa+FaNn9wsKId24AEyH43JTGw==}
|
||||
|
||||
'@cropper/[email protected]':
|
||||
resolution: {integrity: sha512-EdrYDbnkj0IOl/kyWKzzvcDRobr7Tfzho7FkVu8mpSk1ILDHmVf8pfZrYVwou8l4znLqp8ZqwuabAezJPfC2Yg==}
|
||||
|
||||
'@cropper/[email protected]':
|
||||
resolution: {integrity: sha512-CgrLOx0qERWlfJK1Ed6ePq7SXcB6gLHtj3mtDVypyzti70fJdmNxrFm5Ucbq8vpp2Yo3tW2L6uDI0IBDoiT17A==}
|
||||
|
||||
'@cropper/[email protected]':
|
||||
resolution: {integrity: sha512-+D5rFlfFlWSL1lp0vPYI59/R6XM2vMnTSX0/B1zKRvmkc0t0N0m9lxVsaDb+9RkHx6PwAkBBNPZSRUeau33jMg==}
|
||||
|
||||
'@cropper/[email protected]':
|
||||
resolution: {integrity: sha512-b5qFTwws/osx0asIGLK1V//hdL08frGvfpi6s+p6zj5XH05ofVcz0Xl+Tot7sUx12pbfhhAlsquQTOJ1IsOoFw==}
|
||||
|
||||
'@ctrl/[email protected]':
|
||||
resolution: {integrity: sha512-WyOx8cJQ+FQus4Mm4uPIZA64gbk3Wxh0so5Lcii0aJifqwoVOlfFtorjLE0Hen4OYyHZMXDWqMmaQemBhgxFRQ==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -1988,32 +2021,32 @@ packages:
|
||||
'@formatjs/[email protected]':
|
||||
resolution: {integrity: sha512-AfiMi5NOSo2TQImsYAg8UYddsNJ/vUEv/HaNqiFjnI3ZFfWihUtD5QtuX6kHl8+H+d3qvnE/3HZrfzgdWpsLNA==}
|
||||
|
||||
'@formatjs/[email protected]2':
|
||||
resolution: {integrity: sha512-YuwN+dDKgssfuYwtOyGQD0CSn7K46g9cv1Fp0VgKpjZDDFvv8b8crzP990IcJhRPWHhKzCGsbUSJVIguyyFA5A==}
|
||||
'@formatjs/[email protected]3':
|
||||
resolution: {integrity: sha512-iB1fYfhtSGd8uH9A8b1m01Ujcv6QtQnJasefP3jPApmz72L/zNipzf3BdoinTMd1xuviVChjUJWL60iTwjUQ+w==}
|
||||
|
||||
'@formatjs/[email protected]':
|
||||
resolution: {integrity: sha512-i4q4V4qslThK4Ig8SxyD76cp3+QJ3sAqr7f6q9VVfeGtxG9OhiAk3y9XF6Q41OymsKzsGQ6OQQoJNY4/lI8TcQ==}
|
||||
|
||||
'@formatjs/[email protected]2':
|
||||
resolution: {integrity: sha512-ca7tZMLpfHX3sUD7hP6gE/qr6Iq89EDd3ZLeRhbsB7r0UD55DGJq+tmFLzrGrnx5StahUevQmQQVtbDRHsfDHA==}
|
||||
'@formatjs/[email protected]3':
|
||||
resolution: {integrity: sha512-IdAJ9NrHn7AF0zqncD9UpxQozTYyJtbwwrZnJMEKVxTcOOXtVkUb2XYTpNOGTAv58NCsk0rjRB9G+i2SvBmGAQ==}
|
||||
|
||||
'@formatjs/[email protected].2':
|
||||
resolution: {integrity: sha512-isKDo8jeQ8VoYvrweku92ajF/Z9fJ76DDnw2eCmKBTJTwZa5GeGl4u40RJiFYPbR6ROluvN0CsbNtGtnd2NyIQ==}
|
||||
'@formatjs/[email protected].3':
|
||||
resolution: {integrity: sha512-LQed+WhJBlceMJ4Zbe7dDe7Wn15le9t5HXfOFGdJYM/MGDyn56LTHlSzPzFjsJA0rvyp4FjN28jRQiMwtsji6A==}
|
||||
|
||||
'@formatjs/[email protected].1':
|
||||
resolution: {integrity: sha512-8RTQvKIGx+SAqI+bvVrrupwzeEZGGzdzJwVbZaK9U5VKuVo34LOFOkn7GX9KfwHuVIKxM7pKv8yYTs6UIkeyTA==}
|
||||
'@formatjs/[email protected].2':
|
||||
resolution: {integrity: sha512-9gayLdxgPhzw2UT2nbWBhrRN+7T2xy/cvvrEVFClRLzg1DSJJdXtdYRcbbQ8cdCRlC1O3dQag+bUeW+Kt9bRWA==}
|
||||
|
||||
'@formatjs/[email protected].3':
|
||||
resolution: {integrity: sha512-cpyMSdAZxRXVmA0TjZZn38ERjfxV8Kc1nDcp1RBAXyU6pB7Mgox+o3dVUnPLsNfblW5OAr1o281IyPRrod/T6g==}
|
||||
'@formatjs/[email protected].4':
|
||||
resolution: {integrity: sha512-EjUHL8isAQOqXuLxGllgsfIQdLrLNo8iCOQHMqUNY4Fvl52hw6TEP0n9tPnOmQWb5wTUa92NeqyGmwhjQ2lvHA==}
|
||||
|
||||
'@formatjs/[email protected].0':
|
||||
resolution: {integrity: sha512-ndj9jw/LHAftSe34D/A6+lj3Y7GHVfPEZ6FZP2wjd8/beYeh3d1sjlSpZSBlzegSKS3U3mUEIzLCpI0+jKNT6w==}
|
||||
'@formatjs/[email protected].1':
|
||||
resolution: {integrity: sha512-GkXdVwb/4Y9qJaZ19BguPv2i9Vr+z5MrlmFa/9HR7WK2tMcCWZathuGhn7W/V3sFqUdAVB3uvmGuOxXwc61DJQ==}
|
||||
|
||||
'@formatjs/[email protected].1':
|
||||
resolution: {integrity: sha512-V7kMddXDOLeyXAVCBEp8eBnllmPMBqu39U1GO/82xzlaUyK/Gkc3Qgal+9HJx6n1xfCvUn7XHK5t5dSlF7Y2lA==}
|
||||
'@formatjs/[email protected].2':
|
||||
resolution: {integrity: sha512-Qfsm6eyPyD6y7AgRyiOTJVaq0IBClDXQCm0/6vT3CRiwn89npdoUv3kTuwDykGDs1igLoTPWLBdMR81HIP4qQg==}
|
||||
|
||||
'@formatjs/[email protected].2':
|
||||
resolution: {integrity: sha512-2upVZtpzktEkLMffAk14T2HB62B7ytqrlppZuVX4StnbOHNc4qx5ovimMMMn8mkqvAwFlPX+UZygetny+quqIQ==}
|
||||
'@formatjs/[email protected].3':
|
||||
resolution: {integrity: sha512-YdxSbfDifRIew7mukcwITPlhkomfHCcSzzbjEJqOGbod4wrfdUN7SmiZQU2Lr8zs/q/15ntZsthJ8NsW3P30mA==}
|
||||
|
||||
'@formatjs/[email protected]':
|
||||
resolution: {integrity: sha512-gBqglQK9rXuHsLmM8wzTMBy4Xo4d0jamirIVKuEL8F1rwbskWdpMA1kZarSkny+uvr5q/K6GSzE0Rcq1wZLl3g==}
|
||||
@@ -2021,17 +2054,17 @@ packages:
|
||||
'@formatjs/[email protected]':
|
||||
resolution: {integrity: sha512-ePEgLgVCqi2BBFnTMWPfIghu6FkbZnnBVhO2sSxvLfrdFw7wCHAHiDoM2h4NRgjbaY7+B7HgOLZGkK187pZTZg==}
|
||||
|
||||
'@formatjs/[email protected].2':
|
||||
resolution: {integrity: sha512-iLS3ly1lGZ/a/IVpcSUgC37RjQUZ2wN6zR9+Zbr2uXsxATfMLD+fBqggiXuFudLmZXpbPueCs4Mz3vDsaOVE9A==}
|
||||
'@formatjs/[email protected].3':
|
||||
resolution: {integrity: sha512-Hi/3S+Iru5RIYNkiTJwqCmERsNwNacu3k+anfCcp7Nzn9hxhCpAuhjr6I9Y6CVUs/1D3fGSgfnJTXGukot5aBg==}
|
||||
|
||||
'@formatjs/[email protected].2':
|
||||
resolution: {integrity: sha512-PJ4+ldlqXlo3csmP02u2HqyXw09TDB/fatYpDCyWryfEQeVWl2EbMzij9SBksPaW2U//+q69lXR3IMnCRQAmrw==}
|
||||
'@formatjs/[email protected].3':
|
||||
resolution: {integrity: sha512-moK0Ee0ddD8d8tZnoZ1JDUdNLBURXT5NMG4cnvYQY2bTYLphPhZk2KxgD78y6+lL69J4ihpQJleI+K5KlE39gw==}
|
||||
|
||||
'@formatjs/[email protected].1':
|
||||
resolution: {integrity: sha512-Gl5dCHb7elEC19tTzxIyllWLHaHnN+HpQYEaA9xxAt9rKgRLwC3vGty1w8bVGG7p2YcGSPIg5c4qn4KdkGcTqg==}
|
||||
'@formatjs/[email protected].2':
|
||||
resolution: {integrity: sha512-g7AVCP/0r49+h4R1qnQcNpq78JoEIthwp+QtHv9VkHw1c0bEegmhuz1t03DEr2u5nqP4HE//yAFjQtrv66b67A==}
|
||||
|
||||
'@formatjs/[email protected].0':
|
||||
resolution: {integrity: sha512-9rG37qcIBFyX+HSyPelJKpAatFaO+ElvnWYO/FFMERVZG9e9Wj51CWAvF7+ozYd7B1uKBL2ub6aLzrkWA0UBxA==}
|
||||
'@formatjs/[email protected].1':
|
||||
resolution: {integrity: sha512-+9TU+8tqsUqobFzQJEvfQ8Yukt1PYgL5WxYi/yW+hWVNx422wEo/TlVRLvIfdYqlVS048jfyu8piNagfhOV+2A==}
|
||||
|
||||
'@formatjs/[email protected]':
|
||||
resolution: {integrity: sha512-tDkXnA4qpIFcDWac8CyVJq6oW8DR7W44QDUBsfXWIIJD/FYYen0QoH46W7XsVMFfPOVKkvbufjboZrrWbEfmww==}
|
||||
@@ -2661,98 +2694,98 @@ packages:
|
||||
'@codemirror/state': ^6.0.0
|
||||
'@codemirror/view': ^6.0.0
|
||||
|
||||
'@rolldown/[email protected]6':
|
||||
resolution: {integrity: sha512-ia3DwAs0OsMGS7+IConptSiyk9pEXkOx/AbJ2n/lAGxH3QkWQus1t8d7gnAT38FCU2sQu8lUbeR6AiZFv4JxaA==}
|
||||
'@rolldown/[email protected]8':
|
||||
resolution: {integrity: sha512-ubTG+4KlidvioxjOFdsdEa2F6JVGZb8m2ByJUqQ7fYzUeT1cEn194POcsQ10PKH4p//q0YQn4SB3YpSuzcA1Yg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@rolldown/[email protected]6':
|
||||
resolution: {integrity: sha512-kx5wBiMB6E7TEvpl2B9gjLU004BkvEm2Dr941BFjx/SontPwSzNuQUIoVKxHHoSjpFIfHMmcguyLOtyn0hObRg==}
|
||||
'@rolldown/[email protected]8':
|
||||
resolution: {integrity: sha512-2Xa1t/JeFGTZn5TbzgPppcCftF8sCVnTNvrIvoSdcZhoFzAuVy1L3xxQUQ4sfWDtwVzg1u6Yi1R3SlJugRNV2Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rolldown/[email protected]6':
|
||||
resolution: {integrity: sha512-qslZrXnKJU4cUpjoAmtAo/KQM9e2IRMlmpig6Zpwj+8KWxW8v+nkAf9216Bsek9lOC1jvg2yCjygCFo2f83msQ==}
|
||||
'@rolldown/[email protected]8':
|
||||
resolution: {integrity: sha512-iUzw9HshVvrWci9gVXjxWS7/M/IcLvNo67vu2c/w9zKBz4hIvPRixdUpIW/iCfhldLaq9ER2hecsI9UY5z0/yA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/[email protected]6':
|
||||
resolution: {integrity: sha512-FdF+y48ND13eAyM2mQn+85kvAqNlvRWROy2rhgA463X/0Gv4vGtazZtfaF1mRBLscYvtOlh4QnLjOWiFzX69OQ==}
|
||||
'@rolldown/[email protected]8':
|
||||
resolution: {integrity: sha512-XuDHFzbFEtZl0QGJi92+fmG67tfnp1iZ9VOeDkwasGyTq2VG3/5PnJnvAr+MTzFh/rNScJ3VhN+XpZyyGsN7Jw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rolldown/[email protected]6':
|
||||
resolution: {integrity: sha512-ddjqFM5e95AgLbUPtKIS7V1KTVlA0RUgCVr5mn+/IEhsdy3pr+Vuj/HZ7nlMN2FyxIwUktOH1LEPIQXO1v9y1Q==}
|
||||
'@rolldown/[email protected]8':
|
||||
resolution: {integrity: sha512-MM8opLfsLK15oEXDz6iBc0t3ahu+ew9jsAmha+ovZ33hSwYgqo7YmpRITqFTfW7xA71BLEQ3jJdTsaCmhB1c9w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rolldown/[email protected]6':
|
||||
resolution: {integrity: sha512-tHdKWbFkFBI687iim3LIyIvyL7j66VvQYkDPss5Stn/qyE2tWkR1YD0dXmXPFKKqT/o5ZEJVsaRsWS2meQwGOA==}
|
||||
'@rolldown/[email protected]8':
|
||||
resolution: {integrity: sha512-J6EoCWiy5RnFQzHrKyHXXevZ9lukQfGpnchX/zG/k/WC2nc9O9XyS1JgA8kI9VxMFggMbCPjpv7LcikriXfehw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@rolldown/[email protected]6':
|
||||
resolution: {integrity: sha512-FJiLGAblPBMlBEdbOqw6kW6QcXDWAf/oJu7gYs9ZJZwmg+K+/zjFLxPTIY9izJUzxzc24FHvmseyA0IvJSl17w==}
|
||||
'@rolldown/[email protected]8':
|
||||
resolution: {integrity: sha512-HIkFuLF7CdDeki+1aCZLLbLq0hk/PHntb+z4/YCTo47JbksTD6jJEELIz92tYROmDmvMrNqUMqBi71BkJLDR+A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/[email protected]6':
|
||||
resolution: {integrity: sha512-hrJpblMsrQ58U9veWpbXzkhV5zStunds6HWLprqq1qauvgBr1E6P+24kLj6lSLI9vjRn/RYpjprhkHQPKGWpNg==}
|
||||
'@rolldown/[email protected]8':
|
||||
resolution: {integrity: sha512-4oqWItLLYt7IceOnElCct8T2ek5p7PmD7LN04TRJRXGo5f3HtKTgGH6PhYRFK4C2ICkq/LMUU0BVPvvlAKhm8g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/[email protected]6':
|
||||
resolution: {integrity: sha512-qJd/dC6j6CwLG9/s/RP2nb1RZ0GIgj7Ioj7x/SYa3RzjVrlzrmH0fhIOpfvqQDeIsdP0loi+kHaMhJzY1PBIaA==}
|
||||
'@rolldown/[email protected]8':
|
||||
resolution: {integrity: sha512-+AkRly5SrCEgnPhGRfub7pucASYEC2fTsxZg7VCcnozpA2+Wn/gCYy1t7LuJS67/xyJu/7noYBJncDUkbwgw2A==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/[email protected]6':
|
||||
resolution: {integrity: sha512-Vx7Xg5uUJuNcujJjI70OICaH0EYj80BtOLZTSN7uOYg8QOLlyKnaG0IW7Fn/J0IpJsaKzmDKMhOQv1JBM9m9vg==}
|
||||
'@rolldown/[email protected]8':
|
||||
resolution: {integrity: sha512-Ttg1r9vVykMnQrE/GwYO9icKtXiuiMfRe/w74xJkeoVuvhRG+WtT6LEMOf58Yl63yd77lhHzO8Y55R8CC3XALA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/[email protected]6':
|
||||
resolution: {integrity: sha512-TNKhgE3uJTsTzatUSSYt0Kgo+Oa2uSlSjz4r1aEIea7HX6ULBym4fAlYUhG1i6pjNf2njfCUAfKwr6C3E9Xg1w==}
|
||||
'@rolldown/[email protected]8':
|
||||
resolution: {integrity: sha512-xX1Tt4o5jUMma0vJsXp5eRZA9nOuyigb2dKJMMzZRmzMxLYGE9PaKKSqHPzPvSQgM8ZOEacSTQwDQcwWfh0xmQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/[email protected]6':
|
||||
resolution: {integrity: sha512-7chXdpjXZ8qG0+mIxECN3cT9kvhZ3/oZhMX6Pp876P7CDrIy8nJswva6Mq357JR7eYXfuskwZxMeV1SzoZdHrA==}
|
||||
'@rolldown/[email protected]8':
|
||||
resolution: {integrity: sha512-4VVlGI5K2kociZlW3lSN8VjG3TW5yQIlAcfCdBXMgGjJxZll15Fa4ocRHXHvIVVnwlibCw/NQajJrNDiqqpiiw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/[email protected]6':
|
||||
resolution: {integrity: sha512-Kwjt+7uNTJK5/MDkGWGVO2KQPnj8yAV75dMpfwyHZrJSXWHrniOB0aUEWlVYf3H1hOqFLIStLdM5QXq6bnKmOQ==}
|
||||
'@rolldown/[email protected]8':
|
||||
resolution: {integrity: sha512-PApTsgH1jLYuyy3tSCz9De82Gq5qXwi7vImVZhEzTgX1C4150Cp5M2ZfL3pLvbCvbEHL0oJeC6pd6podtv8Y5w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rolldown/[email protected]6':
|
||||
resolution: {integrity: sha512-/LH6WfjDRXXJz/p5xl4TmU+jpcnwcxbKEQBW/GZE0JHfN0lGIyTtsUEGaQ/q/GikkGJ2yLKe6pwm7AnEWO8pdw==}
|
||||
'@rolldown/[email protected]8':
|
||||
resolution: {integrity: sha512-J2XjWjx/SP+4x0H4r6dz3Vzb5WVkg7re40Do9K7HJ3sK2/xcmZrWwakXlSwaMvOkvruLJCD4CL/3wqsIR/thgg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rolldown/[email protected]6':
|
||||
resolution: {integrity: sha512-1lv1KvX0Z871zfk7vw+dnXMMnHNyDwMzWG8Kv3HxDXMOhzCX4SHc7HK319fInP0i+0p9D8brkfzL7pJOaGl+tw==}
|
||||
'@rolldown/[email protected]8':
|
||||
resolution: {integrity: sha512-XwRJxRQvkuEtS0hIIt29QrqPJ+JzMJSNYHJw4ae/mc6uEarfRG1ceyg4HmmWiBdL/YCb6g7N5BLbSvmRJRYZHw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -4576,8 +4609,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==}
|
||||
|
||||
cropperjs@1.7.0:
|
||||
resolution: {integrity: sha512-AkGnZxr7xAJBa63ObXq5ZKLkh9c8wisC+Ym8VzIDn3KZ+6qRTbBnOl2w2dFOTMzLP5IcfOpEK9PICDywNXhncg==}
|
||||
cropperjs@2.3.1:
|
||||
resolution: {integrity: sha512-bgy+iHRFjsBxBCriXkTuFuHRJGSGZISuqDGhDQG7gLK0F7qOzhH06bdXzGf4b0bZSQ+mApakybiW9xvKnS9dbg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
@@ -4961,8 +4994,8 @@ packages:
|
||||
eslint-plugin-import: '>=1.4.0'
|
||||
webpack: '>=1.11.0'
|
||||
|
||||
[email protected].0:
|
||||
resolution: {integrity: sha512-UfxHFmV+f9vwybrNn6LQ1rPzfeDMPGi/dRbqQnCFO/jBzYrujSOyX7jxqBSYr/HwXs/V4XJdEGPvC3Re46nzKg==}
|
||||
[email protected].1:
|
||||
resolution: {integrity: sha512-D1h++38NS2Z40zf8jbd2OHqDrnIFRKpdp8sL0jP4xSDGZj6MNwEQua8F+r3tOWjcHrsWuhHOJ7wASWioJ31tfg==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
peerDependencies:
|
||||
'@typescript-eslint/utils': ^8.56.0
|
||||
@@ -5569,8 +5602,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug==}
|
||||
|
||||
[email protected].4:
|
||||
resolution: {integrity: sha512-JUkmv3cAEyijN3oiRiV3ctel7yVMuUCgfh7Pu8pj1/z6L1DSEPRyhuMaLjVw3pGmGJ5RDH54WVFxfMHZWTwyHw==}
|
||||
[email protected].5:
|
||||
resolution: {integrity: sha512-quhcDFAxy4wr/GrgWgmMjuegoedNUIxGNLORfXgDgdjuv8cBM88ClLTLXmfYC1roMSR1PPc55yUEkpJ5m67W2g==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==}
|
||||
@@ -6912,8 +6945,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-OlwfYEgA2RdboZohpldlvJ1xngOins5d7ejqnIBWr9KaMxsnBqotpptRXTyfNRLnFpqzX6sTDt+X+a+6udnU8g==}
|
||||
|
||||
[email protected]6:
|
||||
resolution: {integrity: sha512-bWvlMpFKiTfDKfnK7FC2MPgPeWCf/Kpwsq49ApdW+FQaBZb6O6dZ4MAPbPUjp7hYQrw/RobqCzn64BRrQecGHw==}
|
||||
[email protected]8:
|
||||
resolution: {integrity: sha512-C1uuT3UR60SShuCaPQFAAZyefJCWZlAKB02BV3BuPE2Rs4fevRQltEKoFO2tV5W9/eJhU5RieV6vJj3euulfNQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
@@ -7721,8 +7754,8 @@ packages:
|
||||
peerDependencies:
|
||||
vite: '*'
|
||||
|
||||
[email protected].6:
|
||||
resolution: {integrity: sha512-UI7dK29NnS54Fk+mmo7o+jI6U8Fp7hDl+X5Q1k45WH+d4dSlbLVUETGQ+hfkNgvLQGyoElPj837nZyv4MBc0VA==}
|
||||
[email protected].7:
|
||||
resolution: {integrity: sha512-oxh0dMxrayBr33ScJvvBJ2HBQ2jc1TBrXKR2NyL/MJ9WPuX19mN2DXWUmywKdyw5RFVQBo8hmtb2FMC0LHrnng==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@@ -9531,6 +9564,72 @@ snapshots:
|
||||
dependencies:
|
||||
commander: 15.0.0
|
||||
|
||||
'@cropper/[email protected]':
|
||||
dependencies:
|
||||
'@cropper/element': 2.3.1
|
||||
'@cropper/utils': 2.3.1
|
||||
|
||||
'@cropper/[email protected]':
|
||||
dependencies:
|
||||
'@cropper/element': 2.3.1
|
||||
'@cropper/utils': 2.3.1
|
||||
|
||||
'@cropper/[email protected]':
|
||||
dependencies:
|
||||
'@cropper/element': 2.3.1
|
||||
'@cropper/utils': 2.3.1
|
||||
|
||||
'@cropper/[email protected]':
|
||||
dependencies:
|
||||
'@cropper/element': 2.3.1
|
||||
'@cropper/utils': 2.3.1
|
||||
|
||||
'@cropper/[email protected]':
|
||||
dependencies:
|
||||
'@cropper/element': 2.3.1
|
||||
'@cropper/element-canvas': 2.3.1
|
||||
'@cropper/utils': 2.3.1
|
||||
|
||||
'@cropper/[email protected]':
|
||||
dependencies:
|
||||
'@cropper/element': 2.3.1
|
||||
'@cropper/element-canvas': 2.3.1
|
||||
'@cropper/element-image': 2.3.1
|
||||
'@cropper/utils': 2.3.1
|
||||
|
||||
'@cropper/[email protected]':
|
||||
dependencies:
|
||||
'@cropper/element': 2.3.1
|
||||
'@cropper/element-canvas': 2.3.1
|
||||
'@cropper/element-selection': 2.3.1
|
||||
'@cropper/utils': 2.3.1
|
||||
|
||||
'@cropper/[email protected]':
|
||||
dependencies:
|
||||
'@cropper/element': 2.3.1
|
||||
'@cropper/element-canvas': 2.3.1
|
||||
'@cropper/element-image': 2.3.1
|
||||
'@cropper/element-selection': 2.3.1
|
||||
'@cropper/utils': 2.3.1
|
||||
|
||||
'@cropper/[email protected]':
|
||||
dependencies:
|
||||
'@cropper/utils': 2.3.1
|
||||
|
||||
'@cropper/[email protected]':
|
||||
dependencies:
|
||||
'@cropper/element': 2.3.1
|
||||
'@cropper/element-canvas': 2.3.1
|
||||
'@cropper/element-crosshair': 2.3.1
|
||||
'@cropper/element-grid': 2.3.1
|
||||
'@cropper/element-handle': 2.3.1
|
||||
'@cropper/element-image': 2.3.1
|
||||
'@cropper/element-selection': 2.3.1
|
||||
'@cropper/element-shade': 2.3.1
|
||||
'@cropper/element-viewer': 2.3.1
|
||||
|
||||
'@cropper/[email protected]': {}
|
||||
|
||||
'@ctrl/[email protected]': {}
|
||||
|
||||
'@custom-elements-manifest/[email protected]':
|
||||
@@ -9682,41 +9781,41 @@ snapshots:
|
||||
'@formatjs/icu-skeleton-parser': 1.8.14
|
||||
tslib: 2.9.1
|
||||
|
||||
'@formatjs/[email protected]2':
|
||||
'@formatjs/[email protected]3':
|
||||
dependencies:
|
||||
'@formatjs/icu-skeleton-parser': 2.1.12
|
||||
'@formatjs/icu-skeleton-parser': 2.1.13
|
||||
|
||||
'@formatjs/[email protected]':
|
||||
dependencies:
|
||||
'@formatjs/ecma402-abstract': 2.3.4
|
||||
tslib: 2.9.1
|
||||
|
||||
'@formatjs/[email protected]2': {}
|
||||
'@formatjs/[email protected]3': {}
|
||||
|
||||
'@formatjs/[email protected].2':
|
||||
'@formatjs/[email protected].3':
|
||||
dependencies:
|
||||
'@formatjs/bigdecimal': 0.2.10
|
||||
'@formatjs/intl-localematcher': 0.10.1
|
||||
|
||||
'@formatjs/[email protected].1':
|
||||
'@formatjs/[email protected].2':
|
||||
dependencies:
|
||||
'@formatjs/intl-localematcher': 0.10.1
|
||||
|
||||
'@formatjs/[email protected].3':
|
||||
'@formatjs/[email protected].4':
|
||||
dependencies:
|
||||
'@formatjs/bigdecimal': 0.2.10
|
||||
'@formatjs/intl-localematcher': 0.10.1
|
||||
|
||||
'@formatjs/[email protected].0': {}
|
||||
'@formatjs/[email protected].1': {}
|
||||
|
||||
'@formatjs/[email protected].1':
|
||||
'@formatjs/[email protected].2':
|
||||
dependencies:
|
||||
'@formatjs/intl-localematcher': 0.10.1
|
||||
|
||||
'@formatjs/[email protected].2':
|
||||
'@formatjs/[email protected].3':
|
||||
dependencies:
|
||||
'@formatjs/intl-getcanonicallocales': 3.3.0
|
||||
'@formatjs/intl-supportedvaluesof': 3.0.0
|
||||
'@formatjs/intl-getcanonicallocales': 3.3.1
|
||||
'@formatjs/intl-supportedvaluesof': 3.0.1
|
||||
|
||||
'@formatjs/[email protected]':
|
||||
dependencies:
|
||||
@@ -9726,21 +9825,21 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.9.1
|
||||
|
||||
'@formatjs/[email protected].2':
|
||||
'@formatjs/[email protected].3':
|
||||
dependencies:
|
||||
'@formatjs/bigdecimal': 0.2.10
|
||||
'@formatjs/intl-localematcher': 0.10.1
|
||||
|
||||
'@formatjs/[email protected].2':
|
||||
'@formatjs/[email protected].3':
|
||||
dependencies:
|
||||
'@formatjs/bigdecimal': 0.2.10
|
||||
'@formatjs/intl-localematcher': 0.10.1
|
||||
|
||||
'@formatjs/[email protected].1':
|
||||
'@formatjs/[email protected].2':
|
||||
dependencies:
|
||||
'@formatjs/intl-localematcher': 0.10.1
|
||||
|
||||
'@formatjs/[email protected].0':
|
||||
'@formatjs/[email protected].1':
|
||||
dependencies:
|
||||
'@formatjs/fast-memoize': 3.1.7
|
||||
|
||||
@@ -10559,49 +10658,49 @@ snapshots:
|
||||
'@codemirror/state': 6.7.6
|
||||
'@codemirror/view': 6.43.15
|
||||
|
||||
'@rolldown/[email protected]6':
|
||||
'@rolldown/[email protected]8':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]6':
|
||||
'@rolldown/[email protected]8':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]6':
|
||||
'@rolldown/[email protected]8':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]6':
|
||||
'@rolldown/[email protected]8':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]6':
|
||||
'@rolldown/[email protected]8':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]6':
|
||||
'@rolldown/[email protected]8':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]6':
|
||||
'@rolldown/[email protected]8':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]6':
|
||||
'@rolldown/[email protected]8':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]6':
|
||||
'@rolldown/[email protected]8':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]6':
|
||||
'@rolldown/[email protected]8':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]6':
|
||||
'@rolldown/[email protected]8':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]6':
|
||||
'@rolldown/[email protected]8':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]6':
|
||||
'@rolldown/[email protected]8':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]6':
|
||||
'@rolldown/[email protected]8':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]6':
|
||||
'@rolldown/[email protected]8':
|
||||
optional: true
|
||||
|
||||
'@rolldown/[email protected]': {}
|
||||
@@ -11598,7 +11697,7 @@ snapshots:
|
||||
obug: 3.0.0
|
||||
std-env: 4.3.0
|
||||
tinyrainbow: 3.2.0
|
||||
vitest: 5.1.0(@types/[email protected])(@vitest/[email protected])([email protected])([email protected].6(@types/[email protected])([email protected])([email protected]))
|
||||
vitest: 5.1.0(@types/[email protected])(@vitest/[email protected])([email protected])([email protected].7(@types/[email protected])([email protected])([email protected]))
|
||||
|
||||
'@vitest/[email protected]': {}
|
||||
|
||||
@@ -11606,14 +11705,14 @@ snapshots:
|
||||
dependencies:
|
||||
'@vitest/istanbul-lib-coverage': 2.1.0
|
||||
|
||||
'@vitest/[email protected]([email protected].6(@types/[email protected])([email protected])([email protected]))':
|
||||
'@vitest/[email protected]([email protected].7(@types/[email protected])([email protected])([email protected]))':
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
'@vitest/spy': 5.1.0
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 1.4.3
|
||||
optionalDependencies:
|
||||
vite: 8.3.6(@types/[email protected])([email protected])([email protected])
|
||||
vite: 8.3.7(@types/[email protected])([email protected])([email protected])
|
||||
|
||||
'@vitest/[email protected]': {}
|
||||
|
||||
@@ -12287,7 +12386,10 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
cropperjs@1.7.0: {}
|
||||
cropperjs@2.3.1:
|
||||
dependencies:
|
||||
'@cropper/elements': 2.3.1
|
||||
'@cropper/utils': 2.3.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
@@ -12721,7 +12823,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
[email protected].0(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected]):
|
||||
[email protected].1(@typescript-eslint/[email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected]([email protected]))([email protected]):
|
||||
dependencies:
|
||||
'@typescript-eslint/types': 8.72.1
|
||||
cjs-module-lexer: 2.3.0
|
||||
@@ -13441,10 +13543,10 @@ snapshots:
|
||||
'@formatjs/icu-messageformat-parser': 2.11.2
|
||||
tslib: 2.9.1
|
||||
|
||||
[email protected].4:
|
||||
[email protected].5:
|
||||
dependencies:
|
||||
'@formatjs/fast-memoize': 3.1.7
|
||||
'@formatjs/icu-messageformat-parser': 3.5.22
|
||||
'@formatjs/icu-messageformat-parser': 3.5.23
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -14840,26 +14942,26 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]6:
|
||||
[email protected]8:
|
||||
dependencies:
|
||||
'@oxc-project/types': 0.156.0
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
optionalDependencies:
|
||||
'@rolldown/binding-android-arm-eabi': 1.2.16
|
||||
'@rolldown/binding-android-arm64': 1.2.16
|
||||
'@rolldown/binding-darwin-arm64': 1.2.16
|
||||
'@rolldown/binding-darwin-x64': 1.2.16
|
||||
'@rolldown/binding-freebsd-x64': 1.2.16
|
||||
'@rolldown/binding-linux-arm-gnueabihf': 1.2.16
|
||||
'@rolldown/binding-linux-arm64-gnu': 1.2.16
|
||||
'@rolldown/binding-linux-arm64-musl': 1.2.16
|
||||
'@rolldown/binding-linux-ppc64-gnu': 1.2.16
|
||||
'@rolldown/binding-linux-s390x-gnu': 1.2.16
|
||||
'@rolldown/binding-linux-x64-gnu': 1.2.16
|
||||
'@rolldown/binding-linux-x64-musl': 1.2.16
|
||||
'@rolldown/binding-openharmony-arm64': 1.2.16
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.2.16
|
||||
'@rolldown/binding-win32-x64-msvc': 1.2.16
|
||||
'@rolldown/binding-android-arm-eabi': 1.2.18
|
||||
'@rolldown/binding-android-arm64': 1.2.18
|
||||
'@rolldown/binding-darwin-arm64': 1.2.18
|
||||
'@rolldown/binding-darwin-x64': 1.2.18
|
||||
'@rolldown/binding-freebsd-x64': 1.2.18
|
||||
'@rolldown/binding-linux-arm-gnueabihf': 1.2.18
|
||||
'@rolldown/binding-linux-arm64-gnu': 1.2.18
|
||||
'@rolldown/binding-linux-arm64-musl': 1.2.18
|
||||
'@rolldown/binding-linux-ppc64-gnu': 1.2.18
|
||||
'@rolldown/binding-linux-s390x-gnu': 1.2.18
|
||||
'@rolldown/binding-linux-x64-gnu': 1.2.18
|
||||
'@rolldown/binding-linux-x64-musl': 1.2.18
|
||||
'@rolldown/binding-openharmony-arm64': 1.2.18
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.2.18
|
||||
'@rolldown/binding-win32-x64-msvc': 1.2.18
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
@@ -15807,22 +15909,22 @@ snapshots:
|
||||
- bare-abort-controller
|
||||
- react-native-b4a
|
||||
|
||||
[email protected]([email protected])([email protected])([email protected].6(@types/[email protected])([email protected])([email protected])):
|
||||
[email protected]([email protected])([email protected])([email protected].7(@types/[email protected])([email protected])([email protected])):
|
||||
dependencies:
|
||||
debug: 4.4.3([email protected])
|
||||
globrex: 0.1.2
|
||||
tsconfck: 3.1.6([email protected])
|
||||
vite: 8.3.6(@types/[email protected])([email protected])([email protected])
|
||||
vite: 8.3.7(@types/[email protected])([email protected])([email protected])
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
- typescript
|
||||
|
||||
[email protected].6(@types/[email protected])([email protected])([email protected]):
|
||||
[email protected].7(@types/[email protected])([email protected])([email protected]):
|
||||
dependencies:
|
||||
lightningcss: 1.33.0
|
||||
picomatch: 4.0.7
|
||||
postcss: 8.5.32
|
||||
rolldown: 1.2.16
|
||||
rolldown: 1.2.18
|
||||
tinyglobby: 0.2.17
|
||||
optionalDependencies:
|
||||
'@types/node': 24.19.2
|
||||
@@ -15830,10 +15932,10 @@ snapshots:
|
||||
terser: 5.50.0
|
||||
yaml: 2.9.1
|
||||
|
||||
[email protected](@types/[email protected])(@vitest/[email protected])([email protected])([email protected].6(@types/[email protected])([email protected])([email protected])):
|
||||
[email protected](@types/[email protected])(@vitest/[email protected])([email protected])([email protected].7(@types/[email protected])([email protected])([email protected])):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/mocker': 5.1.0([email protected].6(@types/[email protected])([email protected])([email protected]))
|
||||
'@vitest/mocker': 5.1.0([email protected].7(@types/[email protected])([email protected])([email protected]))
|
||||
chai: 6.3.0
|
||||
es-module-lexer: 2.3.2
|
||||
expect-type: 1.4.0
|
||||
@@ -15844,7 +15946,7 @@ snapshots:
|
||||
tinybench: 6.2.2
|
||||
tinyexec: 1.3.1
|
||||
tinyglobby: 0.2.17
|
||||
vite: 8.3.6(@types/[email protected])([email protected])([email protected])
|
||||
vite: 8.3.7(@types/[email protected])([email protected])([email protected])
|
||||
why-is-node-running: 3.2.1
|
||||
optionalDependencies:
|
||||
'@types/node': 24.19.2
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
mdiRestart,
|
||||
} from "@mdi/js";
|
||||
import { differenceInMinutes } from "date-fns";
|
||||
import type { DataZoomComponentOption } from "echarts/components";
|
||||
import type {
|
||||
DataZoomComponentOption,
|
||||
MarkAreaComponentOption,
|
||||
} from "echarts/components";
|
||||
import type { EChartsType } from "echarts/core";
|
||||
import type {
|
||||
ECElementEvent,
|
||||
@@ -90,14 +93,29 @@ export const DEFAULT_CHART_WIDTH = 500;
|
||||
// is short enough for a whole screenful; on a desktop that would cover the page.
|
||||
const VISIBILITY_ROOT_MARGIN_NARROW = "100%";
|
||||
const VISIBILITY_ROOT_MARGIN = "300px";
|
||||
const CLICK_DRAG_TOLERANCE = 4;
|
||||
// The selection is shown by the mask, not by the brush
|
||||
const BRUSH_STYLE = {
|
||||
color: "rgba(0, 0, 0, 0)",
|
||||
borderColor: "rgba(0, 0, 0, 0)",
|
||||
};
|
||||
|
||||
export type ZoomSliderMode = "auto" | "always" | "never";
|
||||
|
||||
export type ChartSelectMode = "none" | "point" | "range";
|
||||
const DEFERRED_PROPS = [
|
||||
"options",
|
||||
"data",
|
||||
"_hiddenDatasets",
|
||||
"_isZoomed",
|
||||
"highlight",
|
||||
] as const;
|
||||
const MASK_OPACITY = 0.7;
|
||||
|
||||
export interface ChartTimeRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
type RawSeriesOption = Exclude<
|
||||
NonNullable<ECOption["series"]>,
|
||||
@@ -176,6 +194,18 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
@property({ attribute: "grow-for-zoom-slider", type: Boolean })
|
||||
public growForZoomSlider = false;
|
||||
|
||||
// "point" selects on click, "range" also selects on drag instead of zooming
|
||||
@property({ attribute: "select-mode" })
|
||||
public selectMode: ChartSelectMode = "none";
|
||||
|
||||
// Everything outside this time range fades
|
||||
@property({ attribute: false }) public highlight?: ChartTimeRange;
|
||||
|
||||
// Rounds a selection to the periods of the data, or drops it
|
||||
@property({ attribute: false }) public snapSelection?: (
|
||||
range: ChartTimeRange
|
||||
) => ChartTimeRange | undefined;
|
||||
|
||||
// extraComponents is not reactive and should not trigger updates
|
||||
public extraComponents?: any[];
|
||||
|
||||
@@ -201,6 +231,14 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
|
||||
@state() private _isZoomed = false;
|
||||
|
||||
private _pointerDownX?: number;
|
||||
|
||||
private _selectingRange?: ChartTimeRange;
|
||||
|
||||
private _maskColor = "";
|
||||
|
||||
private _maskShown = false;
|
||||
|
||||
// Separate from _isZoomed, whose touch handling reruns graph layouts
|
||||
@state() private _isGraphRoamed = false;
|
||||
|
||||
@@ -454,6 +492,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
if (changedProps.has("selectMode")) {
|
||||
this._updateSelectMode();
|
||||
}
|
||||
const themeChanged = changedProps.has("_themes") && this.hasUpdated;
|
||||
if (!themeChanged && !DEFERRED_PROPS.some((p) => changedProps.has(p))) {
|
||||
return;
|
||||
@@ -499,6 +540,8 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
) {
|
||||
this._disposeSonification();
|
||||
}
|
||||
} else if (changedProps.has("highlight")) {
|
||||
this._updateMask();
|
||||
}
|
||||
if (changedProps.has("options")) {
|
||||
chartOptions = { ...chartOptions, ...this._createOptions() };
|
||||
@@ -888,6 +931,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
|
||||
const style = getComputedStyle(this);
|
||||
echarts.registerTheme("custom", this._createTheme(style));
|
||||
this._maskColor =
|
||||
style.getPropertyValue("--card-background-color").trim() ||
|
||||
style.getPropertyValue("--primary-background-color").trim();
|
||||
|
||||
this.chart = echarts.init(this._chartContainer!, "custom");
|
||||
this._dragToZoom = false;
|
||||
@@ -970,6 +1016,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
this._legendItemCount = this._getLegendItems()?.length;
|
||||
this._updateSankeyRoam();
|
||||
this._updateDragToZoom();
|
||||
if (this.selectMode !== "none") {
|
||||
this._updateSelectMode();
|
||||
}
|
||||
if (zoomStart !== 0 || zoomEnd !== 100) {
|
||||
this.zoom(zoomStart, zoomEnd, true);
|
||||
}
|
||||
@@ -1021,7 +1070,10 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
|
||||
// A chart can switch type (e.g. pie and bar) without being rebuilt
|
||||
private _updateDragToZoom() {
|
||||
const active = !this._touchInput && this._hasZoomableXAxis();
|
||||
const active =
|
||||
!this._touchInput &&
|
||||
this._hasZoomableXAxis() &&
|
||||
this.selectMode !== "range";
|
||||
if (!this.chart || active === this._dragToZoom) {
|
||||
return;
|
||||
}
|
||||
@@ -1033,6 +1085,127 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _snapRange(range: ChartTimeRange) {
|
||||
return this.snapSelection ? this.snapSelection(range) : range;
|
||||
}
|
||||
|
||||
private _getMaskArea(): MarkAreaComponentOption | undefined {
|
||||
const range = this._selectingRange ?? this.highlight;
|
||||
if (!range) {
|
||||
return undefined;
|
||||
}
|
||||
this._maskShown = true;
|
||||
return {
|
||||
silent: true,
|
||||
z: 10,
|
||||
itemStyle: {
|
||||
color: this._maskColor,
|
||||
opacity: MASK_OPACITY,
|
||||
},
|
||||
data: [
|
||||
[{ xAxis: -Infinity }, { xAxis: range.start }],
|
||||
[{ xAxis: range.end }, { xAxis: Infinity }],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Only the first series carries the mask, merged in without resending data
|
||||
private _updateMask() {
|
||||
this.chart?.setOption({
|
||||
series: [{ markArea: this._getMaskArea() ?? { data: [] } }],
|
||||
});
|
||||
}
|
||||
|
||||
private _handleSelectPointerDown = (e: ECElementEvent) => {
|
||||
this._pointerDownX = e.offsetX;
|
||||
};
|
||||
|
||||
private _handleSelectClick = (e: ECElementEvent) => {
|
||||
const point = [e.offsetX, e.offsetY];
|
||||
// A drag that ends on the plot is not a click
|
||||
const dragged =
|
||||
this._pointerDownX !== undefined &&
|
||||
Math.abs(e.offsetX - this._pointerDownX) > CLICK_DRAG_TOLERANCE;
|
||||
if (dragged || !this.chart?.containPixel("grid", point)) {
|
||||
return;
|
||||
}
|
||||
const [x] = this.chart.convertFromPixel("grid", point) as number[];
|
||||
const selection = this._snapRange({ start: x, end: x });
|
||||
if (selection) {
|
||||
fireEvent(this, "chart-select", selection);
|
||||
}
|
||||
};
|
||||
|
||||
private _handleBrush = (e: any) => {
|
||||
const range = e.areas?.[0]?.coordRange as number[] | undefined;
|
||||
if (range) {
|
||||
this._selectingRange = this._snapRange({
|
||||
start: range[0],
|
||||
end: range[1],
|
||||
});
|
||||
this._updateMask();
|
||||
}
|
||||
};
|
||||
|
||||
private _handleBrushEnd = (e: any) => {
|
||||
const range = e.areas?.[0]?.coordRange as number[] | undefined;
|
||||
this.chart?.dispatchAction({ type: "brush", areas: [] });
|
||||
this._selectingRange = undefined;
|
||||
this._updateMask();
|
||||
const selection =
|
||||
range && this._snapRange({ start: range[0], end: range[1] });
|
||||
if (selection) {
|
||||
fireEvent(this, "chart-select", selection);
|
||||
}
|
||||
};
|
||||
|
||||
private _updateSelectMode() {
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
const zr = this.chart.getZr();
|
||||
zr.off("mousedown", this._handleSelectPointerDown);
|
||||
zr.off("click", this._handleSelectClick);
|
||||
this.chart.off("brush", this._handleBrush);
|
||||
this.chart.off("brushEnd", this._handleBrushEnd);
|
||||
if (this.selectMode !== "none") {
|
||||
zr.on("mousedown", this._handleSelectPointerDown);
|
||||
zr.on("click", this._handleSelectClick);
|
||||
}
|
||||
const selectRange = this.selectMode === "range";
|
||||
if (selectRange) {
|
||||
this.chart.on("brush", this._handleBrush);
|
||||
this.chart.on("brushEnd", this._handleBrushEnd);
|
||||
this.chart.setOption({
|
||||
brush: {
|
||||
xAxisIndex: 0,
|
||||
// Only measures the range, the mask fades what is outside
|
||||
seriesIndex: [],
|
||||
transformable: false,
|
||||
brushStyle: BRUSH_STYLE,
|
||||
},
|
||||
});
|
||||
}
|
||||
const brushCursor = {
|
||||
type: "takeGlobalCursor",
|
||||
key: "brush",
|
||||
brushOption: {
|
||||
brushType: selectRange ? "lineX" : false,
|
||||
brushMode: "single",
|
||||
brushStyle: BRUSH_STYLE,
|
||||
},
|
||||
};
|
||||
// Brush and drag to zoom share the global cursor, the last one taken wins
|
||||
if (selectRange) {
|
||||
this._updateDragToZoom();
|
||||
this.chart.dispatchAction(brushCursor);
|
||||
} else {
|
||||
this.chart.dispatchAction(brushCursor);
|
||||
this._dragToZoom = false;
|
||||
this._updateDragToZoom();
|
||||
}
|
||||
}
|
||||
|
||||
private _hasZoomableXAxis() {
|
||||
const xAxis = ensureArray(this.options?.xAxis)?.[0] as
|
||||
XAXisOption | undefined;
|
||||
@@ -1475,7 +1648,8 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
private _getSeries() {
|
||||
const xAxis = (this.options?.xAxis?.[0] ?? this.options?.xAxis) as
|
||||
XAXisOption | undefined;
|
||||
const series = ensureArray(this.data).map((s) => {
|
||||
const maskArea = this._getMaskArea();
|
||||
const series = ensureArray(this.data).map((s, index) => {
|
||||
const data = this._hiddenDatasets.has(String(s.id ?? s.name))
|
||||
? undefined
|
||||
: s.data;
|
||||
@@ -1513,6 +1687,16 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
} as HaECSeriesItem;
|
||||
}
|
||||
}
|
||||
// ECharts merges series by id and would keep a mask left out here
|
||||
if (this._maskShown && (s.type === "line" || s.type === "bar")) {
|
||||
result = {
|
||||
...result,
|
||||
markArea:
|
||||
index === 0 && maskArea
|
||||
? maskArea
|
||||
: ((s as LineSeriesOption).markArea ?? { data: [] }),
|
||||
} as HaECSeriesItem;
|
||||
}
|
||||
return processSeriesTooltipFormatter(result);
|
||||
});
|
||||
return series as ECOption["series"];
|
||||
@@ -1762,7 +1946,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
// it, so a quick drag out would lose the release and its selection
|
||||
if (
|
||||
ev.type === "pointerdown" &&
|
||||
this._dragToZoom &&
|
||||
(this._dragToZoom || this.selectMode === "range") &&
|
||||
this._isZoomPress(ev)
|
||||
) {
|
||||
this.chart
|
||||
@@ -2337,6 +2521,7 @@ declare global {
|
||||
"dataset-hidden": { id: string };
|
||||
"dataset-unhidden": { id: string };
|
||||
"chart-click": ECElementEvent;
|
||||
"chart-select": ChartTimeRange;
|
||||
"legend-label-click": { id: string };
|
||||
"chart-zoom": {
|
||||
start: number;
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
import type { HassConfig, HassEntities } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
@@ -49,7 +49,13 @@ import type {
|
||||
HomeAssistantUI,
|
||||
} from "../../types";
|
||||
import { getPeriodicAxisLabelConfig } from "./axis-label";
|
||||
import type { CustomLegendOption } from "./ha-chart-base";
|
||||
import type {
|
||||
ChartSelectMode,
|
||||
ChartTimeRange,
|
||||
CustomLegendOption,
|
||||
HaChartBase,
|
||||
ZoomSliderMode,
|
||||
} from "./ha-chart-base";
|
||||
import "./ha-chart-base";
|
||||
import { sideTooltipPosition } from "./chart-tooltip-position";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
@@ -128,6 +134,16 @@ export class StatisticsChart extends LitElement {
|
||||
@property({ attribute: "expand-legend", type: Boolean })
|
||||
public expandLegend?: boolean;
|
||||
|
||||
@property({ attribute: "select-mode" })
|
||||
public selectMode: ChartSelectMode = "none";
|
||||
|
||||
@property({ attribute: "zoom-slider" })
|
||||
public zoomSlider: ZoomSliderMode = "auto";
|
||||
|
||||
@property({ attribute: false }) public highlight?: ChartTimeRange;
|
||||
|
||||
@query("ha-chart-base") private _chartBase?: HaChartBase;
|
||||
|
||||
@state() private _chartData: (LineSeriesOption | BarSeriesOption)[] = [];
|
||||
|
||||
@state() private _legendData: CustomLegendOption["data"];
|
||||
@@ -244,8 +260,12 @@ export class StatisticsChart extends LitElement {
|
||||
return html`
|
||||
<ha-chart-base
|
||||
.data=${this._chartData}
|
||||
.highlight=${this.highlight}
|
||||
.snapSelection=${this._snapSelection}
|
||||
.options=${this._chartOptions}
|
||||
.height=${this.height}
|
||||
.selectMode=${this.selectMode}
|
||||
.zoomSlider=${this.zoomSlider}
|
||||
style=${styleMap({ height: this.height })}
|
||||
@dataset-hidden=${this._datasetHidden}
|
||||
@dataset-unhidden=${this._datasetUnhidden}
|
||||
@@ -259,6 +279,34 @@ export class StatisticsChart extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
public resetZoom() {
|
||||
this._chartBase?.zoom(0, 100);
|
||||
}
|
||||
|
||||
// A click selects the period that contains it, a drag every period it
|
||||
// touches
|
||||
private _snapSelection = (
|
||||
range: ChartTimeRange
|
||||
): ChartTimeRange | undefined => {
|
||||
let start: number | undefined;
|
||||
let end: number | undefined;
|
||||
Object.values(this.statisticsData ?? {}).forEach((values) =>
|
||||
values.forEach((value) => {
|
||||
const touched =
|
||||
range.start === range.end
|
||||
? value.start <= range.start && range.start < value.end
|
||||
: value.start < range.end && value.end > range.start;
|
||||
if (touched) {
|
||||
start = Math.min(start ?? value.start, value.start);
|
||||
end = Math.max(end ?? value.end, value.end);
|
||||
}
|
||||
})
|
||||
);
|
||||
return start === undefined || end === undefined
|
||||
? undefined
|
||||
: { start, end };
|
||||
};
|
||||
|
||||
private _datasetHidden(ev: HASSDomEvent<HASSDomEvents["dataset-hidden"]>) {
|
||||
this._hiddenStats.add(ev.detail.id);
|
||||
this.requestUpdate("_hiddenStats");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -123,7 +123,7 @@ export const setProtectedAlarmControlPanelMode = async (
|
||||
(mode === "disarmed" && stateObj.attributes.code_format)
|
||||
) {
|
||||
const entry = await getExtendedEntityRegistryEntry(
|
||||
hass.callWS,
|
||||
hass,
|
||||
stateObj.entity_id
|
||||
).catch(() => undefined);
|
||||
const defaultCode = entry?.options?.alarm_control_panel?.default_code;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -266,10 +266,10 @@ export const computeEntityRegistryName = (
|
||||
};
|
||||
|
||||
export const getExtendedEntityRegistryEntry = (
|
||||
callWS: HomeAssistant["callWS"],
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
entityId: string
|
||||
): Promise<ExtEntityRegistryEntry> =>
|
||||
callWS({
|
||||
hass.callWS({
|
||||
type: "config/entity_registry/get",
|
||||
entity_id: entityId,
|
||||
});
|
||||
|
||||
+5
-5
@@ -6,7 +6,7 @@ import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { showEnterCodeDialog } from "../dialogs/enter-code/show-enter-code-dialog";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { UNAVAILABLE } from "./entity/entity";
|
||||
import { getExtendedEntityRegistryEntry } from "./entity/entity_registry";
|
||||
import type { ExtEntityRegistryEntry } from "./entity/entity_registry";
|
||||
|
||||
export enum LockEntityFeature {
|
||||
OPEN = 1,
|
||||
@@ -88,10 +88,10 @@ export const callProtectedLockService = async (
|
||||
service: ProtectedLockService
|
||||
) => {
|
||||
let code: string | undefined;
|
||||
const lockRegistryEntry = await getExtendedEntityRegistryEntry(
|
||||
callWS,
|
||||
stateObj.entity_id
|
||||
).catch(() => undefined);
|
||||
const lockRegistryEntry = await callWS<ExtEntityRegistryEntry>({
|
||||
type: "config/entity_registry/get",
|
||||
entity_id: stateObj.entity_id,
|
||||
}).catch(() => undefined);
|
||||
const defaultCode = lockRegistryEntry?.options?.lock?.default_code;
|
||||
|
||||
if (stateObj!.attributes.code_format && !defaultCode) {
|
||||
|
||||
+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,
|
||||
|
||||
@@ -19,7 +19,7 @@ export const fetchEntityConfigFlow = async (
|
||||
if (!entities || !(entityId in entities)) {
|
||||
return undefined;
|
||||
}
|
||||
const regEntry = await getExtendedEntityRegistryEntry(api.callWS, entityId);
|
||||
const regEntry = await getExtendedEntityRegistryEntry(api, entityId);
|
||||
if (!regEntry.config_entry_id) {
|
||||
return 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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ export class HaMoreInfoViewVacuumCleanAreasHeaderAction extends LitElement {
|
||||
if (!this.params.entityId || !this.hass.user?.is_admin) return;
|
||||
try {
|
||||
const entry = await getExtendedEntityRegistryEntry(
|
||||
this.hass.callWS,
|
||||
this.hass,
|
||||
this.params.entityId
|
||||
);
|
||||
const areaMapping = entry?.options?.vacuum?.area_mapping;
|
||||
|
||||
@@ -56,10 +56,7 @@ export class HaMoreInfoViewVacuumCleanAreas extends LitElement {
|
||||
|
||||
try {
|
||||
const entry: ExtEntityRegistryEntry =
|
||||
await getExtendedEntityRegistryEntry(
|
||||
this.hass.callWS,
|
||||
this.params.entityId
|
||||
);
|
||||
await getExtendedEntityRegistryEntry(this.hass, this.params.entityId);
|
||||
|
||||
const areaMapping = entry?.options?.vacuum?.area_mapping || {};
|
||||
const mappedAreaIds = new Set(Object.keys(areaMapping));
|
||||
|
||||
@@ -50,7 +50,7 @@ export class HaMoreInfoViewVacuumSegmentMapping extends LitElement {
|
||||
if (!this.params.entityId) return;
|
||||
|
||||
this._entry = await getExtendedEntityRegistryEntry(
|
||||
this.hass.callWS,
|
||||
this.hass,
|
||||
this.params.entityId
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
})}`;
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
}
|
||||
try {
|
||||
this._entry = await getExtendedEntityRegistryEntry(
|
||||
this.hass.callWS,
|
||||
this.hass,
|
||||
this._entityId
|
||||
);
|
||||
} catch (_e) {
|
||||
|
||||
@@ -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}` : ""}`
|
||||
|
||||
@@ -60,14 +60,25 @@
|
||||
/* Margins, not flex `gap` (Safari 14.1). */
|
||||
#ha-launch-screen .ha-launch-screen-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin: -4px 16px;
|
||||
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 {
|
||||
margin: 4px;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid transparent;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 18px;
|
||||
background: none;
|
||||
color: #009ac7;
|
||||
@@ -76,9 +87,6 @@
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
#ha-launch-screen .ha-launch-screen-actions .ha-launch-screen-continue {
|
||||
border-color: currentColor;
|
||||
}
|
||||
.ohf-logo {
|
||||
margin: 48px 0;
|
||||
display: flex;
|
||||
|
||||
@@ -116,30 +116,33 @@
|
||||
actions.className = "ha-launch-screen-actions";
|
||||
box.appendChild(actions);
|
||||
|
||||
function addButton(label, value) {
|
||||
var button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = label;
|
||||
button.addEventListener("click", function () {
|
||||
try {
|
||||
localStorage.setItem(ACCEPTED_KEY, value());
|
||||
} catch (_err) {
|
||||
// Continue this time only.
|
||||
}
|
||||
box.removeChild(actions);
|
||||
loadApp();
|
||||
});
|
||||
actions.appendChild(button);
|
||||
return button;
|
||||
}
|
||||
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);
|
||||
|
||||
addButton("Continue anyway", function () {
|
||||
return String(Date.now());
|
||||
}).className = "ha-launch-screen-continue";
|
||||
// From the first reminder on. A checkbox, so it is not chosen by
|
||||
// accident.
|
||||
if (acceptedAt) {
|
||||
addButton("Don't show again", function () {
|
||||
return "always";
|
||||
});
|
||||
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);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { mdiInformationOutline } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { consume } from "../../../../common/decorators/consume";
|
||||
import { consumeLocalize } from "../../../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { HASSDomCurrentTargetEvent } from "../../../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../../../common/dom/stop_propagation";
|
||||
import type { LocalizeKeys } from "../../../../common/translations/localize";
|
||||
import type {
|
||||
LocalizeFunc,
|
||||
LocalizeKeys,
|
||||
} from "../../../../common/translations/localize";
|
||||
import "../../../../components/entity/ha-statistic-picker";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/ha-tooltip";
|
||||
import "../../../../components/radio/ha-radio-group";
|
||||
import type { HaRadioGroup } from "../../../../components/radio/ha-radio-group";
|
||||
import "../../../../components/radio/ha-radio-option";
|
||||
import "../../../../components/ha-select";
|
||||
import type { HaSelectSelectEvent } from "../../../../components/ha-select";
|
||||
import { apiContext } from "../../../../data/context";
|
||||
import type { PowerConfig } from "../../../../data/energy";
|
||||
import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
|
||||
import { buttonLinkStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import type { HomeAssistantApi, ValueChangedEvent } from "../../../../types";
|
||||
import type { PowerType } from "./power-config";
|
||||
|
||||
const powerUnitClasses = ["power"];
|
||||
@@ -28,8 +28,6 @@ declare global {
|
||||
|
||||
@customElement("ha-energy-power-config")
|
||||
export class HaEnergyPowerConfig extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public powerType: PowerType = "none";
|
||||
|
||||
@property({ attribute: false }) public powerConfig: PowerConfig = {};
|
||||
@@ -50,67 +48,67 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
@property({ attribute: false }) public localizeBaseKey =
|
||||
"ui.panel.config.energy.battery.dialog";
|
||||
|
||||
@state() @consumeLocalize() private _localize!: LocalizeFunc;
|
||||
|
||||
@consume({ context: apiContext, subscribe: true })
|
||||
private _api!: HomeAssistantApi;
|
||||
|
||||
@state() private _powerUnits?: string[];
|
||||
|
||||
protected async willUpdate(
|
||||
changedProps: PropertyValues<this>
|
||||
): Promise<void> {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
if (changedProps.has("hass") && !this._powerUnits) {
|
||||
this._powerUnits = (
|
||||
await getSensorDeviceClassConvertibleUnits(this.hass, "power")
|
||||
).units;
|
||||
}
|
||||
protected async firstUpdated(): Promise<void> {
|
||||
this._powerUnits = (
|
||||
await getSensorDeviceClassConvertibleUnits(this._api.callWS, "power")
|
||||
).units;
|
||||
}
|
||||
|
||||
private get _requiredError(): string {
|
||||
return this.hass.localize("ui.common.error_required");
|
||||
return this._localize("ui.common.error_required");
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const helperSensorNote = this._localize(
|
||||
`${this.localizeBaseKey}.helper_sensor_note` as LocalizeKeys
|
||||
);
|
||||
return html`
|
||||
<p class="power-section-label">
|
||||
${this.hass.localize(
|
||||
<ha-select
|
||||
.label=${this._localize(
|
||||
`${this.localizeBaseKey}.sensor_type` as LocalizeKeys
|
||||
)}
|
||||
</p>
|
||||
<p class="power-section-description">
|
||||
${this.hass.localize(
|
||||
.helper=${this._localize(
|
||||
`${this.localizeBaseKey}.sensor_type_para` as LocalizeKeys
|
||||
)}
|
||||
</p>
|
||||
|
||||
<ha-radio-group
|
||||
.value=${this.powerType}
|
||||
.options=${[
|
||||
{
|
||||
value: "none",
|
||||
label: this._localize(
|
||||
`${this.localizeBaseKey}.type_none` as LocalizeKeys
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "standard",
|
||||
label: this._localize(
|
||||
`${this.localizeBaseKey}.type_standard` as LocalizeKeys
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "inverted",
|
||||
label: this._localize(
|
||||
`${this.localizeBaseKey}.type_inverted` as LocalizeKeys
|
||||
),
|
||||
secondary: helperSensorNote,
|
||||
},
|
||||
{
|
||||
value: "two_sensors",
|
||||
label: this._localize(
|
||||
`${this.localizeBaseKey}.type_two_sensors` as LocalizeKeys
|
||||
),
|
||||
secondary: helperSensorNote,
|
||||
},
|
||||
]}
|
||||
.disabled=${this.disabled}
|
||||
name="powerType"
|
||||
@change=${this._handlePowerTypeChanged}
|
||||
>
|
||||
<ha-radio-option value="none">
|
||||
${this.hass.localize(
|
||||
`${this.localizeBaseKey}.type_none` as LocalizeKeys
|
||||
)}
|
||||
</ha-radio-option>
|
||||
<ha-radio-option value="standard">
|
||||
${this.hass.localize(
|
||||
`${this.localizeBaseKey}.type_standard` as LocalizeKeys
|
||||
)}
|
||||
</ha-radio-option>
|
||||
<ha-radio-option value="inverted">
|
||||
${this.hass.localize(
|
||||
`${this.localizeBaseKey}.type_inverted` as LocalizeKeys
|
||||
)}
|
||||
${this._renderHelperSensorNote("inverted")}
|
||||
</ha-radio-option>
|
||||
<ha-radio-option value="two_sensors">
|
||||
${this.hass.localize(
|
||||
`${this.localizeBaseKey}.type_two_sensors` as LocalizeKeys
|
||||
)}
|
||||
${this._renderHelperSensorNote("two_sensors")}
|
||||
</ha-radio-option>
|
||||
</ha-radio-group>
|
||||
|
||||
@selected=${this._handlePowerTypeChanged}
|
||||
></ha-select>
|
||||
${
|
||||
this.powerType === "standard"
|
||||
? html`
|
||||
@@ -118,12 +116,12 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
.disabled=${this.disabled}
|
||||
.includeUnitClass=${powerUnitClasses}
|
||||
.value=${this.powerConfig.stat_rate}
|
||||
.label=${this.hass.localize(
|
||||
.label=${this._localize(
|
||||
`${this.localizeBaseKey}.power` as LocalizeKeys
|
||||
)}
|
||||
.excludeStatistics=${this.excludeList}
|
||||
@value-changed=${this._standardPowerChanged}
|
||||
.helper=${this.hass.localize(
|
||||
.helper=${this._localize(
|
||||
`${this.localizeBaseKey}.power_helper` as LocalizeKeys,
|
||||
{ unit: this._powerUnits?.join(", ") || "" }
|
||||
)}
|
||||
@@ -141,12 +139,12 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
.disabled=${this.disabled}
|
||||
.includeUnitClass=${powerUnitClasses}
|
||||
.value=${this.powerConfig.stat_rate_inverted}
|
||||
.label=${this.hass.localize(
|
||||
.label=${this._localize(
|
||||
`${this.localizeBaseKey}.power` as LocalizeKeys
|
||||
)}
|
||||
.excludeStatistics=${this.excludeList}
|
||||
@value-changed=${this._invertedPowerChanged}
|
||||
.helper=${this.hass.localize(
|
||||
.helper=${this._localize(
|
||||
`${this.localizeBaseKey}.type_inverted_description` as LocalizeKeys
|
||||
)}
|
||||
required
|
||||
@@ -165,7 +163,7 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
.disabled=${this.disabled}
|
||||
.includeUnitClass=${powerUnitClasses}
|
||||
.value=${this.powerConfig.stat_rate_from}
|
||||
.label=${this.hass.localize(
|
||||
.label=${this._localize(
|
||||
`${this.localizeBaseKey}.power_from` as LocalizeKeys
|
||||
)}
|
||||
.excludeStatistics=${[
|
||||
@@ -180,7 +178,7 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
.disabled=${this.disabled}
|
||||
.includeUnitClass=${powerUnitClasses}
|
||||
.value=${this.powerConfig.stat_rate_to}
|
||||
.label=${this.hass.localize(
|
||||
.label=${this._localize(
|
||||
`${this.localizeBaseKey}.power_to` as LocalizeKeys
|
||||
)}
|
||||
.excludeStatistics=${[
|
||||
@@ -198,31 +196,13 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderHelperSensorNote(powerType: PowerType) {
|
||||
const id = `helper-sensor-note-${powerType}`;
|
||||
return html`
|
||||
<ha-svg-icon
|
||||
id=${id}
|
||||
tabindex="0"
|
||||
class="note-icon"
|
||||
.path=${mdiInformationOutline}
|
||||
@click=${stopPropagation}
|
||||
></ha-svg-icon>
|
||||
<ha-tooltip .for=${id} placement="top">
|
||||
${this.hass.localize(
|
||||
`${this.localizeBaseKey}.helper_sensor_note` as LocalizeKeys
|
||||
)}
|
||||
</ha-tooltip>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderHelperSensorInUse() {
|
||||
if (!this.helperEntityId) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<p class="helper-sensor-in-use">
|
||||
${this.hass.localize(
|
||||
${this._localize(
|
||||
`${this.localizeBaseKey}.helper_sensor_in_use` as LocalizeKeys,
|
||||
{
|
||||
entity: html`<button class="link" @click=${this._showHelperSensor}>
|
||||
@@ -238,8 +218,8 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
fireEvent(this, "hass-more-info", { entityId: this.helperEntityId! });
|
||||
}
|
||||
|
||||
private _handlePowerTypeChanged(ev: HASSDomCurrentTargetEvent<HaRadioGroup>) {
|
||||
const newPowerType = (ev.currentTarget as HaRadioGroup).value as PowerType;
|
||||
private _handlePowerTypeChanged(ev: HaSelectSelectEvent) {
|
||||
const newPowerType = ev.detail.value as PowerType;
|
||||
// Clear power config when switching types
|
||||
fireEvent(this, "power-config-changed", {
|
||||
powerType: newPowerType,
|
||||
@@ -284,33 +264,17 @@ export class HaEnergyPowerConfig extends LitElement {
|
||||
static readonly styles: CSSResultGroup = [
|
||||
buttonLinkStyle,
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-4);
|
||||
}
|
||||
ha-select,
|
||||
ha-statistic-picker {
|
||||
display: block;
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
ha-statistic-picker:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
ha-radio-group {
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
.power-section-label {
|
||||
margin-top: var(--ha-space-4);
|
||||
margin-bottom: var(--ha-space-2);
|
||||
}
|
||||
.power-section-description {
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--ha-space-2);
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 0.875em;
|
||||
}
|
||||
.note-icon {
|
||||
margin-inline-start: var(--ha-space-1);
|
||||
color: var(--secondary-text-color);
|
||||
--mdc-icon-size: 18px;
|
||||
}
|
||||
.helper-sensor-in-use {
|
||||
margin: var(--ha-space-2) 0 0 0;
|
||||
margin: 0;
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { mdiChartLine, mdiShape } from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consume } from "../../../../common/decorators/consume";
|
||||
import { consumeLocalize } from "../../../../common/decorators/consume-context-entry";
|
||||
import { transform } from "../../../../common/decorators/transform";
|
||||
import {
|
||||
computeEntityNameParts,
|
||||
computeEntitySearchLabels,
|
||||
} from "../../../../common/entity/compute_entity_name_display";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
import "../../../../components/entity/state-badge";
|
||||
import "../../../../components/ha-combo-box-item";
|
||||
import "../../../../components/ha-generic-picker";
|
||||
import type { PickerComboBoxItem } from "../../../../components/ha-picker-combo-box";
|
||||
import type { PickerValueRenderer } from "../../../../components/ha-picker-field";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import {
|
||||
formattersContext,
|
||||
registriesContext,
|
||||
statesContext,
|
||||
} from "../../../../data/context";
|
||||
import type { DeviceConsumptionEnergyPreference } from "../../../../data/energy";
|
||||
import {
|
||||
computeAmbiguousEnergyDevices,
|
||||
@@ -25,7 +35,10 @@ import {
|
||||
getStatisticLabel,
|
||||
type StatisticsMetaData,
|
||||
} from "../../../../data/recorder";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
|
||||
import type {
|
||||
HomeAssistantFormatters,
|
||||
ValueChangedEvent,
|
||||
} from "../../../../types";
|
||||
|
||||
interface UpstreamDeviceComboBoxItem extends PickerComboBoxItem {
|
||||
stateObj?: HassEntity;
|
||||
@@ -44,8 +57,6 @@ const SEARCH_KEYS = [
|
||||
|
||||
@customElement("ha-energy-upstream-device-picker")
|
||||
export class HaEnergyUpstreamDevicePicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property() public value?: string;
|
||||
|
||||
@property() public label?: string;
|
||||
@@ -67,10 +78,30 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public statsMetadata?: Record<string, StatisticsMetaData>;
|
||||
|
||||
@state()
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
private _states!: ContextType<typeof statesContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: registriesContext, subscribe: true })
|
||||
private _registries!: ContextType<typeof registriesContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: formattersContext, subscribe: true })
|
||||
@transform<
|
||||
HomeAssistantFormatters,
|
||||
HomeAssistantFormatters["formatEntityName"]
|
||||
>({
|
||||
transformer: ({ formatEntityName }) => formatEntityName,
|
||||
})
|
||||
private _formatEntityName!: HomeAssistantFormatters["formatEntityName"];
|
||||
|
||||
@state() @consumeLocalize() private _localize!: LocalizeFunc;
|
||||
|
||||
private _ambiguousStatisticIds = memoizeOne(
|
||||
(
|
||||
states: HomeAssistant["states"],
|
||||
formatEntityName: HomeAssistant["formatEntityName"],
|
||||
states: ContextType<typeof statesContext>,
|
||||
formatEntityName: HomeAssistantFormatters["formatEntityName"],
|
||||
devices: DeviceConsumptionEnergyPreference[]
|
||||
) => computeAmbiguousEnergyDevices(states, formatEntityName, devices)
|
||||
);
|
||||
@@ -80,35 +111,38 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
|
||||
const name = this.devices.find(
|
||||
(device) => device.stat_consumption === statisticId
|
||||
)?.name;
|
||||
const stateObj = this.hass.states[statisticId];
|
||||
const stateObj = this._states[statisticId];
|
||||
|
||||
if (stateObj) {
|
||||
const { area: areaName } = computeEntityNameParts(stateObj, this.hass);
|
||||
const { area: areaName } = computeEntityNameParts(
|
||||
stateObj,
|
||||
this._registries
|
||||
);
|
||||
|
||||
return {
|
||||
id: statisticId,
|
||||
// Match the label shown in the device list and the graphs.
|
||||
primary: computeEnergyLabel(
|
||||
this.hass.states,
|
||||
this.hass.formatEntityName,
|
||||
this._states,
|
||||
this._formatEntityName,
|
||||
statisticId,
|
||||
this.statsMetadata?.[statisticId],
|
||||
name,
|
||||
this._ambiguousStatisticIds(
|
||||
this.hass.states,
|
||||
this.hass.formatEntityName,
|
||||
this._states,
|
||||
this._formatEntityName,
|
||||
this.devices
|
||||
).has(statisticId)
|
||||
),
|
||||
secondary: areaName,
|
||||
stateObj,
|
||||
search_labels: computeEntitySearchLabels(stateObj, this.hass),
|
||||
search_labels: computeEntitySearchLabels(stateObj, this._registries),
|
||||
};
|
||||
}
|
||||
|
||||
const label = getStatisticLabel(
|
||||
this.hass.states,
|
||||
this.hass.formatEntityName,
|
||||
this._states,
|
||||
this._formatEntityName,
|
||||
statisticId,
|
||||
this.statsMetadata?.[statisticId]
|
||||
);
|
||||
@@ -116,7 +150,7 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
|
||||
|
||||
if (isExternal) {
|
||||
const domainName = domainToName(
|
||||
this.hass.localize,
|
||||
this._localize,
|
||||
statisticId.split(":")[0]
|
||||
);
|
||||
return {
|
||||
@@ -131,7 +165,7 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
|
||||
return {
|
||||
id: statisticId,
|
||||
primary: name || label,
|
||||
secondary: this.hass.localize("ui.components.statistic-picker.no_state"),
|
||||
secondary: this._localize("ui.components.statistic-picker.no_state"),
|
||||
icon_path: mdiShape,
|
||||
search_labels: { label },
|
||||
};
|
||||
@@ -139,7 +173,10 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
|
||||
|
||||
private _items = memoizeOne(
|
||||
(
|
||||
_hass: HomeAssistant,
|
||||
_states: ContextType<typeof statesContext>,
|
||||
_registries: ContextType<typeof registriesContext>,
|
||||
_formatEntityName: HomeAssistantFormatters["formatEntityName"],
|
||||
_localize: LocalizeFunc,
|
||||
_value: string | undefined,
|
||||
_possibleParents: DeviceConsumptionEnergyPreference[],
|
||||
_devices: DeviceConsumptionEnergyPreference[],
|
||||
@@ -161,7 +198,10 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
|
||||
|
||||
private _getItems = () =>
|
||||
this._items(
|
||||
this.hass,
|
||||
this._states,
|
||||
this._registries,
|
||||
this._formatEntityName,
|
||||
this._localize,
|
||||
this.value,
|
||||
this.possibleParents,
|
||||
this.devices,
|
||||
|
||||
Loaded 100 of 177 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user