Compare commits

..

33 Commits

Author SHA1 Message Date
renovate[bot] 7a36a261fa Update tsparticles to v4.2.0 2026-06-20 16:30:57 +00:00
renovate[bot] 1fab54831f Update dependency @rsdoctor/rspack-plugin to v1.5.14 (#52771)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-20 14:34:13 +02:00
Petar Petrov 4638582c6f Refactor energy dashboard card visibility to a single source of truth (#52673)
Make energy dashboard card visibility declarative via the catalog

Route the five energy view strategies and the dashboard strategy through
the shared ENERGY_CARD_CATALOG instead of re-deriving each card's
applicability conditions inline. A new isEnergyCardVisible() helper makes
the catalog the single source of truth for whether a card is shown, so the
strategies and the customise dialog can no longer disagree.

Behavior-preserving; adds a contract test pinning isEnergyCardVisible to
the catalog for every entry.
2026-06-20 09:06:35 +02:00
Franck Nijhof e5721fb134 Use singular verb for state condition matched with any (#52609)
A state condition with match "any" joins its entities with "or", but the
summary kept a plural verb for multiple entities, reading "If A or B are
on". With "or" English uses singular agreement: "If A or B is on". The
match "all" case joins with "and" and correctly stays plural.

Nest a select on a new matchAny flag inside the multiple-entities plural
branch so the verb agrees with the join. Other languages keep their
count-based plural (the extra argument is ignored). Add a test that renders
the actual en.json string to lock in the grammar.
2026-06-20 09:00:38 +02:00
Petar Petrov bfd8cb54c9 Split negative untracked energy into a toggleable series (#52698)
Negative "untracked" values (tracked devices reporting more than total
consumption, usually a meter resolution mismatch) rendered as confusing
below-zero bars in the devices detail graph. Move them into their own
"Over-reported consumption" series with its own legend item so users can
toggle them off, and only add the series when negatives actually exist.
2026-06-20 09:56:49 +03:00
Franck Nijhof 89bd1058df Fix gauge card dropping negative and monetary values (#52751)
The gauge card reads its display value from the formatted state parts.
A monetary value is split into multiple value parts around the currency
symbol, so the minus sign lands in its own part. Taking only the first
value part meant a value like -182.95 GBP rendered as just "-".

Join all value parts so the full number is shown again.

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-06-20 08:45:28 +02:00
Paul Bottein 405727502f Fix negative monetary (#52766)
* Fix rendering of negative monetary values

* Fix tests
2026-06-20 08:34:47 +02:00
Marcin Bauer dae105531f Fix left column resizing in add automation element dialog (#52745)
The left list column used flex: 4 against a flex: 6 right panel. Because
flex items default to min-width: auto, the right panel's content (which
varies per group) could dictate the split, so the left column width
shifted while browsing groups.

Give the left column a fixed width (flex: 0 0 360px) and let the right
panel take the rest with min-width: 0 so its content shrinks instead of
pushing the left column around.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 08:09:11 +03:00
Franck Nijhof 5f790a4977 Offer category and floor names to the AI suggestion model (#52760)
The metadata suggestion schema sent the internal category and floor IDs as
the select options, so the model only saw opaque IDs and never picked one.
The result processor already maps the chosen value back to an ID by name, so
the two halves disagreed.

Offer the names as the option values instead, matching what the processor
expects, so the model can actually choose a category or floor.
2026-06-20 08:05:10 +03:00
Franck Nijhof d6c16e0736 Count not-ready Z-Wave devices separately from not included (#52757)
The network status on the Z-Wave dashboard added the not-ready nodes to
the provisioning entries and labeled the total as not included. Not-ready
nodes are included though, their interview just has not completed yet, so
this was confusing.

Report not-ready nodes as not ready and keep not included for the
provisioning entries that have not joined the network yet.
2026-06-20 08:04:11 +03:00
Franck Nijhof c562f58326 Group the time and duration input fields for screen readers (#52764)
The day, hour, minute, second, and millisecond inputs were rendered as
separate fields with a label that was not associated with them, so screen
readers announced them as unrelated inputs.

Wrap the fields in a role=group and label that group with the visible label,
so they are announced together as one labeled control.
2026-06-20 07:57:29 +03:00
Franck Nijhof ce5640d13a Fix time zone picker data gaps (UTC and Asia/Sakhalin) (#52754)
* Add UTC time zone to the time zone picker

The time zone list is built from google-timezones-json, which is missing
the bare "UTC" and "Etc/UTC" zones. Both are valid IANA identifiers and a
common server default, so an instance configured to UTC showed up as an
unknown time zone in the settings.

Add the two zones to the picker options, guarded against duplicates in
case the source list starts including them.

* Accept UTC time zone in the clock card config

The clock card validated its time_zone against the raw timezone list, so
it rejected UTC even though the picker now offers it. Validate against the
shared timezone options instead, keeping a single source.

* Correct the invalid Asia/Sakhalin time zone id

google-timezones-json ships Asia/Yuzhno-Sakhalinsk, which is not a valid
IANA identifier, so selecting it failed backend validation. Map it to the
correct Asia/Sakhalin id.
2026-06-20 07:56:15 +03:00
renovate[bot] 6ddcc83638 Update CodeMirror to v6.7.1 (#52767)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-19 22:54:11 +02:00
Franck Nijhof 396f495c9b Discard stale results in the template developer tool (#52762)
Subscribing to a template render is asynchronous and is triggered from a few
places, so two renders could overlap. The second subscription overwrote the
handle of the first, leaving it running, and its late results overwrote the
current ones. That is why the result window sometimes showed the output of a
previous template until the editor was nudged.

Track a render id and bail out (and ignore incoming results) once a newer
render has started.
2026-06-19 21:42:54 +02:00
Petar Petrov d994fd8928 Fix Assist chat freezing when thinking details are opened mid-stream (#52753)
Fix Assist chat freezing when thinking details opened mid-stream
2026-06-19 15:49:43 +02:00
Franck Nijhof 21d8fda76d Mask password values in object selector previews (#52748) 2026-06-19 14:30:57 +02:00
Paul Bottein 49716f4151 Replace until() in icon components with a shared async controller (#52746) 2026-06-19 14:15:54 +02:00
Aidan Timson 657bef6a75 Change dialog enter code to adaptive dialog (#52747) 2026-06-19 14:45:58 +03:00
Franck Nijhof 9edd330728 Fix inverted vertical sliders in RTL languages (#52750)
The control slider flipped its value mapping whenever the document
direction was right-to-left, including for vertical sliders. RTL only
mirrors the horizontal axis, so a vertical slider ended up upside down:
the light brightness and color temperature sliders in the more info
dialog reported the opposite of what they showed (1% gave the brightest
output, 100% the dimmest).

Only mirror for right-to-left when the slider is horizontal.
2026-06-19 14:39:53 +03:00
Petar Petrov 09e83b6450 Omit empty select fields from AI metadata suggestion task (#52749) 2026-06-19 12:40:28 +02:00
Franck Nijhof 9c3f3ed05d Stop icon components leaking memory on every state update (#52743) 2026-06-19 10:36:36 +02:00
Aidan Timson aec6c8c1e4 Effective dirty state, apply to card/badge editor (#52727)
* Effective dirty state

* Effective normalise function for those with defaults not undefined
2026-06-19 11:00:12 +03:00
Franck Nijhof 82f4ae1f08 Return to the device page from the Z-Wave node config view (#52735) 2026-06-19 10:56:21 +03:00
Franck Nijhof 2809091b44 Accept backup uploads by .tar extension, not just MIME type (#52744) 2026-06-19 07:52:05 +00:00
Simon Lamon b2dda0f739 Translate exceptions in hass api calls (#52718) 2026-06-19 07:44:06 +01:00
Franck Nijhof d64845f206 Support a list of entities in the zone trigger editor (#52738) 2026-06-19 07:36:05 +01:00
Franck Nijhof 44d929bf56 Label time trigger and condition days as days of the week (#52737) 2026-06-19 07:33:24 +01:00
Franck Nijhof 56cfff6922 Show real repeat iteration number in trace details (#52736) 2026-06-19 07:32:03 +01:00
Franck Nijhof be8782d928 Include diagnostic battery binary sensors in maintenance view (#52734) 2026-06-19 07:28:41 +01:00
renovate[bot] 2eba8425a7 Update typescript-eslint monorepo to v8.61.1 (#52740)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-19 07:23:14 +01:00
renovate[bot] 5ddc26df7a Update formatjs monorepo to v0.10.15 (#52739)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-19 07:17:11 +02:00
renovate[bot] 97516f5625 Lock file maintenance (#52741)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-19 07:16:44 +02:00
Aidan Timson e8c06b4220 Limit cover/valve card feature width to prevent overflow (#52730)
* Limit cover/valve card feature width to prevent overflow

* Remove comments and unnecessary getter
2026-06-18 17:05:27 +02:00
81 changed files with 7619 additions and 1456 deletions
@@ -502,6 +502,10 @@ const SCHEMAS: {
},
},
},
password: {
label: "Password",
selector: { text: { type: "password" } },
},
},
},
},
+7 -6
View File
@@ -36,14 +36,14 @@
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.3",
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.0",
"@codemirror/search": "6.7.1",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.43.1",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.9",
"@formatjs/intl-displaynames": "7.3.10",
"@formatjs/intl-durationformat": "0.10.14",
"@formatjs/intl-durationformat": "0.10.15",
"@formatjs/intl-getcanonicallocales": "3.2.10",
"@formatjs/intl-listformat": "8.3.10",
"@formatjs/intl-locale": "5.3.9",
@@ -63,6 +63,7 @@
"@lit-labs/virtualizer": "2.1.1",
"@lit/context": "1.1.6",
"@lit/reactive-element": "2.1.2",
"@lit/task": "1.0.3",
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"@material/web": "2.4.1",
@@ -71,8 +72,8 @@
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.23",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "4.1.3",
"@tsparticles/preset-links": "4.1.3",
"@tsparticles/engine": "4.2.0",
"@tsparticles/preset-links": "4.2.0",
"@vibrant/color": "4.0.4",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
@@ -137,7 +138,7 @@
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.5.13",
"@rsdoctor/rspack-plugin": "1.5.14",
"@rspack/core": "2.0.8",
"@rspack/dev-server": "2.0.3",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -195,7 +196,7 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.61.0",
"typescript-eslint": "8.61.1",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.9",
"webpack-stats-plugin": "1.1.3",
@@ -0,0 +1,29 @@
import { Task, type TaskConfig } from "@lit/task";
import type { ReactiveControllerHost } from "lit";
/**
* A `@lit/task` Task with a sticky `resolved` flag: false until the task has
* completed once, then true. Lets callers tell "still loading" apart from
* "resolved with an empty value" without a null sentinel, while keeping the
* previous value during a re-run.
*/
export class AsyncValueTask<T extends readonly unknown[], R> extends Task<
T,
R
> {
private _resolved = false;
constructor(host: ReactiveControllerHost, config: TaskConfig<T, R>) {
super(host, {
...config,
onComplete: (value) => {
this._resolved = true;
config.onComplete?.(value);
},
});
}
public get resolved(): boolean {
return this._resolved;
}
}
+2 -3
View File
@@ -30,7 +30,7 @@ export const computeEntityEntryName = (
fallbackStateObj?: HassEntity
): string | undefined => {
const name =
entry.name ??
entry.name ||
("original_name" in entry && entry.original_name != null
? String(entry.original_name)
: undefined);
@@ -59,8 +59,7 @@ export const computeEntityEntryName = (
return stripPrefixFromEntityName(name, deviceName) || name;
}
// Empty name = main entity → undefined, so callers fall back to the device name.
return name || undefined;
return name;
};
export const entityUseDeviceName = (
@@ -1,6 +1,7 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
import { unitFromParts } from "./value_parts";
interface EntityUnitStubConfig {
entity: string;
@@ -40,5 +41,5 @@ export const computeEntityUnitDisplay = (
? hass.formatEntityAttributeValueToParts(stateObj, config.attribute)
: hass.formatEntityStateToParts(stateObj);
return parts.find((part) => part.type === "unit")?.value ?? "";
return unitFromParts(parts);
};
+2 -1
View File
@@ -160,7 +160,8 @@ const computeStateToPartsFromEntityAttributes = (
const type = MONETARY_TYPE_MAP[part.type];
if (!type) continue;
const last = valueParts[valueParts.length - 1];
// Merge consecutive value parts (e.g. "-" + "12" + "." + "00" → "-12.00")
// Merge consecutive value parts so the number stays a single part
// (e.g. "-" + "12" + "." + "00" → "-12.00")
if (type === "value" && last?.type === "value") {
last.value += part.value;
} else {
+29
View File
@@ -0,0 +1,29 @@
import type { ValuePart } from "../../types";
// Joins every part except the unit, keeping native order so the sign and
// grouping stay with the value (e.g. "-2,548.14").
export const valueFromParts = (parts: ValuePart[]): string =>
parts
.filter((part) => part.type !== "unit")
.map((part) => part.value)
.join("")
.trim();
export const unitFromParts = (parts: ValuePart[]): string =>
parts.find((part) => part.type === "unit")?.value ?? "";
export type UnitPosition = "before" | "after";
// Whether the unit sits before or after the value in the locale's native order
// (e.g. "$5" / "€ 5" → "before", "5 €" / "5 %" → "after").
export const unitPosition = (parts: ValuePart[]): UnitPosition => {
const unitIndex = parts.findIndex((part) => part.type === "unit");
if (unitIndex === -1) {
return "after";
}
const lastValueIndex = parts.reduceRight(
(acc, part, i) => (acc === -1 && part.type === "value" ? i : acc),
-1
);
return unitIndex < lastValueIndex ? "before" : "after";
};
+26
View File
@@ -0,0 +1,26 @@
/**
* Return a shallow copy of an object with every key removed whose value is
* `undefined` or equals that key's default, so a key left at its default
* (whether absent or explicit) does not count as a difference. A key's default
* comes from `defaults` when present, otherwise `false`.
*
* Non-plain-object values are returned unchanged; only top-level keys are
* compared.
*/
export const stripDefaults = <T>(
value: T,
defaults?: Record<string, unknown>
): T => {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return value;
}
const result: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value)) {
const defaultValue = defaults && key in defaults ? defaults[key] : false;
if (val === undefined || val === defaultValue) {
continue;
}
result[key] = val;
}
return result as T;
};
+8 -11
View File
@@ -8,6 +8,7 @@ import { arrayLiteralIncludes } from "../../common/array/literal-includes";
import secondsToDuration from "../../common/datetime/seconds_to_duration";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { unitFromParts, valueFromParts } from "../../common/entity/value_parts";
import { FIXED_DOMAIN_STATES } from "../../common/entity/get_states";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry";
@@ -163,20 +164,18 @@ export class HaStateLabelBadge extends LitElement {
case "sun":
case "timer":
return null;
// @ts-expect-error we don't break and go to default
case "sensor":
if (entry?.platform === "moon") {
return null;
}
// eslint-disable-next-line: disable=no-fallthrough
break;
default:
return entityState.state === UNAVAILABLE ||
entityState.state === UNKNOWN
? "—"
: this.hass!.formatEntityStateToParts(entityState).find(
(part) => part.type === "value"
)?.value;
break;
}
if (entityState.state === UNAVAILABLE || entityState.state === UNKNOWN) {
return "—";
}
return valueFromParts(this.hass!.formatEntityStateToParts(entityState));
}
private _computeShowIcon(
@@ -225,9 +224,7 @@ export class HaStateLabelBadge extends LitElement {
return secondsToDuration(_timerTimeRemaining);
}
return (
this.hass!.formatEntityStateToParts(entityState).find(
(part) => part.type === "unit"
)?.value || null
unitFromParts(this.hass!.formatEntityStateToParts(entityState)) || null
);
}
+6 -4
View File
@@ -400,10 +400,12 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
private _handleToggleThinking(ev: Event) {
const index = (ev.currentTarget as any).index;
this._conversation[index] = {
...this._conversation[index],
thinking_expanded: !this._conversation[index].thinking_expanded,
};
// Mutate the message in place rather than replacing it. The streaming
// processor keeps a reference to this same object and mutates it as deltas
// arrive; swapping in a new object would detach the in-flight message from
// the processor and freeze the chat (see #52501).
const message = this._conversation[index];
message.thinking_expanded = !message.thinking_expanded;
this.requestUpdate("_conversation");
}
+46 -16
View File
@@ -1,9 +1,10 @@
import { consume } from "@lit/context";
import type { ContextType } from "@lit/context";
import { initialState } from "@lit/task";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import {
configContext,
connectionContext,
@@ -35,6 +36,47 @@ export class HaAttributeIcon extends LitElement {
@consume({ context: entitiesContext, subscribe: true })
private _entities?: ContextType<typeof entitiesContext>;
private _iconTask = new AsyncValueTask(this, {
task: ([
icon,
config,
connection,
entities,
stateObj,
attribute,
attributeValue,
]) => {
if (
icon ||
!config ||
!connection ||
!entities ||
!stateObj ||
!attribute
) {
return initialState;
}
return attributeIcon(
config.config,
connection.connection,
entities,
stateObj,
attribute,
attributeValue
);
},
args: () =>
[
this.icon,
this._config,
this._connection,
this._entities,
this.stateObj,
this.attribute,
this.attributeValue,
] as const,
});
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -48,21 +90,9 @@ export class HaAttributeIcon extends LitElement {
return nothing;
}
const icon = attributeIcon(
this._config.config,
this._connection.connection,
this._entities,
this.stateObj,
this.attribute,
this.attributeValue
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return nothing;
});
return html`${until(icon)}`;
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: nothing;
}
}
+21 -12
View File
@@ -1,13 +1,19 @@
import { consume } from "@lit/context";
import type { ContextType } from "@lit/context";
import { initialState } from "@lit/task";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { getValueAttribute } from "../common/entity/get_states";
import { valueFromParts } from "../common/entity/value_parts";
import { formattersContext } from "../data/context";
const isObjectValue = (value: unknown): boolean =>
(Array.isArray(value) && value.some((val) => val instanceof Object)) ||
(!Array.isArray(value) && value instanceof Object);
@customElement("ha-attribute-value")
class HaAttributeValue extends LitElement {
@state()
@@ -20,6 +26,17 @@ class HaAttributeValue extends LitElement {
@property({ type: Boolean, attribute: "hide-unit" }) public hideUnit = false;
private _yamlTask = new AsyncValueTask(this, {
task: async ([attributeValue]) => {
if (!isObjectValue(attributeValue)) {
return initialState;
}
const { dump } = await import("js-yaml");
return dump(attributeValue);
},
args: () => [this.stateObj?.attributes[this.attribute]] as const,
});
protected render() {
if (!this.stateObj) {
return nothing;
@@ -49,13 +66,8 @@ class HaAttributeValue extends LitElement {
}
}
if (
(Array.isArray(attributeValue) &&
attributeValue.some((val) => val instanceof Object)) ||
(!Array.isArray(attributeValue) && attributeValue instanceof Object)
) {
const yaml = import("js-yaml").then(({ dump }) => dump(attributeValue));
return html`<pre>${until(yaml, "")}</pre>`;
if (isObjectValue(attributeValue)) {
return html`<pre>${this._yamlTask.value ?? ""}</pre>`;
}
// Options-list attributes (effect_list, preset_modes, …) translated through
@@ -83,10 +95,7 @@ class HaAttributeValue extends LitElement {
this.stateObj!,
this.attribute
);
return parts
.filter((part) => part.type === "value")
.map((part) => part.value)
.join("");
return valueFromParts(parts);
}
return this._formatters!.formatEntityAttributeValue(
+8 -2
View File
@@ -153,10 +153,16 @@ export class HaBaseTimeInput extends LitElement {
protected render(): TemplateResult {
return html`
${this.label
? html`<label>${this.label}${this.required ? " *" : ""}</label>`
? html`<label id="label"
>${this.label}${this.required ? " *" : ""}</label
>`
: nothing}
<div class="time-input-wrap-wrap">
<div class="time-input-wrap">
<div
class="time-input-wrap"
role="group"
aria-labelledby=${ifDefined(this.label ? "label" : undefined)}
>
${this.enableDay
? html`
<ha-input
+19 -13
View File
@@ -12,10 +12,11 @@ import {
mdiWeatherSunny,
} from "@mdi/js";
import { consume } from "@lit/context";
import { initialState } from "@lit/task";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import type { HassConfig, Connection } from "home-assistant-js-websocket";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { computeDomain } from "../common/entity/compute_domain";
import { transform } from "../common/decorators/transform";
import { configContext, connectionContext } from "../data/context";
@@ -57,6 +58,17 @@ export class HaConditionIcon extends LitElement {
})
private _connection?: Connection;
private _iconTask = new AsyncValueTask(this, {
task: ([icon, connection, config, condition]) => {
if (icon || !connection || !config || !condition) {
return initialState;
}
return conditionIcon(connection, config, condition);
},
args: () =>
[this.icon, this._connection, this._config, this.condition] as const,
});
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -70,18 +82,12 @@ export class HaConditionIcon extends LitElement {
return this._renderFallback();
}
const icon = conditionIcon(
this._connection,
this._config,
this.condition
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
});
return html`${until(icon)}`;
if (!this._iconTask.resolved) {
return nothing;
}
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: this._renderFallback();
}
private _renderFallback() {
+4 -1
View File
@@ -388,7 +388,10 @@ export class HaControlSlider extends LitElement {
private _isVisuallyInverted() {
let inverted = this.inverted;
if (mainWindow.document.dir === "rtl") {
// RTL only mirrors the horizontal axis. A vertical slider always fills
// bottom-to-top regardless of text direction, so it must not be flipped,
// otherwise its value mapping ends up upside down in RTL languages.
if (!this.vertical && mainWindow.document.dir === "rtl") {
inverted = !inverted;
}
+32 -16
View File
@@ -1,7 +1,8 @@
import { consume, type ContextType } from "@lit/context";
import { initialState } from "@lit/task";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { configContext, connectionContext, uiContext } from "../data/context";
import {
DEFAULT_DOMAIN_ICON,
@@ -36,6 +37,30 @@ export class HaDomainIcon extends LitElement {
@consume({ context: uiContext, subscribe: true })
private _hassUi?: ContextType<typeof uiContext>;
private _iconTask = new AsyncValueTask(this, {
task: ([icon, connection, config, domain, deviceClass, domainState]) => {
if (icon || !connection || !config || !domain) {
return initialState;
}
return domainIcon(
connection.connection,
config.config,
domain,
deviceClass,
domainState
);
},
args: () =>
[
this.icon,
this._connection,
this._hassConfig,
this.domain,
this.deviceClass,
this.state,
] as const,
});
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -49,21 +74,12 @@ export class HaDomainIcon extends LitElement {
return this._renderFallback();
}
const icon = domainIcon(
this._connection.connection,
this._hassConfig.config,
this.domain,
this.deviceClass,
this.state
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
});
return html`${until(icon)}`;
if (!this._iconTask.resolved) {
return nothing;
}
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: this._renderFallback();
}
private _renderFallback() {
+36 -12
View File
@@ -1,6 +1,8 @@
import { initialState } from "@lit/task";
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { until } from "lit/directives/until";
import type { HassEntity } from "home-assistant-js-websocket";
import { AsyncValueTask } from "../../common/controllers/async-value-task";
import { fireEvent } from "../../common/dom/fire_event";
import { entityIcon } from "../../data/icons";
import type { IconSelector } from "../../data/selector";
@@ -28,23 +30,45 @@ export class HaIconSelector extends LitElement {
icon_entity?: string;
};
protected render() {
private get _stateObj(): HassEntity | undefined {
const iconEntity = this.context?.icon_entity;
return iconEntity ? this.hass.states[iconEntity] : undefined;
}
const stateObj = iconEntity ? this.hass.states[iconEntity] : undefined;
private _placeholderTask = new AsyncValueTask(this, {
task: ([
placeholder,
attributeIcon,
entities,
config,
connection,
stateObj,
]) => {
if (placeholder || attributeIcon || !stateObj) {
return initialState;
}
return entityIcon(entities, config, connection, stateObj);
},
args: () => {
const stateObj = this._stateObj;
return [
this.selector.icon?.placeholder,
stateObj?.attributes.icon,
this.hass.entities,
this.hass.config,
this.hass.connection,
stateObj,
] as const;
},
});
protected render() {
const stateObj = this._stateObj;
const placeholder =
this.selector.icon?.placeholder ||
stateObj?.attributes.icon ||
(stateObj &&
until(
entityIcon(
this.hass.entities,
this.hass.config,
this.hass.connection,
stateObj
)
));
(stateObj && this._placeholderTask.value);
return html`
<ha-icon-picker
+19 -11
View File
@@ -1,8 +1,9 @@
import { consume } from "@lit/context";
import { initialState } from "@lit/task";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import type { Connection, HassConfig } from "home-assistant-js-websocket";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { computeDomain } from "../common/entity/compute_domain";
import { transform } from "../common/decorators/transform";
import { configContext, connectionContext } from "../data/context";
@@ -34,6 +35,17 @@ export class HaServiceIcon extends LitElement {
})
private _connection?: Connection;
private _iconTask = new AsyncValueTask(this, {
task: ([icon, connection, config, service]) => {
if (icon || !connection || !config || !service) {
return initialState;
}
return serviceIcon(connection, config, service);
},
args: () =>
[this.icon, this._connection, this._config, this.service] as const,
});
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -47,16 +59,12 @@ export class HaServiceIcon extends LitElement {
return this._renderFallback();
}
const icon = serviceIcon(this._connection, this._config, this.service).then(
(icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
}
);
return html`${until(icon)}`;
if (!this._iconTask.resolved) {
return nothing;
}
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: this._renderFallback();
}
private _renderFallback() {
+25 -14
View File
@@ -1,8 +1,9 @@
import { consume } from "@lit/context";
import { initialState } from "@lit/task";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import type { Connection, HassConfig } from "home-assistant-js-websocket";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { transform } from "../common/decorators/transform";
import { configContext, connectionContext } from "../data/context";
import { serviceSectionIcon } from "../data/icons";
@@ -31,6 +32,23 @@ export class HaServiceSectionIcon extends LitElement {
})
private _connection?: Connection;
private _iconTask = new AsyncValueTask(this, {
task: ([icon, connection, config, service, section]) => {
if (icon || !connection || !config || !service || !section) {
return initialState;
}
return serviceSectionIcon(connection, config, service, section);
},
args: () =>
[
this.icon,
this._connection,
this._config,
this.service,
this.section,
] as const,
});
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -44,19 +62,12 @@ export class HaServiceSectionIcon extends LitElement {
return this._renderFallback();
}
const icon = serviceSectionIcon(
this._connection,
this._config,
this.service,
this.section
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
});
return html`${until(icon)}`;
if (!this._iconTask.resolved) {
return nothing;
}
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: this._renderFallback();
}
private _renderFallback() {
+47 -17
View File
@@ -1,8 +1,9 @@
import { consume, type ContextType } from "@lit/context";
import { initialState } from "@lit/task";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import {
configContext,
@@ -37,11 +38,47 @@ export class HaStateIcon extends LitElement {
@consume({ context: entitiesContext, subscribe: true })
protected _entities?: ContextType<typeof entitiesContext>;
protected render() {
const overrideIcon =
private get _overrideIcon(): string | undefined {
return (
this.icon ||
(this.stateObj && this._entities?.[this.stateObj.entity_id]?.icon) ||
this.stateObj?.attributes.icon;
this.stateObj?.attributes.icon
);
}
private _iconTask = new AsyncValueTask(this, {
task: ([
overrideIcon,
entities,
config,
connection,
stateObj,
stateValue,
]) => {
if (overrideIcon || !entities || !config || !connection || !stateObj) {
return initialState;
}
return entityIcon(
entities,
config.config,
connection.connection,
stateObj,
stateValue
);
},
args: () =>
[
this._overrideIcon,
this._entities,
this._config,
this._connection,
this.stateObj,
this.stateValue,
] as const,
});
protected render() {
const overrideIcon = this._overrideIcon;
if (overrideIcon) {
return html`<ha-icon .icon=${overrideIcon}></ha-icon>`;
}
@@ -51,19 +88,12 @@ export class HaStateIcon extends LitElement {
if (!this._config || !this._connection || !this._entities) {
return this._renderFallback();
}
const icon = entityIcon(
this._entities,
this._config.config,
this._connection.connection,
this.stateObj,
this.stateValue
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
});
return html`${until(icon)}`;
if (!this._iconTask.resolved) {
return nothing;
}
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: this._renderFallback();
}
private _renderFallback() {
+34 -6
View File
@@ -13,12 +13,40 @@ const SEARCH_KEYS = [
{ name: "secondary", weight: 8 },
];
export const getTimezoneOptions = (): PickerComboBoxItem[] =>
Object.entries(timezones as Record<string, string>).map(([key, value]) => ({
id: key,
primary: value,
secondary: key,
}));
// google-timezones-json is missing the bare "UTC" and "Etc/UTC" zones, even
// though both are valid IANA identifiers and common server defaults. Without
// them a "UTC" configuration shows up as an unknown time zone. Add them back.
const ADDITIONAL_TIMEZONES: PickerComboBoxItem[] = [
{ id: "UTC", primary: "(GMT+00:00) UTC", secondary: "UTC" },
{ id: "Etc/UTC", primary: "(GMT+00:00) UTC", secondary: "Etc/UTC" },
];
// google-timezones-json also ships an invalid IANA identifier. Correct it so
// the zone can be selected (the backend rejects the invalid id).
const TIMEZONE_ID_CORRECTIONS: Record<string, string> = {
"Asia/Yuzhno-Sakhalinsk": "Asia/Sakhalin",
};
export const getTimezoneOptions = (): PickerComboBoxItem[] => {
const options: PickerComboBoxItem[] = Object.entries(
timezones as Record<string, string>
).map(([key, value]) => {
const id = TIMEZONE_ID_CORRECTIONS[key] ?? key;
return {
id,
primary: value,
secondary: id,
};
});
for (const timezone of ADDITIONAL_TIMEZONES) {
if (!options.some((option) => option.id === timezone.id)) {
options.push(timezone);
}
}
return options;
};
@customElement("ha-timezone-picker")
export class HaTimeZonePicker extends LitElement {
+19 -11
View File
@@ -18,10 +18,11 @@ import {
mdiWebhook,
} from "@mdi/js";
import { consume } from "@lit/context";
import { initialState } from "@lit/task";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import type { Connection, HassConfig } from "home-assistant-js-websocket";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { computeDomain } from "../common/entity/compute_domain";
import { transform } from "../common/decorators/transform";
import { configContext, connectionContext } from "../data/context";
@@ -71,6 +72,17 @@ export class HaTriggerIcon extends LitElement {
})
private _connection?: Connection;
private _iconTask = new AsyncValueTask(this, {
task: ([icon, connection, config, trigger]) => {
if (icon || !connection || !config || !trigger) {
return initialState;
}
return triggerIcon(connection, config, trigger);
},
args: () =>
[this.icon, this._connection, this._config, this.trigger] as const,
});
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -84,16 +96,12 @@ export class HaTriggerIcon extends LitElement {
return this._renderFallback();
}
const icon = triggerIcon(this._connection, this._config, this.trigger).then(
(icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
}
);
return html`${until(icon)}`;
if (!this._iconTask.resolved) {
return nothing;
}
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: this._renderFallback();
}
private _renderFallback() {
@@ -33,6 +33,12 @@ const TRACE_PATH_TABS = [
"logbook",
] as const;
// A repeat keeps only its last iterations, so the array index is not the real
// one. Use the recorded repeat.index when we have it.
const iterationNumber = (trace: ActionTraceStep, index: number): number =>
(trace.changed_variables?.repeat as { index?: number } | undefined)?.index ??
index + 1;
@customElement("ha-trace-path-details")
export class HaTracePathDetails extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -214,7 +220,7 @@ export class HaTracePathDetails extends LitElement {
: html`<h3>
${this.hass!.localize(
"ui.panel.config.automation.trace.path.iteration",
{ number: idx + 1 }
{ number: iterationNumber(trace, idx) }
)}
</h3>`}
${curPath
@@ -318,7 +324,7 @@ export class HaTracePathDetails extends LitElement {
? html`<p>
${this.hass!.localize(
"ui.panel.config.automation.trace.path.iteration",
{ number: idx + 1 }
{ number: iterationNumber(trace, idx) }
)}
</p>`
: ""}
+1 -1
View File
@@ -180,7 +180,7 @@ export interface PersistentNotificationTrigger extends BaseTrigger {
export interface ZoneTrigger extends BaseTrigger {
trigger: "zone";
entity_id: string;
entity_id: string | string[];
zone: string;
event: "enter" | "leave";
}
+3
View File
@@ -1124,6 +1124,9 @@ const describeLegacyCondition = (
hasAttribute: attribute !== "" ? "true" : "false",
attribute: attribute,
numberOfEntities: entities.length,
// With "any", entities are joined with "or", which takes a singular
// verb in English even for multiple entities ("A or B is ...").
matchAny: condition.match === "any" ? "true" : "false",
entities:
condition.match === "any"
? formatListWithOrs(hass.locale, entities)
+6
View File
@@ -486,6 +486,12 @@ export const getFormattedBackupTime = memoizeOne(
export const SUPPORTED_UPLOAD_FORMAT = "application/x-tar";
// Browsers report the MIME type of a .tar inconsistently (Firefox on Windows
// gives an empty or different type), so accept it by extension as well.
export const isSupportedBackupFile = (file: File): boolean =>
file.type === SUPPORTED_UPLOAD_FORMAT ||
file.name.toLowerCase().endsWith(".tar");
export interface BackupUploadFileFormData {
file?: File;
}
+7
View File
@@ -10,6 +10,13 @@ export interface DirtyStateContext<
> {
/** Whether any contributor's current slice differs from its initial snapshot */
isDirty: boolean;
/**
* Like `isDirty`, but treats `false` and `undefined`/absent object keys as
* the same value, so a toggle that ends at its off-default (e.g.
* `show_entity_picture: false`) reads as clean and does not warn on a scrim
* close. `isDirty` still reports the raw change so save can stay enabled.
*/
isEffectiveDirty: boolean;
/**
* Push a state slice. The first push for a slice sets its baseline.
* Subsequent pushes are compared against that baseline using the provider's
+6 -2
View File
@@ -8,9 +8,13 @@ export const uploadFile = async (hass: HomeAssistant, file: File) => {
body: fd,
});
if (resp.status === 413) {
throw new Error(`Uploaded file is too large (${file.name})`);
throw new Error(
hass.localize("ui.common.upload_file_too_large", {
name: file.name,
})
);
} else if (resp.status !== 200) {
throw new Error("Unknown error");
throw new Error(hass.localize("ui.common.unknown_error"));
}
const data = await resp.json();
return data.file_id;
+6 -2
View File
@@ -57,9 +57,13 @@ export const createImage = async (
body: fd,
});
if (resp.status === 413) {
throw new Error(`Uploaded image is too large (${file.name})`);
throw new Error(
hass.localize("ui.common.upload_image_too_large", {
name: file.name,
})
);
} else if (resp.status !== 200) {
throw new Error("Unknown error");
throw new Error(hass.localize("ui.common.unknown_error"));
}
return resp.json();
};
+6 -2
View File
@@ -54,9 +54,13 @@ export const uploadLocalMedia = async (
}
);
if (resp.status === 413) {
throw new Error(`Uploaded file is too large (${file.name})`);
throw new Error(
hass.localize("ui.common.upload_file_too_large", {
name: file.name,
})
);
} else if (resp.status !== 200) {
throw new Error("Unknown error");
throw new Error(hass.localize("ui.common.unknown_error"));
}
return resp.json();
};
+7 -1
View File
@@ -18,9 +18,15 @@ export const formatSelectorValue = (
}
if ("text" in selector) {
const { prefix, suffix } = selector.text || {};
const { prefix, suffix, type } = selector.text || {};
const texts = ensureArray(value);
// Never reveal secret values in a read-only preview.
if (type === "password") {
return texts.map(() => "••••••••").join(", ");
}
return texts
.map((text) => `${prefix || ""}${text}${suffix || ""}`)
.join(", ");
+6 -6
View File
@@ -5,7 +5,7 @@ import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-button";
import "../../components/ha-control-button";
import "../../components/ha-dialog";
import "../../components/ha-adaptive-dialog";
import "../../components/ha-dialog-footer";
import "../../components/input/ha-input";
import type { HaInput } from "../../components/input/ha-input";
@@ -111,7 +111,7 @@ export class DialogEnterCode
if (isText) {
return html`
<ha-dialog
<ha-adaptive-dialog
.open=${this._open}
header-title=${this._dialogParams.title ??
this.hass.localize("ui.dialogs.enter_code.title")}
@@ -143,12 +143,12 @@ export class DialogEnterCode
this.hass.localize("ui.common.submit")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
</ha-adaptive-dialog>
`;
}
return html`
<ha-dialog
<ha-adaptive-dialog
.open=${this._open}
header-title=${this._dialogParams.title ?? "Enter code"}
width="small"
@@ -202,12 +202,12 @@ export class DialogEnterCode
)}
</div>
</div>
</ha-dialog>
</ha-adaptive-dialog>
`;
}
static styles = css`
ha-dialog {
ha-adaptive-dialog {
/* Place above other dialogs */
--dialog-z-index: 104;
}
+53 -5
View File
@@ -26,6 +26,15 @@ export type CompareStrategy<State> =
* so independent contributors (e.g. a helper form alongside the entity
* registry editor) can coexist without overwriting each other.
*
* `isEffectiveDirty` runs the same comparison, but first passes each slice's
* initial and current value through the optional `effectiveNormalize` function
* given to `_initDirtyTracking`. Provide a normalizer that collapses values you
* consider equivalent (e.g. a config with a toggle left at its default vs the
* key being absent) so they do not read as dirty. Without a normalizer it is
* identical to `isDirty`. Use `isEffectiveDirtyState` to decide whether closing
* needs a "discard changes?" prompt, and `isDirtyState` to decide whether save
* is enabled.
*
* @example Eager init for the provider's own slice:
* ```ts
* class MyDialog extends DirtyStateProviderMixin<MyDialogState>()(LitElement) {
@@ -63,23 +72,39 @@ export const DirtyStateProviderMixin =
class DirtyStateProviderMixinClass extends superClass {
private _dirtySlices = new Map<
Key | DefaultDirtyStateKey,
{ initial: State; current: State }
{ initial: State; current: State; normalizedInitial: State }
>();
private _dirtyCompareFn: (a: State, b: State) => boolean = deepEqual;
private _dirtyCloneFn: (value: State) => State = (value) => value;
private _effectiveNormalize?: (value: State) => State;
@provide({ context: dirtyStateContext })
@state()
private _dirtyStateContext: DirtyStateContext<State, Key> =
this._buildContextValue();
private _normalizeEffective(value: State): State {
return this._effectiveNormalize
? this._effectiveNormalize(value)
: value;
}
private _buildContextValue(): DirtyStateContext<State, Key> {
const slices = Array.from(this._dirtySlices.values());
return {
isDirty: Array.from(this._dirtySlices.values()).some(
isDirty: slices.some(
({ initial, current }) => !this._dirtyCompareFn(initial, current)
),
isEffectiveDirty: slices.some(
({ normalizedInitial, current }) =>
!this._dirtyCompareFn(
normalizedInitial,
this._normalizeEffective(current)
)
),
setState: (value: State, key: Key) => {
this._writeSlice(key, value);
},
@@ -97,9 +122,11 @@ export const DirtyStateProviderMixin =
const slice = this._dirtySlices.get(key);
if (!slice) {
// First push for this key becomes the baseline.
const initial = this._dirtyCloneFn(value);
this._dirtySlices.set(key, {
initial: this._dirtyCloneFn(value),
initial,
current: value,
normalizedInitial: this._normalizeEffective(initial),
});
this._publishContext();
return;
@@ -119,12 +146,19 @@ export const DirtyStateProviderMixin =
* push for any key (via the provider helper or a consumer's `setState`)
* becomes that key's baseline.
*
* `effectiveNormalize` transforms a slice value before the
* `isEffectiveDirty` comparison, letting the caller treat values it
* considers equivalent as clean (e.g. a config with a toggle at its
* default vs the key being absent). It does not affect `isDirty`.
*
* Call again to reset (e.g. when the underlying entity changes).
*/
protected _initDirtyTracking(
strategy: CompareStrategy<State>,
initialState?: State
initialState?: State,
effectiveNormalize?: (value: State) => State
): void {
this._effectiveNormalize = effectiveNormalize;
switch (strategy.type) {
case "deep":
this._dirtyCompareFn = (a, b) => deepEqual(a, b);
@@ -140,9 +174,11 @@ export const DirtyStateProviderMixin =
}
this._dirtySlices.clear();
if (initialState !== undefined) {
const initial = this._dirtyCloneFn(initialState);
this._dirtySlices.set(DEFAULT_DIRTY_STATE_KEY, {
initial: this._dirtyCloneFn(initialState),
initial,
current: initialState,
normalizedInitial: this._normalizeEffective(initial),
});
}
this._publishContext();
@@ -164,6 +200,7 @@ export const DirtyStateProviderMixin =
protected _markDirtyStateClean(): void {
for (const slice of this._dirtySlices.values()) {
slice.initial = this._dirtyCloneFn(slice.current);
slice.normalizedInitial = this._normalizeEffective(slice.initial);
}
this._publishContext();
}
@@ -185,6 +222,17 @@ export const DirtyStateProviderMixin =
public get isDirtyState(): boolean {
return this._dirtyStateContext.isDirty;
}
/**
* Like `isDirtyState`, but compares values after the `effectiveNormalize`
* function passed to `_initDirtyTracking`, so values the caller treats as
* equivalent (e.g. a toggle left at its default) do not read as dirty. Use
* it to decide whether closing needs a "discard changes?" prompt, while
* `isDirtyState` decides whether save is enabled.
*/
public get isEffectiveDirtyState(): boolean {
return this._dirtyStateContext.isEffectiveDirty;
}
}
return DirtyStateProviderMixinClass;
};
@@ -9,6 +9,7 @@ import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import {
CORE_LOCAL_AGENT,
HASSIO_LOCAL_AGENT,
isSupportedBackupFile,
SUPPORTED_UPLOAD_FORMAT,
} from "../../data/backup";
import type { LocalizeFunc } from "../../common/translations/localize";
@@ -76,7 +77,7 @@ class OnboardingRestoreBackupUpload extends LitElement {
this._error = undefined;
const file = ev.detail.files[0];
if (!file || file.type !== SUPPORTED_UPLOAD_FORMAT) {
if (!file || !isSupportedBackupFile(file)) {
showAlertDialog(this, {
title: this.localize(
"ui.panel.page-onboarding.restore.unsupported.title"
@@ -2464,7 +2464,9 @@ class DialogAddAutomationElement
ha-automation-add-from-target,
.groups {
overflow: auto;
flex: 4;
/* Fixed-width left column so it does not resize as the right
panel's content width changes between groups. */
flex: 0 0 360px;
margin-inline-end: 0;
}
@@ -2500,7 +2502,8 @@ class DialogAddAutomationElement
}
ha-automation-add-items {
flex: 6;
flex: 1;
min-width: 0;
}
.content.column ha-automation-add-from-target,
@@ -1,8 +1,10 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { ensureArray } from "../../../../../common/array/ensure-array";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { computeStateDomain } from "../../../../../common/entity/compute_state_domain";
import { hasLocation } from "../../../../../common/entity/has_location";
import "../../../../../components/entity/ha-entities-picker";
import "../../../../../components/entity/ha-entity-picker";
import "../../../../../components/radio/ha-radio-group";
import type { HaRadioGroup } from "../../../../../components/radio/ha-radio-group";
@@ -27,7 +29,7 @@ export class HaZoneTrigger extends LitElement {
public static get defaultConfig(): ZoneTrigger {
return {
trigger: "zone",
entity_id: "",
entity_id: [],
zone: "",
event: "enter" as ZoneTrigger["event"],
};
@@ -36,16 +38,16 @@ export class HaZoneTrigger extends LitElement {
protected render() {
const { entity_id, zone, event } = this.trigger;
return html`
<ha-entity-picker
<ha-entities-picker
.label=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.type.zone.entity"
)}
.value=${entity_id}
.value=${entity_id ? ensureArray(entity_id) : []}
.disabled=${this.disabled}
@value-changed=${this._entityPicked}
.hass=${this.hass}
.entityFilter=${zoneAndLocationFilter}
></ha-entity-picker>
></ha-entities-picker>
<ha-entity-picker
.label=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.type.zone.zone"
@@ -81,7 +83,7 @@ export class HaZoneTrigger extends LitElement {
`;
}
private _entityPicked(ev: ValueChangedEvent<string>) {
private _entityPicked(ev: ValueChangedEvent<string[]>) {
ev.stopPropagation();
fireEvent(this, "value-changed", {
value: { ...this.trigger, entity_id: ev.detail.value },
@@ -16,6 +16,7 @@ import {
CORE_LOCAL_AGENT,
HASSIO_LOCAL_AGENT,
INITIAL_UPLOAD_FORM_DATA,
isSupportedBackupFile,
SUPPORTED_UPLOAD_FORMAT,
uploadBackup,
type BackupUploadFileFormData,
@@ -141,7 +142,7 @@ export class DialogUploadBackup
private async _upload() {
const { file } = this._formData!;
if (!file || file.type !== SUPPORTED_UPLOAD_FORMAT) {
if (!file || !isSupportedBackupFile(file)) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.backup.dialogs.upload.unsupported.title"
+44 -31
View File
@@ -69,6 +69,27 @@ export async function generateMetadataSuggestionTask<T>(
include.floor ? fetchFloors(connection) : Promise.resolve(undefined),
]);
// Offer the names (not the internal IDs) to the model. The model has no idea
// what an ID means, and processMetadataSuggestion maps the chosen name back
// to its ID.
const categoryOptions = categories
? Object.values(categories).map((name) => ({
value: name,
label: name,
}))
: [];
const floorOptions = floors
? Object.values(floors).map((floor) => ({
value: floor.name,
label: floor.name,
}))
: [];
// Only offer the select fields when there is at least one option. Some AI
// providers reject a select/enum schema with an empty options list.
const includeCategories = categoryOptions.length > 0;
const includeFloor = floorOptions.length > 0;
const structure: AITaskStructure = {
...(include.name && {
name: {
@@ -99,50 +120,42 @@ export async function generateMetadataSuggestionTask<T>(
},
},
}),
...(include.categories &&
categories && {
category: {
description: `The category of the ${domain}`,
required: false,
selector: {
select: {
options: Object.entries(categories).map(([id, name]) => ({
value: id,
label: name,
})),
},
...(includeCategories && {
category: {
description: `The category of the ${domain}`,
required: false,
selector: {
select: {
options: categoryOptions,
},
},
}),
...(include.floor &&
floors && {
floor: {
description: `The floor of the ${domain}`,
required: false,
selector: {
select: {
options: Object.values(floors).map((floor) => ({
value: floor.floor_id,
label: floor.name,
})),
},
},
}),
...(includeFloor && {
floor: {
description: `The floor of the ${domain}`,
required: false,
selector: {
select: {
options: floorOptions,
},
},
}),
},
}),
};
const requestedParts = [
include.name ? "a name" : null,
include.description ? "a description" : null,
include.categories ? "a category" : null,
includeCategories ? "a category" : null,
include.labels ? "labels" : null,
include.floor ? "a floor" : null,
includeFloor ? "a floor" : null,
].filter((entry): entry is string => entry !== null);
const categoryLabels: string[] = [
include.categories ? "category" : null,
includeCategories ? "category" : null,
include.labels ? "labels" : null,
include.floor ? "floor" : null,
includeFloor ? "floor" : null,
].filter((entry): entry is string => entry !== null);
const categoryLabelsText = PROMPT_LIST_FORMAT.format(categoryLabels);
@@ -168,7 +181,7 @@ export async function generateMetadataSuggestionTask<T>(
`The name should be in same style and sentence capitalization as existing ${domain}s.`,
]
: []),
...(include.categories || include.labels || include.floor
...(includeCategories || include.labels || includeFloor
? [
`Suggest ${categoryLabelsText} if relevant to the ${domain}'s purpose.`,
`Only suggest ${categoryLabelsText} that are already used by existing ${domain}s.`,
@@ -64,6 +64,10 @@ class HaPanelDevTemplate extends LitElement {
private _inited = false;
// Bumped on every (re)subscribe so a superseded render can be detected and
// its late-arriving results discarded.
private _subscribeRequestId = 0;
private _tipResizeObserver?: ResizeObserver;
public connectedCallback() {
@@ -502,8 +506,14 @@ ${type === "object"
}
private async _subscribeTemplate() {
const requestId = ++this._subscribeRequestId;
this._rendering = true;
await this._unsubscribeTemplate();
// A newer render started while we were unsubscribing; let it win so we do
// not leave a stale subscription running that overwrites the result.
if (requestId !== this._subscribeRequestId) {
return;
}
this._error = undefined;
this._errorLevel = undefined;
this._templateResult = undefined;
@@ -511,6 +521,10 @@ ${type === "object"
this._unsubRenderTemplate = subscribeRenderTemplate(
this.hass.connection,
(result) => {
// Ignore results from a render that has since been superseded.
if (requestId !== this._subscribeRequestId) {
return;
}
if ("error" in result) {
// We show the latest error, or a warning if there are no errors
if (result.level === "ERROR" || this._errorLevel !== "ERROR") {
@@ -1,5 +1,4 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import { consume } from "@lit/context";
import { mdiContentCopy, mdiRestore } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
@@ -7,9 +6,9 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import memoizeOne from "memoize-one";
import { consume } from "@lit/context";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeEntityEntryName } from "../../../common/entity/compute_entity_name";
import { computeObjectId } from "../../../common/entity/compute_object_id";
import { supportsFeature } from "../../../common/entity/supports-feature";
import { formatNumber } from "../../../common/number/format_number";
@@ -20,16 +19,14 @@ import type {
LocalizeKeys,
} from "../../../common/translations/localize";
import { copyToClipboard } from "../../../common/util/copy-clipboard";
import "../../../components/entity/ha-entity-picker";
import "../../../components/ha-alert";
import "../../../components/ha-area-picker";
import "../../../components/ha-color-picker";
import "../../../components/ha-dropdown-item";
import "../../../components/entity/ha-entity-picker";
import "../../../components/ha-icon";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-button-next";
import "../../../components/ha-icon-picker";
import "../../../components/ha-input-helper-text";
import "../../../components/ha-labels-picker";
import "../../../components/ha-list-item";
import "../../../components/ha-md-list-item";
@@ -48,16 +45,16 @@ import {
STREAM_TYPE_HLS,
updateCameraPrefs,
} from "../../../data/camera";
import {
dirtyStateContext,
type DirtyStateContext,
} from "../../../data/context/dirty-state";
import type { ConfigEntry } from "../../../data/config_entries";
import { deleteConfigEntry } from "../../../data/config_entries";
import {
createConfigFlow,
handleConfigFlowStep,
} from "../../../data/config_flow";
import {
dirtyStateContext,
type DirtyStateContext,
} from "../../../data/context/dirty-state";
import type { DataEntryFlowStepCreateEntry } from "../../../data/data_entry_flow";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import { updateDeviceRegistryEntry } from "../../../data/device/device_registry";
@@ -196,8 +193,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
@state() private _name!: string;
@state() private _type: "main" | "additional" = "additional";
@state() private _icon!: string;
@state() private _entityId!: EntitySettingsState["entityId"];
@@ -266,12 +261,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
return;
}
this._type =
this.entry.device_id &&
!computeEntityEntryName(this.entry, this.hass.devices)
? "main"
: "additional";
this._name = this._type === "main" ? "" : this.entry.name || "";
this._name = this.entry.name || "";
this._icon = this.entry.icon || "";
this._deviceClass =
this.entry.device_class || this.entry.original_device_class;
@@ -396,7 +386,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
this._dirtyState?.setState(
{
name: this._computeName(),
name: this._name.trim() || null,
icon: this._icon.trim() || null,
entityId: this._entityId.trim(),
areaId: this._areaId ?? null,
@@ -478,75 +468,34 @@ export class EntityRegistrySettingsEditor extends LitElement {
return html`
${this.hideName
? nothing
: this._device
? html`<ha-select
class="type"
.label=${this.hass.localize(
"ui.dialogs.entity_registry.editor.entity_type"
)}
.value=${this._type}
.options=${[
{
value: "main",
label: this.hass.localize(
"ui.dialogs.entity_registry.editor.main_entity"
),
},
{
value: "additional",
label: this.hass.localize(
"ui.dialogs.entity_registry.editor.additional_entity"
),
},
]}
.disabled=${this.disabled}
@selected=${this._entityNameModeChanged}
></ha-select>
<ha-input-helper-text>
${this._type === "main"
? html`${this.hass.localize(
"ui.dialogs.entity_registry.editor.main_entity_description"
)}
${this.hass.localize(
"ui.dialogs.entity_registry.editor.change_device_settings",
{
link: html`<button
class="link"
@click=${this._openDeviceSettings}
>
${this.hass.localize(
"ui.dialogs.entity_registry.editor.change_device_name_link"
)}
</button>`,
}
)}`
: this.hass.localize(
"ui.dialogs.entity_registry.editor.additional_entity_description"
)}
</ha-input-helper-text>
${this._type === "main"
? nothing
: html`<ha-input
inset-label
class="name"
.value=${this._name}
.placeholder=${this.entry.original_name || ""}
.label=${this.hass.localize(
"ui.dialogs.entity_registry.editor.entity_name"
)}
.disabled=${this.disabled}
@input=${this._nameChanged}
></ha-input>`}`
: html`<ha-input
inset-label
class="name"
.value=${this._name}
.label=${this.hass.localize(
"ui.dialogs.entity_registry.editor.name"
)}
.disabled=${this.disabled}
@input=${this._nameChanged}
></ha-input>`}
: html`<ha-input
inset-label
class="name"
.value=${this._name}
.label=${this.hass.localize(
"ui.dialogs.entity_registry.editor.name"
)}
.disabled=${this.disabled}
@input=${this._nameChanged}
>
${this._device
? html`<span slot="hint"
>${this.hass.localize(
"ui.dialogs.entity_registry.editor.device_name_tip",
{
link: html`<button
class="link"
@click=${this._resetNameAndOpenDeviceSettings}
>
${this.hass.localize(
"ui.dialogs.entity_registry.editor.open_device_settings"
)}
</button>`,
}
)}</span
>`
: nothing}
</ha-input>`}
${this.hideIcon
? nothing
: html`
@@ -1229,7 +1178,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
const params: Partial<EntityRegistryEntryUpdateParams> = {
name: this._computeName(),
name: this._name.trim() || null,
icon: this._icon.trim() || null,
area_id: this._areaId || null,
labels: this._labels || [],
@@ -1676,24 +1625,9 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
}
private _entityNameModeChanged(ev: HaSelectSelectEvent): void {
const value = ev.detail.value;
if (!value) {
return;
}
this._type = value === "main" ? "main" : "additional";
this._name = this._type === "main" ? "" : this.entry.name || "";
}
private _computeName(): string | null {
if (this._device && this._type === "main") {
// No original_name → keep null; forcing main on a named entity → "".
if (this.entry.name == null && !this.entry.original_name) {
return null;
}
return "";
}
return this._name.trim() || null;
private _resetNameAndOpenDeviceSettings() {
this._name = this.entry.name || "";
this._openDeviceSettings();
}
private _openDeviceSettings() {
@@ -1830,10 +1764,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
.entityId {
direction: ltr;
}
ha-input-helper-text {
display: block;
margin: 0 0 var(--ha-space-2);
}
`,
];
}
@@ -135,9 +135,11 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
const offlineDevices = nodes.filter(
(node) => node.status === NodeStatus.Dead
).length;
const notReadyDevices =
nodes.filter((node) => !node.ready && node.status !== NodeStatus.Dead)
.length + provisioningDevices;
// Not-ready nodes are included but their interview has not completed yet.
// They are distinct from the provisioning entries, which are not included.
const notReadyDevices = nodes.filter(
(node) => !node.ready && node.status !== NodeStatus.Dead
).length;
return html`
<hass-subpage
@@ -201,11 +203,18 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
}
if (notReadyDevices > 0) {
statusParts.push(
this.hass.localize("ui.panel.config.zwave_js.dashboard.not_included", {
this.hass.localize("ui.panel.config.zwave_js.dashboard.not_ready", {
count: notReadyDevices,
})
);
}
if (provisioningDevices > 0) {
statusParts.push(
this.hass.localize("ui.panel.config.zwave_js.dashboard.not_included", {
count: provisioningDevices,
})
);
}
return html`
<ha-card class="content network-status">
<div class="card-content">
@@ -121,8 +121,7 @@ class ZWaveJSNodeConfig extends LitElement {
.header=${this.hass.localize(
"ui.panel.config.zwave_js.node_config.header"
)}
back-path="/config/zwave_js/dashboard?config_entry=${this
.configEntryId}"
back-path="/config/devices/device/${this.deviceId}"
>
<ha-config-section
.narrow=${this.narrow}
+57 -5
View File
@@ -19,9 +19,11 @@ export type EnergyViewPath =
| "now";
// --- Applicability helpers -------------------------------------------------
// These mirror, one-to-one, the conditions the individual view strategies use
// to decide whether to emit a card. The catalog and the strategies must agree
// on what "applicable" means, so the conditions live here and are reused.
// Source-shape predicates shared by the catalog entries below, the view
// strategies (for view-level decisions and badges), and the dashboard
// strategy. Card applicability itself lives in the catalog: strategies decide
// whether to emit a card through `isEnergyCardVisible()`, so they never
// re-derive these conditions inline and can never disagree with the catalog.
export const hasGridSource = (prefs: EnergyPreferences): boolean =>
prefs.energy_sources.some(
@@ -41,6 +43,12 @@ export const hasSolar = (prefs: EnergyPreferences): boolean =>
export const hasBattery = (prefs: EnergyPreferences): boolean =>
prefs.energy_sources.some((source) => source.type === "battery");
/** Any electricity-relevant source: grid, solar, or battery. */
export const hasEnergySource = (prefs: EnergyPreferences): boolean =>
prefs.energy_sources.some((source) =>
["grid", "solar", "battery"].includes(source.type)
);
export const hasGasSource = (prefs: EnergyPreferences): boolean =>
prefs.energy_sources.some((source) => source.type === "gas");
@@ -66,9 +74,21 @@ export const hasPowerSources = (prefs: EnergyPreferences): boolean =>
export const hasPowerDevices = (prefs: EnergyPreferences): boolean =>
prefs.device_consumption.some((device) => device.stat_rate);
export const hasPowerWaterDevices = (prefs: EnergyPreferences): boolean =>
export const hasWaterRateDevices = (prefs: EnergyPreferences): boolean =>
(prefs.device_consumption_water ?? []).some((device) => device.stat_rate);
/** A water source exposing a live flow-rate statistic. */
export const hasWaterRateSource = (prefs: EnergyPreferences): boolean =>
prefs.energy_sources.some(
(source) => source.type === "water" && !!source.stat_rate
);
/** A gas source exposing a live flow-rate statistic. */
export const hasGasRateSource = (prefs: EnergyPreferences): boolean =>
prefs.energy_sources.some(
(source) => source.type === "gas" && !!source.stat_rate
);
// --- Card catalog ----------------------------------------------------------
export interface EnergyCardCatalogEntry {
@@ -262,18 +282,50 @@ export const ENERGY_CARD_CATALOG: readonly EnergyCardCatalogEntry[] = [
"now",
"water-flow-sankey",
"ui.panel.energy.cards.water_flow_sankey_title",
(p) => hasPowerWaterDevices(p)
(p) => hasWaterRateDevices(p)
),
];
// --- Lookup helpers --------------------------------------------------------
const ENERGY_CARD_CATALOG_BY_KEY = new Map<string, EnergyCardCatalogEntry>(
ENERGY_CARD_CATALOG.map((c) => [c.key, c])
);
/** The catalog entry for a `(view, cardType)` pair, or undefined if unknown. */
export const energyCardEntry = (
view: EnergyViewPath,
cardType: string
): EnergyCardCatalogEntry | undefined =>
ENERGY_CARD_CATALOG_BY_KEY.get(energyCardKey(view, cardType));
export const isEnergyCardHidden = (
view: EnergyViewPath,
cardType: string,
hidden: string[] | undefined
): boolean => !!hidden?.includes(energyCardKey(view, cardType));
/**
* Single source of truth for whether a view strategy should emit a card: the
* card must be in the catalog, apply to the current preferences, and not be
* hidden by the user. Strategies call this instead of re-deriving the
* applicability conditions inline, so the catalog and the strategies can never
* disagree on what "applicable" means.
*/
export const isEnergyCardVisible = (
view: EnergyViewPath,
cardType: string,
prefs: EnergyPreferences,
hidden: string[] | undefined
): boolean => {
const cardEntry = energyCardEntry(view, cardType);
return (
!!cardEntry &&
cardEntry.isApplicable(prefs) &&
!hidden?.includes(cardEntry.key)
);
};
/** Keys of all catalog cards that apply to the given preferences for a view. */
export const applicableEnergyCardKeys = (
view: EnergyViewPath,
@@ -16,7 +16,16 @@ import {
DEFAULT_POWER_COLLECTION_KEY,
} from "../constants";
import type { EnergyViewPath } from "./energy-cards";
import { isEnergyViewEmpty } from "./energy-cards";
import {
hasDeviceConsumption,
hasEnergySource,
hasGasSource,
hasPowerDevices,
hasPowerSources,
hasWaterDevices,
hasWaterSource,
isEnergyViewEmpty,
} from "./energy-cards";
const OVERVIEW_VIEW = {
path: "overview",
@@ -91,37 +100,18 @@ export class EnergyDashboardStrategy extends ReactiveElement {
};
}
const hasEnergy = prefs.energy_sources.some((source) =>
["grid", "solar", "battery"].includes(source.type)
);
const hasPowerSource = prefs.energy_sources.some((source) => {
if (source.type === "solar" && source.stat_rate) return true;
if (source.type === "battery" && source.stat_rate) return true;
if (source.type === "grid") {
return !!source.stat_rate || !!source.power_config;
}
return false;
});
const hasDevicePower = prefs.device_consumption.some(
(device) => device.stat_rate
);
const hasEnergy = hasEnergySource(prefs);
const hasPowerSource = hasPowerSources(prefs);
const hasDevicePower = hasPowerDevices(prefs);
const hasPower = hasPowerSource || hasDevicePower;
const hasWater =
prefs.energy_sources.some((source) => source.type === "water") ||
prefs.device_consumption_water?.length > 0;
const hasGas = prefs.energy_sources.some((source) => source.type === "gas");
const hasDeviceConsumption = prefs.device_consumption.length > 0;
const hasWater = hasWaterSource(prefs) || hasWaterDevices(prefs);
const hasGas = hasGasSource(prefs);
const hasDevices = hasDeviceConsumption(prefs);
const hidden = _config.hidden_cards;
const candidateViews: LovelaceStrategyViewConfig[] = [];
if (hasEnergy || hasDeviceConsumption) {
if (hasEnergy || hasDevices) {
candidateViews.push(ENERGY_VIEW);
}
if (hasGas) {
@@ -1,13 +1,12 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import type { GridSourceTypeEnergyPreference } from "../../../data/energy";
import { getEnergyDataCollection } from "../../../data/energy";
import type { HomeAssistant } from "../../../types";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { LovelaceStrategyDependency } from "../../lovelace/strategies/types";
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../constants";
import type { EnergyViewStrategyConfig } from "./energy-cards";
import { isEnergyCardHidden } from "./energy-cards";
import { hasWaterSource, isEnergyCardVisible } from "./energy-cards";
@customElement("energy-overview-view-strategy")
export class EnergyOverviewViewStrategy extends ReactiveElement {
@@ -53,35 +52,7 @@ export class EnergyOverviewViewStrategy extends ReactiveElement {
return view;
}
const hasGrid = prefs.energy_sources.find(
(source): source is GridSourceTypeEnergyPreference =>
source.type === "grid" &&
(!!source.stat_energy_from || !!source.stat_energy_to)
);
const hasGas = prefs.energy_sources.some((source) => source.type === "gas");
const hasBattery = prefs.energy_sources.some(
(source) => source.type === "battery"
);
const hasSolar = prefs.energy_sources.some(
(source) => source.type === "solar"
);
const hasWaterSources = prefs.energy_sources.some(
(source) => source.type === "water"
);
const hasWaterDevices = prefs.device_consumption_water?.length;
const hasPowerSources = prefs.energy_sources.find((source) => {
if (source.type === "solar" && source.stat_rate) return true;
if (source.type === "battery" && source.stat_rate) return true;
if (source.type === "grid") {
return !!source.stat_rate || !!source.power_config;
}
return false;
});
if (
(hasGrid || hasBattery || hasSolar) &&
!isEnergyCardHidden("overview", "energy-distribution", hidden)
) {
if (isEnergyCardVisible("overview", "energy-distribution", prefs, hidden)) {
view.sections!.push({
type: "grid",
cards: [
@@ -97,8 +68,7 @@ export class EnergyOverviewViewStrategy extends ReactiveElement {
}
if (
prefs.energy_sources.length &&
!isEnergyCardHidden("overview", "energy-sources-table", hidden)
isEnergyCardVisible("overview", "energy-sources-table", prefs, hidden)
) {
view.sections!.push({
type: "grid",
@@ -115,10 +85,7 @@ export class EnergyOverviewViewStrategy extends ReactiveElement {
});
}
if (
hasPowerSources &&
!isEnergyCardHidden("overview", "power-sources-graph", hidden)
) {
if (isEnergyCardVisible("overview", "power-sources-graph", prefs, hidden)) {
view.sections!.push({
type: "grid",
cards: [
@@ -134,10 +101,7 @@ export class EnergyOverviewViewStrategy extends ReactiveElement {
});
}
if (
(hasGrid || hasBattery) &&
!isEnergyCardHidden("overview", "energy-usage-graph", hidden)
) {
if (isEnergyCardVisible("overview", "energy-usage-graph", prefs, hidden)) {
view.sections!.push({
type: "grid",
cards: [
@@ -152,7 +116,7 @@ export class EnergyOverviewViewStrategy extends ReactiveElement {
});
}
if (hasGas && !isEnergyCardHidden("overview", "energy-gas-graph", hidden)) {
if (isEnergyCardVisible("overview", "energy-gas-graph", prefs, hidden)) {
view.sections!.push({
type: "grid",
cards: [
@@ -167,14 +131,11 @@ export class EnergyOverviewViewStrategy extends ReactiveElement {
});
}
if (
(hasWaterSources || hasWaterDevices) &&
!isEnergyCardHidden("overview", "energy-water-graph", hidden)
) {
if (isEnergyCardVisible("overview", "energy-water-graph", prefs, hidden)) {
view.sections!.push({
type: "grid",
cards: [
hasWaterSources
hasWaterSource(prefs)
? {
title: hass.localize(
"ui.panel.energy.cards.energy_water_graph_title"
@@ -1,13 +1,12 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import type { GridSourceTypeEnergyPreference } from "../../../data/energy";
import { getEnergyDataCollection } from "../../../data/energy";
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../constants";
import type { EnergyViewStrategyConfig } from "./energy-cards";
import { isEnergyCardHidden } from "./energy-cards";
import { isEnergyCardVisible } from "./energy-cards";
import { shouldShowFloorsAndAreas } from "./show-floors-and-areas";
import {
LARGE_SCREEN_CONDITION,
@@ -61,28 +60,12 @@ export class EnergyViewStrategy extends ReactiveElement {
return view;
}
const hasGrid = prefs.energy_sources.find(
(source): source is GridSourceTypeEnergyPreference =>
source.type === "grid" &&
(!!source.stat_energy_from || !!source.stat_energy_to)
);
const hasReturn = prefs.energy_sources.some(
(source) => source.type === "grid" && !!source.stat_energy_to
);
const hasSolar = prefs.energy_sources.some(
(source) => source.type === "solar"
);
const hasBattery = prefs.energy_sources.some(
(source) => source.type === "battery"
);
const mainCards: LovelaceCardConfig[] = [];
const gaugeCards: LovelaceCardConfig[] = [];
const sidebarSection = view.sidebar!.sections![0];
if (
(hasGrid || hasBattery || hasSolar) &&
!isEnergyCardHidden("electricity", "energy-distribution", hidden)
isEnergyCardVisible("electricity", "energy-distribution", prefs, hidden)
) {
const distributionCard = {
title: hass.localize("ui.panel.energy.cards.energy_distribution_title"),
@@ -100,9 +83,7 @@ export class EnergyViewStrategy extends ReactiveElement {
// Only include if we have both grid import and export configured
if (
hasGrid &&
hasReturn &&
!isEnergyCardHidden("electricity", "energy-grid-balance", hidden)
isEnergyCardVisible("electricity", "energy-grid-balance", prefs, hidden)
) {
const gridResultCard = {
type: "energy-grid-balance",
@@ -119,58 +100,62 @@ export class EnergyViewStrategy extends ReactiveElement {
// Only include if we have a grid source & return.
if (
hasReturn &&
!isEnergyCardHidden("electricity", "energy-grid-neutrality-gauge", hidden)
isEnergyCardVisible(
"electricity",
"energy-grid-neutrality-gauge",
prefs,
hidden
)
) {
const card = {
gaugeCards.push({
type: "energy-grid-neutrality-gauge",
collection_key: collectionKey,
};
gaugeCards.push(card);
});
}
// Only include if we have a solar source.
if (hasSolar) {
if (
hasReturn &&
!isEnergyCardHidden(
"electricity",
"energy-solar-consumed-gauge",
hidden
)
) {
const card = {
type: "energy-solar-consumed-gauge",
collection_key: collectionKey,
};
gaugeCards.push(card);
}
if (
hasGrid &&
!isEnergyCardHidden(
"electricity",
"energy-self-sufficiency-gauge",
hidden
)
) {
const card = {
type: "energy-self-sufficiency-gauge",
collection_key: collectionKey,
};
gaugeCards.push(card);
}
// Only include if we have a solar source & return.
if (
isEnergyCardVisible(
"electricity",
"energy-solar-consumed-gauge",
prefs,
hidden
)
) {
gaugeCards.push({
type: "energy-solar-consumed-gauge",
collection_key: collectionKey,
});
}
// Only include if we have a solar source & grid.
if (
isEnergyCardVisible(
"electricity",
"energy-self-sufficiency-gauge",
prefs,
hidden
)
) {
gaugeCards.push({
type: "energy-self-sufficiency-gauge",
collection_key: collectionKey,
});
}
// Only include if we have a grid
if (
hasGrid &&
!isEnergyCardHidden("electricity", "energy-carbon-consumed-gauge", hidden)
isEnergyCardVisible(
"electricity",
"energy-carbon-consumed-gauge",
prefs,
hidden
)
) {
const card = {
gaugeCards.push({
type: "energy-carbon-consumed-gauge",
collection_key: collectionKey,
};
gaugeCards.push(card);
});
}
if (gaugeCards.length) {
@@ -201,8 +186,7 @@ export class EnergyViewStrategy extends ReactiveElement {
// Only include if we have a grid or battery.
if (
(hasGrid || hasBattery) &&
!isEnergyCardHidden("electricity", "energy-usage-graph", hidden)
isEnergyCardVisible("electricity", "energy-usage-graph", prefs, hidden)
) {
mainCards.push({
title: hass.localize("ui.panel.energy.cards.energy_usage_graph_title"),
@@ -214,8 +198,7 @@ export class EnergyViewStrategy extends ReactiveElement {
// Only include if we have a solar source.
if (
hasSolar &&
!isEnergyCardHidden("electricity", "energy-solar-graph", hidden)
isEnergyCardVisible("electricity", "energy-solar-graph", prefs, hidden)
) {
mainCards.push({
title: hass.localize("ui.panel.energy.cards.energy_solar_graph_title"),
@@ -226,8 +209,7 @@ export class EnergyViewStrategy extends ReactiveElement {
}
if (
(hasGrid || hasSolar || hasBattery) &&
!isEnergyCardHidden("electricity", "energy-sources-table", hidden)
isEnergyCardVisible("electricity", "energy-sources-table", prefs, hidden)
) {
mainCards.push({
title: hass.localize(
@@ -240,49 +222,50 @@ export class EnergyViewStrategy extends ReactiveElement {
});
}
// Only include if we have at least 1 device in the config.
if (prefs.device_consumption.length) {
if (
!isEnergyCardHidden(
"electricity",
"energy-devices-detail-graph",
hidden
)
) {
mainCards.push({
title: hass.localize(
"ui.panel.energy.cards.energy_devices_detail_graph_title"
),
type: "energy-devices-detail-graph",
collection_key: collectionKey,
grid_options: { columns: 36 },
});
}
if (!isEnergyCardHidden("electricity", "energy-devices-graph", hidden)) {
mainCards.push({
title: hass.localize(
"ui.panel.energy.cards.energy_devices_graph_title"
),
type: "energy-devices-graph",
collection_key: collectionKey,
grid_options: { columns: 36 },
});
}
if (!isEnergyCardHidden("electricity", "energy-sankey", hidden)) {
const showFloorsAndAreas = shouldShowFloorsAndAreas(
prefs.device_consumption,
hass,
(d) => d.stat_consumption
);
mainCards.push({
title: hass.localize("ui.panel.energy.cards.energy_sankey_title"),
type: "energy-sankey",
collection_key: collectionKey,
group_by_floor: showFloorsAndAreas,
group_by_area: showFloorsAndAreas,
grid_options: { columns: 36 },
});
}
// Device cards: each only included if we have at least 1 device configured.
if (
isEnergyCardVisible(
"electricity",
"energy-devices-detail-graph",
prefs,
hidden
)
) {
mainCards.push({
title: hass.localize(
"ui.panel.energy.cards.energy_devices_detail_graph_title"
),
type: "energy-devices-detail-graph",
collection_key: collectionKey,
grid_options: { columns: 36 },
});
}
if (
isEnergyCardVisible("electricity", "energy-devices-graph", prefs, hidden)
) {
mainCards.push({
title: hass.localize(
"ui.panel.energy.cards.energy_devices_graph_title"
),
type: "energy-devices-graph",
collection_key: collectionKey,
grid_options: { columns: 36 },
});
}
if (isEnergyCardVisible("electricity", "energy-sankey", prefs, hidden)) {
const showFloorsAndAreas = shouldShowFloorsAndAreas(
prefs.device_consumption,
hass,
(d) => d.stat_consumption
);
mainCards.push({
title: hass.localize("ui.panel.energy.cards.energy_sankey_title"),
type: "energy-sankey",
collection_key: collectionKey,
group_by_floor: showFloorsAndAreas,
group_by_area: showFloorsAndAreas,
grid_options: { columns: 36 },
});
}
view.sections!.push({
@@ -5,7 +5,7 @@ import type { HomeAssistant } from "../../../types";
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../constants";
import type { EnergyViewStrategyConfig } from "./energy-cards";
import { isEnergyCardHidden } from "./energy-cards";
import { hasGasSource, isEnergyCardVisible } from "./energy-cards";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
import type { LovelaceStrategyDependency } from "../../lovelace/strategies/types";
@@ -43,12 +43,8 @@ export class GasViewStrategy extends ReactiveElement {
}
const prefs = energyCollection.prefs;
const hasGasSources = prefs?.energy_sources.some(
(source) => source.type === "gas"
);
// No gas sources available
if (!prefs || !hasGasSources) {
if (!prefs || !hasGasSource(prefs)) {
return view;
}
@@ -62,7 +58,7 @@ export class GasViewStrategy extends ReactiveElement {
},
});
if (!isEnergyCardHidden("gas", "energy-gas-graph", hidden)) {
if (isEnergyCardVisible("gas", "energy-gas-graph", prefs, hidden)) {
section.cards!.push({
title: hass.localize("ui.panel.energy.cards.energy_gas_graph_title"),
type: "energy-gas-graph",
@@ -73,7 +69,7 @@ export class GasViewStrategy extends ReactiveElement {
});
}
if (!isEnergyCardHidden("gas", "energy-sources-table", hidden)) {
if (isEnergyCardVisible("gas", "energy-sources-table", prefs, hidden)) {
section.cards!.push({
title: hass.localize(
"ui.panel.energy.cards.energy_sources_table_title"
@@ -5,7 +5,14 @@ import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../types";
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../constants";
import type { EnergyViewStrategyConfig } from "./energy-cards";
import { isEnergyCardHidden } from "./energy-cards";
import {
hasGasRateSource,
hasPowerDevices,
hasPowerSources,
hasWaterRateDevices,
hasWaterRateSource,
isEnergyCardVisible,
} from "./energy-cards";
import { shouldShowFloorsAndAreas } from "./show-floors-and-areas";
import type { LovelaceSectionConfig } from "../../../data/lovelace/config/section";
import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
@@ -31,27 +38,6 @@ export class PowerViewStrategy extends ReactiveElement {
}
const prefs = energyCollection.prefs;
const hasPowerSources = prefs?.energy_sources.some((source) => {
if (source.type === "solar" && source.stat_rate) return true;
if (source.type === "battery" && source.stat_rate) return true;
if (source.type === "grid") {
return !!source.stat_rate || !!source.power_config;
}
return false;
});
const hasPowerDevices = prefs?.device_consumption.some(
(device) => device.stat_rate
);
const hasWaterDevices = prefs?.device_consumption_water.some(
(device) => device.stat_rate
);
const hasWaterSources = prefs?.energy_sources.some(
(source) => source.type === "water" && source.stat_rate
);
const hasGasSources = prefs?.energy_sources.some(
(source) => source.type === "gas" && source.stat_rate
);
const chartsSection: LovelaceSectionConfig = {
type: "grid",
cards: [],
@@ -63,46 +49,50 @@ export class PowerViewStrategy extends ReactiveElement {
sections: [chartsSection],
};
const hasPowerSrc = !!prefs && hasPowerSources(prefs);
const hasPowerDev = !!prefs && hasPowerDevices(prefs);
const hasWaterDev = !!prefs && hasWaterRateDevices(prefs);
const hasWaterSrc = !!prefs && hasWaterRateSource(prefs);
const hasGasSrc = !!prefs && hasGasRateSource(prefs);
// No sources configured
if (
!prefs ||
(!hasPowerSources &&
!hasPowerDevices &&
!hasWaterDevices &&
!hasWaterSources &&
!hasGasSources)
(!hasPowerSrc &&
!hasPowerDev &&
!hasWaterDev &&
!hasWaterSrc &&
!hasGasSrc)
) {
return view;
}
if (hasPowerSources) {
if (hasPowerSrc) {
badges.push({
type: "power-total",
collection_key: collectionKey,
});
if (!isEnergyCardHidden("now", "power-sources-graph", hidden)) {
chartsSection.cards!.push({
title: hass.localize(
"ui.panel.energy.cards.power_sources_graph_title"
),
type: "power-sources-graph",
collection_key: collectionKey,
grid_options: {
columns: 36,
},
});
}
}
if (hasGasSources) {
if (isEnergyCardVisible("now", "power-sources-graph", prefs, hidden)) {
chartsSection.cards!.push({
title: hass.localize("ui.panel.energy.cards.power_sources_graph_title"),
type: "power-sources-graph",
collection_key: collectionKey,
grid_options: {
columns: 36,
},
});
}
if (hasGasSrc) {
badges.push({
type: "gas-total",
collection_key: collectionKey,
});
}
if (hasWaterSources) {
if (hasWaterSrc) {
badges.push({
type: "water-total",
collection_key: collectionKey,
@@ -118,7 +108,7 @@ export class PowerViewStrategy extends ReactiveElement {
}
});
if (hasPowerDevices && !isEnergyCardHidden("now", "power-sankey", hidden)) {
if (isEnergyCardVisible("now", "power-sankey", prefs, hidden)) {
const showFloorsAndAreas = shouldShowFloorsAndAreas(
prefs.device_consumption,
hass,
@@ -136,10 +126,7 @@ export class PowerViewStrategy extends ReactiveElement {
});
}
if (
hasWaterDevices &&
!isEnergyCardHidden("now", "water-flow-sankey", hidden)
) {
if (isEnergyCardVisible("now", "water-flow-sankey", prefs, hidden)) {
const showFloorsAndAreas = shouldShowFloorsAndAreas(
prefs.device_consumption_water,
hass,
@@ -7,7 +7,11 @@ import type { HomeAssistant } from "../../../types";
import type { LovelaceStrategyDependency } from "../../lovelace/strategies/types";
import { DEFAULT_ENERGY_COLLECTION_KEY } from "../constants";
import type { EnergyViewStrategyConfig } from "./energy-cards";
import { isEnergyCardHidden } from "./energy-cards";
import {
hasWaterDevices,
hasWaterSource,
isEnergyCardVisible,
} from "./energy-cards";
import { shouldShowFloorsAndAreas } from "./show-floors-and-areas";
@customElement("water-view-strategy")
@@ -44,13 +48,8 @@ export class WaterViewStrategy extends ReactiveElement {
}
const prefs = energyCollection.prefs;
const hasWaterSources = prefs?.energy_sources.some(
(source) => source.type === "water"
);
const hasWaterDevices = prefs?.device_consumption_water?.length;
// No water sources available
if (!prefs || (!hasWaterDevices && !hasWaterSources)) {
// No water sources or devices available
if (!prefs || (!hasWaterDevices(prefs) && !hasWaterSource(prefs))) {
return view;
}
@@ -64,39 +63,33 @@ export class WaterViewStrategy extends ReactiveElement {
},
});
if (hasWaterSources) {
if (!isEnergyCardHidden("water", "energy-water-graph", hidden)) {
section.cards!.push({
title: hass.localize(
"ui.panel.energy.cards.energy_water_graph_title"
),
type: "energy-water-graph",
collection_key: collectionKey,
grid_options: {
columns: 24,
},
});
}
if (!isEnergyCardHidden("water", "energy-sources-table", hidden)) {
section.cards!.push({
title: hass.localize(
"ui.panel.energy.cards.energy_sources_table_title"
),
type: "energy-sources-table",
collection_key: collectionKey,
types: ["water"],
grid_options: {
columns: 12,
},
});
}
if (isEnergyCardVisible("water", "energy-water-graph", prefs, hidden)) {
section.cards!.push({
title: hass.localize("ui.panel.energy.cards.energy_water_graph_title"),
type: "energy-water-graph",
collection_key: collectionKey,
grid_options: {
columns: 24,
},
});
}
if (isEnergyCardVisible("water", "energy-sources-table", prefs, hidden)) {
section.cards!.push({
title: hass.localize(
"ui.panel.energy.cards.energy_sources_table_title"
),
type: "energy-sources-table",
collection_key: collectionKey,
types: ["water"],
grid_options: {
columns: 12,
},
});
}
// Only include if we have at least 1 water device in the config.
if (
hasWaterDevices &&
!isEnergyCardHidden("water", "water-sankey", hidden)
) {
if (isEnergyCardVisible("water", "water-sankey", prefs, hidden)) {
const showFloorsAndAreas = shouldShowFloorsAndAreas(
prefs.device_consumption_water,
hass,
@@ -84,6 +84,10 @@ export class HuiEntityBadge extends LitElement implements LovelaceBadge {
};
}
public static getDefaultConfig(): Partial<EntityBadgeConfig> {
return DEFAULT_CONFIG;
}
@property({ attribute: false }) public hass?: HomeAssistant;
@state() protected _config?: EntityBadgeConfig;
@@ -1,6 +1,7 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { css, html, LitElement } from "lit";
import { property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { computeCssColor } from "../../../common/color/compute-color";
@@ -23,6 +24,8 @@ import type {
LovelaceCardFeatureContext,
} from "./types";
const OPTION_MIN_WIDTH = 30;
type NumericFavoriteEntity = HassEntity & {
attributes: HassEntity["attributes"] & {
current_position?: number;
@@ -93,6 +96,16 @@ export abstract class HuiNumericFavoriteCardFeatureBase<
private _subscribedConnection?: HomeAssistant["connection"];
private _resizeController = new ResizeController<number | undefined>(this, {
callback: (entries: { contentRect?: { width: number } }[]) => {
const width = entries[0]?.contentRect?.width;
if (!width) {
return undefined;
}
return Math.max(1, Math.floor(width / OPTION_MIN_WIDTH));
},
});
protected abstract get _definition(): NumericFavoriteCardFeatureDefinition<TEntity>;
protected get _stateObj(): TEntity | undefined {
@@ -301,7 +314,11 @@ export abstract class HuiNumericFavoriteCardFeatureBase<
return null;
}
const options = positions.map((position) => ({
const maxVisible = this._resizeController.value;
const visiblePositions =
maxVisible != null ? positions.slice(0, maxVisible) : positions;
const options = visiblePositions.map((position) => ({
value: String(position),
label: `${position}%`,
ariaLabel: hass.localize(this._definition.setPositionLabelKey, {
@@ -330,6 +347,13 @@ export abstract class HuiNumericFavoriteCardFeatureBase<
}
static get styles() {
return cardFeatureStyles;
return [
cardFeatureStyles,
css`
:host {
display: block;
}
`,
];
}
}
@@ -366,6 +366,42 @@ export function computeStatMidpoint(
return (start + end) / 2;
}
export interface UntrackedSplit {
/** Untracked consumption per timestamp, clamped to >= 0. */
positive: Record<number, number>;
/** Negative untracked per timestamp only timestamps where the raw value
* was below zero (tracked devices reported more than total consumption). */
negative: Record<number, number>;
}
/**
* Split untracked energy consumption into positive and negative parts per
* timestamp.
*
* Untracked is `used_total - sum(tracked device consumption)`. It can go
* negative when meters report at coarser resolution than device sensors
* (e.g. an integer-kWh grid meter vs fractional device sensors). The positive
* part is the genuine untracked consumption; the negative part is surfaced as
* a separate, toggleable series so users can hide it without losing it as a
* diagnostic signal.
*/
export function splitUntrackedConsumption(
usedTotal: Record<number, number>,
totalDeviceConsumption: Record<number, number>
): UntrackedSplit {
const positive: Record<number, number> = {};
const negative: Record<number, number> = {};
for (const time of Object.keys(usedTotal)) {
const ts = Number(time);
const raw = usedTotal[ts] - (totalDeviceConsumption[ts] || 0);
positive[ts] = Math.max(0, raw);
if (raw < 0) {
negative[ts] = raw;
}
}
return { positive, negative };
}
export function getCompareTransform(start: Date, compareStart?: Date) {
if (!compareStart) {
return (ts: Date) => ts;
@@ -24,6 +24,7 @@ import {
type EnergyDataPoint,
fillDataGapsAndRoundCaps,
getCompareTransform,
splitUntrackedConsumption,
} from "./common/energy-chart-options";
import { getEnergyColor } from "./common/color";
@@ -211,7 +212,7 @@ function processUntracked(
consumptionData,
trackY: (v: number) => void,
compare: boolean
): BarSeriesOption {
): { dataset: BarSeriesOption; negativeDataset: BarSeriesOption } {
const totalDeviceConsumption: Record<number, number> = {};
processedData.forEach((device) => {
@@ -223,7 +224,16 @@ function processUntracked(
const compareTransform = getCompareTransform(ctx.start, ctx.compareStart!);
const period = getSuggestedPeriod(ctx.start, ctx.end);
// Split untracked into its positive part (genuine untracked consumption) and
// its negative part (tracked devices over-reporting relative to the meter).
// The negative part becomes a separate, toggleable series.
const { positive, negative } = splitUntrackedConsumption(
consumptionData.used_total,
totalDeviceConsumption
);
const untrackedConsumption: BarSeriesOption["data"] = [];
const negativeUntracked: BarSeriesOption["data"] = [];
const sortedTimes = Object.keys(consumptionData.used_total).sort(
(a, b) => Number(a) - Number(b)
);
@@ -235,24 +245,30 @@ function processUntracked(
: 0;
sortedTimes.forEach((time) => {
const ts = Number(time);
const value =
consumptionData.used_total[time] - (totalDeviceConsumption[time] || 0);
const dataPoint: EnergyDataPoint = [ts + periodOffset, value, ts];
if (compare) {
dataPoint[0] = compareTransform(new Date(ts)).getTime() + periodOffset;
const x = compare
? compareTransform(new Date(ts)).getTime() + periodOffset
: ts + periodOffset;
untrackedConsumption.push([x, positive[ts], ts]);
trackY(positive[ts]);
if (ts in negative) {
negativeUntracked.push([x, negative[ts], ts]);
trackY(negative[ts]);
}
untrackedConsumption.push(dataPoint);
trackY(value);
});
// random id to always add untracked at the end
const order = ctx.untrackedOrder;
const dataset: BarSeriesOption = {
const stack = compare ? "devicesCompare" : "devices";
// The positive untracked and negative ("over-reported") series share styling
// and stack, differing only by id, name and data.
const makeDataset = (
id: string,
name: string,
data: BarSeriesOption["data"]
): BarSeriesOption => ({
type: "bar",
cursor: "default",
id: compare ? `compare-untracked-${order}` : `untracked-${order}`,
name: ctx.hass.localize(
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
),
id,
name,
itemStyle: {
borderColor: getEnergyColor(
computedStyle,
@@ -270,12 +286,44 @@ function processUntracked(
compare,
"--history-unknown-color"
),
data: untrackedConsumption,
stack: compare ? "devicesCompare" : "devices",
};
return dataset;
data,
stack,
});
const dataset = makeDataset(
compare ? `compare-untracked-${order}` : `untracked-${order}`,
ctx.hass.localize(
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
),
untrackedConsumption
);
// Only added by the caller when it has data.
const negativeDataset = makeDataset(
compare
? `compare-untracked-negative-${order}`
: `untracked-negative-${order}`,
ctx.hass.localize(
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.over_reported_consumption"
),
negativeUntracked
);
return { dataset, negativeDataset };
}
// Legend item for an untracked series (positive or negative): not tied to an
// entity, so the label isn't clickable, and paired with its compare series.
const untrackedLegendItem = (
dataset: BarSeriesOption
): NonNullable<CustomLegendOption["data"]>[number] => ({
id: dataset.id as string,
secondaryIds: [`compare-${dataset.id}`],
name: dataset.name as string,
itemStyle: {
color: dataset.color as string,
borderColor: dataset.itemStyle?.borderColor as string,
},
noLabelClick: true,
});
/**
* Transforms an `EnergyData` collection update into the ECharts bar series and
* derived chart state for `hui-energy-devices-detail-graph-card`. Pure data
@@ -366,6 +414,7 @@ export function generateEnergyDevicesDetailGraphData(
? computeConsumptionData(summedData, compareSummedData)
: { consumption: undefined, compareConsumption: undefined };
let compareNegativeDataset: BarSeriesOption | undefined;
if (compareData) {
const processedCompareData = processDataSet(
ctx,
@@ -382,15 +431,22 @@ export function generateEnergyDevicesDetailGraphData(
datasets.push(...processedCompareData);
if (showUntracked) {
const untrackedCompareData = processUntracked(
ctx,
computedStyles,
processedCompareData,
consumptionCompareData,
trackY,
true
);
const { dataset: untrackedCompareData, negativeDataset } =
processUntracked(
ctx,
computedStyles,
processedCompareData,
consumptionCompareData,
trackY,
true
);
datasets.push(untrackedCompareData);
compareNegativeDataset = negativeDataset;
// Keep the compare negative series grouped with the other compare series
// (before the placeholder), mirroring the positive untracked placement.
if (negativeDataset.data?.length) {
datasets.push(negativeDataset);
}
}
}
@@ -430,7 +486,7 @@ export function generateEnergyDevicesDetailGraphData(
});
if (showUntracked) {
const untrackedData = processUntracked(
const { dataset: untrackedData, negativeDataset } = processUntracked(
ctx,
computedStyles,
processedData,
@@ -439,16 +495,20 @@ export function generateEnergyDevicesDetailGraphData(
false
);
datasets.push(untrackedData);
legendData.push({
id: untrackedData.id as string,
secondaryIds: [`compare-${untrackedData.id}`],
name: untrackedData.name as string,
itemStyle: {
color: untrackedData.color as string,
borderColor: untrackedData.itemStyle?.borderColor as string,
},
noLabelClick: true,
});
legendData.push(untrackedLegendItem(untrackedData));
// Only surface the negative untracked series (and its legend item) when
// either the main or compare period actually has negative values, so users
// with clean data don't get extra chart clutter. The compare series is
// pushed in the compare block above to keep series order consistent.
const hasNegative = !!negativeDataset.data?.length;
const hasCompareNegative = !!compareNegativeDataset?.data?.length;
if (hasNegative) {
datasets.push(negativeDataset);
}
if (hasNegative || hasCompareNegative) {
legendData.push(untrackedLegendItem(negativeDataset));
}
}
fillDataGapsAndRoundCaps(datasets);
+42 -35
View File
@@ -1,5 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
@@ -9,6 +9,10 @@ import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_elemen
import { stopPropagation } from "../../../common/dom/stop_propagation";
import { computeEntityUnitDisplay } from "../../../common/entity/compute_entity_unit_display";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import {
unitPosition,
valueFromParts,
} from "../../../common/entity/value_parts";
import {
stateColorBrightness,
stateColorCss,
@@ -119,15 +123,12 @@ export class HuiEntityCard extends LitElement implements LovelaceCard {
}
const domain = computeStateDomain(stateObj);
const customUnit = this._config.unit;
const unit = computeEntityUnitDisplay(this.hass, stateObj, this._config);
const stateParts = this.hass.formatEntityStateToParts(stateObj);
const indexUnit = stateParts.findIndex((part) => part.type === "unit");
const indexValue = stateParts.reduceRight(
(acc, part, i) => (acc === -1 && part.type === "value" ? i : acc),
-1
);
const reversedOrder = indexUnit !== -1 && indexUnit < indexValue;
// The unit is styled separately, so place it before or after the value
// following the locale's native order. A custom unit always trails.
const unitFirst = !customUnit && unitPosition(stateParts) === "before";
const name = this.hass.formatEntityName(stateObj, this._config.name);
@@ -172,33 +173,23 @@ export class HuiEntityCard extends LitElement implements LovelaceCard {
</div>
</div>
<div class="info">
<span
class=${classMap({
value: true,
"first-part": !reversedOrder,
})}
>${"attribute" in this._config
? stateObj.attributes[this._config.attribute!] !== undefined
? html`<ha-attribute-value
hide-unit
.stateObj=${stateObj}
.attribute=${this._config.attribute!}
>
</ha-attribute-value>`
: this.hass.localize("state.default.unknown")
: stateParts
.filter((part) => part.type === "value")
.map((part) => part.value)
.join("")}</span
>${unit
? html`<span
class=${classMap({
measurement: true,
"first-part": reversedOrder,
})}
>${unit}</span
>`
: nothing}
${"attribute" in this._config
? this._renderValueWithUnit(
stateObj.attributes[this._config.attribute!] !== undefined
? html`<ha-attribute-value
hide-unit
.stateObj=${stateObj}
.attribute=${this._config.attribute!}
></ha-attribute-value>`
: this.hass.localize("state.default.unknown"),
unit,
false
)
: this._renderValueWithUnit(
valueFromParts(stateParts),
unit,
unitFirst
)}
</div>
<div
class="footer"
@@ -214,6 +205,22 @@ export class HuiEntityCard extends LitElement implements LovelaceCard {
`;
}
private _renderValueWithUnit(
value: TemplateResult | string,
unit: string,
unitFirst: boolean
) {
return html`<span
class=${classMap({ value: true, "first-part": !unitFirst })}
>${value}</span
>${unit
? html`<span
class=${classMap({ measurement: true, "first-part": unitFirst })}
>${unit}</span
>`
: nothing}`;
}
private _computeColor(stateObj: HassEntity): string | undefined {
if (stateObj.attributes.hvac_action) {
const hvacAction = stateObj.attributes.hvac_action;
+8 -5
View File
@@ -6,7 +6,7 @@ import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { computeEntityUnitDisplay } from "../../../common/entity/compute_entity_unit_display";
import { valueFromParts } from "../../../common/entity/value_parts";
import { isValidEntityId } from "../../../common/entity/valid_entity_id";
import "../../../components/ha-card";
import "../../../components/ha-gauge";
@@ -115,7 +115,12 @@ class HuiGaugeCard extends LitElement implements LovelaceCard {
} else {
parts = this.hass.formatEntityStateToParts(stateObj);
}
const valueToDisplay = parts.find((part) => part.type === "value")?.value;
const customUnit = this._config.unit;
// Custom unit can't keep a locale position, so append it at the end;
// otherwise render natively.
const valueToDisplay = customUnit
? valueFromParts(parts)
: parts.map((part) => part.value).join("");
const value = this._config.attribute
? stateObj.attributes[this._config.attribute]
: stateObj.state;
@@ -134,8 +139,6 @@ class HuiGaugeCard extends LitElement implements LovelaceCard {
}
const name = this.hass.formatEntityName(stateObj, this._config.name);
const unit =
computeEntityUnitDisplay(this.hass, stateObj, this._config) ?? "";
return html`
<ha-card
@@ -159,7 +162,7 @@ class HuiGaugeCard extends LitElement implements LovelaceCard {
.value=${value}
.valueText=${valueToDisplay}
.locale=${this.hass!.locale}
.label=${unit}
.label=${customUnit ?? ""}
style=${styleMap({
"--gauge-color": this._computeSeverity(Number(value)),
})}
@@ -53,6 +53,15 @@ class HuiPictureEntityCard extends LitElement implements LovelaceCard {
};
}
public static getDefaultConfig(): Partial<PictureEntityCardConfig> {
return {
show_name: true,
show_state: true,
camera_view: "auto",
fit_mode: "cover",
};
}
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public layout?: string;
@@ -12,6 +12,7 @@ import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_elemen
import { fireEvent } from "../../../common/dom/fire_event";
import { batteryLevelIcon } from "../../../common/entity/battery_icon";
import { batteryStateColorProperty } from "../../../common/entity/color/battery_color";
import { valueFromParts } from "../../../common/entity/value_parts";
import "../../../components/ha-card";
import "../../../components/ha-svg-icon";
import { computeCssVariable } from "../../../resources/css-variables";
@@ -258,10 +259,9 @@ class HuiPlantStatusCard extends LitElement implements LovelaceCard {
? this.hass!.states[sensorEntityId]
: undefined;
if (sensorStateObj) {
return this.hass!.formatEntityStateToParts(sensorStateObj)
.filter((part) => part.type !== "unit")
.map((part) => part.value)
.join("");
return valueFromParts(
this.hass!.formatEntityStateToParts(sensorStateObj)
);
}
return stateObj.attributes[attribute] ?? "";
}
@@ -73,6 +73,12 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
};
}
public static getDefaultConfig(): Partial<TileCardConfig> {
return {
features_position: "bottom",
};
}
@property({ attribute: false }) public hass?: HomeAssistant;
@state() private _config?: TileCardConfig;
@@ -6,6 +6,7 @@ import { customElement, property, query, state } from "lit/decorators";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeRTLDirection } from "../../../../common/util/compute_rtl";
import { stripDefaults } from "../../../../common/util/strip-defaults";
import { withViewTransition } from "../../../../common/util/view-transition";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-footer";
@@ -32,6 +33,7 @@ import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
import "../../badges/hui-badge";
import "../../sections/hui-section";
import { addBadge, replaceBadge } from "../config-util";
import { getBadgeDefaultConfig } from "../get-badge-default-config";
import { getBadgeDocumentationURL } from "../get-dashboard-documentation-url";
import type { ConfigChangedEvent } from "../hui-element-editor";
import { findLovelaceContainer } from "../lovelace-path";
@@ -109,16 +111,21 @@ export class HuiDialogEditBadge
if (this._badgeConfig && !Object.isFrozen(this._badgeConfig)) {
this._badgeConfig = deepFreeze(this._badgeConfig);
}
const effectiveDefaults = this._badgeConfig?.type
? await getBadgeDefaultConfig(this._badgeConfig.type)
: undefined;
const normalize = (config: LovelaceBadgeConfig) =>
stripDefaults(config, effectiveDefaults);
if ("badgeConfig" in params && this._badgeConfig) {
this._initDirtyTracking({ type: "deep" }, { type: "" });
this._initDirtyTracking({ type: "deep" }, { type: "" }, normalize);
this._updateDirtyState(this._badgeConfig);
} else {
this._initDirtyTracking({ type: "deep" }, this._badgeConfig);
this._initDirtyTracking({ type: "deep" }, this._badgeConfig, normalize);
}
}
public closeDialog(): boolean {
if (this.isDirtyState) {
if (this.isEffectiveDirtyState) {
this._confirmCancel();
return false;
}
@@ -196,7 +203,7 @@ export class HuiDialogEditBadge
<ha-dialog
.open=${this._open}
.width=${this.large ? "full" : "large"}
.preventScrimClose=${this.isDirtyState}
.preventScrimClose=${this.isEffectiveDirtyState}
@keydown=${this._ignoreKeydown}
@closed=${this._dialogClosed}
@opened=${this._opened}
@@ -7,6 +7,7 @@ import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeRTLDirection } from "../../../../common/util/compute_rtl";
import { stripDefaults } from "../../../../common/util/strip-defaults";
import { withViewTransition } from "../../../../common/util/view-transition";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog";
@@ -33,6 +34,7 @@ import { showToast } from "../../../../util/toast";
import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
import "../../cards/hui-card";
import "../../sections/hui-section";
import { getCardDefaultConfig } from "../get-card-default-config";
import { getCardDocumentationURL } from "../get-dashboard-documentation-url";
import type { ConfigChangedEvent } from "../hui-element-editor";
import type { GUIModeChangedEvent } from "../types";
@@ -95,16 +97,21 @@ export class HuiDialogEditCard
if (this._cardConfig && !Object.isFrozen(this._cardConfig)) {
this._cardConfig = deepFreeze(this._cardConfig);
}
const effectiveDefaults = this._cardConfig?.type
? await getCardDefaultConfig(this._cardConfig.type)
: undefined;
const normalize = (config: LovelaceCardConfig) =>
stripDefaults(config, effectiveDefaults);
if (params.isNew && this._cardConfig) {
this._initDirtyTracking({ type: "deep" }, { type: "" });
this._initDirtyTracking({ type: "deep" }, { type: "" }, normalize);
this._updateDirtyState(this._cardConfig);
} else {
this._initDirtyTracking({ type: "deep" }, this._cardConfig);
this._initDirtyTracking({ type: "deep" }, this._cardConfig, normalize);
}
}
public closeDialog(): boolean {
if (this.isDirtyState) {
if (this.isEffectiveDirtyState) {
this._confirmCancel();
return false;
}
@@ -172,7 +179,7 @@ export class HuiDialogEditCard
<ha-dialog
.open=${this._open}
.width=${this.large ? "full" : "large"}
.preventScrimClose=${this.isDirtyState}
.preventScrimClose=${this.isEffectiveDirtyState}
@keydown=${this._ignoreKeydown}
@closed=${this._dialogClosed}
@opened=${this._opened}
@@ -1,4 +1,3 @@
import timezones from "google-timezones-json";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -26,6 +25,7 @@ import type { ClockCardConfig } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import { TimeFormat } from "../../../../data/translation";
import { getTimezoneOptions } from "../../../../components/ha-timezone-picker";
const cardConfigStruct = assign(
baseLovelaceCardConfig,
@@ -36,7 +36,7 @@ const cardConfigStruct = assign(
union([literal("small"), literal("medium"), literal("large")])
),
time_format: optional(enums(Object.values(TimeFormat))),
time_zone: optional(enums(Object.keys(timezones))),
time_zone: optional(enums(getTimezoneOptions().map((option) => option.id))),
show_seconds: optional(boolean()),
no_background: optional(boolean()),
// Analog clock options
@@ -0,0 +1,13 @@
import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
import { getBadgeElementClass } from "../create-element/create-badge-element";
export const getBadgeDefaultConfig = async (
type: string
): Promise<Partial<LovelaceBadgeConfig> | undefined> => {
try {
const elClass = await getBadgeElementClass(type);
return elClass?.getDefaultConfig?.();
} catch (_err) {
return undefined;
}
};
@@ -0,0 +1,13 @@
import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import { getCardElementClass } from "../create-element/create-card-element";
export const getCardDefaultConfig = async (
type: string
): Promise<Partial<LovelaceCardConfig> | undefined> => {
try {
const elClass = await getCardElementClass(type);
return elClass?.getDefaultConfig?.();
} catch (_err) {
return undefined;
}
};
+2
View File
@@ -96,6 +96,7 @@ export interface LovelaceCardConstructor extends Constructor<LovelaceCard> {
entities: string[],
entitiesFallback: string[]
) => LovelaceCardConfig;
getDefaultConfig?: () => Partial<LovelaceCardConfig>;
getConfigElement?: () => LovelaceCardEditor;
getConfigForm?: () => LovelaceConfigForm;
}
@@ -106,6 +107,7 @@ export interface LovelaceBadgeConstructor extends Constructor<LovelaceBadge> {
entities: string[],
entitiesFallback: string[]
) => LovelaceBadgeConfig;
getDefaultConfig?: () => Partial<LovelaceBadgeConfig>;
getConfigElement?: () => LovelaceBadgeEditor;
getConfigForm?: () => LovelaceConfigForm;
}
@@ -32,7 +32,6 @@ export const maintenanceEntityFilters: EntityFilter[] = [
{
domain: "binary_sensor",
device_class: ["battery"],
entity_category: "none",
},
];
+8 -10
View File
@@ -444,6 +444,9 @@
"loading": "Loading",
"refresh": "Refresh",
"cancel": "Cancel",
"upload_file_too_large": "Uploaded file {name} is too large",
"upload_image_too_large": "Uploaded image {name} is too large",
"unknown_error": "Unknown error",
"delete": "Delete",
"delete_all": "Delete all",
"download": "Download",
@@ -1920,12 +1923,6 @@
"enable_entity": "Enable",
"open_device_settings": "Open device settings",
"device_name_tip": "Consider renaming the device instead to update all its entities at once. {link}",
"entity_type": "Entity type",
"main_entity": "Main entity",
"main_entity_description": "Represents the device.",
"additional_entity_description": "Has its own name, shown with the device name.",
"additional_entity": "Additional entity",
"entity_name": "Entity name",
"switch_as_x_confirm": "This switch will be hidden and a new {domain} will be added. Your existing configurations using the switch will continue to work.",
"switch_as_x_remove_confirm": "This {domain} will be removed and the original switch will be visible again. Your existing configurations using the {domain} will no longer work!",
"switch_as_x_change_confirm": "This {domain_1} will be removed and will be replaced by a new {domain_2}. Your existing configurations using the {domain_1} will no longer work!",
@@ -1948,7 +1945,6 @@
"use_device_area": "Use device area",
"change_device_settings": "You can {link} in the device settings",
"change_device_area_link": "change the device area",
"change_device_name_link": "change the device name",
"configure_state": "{integration} options",
"configure_state_secondary": "Specific settings for {integration}",
"stream": {
@@ -5356,7 +5352,7 @@
"entity": "Entity with timestamp",
"offset_by": "offset by {offset}",
"mode": "Mode",
"weekday": "Days of the Week",
"weekday": "Days of the week",
"weekdays": {
"mon": "[%key:ui::weekdays::monday%]",
"tue": "[%key:ui::weekdays::tuesday%]",
@@ -5534,7 +5530,7 @@
"description": {
"picker": "Tests if an entity (or attribute) is in a specific state.",
"no_entity": "If state confirmed",
"full": "If{hasAttribute, select, \n true { {attribute} of}\n other {}\n} {numberOfEntities, plural,\n =0 {an entity is}\n one {{entities} is}\n other {{entities} are}\n} {numberOfStates, plural,\n =0 {a state}\n other {{states}}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n }"
"full": "If{hasAttribute, select, \n true { {attribute} of}\n other {}\n} {numberOfEntities, plural,\n =0 {an entity is}\n one {{entities} is}\n other {{entities} {matchAny, select,\n true {is}\n other {are}\n}}\n} {numberOfStates, plural,\n =0 {a state}\n other {{states}}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n }"
}
},
"sun": {
@@ -5567,7 +5563,7 @@
"label": "[%key:ui::panel::config::automation::editor::triggers::type::time::label%]",
"after": "After",
"before": "Before",
"weekday": "Weekdays",
"weekday": "Days of the week",
"mode_after": "[%key:ui::panel::config::automation::editor::conditions::type::time::after%]",
"mode_before": "[%key:ui::panel::config::automation::editor::conditions::type::time::before%]",
"weekdays": {
@@ -7496,6 +7492,7 @@
"provisioned_devices": "Provisioned devices",
"provisioned_count": "{count} {count, plural,\n one {provisioned device}\n other {provisioned devices}\n}",
"not_included": "{count} not included",
"not_ready": "{count} not ready",
"devices_offline": "{count} offline",
"rebuild_routes_description": "Rebuilding routes creates heavy traffic and may degrade performance for minutes to hours",
"rebuild_routes_action": "Rebuild",
@@ -8800,6 +8797,7 @@
},
"energy_devices_detail_graph": {
"untracked_consumption": "Untracked consumption",
"over_reported_consumption": "Over-reported consumption",
"untracked": "untracked",
"other": "Other"
},
@@ -3,6 +3,7 @@ import { assert, beforeEach, describe, expect, it } from "vitest";
import {
computeStateDisplay,
computeStateDisplayFromEntityAttributes,
computeStateToParts,
} from "../../../src/common/entity/compute_state_display";
import { UNKNOWN } from "../../../src/data/entity/entity";
import type { EntityRegistryDisplayEntry } from "../../../src/data/entity/entity_registry";
@@ -572,6 +573,34 @@ describe("computeStateDisplayFromEntityAttributes with numeric device classes",
}).format(-12);
expect(result).toBe(expected);
});
it("Splits a negative monetary value into multiple value parts", () => {
const parts = computeStateToParts(
// eslint-disable-next-line @typescript-eslint/no-empty-function
(() => {}) as any,
{
entity_id: "sensor.balance",
state: "-12.95",
attributes: {
device_class: "monetary",
unit_of_measurement: "USD",
},
} as any,
{
language: "en",
} as FrontendLocaleData,
{} as HassConfig,
{} as any
);
const valueParts = parts.filter((part) => part.type === "value");
// The currency symbol splits the number, so the minus sign ends up in a
// separate value part from the digits. Taking only the first value part
// (as the gauge card did) would drop everything but the sign.
expect(valueParts.length).toBeGreaterThan(1);
expect(valueParts.map((part) => part.value).join("")).toBe("-12.95");
});
});
describe("computeStateDisplayFromEntityAttributes datetime device calss", () => {
+125
View File
@@ -0,0 +1,125 @@
import { describe, expect, it } from "vitest";
import { computeStateToParts } from "../../../src/common/entity/compute_state_display";
import {
unitFromParts,
unitPosition,
valueFromParts,
} from "../../../src/common/entity/value_parts";
import type { FrontendLocaleData } from "../../../src/data/translation";
import {
DateFormat,
FirstWeekday,
NumberFormat,
TimeFormat,
TimeZone,
} from "../../../src/data/translation";
import { demoConfig } from "../../../src/fake_data/demo_config";
import type { ValuePart } from "../../../src/types";
describe("valueFromParts", () => {
it("keeps the sign and drops the unit when the symbol leads", () => {
const parts: ValuePart[] = [
{ type: "value", value: "-" },
{ type: "unit", value: "$" },
{ type: "value", value: "12.00" },
];
expect(valueFromParts(parts)).toBe("-12.00");
});
it("keeps the sign and drops the unit when the symbol trails", () => {
const parts: ValuePart[] = [
{ type: "value", value: "-12,00" },
{ type: "literal", value: " " },
{ type: "unit", value: "€" },
];
expect(valueFromParts(parts)).toBe("-12,00");
});
it("trims the literal left behind by a leading unit", () => {
const parts: ValuePart[] = [
{ type: "unit", value: "€" },
{ type: "literal", value: " " },
{ type: "value", value: "-12,00" },
];
expect(valueFromParts(parts)).toBe("-12,00");
});
it("returns the value unchanged when there is no unit", () => {
const parts: ValuePart[] = [{ type: "value", value: "21" }];
expect(valueFromParts(parts)).toBe("21");
});
});
describe("unitFromParts", () => {
it("returns the unit value", () => {
const parts: ValuePart[] = [
{ type: "value", value: "21" },
{ type: "literal", value: " " },
{ type: "unit", value: "°C" },
];
expect(unitFromParts(parts)).toBe("°C");
});
it("returns an empty string when there is no unit", () => {
const parts: ValuePart[] = [{ type: "value", value: "21" }];
expect(unitFromParts(parts)).toBe("");
});
});
describe("unitPosition", () => {
it("is before when the symbol sits between the sign and digits ($-12)", () => {
const parts: ValuePart[] = [
{ type: "value", value: "-" },
{ type: "unit", value: "$" },
{ type: "value", value: "12.00" },
];
expect(unitPosition(parts)).toBe("before");
});
it("is before when the symbol leads (€ -12)", () => {
const parts: ValuePart[] = [
{ type: "unit", value: "€" },
{ type: "literal", value: " " },
{ type: "value", value: "-12,00" },
];
expect(unitPosition(parts)).toBe("before");
});
it("is after when the symbol trails (-12 €)", () => {
const parts: ValuePart[] = [
{ type: "value", value: "-12,00" },
{ type: "literal", value: " " },
{ type: "unit", value: "€" },
];
expect(unitPosition(parts)).toBe("after");
});
});
describe("negative monetary parts", () => {
const localize = ((message: string) => message) as any;
const localeData: FrontendLocaleData = {
language: "en",
number_format: NumberFormat.comma_decimal,
time_format: TimeFormat.am_pm,
date_format: DateFormat.language,
time_zone: TimeZone.local,
first_weekday: FirstWeekday.language,
};
const stateObj: any = {
entity_id: "sensor.balance",
state: "-12",
attributes: { device_class: "monetary", unit_of_measurement: "USD" },
};
it("keep the sign with the value and expose the symbol as the unit", () => {
const parts = computeStateToParts(
localize,
stateObj,
localeData,
demoConfig,
{}
);
expect(valueFromParts(parts)).toBe("-12.00");
expect(unitFromParts(parts)).toBe("$");
});
});
+178
View File
@@ -0,0 +1,178 @@
import { describe, expect, it } from "vitest";
import { deepEqual } from "../../../src/common/util/deep-equal";
import { stripDefaults } from "../../../src/common/util/strip-defaults";
describe("stripDefaults", () => {
describe("without a defaults map (default is false)", () => {
it("removes keys whose value is false", () => {
expect(stripDefaults({ a: 1, b: false })).toEqual({ a: 1 });
});
it("removes keys whose value is undefined", () => {
expect(stripDefaults({ a: 1, b: undefined })).toEqual({ a: 1 });
});
it("keeps other falsy values (0, empty string, null)", () => {
expect(stripDefaults({ a: 0, b: "", c: null })).toEqual({
a: 0,
b: "",
c: null,
});
});
it("keeps true and non-empty values", () => {
const obj = { a: true, b: "x", c: { d: 1 }, e: [1, 2] };
expect(stripDefaults(obj)).toEqual(obj);
});
it("does not recurse into nested objects", () => {
expect(stripDefaults({ a: { b: false } })).toEqual({ a: { b: false } });
});
it("returns non-plain-object values unchanged", () => {
expect(stripDefaults(undefined)).toBe(undefined);
expect(stripDefaults(null)).toBe(null);
expect(stripDefaults(5)).toBe(5);
expect(stripDefaults("x")).toBe("x");
const arr = [1, false, undefined];
expect(stripDefaults(arr)).toBe(arr);
});
});
describe("with a defaults map", () => {
it("drops a key that equals its default (default true)", () => {
expect(stripDefaults({ show_name: true }, { show_name: true })).toEqual(
{}
);
});
it("keeps a default-true key set to false (a real change)", () => {
expect(stripDefaults({ show_name: false }, { show_name: true })).toEqual({
show_name: false,
});
});
it("drops a non-boolean key that equals its default", () => {
expect(
stripDefaults(
{ features_position: "bottom" },
{ features_position: "bottom" }
)
).toEqual({});
});
it("keeps a non-boolean key that differs from its default", () => {
expect(
stripDefaults(
{ features_position: "inline" },
{ features_position: "bottom" }
)
).toEqual({ features_position: "inline" });
});
it("still drops false for keys absent from the map", () => {
expect(
stripDefaults({ vertical: false }, { features_position: "bottom" })
).toEqual({});
});
it("always drops undefined, even when the default is true", () => {
expect(
stripDefaults({ show_name: undefined }, { show_name: true })
).toEqual({});
});
});
});
// How the card/badge dialogs compare configs for the effective-dirty signal.
describe("effective dirty comparison", () => {
const effectivelyEqual = (
a: unknown,
b: unknown,
defaults?: Record<string, unknown>
) => deepEqual(stripDefaults(a, defaults), stripDefaults(b, defaults));
it("tile: baked defaults (incl. features_position) read as unchanged", () => {
const defaults = { features_position: "bottom" };
expect(
effectivelyEqual(
{ type: "tile", entity: "cover.curtain", features: [{ type: "f" }] },
{
type: "tile",
entity: "cover.curtain",
show_entity_picture: false,
vertical: false,
features: [{ type: "f" }],
features_position: "bottom",
},
defaults
)
).toBe(true);
});
it("tile: a real toggle (show_entity_picture: true) reads as changed", () => {
const defaults = { features_position: "bottom" };
expect(
effectivelyEqual(
{ type: "tile", entity: "cover.curtain", features: [{ type: "f" }] },
{
type: "tile",
entity: "cover.curtain",
show_entity_picture: true,
vertical: false,
features: [{ type: "f" }],
features_position: "bottom",
},
defaults
)
).toBe(false);
});
it("picture-entity: baked default-true toggles read as unchanged", () => {
const defaults = {
show_name: true,
show_state: true,
camera_view: "auto",
fit_mode: "cover",
};
expect(
effectivelyEqual(
{ type: "picture-entity", entity: "light.x", image: "x.png" },
{
type: "picture-entity",
entity: "light.x",
image: "x.png",
show_name: true,
show_state: true,
camera_view: "auto",
fit_mode: "cover",
},
defaults
)
).toBe(true);
});
it("picture-entity: turning off a default-true toggle reads as changed", () => {
const defaults = {
show_name: true,
show_state: true,
camera_view: "auto",
fit_mode: "cover",
};
expect(
effectivelyEqual(
{ type: "picture-entity", entity: "light.x", image: "x.png" },
{
type: "picture-entity",
entity: "light.x",
image: "x.png",
show_name: false,
show_state: true,
camera_view: "auto",
fit_mode: "cover",
},
defaults
)
).toBe(false);
});
});
+50
View File
@@ -0,0 +1,50 @@
import { describe, it, expect, afterEach } from "vitest";
import "../../src/components/ha-control-slider";
import type { HaControlSlider } from "../../src/components/ha-control-slider";
const createSlider = (props: Partial<HaControlSlider>): HaControlSlider => {
const el = document.createElement("ha-control-slider") as HaControlSlider;
Object.assign(el, props);
return el;
};
describe("ha-control-slider value mapping", () => {
afterEach(() => {
document.dir = "ltr";
});
it("maps a vertical slider bottom-to-top in LTR", () => {
const el = createSlider({ vertical: true, min: 0, max: 100 });
expect(el.valueToPercentage(100)).toBe(1);
expect(el.valueToPercentage(0)).toBe(0);
expect(el.percentageToValue(1)).toBe(100);
});
it("does not invert a vertical slider in RTL", () => {
document.dir = "rtl";
const el = createSlider({ vertical: true, min: 0, max: 100 });
// A vertical slider must ignore RTL: the top stays at the maximum.
expect(el.valueToPercentage(100)).toBe(1);
expect(el.percentageToValue(1)).toBe(100);
expect(el.percentageToValue(0)).toBe(0);
});
it("still mirrors a horizontal slider in RTL", () => {
document.dir = "rtl";
const el = createSlider({ vertical: false, min: 0, max: 100 });
expect(el.valueToPercentage(100)).toBe(0);
expect(el.percentageToValue(0)).toBe(100);
});
it("keeps an explicitly inverted vertical slider inverted in both directions", () => {
const el = createSlider({
vertical: true,
inverted: true,
min: 0,
max: 100,
});
expect(el.valueToPercentage(100)).toBe(0);
document.dir = "rtl";
expect(el.valueToPercentage(100)).toBe(0);
});
});
@@ -0,0 +1,38 @@
import { describe, it, expect } from "vitest";
import { getTimezoneOptions } from "../../src/components/ha-timezone-picker";
describe("getTimezoneOptions", () => {
const options = getTimezoneOptions();
it("includes the bare UTC zone so a UTC config is recognized", () => {
const utc = options.find((option) => option.id === "UTC");
expect(utc).toBeDefined();
expect(utc?.primary).toContain("UTC");
});
it("includes the Etc/UTC zone", () => {
expect(options.some((option) => option.id === "Etc/UTC")).toBe(true);
});
it("does not duplicate any zone id", () => {
const ids = options.map((option) => option.id);
expect(new Set(ids).size).toBe(ids.length);
});
it("keeps the zones from the source list", () => {
// A sanity check that the base list is still present alongside the additions.
expect(options.some((option) => option.id === "Europe/Amsterdam")).toBe(
true
);
expect(options.length).toBeGreaterThan(100);
});
it("corrects the invalid Asia/Yuzhno-Sakhalinsk id to Asia/Sakhalin", () => {
expect(
options.some((option) => option.id === "Asia/Yuzhno-Sakhalinsk")
).toBe(false);
const sakhalin = options.find((option) => option.id === "Asia/Sakhalin");
expect(sakhalin).toBeDefined();
expect(sakhalin?.secondary).toBe("Asia/Sakhalin");
});
});
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { isSupportedBackupFile } from "../../src/data/backup";
const makeFile = (name: string, type: string): File =>
new File([""], name, { type });
describe("isSupportedBackupFile", () => {
it("accepts a .tar with the correct MIME type", () => {
expect(
isSupportedBackupFile(makeFile("backup.tar", "application/x-tar"))
).toBe(true);
});
it("accepts a .tar when the browser reports no MIME type (Firefox on Windows)", () => {
expect(isSupportedBackupFile(makeFile("backup.tar", ""))).toBe(true);
});
it("accepts a .tar reported as a generic binary type", () => {
expect(
isSupportedBackupFile(makeFile("backup.tar", "application/octet-stream"))
).toBe(true);
});
it("accepts regardless of extension casing", () => {
expect(isSupportedBackupFile(makeFile("BACKUP.TAR", ""))).toBe(true);
});
it("rejects a non-tar file", () => {
expect(isSupportedBackupFile(makeFile("photo.png", "image/png"))).toBe(
false
);
});
});
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect } from "vitest";
import { formatSelectorValue } from "../../src/data/selector/format_selector_value";
import type { HomeAssistant } from "../../src/types";
// formatSelectorValue only touches hass for floor/area/entity/device
// selectors, none of which these tests exercise.
const hass = {} as HomeAssistant;
describe("formatSelectorValue", () => {
it("returns an empty string for nullish values", () => {
expect(formatSelectorValue(hass, null)).toBe("");
expect(formatSelectorValue(hass, undefined)).toBe("");
});
it("renders a plain text value", () => {
expect(formatSelectorValue(hass, "hello", { text: { type: "text" } })).toBe(
"hello"
);
});
it("applies prefix and suffix for text selectors", () => {
expect(
formatSelectorValue(hass, "5", {
text: { type: "text", prefix: "$", suffix: " each" },
})
).toBe("$5 each");
});
it("masks a password text value instead of revealing it", () => {
const result = formatSelectorValue(hass, "hunter2", {
text: { type: "password" },
});
expect(result).not.toContain("hunter2");
expect(result).toBe("••••••••");
});
it("masks every value of a multiple password selector", () => {
const result = formatSelectorValue(hass, ["one", "two"], {
text: { type: "password" },
});
expect(result).not.toContain("one");
expect(result).not.toContain("two");
expect(result).toBe("••••••••, ••••••••");
});
it("masks a nested password field in an object selector preview", () => {
const result = formatSelectorValue(
hass,
{ username: "admin", password: "hunter2" },
{
object: {
fields: {
username: { selector: { text: { type: "text" } } },
password: { selector: { text: { type: "password" } } },
},
},
}
);
expect(result).toContain("admin");
expect(result).not.toContain("hunter2");
expect(result).toContain("••••••••");
});
});
+7
View File
@@ -84,6 +84,12 @@ describe("image_upload", () => {
const file = new File([""], "image.png", { type: "image/png" });
const hass = {
fetchWithAuth: vi.fn().mockResolvedValue({ status: 413 }),
localize: vi.fn((key: string, values?: Record<string, string>) => {
if (key === "ui.common.upload_image_too_large") {
return `Uploaded image is too large (${values?.name})`;
}
return "Unknown error";
}),
} as unknown as HomeAssistant;
await expect(createImage(hass, file)).rejects.toThrow(
"Uploaded image is too large (image.png)"
@@ -94,6 +100,7 @@ describe("image_upload", () => {
const file = new File([""], "image.png", { type: "image/png" });
const hass = {
fetchWithAuth: vi.fn().mockResolvedValue({ status: 500 }),
localize: vi.fn(() => "Unknown error"),
} as unknown as HomeAssistant;
await expect(createImage(hass, file)).rejects.toThrow("Unknown error");
});
+56
View File
@@ -0,0 +1,56 @@
import { IntlMessageFormat } from "intl-messageformat";
import { describe, expect, it } from "vitest";
import en from "../../src/translations/en.json";
// The state condition summary string. Its verb must agree with how the entity
// list is joined: "and" (match "all") takes a plural verb, "or" (match "any")
// takes a singular verb in English.
const message = (en as any).ui.panel.config.automation.editor.conditions.type
.state.description.full;
const format = (values: Record<string, unknown>) =>
new IntlMessageFormat(message, "en").format(values) as string;
describe("state condition summary grammar", () => {
it("uses a singular verb for a single entity", () => {
expect(
format({
hasAttribute: "false",
numberOfEntities: 1,
matchAny: "false",
entities: "Light",
numberOfStates: 1,
states: "on",
hasDuration: "false",
})
).toBe("If Light is on");
});
it("uses a plural verb for multiple entities matched with all (and)", () => {
expect(
format({
hasAttribute: "false",
numberOfEntities: 2,
matchAny: "false",
entities: "A and B",
numberOfStates: 1,
states: "on",
hasDuration: "false",
})
).toBe("If A and B are on");
});
it("uses a singular verb for multiple entities matched with any (or)", () => {
expect(
format({
hasAttribute: "false",
numberOfEntities: 2,
matchAny: "true",
entities: "A or B",
numberOfStates: 1,
states: "on",
hasDuration: "false",
})
).toBe("If A or B is on");
});
});
@@ -0,0 +1,154 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
generateMetadataSuggestionTask,
processMetadataSuggestion,
} from "../../../../src/panels/config/common/suggest-metadata-ai";
import type {
MetadataSuggestionInclude,
MetadataSuggestionResult,
} from "../../../../src/panels/config/common/suggest-metadata-ai";
import type { GenDataTaskResult } from "../../../../src/data/ai_task";
import type { HomeAssistant } from "../../../../src/types";
const fetchCategories = vi.hoisted(() => vi.fn());
const fetchFloors = vi.hoisted(() => vi.fn());
const fetchLabels = vi.hoisted(() => vi.fn());
vi.mock(
"../../../../src/panels/config/common/suggest-metadata-helpers",
() => ({
fetchCategories,
fetchFloors,
fetchLabels,
})
);
const connection = {} as HomeAssistant["connection"];
const INCLUDE_ALL: MetadataSuggestionInclude = {
name: true,
description: true,
categories: true,
labels: true,
floor: true,
};
const generate = (include: MetadataSuggestionInclude) =>
generateMetadataSuggestionTask(
connection,
"en",
"automation",
{ alias: "Test" },
[],
include
);
describe("generateMetadataSuggestionTask", () => {
beforeEach(() => {
fetchCategories.mockResolvedValue({});
fetchFloors.mockResolvedValue({});
fetchLabels.mockResolvedValue({});
});
it("omits the category field when there are no categories", async () => {
const result = await generate(INCLUDE_ALL);
expect(result.task.structure?.category).toBeUndefined();
expect(result.task.instructions).not.toContain("a category");
});
it("includes the category field when categories exist", async () => {
fetchCategories.mockResolvedValue({ work: "Work" });
const result = await generate(INCLUDE_ALL);
expect(result.task.structure?.category).toEqual({
description: "The category of the automation",
required: false,
// The model is offered the category name, not its internal ID.
selector: { select: { options: [{ value: "Work", label: "Work" }] } },
});
expect(result.task.instructions).toContain("a category");
});
it("omits the floor field when there are no floors", async () => {
const result = await generate(INCLUDE_ALL);
expect(result.task.structure?.floor).toBeUndefined();
expect(result.task.instructions).not.toContain("a floor");
});
it("includes the floor field when floors exist", async () => {
fetchFloors.mockResolvedValue({
ground: { floor_id: "ground", name: "Ground floor" },
});
const result = await generate(INCLUDE_ALL);
expect(result.task.structure?.floor).toEqual({
description: "The floor of the automation",
required: false,
// The model is offered the floor name, not its internal ID.
selector: {
select: {
options: [{ value: "Ground floor", label: "Ground floor" }],
},
},
});
});
it("always includes the free-text labels field regardless of registries", async () => {
const result = await generate(INCLUDE_ALL);
expect(result.task.structure?.labels).toEqual({
description: "Labels for the automation",
required: false,
selector: { text: { multiple: true } },
});
});
});
describe("processMetadataSuggestion", () => {
beforeEach(() => {
fetchCategories.mockResolvedValue({});
fetchFloors.mockResolvedValue({});
fetchLabels.mockResolvedValue({});
});
const result = (
data: MetadataSuggestionResult
): GenDataTaskResult<MetadataSuggestionResult> => ({
conversation_id: "test",
data,
});
// The model is offered the category name, so the result carries a name that
// has to map back to the internal ID.
it("maps a category name from the model back to its ID", async () => {
fetchCategories.mockResolvedValue({ work: "Work" });
const processed = await processMetadataSuggestion(
connection,
"automation",
result({ category: "Work" }),
INCLUDE_ALL
);
expect(processed.category).toBe("work");
});
it("maps a floor name from the model back to its ID", async () => {
fetchFloors.mockResolvedValue({
ground: { floor_id: "ground", name: "Ground floor" },
});
const processed = await processMetadataSuggestion(
connection,
"automation",
result({ floor: "Ground floor" }),
INCLUDE_ALL
);
expect(processed.floor).toBe("ground");
});
});
@@ -7,7 +7,11 @@ import {
applicableEnergyCardKeys,
ENERGY_CARD_CATALOG,
energyCardKey,
hasEnergySource,
hasGasRateSource,
hasWaterRateSource,
isEnergyCardHidden,
isEnergyCardVisible,
isEnergyViewEmpty,
} from "../../../../src/panels/energy/strategies/energy-cards";
@@ -31,6 +35,16 @@ const GRID_RETURN = source({
const SOLAR = source({ type: "solar", stat_energy_from: "sensor.solar" });
const GAS = source({ type: "gas", stat_energy_from: "sensor.gas" });
const WATER = source({ type: "water", stat_energy_from: "sensor.water" });
const GAS_RATE = source({
type: "gas",
stat_energy_from: "sensor.gas",
stat_rate: "sensor.gas_rate",
});
const WATER_RATE = source({
type: "water",
stat_energy_from: "sensor.water",
stat_rate: "sensor.water_rate",
});
describe("energyCardKey", () => {
it("joins the view path and card type", () => {
@@ -128,3 +142,86 @@ describe("isEnergyViewEmpty", () => {
expect(isEnergyViewEmpty("gas", prefs, [])).toBe(false);
});
});
describe("source predicates", () => {
it("hasEnergySource matches grid/solar/battery sources only", () => {
expect(hasEnergySource(makePrefs({ energy_sources: [SOLAR] }))).toBe(true);
expect(hasEnergySource(makePrefs({ energy_sources: [GRID_RETURN] }))).toBe(
true
);
expect(hasEnergySource(makePrefs({ energy_sources: [GAS, WATER] }))).toBe(
false
);
});
it("hasWaterRateSource / hasGasRateSource require a rate statistic", () => {
expect(hasWaterRateSource(makePrefs({ energy_sources: [WATER] }))).toBe(
false
);
expect(
hasWaterRateSource(makePrefs({ energy_sources: [WATER_RATE] }))
).toBe(true);
expect(hasGasRateSource(makePrefs({ energy_sources: [GAS] }))).toBe(false);
expect(hasGasRateSource(makePrefs({ energy_sources: [GAS_RATE] }))).toBe(
true
);
});
});
describe("isEnergyCardVisible", () => {
const solarPrefs = makePrefs({ energy_sources: [SOLAR] });
it("is true when the card applies and is not hidden", () => {
expect(
isEnergyCardVisible(
"electricity",
"energy-solar-graph",
solarPrefs,
undefined
)
).toBe(true);
});
it("is false when the card applies but is hidden", () => {
expect(
isEnergyCardVisible("electricity", "energy-solar-graph", solarPrefs, [
"electricity.energy-solar-graph",
])
).toBe(false);
});
it("is false when the card does not apply to the preferences", () => {
// No solar source -> the solar graph never applies, hidden or not.
expect(
isEnergyCardVisible(
"electricity",
"energy-solar-graph",
makePrefs({ energy_sources: [GRID_RETURN] }),
undefined
)
).toBe(false);
});
it("is false for a card type that is not in the catalog for the view", () => {
expect(
isEnergyCardVisible("gas", "energy-solar-graph", solarPrefs, undefined)
).toBe(false);
});
it("equals isApplicable && !hidden for every catalog entry", () => {
// A config that exercises every source type, so many cards apply.
const richPrefs = makePrefs({
energy_sources: [GRID_RETURN, SOLAR, GAS, WATER],
});
for (const card of ENERGY_CARD_CATALOG) {
const cardType = card.key.slice(card.view.length + 1);
expect(
isEnergyCardVisible(card.view, cardType, richPrefs, undefined)
).toBe(card.isApplicable(richPrefs));
// Hiding the card's own key always wins.
expect(
isEnergyCardVisible(card.view, cardType, richPrefs, [card.key])
).toBe(false);
}
});
});
@@ -7,6 +7,7 @@ import {
fillLineGaps,
getCompareTransform,
getSuggestedMax,
splitUntrackedConsumption,
} from "../../../../../../src/panels/lovelace/cards/energy/common/energy-chart-options";
// Helper to get x value from either [x,y] or {value: [x,y]} format
@@ -662,3 +663,76 @@ describe("computeStatMidpoint", () => {
);
});
});
describe("splitUntrackedConsumption", () => {
it("passes through positive untracked when grid exceeds devices", () => {
const usedTotal = { 1000: 5, 2000: 3 };
const deviceTotal = { 1000: 2, 2000: 1 };
const result = splitUntrackedConsumption(usedTotal, deviceTotal);
assert.deepEqual(result.positive, { 1000: 3, 2000: 2 });
assert.deepEqual(result.negative, {});
});
it("clamps positive to zero and records negatives separately", () => {
// Device sensors report more than the integer grid meter
const usedTotal = { 1000: 0, 2000: 1 };
const deviceTotal = { 1000: 0.3, 2000: 1.7 };
const result = splitUntrackedConsumption(usedTotal, deviceTotal);
assert.equal(result.positive[1000], 0);
assert.equal(result.positive[2000], 0);
assert.approximately(result.negative[1000], -0.3, 0.001);
assert.approximately(result.negative[2000], -0.7, 0.001);
});
it("treats grid equal to devices as zero with no negative", () => {
const usedTotal = { 1000: 2.5 };
const deviceTotal = { 1000: 2.5 };
const result = splitUntrackedConsumption(usedTotal, deviceTotal);
assert.equal(result.positive[1000], 0);
assert.deepEqual(result.negative, {});
});
it("returns full grid value when no device data exists for timestamp", () => {
const usedTotal = { 1000: 4 };
const deviceTotal = {};
const result = splitUntrackedConsumption(usedTotal, deviceTotal);
assert.equal(result.positive[1000], 4);
assert.deepEqual(result.negative, {});
});
it("ignores device timestamps not present in usedTotal", () => {
const usedTotal = { 1000: 2 };
const deviceTotal = { 1000: 1, 9999: 5 };
const result = splitUntrackedConsumption(usedTotal, deviceTotal);
assert.deepEqual(result.positive, { 1000: 1 });
assert.deepEqual(result.negative, {});
});
it("handles mixed positive and negative across timestamps", () => {
const usedTotal = { 1000: 0, 2000: 3, 3000: 1 };
const deviceTotal = { 1000: 0.5, 2000: 1, 3000: 2 };
const result = splitUntrackedConsumption(usedTotal, deviceTotal);
assert.equal(result.positive[1000], 0); // clamped
assert.equal(result.positive[2000], 2); // genuine untracked
assert.equal(result.positive[3000], 0); // clamped
assert.approximately(result.negative[1000], -0.5, 0.001);
assert.isUndefined(result.negative[2000]); // positive, not recorded
assert.approximately(result.negative[3000], -1, 0.001);
});
it("returns empty result for empty inputs", () => {
const result = splitUntrackedConsumption({}, {});
assert.deepEqual(result.positive, {});
assert.deepEqual(result.negative, {});
});
it("does not mutate input objects", () => {
const usedTotal = { 1000: 5 };
const deviceTotal = { 1000: 2 };
const usedCopy = { ...usedTotal };
const deviceCopy = { ...deviceTotal };
splitUntrackedConsumption(usedTotal, deviceTotal);
assert.deepEqual(usedTotal, usedCopy);
assert.deepEqual(deviceTotal, deviceCopy);
});
});
@@ -3,7 +3,7 @@
* devices-detail graph data transform. Do NOT update these snapshots to make
* an optimization pass see test/benchmarks/README.md.
*/
import { describe, expect, it } from "vitest";
import { assert, describe, expect, it } from "vitest";
import type {
DeviceConsumptionEnergyPreference,
EnergyPreferences,
@@ -163,4 +163,97 @@ describe("generateEnergyDevicesDetailGraphData", () => {
)
).toMatchSnapshot();
});
// 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.
describe("negative untracked series", () => {
const NEGATIVE_NAME =
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.over_reported_consumption";
const isNegativeSeries = (id: unknown) =>
typeof id === "string" && id.includes("untracked-negative");
it("omits the over-reported series when untracked is all positive", () => {
// Grid-only with no export keeps used_total >= 0, and the device
// references a stat with no data, so untracked == used_total >= 0.
const gridPrefs = generateEnergyPreferences({ grid: true });
const prefs: EnergyPreferences = {
...gridPrefs,
energy_sources: gridPrefs.energy_sources.map((s) =>
s.type === "grid" ? { ...s, stat_energy_to: null } : s
),
device_consumption: [
{ stat_consumption: "sensor.device_without_stats", name: "Phantom" },
],
};
const energyData = generateEnergyData(9, {
days: 1,
period: "hour",
prefs,
});
const result = generateEnergyDevicesDetailGraphData({
...baseParams,
energyData,
});
// No negative series and no extra legend item for clean data.
assert.isFalse(result.chartData.some((d) => isNegativeSeries(d.id)));
assert.isUndefined(
result.legendData?.find((l) => l.name === NEGATIVE_NAME)
);
// The positive untracked series still carries the genuine (>= 0) values.
const untracked = result.chartData.find(
(d) =>
typeof d.id === "string" &&
d.id.startsWith("untracked-") &&
!isNegativeSeries(d.id)
);
assert.exists(untracked);
const ys = (untracked!.data as any[]).map((p) => p.value?.[1] ?? p?.[1]);
assert.isTrue(ys.every((y) => y >= 0));
assert.isTrue(ys.some((y) => y > 0));
});
it("adds a toggleable over-reported series when negatives exist", () => {
// buildPrefs devices reference the source stats -> negative untracked.
const energyData = generateEnergyData(1, {
days: 1,
period: "hour",
prefs: buildPrefs(false),
});
const result = generateEnergyDevicesDetailGraphData({
...baseParams,
energyData,
});
const negative = result.chartData.find((d) => isNegativeSeries(d.id));
assert.exists(negative);
// It carries negative values and shares the devices stack.
const ys = (negative!.data as any[]).map((p) => p.value?.[1] ?? p?.[1]);
assert.isTrue(ys.some((y) => y < 0));
assert.equal(negative!.stack, "devices");
// The positive untracked is clamped to >= 0 (negatives moved to the
// separate series).
const untracked = result.chartData.find(
(d) =>
typeof d.id === "string" &&
d.id.startsWith("untracked-") &&
!isNegativeSeries(d.id)
);
const posYs = (untracked!.data as any[]).map(
(p) => p.value?.[1] ?? p?.[1]
);
assert.isTrue(posYs.every((y) => y >= 0));
// A dedicated, non-clickable legend item paired with the compare series.
const legend = result.legendData?.find((l) => l.name === NEGATIVE_NAME);
assert.exists(legend);
assert.equal(legend!.id, negative!.id);
assert.isTrue(legend!.noLabelClick);
assert.deepEqual(legend!.secondaryIds, [`compare-${negative!.id}`]);
});
});
});
+820 -652
View File
File diff suppressed because it is too large Load Diff