Compare commits

..
Author SHA1 Message Date
Paul Bottein 692338459d Only add empty chart mask areas once a mask was shown 2026-11-12 18:03:50 +01:00
Paul Bottein a8ee519fcf Keep drag to zoom when the chart select mode is point 2026-11-12 18:03:50 +01:00
Paul Bottein fdbab9046c Make chart selection opt-in with select-mode and cache the mask color 2026-11-12 18:03:50 +01:00
Paul Bottein 9f19a66a51 Clear stale chart masks after a data update 2026-11-12 18:03:50 +01:00
Paul Bottein 7b5d02bdba Move chart selection and highlight into ha-chart-base 2026-11-12 18:03:50 +01:00
Paul Bottein 3a3228bf79 Unify chart selection with a fade mask and a clear button in the gallery 2026-11-12 18:03:50 +01:00
Paul Bottein a299f5f9a7 Add range selection examples to the statistics chart gallery page 2026-11-12 18:03:50 +01:00
Paul Bottein 27902ef586 Keep range selection on release outside the chart and simplify the highlight 2026-11-12 18:03:50 +01:00
Paul Bottein 2d0ab5349b Add range selection and highlight to charts 2026-11-12 18:03:50 +01:00
Paul Bottein 77170b447f Fix favorites centering in the more info dialog (#55884) 2026-11-12 17:26:25 +02:00
c0ffeeca7 e2bb744202 MCP: change AI agent to AI app (#55885) 2026-11-12 17:25:49 +02:00
Wendelin 0eec83448a Revert camera ICE server filter and WebRTC-first memory (#55881)
* Revert camera ICE server filter and WebRTC-first memory

Follow-up to #55861 after review feedback:

- Use the full ICE server list from core again. The slow starts come from
  go2rtc, which stalls while it sets up TURN relays with the cloud ICE
  servers, so filtering in the browser does not help.
- Start HLS and WebRTC together again, remove the per-camera WebRTC-first
  memory and its 12 second HLS delay.
- Retry a WebRTC stream at most once, only after it played or on a backend
  error. A new connection that gets no answer or finds no path fails
  directly, so HLS or MJPEG takes over.

* Ignore stream URL errors of a removed HLS player

Switching cameras while the old HLS player was still fetching its stream
URL could mark HLS as failed for the new camera.
2026-11-12 14:51:53 +01:00
WendelinandClaude Sonnet 5.5 1864f6f802 Move the media browser list item to its own file (#55877)
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-11-12 13:26:28 +01:00
renovate[bot] 9a19cc3bf6 Update dependency vite to v8.3.7 (#55880)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-12 13:24:58 +01:00
WendelinandClaude Fable 5.2 ec51fb4f8d Add attributes to wrap text in list rows (#55864)
The headline and supporting text of ha-row-item are truncated to one
line. Many pages overrode that with white-space rules on the headline
and supporting-text parts.

Add the wrap-headline and wrap-supporting-text attributes and use them
in place of those CSS overrides.

Co-authored-by: Claude Fable 5.2 <[email protected]>
2026-11-12 13:20:40 +02:00
Paul Bottein 6a1a9bfb1b Suggest area icons based on the area name (#55708)
* Suggest area icons from the room in the area name

* Fix stale icon list, closed dialog and AI icon override

* Keep an untouched area dialog clean after the icon prefill

* Keep the suggested section visible when a suggested icon is selected

* Move area icon keywords to the translations

* Move room keywords to a shared translation key

* Remove home cinema and gym from the room list

* Note the Lokalise draft effect of editing room keywords

* Ship room keyword drafts and group keywords under rooms.keywords

* Add more room types for area icon suggestions

* Fetch room keyword drafts through the Lokalise keys API
2026-11-12 10:51:12 +01:00
Maarten Lakerveld 16807d2765 Make "Don't show again" a checkbox on the browser warning (#55863)
As a second button it was easy to press by accident. The reminder now has a
checkbox, and "Continue anyway" applies it.
2026-11-12 09:48:17 +01:00
renovate[bot] 9caad65c91 Update dependency eslint-plugin-import-x to v4.18.1 (#55874)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-12 08:16:21 +02:00
Bram KragtenandClaude Opus 5.5 af523f1087 Fix Matter add device dialog stuck on the spinner (#55869)
The dialog waited for the companion app's commission finish message on
every step, but only the app's own flow sends it. Steps that commission
through core or another app now advance as soon as the device appears.
A failed finish message after the device appeared also advances now.

The new device watcher unsubscribed twice after a find, which dropped
another subscriber from the device registry collection. After a few
adds the registry stopped updating and the dialog never saw the device.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-12 08:12:21 +02:00
renovate[bot] 349d5f7125 Update dependency intl-messageformat to v12.1.5 (#55872)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-12 07:01:41 +01:00
renovate[bot] de64bd3c59 Update formatjs (#55873)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-12 07:01:21 +01:00
Zedas 5253eef284 Allow a few pixels of finger movement in the action handler (#55826)
* Allow a few pixels of finger movement in the action handler

* Cancel the action handler press when the browser takes over the touch

The document scroll listener only hears the page itself, not nested
scrollers. pointercancel is composed and fires when the browser starts
scrolling a touch, so it covers those as well.
2026-11-11 22:51:14 +01:00
WendelinandClaude Fable 5.2 b1164739c3 Make camera WebRTC streams connect faster and more reliably (#55861)
* Make camera WebRTC streams connect faster and more reliably

- Limit the ICE server URLs the browser uses to one STUN, one UDP TURN
  and one TCP TURN URL per host. The last retry uses the full list.
- Start WebRTC first for cameras where it worked before in this browser,
  with HLS as fallback after 12 seconds.
- Always prefer WebRTC over HLS once it plays.
- Restart a WebRTC stream up to two times when it fails, loses its
  connection or does not connect in time, then report the failure so HLS
  takes over.
- Fix the ICE candidates added to the offer.

Co-Authored-By: Claude Fable 5.2 <[email protected]>

* Address review and connect the microphone over the known path

- Always forget a camera when WebRTC shows no video within the deadline.
- Fall back to MJPEG when a WebRTC only camera delivers audio but no
  video.
- Keep using all ICE servers once a stream only loaded with them.
- Start the microphone connection with only the ICE servers the running
  stream needs for its path.

Co-Authored-By: Claude Fable 5.2 <[email protected]>

* Keep all ICE servers for a direct microphone path that needed them

Co-Authored-By: Claude Fable 5.2 <[email protected]>

* Improve microphone connection handling in WebRTC player

---------

Co-authored-by: Claude Fable 5.2 <[email protected]>
2026-11-11 16:35:47 +01:00
Petar Petrov 80a516c673 Allow selecting values in the energy sources table (#55852) 2026-11-11 16:07:46 +01:00
Petar Petrov d5d0f45919 Move energy settings from dialogs to pages (#55410)
* Move energy settings from dialogs to an editor sidebar

Sources and devices on all energy configuration tabs now open in a
sidebar like automation steps, with the selected row highlighted and a
save button for unsaved changes. Cost tracking and power measurement type
use dropdowns, and the editor inputs are grouped with dividers. The energy
setup wizard keeps its dialogs, which now reuse the same editors.

* Read the energy editors' data from contexts

The energy editors, the power measurement config and the upstream device
picker consume contexts instead of hass, so the setup wizard dialog no
longer passes it. createConfigFlow, handleConfigFlowStep and
getSensorDeviceClassConvertibleUnits take callApi or callWS.

The sidebar slots the editor in, so switching to the bottom sheet keeps
unsaved changes, and the lists are inert while the editor saves.

* Confirm unsaved energy edits on the back arrow

The toolbar back arrow asks before discarding the editor's changes, like
the automation editor does, and the sidebar is inert while the editor
saves, like the lists.

* Edit energy sources and devices on pages of their own

Sources and devices open on a page with a back arrow, their name as the
title and the form in a card, instead of in a sidebar. The lists go back
to the centered column, and the energy setup wizard keeps its dialogs.

* Keep energy page saves and deletes apart

Save and Delete are disabled while the other runs, since both write all
preferences, and a page that was left no longer navigates when its save
or delete finishes.
2026-11-11 15:53:24 +01:00
Petar Petrov f922bc59a5 Drop hass from the state card content (#55853)
stateCardType, canToggleState and canToggleDomain take states and services; HassMediaPlayerEntity takes callService.
2026-11-11 15:36:31 +01:00
Petar Petrov a71a79b01b Drop hass from the target details dialog (#55850)
The dialog reads localize, callWS, states and entities from contexts, and resolveItemExclusions takes callWS.
2026-11-11 15:21:23 +01:00
Petar Petrov 9b8409d7ec Show model ID in the devices table when the model is missing (#55856) 2026-11-11 15:17:41 +01:00
Petar Petrov fc8d21b920 Point links in a duplicated dashboard at the copy (#55859) 2026-11-11 15:15:13 +01:00
Maarten Lakerveld fce0f4da4e Migrate the image cropper dialog to cropperjs 2 (#55570)
* Migrate the image cropper dialog to cropperjs 2

Cropper.js 2 is a rewrite built on shadow-DOM custom elements: the v1
class API (getData, getImageData, getCroppedCanvas, the ready and crop
callbacks) and the cropper.css stylesheet no longer exist, so the plain
Renovate bump (#55562) fails type checking and every rspack build.

Render the v2 elements directly from the dialog template instead of
wrapping an <img>. The canvas has no intrinsic size, so decode the file
first and give the canvas the image aspect ratio; the image then fills
it exactly. Initialise after the dialog has finished its scale-in
animation, because the elements measure themselves with
getBoundingClientRect, and refit the image and selection when the
canvas is resized.

Keep the v1 behaviour the callers rely on: the selection cannot leave
the image or the visible canvas, the image cannot be moved or zoomed out
from under it (viewMode 1), resizing stops at 50px, the rounded preview
is CSS only so the exported file stays rectangular, and the export is
rendered at the source resolution. Dirty tracking compares the crop
rectangle in source image pixels, like getData() did in v1.

* Clamp cropper drags to the edge and show image load errors

Cropper.js 2 moves the selection and image in relative steps per pointer
event. Rejecting a step that crossed the image edge dropped its movement on
both axes, so on fast drags the selection fell behind the cursor and got
stuck along an edge, mostly with portrait images. Clamp moves, resizes and
image pans to the edge instead, as v1 did. Fixed-ratio resizes shrink
around the anchored side to keep the ratio.

Show an error alert when the picture cannot be decoded instead of an empty
dialog.

* Keep the Shift-locked aspect ratio when a free crop hits the edge

With a free aspect ratio, Shift locks the ratio during a resize. The
out-of-bounds correction clipped the proposal instead of scaling it, so
the selection lost that ratio at the image or canvas edge.
2026-11-11 14:44:01 +01:00
WendelinandClaude Opus 5.5 2391c700c6 Expose action buttons in list rows to screen readers (#55831)
* Keep action controls in list rows reachable for screen readers

ha-list-item-button wrapped the whole row, including the start and end
slots, in its native button or link. Controls placed in those slots
were nested inside it and hidden from assistive technology.

Only the headline and supporting text are inside the native element now,
and its hit area is stretched over the row. Slotted controls sit above
it; icons, badges, text and empty dividers sit below, so they still
activate the row with native link behaviour.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Rebind the list row ripple when the control is replaced

Switching href swaps the inner <a> and <button>, and media browser rows
drop the control when not interactive. The ripple kept listening to the
removed element, so the new control had no hover or press feedback.

Render the ripple after the control and recreate it with the control.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-11-11 15:26:10 +02:00
R HOCH RandCopilot Autofix powered by AI 8cd79a6776 Add button to duplicate dashboards (#55832)
* Add option to duplicate dashboards

* Add error handling for missing dashboard config

Handle config not found error when fetching dashboard.

Co-authored-by: Copilot Autofix powered by AI <[email protected]>

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-11-11 13:25:56 +02:00
Petar Petrov b4f6e4e2ea Drop hass from the target picker (#55855)
* Drop hass from the target picker

getEntities takes an options object of separate values; ha-target-picker, ha-sources-picker, ha-selector-target and the exclusions controller consume contexts.

* Rerender the target picker only when a pending entity appears

Consume statesContext without @state and narrow a second consumer to the pending helper entity.

* Redraw target picker icons when the brands token arrives

Also refetch target selector entity sources after a reconnect, like the entity selector.
2026-11-11 11:00:13 +01:00
Lorenzo Marroccoli 50bb1cf53e Prefer model ID over firmware in integration device rows (#55845)
* Prefer model ID over firmware in integration device rows

* Include model ID in integration device search
2026-11-11 11:59:54 +02:00
Petar Petrov 70a1946874 Fix lazy context provider error and reconnect handling (#55854)
* Fix lazy context provider error and detached consumer handling

Retry failed subscriptions, drop consumers that left before the first value, and give config entry updates a new array.

* Reset config entries on a new snapshot

Replace the list when a resubscribe sends all entries again, and log failed subscriptions.

* Clear config entries on an empty snapshot

Core sends an empty list when no entries match the subscription.
2026-11-11 10:42:49 +01:00
Paul Bottein 3da9ba8119 Add default option and icons to the entity type select (#55833)
* Add default option and icons to the entity type select

* Simplify the entity type select code

* Store the device class override separately from the effective class
2026-11-11 09:57:22 +01:00
Petar Petrov eb8ac4139b Translate YAML editor error reasons (#55836)
Translate common YAML parse error reasons in the editor
2026-11-11 09:46:18 +01:00
Petar Petrov f9aae72325 Use "remove" wording when removing devices (#55835)
* Use "remove" wording when removing devices

* Keep "delete" wording for config entries
2026-11-11 09:18:30 +01:00
Christian Wiedel 81279aa6d2 Allow checking off to-do items without a name (#55847)
* Allow checking off to-do items without a name

* Keep whitespace-only names rejected by core
2026-11-11 10:08:27 +02:00
177 changed files with 9890 additions and 5510 deletions

No files matched your search

+107 -42
View File
@@ -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);
-1
View File
@@ -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
+19
View File
@@ -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,
});
}
});
+24 -5
View File
@@ -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>`
+80
View File
@@ -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
View File
@@ -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",
+253 -151
View File
@@ -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
+29
View File
@@ -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);
+5 -5
View File
@@ -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;
};
+13 -6
View File
@@ -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);
};
+11 -4
View File
@@ -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;
};
+56 -12
View File
@@ -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);
+189 -4
View File
@@ -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;
+50 -2
View File
@@ -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");
+18 -19
View File
@@ -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(
+55 -94
View File
@@ -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,
});
}
);
+7 -11
View File
@@ -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;
}
`;
+4
View File
@@ -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>
`;
}
+7 -2
View File
@@ -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 });
}
}
+62 -18
View File
@@ -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;
+12 -2
View File
@@ -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
+3 -2
View File
@@ -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)
+14 -6
View File
@@ -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}
+165 -84
View File
@@ -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`
+129 -12
View File
@@ -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) {
+8 -3
View File
@@ -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 = {};
+116 -11
View File
@@ -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;
}
`,
];
+25 -11
View File
@@ -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
+1 -1
View File
@@ -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;
+201
View File
@@ -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));
};
+80
View File
@@ -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
View File
@@ -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";
+10 -4
View File
@@ -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;
}
}
+4 -4
View File
@@ -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,
+23 -23
View File
@@ -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,
},
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
@@ -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>
`;
})}`;
}
+1 -1
View File
@@ -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>
`
}
+11 -1
View File
@@ -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(
+21 -26
View File
@@ -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}` : ""}`
+16 -8
View File
@@ -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;
+25 -22
View File
@@ -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);
}
})();
}
+4
View File
@@ -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
+2 -2
View File
@@ -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