Compare commits

..
2 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
6 changed files with 356 additions and 131 deletions
@@ -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 {
+1 -8
View File
@@ -476,13 +476,6 @@ export class HAFullCalendar extends LitElement {
const wasShowingToday = this._isShowingToday();
const nextMidnight = new TZDate(new Date(), this._calendarTimeZone());
nextMidnight.setHours(24, 0, 0, 0);
const delay = nextMidnight.getTime() - Date.now();
// Guard against a NaN/negative delay (e.g. Intl longOffset unsupported on
// Chromium < 95) so the midnight refresh can't fire in a tight loop (#54182).
if (!Number.isFinite(delay) || delay <= 0) {
return;
}
this._midnightRefreshTimeout = window.setTimeout(() => {
if (wasShowingToday) {
@@ -492,7 +485,7 @@ export class HAFullCalendar extends LitElement {
}
this._scheduleMidnightRefresh();
}, delay);
}, nextMidnight.getTime() - Date.now());
}
private _clearMidnightRefreshTimeout(): void {
@@ -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"
@@ -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);
});
});