mirror of
https://github.com/home-assistant/frontend.git
synced 2026-06-20 23:31:35 +00:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a36a261fa | |||
| 1fab54831f | |||
| 4638582c6f | |||
| e5721fb134 | |||
| bfd8cb54c9 | |||
| 89bd1058df | |||
| 405727502f | |||
| dae105531f | |||
| 5f790a4977 | |||
| d6c16e0736 | |||
| c562f58326 | |||
| ce5640d13a | |||
| 6ddcc83638 | |||
| 396f495c9b | |||
| d994fd8928 | |||
| 21d8fda76d | |||
| 49716f4151 | |||
| 657bef6a75 | |||
| 9edd330728 | |||
| 09e83b6450 | |||
| 9c3f3ed05d | |||
| aec6c8c1e4 | |||
| 82f4ae1f08 | |||
| 2809091b44 | |||
| b2dda0f739 | |||
| d64845f206 | |||
| 44d929bf56 | |||
| 56cfff6922 | |||
| be8782d928 | |||
| 2eba8425a7 | |||
| 5ddc26df7a | |||
| 97516f5625 | |||
| e8c06b4220 |
@@ -502,6 +502,10 @@ const SCHEMAS: {
|
||||
},
|
||||
},
|
||||
},
|
||||
password: {
|
||||
label: "Password",
|
||||
selector: { text: { type: "password" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+7
-6
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
@@ -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,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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>`
|
||||
: ""}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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(", ");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
+13
-4
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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("$");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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("••••••••");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+4601
-81
File diff suppressed because it is too large
Load Diff
@@ -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}`]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user