Compare commits

...
Author SHA1 Message Date
Petar Petrov 55ba59074e Fix energy distribution home ring overflowing to 100% grid (#54191)
* Fix energy distribution home ring overflowing to 100% grid

Size the home circle from allocated used_solar, used_battery, and
used_grid instead of net used_total so misaligned hours cannot produce
a negative grid arc.

* Add gallery demo for energy distribution home ring

Mock the issue 54185 daily totals with realistic hourly PV, battery,
and grid statistics, including an export-lagged card that overflows
the old ring math.

* Remove gallery energy distribution demo from this PR

Keep the home-ring fix and its unit tests; drop the gallery page
that was only used for local browser verification.

* Keep a zero-consumption home ring as a plain border

When solar is configured but allocated flows are all zero, leave the
arcs unset so the card can show the home outline instead of zero-length
dashes on a borderless circle.

* Keep a defined zero low-carbon home arc

The energy distribution card mounts the home SVG when solar or
lowCarbon is defined. Omitting a 0 leftover hid grid and battery
arcs on a no-solar 100% high-carbon ring.
2026-09-16 15:49:29 +02:00
Maarten Lakerveld eeb674dd5a Show the selected light control on light group members (#54167)
* Show the selected light control on light group members

When a light group's more-info dialog is switched to color temperature, the
member tiles listed below now show a color temperature slider instead of
brightness, so the children can be adjusted on the same axis as the group.

The light control fires light-main-control-changed whenever its main control
changes, and more-info-content picks the member tile feature from that.
Members that do not support color temperature keep their brightness slider,
and the color mode keeps brightness since there is no tile feature for it.
Legacy group.* entities are unchanged.

* Hide member light control when group is in RGB color mode

There is no RGB tile feature, so showing a brightness slider on group
members while the group control is set to color is inconsistent. Show no
control in that case instead, per review feedback.
2026-09-16 15:44:11 +02:00
Jan-Philipp Benecke afe6e84e3c Make trace pages resizable two-pane layouts (#54216)
* Make trace pages resizable two-pane layouts

* Remove cast

* Remove cast
2026-09-16 16:06:56 +03:00
Bruno Pantaleão Gonçalves 3ddbc8f4e3 Communicate entity control to external bus (#54215)
* Communicate entity control to external bus

* Prefer target entity ids and report disconnecting calls

* Split and lowercase entity ids like Core does

* Keep entity control reporting best effort

* Assert wildcard entity ids are dropped
2026-09-16 15:25:32 +03:00
Jan-Philipp Benecke 216468cf50 Strip unreferenced duplicate trigger IDs (#54214) 2026-09-16 13:52:04 +02:00
14 changed files with 852 additions and 325 deletions
+19 -1
View File
@@ -1,4 +1,6 @@
import type { Context, HomeAssistant } from "../types";
import { ensureArray } from "../common/array/ensure-array";
import { isValidEntityId } from "../common/entity/valid_entity_id";
import type { Context, HomeAssistant, ServiceCallRequest } from "../types";
import type { Action } from "./script";
export const callExecuteScript = (
@@ -22,3 +24,19 @@ export const serviceCallWillDisconnect = (
"update.home_assistant_core_update",
"update.home_assistant_operating_system_update",
].includes(serviceData?.entity_id));
// Core merges the target into the service data, so a target entity_id
// replaces the legacy service data one rather than adding to it. Its schema
// also accepts comma separated ids and lowercases them.
export const getServiceCallEntityIds = (
serviceData?: ServiceCallRequest["serviceData"],
target?: ServiceCallRequest["target"]
): string[] => [
...new Set(
(ensureArray(target?.entity_id ?? serviceData?.entity_id) ?? [])
.filter((id): id is string => typeof id === "string")
.flatMap((id) => id.split(","))
.map((id) => id.trim().toLowerCase())
.filter(isValidEntityId)
),
];
@@ -11,6 +11,7 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
import { supportsFeature } from "../../../common/entity/supports-feature";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-attribute-icon";
@@ -85,12 +86,26 @@ class MoreInfoLight extends LitElement {
private _setMainControl(ev: any) {
ev.stopPropagation();
this._mainControl = ev.currentTarget.control;
this._changeMainControl(ev.currentTarget.control);
}
private _resetMainControl(ev: any) {
ev.stopPropagation();
this._mainControl = "brightness";
this._changeMainControl("brightness");
}
public connectedCallback(): void {
super.connectedCallback();
// A container that outlives this control (e.g. more-info-content when the
// dialog moves between entities) resyncs with the default control.
fireEvent(this, "light-main-control-changed", {
control: this._mainControl,
});
}
private _changeMainControl(control: MainControl) {
this._mainControl = control;
fireEvent(this, "light-main-control-changed", { control });
}
private get _stateOverride() {
@@ -399,4 +414,14 @@ declare global {
interface HTMLElementTagNameMap {
"more-info-light": MoreInfoLight;
}
interface HASSDomEvents {
"light-main-control-changed": { control: MainControl };
}
interface HTMLElementEventMap {
"light-main-control-changed": HASSDomEvent<
HASSDomEvents["light-main-control-changed"]
>;
}
}
+115 -84
View File
@@ -1,6 +1,7 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import { computeEntityName } from "../../common/entity/compute_entity_name";
@@ -11,6 +12,7 @@ import "../../components/ha-badge";
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
import { supportsCoverPositionCardFeature } from "../../panels/lovelace/card-features/hui-cover-position-card-feature";
import { supportsLightBrightnessCardFeature } from "../../panels/lovelace/card-features/hui-light-brightness-card-feature";
import { supportsLightColorTempCardFeature } from "../../panels/lovelace/card-features/hui-light-color-temp-card-feature";
import type { LovelaceCardFeatureConfig } from "../../panels/lovelace/card-features/types";
import type { TileCardConfig } from "../../panels/lovelace/cards/types";
import { importMoreInfoControl } from "../../panels/lovelace/custom-card-helpers";
@@ -25,6 +27,8 @@ interface EntityInfo {
deviceId: string | undefined;
}
type LightMainControl = HASSDomEvents["light-main-control-changed"]["control"];
@customElement("more-info-content")
class MoreInfoContent extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@@ -37,6 +41,17 @@ class MoreInfoContent extends LitElement {
@property({ attribute: false }) public data?: Record<string, any>;
// Mirrors the mode selected in the light control so group members
// show the matching slider.
@state() private _lightMainControl: LightMainControl = "brightness";
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.addEventListener("light-main-control-changed", (ev) => {
this._lightMainControl = ev.detail.control;
});
}
protected render() {
let moreInfoType: string | undefined;
@@ -73,7 +88,10 @@ class MoreInfoContent extends LitElement {
? html`
<hui-section
.hass=${this.hass}
.config=${this._entitiesSectionConfig(memberIds)}
.config=${this._entitiesSectionConfig(
memberIds,
this._lightMainControl
)}
>
</hui-section>
`
@@ -104,97 +122,110 @@ class MoreInfoContent extends LitElement {
}
);
private _entitiesSectionConfig = memoizeOne((entityIds: string[]) => {
const hass = this.hass!;
private _entitiesSectionConfig = memoizeOne(
(entityIds: string[], lightMainControl: LightMainControl) => {
const hass = this.hass!;
// Get entity names and areas for all visible entities
const entityInfos = entityIds
.map<EntityInfo | null>((entityId) => {
const entry = hass.entities[entityId];
if (entry?.hidden) {
return null;
}
const stateObj = hass.states[entityId];
if (!stateObj) {
return null;
}
const entityName = computeEntityName(
stateObj,
hass.entities,
hass.devices
);
const { area, device } = getEntityContext(
stateObj,
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const areaId = area?.area_id;
const deviceId = device?.id;
return { entityId, entityName, areaId, deviceId };
})
.filter(Boolean) as EntityInfo[];
// Get entity names and areas for all visible entities
const entityInfos = entityIds
.map<EntityInfo | null>((entityId) => {
const entry = hass.entities[entityId];
if (entry?.hidden) {
return null;
}
const stateObj = hass.states[entityId];
if (!stateObj) {
return null;
}
const entityName = computeEntityName(
stateObj,
hass.entities,
hass.devices
);
const { area, device } = getEntityContext(
stateObj,
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const areaId = area?.area_id;
const deviceId = device?.id;
return { entityId, entityName, areaId, deviceId };
})
.filter(Boolean) as EntityInfo[];
// Check if all entities have the same entity name
const entityNames = new Set(entityInfos.map((info) => info.entityName));
const allSameEntityName = entityNames.size === 1;
// Check if all entities have the same entity name
const entityNames = new Set(entityInfos.map((info) => info.entityName));
const allSameEntityName = entityNames.size === 1;
// Check if all entities have the same area
const areaIds = new Set(entityInfos.map((info) => info.areaId));
const allSameArea = areaIds.size === 1;
// Check if all entities have the same area
const areaIds = new Set(entityInfos.map((info) => info.areaId));
const allSameArea = areaIds.size === 1;
// Check if all entities belong to the same device
const deviceIds = new Set(entityInfos.map((info) => info.deviceId));
const allSameDevice = deviceIds.size === 1;
// Check if all entities belong to the same device
const deviceIds = new Set(entityInfos.map((info) => info.deviceId));
const allSameDevice = deviceIds.size === 1;
// Build name and state content config based on conditions. The device name
// is redundant when every member belongs to the same device, so omit it
// (and fall back to the entity name so the tile still has a label).
const name: EntityNameItem[] = [];
// Build name and state content config based on conditions. The device name
// is redundant when every member belongs to the same device, so omit it
// (and fall back to the entity name so the tile still has a label).
const name: EntityNameItem[] = [];
if (!allSameDevice) {
name.push({ type: "device" });
}
if (!allSameEntityName || allSameDevice) {
name.push({ type: "entity" });
}
const stateContent = ["state"];
if (!allSameArea) {
stateContent.push("area_name");
}
const cards = entityInfos.map(({ entityId }) => {
const features: LovelaceCardFeatureConfig[] = [];
const context = { entity_id: entityId };
if (supportsCoverPositionCardFeature(hass, context)) {
features.push({
type: "cover-position",
});
} else if (supportsLightBrightnessCardFeature(hass, context)) {
features.push({
type: "light-brightness",
});
if (!allSameDevice) {
name.push({ type: "device" });
}
return {
type: "tile",
entity: entityId,
name,
state_content: stateContent,
features_position: "inline",
features,
grid_options: { columns: 12 },
} as TileCardConfig;
});
if (!allSameEntityName || allSameDevice) {
name.push({ type: "entity" });
}
return {
type: "grid",
cards,
};
});
const stateContent = ["state"];
if (!allSameArea) {
stateContent.push("area_name");
}
const cards = entityInfos.map(({ entityId }) => {
const features: LovelaceCardFeatureConfig[] = [];
const context = { entity_id: entityId };
if (supportsCoverPositionCardFeature(hass, context)) {
features.push({
type: "cover-position",
});
} else if (lightMainControl === "color") {
// There is no RGB tile feature yet, so when the group is set to
// color the members show no control rather than a mismatched
// brightness slider.
} else if (
lightMainControl === "color_temp" &&
supportsLightColorTempCardFeature(hass, context)
) {
features.push({
type: "light-color-temp",
});
} else if (supportsLightBrightnessCardFeature(hass, context)) {
features.push({
type: "light-brightness",
});
}
return {
type: "tile",
entity: entityId,
name,
state_content: stateContent,
features_position: "inline",
features,
grid_options: { columns: 12 },
} as TileCardConfig;
});
return {
type: "grid",
cards,
};
}
);
static styles = css`
hui-section {
+10
View File
@@ -184,6 +184,15 @@ interface EMOutgoingMessageAddEntityTo extends EMMessage {
};
}
interface EMOutgoingMessageEntityControlled extends EMMessage {
type: "entity/controlled";
payload: {
entity_ids: string[];
domain: string;
service: string;
};
}
interface EMOutgoingMessageMoreInfoOpened extends EMMessage {
type: "more_info/opened";
payload: {
@@ -239,6 +248,7 @@ type EMOutgoingMessageWithoutAnswer =
| EMOutgoingMessageImprovScan
| EMOutgoingMessageImprovConfigureDevice
| EMOutgoingMessageAddEntityTo
| EMOutgoingMessageEntityControlled
| EMOutgoingMessageFocusElement
| EMOutgoingMessageReloadAndClearCache
| EMOutgoingMessageAssistSettings;
@@ -13,9 +13,13 @@ import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { fireEvent } from "../../../common/dom/fire_event";
import {
fireEvent,
type HASSDomTargetEvent,
} from "../../../common/dom/fire_event";
import { navigate, replaceCurrentUrl } from "../../../common/navigate";
import { computeRTL } from "../../../common/util/compute_rtl";
import { debounce } from "../../../common/util/debounce";
import "../../../components/ha-button";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
@@ -49,9 +53,14 @@ import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
import { fileDownload } from "../../../util/file_download";
import "../../../components/ha-trace-picker";
import "../../../components/ha-split-panel";
import type { HaSplitPanel } from "../../../components/ha-split-panel";
const TABS = ["details", "timeline", "logbook", "automation_config"] as const;
const STORAGE_KEY_SPLIT_POSITION = "automation-trace-split-position";
const DEFAULT_SPLIT_POSITION = 20;
@customElement("ha-automation-trace")
export class HaAutomationTrace extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -84,6 +93,8 @@ export class HaAutomationTrace extends LitElement {
@state() private _view: (typeof TABS)[number] | "blueprint" = "details";
@state() private _splitPosition = DEFAULT_SPLIT_POSITION;
@query("hat-script-graph") private _graph?: HatScriptGraph;
protected render(): TemplateResult {
@@ -91,10 +102,6 @@ export class HaAutomationTrace extends LitElement {
? this.hass.states[this._entityId]
: undefined;
const graph = this._graph;
const trackedNodes = graph?.trackedNodes;
const renderedNodes = graph?.renderedNodes;
const title = stateObj?.attributes.friendly_name || this._entityId;
let devButtons: TemplateResult | string = "";
@@ -240,94 +247,19 @@ export class HaAutomationTrace extends LitElement {
? ""
: html`
<div class="main">
<div class="graph">
<hat-script-graph
.trace=${this._trace}
.selected=${this._selected?.path}
@graph-node-selected=${this._pickNode}
></hat-script-graph>
</div>
<div class="info">
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
${TABS.map(
(view) => html`
<ha-tab-group-tab
slot="nav"
.active=${this._view === view}
.panel=${view}
${
this.narrow
? this._renderPanes()
: html`
<ha-split-panel
class="split"
.position=${this._splitPosition}
@wa-reposition=${this._splitRepositioned}
>
${this.hass!.localize(
`ui.panel.config.automation.trace.tabs.${view}`
)}
</ha-tab-group-tab>
${this._renderPanes()}
</ha-split-panel>
`
)}
${
this._trace.blueprint_inputs
? html`
<ha-tab-group-tab
slot="nav"
.active=${this._view === "blueprint"}
panel="blueprint"
>
${this.hass!.localize(
`ui.panel.config.automation.trace.tabs.blueprint_config`
)}
</ha-tab-group-tab>
`
: ""
}
</ha-tab-group>
${
this._selected === undefined ||
this._logbookEntries === undefined ||
trackedNodes === undefined
? nothing
: this._view === "details"
? html`
<ha-trace-path-details
.hass=${this.hass}
.narrow=${this.narrow}
.trace=${this._trace}
.selected=${this._selected}
.logbookEntries=${this._logbookEntries}
.trackedNodes=${trackedNodes}
.renderedNodes=${renderedNodes!}
></ha-trace-path-details>
`
: this._view === "automation_config"
? html`
<ha-trace-config
.trace=${this._trace}
></ha-trace-config>
`
: this._view === "logbook"
? html`
<ha-trace-logbook
.hass=${this.hass}
.narrow=${this.narrow}
.trace=${this._trace}
.logbookEntries=${this._logbookEntries}
></ha-trace-logbook>
`
: this._view === "blueprint"
? html`
<ha-trace-blueprint-config
.trace=${this._trace}
></ha-trace-blueprint-config>
`
: html`
<ha-trace-timeline
.hass=${this.hass}
.trace=${this._trace}
.logbookEntries=${this._logbookEntries}
.selected=${this._selected}
@value-changed=${this._timelinePathPicked}
></ha-trace-timeline>
`
}
</div>
}
</div>
`
}
@@ -335,12 +267,115 @@ export class HaAutomationTrace extends LitElement {
`;
}
private _renderPanes(): TemplateResult {
const graph = this._graph;
const trackedNodes = graph?.trackedNodes;
const renderedNodes = graph?.renderedNodes;
return html`
<div class="graph" slot="start">
<hat-script-graph
.trace=${this._trace}
.selected=${this._selected?.path}
@graph-node-selected=${this._pickNode}
></hat-script-graph>
</div>
<div class="info" slot="end">
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
${TABS.map(
(view) => html`
<ha-tab-group-tab
slot="nav"
.active=${this._view === view}
.panel=${view}
>
${this.hass!.localize(
`ui.panel.config.automation.trace.tabs.${view}`
)}
</ha-tab-group-tab>
`
)}
${
this._trace!.blueprint_inputs
? html`
<ha-tab-group-tab
slot="nav"
.active=${this._view === "blueprint"}
panel="blueprint"
>
${this.hass!.localize(
`ui.panel.config.automation.trace.tabs.blueprint_config`
)}
</ha-tab-group-tab>
`
: ""
}
</ha-tab-group>
${
this._selected === undefined ||
this._logbookEntries === undefined ||
trackedNodes === undefined
? nothing
: this._view === "details"
? html`
<ha-trace-path-details
.hass=${this.hass}
.narrow=${this.narrow}
.trace=${this._trace}
.selected=${this._selected}
.logbookEntries=${this._logbookEntries}
.trackedNodes=${trackedNodes}
.renderedNodes=${renderedNodes!}
></ha-trace-path-details>
`
: this._view === "automation_config"
? html`
<ha-trace-config .trace=${this._trace}></ha-trace-config>
`
: this._view === "logbook"
? html`
<ha-trace-logbook
.hass=${this.hass}
.narrow=${this.narrow}
.trace=${this._trace}
.logbookEntries=${this._logbookEntries}
></ha-trace-logbook>
`
: this._view === "blueprint"
? html`
<ha-trace-blueprint-config
.trace=${this._trace}
></ha-trace-blueprint-config>
`
: html`
<ha-trace-timeline
.hass=${this.hass}
.trace=${this._trace}
.logbookEntries=${this._logbookEntries}
.selected=${this._selected}
@value-changed=${this._timelinePathPicked}
></ha-trace-timeline>
`
}
</div>
`;
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.hass.loadBackendTranslation("triggers");
this.hass.loadBackendTranslation("conditions");
const storedPosition = localStorage?.[STORAGE_KEY_SPLIT_POSITION];
if (storedPosition) {
const parsed = parseFloat(storedPosition);
if (!isNaN(parsed) && parsed > 0 && parsed < 100) {
this._splitPosition = parsed;
}
}
if (!this.automationId) {
return;
}
@@ -428,6 +463,19 @@ export class HaAutomationTrace extends LitElement {
this._selected = ev.detail;
}
private _splitRepositioned(ev: HASSDomTargetEvent<HaSplitPanel>) {
this._splitPosition = ev.target.position;
this._storeSplitPosition();
}
private _storeSplitPosition = debounce(
() => {
localStorage[STORAGE_KEY_SPLIT_POSITION] = String(this._splitPosition);
},
500,
false
);
private _refreshTraces() {
this._loadTraces();
}
@@ -618,13 +666,22 @@ export class HaAutomationTrace extends LitElement {
padding: 16px;
}
ha-split-panel.split {
flex: 1;
min-height: 0;
min-width: 0;
--ha-split-panel-min: 10%;
--ha-split-panel-max: 80%;
--ha-split-panel-divider-hit-area: var(--ha-space-4);
}
.graph {
border-right: 1px solid var(--divider-color);
max-width: 50%;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
min-height: 0;
}
hat-script-graph {
flex: 1;
@@ -642,6 +699,8 @@ export class HaAutomationTrace extends LitElement {
}
.info {
flex: 1;
min-width: 0;
min-height: 0;
overflow-y: auto;
background-color: var(--card-background-color);
}
@@ -454,9 +454,11 @@ export const cleanupRemovedGeneratedTriggerReferences = (
};
/**
* Assign a fresh generated ID to every leaf sharing a stored ID, including manual IDs.
* Assign a fresh generated ID to every referenced leaf sharing a stored ID.
* Expand trigger-condition references to all replacements for the old ID, preserving
* their original "any of these triggers" meaning. Return the original config if IDs are unique.
* their original "any of these triggers" meaning. Duplicate IDs that no trigger
* condition references are stripped instead, so the fix does not create generated
* IDs nobody uses. Return the original config if IDs are unique.
* Templates and action data that inspect trigger.id directly are not rewritten;
* the controller's confirmation dialog warns about that limitation.
*/
@@ -470,6 +472,21 @@ export const makeDuplicateTriggerIdsUnique = (
return config;
}
const referencedIds = new Set<string>();
new AutomationTriggerConditionMapper((condition) =>
mapReferencedTriggerIds(condition, (id) => {
referencedIds.add(id);
return id;
})
).map(config);
const referencedDuplicates = new Set(
[...duplicates].filter((id) => referencedIds.has(id))
);
const unreferencedDuplicates = new Set(
[...duplicates].filter((id) => !referencedIds.has(id))
);
const reservedIds = new Set(ids.filter((id) => !duplicates.has(id)));
const generatedIds = new Set<string>();
const assignments = new Map<Trigger, string>();
@@ -479,7 +496,7 @@ export const makeDuplicateTriggerIdsUnique = (
// trigger its own ID, references to the old ID must expand to every new ID.
flattenTriggers(config.triggers).forEach((trigger) => {
const id = getTriggerId(trigger);
if (!id || !duplicates.has(id)) {
if (!id || !referencedDuplicates.has(id)) {
return;
}
const generatedId = getGeneratedTriggerId(reservedIds, generatedIds);
@@ -487,14 +504,32 @@ export const makeDuplicateTriggerIdsUnique = (
replacementIds.set(id, [...(replacementIds.get(id) || []), generatedId]);
});
const triggers = walkLeafTriggers(config.triggers, (trigger) => {
if (assignments.has(trigger)) {
return { ...trigger, id: assignments.get(trigger) };
}
if (isTriggerList(trigger)) {
return trigger;
}
const id = getTriggerId(trigger);
if (id && unreferencedDuplicates.has(id)) {
const { id: _id, ...rest } = trigger;
return rest as Trigger;
}
return trigger;
}) as Trigger | Trigger[];
if (!referencedDuplicates.size) {
return {
...config,
triggers,
};
}
return {
...new AutomationTriggerConditionMapper((condition) =>
mapReferencedTriggerIds(condition, (id) => replacementIds.get(id) ?? id)
).map(config),
triggers: walkLeafTriggers(config.triggers, (trigger) =>
assignments.has(trigger)
? { ...trigger, id: assignments.get(trigger) }
: trigger
) as Trigger | Trigger[],
triggers,
};
};
+154 -95
View File
@@ -13,8 +13,12 @@ import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { fireEvent } from "../../../common/dom/fire_event";
import {
fireEvent,
type HASSDomTargetEvent,
} from "../../../common/dom/fire_event";
import { navigate, replaceCurrentUrl } from "../../../common/navigate";
import { debounce } from "../../../common/util/debounce";
import "../../../components/ha-button";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
@@ -45,9 +49,14 @@ import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
import { fileDownload } from "../../../util/file_download";
import "../../../components/ha-trace-picker";
import "../../../components/ha-split-panel";
import type { HaSplitPanel } from "../../../components/ha-split-panel";
const TABS = ["details", "timeline", "logbook", "config"] as const;
const STORAGE_KEY_SPLIT_POSITION = "script-trace-split-position";
const DEFAULT_SPLIT_POSITION = 20;
@customElement("ha-script-trace")
export class HaScriptTrace extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -80,6 +89,8 @@ export class HaScriptTrace extends LitElement {
@state() private _view: (typeof TABS)[number] | "blueprint" = "details";
@state() private _splitPosition = DEFAULT_SPLIT_POSITION;
@query("hat-script-graph") private _graph?: HatScriptGraph;
protected render(): TemplateResult {
@@ -87,10 +98,6 @@ export class HaScriptTrace extends LitElement {
? this.hass.states[this._entityId]
: undefined;
const graph = this._graph;
const trackedNodes = graph?.trackedNodes;
const renderedNodes = graph?.renderedNodes;
const title = stateObj?.attributes.friendly_name || this._entityId;
let devButtons: TemplateResult | string = "";
@@ -220,96 +227,19 @@ export class HaScriptTrace extends LitElement {
? ""
: html`
<div class="main">
<div class="graph">
<hat-script-graph
.trace=${this._trace}
.selected=${this._selected?.path}
@graph-node-selected=${this._pickNode}
></hat-script-graph>
</div>
<div class="info">
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
${TABS.map(
(view) => html`
<ha-tab-group-tab
slot="nav"
.active=${this._view === view}
.panel=${view}
${
this.narrow
? this._renderPanes()
: html`
<ha-split-panel
class="split"
.position=${this._splitPosition}
@wa-reposition=${this._splitRepositioned}
>
${this.hass.localize(
`ui.panel.config.automation.trace.tabs.${
view === "config" ? "script_config" : view
}`
)}
</ha-tab-group-tab>
${this._renderPanes()}
</ha-split-panel>
`
)}
${
this._trace.blueprint_inputs
? html`
<ha-tab-group-tab
slot="nav"
.active=${this._view === "blueprint"}
panel="blueprint"
>
${this.hass!.localize(
`ui.panel.config.automation.trace.tabs.blueprint_config`
)}
</ha-tab-group-tab>
`
: ""
}
</ha-tab-group>
${
this._selected === undefined ||
this._logbookEntries === undefined ||
trackedNodes === undefined
? ""
: this._view === "details"
? html`
<ha-trace-path-details
.hass=${this.hass}
.narrow=${this.narrow}
.trace=${this._trace}
.selected=${this._selected}
.logbookEntries=${this._logbookEntries}
.trackedNodes=${trackedNodes}
.renderedNodes=${renderedNodes!}
></ha-trace-path-details>
`
: this._view === "config"
? html`
<ha-trace-config
.trace=${this._trace}
></ha-trace-config>
`
: this._view === "logbook"
? html`
<ha-trace-logbook
.hass=${this.hass}
.narrow=${this.narrow}
.trace=${this._trace}
.logbookEntries=${this._logbookEntries}
></ha-trace-logbook>
`
: this._view === "blueprint"
? html`
<ha-trace-blueprint-config
.trace=${this._trace}
></ha-trace-blueprint-config>
`
: html`
<ha-trace-timeline
.hass=${this.hass}
.trace=${this._trace}
.logbookEntries=${this._logbookEntries}
.selected=${this._selected}
@value-changed=${this._timelinePathPicked}
></ha-trace-timeline>
`
}
</div>
}
</div>
`
}
@@ -317,9 +247,114 @@ export class HaScriptTrace extends LitElement {
`;
}
private _renderPanes(): TemplateResult {
const graph = this._graph;
const trackedNodes = graph?.trackedNodes;
const renderedNodes = graph?.renderedNodes;
return html`
<div class="graph" slot="start">
<hat-script-graph
.trace=${this._trace}
.selected=${this._selected?.path}
@graph-node-selected=${this._pickNode}
></hat-script-graph>
</div>
<div class="info" slot="end">
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
${TABS.map(
(view) => html`
<ha-tab-group-tab
slot="nav"
.active=${this._view === view}
.panel=${view}
>
${this.hass.localize(
`ui.panel.config.automation.trace.tabs.${
view === "config" ? "script_config" : view
}`
)}
</ha-tab-group-tab>
`
)}
${
this._trace!.blueprint_inputs
? html`
<ha-tab-group-tab
slot="nav"
.active=${this._view === "blueprint"}
panel="blueprint"
>
${this.hass!.localize(
`ui.panel.config.automation.trace.tabs.blueprint_config`
)}
</ha-tab-group-tab>
`
: ""
}
</ha-tab-group>
${
this._selected === undefined ||
this._logbookEntries === undefined ||
trackedNodes === undefined
? ""
: this._view === "details"
? html`
<ha-trace-path-details
.hass=${this.hass}
.narrow=${this.narrow}
.trace=${this._trace}
.selected=${this._selected}
.logbookEntries=${this._logbookEntries}
.trackedNodes=${trackedNodes}
.renderedNodes=${renderedNodes!}
></ha-trace-path-details>
`
: this._view === "config"
? html`
<ha-trace-config .trace=${this._trace}></ha-trace-config>
`
: this._view === "logbook"
? html`
<ha-trace-logbook
.hass=${this.hass}
.narrow=${this.narrow}
.trace=${this._trace}
.logbookEntries=${this._logbookEntries}
></ha-trace-logbook>
`
: this._view === "blueprint"
? html`
<ha-trace-blueprint-config
.trace=${this._trace}
></ha-trace-blueprint-config>
`
: html`
<ha-trace-timeline
.hass=${this.hass}
.trace=${this._trace}
.logbookEntries=${this._logbookEntries}
.selected=${this._selected}
@value-changed=${this._timelinePathPicked}
></ha-trace-timeline>
`
}
</div>
`;
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
const storedPosition = localStorage?.[STORAGE_KEY_SPLIT_POSITION];
if (storedPosition) {
const parsed = parseFloat(storedPosition);
if (!isNaN(parsed) && parsed > 0 && parsed < 100) {
this._splitPosition = parsed;
}
}
if (!this.scriptId) {
return;
}
@@ -409,6 +444,19 @@ export class HaScriptTrace extends LitElement {
this._selected = ev.detail;
}
private _splitRepositioned(ev: HASSDomTargetEvent<HaSplitPanel>) {
this._splitPosition = ev.target.position;
this._storeSplitPosition();
}
private _storeSplitPosition = debounce(
() => {
localStorage[STORAGE_KEY_SPLIT_POSITION] = String(this._splitPosition);
},
500,
false
);
private _refreshTraces() {
this._loadTraces();
}
@@ -594,13 +642,22 @@ export class HaScriptTrace extends LitElement {
padding: 16px;
}
ha-split-panel.split {
flex: 1;
min-height: 0;
min-width: 0;
--ha-split-panel-min: 10%;
--ha-split-panel-max: 80%;
--ha-split-panel-divider-hit-area: var(--ha-space-4);
}
.graph {
border-right: 1px solid var(--divider-color);
max-width: 50%;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
min-width: 0;
min-height: 0;
}
hat-script-graph {
flex: 1;
@@ -618,6 +675,8 @@ export class HaScriptTrace extends LitElement {
}
.info {
flex: 1;
min-width: 0;
min-height: 0;
overflow-y: auto;
background-color: var(--card-background-color);
}
@@ -0,0 +1,73 @@
/**
* Home-circle stroke lengths for the energy distribution card.
*
* Hourly allocation can produce used_solar + used_battery + used_grid larger
* than net used_total when some hours export more than they produce. Dividing
* by net used_total then overflows the circle and the leftover grid arc goes
* negative, which browsers paint as a solid grid ring.
*
* These arcs use the sum of the allocated home flows as the denominator so
* they always fit the circumference.
*/
export const ENERGY_DISTRIBUTION_HOME_CIRCLE_CIRCUMFERENCE = 238.76104;
export interface EnergyDistributionHomeCircleArcs {
solar?: number;
battery?: number;
lowCarbon?: number;
highCarbon?: number;
grid?: number;
}
export const computeEnergyDistributionHomeCircleArcs = ({
usedSolar = 0,
usedBattery = 0,
usedGrid = 0,
hasSolar,
hasGrid,
highCarbonConsumption,
circumference = ENERGY_DISTRIBUTION_HOME_CIRCLE_CIRCUMFERENCE,
}: {
usedSolar?: number;
usedBattery?: number;
usedGrid?: number;
hasSolar: boolean;
hasGrid: boolean;
highCarbonConsumption?: number;
circumference?: number;
}): EnergyDistributionHomeCircleArcs => {
const solar = Math.max(usedSolar, 0);
const battery = Math.max(usedBattery, 0);
const grid = Math.max(usedGrid, 0);
const ringTotal = solar + battery + grid;
const arcs: EnergyDistributionHomeCircleArcs = {};
// Leave arcs unset so the card can fall back to the plain home border
// instead of painting zero-length dashes over a borderless circle.
if (ringTotal <= 0) {
return arcs;
}
const share = (value: number): number => circumference * (value / ringTotal);
if (hasSolar) {
arcs.solar = share(solar);
}
if (battery > 0) {
arcs.battery = share(battery);
}
if (hasGrid) {
if (highCarbonConsumption !== undefined) {
const highCarbon = Math.min(Math.max(highCarbonConsumption, 0), grid);
arcs.highCarbon = share(highCarbon);
// Keep 0 defined: the card mounts the home SVG when solar or
// lowCarbon is defined, not when the low-carbon stroke is painted.
arcs.lowCarbon = share(grid - highCarbon);
arcs.grid = arcs.highCarbon;
} else {
arcs.grid = share(grid);
}
}
return arcs;
};
@@ -37,8 +37,10 @@ import type { LovelaceCard } from "../../types";
import type { EnergyDistributionCardConfig } from "../types";
import { formatNumber } from "../../../../common/number/format_number";
import { round } from "../../../../common/number/round";
const CIRCLE_CIRCUMFERENCE = 238.76104;
import {
ENERGY_DISTRIBUTION_HOME_CIRCLE_CIRCUMFERENCE as CIRCLE_CIRCUMFERENCE,
computeEnergyDistributionHomeCircleArcs,
} from "./energy-distribution-home-circle";
// Flows are differences of sums; anything that rounds to 0 Wh is noise
const hasFlow = (value: number | null): value is number =>
@@ -321,22 +323,8 @@ class HuiEnergyDistrubutionCard
const totalHomeConsumption = Math.max(0, consumption.total.used_total);
let homeSolarCircumference: number | undefined;
if (hasSolarProduction) {
homeSolarCircumference =
CIRCLE_CIRCUMFERENCE * (solarConsumption! / totalHomeConsumption);
}
let homeBatteryCircumference: number | undefined;
if (batteryConsumption) {
homeBatteryCircumference =
CIRCLE_CIRCUMFERENCE * (batteryConsumption / totalHomeConsumption);
}
let lowCarbonEnergy: number | undefined;
let homeLowCarbonCircumference: number | undefined;
let homeHighCarbonCircumference: number | undefined;
let highCarbonConsumption: number | undefined;
// This fallback is used in the demo
let electricityMapUrl = "https://app.electricitymaps.com";
@@ -360,7 +348,6 @@ class HuiEnergyDistrubutionCard
if (highCarbonEnergy !== null) {
lowCarbonEnergy = totalFromGrid - highCarbonEnergy;
let highCarbonConsumption: number;
if (gridConsumption !== totalFromGrid) {
// Only get the part that was used for consumption and not the battery
highCarbonConsumption =
@@ -368,18 +355,23 @@ class HuiEnergyDistrubutionCard
} else {
highCarbonConsumption = highCarbonEnergy;
}
homeHighCarbonCircumference =
CIRCLE_CIRCUMFERENCE * (highCarbonConsumption / totalHomeConsumption);
homeLowCarbonCircumference =
CIRCLE_CIRCUMFERENCE -
(homeSolarCircumference || 0) -
(homeBatteryCircumference || 0) -
homeHighCarbonCircumference;
}
}
const {
solar: homeSolarCircumference,
battery: homeBatteryCircumference,
lowCarbon: homeLowCarbonCircumference,
grid: homeGridCircumference,
} = computeEnergyDistributionHomeCircleArcs({
usedSolar: solarConsumption ?? 0,
usedBattery: batteryConsumption ?? 0,
usedGrid: gridConsumption,
hasSolar: hasSolarProduction,
hasGrid: Boolean(hasGrid),
highCarbonConsumption,
});
const totalLines =
gridConsumption +
(solarConsumption || 0) +
@@ -670,16 +662,8 @@ class HuiEnergyDistrubutionCard
cx="40"
cy="40"
r="38"
stroke-dasharray="${
homeHighCarbonCircumference ??
CIRCLE_CIRCUMFERENCE -
homeSolarCircumference! -
(homeBatteryCircumference || 0)
} ${
homeHighCarbonCircumference !== undefined
? CIRCLE_CIRCUMFERENCE - homeHighCarbonCircumference
: homeSolarCircumference! +
(homeBatteryCircumference || 0)
stroke-dasharray="${homeGridCircumference} ${
CIRCLE_CIRCUMFERENCE - (homeGridCircumference ?? 0)
}"
stroke-dashoffset="0"
shape-rendering="geometricPrecision"
+48 -3
View File
@@ -18,7 +18,10 @@ import {
subscribeFrontendUserData,
} from "../data/frontend";
import { forwardHaptic } from "../data/haptics";
import { serviceCallWillDisconnect } from "../data/service";
import {
getServiceCallEntityIds,
serviceCallWillDisconnect,
} from "../data/service";
import {
DateFormat,
FirstWeekday,
@@ -32,7 +35,12 @@ import { preserveUnchangedRecord } from "../common/util/preserve-unchanged-recor
import { subscribeFloorRegistry } from "../data/ws-floor_registry";
import { subscribePanels } from "../data/ws-panels";
import { translationMetadata } from "../resources/translations-metadata";
import type { Constructor, HomeAssistant, ServiceCallResponse } from "../types";
import type {
Constructor,
HomeAssistant,
ServiceCallRequest,
ServiceCallResponse,
} from "../types";
import {
addBrandsAuth,
clearBrandsTokenRefresh,
@@ -114,7 +122,7 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
);
}
try {
return (await callService(
const response = (await callService(
conn,
domain,
service,
@@ -122,11 +130,24 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
target,
returnResponse
)) as ServiceCallResponse;
this._reportEntityControlToExternalApp(
domain,
service,
serviceData,
target
);
return response;
} catch (err: any) {
if (
err.error?.code === ERR_CONNECTION_LOST &&
serviceCallWillDisconnect(domain, service, serviceData)
) {
this._reportEntityControlToExternalApp(
domain,
service,
serviceData,
target
);
return { context: { id: "" } };
}
if (this.hass?.debugConnection) {
@@ -405,4 +426,28 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
this._updateHass({});
}
}
private _reportEntityControlToExternalApp(
domain: string,
service: string,
serviceData?: ServiceCallRequest["serviceData"],
target?: ServiceCallRequest["target"]
) {
const external = this.hass?.auth.external;
if (!external) {
return;
}
const entityIds = getServiceCallEntityIds(serviceData, target);
if (!entityIds.length) {
return;
}
try {
external.fireMessage({
type: "entity/controlled",
payload: { entity_ids: entityIds, domain, service },
});
} catch (_err) {
// Reporting is best effort and must not fail the service call.
}
}
};
+2 -2
View File
@@ -5866,8 +5866,8 @@
"missing_triggers": "This condition references a trigger that no longer exists. Uncheck it to clear it.",
"duplicate_ids": "Some triggers share the same trigger ID. Avoid reusing a trigger ID to group triggers and select multiple triggers here instead.",
"duplicate_ids_fix": "Fix",
"assign_unique_ids_title": "Assign unique trigger IDs?",
"assign_unique_ids_description": "Each trigger sharing an ID gets a unique one, and conditions using it are updated to match. Templates or actions that use trigger.id directly may need to be updated.",
"assign_unique_ids_title": "Fix duplicate trigger IDs?",
"assign_unique_ids_description": "Triggers that share a used ID get a unique ID, and conditions using it are updated to match. IDs that nothing uses are removed. Templates or actions that use trigger.id directly may need to be updated.",
"description": {
"picker": "Tests if the automation has been triggered by a specific trigger.",
"full": "If triggered by {id}",
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { getServiceCallEntityIds } from "../../src/data/service";
describe("getServiceCallEntityIds", () => {
it("returns an empty list when no entities are targeted", () => {
expect(getServiceCallEntityIds()).toEqual([]);
expect(getServiceCallEntityIds({ brightness: 50 }, {})).toEqual([]);
expect(getServiceCallEntityIds({}, { area_id: "kitchen" })).toEqual([]);
});
it("reads a single entity from target or service data", () => {
expect(getServiceCallEntityIds({}, { entity_id: "light.a" })).toEqual([
"light.a",
]);
expect(getServiceCallEntityIds({ entity_id: "light.a" })).toEqual([
"light.a",
]);
});
it("prefers the target over the legacy service data entity ids", () => {
expect(
getServiceCallEntityIds(
{ entity_id: ["light.a", "light.b"] },
{ entity_id: ["light.b", "light.c"] }
)
).toEqual(["light.b", "light.c"]);
});
it("deduplicates entity ids", () => {
expect(
getServiceCallEntityIds({}, { entity_id: ["light.a", "light.a"] })
).toEqual(["light.a"]);
});
it("splits comma separated ids and lowercases them like Core does", () => {
expect(
getServiceCallEntityIds({}, { entity_id: "Light.A, light.b ,light.a" })
).toEqual(["light.a", "light.b"]);
});
it("ignores wildcard, malformed, and non-string entity ids", () => {
expect(getServiceCallEntityIds({ entity_id: "all" })).toEqual([]);
expect(
getServiceCallEntityIds(
{},
{ entity_id: ["all", "none", "", "light", 5] as unknown as string[] }
)
).toEqual([]);
});
});
@@ -290,6 +290,25 @@ describe("automation trigger IDs", () => {
]);
});
it("strips unreferenced duplicate IDs instead of generating new ones", () => {
const config: AutomationConfig = {
triggers: [
{ trigger: "state", entity_id: "light.kitchen", id: "motion" },
{ trigger: "time", at: "12:00:00", id: "motion" },
],
conditions: [{ condition: "trigger", id: "" }],
actions: [],
};
const updated = makeDuplicateTriggerIdsUnique(config);
expect(updated.triggers).toEqual([
{ trigger: "state", entity_id: "light.kitchen" },
{ trigger: "time", at: "12:00:00" },
]);
expect(updated.conditions).toBe(config.conditions);
});
it("removes generated trigger IDs that no condition or action references", () => {
const generatedA = `${GENERATED_TRIGGER_ID_PREFIX}aB3x`;
const generatedB = `${GENERATED_TRIGGER_ID_PREFIX}yZ7w`;
@@ -0,0 +1,119 @@
/**
* Protects the energy-distribution home ring from overflowing when hourly
* allocation yields more used_solar/used_battery/used_grid than net used_total
* (https://github.com/home-assistant/frontend/issues/54185).
*/
import { assert, describe, it } from "vitest";
import {
ENERGY_DISTRIBUTION_HOME_CIRCLE_CIRCUMFERENCE as CIRCLE,
computeEnergyDistributionHomeCircleArcs,
} from "../../../../../src/panels/lovelace/cards/energy/energy-distribution-home-circle";
const sumDefinedArcs = (
arcs: ReturnType<typeof computeEnergyDistributionHomeCircleArcs>
): number =>
(arcs.solar ?? 0) +
(arcs.battery ?? 0) +
(arcs.lowCarbon ?? 0) +
(arcs.grid ?? 0);
describe("computeEnergyDistributionHomeCircleArcs", () => {
it("sizes arcs from allocated home flows so they fill the circle", () => {
const arcs = computeEnergyDistributionHomeCircleArcs({
usedSolar: 8.5,
usedBattery: 4.6,
usedGrid: 0.12,
hasSolar: true,
hasGrid: true,
});
assert.approximately(arcs.solar!, CIRCLE * (8.5 / 13.22), 1e-6);
assert.approximately(arcs.battery!, CIRCLE * (4.6 / 13.22), 1e-6);
assert.approximately(arcs.grid!, CIRCLE * (0.12 / 13.22), 1e-6);
assert.approximately(sumDefinedArcs(arcs), CIRCLE, 1e-6);
assert.isAtLeast(arcs.grid!, 0);
});
it("does not paint a full grid ring when used_solar exceeds net home energy", () => {
// Screenshot totals from #54185 with solar and export in different hours:
// used_solar 77.1, used_battery 0, used_grid 0, net used_total 13.22.
const arcs = computeEnergyDistributionHomeCircleArcs({
usedSolar: 77.1,
usedBattery: 0,
usedGrid: 0,
hasSolar: true,
hasGrid: true,
});
assert.approximately(arcs.solar!, CIRCLE, 1e-6);
assert.isUndefined(arcs.battery);
assert.equal(arcs.grid, 0);
assert.approximately(sumDefinedArcs(arcs), CIRCLE, 1e-6);
});
it("keeps a zero-consumption ring from producing NaN or negative dashes", () => {
const arcs = computeEnergyDistributionHomeCircleArcs({
usedSolar: 0,
usedBattery: 0,
usedGrid: 0,
hasSolar: true,
hasGrid: true,
});
assert.deepEqual(arcs, {});
});
it("splits grid into low-carbon and high-carbon without exceeding the grid share", () => {
const arcs = computeEnergyDistributionHomeCircleArcs({
usedSolar: 4,
usedBattery: 0,
usedGrid: 6,
hasSolar: true,
hasGrid: true,
highCarbonConsumption: 2,
});
const ringTotal = 10;
assert.approximately(arcs.solar!, CIRCLE * (4 / ringTotal), 1e-6);
assert.approximately(arcs.lowCarbon!, CIRCLE * (4 / ringTotal), 1e-6);
assert.approximately(arcs.grid!, CIRCLE * (2 / ringTotal), 1e-6);
assert.approximately(sumDefinedArcs(arcs), CIRCLE, 1e-6);
assert.isAtLeast(arcs.lowCarbon!, 0);
assert.isAtLeast(arcs.grid!, 0);
});
it("clamps high-carbon consumption to the grid share", () => {
const arcs = computeEnergyDistributionHomeCircleArcs({
usedSolar: 5,
usedBattery: 0,
usedGrid: 5,
hasSolar: true,
hasGrid: true,
highCarbonConsumption: 50,
});
assert.equal(arcs.lowCarbon, 0);
assert.approximately(arcs.grid!, CIRCLE * 0.5, 1e-6);
assert.approximately(sumDefinedArcs(arcs), CIRCLE, 1e-6);
});
it("keeps a defined zero low-carbon arc so a grid-only high-carbon ring still renders", () => {
// The card only mounts the home SVG when solar or lowCarbon is defined.
// Omitting 0 would drop battery/grid arcs in a no-solar 100% fossil grid.
const arcs = computeEnergyDistributionHomeCircleArcs({
usedSolar: 0,
usedBattery: 4,
usedGrid: 6,
hasSolar: false,
hasGrid: true,
highCarbonConsumption: 6,
});
assert.isUndefined(arcs.solar);
assert.equal(arcs.lowCarbon, 0);
assert.approximately(arcs.battery!, CIRCLE * 0.4, 1e-6);
assert.approximately(arcs.grid!, CIRCLE * 0.6, 1e-6);
assert.approximately(sumDefinedArcs(arcs), CIRCLE, 1e-6);
});
});