Compare commits

...

22 Commits

Author SHA1 Message Date
Bram Kragten c573669786 Bumped version to 20260624.5 2026-07-10 14:57:54 +02:00
Petar Petrov 4e1ccab159 Restore per-second updating of ha-relative-time (#53070)
Restore per-second ticking for ha-relative-time
2026-07-10 14:57:14 +02:00
Petar Petrov 347d63bce9 Wrap device automation picker option labels instead of truncating (#53065) 2026-07-10 14:57:13 +02:00
Petar Petrov a1d7b31732 Fix cloning when drag-sorting a section to the last position (#53050) 2026-07-10 14:57:12 +02:00
Petar Petrov 4d61df7ed7 Fix target picker "Add target" popover opening off to the right (#53041) 2026-07-10 14:57:11 +02:00
Petar Petrov 991dda70fc Only show solar/battery legend in energy usage graph when configured (#53030) 2026-07-10 14:57:10 +02:00
Petar Petrov 58f5480ca3 Fix escape key discarding unsaved changes in dashboard editor (#53029) 2026-07-10 14:57:09 +02:00
Petar Petrov 1f20bc0749 Fix number/input_number slider clipped in entities card at certain zooms (#53017) 2026-07-10 14:57:09 +02:00
Petar Petrov 0846cb3be3 Fix tile trend graph rendering blank when shown by a visibility condition (#53016) 2026-07-10 14:57:08 +02:00
Petar Petrov 33afb77367 Fix My redirect for AI tasks to /config/ai-tasks (#53015) 2026-07-10 14:57:07 +02:00
Petar Petrov b2f85e2595 Fix history/logbook entity selection leaking across browser tabs (#53014)
Keep history and logbook entity selection independent per tab
2026-07-10 14:57:06 +02:00
Simon Lamon 09956a7d9c No need to show rename when alias is already provided (#53003)
* No need to show rename when alias is already provided

* Update src/panels/config/script/ha-script-editor.ts

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-10 14:57:05 +02:00
Simon Lamon 8507e222f8 Add entity update tracking again in Automation and Script editor (#53002) 2026-07-10 14:57:04 +02:00
Petar Petrov 5737480398 Match Statistic card line height to Entity card (#52981) 2026-07-10 14:57:03 +02:00
Petar Petrov 44163b9ccb Fix focus loss in text selector with multiple (#52980) 2026-07-10 14:57:03 +02:00
Bram Kragten e79cd0c5b2 Bumped version to 20260624.4 2026-07-03 12:29:13 +02:00
Petar Petrov 52379b39e0 Fix My link for adding an add-on repository not doing anything (#52945) 2026-07-03 12:28:54 +02:00
Franck Nijhof 656e1bea8e Refresh the template tool documentation panel (#52941)
The About templates panel still pointed at the upstream Jinja2 docs and a
single extensions page. Rewrite the intro and link to the current templating
documentation instead: the learning guide (introduction, working with states,
debugging) and the searchable template functions reference.
2026-07-03 12:28:53 +02:00
Franck Nijhof 4ef3ed2f02 Link the config pane help icon to the dedicated docs page (#52940)
For built-in triggers, conditions, and actions, the help icon in the editor
config pane (and Developer Tools) linked to the integration page. Point it at
the dedicated page for that specific trigger/condition/action instead, e.g.
/triggers/air_quality.co2_changed. Custom integrations keep their own
documentation URL.
2026-07-03 12:28:27 +02:00
Petar Petrov fbad0ba885 Fix double bar in energy devices detail graph at start of day (#52939) 2026-07-03 12:25:06 +02:00
Petar Petrov c892691344 Add My links for infrared and radio frequency config panels (#52931) 2026-07-03 12:25:05 +02:00
Petar Petrov 811b7c7d98 Allow negative number entry in number selector on iOS (#52925)
Allow entering negative numbers in number selector on iOS
2026-07-03 12:24:37 +02:00
32 changed files with 557 additions and 188 deletions
+1 -1
View File
@@ -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}`
+9
View File
@@ -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;
+15 -8
View File
@@ -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() {
+35 -39
View File
@@ -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()
+2 -2
View File
@@ -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(
+1
View File
@@ -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}
+29 -2
View File
@@ -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;
+51 -13
View File
@@ -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() {
+14 -6
View File
@@ -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();
}
+10 -3
View File
@@ -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({
+8 -1
View File
@@ -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",
+12 -3
View File
@@ -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.",
+5 -1
View File
@@ -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.