mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-15 12:33:35 +00:00
Compare commits
22 Commits
20260624.3
...
rc
| Author | SHA1 | Date | |
|---|---|---|---|
| c573669786 | |||
| 4e1ccab159 | |||
| 347d63bce9 | |||
| a1d7b31732 | |||
| 4d61df7ed7 | |||
| 991dda70fc | |||
| 58f5480ca3 | |||
| 1f20bc0749 | |||
| 0846cb3be3 | |||
| 33afb77367 | |||
| b2f85e2595 | |||
| 09956a7d9c | |||
| 8507e222f8 | |||
| 5737480398 | |||
| 44163b9ccb | |||
| e79cd0c5b2 | |||
| 52379b39e0 | |||
| 656e1bea8e | |||
| 4ef3ed2f02 | |||
| fbad0ba885 | |||
| c892691344 | |||
| 811b7c7d98 |
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20260624.3"
|
||||
version = "20260624.5"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { consume } from "@lit/context";
|
||||
import type { HassEntities } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
@@ -15,7 +16,12 @@ import {
|
||||
} from "../../data/device/device_automation";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
import type { CallWS, HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import "../ha-combo-box-item";
|
||||
import "../ha-generic-picker";
|
||||
import {
|
||||
DEFAULT_ROW_RENDERER_CONTENT,
|
||||
type PickerComboBoxItem,
|
||||
} from "../ha-picker-combo-box";
|
||||
import type { PickerValueRenderer } from "../ha-picker-field";
|
||||
|
||||
const NO_AUTOMATION_KEY = "NO_AUTOMATION";
|
||||
@@ -105,6 +111,7 @@ export abstract class HaDeviceAutomationPicker<
|
||||
.disabled=${!this._automations || this._automations.length === 0}
|
||||
.getItems=${this._getItems(value, this._automations)}
|
||||
@value-changed=${this._automationChanged}
|
||||
.rowRenderer=${this._rowRenderer}
|
||||
.valueRenderer=${this._valueRenderer}
|
||||
.unknownItemText=${this.hass.localize(
|
||||
"ui.panel.config.devices.automation.actions.unknown_action"
|
||||
@@ -160,6 +167,13 @@ export abstract class HaDeviceAutomationPicker<
|
||||
}
|
||||
);
|
||||
|
||||
// Device automation labels (entity name + subtype) are often longer than the
|
||||
// field, so let the option wrap onto multiple lines instead of truncating.
|
||||
private _rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) =>
|
||||
html`<ha-combo-box-item type="button" compact multiline>
|
||||
${DEFAULT_ROW_RENDERER_CONTENT(item)}
|
||||
</ha-combo-box-item>`;
|
||||
|
||||
private _valueRenderer: PickerValueRenderer = (value: string) => {
|
||||
const automation = this._automations?.find(
|
||||
(a, idx) => value === `${a.device_id}_${idx}`
|
||||
|
||||
@@ -7,6 +7,11 @@ export class HaComboBoxItem extends HaMdListItem {
|
||||
@property({ type: Boolean, reflect: true, attribute: "border-top" })
|
||||
public borderTop = false;
|
||||
|
||||
// Allow the headline/supporting text to wrap onto multiple lines instead of
|
||||
// truncating with an ellipsis. Off by default to preserve single-line rows.
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public multiline = false;
|
||||
|
||||
static override styles = [
|
||||
...haMdListStyles,
|
||||
css`
|
||||
@@ -41,6 +46,10 @@ export class HaComboBoxItem extends HaMdListItem {
|
||||
font-size: var(--ha-font-size-s);
|
||||
white-space: nowrap;
|
||||
}
|
||||
:host([multiline]) [slot="headline"],
|
||||
:host([multiline]) [slot="supporting-text"] {
|
||||
white-space: normal;
|
||||
}
|
||||
::slotted(state-badge),
|
||||
::slotted(img) {
|
||||
width: 32px;
|
||||
|
||||
@@ -135,8 +135,6 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
|
||||
@state()
|
||||
private _bodyScrolled = false;
|
||||
|
||||
private _escapePressed = false;
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.addEventListener(
|
||||
@@ -290,7 +288,6 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
private _handleKeyDown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Escape") {
|
||||
this._escapePressed = true;
|
||||
if (this.preventScrimClose) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
@@ -300,13 +297,23 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
|
||||
}
|
||||
|
||||
private _handleHide(ev: DialogHideEvent) {
|
||||
const sourceIsDialog = ev.detail?.source === (ev.target as WaDialog).dialog;
|
||||
|
||||
if (this.preventScrimClose && this._escapePressed && sourceIsDialog) {
|
||||
ev.preventDefault();
|
||||
// Ignore wa-hide events bubbling up from nested overlays (pickers,
|
||||
// popovers, bottom sheets, nested dialogs); only handle this dialog's own
|
||||
// hide, like the sibling wa-* handlers above.
|
||||
if (ev.eventPhase !== Event.AT_TARGET) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._escapePressed = false;
|
||||
const sourceIsDialog = ev.detail?.source === (ev.target as WaDialog).dialog;
|
||||
|
||||
// A dialog-sourced hide (Escape, native cancel, or scrim) while the host
|
||||
// still wants the dialog open is a user dismissal, not a programmatic
|
||||
// close. Block it when closing must be guarded, regardless of where focus
|
||||
// currently is — e.g. after a picker overlay took focus and dropped it
|
||||
// outside the dialog, the Escape keydown never reaches this element.
|
||||
if (this.preventScrimClose && sourceIsDialog && this.open) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
|
||||
@@ -20,18 +20,16 @@ class HaRelativeTime extends ReactiveElement {
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: HomeAssistantInternationalization;
|
||||
|
||||
private _interval?: number;
|
||||
private _timeout?: number;
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._clearInterval();
|
||||
this._clearTimeout();
|
||||
}
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (this.datetime) {
|
||||
this._startInterval();
|
||||
}
|
||||
this._updateRelative();
|
||||
}
|
||||
|
||||
protected createRenderRoot() {
|
||||
@@ -40,31 +38,19 @@ class HaRelativeTime extends ReactiveElement {
|
||||
|
||||
protected update(changedProps: PropertyValues<this>) {
|
||||
super.update(changedProps);
|
||||
if (changedProps.has("datetime")) {
|
||||
if (this.datetime) {
|
||||
this._startInterval();
|
||||
} else {
|
||||
this._clearInterval();
|
||||
}
|
||||
}
|
||||
this._updateRelative();
|
||||
}
|
||||
|
||||
private _clearInterval(): void {
|
||||
if (this._interval) {
|
||||
window.clearInterval(this._interval);
|
||||
this._interval = undefined;
|
||||
private _clearTimeout(): void {
|
||||
if (this._timeout) {
|
||||
window.clearTimeout(this._timeout);
|
||||
this._timeout = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _startInterval(): void {
|
||||
this._clearInterval();
|
||||
|
||||
// update every 60 seconds
|
||||
this._interval = window.setInterval(() => this._updateRelative(), 60000);
|
||||
}
|
||||
|
||||
private _updateRelative(): void {
|
||||
this._clearTimeout();
|
||||
|
||||
if (!this._i18n) {
|
||||
return;
|
||||
}
|
||||
@@ -73,23 +59,33 @@ class HaRelativeTime extends ReactiveElement {
|
||||
this.textContent = this._i18n.localize(
|
||||
"ui.components.relative_time.never"
|
||||
);
|
||||
} else {
|
||||
const date =
|
||||
typeof this.datetime === "string"
|
||||
? parseISO(this.datetime)
|
||||
: this.datetime;
|
||||
|
||||
const relTime = relativeTime(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
undefined,
|
||||
true,
|
||||
this.format
|
||||
);
|
||||
this.textContent = this.capitalize
|
||||
? capitalizeFirstLetter(relTime)
|
||||
: relTime;
|
||||
return;
|
||||
}
|
||||
|
||||
const date =
|
||||
typeof this.datetime === "string"
|
||||
? parseISO(this.datetime)
|
||||
: this.datetime;
|
||||
|
||||
const relTime = relativeTime(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
undefined,
|
||||
true,
|
||||
this.format
|
||||
);
|
||||
this.textContent = this.capitalize
|
||||
? capitalizeFirstLetter(relTime)
|
||||
: relTime;
|
||||
|
||||
// Keep the relative time counting up on its own. Refresh every second
|
||||
// while the difference is still measured in seconds, otherwise every
|
||||
// minute.
|
||||
const secondsDiff = Math.abs(Date.now() - date.getTime()) / 1000;
|
||||
this._timeout = window.setTimeout(
|
||||
() => this._updateRelative(),
|
||||
secondsDiff < 60 ? 1000 : 60000
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { NumberSelector } from "../../data/selector";
|
||||
import { isSafari } from "../../util/is_safari";
|
||||
import "../ha-input-helper-text";
|
||||
import "../ha-slider";
|
||||
import "../input/ha-input";
|
||||
@@ -66,6 +67,16 @@ export class HaNumberSelector extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
// On iOS/iPadOS the numeric and decimal on-screen keypads have no minus key,
|
||||
// so negatives can only be typed with the full "text" keyboard. Other
|
||||
// platforms include a minus on their number keypads, so restrict this
|
||||
// workaround to Safari/WebKit and only when the selector allows negatives
|
||||
// (e.g. numeric_state triggers/conditions).
|
||||
const useTextInputMode =
|
||||
isSafari &&
|
||||
this.selector.number?.min !== undefined &&
|
||||
this.selector.number.min < 0;
|
||||
|
||||
const translationKey = this.selector.number?.translation_key;
|
||||
let unit = this.selector.number?.unit_of_measurement;
|
||||
if (isBox && unit && this.localizeValue && translationKey) {
|
||||
@@ -96,10 +107,12 @@ export class HaNumberSelector extends LitElement {
|
||||
`
|
||||
: nothing}
|
||||
<ha-input
|
||||
.inputMode=${this.selector.number?.step === "any" ||
|
||||
(this.selector.number?.step ?? 1) % 1 !== 0
|
||||
? "decimal"
|
||||
: "numeric"}
|
||||
.inputmode=${useTextInputMode
|
||||
? "text"
|
||||
: this.selector.number?.step === "any" ||
|
||||
(this.selector.number?.step ?? 1) % 1 !== 0
|
||||
? "decimal"
|
||||
: "numeric"}
|
||||
.label=${!isBox ? undefined : this.label}
|
||||
.placeholder=${this.placeholder !== undefined
|
||||
? this.placeholder.toString()
|
||||
|
||||
@@ -521,10 +521,10 @@ export class HaServiceControl extends LitElement {
|
||||
${description ? html`<p>${description}</p>` : ""}
|
||||
${this._manifest
|
||||
? html` <a
|
||||
href=${this._manifest.is_built_in
|
||||
href=${this._manifest.is_built_in && this._value?.action
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/integrations/${this._manifest.domain}`
|
||||
`/actions/${this._value.action}`
|
||||
)
|
||||
: this._manifest.documentation}
|
||||
title=${this.hass.localize(
|
||||
|
||||
@@ -461,6 +461,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
<div class="add-target-wrapper">
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
popover-placement="bottom-start"
|
||||
.disabled=${this.disabled}
|
||||
.autofocus=${this.autofocus}
|
||||
.helper=${this.helper}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiDeleteOutline, mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { uid } from "../../common/util/uid";
|
||||
import { internationalizationContext } from "../../data/context";
|
||||
import { haStyle } from "../../resources/styles";
|
||||
import "../ha-button";
|
||||
@@ -69,6 +70,25 @@ class HaInputMulti extends LitElement {
|
||||
|
||||
@query("ha-input[data-last]") private _lastInput?: HaInput;
|
||||
|
||||
// Stable key per row, kept in sync with `value`. Because items are plain
|
||||
// strings we cannot use a WeakMap (as the object-based sortable lists do),
|
||||
// so we track keys in a parallel array. Keys stay fixed while a row is
|
||||
// edited (preserving input focus) and travel with the row when reordered.
|
||||
@state() private _keys: string[] = [];
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues) {
|
||||
super.willUpdate(changedProps);
|
||||
if (changedProps.has("value") && this._keys.length !== this._items.length) {
|
||||
// Reconcile keys when `value` is (re)set from outside, reusing existing
|
||||
// keys and minting new ones for added rows. Internal add/remove/reorder
|
||||
// keep `_keys` in sync themselves, so this is skipped in those cases.
|
||||
this._keys = Array.from(
|
||||
{ length: this._items.length },
|
||||
(_, i) => this._keys[i] ?? uid()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-sortable
|
||||
@@ -80,7 +100,7 @@ class HaInputMulti extends LitElement {
|
||||
<div class="items">
|
||||
${repeat(
|
||||
this._items,
|
||||
(item, index) => `${item}-${index}`,
|
||||
(_item, index) => this._keys[index],
|
||||
(item, index) => {
|
||||
const indexSuffix = `${this.itemIndex ? ` ${index + 1}` : ""}`;
|
||||
return html`
|
||||
@@ -162,6 +182,7 @@ class HaInputMulti extends LitElement {
|
||||
if (this.max != null && this._items.length >= this.max) {
|
||||
return;
|
||||
}
|
||||
this._keys = [...this._keys, uid()];
|
||||
const items = [...this._items, ""];
|
||||
this._fireChanged(items);
|
||||
await this.updateComplete;
|
||||
@@ -194,11 +215,17 @@ class HaInputMulti extends LitElement {
|
||||
const items = [...this._items];
|
||||
const [moved] = items.splice(oldIndex, 1);
|
||||
items.splice(newIndex, 0, moved);
|
||||
// Move the row's key with it so its DOM (and identity) is preserved.
|
||||
const keys = [...this._keys];
|
||||
const [movedKey] = keys.splice(oldIndex, 1);
|
||||
keys.splice(newIndex, 0, movedKey);
|
||||
this._keys = keys;
|
||||
this._fireChanged(items);
|
||||
}
|
||||
|
||||
private async _removeItem(ev: Event) {
|
||||
const index = (ev.target as any).index;
|
||||
this._keys = this._keys.filter((_, i) => i !== index);
|
||||
const items = [...this._items];
|
||||
items.splice(index, 1);
|
||||
this._fireChanged(items);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import { extractSearchParam } from "../../../common/url/search-params";
|
||||
import "../../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
@@ -23,8 +24,14 @@ import type {
|
||||
StoreAddon,
|
||||
SupervisorStore,
|
||||
} from "../../../data/supervisor/store";
|
||||
import { fetchSupervisorStore } from "../../../data/supervisor/store";
|
||||
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import {
|
||||
addStoreRepository,
|
||||
fetchSupervisorStore,
|
||||
} from "../../../data/supervisor/store";
|
||||
import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
} from "../../../dialogs/generic/show-dialog-box";
|
||||
import "../../../layouts/hass-error-screen";
|
||||
import "../../../layouts/hass-loading-screen";
|
||||
import "../../../layouts/hass-subpage";
|
||||
@@ -82,7 +89,15 @@ export class HaConfigAppsAvailable extends LitElement {
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
this._loadData();
|
||||
const repositoryUrl = extractSearchParam("repository_url");
|
||||
if (repositoryUrl) {
|
||||
navigate("/config/apps/available", { replace: true });
|
||||
}
|
||||
this._loadData().then(() => {
|
||||
if (repositoryUrl) {
|
||||
this._addRepository(repositoryUrl);
|
||||
}
|
||||
});
|
||||
this.addEventListener("hass-api-called", (ev) => this._apiCalled(ev));
|
||||
}
|
||||
|
||||
@@ -226,6 +241,40 @@ export class HaConfigAppsAvailable extends LitElement {
|
||||
navigate("/config/apps/registries");
|
||||
}
|
||||
|
||||
private async _addRepository(repositoryUrl: string): Promise<void> {
|
||||
if (
|
||||
!this._store ||
|
||||
this._store.repositories.some((repo) => repo.source === repositoryUrl)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!(await showConfirmationDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.apps.my.add_repository_title"
|
||||
),
|
||||
text: this.hass.localize(
|
||||
"ui.panel.config.apps.my.add_repository_store_description",
|
||||
{ repository: repositoryUrl }
|
||||
),
|
||||
confirmText: this.hass.localize("ui.common.add"),
|
||||
dismissText: this.hass.localize("ui.common.cancel"),
|
||||
}))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await addStoreRepository(this.hass, repositoryUrl);
|
||||
await this._loadData();
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, {
|
||||
text: extractApiErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async _loadData(): Promise<void> {
|
||||
try {
|
||||
const [addon, store] = await Promise.all([
|
||||
|
||||
@@ -5,7 +5,6 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
|
||||
import { extractSearchParam } from "../../../common/url/search-params";
|
||||
import "../../../components/data-table/ha-data-table";
|
||||
import type { DataTableColumnContainer } from "../../../components/data-table/ha-data-table";
|
||||
import "../../../components/ha-button";
|
||||
@@ -56,12 +55,7 @@ export class HaConfigAppsRepositories extends LitElement {
|
||||
@state() private _error?: string;
|
||||
|
||||
protected firstUpdated() {
|
||||
this._loadData().then(() => {
|
||||
const repositoryUrl = extractSearchParam("repository_url");
|
||||
if (repositoryUrl) {
|
||||
this._addRepository(repositoryUrl);
|
||||
}
|
||||
});
|
||||
this._loadData();
|
||||
}
|
||||
|
||||
private _columns = memoizeOne(
|
||||
@@ -224,18 +218,6 @@ export class HaConfigAppsRepositories extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private async _addRepository(url: string) {
|
||||
try {
|
||||
await addStoreRepository(this.hass, url);
|
||||
await this._loadData();
|
||||
fireEvent(this, "apps-collection-refresh", { collection: "store" });
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, {
|
||||
text: extractApiErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _removeRepository = async (ev: Event) => {
|
||||
const slug = (ev.currentTarget as any).slug;
|
||||
const repo = this._repositories?.find((r) => r.slug === slug);
|
||||
|
||||
@@ -195,7 +195,7 @@ export class HaPlatformCondition extends LitElement {
|
||||
href=${this._manifest.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/integrations/${this._manifest.domain}`
|
||||
`/conditions/${this.condition.condition}`
|
||||
)
|
||||
: this._manifest.documentation}
|
||||
title=${this.hass.localize(
|
||||
|
||||
@@ -646,8 +646,17 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
...baseConfig,
|
||||
...(initData ? normalizeAutomationConfig(initData) : initData),
|
||||
} as AutomationConfig;
|
||||
this._initDirtyTracking({ type: "deep" }, baseConfig as AutomationConfig);
|
||||
this._updateDirtyState(this.config);
|
||||
this._initDirtyTracking(
|
||||
{ type: "deep" },
|
||||
{
|
||||
config: baseConfig as AutomationConfig,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
}
|
||||
);
|
||||
this._updateDirtyState({
|
||||
config: this.config,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
this.currentEntityId = undefined;
|
||||
this.readOnly = false;
|
||||
}
|
||||
@@ -655,7 +664,13 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
if (changedProps.has("entityId") && this.entityId) {
|
||||
getAutomationStateConfig(this.hass, this.entityId).then((c) => {
|
||||
this.config = normalizeAutomationConfig(c.config);
|
||||
this._initDirtyTracking({ type: "deep" }, this.config);
|
||||
this._initDirtyTracking(
|
||||
{ type: "deep" },
|
||||
{
|
||||
config: this.config,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
}
|
||||
);
|
||||
this._checkValidation();
|
||||
});
|
||||
this.currentEntityId = this.entityId;
|
||||
@@ -721,7 +736,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
if (this.readOnly) {
|
||||
return;
|
||||
}
|
||||
this._updateDirtyState(this.config);
|
||||
this._updateDirtyState({
|
||||
config: this.config,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
this.errors = undefined;
|
||||
}
|
||||
|
||||
@@ -802,7 +820,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
id: this.config?.id,
|
||||
...normalizeAutomationConfig(ev.detail.value),
|
||||
};
|
||||
this._updateDirtyState(this.config!);
|
||||
this._updateDirtyState({
|
||||
config: this.config!,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
this.errors = undefined;
|
||||
}
|
||||
|
||||
@@ -818,7 +839,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
updateConfig: async (config, entityRegistryUpdate) => {
|
||||
this.config = config;
|
||||
this.entityRegistryUpdate = entityRegistryUpdate;
|
||||
this._updateDirtyState(this.config);
|
||||
this._updateDirtyState({
|
||||
config: this.config,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
this.requestUpdate();
|
||||
|
||||
const id = this.automationId || String(Date.now());
|
||||
@@ -932,7 +956,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
updateConfig: async (config, entityRegistryUpdate) => {
|
||||
this.config = config;
|
||||
this.entityRegistryUpdate = entityRegistryUpdate;
|
||||
this._updateDirtyState(this.config);
|
||||
this._updateDirtyState({
|
||||
config: this.config,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
this.requestUpdate();
|
||||
resolve(true);
|
||||
},
|
||||
@@ -949,7 +976,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
config: this.config!,
|
||||
updateConfig: (config) => {
|
||||
this.config = config;
|
||||
this._updateDirtyState(config);
|
||||
this._updateDirtyState({
|
||||
config,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
this.requestUpdate();
|
||||
resolve();
|
||||
},
|
||||
@@ -969,7 +999,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
this._manualEditor?.resetPastedConfig();
|
||||
|
||||
const id = this.automationId || String(Date.now());
|
||||
if (!this.automationId) {
|
||||
if (!this.automationId && !this.config?.alias) {
|
||||
const saved = await this._promptAutomationAlias();
|
||||
if (!saved) {
|
||||
return;
|
||||
@@ -1100,7 +1130,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
private _applyUndoRedo(config: AutomationConfig) {
|
||||
this._manualEditor?.triggerCloseSidebar();
|
||||
this.config = config;
|
||||
this._updateDirtyState(this.config);
|
||||
this._updateDirtyState({
|
||||
config: this.config,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
private _undo() {
|
||||
|
||||
@@ -85,12 +85,17 @@ export interface EditorDomainHooks<TConfig> {
|
||||
domain: "automation" | "script";
|
||||
}
|
||||
|
||||
interface AutomationEditorConfig<TConfig> {
|
||||
config: TConfig;
|
||||
entityRegistryUpdate?: EntityRegistryUpdate;
|
||||
}
|
||||
|
||||
export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
superClass: Constructor<LitElement>
|
||||
) => {
|
||||
class AutomationScriptEditorClass extends DirtyStateProviderMixin<TConfig>()(
|
||||
superClass
|
||||
) {
|
||||
class AutomationScriptEditorClass extends DirtyStateProviderMixin<
|
||||
AutomationEditorConfig<TConfig>
|
||||
>()(superClass) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
|
||||
@@ -220,8 +225,17 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
protected takeControlSave() {
|
||||
this.readOnly = false;
|
||||
// Force dirty: set baseline to null so current config always differs
|
||||
this._initDirtyTracking({ type: "deep" }, null as unknown as TConfig);
|
||||
this._updateDirtyState(this.config!);
|
||||
this._initDirtyTracking(
|
||||
{ type: "deep" },
|
||||
{
|
||||
config: null as unknown as TConfig,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
}
|
||||
);
|
||||
this._updateDirtyState({
|
||||
config: this.config!,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
this.blueprintConfig = undefined;
|
||||
}
|
||||
|
||||
@@ -266,7 +280,13 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
// looks dirty. Surface an alert offering to save when deprecated
|
||||
// options were migrated.
|
||||
this.deprecatedConfigMigrated = report.deprecated;
|
||||
this._initDirtyTracking({ type: "deep" }, this.config);
|
||||
this._initDirtyTracking(
|
||||
{ type: "deep" },
|
||||
{
|
||||
config: this.config,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
}
|
||||
);
|
||||
hooks.checkValidation();
|
||||
} catch (err: any) {
|
||||
if (err.status_code !== 404) {
|
||||
|
||||
@@ -189,7 +189,7 @@ export class HaPlatformTrigger extends LitElement {
|
||||
href=${this._manifest.is_built_in
|
||||
? documentationUrl(
|
||||
this.hass,
|
||||
`/integrations/${this._manifest.domain}`
|
||||
`/triggers/${this.trigger.trigger}`
|
||||
)
|
||||
: this._manifest.documentation}
|
||||
title=${this.hass.localize(
|
||||
|
||||
@@ -4,6 +4,7 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
|
||||
import type { LocalizeKeys } from "../../../../common/translations/localize";
|
||||
import { debounce } from "../../../../common/util/debounce";
|
||||
import "../../../../components/ha-alert";
|
||||
import "../../../../components/ha-button";
|
||||
@@ -40,6 +41,15 @@ For loop example getting entity values in the weather domain:
|
||||
{{ state.name | lower }} is {{state.state_with_unit}}
|
||||
{%- endfor %}.`;
|
||||
|
||||
// key resolves the label/description translation keys; path is passed through
|
||||
// documentationUrl().
|
||||
const TEMPLATE_DOCS_LINKS: { key: string; path: string }[] = [
|
||||
{ key: "docs_introduction", path: "/docs/templating/introduction/" },
|
||||
{ key: "docs_states", path: "/docs/templating/states/" },
|
||||
{ key: "docs_debugging", path: "/docs/templating/debugging/" },
|
||||
{ key: "docs_functions", path: "/template-functions/" },
|
||||
];
|
||||
|
||||
@customElement("developer-tools-template")
|
||||
class HaPanelDevTemplate extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -120,31 +130,36 @@ class HaPanelDevTemplate extends LitElement {
|
||||
"ui.panel.config.developer-tools.tabs.templates.description"
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.templates.engine_info"
|
||||
)}
|
||||
</p>
|
||||
<h3>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.templates.learn_more"
|
||||
)}
|
||||
</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<a
|
||||
href="https://jinja.palletsprojects.com/en/latest/templates/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.templates.jinja_documentation"
|
||||
)}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href=${documentationUrl(
|
||||
this.hass,
|
||||
"/docs/configuration/templating/"
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.developer-tools.tabs.templates.template_extensions"
|
||||
)}</a
|
||||
>
|
||||
</li>
|
||||
${TEMPLATE_DOCS_LINKS.map(
|
||||
(link) => html`
|
||||
<li>
|
||||
<a
|
||||
href=${documentationUrl(this.hass, link.path)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>${this.hass.localize(
|
||||
`ui.panel.config.developer-tools.tabs.templates.${link.key}` as LocalizeKeys
|
||||
)}</a
|
||||
>
|
||||
<span class="link-description"
|
||||
>${this.hass.localize(
|
||||
`ui.panel.config.developer-tools.tabs.templates.${link.key}_description` as LocalizeKeys
|
||||
)}</span
|
||||
>
|
||||
</li>
|
||||
`
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
@@ -430,6 +445,17 @@ ${type === "object"
|
||||
margin-block-start: var(--ha-space-1);
|
||||
margin-block-end: var(--ha-space-1);
|
||||
}
|
||||
.description > h3 {
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
margin-block-end: var(--ha-space-1);
|
||||
}
|
||||
.description li {
|
||||
margin-block-end: var(--ha-space-1);
|
||||
}
|
||||
.description .link-description {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.render-pane .card-content {
|
||||
user-select: text;
|
||||
|
||||
@@ -560,15 +560,30 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
...baseConfig,
|
||||
...initData,
|
||||
} as ScriptConfig;
|
||||
this._initDirtyTracking({ type: "deep" }, baseConfig as ScriptConfig);
|
||||
this._updateDirtyState(this.config);
|
||||
this._initDirtyTracking(
|
||||
{ type: "deep" },
|
||||
{
|
||||
config: baseConfig as ScriptConfig,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
}
|
||||
);
|
||||
this._updateDirtyState({
|
||||
config: this.config,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
this.readOnly = false;
|
||||
}
|
||||
|
||||
if (changedProps.has("entityId") && this.entityId) {
|
||||
getScriptStateConfig(this.hass, this.entityId).then((c) => {
|
||||
this.config = normalizeScriptConfig(c.config);
|
||||
this._initDirtyTracking({ type: "deep" }, this.config);
|
||||
this._initDirtyTracking(
|
||||
{ type: "deep" },
|
||||
{
|
||||
config: this.config,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
}
|
||||
);
|
||||
this._checkValidation();
|
||||
});
|
||||
const regEntry = this.entityRegistry?.find(
|
||||
@@ -613,7 +628,10 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
|
||||
this.config = ev.detail.value;
|
||||
this.errors = undefined;
|
||||
this._updateDirtyState(this.config!);
|
||||
this._updateDirtyState({
|
||||
config: this.config!,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
private async _runScript() {
|
||||
@@ -700,7 +718,10 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
}
|
||||
|
||||
this._manualEditor?.addFields();
|
||||
this._updateDirtyState(this.config!);
|
||||
this._updateDirtyState({
|
||||
config: this.config!,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
private _preprocessYaml() {
|
||||
@@ -715,7 +736,10 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
}
|
||||
this.yamlErrors = undefined;
|
||||
this.config = ev.detail.value;
|
||||
this._updateDirtyState(this.config!);
|
||||
this._updateDirtyState({
|
||||
config: this.config!,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
this.errors = undefined;
|
||||
}
|
||||
|
||||
@@ -731,7 +755,10 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
updateConfig: async (config, entityRegistryUpdate) => {
|
||||
this.config = config;
|
||||
this.entityRegistryUpdate = entityRegistryUpdate;
|
||||
this._updateDirtyState(this.config);
|
||||
this._updateDirtyState({
|
||||
config: this.config,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
this.requestUpdate();
|
||||
|
||||
const id = this.scriptId || String(Date.now());
|
||||
@@ -846,7 +873,10 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
updateConfig: async (config, entityRegistryUpdate) => {
|
||||
this.config = config;
|
||||
this.entityRegistryUpdate = entityRegistryUpdate;
|
||||
this._updateDirtyState(this.config);
|
||||
this._updateDirtyState({
|
||||
config: this.config,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
this.requestUpdate();
|
||||
resolve(true);
|
||||
},
|
||||
@@ -865,7 +895,10 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
config: this.config!,
|
||||
updateConfig: (config) => {
|
||||
this.config = config;
|
||||
this._updateDirtyState(config);
|
||||
this._updateDirtyState({
|
||||
config,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
this.requestUpdate();
|
||||
resolve();
|
||||
},
|
||||
@@ -885,9 +918,11 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
this._manualEditor?.resetPastedConfig();
|
||||
|
||||
if (!this.scriptId) {
|
||||
const saved = await this._promptScriptAlias();
|
||||
if (!saved) {
|
||||
return;
|
||||
if (!this.config?.alias) {
|
||||
const saved = await this._promptScriptAlias();
|
||||
if (!saved) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.currentEntityId = this._computeEntityIdFromAlias(this.config!.alias);
|
||||
}
|
||||
@@ -1012,7 +1047,10 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
private _applyUndoRedo(config: ScriptConfig) {
|
||||
this._manualEditor?.triggerCloseSidebar();
|
||||
this.config = config;
|
||||
this._updateDirtyState(this.config);
|
||||
this._updateDirtyState({
|
||||
config: this.config,
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
private _undo() {
|
||||
|
||||
@@ -64,13 +64,17 @@ class HaPanelHistory extends LitElement {
|
||||
|
||||
@state() private _endDate: Date;
|
||||
|
||||
@state()
|
||||
@state() private _targetPickerValue: HassServiceTarget = {};
|
||||
|
||||
// Remembers the last user-picked selection as a fallback for visits without
|
||||
// URL params. Kept separate from _targetPickerValue because localStorage is
|
||||
// synced across tabs and would leak one tab's selection into the others.
|
||||
@storage({
|
||||
key: "historyPickedValue",
|
||||
state: true,
|
||||
state: false,
|
||||
subscribe: false,
|
||||
})
|
||||
private _targetPickerValue: HassServiceTarget = {};
|
||||
private _storedTargetPickerValue?: HassServiceTarget;
|
||||
|
||||
@state() private _isLoading = false;
|
||||
|
||||
@@ -224,9 +228,11 @@ class HaPanelHistory extends LitElement {
|
||||
const queryParams = decodeHistoryLogbookQueryParams(
|
||||
extractSearchParamsObject()
|
||||
);
|
||||
const targetPickerValue = historyLogbookTargetFromQueryParams(queryParams);
|
||||
if (targetPickerValue) {
|
||||
this._targetPickerValue = targetPickerValue;
|
||||
const initialValue =
|
||||
historyLogbookTargetFromQueryParams(queryParams) ??
|
||||
this._storedTargetPickerValue;
|
||||
if (initialValue) {
|
||||
this._targetPickerValue = initialValue;
|
||||
}
|
||||
if (queryParams.start_date) {
|
||||
this._startDate = queryParams.start_date;
|
||||
@@ -264,6 +270,7 @@ class HaPanelHistory extends LitElement {
|
||||
|
||||
private _removeAll() {
|
||||
this._targetPickerValue = {};
|
||||
this._storedTargetPickerValue = this._targetPickerValue;
|
||||
this._updatePath();
|
||||
}
|
||||
|
||||
@@ -398,6 +405,7 @@ class HaPanelHistory extends LitElement {
|
||||
|
||||
private _targetsChanged(ev) {
|
||||
this._targetPickerValue = ev.detail.value || {};
|
||||
this._storedTargetPickerValue = this._targetPickerValue;
|
||||
this._updatePath();
|
||||
}
|
||||
|
||||
|
||||
@@ -40,13 +40,17 @@ export class HaPanelLogbook extends LitElement {
|
||||
@state()
|
||||
private _showBack?: boolean;
|
||||
|
||||
@state()
|
||||
@state() private _targetPickerValue: HassServiceTarget = {};
|
||||
|
||||
// Remembers the last user-picked selection as a fallback for visits without
|
||||
// URL params. Kept separate from _targetPickerValue because localStorage is
|
||||
// synced across tabs and would leak one tab's selection into the others.
|
||||
@storage({
|
||||
key: "logbookPickedValue",
|
||||
state: true,
|
||||
state: false,
|
||||
subscribe: false,
|
||||
})
|
||||
private _targetPickerValue: HassServiceTarget = {};
|
||||
private _storedTargetPickerValue?: HassServiceTarget;
|
||||
|
||||
public constructor() {
|
||||
super();
|
||||
@@ -176,6 +180,8 @@ export class HaPanelLogbook extends LitElement {
|
||||
const targetPickerValue = historyLogbookTargetFromQueryParams(queryParams);
|
||||
if (targetPickerValue) {
|
||||
this._targetPickerValue = targetPickerValue;
|
||||
} else if (!this.hasUpdated && this._storedTargetPickerValue) {
|
||||
this._targetPickerValue = this._storedTargetPickerValue;
|
||||
}
|
||||
|
||||
if (queryParams.start_date || queryParams.end_date) {
|
||||
@@ -208,6 +214,7 @@ export class HaPanelLogbook extends LitElement {
|
||||
|
||||
private _targetsChanged(ev) {
|
||||
this._targetPickerValue = ev.detail.value || {};
|
||||
this._storedTargetPickerValue = this._targetPickerValue;
|
||||
this._updatePath();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
@@ -6,6 +7,7 @@ import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { isNumericFromAttributes } from "../../../common/number/format_number";
|
||||
import {
|
||||
type EntityHistoryState,
|
||||
limitedHistoryFromStateObj,
|
||||
subscribeHistoryStatesTimeWindow,
|
||||
} from "../../../data/history";
|
||||
@@ -56,6 +58,21 @@ class HuiHistoryChartCardFeature
|
||||
|
||||
private _interval?: number;
|
||||
|
||||
private _stateHistory?: EntityHistoryState[];
|
||||
|
||||
// Recompute the graph geometry when the feature is resized or revealed
|
||||
// (e.g. by a section/card visibility condition), since the coordinates are
|
||||
// scaled to the element's pixel size and would otherwise stay collapsed to
|
||||
// the size measured while hidden.
|
||||
// @ts-ignore side-effect-only controller, its value is never read
|
||||
private _resizeController = new ResizeController(this, {
|
||||
callback: (entries) => {
|
||||
if (entries[0]?.contentRect.width) {
|
||||
this._calculateCoordinates();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
static getStubConfig(): TrendGraphCardFeatureConfig {
|
||||
return {
|
||||
type: "trend-graph",
|
||||
@@ -91,26 +108,51 @@ class HuiHistoryChartCardFeature
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
this._setLoadingCoordinates();
|
||||
this._calculateCoordinates();
|
||||
if (this.isConnected) {
|
||||
this._subscribeHistory();
|
||||
}
|
||||
}
|
||||
|
||||
private _setLoadingCoordinates() {
|
||||
private _calculateCoordinates() {
|
||||
const entityId = this.context?.entity_id;
|
||||
if (!entityId || !this.hass) {
|
||||
return;
|
||||
}
|
||||
const stateObj = this.hass.states[entityId];
|
||||
if (!stateObj) {
|
||||
const width = this.clientWidth;
|
||||
const height = this.clientHeight;
|
||||
|
||||
// History not loaded yet: show the loading state based on the current state.
|
||||
if (!this._stateHistory) {
|
||||
const stateObj = this.hass.states[entityId];
|
||||
if (!stateObj) {
|
||||
return;
|
||||
}
|
||||
const { points, yAxisOrigin } = coordinatesMinimalResponseCompressedState(
|
||||
limitedHistoryFromStateObj(stateObj),
|
||||
width,
|
||||
height,
|
||||
10
|
||||
);
|
||||
this._coordinates = points;
|
||||
this._yAxisOrigin = yAxisOrigin;
|
||||
return;
|
||||
}
|
||||
|
||||
const hourToShow = this._config?.hours_to_show ?? DEFAULT_HOURS_TO_SHOW;
|
||||
const detail = this._config?.detail !== false; // default to true (high detail)
|
||||
// sample to 1 point per hour for low detail or 1 point per 5 pixels for high detail
|
||||
const maxDetails = detail
|
||||
? Math.max(10, width / 5, hourToShow)
|
||||
: Math.max(10, hourToShow);
|
||||
const useMean = !detail;
|
||||
const { points, yAxisOrigin } = coordinatesMinimalResponseCompressedState(
|
||||
limitedHistoryFromStateObj(stateObj),
|
||||
this.clientWidth,
|
||||
this.clientHeight,
|
||||
10
|
||||
this._stateHistory,
|
||||
width,
|
||||
height,
|
||||
maxDetails,
|
||||
undefined,
|
||||
useMean
|
||||
);
|
||||
this._coordinates = points;
|
||||
this._yAxisOrigin = yAxisOrigin;
|
||||
@@ -186,7 +228,6 @@ class HuiHistoryChartCardFeature
|
||||
}
|
||||
|
||||
const hourToShow = this._config.hours_to_show ?? DEFAULT_HOURS_TO_SHOW;
|
||||
const detail = this._config.detail !== false; // default to true (high detail)
|
||||
|
||||
this._subscribed = subscribeHistoryStatesTimeWindow(
|
||||
this.hass!,
|
||||
@@ -199,23 +240,9 @@ class HuiHistoryChartCardFeature
|
||||
history = limitedHistoryFromStateObj(stateObj);
|
||||
}
|
||||
}
|
||||
// sample to 1 point per hour for low detail or 1 point per 5 pixels for high detail
|
||||
const maxDetails = detail
|
||||
? Math.max(10, this.clientWidth / 5, hourToShow)
|
||||
: Math.max(10, hourToShow);
|
||||
const useMean = !detail;
|
||||
const { points, yAxisOrigin } =
|
||||
coordinatesMinimalResponseCompressedState(
|
||||
history,
|
||||
this.clientWidth,
|
||||
this.clientHeight,
|
||||
maxDetails,
|
||||
undefined,
|
||||
useMean
|
||||
);
|
||||
this._coordinates = points;
|
||||
this._yAxisOrigin = yAxisOrigin;
|
||||
this._stateHistory = history ?? [];
|
||||
this._loading = false;
|
||||
this._calculateCoordinates();
|
||||
},
|
||||
hourToShow,
|
||||
[this.context!.entity_id!]
|
||||
|
||||
@@ -369,6 +369,22 @@ export function computeStatMidpoint(
|
||||
return (start + end) / 2;
|
||||
}
|
||||
|
||||
const PERIOD_MS: Record<string, number> = {
|
||||
"5minute": 5 * 60 * 1000,
|
||||
hour: 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Offset from a period's start to its midpoint, for centering sub-daily bars
|
||||
* (and forecast lines) between axis ticks — 0 for daily+ periods, which sit at
|
||||
* the start. Derived from the period, not from the data, so the first/only
|
||||
* bucket centers identically to every other bucket. (Previously estimated from
|
||||
* the gap between the first two entries, which collapsed to 0 with one bucket.)
|
||||
*/
|
||||
export function getPeriodMidpointOffset(period: string): number {
|
||||
return (PERIOD_MS[period] ?? 0) / 2;
|
||||
}
|
||||
|
||||
export interface UntrackedSplit {
|
||||
/** Untracked consumption per timestamp, clamped to >= 0. */
|
||||
positive: Record<number, number>;
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type EnergyDataPoint,
|
||||
fillDataGapsAndRoundCaps,
|
||||
getCompareTransform,
|
||||
getPeriodMidpointOffset,
|
||||
splitUntrackedConsumption,
|
||||
} from "./common/energy-chart-options";
|
||||
import { getEnergyColor } from "./common/color";
|
||||
@@ -237,12 +238,15 @@ function processUntracked(
|
||||
const sortedTimes = Object.keys(consumptionData.used_total).sort(
|
||||
(a, b) => Number(a) - Number(b)
|
||||
);
|
||||
// Only start timestamps available here, so estimate midpoint from the gap
|
||||
// between the first two entries. Assumes uniform period spacing.
|
||||
// Only start timestamps available here, so center sub-daily bars using the
|
||||
// gap between the first two entries. With a lone first-of-day bucket there is
|
||||
// no gap to measure, so fall back to the nominal period midpoint — which
|
||||
// matches the device bars' computeStatMidpoint instead of collapsing to the
|
||||
// period start and splitting into a second stack.
|
||||
const periodOffset =
|
||||
(period === "hour" || period === "5minute") && sortedTimes.length >= 2
|
||||
? (Number(sortedTimes[1]) - Number(sortedTimes[0])) / 2
|
||||
: 0;
|
||||
: getPeriodMidpointOffset(period);
|
||||
sortedTimes.forEach((time) => {
|
||||
const ts = Number(time);
|
||||
const x = compare
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type EnergyDataPoint,
|
||||
fillDataGapsAndRoundCaps,
|
||||
getCompareTransform,
|
||||
getPeriodMidpointOffset,
|
||||
} from "./common/energy-chart-options";
|
||||
|
||||
export interface EnergySolarGraphDataParams {
|
||||
@@ -323,7 +324,8 @@ function processForecast(
|
||||
const solarForecastData: LineSeriesOption["data"] = [];
|
||||
// Only center forecast points for sub-daily periods to align with bars.
|
||||
// Only start timestamps available, so estimate midpoint from the gap
|
||||
// between the first two entries. Assumes uniform spacing.
|
||||
// between the first two entries; with a lone first bucket there is no
|
||||
// gap to measure, so fall back to the nominal period midpoint.
|
||||
let forecastOffset = 0;
|
||||
if (period === "hour" || period === "5minute") {
|
||||
const forecastTimes = Object.keys(forecastsData)
|
||||
@@ -332,7 +334,7 @@ function processForecast(
|
||||
forecastOffset =
|
||||
forecastTimes.length >= 2
|
||||
? (forecastTimes[1] - forecastTimes[0]) / 2
|
||||
: 0;
|
||||
: getPeriodMidpointOffset(period);
|
||||
}
|
||||
for (const [time, value] of Object.entries(forecastsData)) {
|
||||
const kWh = value / 1000;
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
fillDataGapsAndRoundCaps,
|
||||
getCommonOptions,
|
||||
getCompareTransform,
|
||||
getPeriodMidpointOffset,
|
||||
} from "./common/energy-chart-options";
|
||||
import type { HaECOption } from "../../../../resources/echarts/echarts";
|
||||
import type { CustomLegendOption } from "../../../../components/chart/ha-chart-base";
|
||||
@@ -543,10 +544,17 @@ export class HuiEnergyUsageGraphCard
|
||||
combinedData[key] = sets;
|
||||
});
|
||||
|
||||
combinedData.used_solar = { used_solar: consumptionData.used_solar };
|
||||
combinedData.used_battery = {
|
||||
used_battery: consumptionData.used_battery,
|
||||
};
|
||||
// Only add solar/battery consumption series when such a source is
|
||||
// actually configured, otherwise the legend shows empty solar/battery
|
||||
// entries for grid-only setups.
|
||||
if (statIdsByCat.solar) {
|
||||
combinedData.used_solar = { used_solar: consumptionData.used_solar };
|
||||
}
|
||||
if (statIdsByCat.from_battery) {
|
||||
combinedData.used_battery = {
|
||||
used_battery: consumptionData.used_battery,
|
||||
};
|
||||
}
|
||||
|
||||
if (combinedData.from_grid && summedData.to_battery) {
|
||||
const used_grid = {};
|
||||
@@ -585,14 +593,15 @@ export class HuiEnergyUsageGraphCard
|
||||
|
||||
const uniqueKeys = summedData.timestamps;
|
||||
|
||||
// Only center bars for sub-daily periods (hour/5min).
|
||||
// Only start timestamps available here, so estimate midpoint from the gap
|
||||
// between the first two entries. Assumes uniform period spacing.
|
||||
// Only center bars for sub-daily periods (hour/5min). Only start timestamps
|
||||
// available here, so estimate midpoint from the gap between the first two
|
||||
// entries; with a lone first-of-day bucket there is no gap to measure, so
|
||||
// fall back to the nominal period midpoint so the bar stays centered.
|
||||
const period = getSuggestedPeriod(this._start, this._end);
|
||||
const periodOffset =
|
||||
(period === "hour" || period === "5minute") && uniqueKeys.length >= 2
|
||||
? (uniqueKeys[1] - uniqueKeys[0]) / 2
|
||||
: 0;
|
||||
: getPeriodMidpointOffset(period);
|
||||
|
||||
const compareTransform = getCompareTransform(
|
||||
this._start,
|
||||
|
||||
@@ -428,7 +428,7 @@ export class HuiStatisticCard extends LitElement implements LovelaceCard {
|
||||
|
||||
.name {
|
||||
color: var(--secondary-text-color);
|
||||
line-height: var(--ha-line-height-expanded);
|
||||
line-height: 40px;
|
||||
font-size: var(--ha-font-size-l);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
overflow: hidden;
|
||||
@@ -442,12 +442,17 @@ export class HuiStatisticCard extends LitElement implements LovelaceCard {
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
padding: 0px 16px 16px;
|
||||
margin-top: -4px;
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
}
|
||||
|
||||
.info > * {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
line-height: var(--ha-line-height-expanded);
|
||||
}
|
||||
|
||||
.value {
|
||||
|
||||
@@ -135,6 +135,9 @@ class HuiInputNumberEntityRow extends LitElement implements LovelaceRow {
|
||||
ha-slider {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
/* Horizontal margin leaves room for the thumb at min and max so it
|
||||
isn't clipped by the card's overflow-x: hidden. */
|
||||
margin: 1px var(--ha-space-2);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -141,6 +141,9 @@ class HuiNumberEntityRow extends LitElement implements LovelaceRow {
|
||||
ha-slider {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
/* Horizontal margin leaves room for the thumb at min and max so it
|
||||
isn't clipped by the card's overflow-x: hidden. */
|
||||
margin: 1px var(--ha-space-2);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -234,7 +234,6 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
group="section"
|
||||
handle-selector=".handle"
|
||||
draggable-selector=".section"
|
||||
.rollback=${false}
|
||||
>
|
||||
<div
|
||||
class="content ${classMap({
|
||||
|
||||
@@ -135,7 +135,7 @@ export const getMyRedirects = (): Redirects => ({
|
||||
redirect: "/config/bluetooth/visualization",
|
||||
},
|
||||
config_ai_task: {
|
||||
redirect: "/config/general/#ai-task",
|
||||
redirect: "/config/ai-tasks",
|
||||
},
|
||||
config_bluetooth: {
|
||||
component: "bluetooth",
|
||||
@@ -149,6 +149,13 @@ export const getMyRedirects = (): Redirects => ({
|
||||
component: "energy",
|
||||
redirect: "/config/energy",
|
||||
},
|
||||
config_infrared: {
|
||||
redirect: "/config/infrared",
|
||||
},
|
||||
config_radiofrequency: {
|
||||
component: "radio_frequency",
|
||||
redirect: "/config/radio-frequency",
|
||||
},
|
||||
config_ssdp: {
|
||||
component: "ssdp",
|
||||
redirect: "/config/ssdp",
|
||||
|
||||
@@ -2853,6 +2853,7 @@
|
||||
"my": {
|
||||
"add_repository_title": "Add app repository?",
|
||||
"add_repository_description": "This app requires a repository that is currently not known. Do you want to add the repository {repository}?",
|
||||
"add_repository_store_description": "Do you want to add the app repository {repository}?",
|
||||
"error_repository_not_found": "The repository for this app was not found"
|
||||
},
|
||||
"panel": {
|
||||
@@ -3904,7 +3905,9 @@
|
||||
},
|
||||
"templates": {
|
||||
"title": "Template",
|
||||
"description": "Templates are rendered using the Jinja2 template engine with some Home Assistant specific extensions.",
|
||||
"description": "Templates let you generate dynamic content from your Home Assistant data, such as a notification that lists which lights are on, or a sensor whose value is calculated from several other entities.",
|
||||
"engine_info": "Home Assistant uses the Jinja templating engine, extended with functions for working with your entities, areas, devices, and more. Write a template in the editor below and its result updates live as your states change.",
|
||||
"learn_more": "Learn more",
|
||||
"about": "About templates",
|
||||
"editor": "Template editor",
|
||||
"result": "Result",
|
||||
@@ -3912,8 +3915,14 @@
|
||||
"confirm_reset": "Do you want to reset your current template back to the demo template?",
|
||||
"confirm_clear": "Do you want to clear your current template?",
|
||||
"result_type": "Result type",
|
||||
"jinja_documentation": "Jinja2 template documentation",
|
||||
"template_extensions": "Home Assistant template extensions",
|
||||
"docs_introduction": "Introduction to templating",
|
||||
"docs_introduction_description": "Start here for a step-by-step guide.",
|
||||
"docs_states": "Working with states",
|
||||
"docs_states_description": "Read entity states and attributes in templates.",
|
||||
"docs_debugging": "Debugging templates",
|
||||
"docs_debugging_description": "Find and fix problems in your templates.",
|
||||
"docs_functions": "Template functions reference",
|
||||
"docs_functions_description": "Search every available function, filter, and test.",
|
||||
"unknown_error_template": "Unknown error rendering template",
|
||||
"time": "This template updates at the start of each minute.",
|
||||
"all_listeners": "This template listens for all state changed events.",
|
||||
|
||||
Vendored
+5
-1
@@ -89,6 +89,8 @@ export interface EnergyDataOptions {
|
||||
period?: "5minute" | "hour" | "day";
|
||||
compare?: boolean;
|
||||
prefs?: EnergyPreferences;
|
||||
/** Probability a period is missing (creates gaps); 0 for a dense dataset. */
|
||||
gapChance?: number;
|
||||
}
|
||||
|
||||
const statisticIdsForPrefs = (prefs: EnergyPreferences): string[] => {
|
||||
@@ -115,7 +117,7 @@ export const generateEnergyData = (
|
||||
seed: number,
|
||||
options: EnergyDataOptions
|
||||
): EnergyData => {
|
||||
const { days, period = "hour", compare = false } = options;
|
||||
const { days, period = "hour", compare = false, gapChance } = options;
|
||||
const prefs = options.prefs ?? generateEnergyPreferences();
|
||||
const ids = statisticIdsForPrefs(prefs);
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
@@ -124,6 +126,7 @@ export const generateEnergyData = (
|
||||
ids,
|
||||
period,
|
||||
days,
|
||||
gapChance,
|
||||
sumStatistics: true,
|
||||
});
|
||||
const statsCompare = compare
|
||||
@@ -132,6 +135,7 @@ export const generateEnergyData = (
|
||||
period,
|
||||
days,
|
||||
startMs: FIXED_EPOCH_MS - days * dayMs,
|
||||
gapChance,
|
||||
sumStatistics: true,
|
||||
})
|
||||
: ({} as EnergyData["statsCompare"]);
|
||||
|
||||
@@ -164,6 +164,57 @@ describe("generateEnergyDevicesDetailGraphData", () => {
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
|
||||
// Regression test for #52937: at the start of the day only the first hour
|
||||
// has data. The untracked/over-reported bars must center on the same period
|
||||
// midpoint as the device bars so they stack as one bar instead of splitting
|
||||
// into a second stack at the period start.
|
||||
it("stacks untracked bars on the device bars for a lone first-of-day bucket", () => {
|
||||
// Full-day range (so getSuggestedPeriod stays "hour") but keep only the
|
||||
// first hourly bucket in every stat. gapChance: 0 makes the bucket dense.
|
||||
const full = generateEnergyData(1, {
|
||||
days: 1,
|
||||
period: "hour",
|
||||
gapChance: 0,
|
||||
prefs: buildPrefs(false),
|
||||
});
|
||||
const firstStart = full.start.getTime();
|
||||
const energyData = {
|
||||
...full,
|
||||
stats: Object.fromEntries(
|
||||
Object.entries(full.stats).map(
|
||||
([id, values]) =>
|
||||
[id, values.filter((s) => s.start === firstStart)] as const
|
||||
)
|
||||
),
|
||||
};
|
||||
|
||||
const result = generateEnergyDevicesDetailGraphData({
|
||||
...baseParams,
|
||||
energyData,
|
||||
});
|
||||
|
||||
// Collect the display x of every bar across all series.
|
||||
const xs = new Set<number>();
|
||||
let nonEmptySeries = 0;
|
||||
for (const series of result.chartData) {
|
||||
const points = series.data ?? [];
|
||||
if (points.length) {
|
||||
nonEmptySeries++;
|
||||
}
|
||||
for (const point of points as any[]) {
|
||||
const x = Array.isArray(point) ? point[0] : point?.value?.[0];
|
||||
if (x != null) {
|
||||
xs.add(Number(x));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Device bars + at least one untracked series are present...
|
||||
assert.isAtLeast(nonEmptySeries, 2);
|
||||
// ...and they all share a single x, so they render as one full stack.
|
||||
assert.equal(xs.size, 1);
|
||||
});
|
||||
|
||||
// The seeded fixtures above all happen to produce fully-negative untracked
|
||||
// (devices reference the source stats, so they consume all of used_total).
|
||||
// These two cases pin the branches those snapshots can't reach.
|
||||
|
||||
Reference in New Issue
Block a user